diff --git a/.betterer.results b/.betterer.results index 95894921943..239af992de5 100644 --- a/.betterer.results +++ b/.betterer.results @@ -576,9 +576,13 @@ exports[`better eslint`] = { ], "packages/grafana-sql/src/components/configuration/ConnectionLimits.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"], - [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.", "3"] + [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"] + ], + "packages/grafana-sql/src/components/configuration/MaxLifetimeField.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"] + ], + "packages/grafana-sql/src/components/configuration/MaxOpenConnectionsField.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"] ], "packages/grafana-sql/src/components/configuration/TLSSecretsConfig.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"], @@ -2748,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"], @@ -3494,12 +3493,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"] ], diff --git a/.drone.yml b/.drone.yml index 345aae7d4bd..2ca377f28a9 100644 --- a/.drone.yml +++ b/.drone.yml @@ -2049,264 +2049,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 @@ -2625,6 +2367,6 @@ kind: secret name: gcr_credentials --- kind: signature -hmac: aef043aae7394d3160a7147c8b57599bf1a2f4ba5c596ffb795a0e6a049c73a6 +hmac: bfbdbe4aeedbcdc8778dd67abdfe94a75a20bdb314d1c53418cf2c2ea7a9f0c9 ... diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4cf7c2c931f..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 @@ -408,6 +411,18 @@ /e2e/cloud-plugins-suite/ @grafana/partner-datasources /e2e-playwright/ @grafana/grafana-frontend-platform /e2e-playwright/dashboard-new-layouts @grafana/dashboards-squad +/e2e-playwright/panels-suite/dashlist.spec.ts @grafana/grafana-search-navigate-organise +/e2e-playwright/panels-suite/datagrid-data-change.spec.ts @grafana/dataviz-squad +/e2e-playwright/panels-suite/datagrid-editing-features.spec.ts @grafana/dataviz-squad +/e2e-playwright/panels-suite/frontend-sandbox-panel.spec.ts @grafana/plugins-platform-frontend +/e2e-playwright/panels-suite/geomap-layer-types.spec.ts @grafana/dataviz-squad +/e2e-playwright/panels-suite/geomap-map-controls.spec.ts @grafana/dataviz-squad +/e2e-playwright/panels-suite/geomap-spatial-operations-transform.spec.ts @grafana/dataviz-squad +/e2e-playwright/panels-suite/panelEdit_base.spec.ts @grafana/dashboards-squad +/e2e-playwright/panels-suite/panelEdit_queries.spec.ts @grafana/dashboards-squad +/e2e-playwright/panels-suite/panelEdit_transforms.spec.ts @grafana/datapro +/e2e-playwright/panels-suite/table-kitchenSink.spec.ts @grafana/dataviz-squad +/e2e-playwright/panels-suite/table-sparkline.spec.ts @grafana/dataviz-squad /e2e-playwright/plugin-e2e/ @grafana/oss-big-tent @grafana/partner-datasources /e2e-playwright/plugin-e2e/plugin-e2e-api-tests/ @grafana/plugins-platform-frontend /e2e-playwright/test-plugins/grafana-extensionstest-app/ @grafana/plugins-platform-frontend @@ -586,7 +601,7 @@ /packages/grafana-schema/src/**/nodegraph @grafana/observability-traces-and-profiling @grafana/app-o11y-visualizations /packages/grafana-schema/src/**/parca @grafana/oss-big-tent /packages/grafana-schema/src/**/piechart @grafana/dataviz-squad -/packages/grafana-schema/src/**/publicdashboard @grafana/sharing-squad +/packages/grafana-schema/src/**/publicdashboard @grafana/grafana-operator-experience-squad /packages/grafana-schema/src/**/stat @grafana/dataviz-squad /packages/grafana-schema/src/**/statetimeline @grafana/dataviz-squad /packages/grafana-schema/src/**/statushistory @grafana/dataviz-squad diff --git a/.github/commands.json b/.github/commands.json index 0e78a9504e7..06f91a56c59 100644 --- a/.github/commands.json +++ b/.github/commands.json @@ -584,7 +584,7 @@ "name": "area/public-dashboards", "action": "addToProject", "addToProject": { - "url": "https://github.com/orgs/grafana/projects/482" + "url": "https://github.com/orgs/grafana/projects/467" } }, { @@ -696,7 +696,7 @@ "name": "area/navigation", "action": "addToProject", "addToProject": { - "url": "https://github.com/orgs/grafana/projects/78" + "url": "https://github.com/orgs/grafana/projects/911" } }, { @@ -704,7 +704,7 @@ "name": "area/search", "action": "addToProject", "addToProject": { - "url": "https://github.com/orgs/grafana/projects/78" + "url": "https://github.com/orgs/grafana/projects/911" } }, { @@ -752,7 +752,7 @@ "name": "area/image-rendering", "action": "addToProject", "addToProject": { - "url": "https://github.com/orgs/grafana/projects/482" + "url": "https://github.com/orgs/grafana/projects/467" } }, { @@ -1000,7 +1000,7 @@ "name": "area/frontend/login", "action": "addToProject", "addToProject": { - "url": "https://github.com/orgs/grafana/projects/78" + "url": "https://github.com/orgs/grafana/projects/911" } }, { @@ -1032,7 +1032,7 @@ "name": "area/panel/dashboard-list", "action": "addToProject", "addToProject": { - "url": "https://github.com/orgs/grafana/projects/202" + "url": "https://github.com/orgs/grafana/projects/911" } }, { 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/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 ce7703d2b76..6ceff2b9d45 100644 --- a/.github/workflows/pr-e2e-tests.yml +++ b/.github/workflows/pr-e2e-tests.yml @@ -113,7 +113,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 }} @@ -143,8 +143,6 @@ jobs: path: e2e/various-suite - suite: dashboards-suite path: e2e/dashboards-suite - - suite: panels-suite - path: e2e/panels-suite - suite: various-suite (old arch) path: e2e/old-arch/various-suite flags: --flags="--env dashboardScene=false" diff --git a/.github/workflows/pr-frontend-unit-tests.yml b/.github/workflows/pr-frontend-unit-tests.yml index 25d2450e002..283a186c50d 100644 --- a/.github/workflows/pr-frontend-unit-tests.yml +++ b/.github/workflows/pr-frontend-unit-tests.yml @@ -107,6 +107,24 @@ jobs: - run: yarn install --immutable --check-cache - run: yarn run plugin:test:ci + frontend-packages-unit-tests: + needs: detect-changes + if: needs.detect-changes.outputs.changed == 'true' + runs-on: ubuntu-latest-8-cores + name: "Packages unit tests" + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + cache-dependency-path: 'yarn.lock' + - run: yarn install --immutable --check-cache + - run: yarn run packages:test:ci + + # This is the job that is actually required by rulesets. # We need to require EITHER the OSS or the Enterprise job to pass. # However, if one is skipped, GitHub won't flat-map the shards, @@ -116,6 +134,7 @@ jobs: - frontend-unit-tests - frontend-unit-tests-enterprise - frontend-decoupled-plugin-tests + - frontend-packages-unit-tests # always() is the best function here. # success() || failure() will skip this function if any need is also skipped. # That means conditional test suites will fail the entire requirement check. 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 fda8533dc0e..231bb0395c2 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -133,22 +133,31 @@ jobs: include: - name: linux-amd64 artifacts: targz:grafana:linux/amd64,deb:grafana:linux/amd64,rpm:grafana:linux/amd64,docker:grafana:linux/amd64,docker:grafana:linux/amd64:ubuntu,npm:grafana,storybook + verify: true - name: linux-arm64 artifacts: targz:grafana:linux/arm64,deb:grafana:linux/arm64,rpm:grafana:linux/arm64,docker:grafana:linux/arm64,docker:grafana:linux/arm64:ubuntu + verify: false - name: linux-s390x artifacts: targz:grafana:linux/s390x,deb:grafana:linux/s390x,rpm:grafana:linux/s390x,docker:grafana:linux/s390x,docker:grafana:linux/s390x:ubuntu + verify: true - name: linux-armv7 artifacts: targz:grafana:linux/arm/v7,deb:grafana:linux/arm/v7,docker:grafana:linux/arm/v7,docker:grafana:linux/arm/v7:ubuntu + verify: true - name: linux-armv6 artifacts: targz:grafana:linux/arm/v6,deb:grafana:linux/arm/v6 + verify: true - name: windows-amd64 artifacts: targz:grafana:windows/amd64,zip:grafana:windows/amd64,msi:grafana:windows/amd64 + verify: true - name: windows-arm64 artifacts: targz:grafana:windows/arm64,zip:grafana:windows/arm64 + verify: true - name: darwin-amd64 artifacts: targz:grafana:darwin/amd64 + verify: true - name: darwin-arm64 artifacts: targz:grafana:darwin/arm64 + verify: true steps: - uses: grafana/shared-workflows/actions/dockerhub-login@main - uses: actions/checkout@v4 @@ -167,7 +176,7 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} version: ${{ needs.setup.outputs.version }} output: artifacts-${{ matrix.name }}.txt - verify: true + verify: ${{ matrix.verify }} build-id: ${{ github.run_id }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: 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 9379b0af3e3..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 @@ -27,8 +27,8 @@ require ( cloud.google.com/go/compute/metadata v0.7.0 // indirect dario.cat/mergo v1.0.1 // indirect filippo.io/edwards25519 v1.1.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect @@ -39,6 +39,7 @@ require ( github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f // indirect github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apache/arrow-go/v18 v18.3.0 // indirect github.com/armon/go-metrics v0.4.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect @@ -52,8 +53,10 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 // indirect github.com/aws/smithy-go v1.22.4 // indirect + github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect github.com/benbjohnson/clock v1.3.5 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver v3.5.1+incompatible // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/bluele/gcache v0.0.2 // indirect github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect @@ -68,6 +71,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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/diegoholiveira/jsonlogic/v3 v3.7.4 // indirect github.com/dlmiddlecote/sqlstats v1.0.2 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dolthub/flatbuffers/v23 v23.3.3-dh.2 // indirect @@ -75,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 @@ -109,7 +114,7 @@ require ( github.com/gogo/googleapis v1.4.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.3 // indirect github.com/golang-migrate/migrate/v4 v4.7.0 // indirect github.com/golang/mock v1.7.0-rc.1 // indirect github.com/golang/protobuf v1.5.4 // indirect @@ -121,12 +126,12 @@ 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 github.com/grafana/grafana-aws-sdk v1.0.4 // indirect - github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6 // indirect + github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 // indirect github.com/grafana/grafana/pkg/apiserver v0.0.0-20250627191313-2f1a6ae1712b // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect @@ -148,7 +153,7 @@ require ( github.com/hashicorp/yamux v0.1.1 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/jaegertracing/jaeger-idl v0.5.0 // indirect - github.com/jessevdk/go-flags v1.5.0 // indirect + github.com/jessevdk/go-flags v1.6.1 // indirect github.com/jmespath-community/go-jmespath v1.1.1 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jmoiron/sqlx v1.3.5 // indirect @@ -186,6 +191,8 @@ 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 github.com/oklog/run v1.1.0 // indirect @@ -208,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 @@ -220,6 +228,7 @@ require ( github.com/spf13/pflag v1.0.7 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/tetratelabs/wazero v1.8.2 // indirect + github.com/thomaspoignant/go-feature-flag v1.42.0 // indirect github.com/tjhop/slog-gokit v0.1.3 // indirect github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 // indirect github.com/unknwon/com v1.0.1 // indirect @@ -227,7 +236,7 @@ require ( github.com/urfave/cli v1.22.16 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect - go.mongodb.org/mongo-driver v1.16.1 // indirect + go.mongodb.org/mongo-driver v1.17.3 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.61.0 // indirect @@ -261,7 +270,7 @@ require ( gonum.org/v1/gonum v0.16.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/grpc v1.74.0 // indirect + google.golang.org/grpc v1.74.2 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect @@ -278,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 778e8e0f26b..653ca2c8c71 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -78,10 +78,10 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7 filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= -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.9.0 h1:OVoM452qUFBrX+URdH3VpR299ma4kfom0yB0URYky9g= -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/azcore v1.18.1 h1:Wc1ml6QlJs2BHQ/9Bqu1jiyggbsSjramq2oUmp5WeIo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= @@ -210,6 +210,8 @@ github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 h1:60m4tnanN1c github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0= +github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df/go.mod h1:hiVxq5OP2bUGBRNS3Z/bt/reCLFNbdcST6gISi1fiOM= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -220,6 +222,8 @@ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k= github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/blevesearch/bleve/v2 v2.5.0 h1:HzYqBy/5/M9Ul9ESEmXzN/3Jl7YpmWBdHM/+zzv/3k4= @@ -354,6 +358,8 @@ github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fp github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dhui/dktest v0.3.0/go.mod h1:cyzIUfGsBEbZ6BT7tnXqAShHSXCZhSNmFl70sZ7c1yc= +github.com/diegoholiveira/jsonlogic/v3 v3.7.4 h1:92HSmB9bwM/o0ZvrCpcvTP2EsPXSkKtAniIr2W/dcIM= +github.com/diegoholiveira/jsonlogic/v3 v3.7.4/go.mod h1:OYRb6FSTVmMM+MNQ7ElmMsczyNSepw+OU4Z8emDSi4w= github.com/dlmiddlecote/sqlstats v1.0.2 h1:gSU11YN23D/iY50A2zVYwgXgy072khatTsIW6UPjUtI= github.com/dlmiddlecote/sqlstats v1.0.2/go.mod h1:0CWaIh/Th+z2aI6Q9Jpfg/o21zmGxWhbByHgQSCUQvY= github.com/docker/distribution v2.7.0+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= @@ -512,8 +518,8 @@ github.com/gogo/status v1.1.1 h1:DuHXlSFHNKqTQ+/ACf5Vs6r4X/dH2EgIzR9Vr+H65kg= github.com/gogo/status v1.1.1/go.mod h1:jpG3dM5QPcqu19Hg8lkUhBFBa3TcLs1DG7+2Jqci7oU= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= +github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-migrate/migrate/v4 v4.7.0 h1:gONcHxHApDTKXDyLH/H97gEHmpu1zcnnbAaq2zgrPrs= github.com/golang-migrate/migrate/v4 v4.7.0/go.mod h1:Qvut3N4xKWjoH3sokBccML6WyHSnggXm/DvMMnTsQIc= github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 h1:gtexQ/VGyN+VVFRXSFiguSNcXmS6rkKT+X7FdIrTtfo= @@ -645,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= @@ -657,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.1.6 h1:OfCkitCuomzZKW1WYHrG8MxKwtMhALb7jqoj+487eTg= -github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6/go.mod h1:V7y2BmsWxS3A9Ohebwn4OiSfJJqi//4JQydQ8fHTduo= +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= @@ -776,8 +782,8 @@ github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80s github.com/jackc/pgx v3.2.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= github.com/jaegertracing/jaeger-idl v0.5.0 h1:zFXR5NL3Utu7MhPg8ZorxtCBjHrL3ReM1VoB65FOFGE= github.com/jaegertracing/jaeger-idl v0.5.0/go.mod h1:ON90zFo9eoyXrt9F/KN8YeF3zxcnujaisMweFY/rg5k= -github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= -github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= +github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= +github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= github.com/jmespath-community/go-jmespath v1.1.1 h1:bFikPhsi/FdmlZhVgSCd2jj1e7G/rw+zyQfyg5UF+L4= @@ -938,6 +944,10 @@ 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= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= @@ -1046,8 +1056,10 @@ github.com/prometheus/prometheus v0.303.1/go.mod h1:WEq2ogBPZoLjj9x5K67VEk7ECR0n 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/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= -github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= +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= @@ -1125,6 +1137,10 @@ github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf h1:Z2X3Os7oRzpdJ7 github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0= github.com/tetratelabs/wazero v1.8.2 h1:yIgLR/b2bN31bjxwXHD8a3d+BogigR952csSDdLYEv4= github.com/tetratelabs/wazero v1.8.2/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs= +github.com/thejerf/slogassert v0.3.4 h1:VoTsXixRbXMrRSSxDjYTiEDCM4VWbsYPW5rB/hX24kM= +github.com/thejerf/slogassert v0.3.4/go.mod h1:0zn9ISLVKo1aPMTqcGfG1o6dWwt+Rk574GlUxHD4rs8= +github.com/thomaspoignant/go-feature-flag v1.42.0 h1:C7embmOTzaLyRki+OoU2RvtVjJE9IrvgBA2C1mRN1lc= +github.com/thomaspoignant/go-feature-flag v1.42.0/go.mod h1:y0QiWH7chHWhGATb/+XqwAwErORmPSH2MUsQlCmmWlM= github.com/tidwall/pretty v0.0.0-20180105212114-65a9db5fad51/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tjhop/slog-gokit v0.1.3 h1:6SdexP3UIeg93KLFeiM1Wp1caRwdTLgsD/THxBUy1+o= github.com/tjhop/slog-gokit v0.1.3/go.mod h1:Bbu5v2748qpAWH7k6gse/kw3076IJf6owJmh7yArmJs= @@ -1180,8 +1196,8 @@ go.etcd.io/etcd/client/v3 v3.5.4/go.mod h1:ZaRkVgBZC+L+dLCjTcF1hRXpgZXQPOvnA/Ak/ go.etcd.io/etcd/client/v3 v3.5.21 h1:T6b1Ow6fNjOLOtM0xSoKNQt1ASPCLWrF9XMHcH9pEyY= go.etcd.io/etcd/client/v3 v3.5.21/go.mod h1:mFYy67IOqmbRf/kRUvsHixzo3iG+1OF2W2+jVIQRAnU= go.mongodb.org/mongo-driver v1.1.0/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= -go.mongodb.org/mongo-driver v1.16.1 h1:rIVLL3q0IHM39dvE+z2ulZLp9ENZKThVfuvN/IiN4l8= -go.mongodb.org/mongo-driver v1.16.1/go.mod h1:oB6AhJQvFQL4LEHyXi6aJzQJtBiTQHiAd83l0GdFaiw= +go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= +go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= @@ -1809,8 +1825,8 @@ google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ5 google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.74.0 h1:sxRSkyLxlceWQiqDofxDot3d4u7DyoHPc7SBXMj8gGY= -google.golang.org/grpc v1.74.0/go.mod h1:NZUaK8dAMUfzhK6uxZ+9511LtOrk73UGWOFoNvz7z+s= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -1898,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 ac5f43ee6bd..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 @@ -93,7 +93,7 @@ require ( google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/grpc v1.74.0 // indirect + google.golang.org/grpc v1.74.2 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/apps/alerting/notifications/go.sum b/apps/alerting/notifications/go.sum index 8f46c0338ee..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= @@ -333,8 +333,8 @@ google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.74.0 h1:sxRSkyLxlceWQiqDofxDot3d4u7DyoHPc7SBXMj8gGY= -google.golang.org/grpc v1.74.0/go.mod h1:NZUaK8dAMUfzhK6uxZ+9511LtOrk73UGWOFoNvz7z+s= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/receiver_schema_gen.go b/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/receiver_schema_gen.go index acda5c7d1c9..314d36b602a 100644 --- a/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/receiver_schema_gen.go +++ b/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/receiver_schema_gen.go @@ -13,7 +13,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( schemaReceiver = resource.NewSimpleSchema("notifications.alerting.grafana.app", "v0alpha1", &Receiver{}, &ReceiverList{}, resource.WithKind("Receiver"), - resource.WithPlural("receivers"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{resource.SelectableField{ + resource.WithPlural("receivers"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{{ FieldSelector: "spec.title", FieldValueFunc: func(o resource.Object) (string, error) { cast, ok := o.(*Receiver) diff --git a/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/templategroup_schema_gen.go b/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/templategroup_schema_gen.go index 339d0bd6d04..509bfc4aeca 100644 --- a/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/templategroup_schema_gen.go +++ b/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/templategroup_schema_gen.go @@ -13,7 +13,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( schemaTemplateGroup = resource.NewSimpleSchema("notifications.alerting.grafana.app", "v0alpha1", &TemplateGroup{}, &TemplateGroupList{}, resource.WithKind("TemplateGroup"), - resource.WithPlural("templategroups"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{resource.SelectableField{ + resource.WithPlural("templategroups"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{{ FieldSelector: "spec.title", FieldValueFunc: func(o resource.Object) (string, error) { cast, ok := o.(*TemplateGroup) diff --git a/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/timeinterval_schema_gen.go b/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/timeinterval_schema_gen.go index a6c809e0d3b..8c5459f0059 100644 --- a/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/timeinterval_schema_gen.go +++ b/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/timeinterval_schema_gen.go @@ -13,7 +13,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( schemaTimeInterval = resource.NewSimpleSchema("notifications.alerting.grafana.app", "v0alpha1", &TimeInterval{}, &TimeIntervalList{}, resource.WithKind("TimeInterval"), - resource.WithPlural("timeintervals"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{resource.SelectableField{ + resource.WithPlural("timeintervals"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{{ FieldSelector: "spec.name", FieldValueFunc: func(o resource.Object) (string, error) { cast, ok := o.(*TimeInterval) diff --git a/apps/alerting/notifications/pkg/apis/alerting_manifest.go b/apps/alerting/notifications/pkg/apis/alerting_manifest.go index 40f05004004..bde16bfb81d 100644 --- a/apps/alerting/notifications/pkg/apis/alerting_manifest.go +++ b/apps/alerting/notifications/pkg/apis/alerting_manifest.go @@ -15,8 +15,6 @@ import ( v0alpha1 "github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/alerting/v0alpha1" ) -var () - var appManifestData = app.ManifestData{ AppName: "alerting", Group: "notifications.alerting.grafana.app", diff --git a/apps/dashboard/Makefile b/apps/dashboard/Makefile index c3905410aed..fa75513d964 100644 --- a/apps/dashboard/Makefile +++ b/apps/dashboard/Makefile @@ -44,9 +44,9 @@ post-generate-cleanup: ## Clean up the generated code @echo "" >> ./pkg/apis/dashboard/v2alpha1/dashboard_spec.cue @cat ./kinds/v2alpha1/dashboard_spec.cue >> ./pkg/apis/dashboard/v2alpha1/dashboard_spec.cue - # Copy dashboard/v2alpha2 spec so we can use it for schema validation - @echo "// This file is managed by grafana-app-sdk - DO NOT EDIT MANUALLY" > ./pkg/apis/dashboard/v2alpha2/dashboard_spec.cue - @echo "// Source: apps/dashboard/kinds/v2alpha2/dashboard_spec.cue" >> ./pkg/apis/dashboard/v2alpha2/dashboard_spec.cue - @echo "// To sync changes, run: make generate in apps/dashboard" >> ./pkg/apis/dashboard/v2alpha2/dashboard_spec.cue - @echo "" >> ./pkg/apis/dashboard/v2alpha2/dashboard_spec.cue - @cat ./kinds/v2alpha2/dashboard_spec.cue >> ./pkg/apis/dashboard/v2alpha2/dashboard_spec.cue + # Copy dashboard/v2beta1 spec so we can use it for schema validation + @echo "// This file is managed by grafana-app-sdk - DO NOT EDIT MANUALLY" > ./pkg/apis/dashboard/v2beta1/dashboard_spec.cue + @echo "// Source: apps/dashboard/kinds/v2beta1/dashboard_spec.cue" >> ./pkg/apis/dashboard/v2beta1/dashboard_spec.cue + @echo "// To sync changes, run: make generate in apps/dashboard" >> ./pkg/apis/dashboard/v2beta1/dashboard_spec.cue + @echo "" >> ./pkg/apis/dashboard/v2beta1/dashboard_spec.cue + @cat ./kinds/v2beta1/dashboard_spec.cue >> ./pkg/apis/dashboard/v2beta1/dashboard_spec.cue diff --git a/apps/dashboard/go.mod b/apps/dashboard/go.mod index 423dfbc0986..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 @@ -122,7 +122,7 @@ require ( golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/grpc v1.74.0 // indirect + google.golang.org/grpc v1.74.2 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/apps/dashboard/go.sum b/apps/dashboard/go.sum index 95d9b4b63a0..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= @@ -370,8 +370,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1: google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.74.0 h1:sxRSkyLxlceWQiqDofxDot3d4u7DyoHPc7SBXMj8gGY= -google.golang.org/grpc v1.74.0/go.mod h1:NZUaK8dAMUfzhK6uxZ+9511LtOrk73UGWOFoNvz7z+s= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/apps/dashboard/kinds/dashboard.cue b/apps/dashboard/kinds/dashboard.cue index 7c68295d394..490f6173700 100644 --- a/apps/dashboard/kinds/dashboard.cue +++ b/apps/dashboard/kinds/dashboard.cue @@ -4,7 +4,7 @@ import ( v0 "github.com/grafana/grafana/sdkkinds/dashboard/v0alpha1" v1 "github.com/grafana/grafana/sdkkinds/dashboard/v1beta1" v2alpha1 "github.com/grafana/grafana/sdkkinds/dashboard/v2alpha1" - v2alpha2 "github.com/grafana/grafana/sdkkinds/dashboard/v2alpha2" + v2beta1 "github.com/grafana/grafana/sdkkinds/dashboard/v2beta1" ) // Status is the shared status of all dashboard versions. @@ -67,9 +67,9 @@ dashboard: { status: DashboardStatus } } - "v2alpha2": { + "v2beta1": { schema: { - spec: v2alpha2.DashboardSpec + spec: v2beta1.DashboardSpec status: DashboardStatus } } diff --git a/apps/dashboard/kinds/v2alpha2/dashboard_spec.cue b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue similarity index 97% rename from apps/dashboard/kinds/v2alpha2/dashboard_spec.cue rename to apps/dashboard/kinds/v2beta1/dashboard_spec.cue index 4c1202caea7..cf71e6b2dc4 100644 --- a/apps/dashboard/kinds/v2alpha2/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue @@ -1,4 +1,4 @@ -package v2alpha2 +package v2beta1 DashboardSpec: { annotations: [...AnnotationQueryKind] | *[] @@ -111,14 +111,6 @@ DashboardLink: { keepTime: bool | *false } -DataSourceRef: { - // The plugin type-id - type?: string - - // Specific datasource instance - uid?: string -} - // A topic is attached to DataFrame metadata in query results. // This specifies where the data should be used. DataTopic: "series" | "annotations" | "alertStates" @cog(kind="enum",memberNames="Series|Annotations|AlertStates") @@ -373,27 +365,27 @@ Kind: { // --- Kinds --- VizConfigSpec: { - pluginVersion: string options: [string]: _ fieldConfig: FieldConfigSource } VizConfigKind: { - // The kind of a VizConfigKind is the plugin ID - kind: string + kind: "VizConfig" + // The group is the plugin ID + group: string + version: string spec: VizConfigSpec } AnnotationQuerySpec: { - datasource?: DataSourceRef - query?: DataQueryKind + query: DataQueryKind enable: bool hide: bool iconColor: string name: string builtIn?: bool | *false filter?: AnnotationPanelFilter - legacyOptions?: [string]: _ //Catch-all field for datasource-specific properties + legacyOptions?: [string]: _ // Catch-all field for datasource-specific properties. Should not be available in as code tooling. } AnnotationQueryKind: { @@ -412,15 +404,19 @@ QueryOptionsSpec: { } DataQueryKind: { - // The kind of a DataQueryKind is the datasource type - kind: string + kind: "DataQuery" + group: string + version: string | *"v0" + // New type for datasource reference + // Not creating a new type until we figure out how to handle DS refs for group by, adhoc, and every place that uses DataSourceRef in TS. + datasource?: { + name?: string + } spec: [string]: _ } PanelQuerySpec: { query: DataQueryKind - datasource?: DataSourceRef - refId: string hidden: bool } @@ -723,7 +719,6 @@ QueryVariableSpec: { refresh: VariableRefresh skipUrlSync: bool | *false description?: string - datasource?: DataSourceRef query: DataQueryKind regex: string | *"" sort: VariableSort @@ -861,7 +856,6 @@ CustomVariableKind: { // GroupBy variable specification GroupByVariableSpec: { name: string | *"" - datasource?: DataSourceRef defaultValue?: VariableOption current: VariableOption | *{ text: "" @@ -878,13 +872,16 @@ GroupByVariableSpec: { // Group variable kind GroupByVariableKind: { kind: "GroupByVariable" + group: string + datasource?: { + name?: string + } spec: GroupByVariableSpec } // Adhoc variable specification AdhocVariableSpec: { name: string | *"" - datasource?: DataSourceRef baseFilters: [...AdHocFilterWithLabels] | *[] filters: [...AdHocFilterWithLabels] | *[] defaultKeys: [...MetricFindValue] | *[] @@ -920,6 +917,10 @@ AdHocFilterWithLabels: { // Adhoc variable kind AdhocVariableKind: { kind: "AdhocVariable" + group: string + datasource?: { + name?: string + } spec: AdhocVariableSpec } 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/apis/dashboard/v2alpha2/zz_generated.conversion.go b/apps/dashboard/pkg/apis/dashboard/v2alpha2/zz_generated.conversion.go deleted file mode 100644 index 3159ff2e0f7..00000000000 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha2/zz_generated.conversion.go +++ /dev/null @@ -1,175 +0,0 @@ -//go:build !ignore_autogenerated -// +build !ignore_autogenerated - -// SPDX-License-Identifier: AGPL-3.0-only - -// Code generated by conversion-gen. DO NOT EDIT. - -package v2alpha2 - -import ( - url "net/url" - unsafe "unsafe" - - dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard" - conversion "k8s.io/apimachinery/pkg/conversion" - runtime "k8s.io/apimachinery/pkg/runtime" -) - -func init() { - localSchemeBuilder.Register(RegisterConversions) -} - -// RegisterConversions adds conversion functions to the given scheme. -// Public to allow building arbitrary schemes. -func RegisterConversions(s *runtime.Scheme) error { - if err := s.AddGeneratedConversionFunc((*AnnotationActions)(nil), (*dashboard.AnnotationActions)(nil), func(a, b interface{}, scope conversion.Scope) error { - return Convert_v2alpha2_AnnotationActions_To_dashboard_AnnotationActions(a.(*AnnotationActions), b.(*dashboard.AnnotationActions), scope) - }); err != nil { - return err - } - if err := s.AddGeneratedConversionFunc((*dashboard.AnnotationActions)(nil), (*AnnotationActions)(nil), func(a, b interface{}, scope conversion.Scope) error { - return Convert_dashboard_AnnotationActions_To_v2alpha2_AnnotationActions(a.(*dashboard.AnnotationActions), b.(*AnnotationActions), scope) - }); err != nil { - return err - } - if err := s.AddGeneratedConversionFunc((*AnnotationPermission)(nil), (*dashboard.AnnotationPermission)(nil), func(a, b interface{}, scope conversion.Scope) error { - return Convert_v2alpha2_AnnotationPermission_To_dashboard_AnnotationPermission(a.(*AnnotationPermission), b.(*dashboard.AnnotationPermission), scope) - }); err != nil { - return err - } - if err := s.AddGeneratedConversionFunc((*dashboard.AnnotationPermission)(nil), (*AnnotationPermission)(nil), func(a, b interface{}, scope conversion.Scope) error { - return Convert_dashboard_AnnotationPermission_To_v2alpha2_AnnotationPermission(a.(*dashboard.AnnotationPermission), b.(*AnnotationPermission), scope) - }); err != nil { - return err - } - if err := s.AddGeneratedConversionFunc((*DashboardAccess)(nil), (*dashboard.DashboardAccess)(nil), func(a, b interface{}, scope conversion.Scope) error { - return Convert_v2alpha2_DashboardAccess_To_dashboard_DashboardAccess(a.(*DashboardAccess), b.(*dashboard.DashboardAccess), scope) - }); err != nil { - return err - } - if err := s.AddGeneratedConversionFunc((*dashboard.DashboardAccess)(nil), (*DashboardAccess)(nil), func(a, b interface{}, scope conversion.Scope) error { - return Convert_dashboard_DashboardAccess_To_v2alpha2_DashboardAccess(a.(*dashboard.DashboardAccess), b.(*DashboardAccess), scope) - }); err != nil { - return err - } - if err := s.AddGeneratedConversionFunc((*url.Values)(nil), (*VersionsQueryOptions)(nil), func(a, b interface{}, scope conversion.Scope) error { - return Convert_url_Values_To_v2alpha2_VersionsQueryOptions(a.(*url.Values), b.(*VersionsQueryOptions), scope) - }); err != nil { - return err - } - return nil -} - -func autoConvert_v2alpha2_AnnotationActions_To_dashboard_AnnotationActions(in *AnnotationActions, out *dashboard.AnnotationActions, s conversion.Scope) error { - out.CanAdd = in.CanAdd - out.CanEdit = in.CanEdit - out.CanDelete = in.CanDelete - return nil -} - -// Convert_v2alpha2_AnnotationActions_To_dashboard_AnnotationActions is an autogenerated conversion function. -func Convert_v2alpha2_AnnotationActions_To_dashboard_AnnotationActions(in *AnnotationActions, out *dashboard.AnnotationActions, s conversion.Scope) error { - return autoConvert_v2alpha2_AnnotationActions_To_dashboard_AnnotationActions(in, out, s) -} - -func autoConvert_dashboard_AnnotationActions_To_v2alpha2_AnnotationActions(in *dashboard.AnnotationActions, out *AnnotationActions, s conversion.Scope) error { - out.CanAdd = in.CanAdd - out.CanEdit = in.CanEdit - out.CanDelete = in.CanDelete - return nil -} - -// Convert_dashboard_AnnotationActions_To_v2alpha2_AnnotationActions is an autogenerated conversion function. -func Convert_dashboard_AnnotationActions_To_v2alpha2_AnnotationActions(in *dashboard.AnnotationActions, out *AnnotationActions, s conversion.Scope) error { - return autoConvert_dashboard_AnnotationActions_To_v2alpha2_AnnotationActions(in, out, s) -} - -func autoConvert_v2alpha2_AnnotationPermission_To_dashboard_AnnotationPermission(in *AnnotationPermission, out *dashboard.AnnotationPermission, s conversion.Scope) error { - if err := Convert_v2alpha2_AnnotationActions_To_dashboard_AnnotationActions(&in.Dashboard, &out.Dashboard, s); err != nil { - return err - } - if err := Convert_v2alpha2_AnnotationActions_To_dashboard_AnnotationActions(&in.Organization, &out.Organization, s); err != nil { - return err - } - return nil -} - -// Convert_v2alpha2_AnnotationPermission_To_dashboard_AnnotationPermission is an autogenerated conversion function. -func Convert_v2alpha2_AnnotationPermission_To_dashboard_AnnotationPermission(in *AnnotationPermission, out *dashboard.AnnotationPermission, s conversion.Scope) error { - return autoConvert_v2alpha2_AnnotationPermission_To_dashboard_AnnotationPermission(in, out, s) -} - -func autoConvert_dashboard_AnnotationPermission_To_v2alpha2_AnnotationPermission(in *dashboard.AnnotationPermission, out *AnnotationPermission, s conversion.Scope) error { - if err := Convert_dashboard_AnnotationActions_To_v2alpha2_AnnotationActions(&in.Dashboard, &out.Dashboard, s); err != nil { - return err - } - if err := Convert_dashboard_AnnotationActions_To_v2alpha2_AnnotationActions(&in.Organization, &out.Organization, s); err != nil { - return err - } - return nil -} - -// Convert_dashboard_AnnotationPermission_To_v2alpha2_AnnotationPermission is an autogenerated conversion function. -func Convert_dashboard_AnnotationPermission_To_v2alpha2_AnnotationPermission(in *dashboard.AnnotationPermission, out *AnnotationPermission, s conversion.Scope) error { - return autoConvert_dashboard_AnnotationPermission_To_v2alpha2_AnnotationPermission(in, out, s) -} - -func autoConvert_v2alpha2_DashboardAccess_To_dashboard_DashboardAccess(in *DashboardAccess, out *dashboard.DashboardAccess, s conversion.Scope) error { - out.Slug = in.Slug - out.Url = in.Url - out.CanSave = in.CanSave - out.CanEdit = in.CanEdit - out.CanAdmin = in.CanAdmin - out.CanStar = in.CanStar - out.CanDelete = in.CanDelete - out.AnnotationsPermissions = (*dashboard.AnnotationPermission)(unsafe.Pointer(in.AnnotationsPermissions)) - return nil -} - -// Convert_v2alpha2_DashboardAccess_To_dashboard_DashboardAccess is an autogenerated conversion function. -func Convert_v2alpha2_DashboardAccess_To_dashboard_DashboardAccess(in *DashboardAccess, out *dashboard.DashboardAccess, s conversion.Scope) error { - return autoConvert_v2alpha2_DashboardAccess_To_dashboard_DashboardAccess(in, out, s) -} - -func autoConvert_dashboard_DashboardAccess_To_v2alpha2_DashboardAccess(in *dashboard.DashboardAccess, out *DashboardAccess, s conversion.Scope) error { - out.Slug = in.Slug - out.Url = in.Url - out.CanSave = in.CanSave - out.CanEdit = in.CanEdit - out.CanAdmin = in.CanAdmin - out.CanStar = in.CanStar - out.CanDelete = in.CanDelete - out.AnnotationsPermissions = (*AnnotationPermission)(unsafe.Pointer(in.AnnotationsPermissions)) - return nil -} - -// Convert_dashboard_DashboardAccess_To_v2alpha2_DashboardAccess is an autogenerated conversion function. -func Convert_dashboard_DashboardAccess_To_v2alpha2_DashboardAccess(in *dashboard.DashboardAccess, out *DashboardAccess, s conversion.Scope) error { - return autoConvert_dashboard_DashboardAccess_To_v2alpha2_DashboardAccess(in, out, s) -} - -func autoConvert_url_Values_To_v2alpha2_VersionsQueryOptions(in *url.Values, out *VersionsQueryOptions, s conversion.Scope) error { - // WARNING: Field TypeMeta does not have json tag, skipping. - - if values, ok := map[string][]string(*in)["path"]; ok && len(values) > 0 { - if err := runtime.Convert_Slice_string_To_string(&values, &out.Path, s); err != nil { - return err - } - } else { - out.Path = "" - } - if values, ok := map[string][]string(*in)["version"]; ok && len(values) > 0 { - if err := runtime.Convert_Slice_string_To_int64(&values, &out.Version, s); err != nil { - return err - } - } else { - out.Version = 0 - } - return nil -} - -// Convert_url_Values_To_v2alpha2_VersionsQueryOptions is an autogenerated conversion function. -func Convert_url_Values_To_v2alpha2_VersionsQueryOptions(in *url.Values, out *VersionsQueryOptions, s conversion.Scope) error { - return autoConvert_url_Values_To_v2alpha2_VersionsQueryOptions(in, out, s) -} diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha2/zz_generated.openapi_violation_exceptions.list b/apps/dashboard/pkg/apis/dashboard/v2alpha2/zz_generated.openapi_violation_exceptions.list deleted file mode 100644 index 1db67fbe438..00000000000 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha2/zz_generated.openapi_violation_exceptions.list +++ /dev/null @@ -1,67 +0,0 @@ -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardAdHocFilterWithLabels,ValueLabels -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardAdHocFilterWithLabels,Values -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardAdhocVariableSpec,BaseFilters -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardAdhocVariableSpec,DefaultKeys -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardAdhocVariableSpec,Filters -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardAnnotationPanelFilter,Ids -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardAutoGridLayoutSpec,Items -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardConditionalRenderingGroupSpec,Items -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardCustomVariableSpec,Options -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardDashboardLink,Tags -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardDatasourceVariableSpec,Options -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardFieldConfig,Links -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardFieldConfig,Mappings -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardFieldConfigSource,Overrides -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardGridLayoutSpec,Items -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardGroupByVariableSpec,Options -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardIntervalVariableSpec,Options -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardMetadata,Finalizers -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardPanelSpec,Links -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardQueryGroupSpec,Queries -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardQueryGroupSpec,Transformations -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardQueryVariableSpec,Options -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardQueryVariableSpec,StaticOptions -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardRowsLayoutSpec,Rows -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardSpec,Annotations -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardSpec,Links -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardSpec,Tags -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardSpec,Variables -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardStringOrArrayOfString,ArrayOfString -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardTabsLayoutSpec,Tabs -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardThresholdsConfig,Steps -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardTimeSettingsSpec,AutoRefreshIntervals -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardTimeSettingsSpec,QuickRanges -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardV2alpha2FieldConfigSourceOverrides,Properties -API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,LibraryPanelStatus,Warnings -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind,ConditionalRenderingDataKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind,ConditionalRenderingTimeRangeSizeKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind,ConditionalRenderingVariableKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind,AutoGridLayoutKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind,GridLayoutKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind,RowsLayoutKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind,TabsLayoutKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind,AutoGridLayoutKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind,GridLayoutKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind,RowsLayoutKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind,TabsLayoutKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardIntervalVariableSpec,AutoCount -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardIntervalVariableSpec,AutoMin -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardPanelKindOrLibraryPanelKind,LibraryPanelKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardPanelKindOrLibraryPanelKind,PanelKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,AdhocVariableKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,ConstantVariableKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,CustomVariableKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,DatasourceVariableKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,GroupByVariableKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,IntervalVariableKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,QueryVariableKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,TextVariableKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardStringOrArrayOfString,ArrayOfString -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardStringOrArrayOfString,String -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardStringOrFloat64,Float64 -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardStringOrFloat64,String -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap,RangeMap -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap,RegexMap -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap,SpecialValueMap -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap,ValueMap -API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2,DashboardList,ListMeta diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha2/constants.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/constants.go similarity index 91% rename from apps/dashboard/pkg/apis/dashboard/v2alpha2/constants.go rename to apps/dashboard/pkg/apis/dashboard/v2beta1/constants.go index 3942a399896..d69cd10d958 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha2/constants.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/constants.go @@ -1,4 +1,4 @@ -package v2alpha2 +package v2beta1 import "k8s.io/apimachinery/pkg/runtime/schema" @@ -6,7 +6,7 @@ const ( // APIGroup is the API group used by all kinds in this package APIGroup = "dashboard.grafana.app" // APIVersion is the API version used by all kinds in this package - APIVersion = "v2alpha2" + APIVersion = "v2beta1" ) var ( diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha2/dashboard_codec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_codec_gen.go similarity index 97% rename from apps/dashboard/pkg/apis/dashboard/v2alpha2/dashboard_codec_gen.go rename to apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_codec_gen.go index 9c679dcafd7..eccc24b91f7 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha2/dashboard_codec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_codec_gen.go @@ -2,7 +2,7 @@ // Code generated by grafana-app-sdk. DO NOT EDIT. // -package v2alpha2 +package v2beta1 import ( "encoding/json" diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha2/dashboard_metadata_gen.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_metadata_gen.go similarity index 98% rename from apps/dashboard/pkg/apis/dashboard/v2alpha2/dashboard_metadata_gen.go rename to apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_metadata_gen.go index 62e187af661..d260605bd51 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha2/dashboard_metadata_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_metadata_gen.go @@ -1,6 +1,6 @@ // Code generated - EDITING IS FUTILE. DO NOT EDIT. -package v2alpha2 +package v2beta1 import ( time "time" diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha2/dashboard_object_gen.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_object_gen.go similarity index 99% rename from apps/dashboard/pkg/apis/dashboard/v2alpha2/dashboard_object_gen.go rename to apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_object_gen.go index 641ba8ce808..5076b7d9b0c 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha2/dashboard_object_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_object_gen.go @@ -2,7 +2,7 @@ // Code generated by grafana-app-sdk. DO NOT EDIT. // -package v2alpha2 +package v2beta1 import ( "fmt" diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha2/dashboard_schema_gen.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_schema_gen.go similarity index 89% rename from apps/dashboard/pkg/apis/dashboard/v2alpha2/dashboard_schema_gen.go rename to apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_schema_gen.go index da9ae612d71..30c2237ca31 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha2/dashboard_schema_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_schema_gen.go @@ -2,7 +2,7 @@ // Code generated by grafana-app-sdk. DO NOT EDIT. // -package v2alpha2 +package v2beta1 import ( "github.com/grafana/grafana-app-sdk/resource" @@ -10,7 +10,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( - schemaDashboard = resource.NewSimpleSchema("dashboard.grafana.app", "v2alpha2", &Dashboard{}, &DashboardList{}, resource.WithKind("Dashboard"), + schemaDashboard = resource.NewSimpleSchema("dashboard.grafana.app", "v2beta1", &Dashboard{}, &DashboardList{}, resource.WithKind("Dashboard"), resource.WithPlural("dashboards"), resource.WithScope(resource.NamespacedScope)) kindDashboard = resource.Kind{ Schema: schemaDashboard, diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha2/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue similarity index 97% rename from apps/dashboard/pkg/apis/dashboard/v2alpha2/dashboard_spec.cue rename to apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue index 4fdce729b2c..a35eaa482f7 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha2/dashboard_spec.cue +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue @@ -1,8 +1,8 @@ // This file is managed by grafana-app-sdk - DO NOT EDIT MANUALLY -// Source: apps/dashboard/kinds/v2alpha2/dashboard_spec.cue +// Source: apps/dashboard/kinds/v2beta1/dashboard_spec.cue // To sync changes, run: make generate in apps/dashboard -package v2alpha2 +package v2beta1 DashboardSpec: { annotations: [...AnnotationQueryKind] | *[] @@ -115,14 +115,6 @@ DashboardLink: { keepTime: bool | *false } -DataSourceRef: { - // The plugin type-id - type?: string - - // Specific datasource instance - uid?: string -} - // A topic is attached to DataFrame metadata in query results. // This specifies where the data should be used. DataTopic: "series" | "annotations" | "alertStates" @cog(kind="enum",memberNames="Series|Annotations|AlertStates") @@ -377,27 +369,27 @@ Kind: { // --- Kinds --- VizConfigSpec: { - pluginVersion: string options: [string]: _ fieldConfig: FieldConfigSource } VizConfigKind: { - // The kind of a VizConfigKind is the plugin ID - kind: string + kind: "VizConfig" + // The group is the plugin ID + group: string + version: string spec: VizConfigSpec } AnnotationQuerySpec: { - datasource?: DataSourceRef - query?: DataQueryKind + query: DataQueryKind enable: bool hide: bool iconColor: string name: string builtIn?: bool | *false filter?: AnnotationPanelFilter - legacyOptions?: [string]: _ //Catch-all field for datasource-specific properties + legacyOptions?: [string]: _ // Catch-all field for datasource-specific properties. Should not be available in as code tooling. } AnnotationQueryKind: { @@ -416,15 +408,19 @@ QueryOptionsSpec: { } DataQueryKind: { - // The kind of a DataQueryKind is the datasource type - kind: string + kind: "DataQuery" + group: string + version: string | *"v0" + // New type for datasource reference + // Not creating a new type until we figure out how to handle DS refs for group by, adhoc, and every place that uses DataSourceRef in TS. + datasource?: { + name?: string + } spec: [string]: _ } PanelQuerySpec: { query: DataQueryKind - datasource?: DataSourceRef - refId: string hidden: bool } @@ -727,7 +723,6 @@ QueryVariableSpec: { refresh: VariableRefresh skipUrlSync: bool | *false description?: string - datasource?: DataSourceRef query: DataQueryKind regex: string | *"" sort: VariableSort @@ -865,7 +860,6 @@ CustomVariableKind: { // GroupBy variable specification GroupByVariableSpec: { name: string | *"" - datasource?: DataSourceRef defaultValue?: VariableOption current: VariableOption | *{ text: "" @@ -882,13 +876,16 @@ GroupByVariableSpec: { // Group variable kind GroupByVariableKind: { kind: "GroupByVariable" + group: string + datasource?: { + name?: string + } spec: GroupByVariableSpec } // Adhoc variable specification AdhocVariableSpec: { name: string | *"" - datasource?: DataSourceRef baseFilters: [...AdHocFilterWithLabels] | *[] filters: [...AdHocFilterWithLabels] | *[] defaultKeys: [...MetricFindValue] | *[] @@ -924,6 +921,10 @@ AdHocFilterWithLabels: { // Adhoc variable kind AdhocVariableKind: { kind: "AdhocVariable" + group: string + datasource?: { + name?: string + } spec: AdhocVariableSpec } diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha2/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go similarity index 95% rename from apps/dashboard/pkg/apis/dashboard/v2alpha2/dashboard_spec_gen.go rename to apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go index 32b90053977..d66f582e42a 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha2/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go @@ -1,6 +1,6 @@ // Code generated - EDITING IS FUTILE. DO NOT EDIT. -package v2alpha2 +package v2beta1 import ( json "encoding/json" @@ -23,49 +23,42 @@ func NewDashboardAnnotationQueryKind() *DashboardAnnotationQueryKind { // +k8s:openapi-gen=true type DashboardAnnotationQuerySpec struct { - Datasource *DashboardDataSourceRef `json:"datasource,omitempty"` - Query *DashboardDataQueryKind `json:"query,omitempty"` - Enable bool `json:"enable"` - Hide bool `json:"hide"` - IconColor string `json:"iconColor"` - Name string `json:"name"` - BuiltIn *bool `json:"builtIn,omitempty"` - Filter *DashboardAnnotationPanelFilter `json:"filter,omitempty"` - // Catch-all field for datasource-specific properties + Query DashboardDataQueryKind `json:"query"` + Enable bool `json:"enable"` + Hide bool `json:"hide"` + IconColor string `json:"iconColor"` + Name string `json:"name"` + BuiltIn *bool `json:"builtIn,omitempty"` + Filter *DashboardAnnotationPanelFilter `json:"filter,omitempty"` + // Catch-all field for datasource-specific properties. Should not be available in as code tooling. LegacyOptions map[string]interface{} `json:"legacyOptions,omitempty"` } // NewDashboardAnnotationQuerySpec creates a new DashboardAnnotationQuerySpec object. func NewDashboardAnnotationQuerySpec() *DashboardAnnotationQuerySpec { return &DashboardAnnotationQuerySpec{ + Query: *NewDashboardDataQueryKind(), BuiltIn: (func(input bool) *bool { return &input })(false), } } -// +k8s:openapi-gen=true -type DashboardDataSourceRef struct { - // The plugin type-id - Type *string `json:"type,omitempty"` - // Specific datasource instance - Uid *string `json:"uid,omitempty"` -} - -// NewDashboardDataSourceRef creates a new DashboardDataSourceRef object. -func NewDashboardDataSourceRef() *DashboardDataSourceRef { - return &DashboardDataSourceRef{} -} - // +k8s:openapi-gen=true type DashboardDataQueryKind struct { - // The kind of a DataQueryKind is the datasource type - Kind string `json:"kind"` - Spec map[string]interface{} `json:"spec"` + Kind string `json:"kind"` + Group string `json:"group"` + Version string `json:"version"` + // New type for datasource reference + // Not creating a new type until we figure out how to handle DS refs for group by, adhoc, and every place that uses DataSourceRef in TS. + Datasource *DashboardV2beta1DataQueryKindDatasource `json:"datasource,omitempty"` + Spec map[string]interface{} `json:"spec"` } // NewDashboardDataQueryKind creates a new DashboardDataQueryKind object. func NewDashboardDataQueryKind() *DashboardDataQueryKind { return &DashboardDataQueryKind{ - Spec: map[string]interface{}{}, + Kind: "DataQuery", + Version: "v0", + Spec: map[string]interface{}{}, } } @@ -199,10 +192,9 @@ func NewDashboardPanelQueryKind() *DashboardPanelQueryKind { // +k8s:openapi-gen=true type DashboardPanelQuerySpec struct { - Query DashboardDataQueryKind `json:"query"` - Datasource *DashboardDataSourceRef `json:"datasource,omitempty"` - RefId string `json:"refId"` - Hidden bool `json:"hidden"` + Query DashboardDataQueryKind `json:"query"` + RefId string `json:"refId"` + Hidden bool `json:"hidden"` } // NewDashboardPanelQuerySpec creates a new DashboardPanelQuerySpec object. @@ -295,14 +287,17 @@ func NewDashboardQueryOptionsSpec() *DashboardQueryOptionsSpec { // +k8s:openapi-gen=true type DashboardVizConfigKind struct { - // The kind of a VizConfigKind is the plugin ID - Kind string `json:"kind"` - Spec DashboardVizConfigSpec `json:"spec"` + Kind string `json:"kind"` + // The group is the plugin ID + Group string `json:"group"` + Version string `json:"version"` + Spec DashboardVizConfigSpec `json:"spec"` } // NewDashboardVizConfigKind creates a new DashboardVizConfigKind object. func NewDashboardVizConfigKind() *DashboardVizConfigKind { return &DashboardVizConfigKind{ + Kind: "VizConfig", Spec: *NewDashboardVizConfigSpec(), } } @@ -310,9 +305,8 @@ func NewDashboardVizConfigKind() *DashboardVizConfigKind { // --- Kinds --- // +k8s:openapi-gen=true type DashboardVizConfigSpec struct { - PluginVersion string `json:"pluginVersion"` - Options map[string]interface{} `json:"options"` - FieldConfig DashboardFieldConfigSource `json:"fieldConfig"` + Options map[string]interface{} `json:"options"` + FieldConfig DashboardFieldConfigSource `json:"fieldConfig"` } // NewDashboardVizConfigSpec creates a new DashboardVizConfigSpec object. @@ -331,14 +325,14 @@ type DashboardFieldConfigSource struct { // Defaults are the options applied to all fields. Defaults DashboardFieldConfig `json:"defaults"` // Overrides are the options applied to specific fields overriding the defaults. - Overrides []DashboardV2alpha2FieldConfigSourceOverrides `json:"overrides"` + Overrides []DashboardV2beta1FieldConfigSourceOverrides `json:"overrides"` } // NewDashboardFieldConfigSource creates a new DashboardFieldConfigSource object. func NewDashboardFieldConfigSource() *DashboardFieldConfigSource { return &DashboardFieldConfigSource{ Defaults: *NewDashboardFieldConfig(), - Overrides: []DashboardV2alpha2FieldConfigSourceOverrides{}, + Overrides: []DashboardV2beta1FieldConfigSourceOverrides{}, } } @@ -468,14 +462,14 @@ func NewDashboardValueMappingResult() *DashboardValueMappingResult { type DashboardRangeMap struct { Type DashboardMappingType `json:"type"` // Range to match against and the result to apply when the value is within the range - Options DashboardV2alpha2RangeMapOptions `json:"options"` + Options DashboardV2beta1RangeMapOptions `json:"options"` } // NewDashboardRangeMap creates a new DashboardRangeMap object. func NewDashboardRangeMap() *DashboardRangeMap { return &DashboardRangeMap{ Type: DashboardMappingTypeRange, - Options: *NewDashboardV2alpha2RangeMapOptions(), + Options: *NewDashboardV2beta1RangeMapOptions(), } } @@ -485,14 +479,14 @@ func NewDashboardRangeMap() *DashboardRangeMap { type DashboardRegexMap struct { Type DashboardMappingType `json:"type"` // Regular expression to match against and the result to apply when the value matches the regex - Options DashboardV2alpha2RegexMapOptions `json:"options"` + Options DashboardV2beta1RegexMapOptions `json:"options"` } // NewDashboardRegexMap creates a new DashboardRegexMap object. func NewDashboardRegexMap() *DashboardRegexMap { return &DashboardRegexMap{ Type: DashboardMappingTypeRegex, - Options: *NewDashboardV2alpha2RegexMapOptions(), + Options: *NewDashboardV2beta1RegexMapOptions(), } } @@ -501,15 +495,15 @@ func NewDashboardRegexMap() *DashboardRegexMap { // For example, you can configure a special value mapping so that null values appear as N/A. // +k8s:openapi-gen=true type DashboardSpecialValueMap struct { - Type DashboardMappingType `json:"type"` - Options DashboardV2alpha2SpecialValueMapOptions `json:"options"` + Type DashboardMappingType `json:"type"` + Options DashboardV2beta1SpecialValueMapOptions `json:"options"` } // NewDashboardSpecialValueMap creates a new DashboardSpecialValueMap object. func NewDashboardSpecialValueMap() *DashboardSpecialValueMap { return &DashboardSpecialValueMap{ Type: DashboardMappingTypeSpecial, - Options: *NewDashboardV2alpha2SpecialValueMapOptions(), + Options: *NewDashboardV2beta1SpecialValueMapOptions(), } } @@ -1229,7 +1223,6 @@ type DashboardQueryVariableSpec struct { Refresh DashboardVariableRefresh `json:"refresh"` SkipUrlSync bool `json:"skipUrlSync"` Description *string `json:"description,omitempty"` - Datasource *DashboardDataSourceRef `json:"datasource,omitempty"` Query DashboardDataQueryKind `json:"query"` Regex string `json:"regex"` Sort DashboardVariableSort `json:"sort"` @@ -1591,8 +1584,10 @@ func NewDashboardCustomVariableSpec() *DashboardCustomVariableSpec { // Group variable kind // +k8s:openapi-gen=true type DashboardGroupByVariableKind struct { - Kind string `json:"kind"` - Spec DashboardGroupByVariableSpec `json:"spec"` + Kind string `json:"kind"` + Group string `json:"group"` + Datasource *DashboardV2beta1GroupByVariableKindDatasource `json:"datasource,omitempty"` + Spec DashboardGroupByVariableSpec `json:"spec"` } // NewDashboardGroupByVariableKind creates a new DashboardGroupByVariableKind object. @@ -1607,7 +1602,6 @@ func NewDashboardGroupByVariableKind() *DashboardGroupByVariableKind { // +k8s:openapi-gen=true type DashboardGroupByVariableSpec struct { Name string `json:"name"` - Datasource *DashboardDataSourceRef `json:"datasource,omitempty"` DefaultValue *DashboardVariableOption `json:"defaultValue,omitempty"` Current DashboardVariableOption `json:"current"` Options []DashboardVariableOption `json:"options"` @@ -1640,8 +1634,10 @@ func NewDashboardGroupByVariableSpec() *DashboardGroupByVariableSpec { // Adhoc variable kind // +k8s:openapi-gen=true type DashboardAdhocVariableKind struct { - Kind string `json:"kind"` - Spec DashboardAdhocVariableSpec `json:"spec"` + Kind string `json:"kind"` + Group string `json:"group"` + Datasource *DashboardV2beta1AdhocVariableKindDatasource `json:"datasource,omitempty"` + Spec DashboardAdhocVariableSpec `json:"spec"` } // NewDashboardAdhocVariableKind creates a new DashboardAdhocVariableKind object. @@ -1656,7 +1652,6 @@ func NewDashboardAdhocVariableKind() *DashboardAdhocVariableKind { // +k8s:openapi-gen=true type DashboardAdhocVariableSpec struct { Name string `json:"name"` - Datasource *DashboardDataSourceRef `json:"datasource,omitempty"` BaseFilters []DashboardAdHocFilterWithLabels `json:"baseFilters"` Filters []DashboardAdHocFilterWithLabels `json:"filters"` DefaultKeys []DashboardMetricFindValue `json:"defaultKeys"` @@ -1771,21 +1766,31 @@ func NewDashboardSpec() *DashboardSpec { } // +k8s:openapi-gen=true -type DashboardV2alpha2FieldConfigSourceOverrides struct { +type DashboardV2beta1DataQueryKindDatasource struct { + Name *string `json:"name,omitempty"` +} + +// NewDashboardV2beta1DataQueryKindDatasource creates a new DashboardV2beta1DataQueryKindDatasource object. +func NewDashboardV2beta1DataQueryKindDatasource() *DashboardV2beta1DataQueryKindDatasource { + return &DashboardV2beta1DataQueryKindDatasource{} +} + +// +k8s:openapi-gen=true +type DashboardV2beta1FieldConfigSourceOverrides struct { Matcher DashboardMatcherConfig `json:"matcher"` Properties []DashboardDynamicConfigValue `json:"properties"` } -// NewDashboardV2alpha2FieldConfigSourceOverrides creates a new DashboardV2alpha2FieldConfigSourceOverrides object. -func NewDashboardV2alpha2FieldConfigSourceOverrides() *DashboardV2alpha2FieldConfigSourceOverrides { - return &DashboardV2alpha2FieldConfigSourceOverrides{ +// NewDashboardV2beta1FieldConfigSourceOverrides creates a new DashboardV2beta1FieldConfigSourceOverrides object. +func NewDashboardV2beta1FieldConfigSourceOverrides() *DashboardV2beta1FieldConfigSourceOverrides { + return &DashboardV2beta1FieldConfigSourceOverrides{ Matcher: *NewDashboardMatcherConfig(), Properties: []DashboardDynamicConfigValue{}, } } // +k8s:openapi-gen=true -type DashboardV2alpha2RangeMapOptions struct { +type DashboardV2beta1RangeMapOptions struct { // Min value of the range. It can be null which means -Infinity From *float64 `json:"from"` // Max value of the range. It can be null which means +Infinity @@ -1794,43 +1799,63 @@ type DashboardV2alpha2RangeMapOptions struct { Result DashboardValueMappingResult `json:"result"` } -// NewDashboardV2alpha2RangeMapOptions creates a new DashboardV2alpha2RangeMapOptions object. -func NewDashboardV2alpha2RangeMapOptions() *DashboardV2alpha2RangeMapOptions { - return &DashboardV2alpha2RangeMapOptions{ +// NewDashboardV2beta1RangeMapOptions creates a new DashboardV2beta1RangeMapOptions object. +func NewDashboardV2beta1RangeMapOptions() *DashboardV2beta1RangeMapOptions { + return &DashboardV2beta1RangeMapOptions{ Result: *NewDashboardValueMappingResult(), } } // +k8s:openapi-gen=true -type DashboardV2alpha2RegexMapOptions struct { +type DashboardV2beta1RegexMapOptions struct { // Regular expression to match against Pattern string `json:"pattern"` // Config to apply when the value matches the regex Result DashboardValueMappingResult `json:"result"` } -// NewDashboardV2alpha2RegexMapOptions creates a new DashboardV2alpha2RegexMapOptions object. -func NewDashboardV2alpha2RegexMapOptions() *DashboardV2alpha2RegexMapOptions { - return &DashboardV2alpha2RegexMapOptions{ +// NewDashboardV2beta1RegexMapOptions creates a new DashboardV2beta1RegexMapOptions object. +func NewDashboardV2beta1RegexMapOptions() *DashboardV2beta1RegexMapOptions { + return &DashboardV2beta1RegexMapOptions{ Result: *NewDashboardValueMappingResult(), } } // +k8s:openapi-gen=true -type DashboardV2alpha2SpecialValueMapOptions struct { +type DashboardV2beta1SpecialValueMapOptions struct { // Special value to match against Match DashboardSpecialValueMatch `json:"match"` // Config to apply when the value matches the special value Result DashboardValueMappingResult `json:"result"` } -// NewDashboardV2alpha2SpecialValueMapOptions creates a new DashboardV2alpha2SpecialValueMapOptions object. -func NewDashboardV2alpha2SpecialValueMapOptions() *DashboardV2alpha2SpecialValueMapOptions { - return &DashboardV2alpha2SpecialValueMapOptions{ +// NewDashboardV2beta1SpecialValueMapOptions creates a new DashboardV2beta1SpecialValueMapOptions object. +func NewDashboardV2beta1SpecialValueMapOptions() *DashboardV2beta1SpecialValueMapOptions { + return &DashboardV2beta1SpecialValueMapOptions{ Result: *NewDashboardValueMappingResult(), } } +// +k8s:openapi-gen=true +type DashboardV2beta1GroupByVariableKindDatasource struct { + Name *string `json:"name,omitempty"` +} + +// NewDashboardV2beta1GroupByVariableKindDatasource creates a new DashboardV2beta1GroupByVariableKindDatasource object. +func NewDashboardV2beta1GroupByVariableKindDatasource() *DashboardV2beta1GroupByVariableKindDatasource { + return &DashboardV2beta1GroupByVariableKindDatasource{} +} + +// +k8s:openapi-gen=true +type DashboardV2beta1AdhocVariableKindDatasource struct { + Name *string `json:"name,omitempty"` +} + +// NewDashboardV2beta1AdhocVariableKindDatasource creates a new DashboardV2beta1AdhocVariableKindDatasource object. +func NewDashboardV2beta1AdhocVariableKindDatasource() *DashboardV2beta1AdhocVariableKindDatasource { + return &DashboardV2beta1AdhocVariableKindDatasource{} +} + // +k8s:openapi-gen=true type DashboardRepeatOptionsDirection string diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha2/dashboard_status_gen.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_status_gen.go similarity index 98% rename from apps/dashboard/pkg/apis/dashboard/v2alpha2/dashboard_status_gen.go rename to apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_status_gen.go index 322f9f072ec..87ab45e9c54 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha2/dashboard_status_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_status_gen.go @@ -1,6 +1,6 @@ // Code generated - EDITING IS FUTILE. DO NOT EDIT. -package v2alpha2 +package v2beta1 // ConversionStatus is the status of the conversion of the dashboard. // +k8s:openapi-gen=true diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha2/doc.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/doc.go similarity index 80% rename from apps/dashboard/pkg/apis/dashboard/v2alpha2/doc.go rename to apps/dashboard/pkg/apis/dashboard/v2beta1/doc.go index 95dba55d4cd..ec8aad18799 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha2/doc.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/doc.go @@ -7,4 +7,4 @@ // because grafana-app-sdk already provides deepcopy functions. // Kinds which are not generated by the SDK are explicitly opted in to deepcopy generation. -package v2alpha2 // import "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" +package v2beta1 // import "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha2/register.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/register.go similarity index 98% rename from apps/dashboard/pkg/apis/dashboard/v2alpha2/register.go rename to apps/dashboard/pkg/apis/dashboard/v2beta1/register.go index 0a68d910fb1..28f74501e6e 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha2/register.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/register.go @@ -1,4 +1,4 @@ -package v2alpha2 +package v2beta1 import ( "fmt" @@ -12,7 +12,7 @@ import ( const ( GROUP = "dashboard.grafana.app" - VERSION = "v2alpha2" + VERSION = "v2beta1" APIVERSION = GROUP + "/" + VERSION ) diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha2/types.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/types.go similarity index 99% rename from apps/dashboard/pkg/apis/dashboard/v2alpha2/types.go rename to apps/dashboard/pkg/apis/dashboard/v2beta1/types.go index b40a86fe94e..8ecda3cfd7d 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha2/types.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/types.go @@ -1,4 +1,4 @@ -package v2alpha2 +package v2beta1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha2/validation.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/validation.go similarity index 99% rename from apps/dashboard/pkg/apis/dashboard/v2alpha2/validation.go rename to apps/dashboard/pkg/apis/dashboard/v2beta1/validation.go index 9b01c53a3e1..3946c0d9b14 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha2/validation.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/validation.go @@ -1,4 +1,4 @@ -package v2alpha2 +package v2beta1 import ( _ "embed" diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.conversion.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.conversion.go new file mode 100644 index 00000000000..fce43178b8a --- /dev/null +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.conversion.go @@ -0,0 +1,175 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by conversion-gen. DO NOT EDIT. + +package v2beta1 + +import ( + url "net/url" + unsafe "unsafe" + + dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard" + conversion "k8s.io/apimachinery/pkg/conversion" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +func init() { + localSchemeBuilder.Register(RegisterConversions) +} + +// RegisterConversions adds conversion functions to the given scheme. +// Public to allow building arbitrary schemes. +func RegisterConversions(s *runtime.Scheme) error { + if err := s.AddGeneratedConversionFunc((*AnnotationActions)(nil), (*dashboard.AnnotationActions)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_v2beta1_AnnotationActions_To_dashboard_AnnotationActions(a.(*AnnotationActions), b.(*dashboard.AnnotationActions), scope) + }); err != nil { + return err + } + if err := s.AddGeneratedConversionFunc((*dashboard.AnnotationActions)(nil), (*AnnotationActions)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_dashboard_AnnotationActions_To_v2beta1_AnnotationActions(a.(*dashboard.AnnotationActions), b.(*AnnotationActions), scope) + }); err != nil { + return err + } + if err := s.AddGeneratedConversionFunc((*AnnotationPermission)(nil), (*dashboard.AnnotationPermission)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_v2beta1_AnnotationPermission_To_dashboard_AnnotationPermission(a.(*AnnotationPermission), b.(*dashboard.AnnotationPermission), scope) + }); err != nil { + return err + } + if err := s.AddGeneratedConversionFunc((*dashboard.AnnotationPermission)(nil), (*AnnotationPermission)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_dashboard_AnnotationPermission_To_v2beta1_AnnotationPermission(a.(*dashboard.AnnotationPermission), b.(*AnnotationPermission), scope) + }); err != nil { + return err + } + if err := s.AddGeneratedConversionFunc((*DashboardAccess)(nil), (*dashboard.DashboardAccess)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_v2beta1_DashboardAccess_To_dashboard_DashboardAccess(a.(*DashboardAccess), b.(*dashboard.DashboardAccess), scope) + }); err != nil { + return err + } + if err := s.AddGeneratedConversionFunc((*dashboard.DashboardAccess)(nil), (*DashboardAccess)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_dashboard_DashboardAccess_To_v2beta1_DashboardAccess(a.(*dashboard.DashboardAccess), b.(*DashboardAccess), scope) + }); err != nil { + return err + } + if err := s.AddGeneratedConversionFunc((*url.Values)(nil), (*VersionsQueryOptions)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_url_Values_To_v2beta1_VersionsQueryOptions(a.(*url.Values), b.(*VersionsQueryOptions), scope) + }); err != nil { + return err + } + return nil +} + +func autoConvert_v2beta1_AnnotationActions_To_dashboard_AnnotationActions(in *AnnotationActions, out *dashboard.AnnotationActions, s conversion.Scope) error { + out.CanAdd = in.CanAdd + out.CanEdit = in.CanEdit + out.CanDelete = in.CanDelete + return nil +} + +// Convert_v2beta1_AnnotationActions_To_dashboard_AnnotationActions is an autogenerated conversion function. +func Convert_v2beta1_AnnotationActions_To_dashboard_AnnotationActions(in *AnnotationActions, out *dashboard.AnnotationActions, s conversion.Scope) error { + return autoConvert_v2beta1_AnnotationActions_To_dashboard_AnnotationActions(in, out, s) +} + +func autoConvert_dashboard_AnnotationActions_To_v2beta1_AnnotationActions(in *dashboard.AnnotationActions, out *AnnotationActions, s conversion.Scope) error { + out.CanAdd = in.CanAdd + out.CanEdit = in.CanEdit + out.CanDelete = in.CanDelete + return nil +} + +// Convert_dashboard_AnnotationActions_To_v2beta1_AnnotationActions is an autogenerated conversion function. +func Convert_dashboard_AnnotationActions_To_v2beta1_AnnotationActions(in *dashboard.AnnotationActions, out *AnnotationActions, s conversion.Scope) error { + return autoConvert_dashboard_AnnotationActions_To_v2beta1_AnnotationActions(in, out, s) +} + +func autoConvert_v2beta1_AnnotationPermission_To_dashboard_AnnotationPermission(in *AnnotationPermission, out *dashboard.AnnotationPermission, s conversion.Scope) error { + if err := Convert_v2beta1_AnnotationActions_To_dashboard_AnnotationActions(&in.Dashboard, &out.Dashboard, s); err != nil { + return err + } + if err := Convert_v2beta1_AnnotationActions_To_dashboard_AnnotationActions(&in.Organization, &out.Organization, s); err != nil { + return err + } + return nil +} + +// Convert_v2beta1_AnnotationPermission_To_dashboard_AnnotationPermission is an autogenerated conversion function. +func Convert_v2beta1_AnnotationPermission_To_dashboard_AnnotationPermission(in *AnnotationPermission, out *dashboard.AnnotationPermission, s conversion.Scope) error { + return autoConvert_v2beta1_AnnotationPermission_To_dashboard_AnnotationPermission(in, out, s) +} + +func autoConvert_dashboard_AnnotationPermission_To_v2beta1_AnnotationPermission(in *dashboard.AnnotationPermission, out *AnnotationPermission, s conversion.Scope) error { + if err := Convert_dashboard_AnnotationActions_To_v2beta1_AnnotationActions(&in.Dashboard, &out.Dashboard, s); err != nil { + return err + } + if err := Convert_dashboard_AnnotationActions_To_v2beta1_AnnotationActions(&in.Organization, &out.Organization, s); err != nil { + return err + } + return nil +} + +// Convert_dashboard_AnnotationPermission_To_v2beta1_AnnotationPermission is an autogenerated conversion function. +func Convert_dashboard_AnnotationPermission_To_v2beta1_AnnotationPermission(in *dashboard.AnnotationPermission, out *AnnotationPermission, s conversion.Scope) error { + return autoConvert_dashboard_AnnotationPermission_To_v2beta1_AnnotationPermission(in, out, s) +} + +func autoConvert_v2beta1_DashboardAccess_To_dashboard_DashboardAccess(in *DashboardAccess, out *dashboard.DashboardAccess, s conversion.Scope) error { + out.Slug = in.Slug + out.Url = in.Url + out.CanSave = in.CanSave + out.CanEdit = in.CanEdit + out.CanAdmin = in.CanAdmin + out.CanStar = in.CanStar + out.CanDelete = in.CanDelete + out.AnnotationsPermissions = (*dashboard.AnnotationPermission)(unsafe.Pointer(in.AnnotationsPermissions)) + return nil +} + +// Convert_v2beta1_DashboardAccess_To_dashboard_DashboardAccess is an autogenerated conversion function. +func Convert_v2beta1_DashboardAccess_To_dashboard_DashboardAccess(in *DashboardAccess, out *dashboard.DashboardAccess, s conversion.Scope) error { + return autoConvert_v2beta1_DashboardAccess_To_dashboard_DashboardAccess(in, out, s) +} + +func autoConvert_dashboard_DashboardAccess_To_v2beta1_DashboardAccess(in *dashboard.DashboardAccess, out *DashboardAccess, s conversion.Scope) error { + out.Slug = in.Slug + out.Url = in.Url + out.CanSave = in.CanSave + out.CanEdit = in.CanEdit + out.CanAdmin = in.CanAdmin + out.CanStar = in.CanStar + out.CanDelete = in.CanDelete + out.AnnotationsPermissions = (*AnnotationPermission)(unsafe.Pointer(in.AnnotationsPermissions)) + return nil +} + +// Convert_dashboard_DashboardAccess_To_v2beta1_DashboardAccess is an autogenerated conversion function. +func Convert_dashboard_DashboardAccess_To_v2beta1_DashboardAccess(in *dashboard.DashboardAccess, out *DashboardAccess, s conversion.Scope) error { + return autoConvert_dashboard_DashboardAccess_To_v2beta1_DashboardAccess(in, out, s) +} + +func autoConvert_url_Values_To_v2beta1_VersionsQueryOptions(in *url.Values, out *VersionsQueryOptions, s conversion.Scope) error { + // WARNING: Field TypeMeta does not have json tag, skipping. + + if values, ok := map[string][]string(*in)["path"]; ok && len(values) > 0 { + if err := runtime.Convert_Slice_string_To_string(&values, &out.Path, s); err != nil { + return err + } + } else { + out.Path = "" + } + if values, ok := map[string][]string(*in)["version"]; ok && len(values) > 0 { + if err := runtime.Convert_Slice_string_To_int64(&values, &out.Version, s); err != nil { + return err + } + } else { + out.Version = 0 + } + return nil +} + +// Convert_url_Values_To_v2beta1_VersionsQueryOptions is an autogenerated conversion function. +func Convert_url_Values_To_v2beta1_VersionsQueryOptions(in *url.Values, out *VersionsQueryOptions, s conversion.Scope) error { + return autoConvert_url_Values_To_v2beta1_VersionsQueryOptions(in, out, s) +} diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha2/zz_generated.deepcopy.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.deepcopy.go similarity index 99% rename from apps/dashboard/pkg/apis/dashboard/v2alpha2/zz_generated.deepcopy.go rename to apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.deepcopy.go index 9a8a05f7f22..5216c101ff9 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha2/zz_generated.deepcopy.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ // Code generated by deepcopy-gen. DO NOT EDIT. -package v2alpha2 +package v2beta1 import ( v0alpha1 "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha2/zz_generated.defaults.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.defaults.go similarity index 96% rename from apps/dashboard/pkg/apis/dashboard/v2alpha2/zz_generated.defaults.go rename to apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.defaults.go index efead5bdea7..a41e5b8d613 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha2/zz_generated.defaults.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.defaults.go @@ -5,7 +5,7 @@ // Code generated by defaulter-gen. DO NOT EDIT. -package v2alpha2 +package v2beta1 import ( runtime "k8s.io/apimachinery/pkg/runtime" diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha2/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go similarity index 73% rename from apps/dashboard/pkg/apis/dashboard/v2alpha2/zz_generated.openapi.go rename to apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go index 7ed5e3983d4..c7aa3cb8481 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha2/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go @@ -5,7 +5,7 @@ // Code generated by openapi-gen. DO NOT EDIT. -package v2alpha2 +package v2beta1 import ( common "k8s.io/kube-openapi/pkg/common" @@ -14,123 +14,125 @@ import ( func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { return map[string]common.OpenAPIDefinition{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.AnnotationActions": schema_pkg_apis_dashboard_v2alpha2_AnnotationActions(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.AnnotationPermission": schema_pkg_apis_dashboard_v2alpha2_AnnotationPermission(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.Dashboard": schema_pkg_apis_dashboard_v2alpha2_Dashboard(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAccess": schema_pkg_apis_dashboard_v2alpha2_DashboardAccess(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAdHocFilterWithLabels": schema_pkg_apis_dashboard_v2alpha2_DashboardAdHocFilterWithLabels(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAdhocVariableKind": schema_pkg_apis_dashboard_v2alpha2_DashboardAdhocVariableKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAdhocVariableSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardAdhocVariableSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAnnotationPanelFilter": schema_pkg_apis_dashboard_v2alpha2_DashboardAnnotationPanelFilter(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAnnotationQueryKind": schema_pkg_apis_dashboard_v2alpha2_DashboardAnnotationQueryKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAnnotationQuerySpec": schema_pkg_apis_dashboard_v2alpha2_DashboardAnnotationQuerySpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAutoGridLayoutItemKind": schema_pkg_apis_dashboard_v2alpha2_DashboardAutoGridLayoutItemKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAutoGridLayoutItemSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardAutoGridLayoutItemSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAutoGridLayoutKind": schema_pkg_apis_dashboard_v2alpha2_DashboardAutoGridLayoutKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAutoGridLayoutSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardAutoGridLayoutSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAutoGridRepeatOptions": schema_pkg_apis_dashboard_v2alpha2_DashboardAutoGridRepeatOptions(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingDataKind": schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingDataKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingDataSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingDataSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingGroupKind": schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingGroupKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingGroupSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingGroupSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingTimeRangeSizeKind": schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingTimeRangeSizeKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingTimeRangeSizeSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingTimeRangeSizeSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingVariableKind": schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingVariableKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind": schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingVariableSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingVariableSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConstantVariableKind": schema_pkg_apis_dashboard_v2alpha2_DashboardConstantVariableKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConstantVariableSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardConstantVariableSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConversionStatus": schema_pkg_apis_dashboard_v2alpha2_DashboardConversionStatus(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardCustomVariableKind": schema_pkg_apis_dashboard_v2alpha2_DashboardCustomVariableKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardCustomVariableSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardCustomVariableSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDashboardLink": schema_pkg_apis_dashboard_v2alpha2_DashboardDashboardLink(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataLink": schema_pkg_apis_dashboard_v2alpha2_DashboardDataLink(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataQueryKind": schema_pkg_apis_dashboard_v2alpha2_DashboardDataQueryKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataSourceRef": schema_pkg_apis_dashboard_v2alpha2_DashboardDataSourceRef(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataTransformerConfig": schema_pkg_apis_dashboard_v2alpha2_DashboardDataTransformerConfig(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDatasourceVariableKind": schema_pkg_apis_dashboard_v2alpha2_DashboardDatasourceVariableKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDatasourceVariableSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardDatasourceVariableSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDynamicConfigValue": schema_pkg_apis_dashboard_v2alpha2_DashboardDynamicConfigValue(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardElementReference": schema_pkg_apis_dashboard_v2alpha2_DashboardElementReference(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardFieldColor": schema_pkg_apis_dashboard_v2alpha2_DashboardFieldColor(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardFieldConfig": schema_pkg_apis_dashboard_v2alpha2_DashboardFieldConfig(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardFieldConfigSource": schema_pkg_apis_dashboard_v2alpha2_DashboardFieldConfigSource(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutItemKind": schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutItemKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutItemSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutItemSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutKind": schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind": schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind": schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGroupByVariableKind": schema_pkg_apis_dashboard_v2alpha2_DashboardGroupByVariableKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGroupByVariableSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardGroupByVariableSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardIntervalVariableKind": schema_pkg_apis_dashboard_v2alpha2_DashboardIntervalVariableKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardIntervalVariableSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardIntervalVariableSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardJSONCodec": schema_pkg_apis_dashboard_v2alpha2_DashboardJSONCodec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardLibraryPanelKind": schema_pkg_apis_dashboard_v2alpha2_DashboardLibraryPanelKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardLibraryPanelKindSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardLibraryPanelKindSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardLibraryPanelRef": schema_pkg_apis_dashboard_v2alpha2_DashboardLibraryPanelRef(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardList": schema_pkg_apis_dashboard_v2alpha2_DashboardList(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardMatcherConfig": schema_pkg_apis_dashboard_v2alpha2_DashboardMatcherConfig(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardMetadata": schema_pkg_apis_dashboard_v2alpha2_DashboardMetadata(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardMetricFindValue": schema_pkg_apis_dashboard_v2alpha2_DashboardMetricFindValue(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardPanelKind": schema_pkg_apis_dashboard_v2alpha2_DashboardPanelKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardPanelKindOrLibraryPanelKind": schema_pkg_apis_dashboard_v2alpha2_DashboardPanelKindOrLibraryPanelKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardPanelQueryKind": schema_pkg_apis_dashboard_v2alpha2_DashboardPanelQueryKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardPanelQuerySpec": schema_pkg_apis_dashboard_v2alpha2_DashboardPanelQuerySpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardPanelSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardPanelSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardQueryGroupKind": schema_pkg_apis_dashboard_v2alpha2_DashboardQueryGroupKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardQueryGroupSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardQueryGroupSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardQueryOptionsSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardQueryOptionsSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardQueryVariableKind": schema_pkg_apis_dashboard_v2alpha2_DashboardQueryVariableKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind": schema_pkg_apis_dashboard_v2alpha2_DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardQueryVariableSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardQueryVariableSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRangeMap": schema_pkg_apis_dashboard_v2alpha2_DashboardRangeMap(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRegexMap": schema_pkg_apis_dashboard_v2alpha2_DashboardRegexMap(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRepeatOptions": schema_pkg_apis_dashboard_v2alpha2_DashboardRepeatOptions(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRowRepeatOptions": schema_pkg_apis_dashboard_v2alpha2_DashboardRowRepeatOptions(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRowsLayoutKind": schema_pkg_apis_dashboard_v2alpha2_DashboardRowsLayoutKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRowsLayoutRowKind": schema_pkg_apis_dashboard_v2alpha2_DashboardRowsLayoutRowKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRowsLayoutRowSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardRowsLayoutRowSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRowsLayoutSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardRowsLayoutSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardSpecialValueMap": schema_pkg_apis_dashboard_v2alpha2_DashboardSpecialValueMap(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardStatus": schema_pkg_apis_dashboard_v2alpha2_DashboardStatus(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardStringOrArrayOfString": schema_pkg_apis_dashboard_v2alpha2_DashboardStringOrArrayOfString(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardStringOrFloat64": schema_pkg_apis_dashboard_v2alpha2_DashboardStringOrFloat64(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTabRepeatOptions": schema_pkg_apis_dashboard_v2alpha2_DashboardTabRepeatOptions(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTabsLayoutKind": schema_pkg_apis_dashboard_v2alpha2_DashboardTabsLayoutKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTabsLayoutSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardTabsLayoutSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTabsLayoutTabKind": schema_pkg_apis_dashboard_v2alpha2_DashboardTabsLayoutTabKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTabsLayoutTabSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardTabsLayoutTabSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTextVariableKind": schema_pkg_apis_dashboard_v2alpha2_DashboardTextVariableKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTextVariableSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardTextVariableSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardThreshold": schema_pkg_apis_dashboard_v2alpha2_DashboardThreshold(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardThresholdsConfig": schema_pkg_apis_dashboard_v2alpha2_DashboardThresholdsConfig(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTimeRangeOption": schema_pkg_apis_dashboard_v2alpha2_DashboardTimeRangeOption(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTimeSettingsSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardTimeSettingsSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTransformationKind": schema_pkg_apis_dashboard_v2alpha2_DashboardTransformationKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardV2alpha2FieldConfigSourceOverrides": schema_pkg_apis_dashboard_v2alpha2_DashboardV2alpha2FieldConfigSourceOverrides(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardV2alpha2RangeMapOptions": schema_pkg_apis_dashboard_v2alpha2_DashboardV2alpha2RangeMapOptions(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardV2alpha2RegexMapOptions": schema_pkg_apis_dashboard_v2alpha2_DashboardV2alpha2RegexMapOptions(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardV2alpha2SpecialValueMapOptions": schema_pkg_apis_dashboard_v2alpha2_DashboardV2alpha2SpecialValueMapOptions(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardValueMap": schema_pkg_apis_dashboard_v2alpha2_DashboardValueMap(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap": schema_pkg_apis_dashboard_v2alpha2_DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardValueMappingResult": schema_pkg_apis_dashboard_v2alpha2_DashboardValueMappingResult(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption": schema_pkg_apis_dashboard_v2alpha2_DashboardVariableOption(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVersionInfo": schema_pkg_apis_dashboard_v2alpha2_DashboardVersionInfo(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVersionList": schema_pkg_apis_dashboard_v2alpha2_DashboardVersionList(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVizConfigKind": schema_pkg_apis_dashboard_v2alpha2_DashboardVizConfigKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVizConfigSpec": schema_pkg_apis_dashboard_v2alpha2_DashboardVizConfigSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardWithAccessInfo": schema_pkg_apis_dashboard_v2alpha2_DashboardWithAccessInfo(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.LibraryPanel": schema_pkg_apis_dashboard_v2alpha2_LibraryPanel(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.LibraryPanelList": schema_pkg_apis_dashboard_v2alpha2_LibraryPanelList(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.LibraryPanelSpec": schema_pkg_apis_dashboard_v2alpha2_LibraryPanelSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.LibraryPanelStatus": schema_pkg_apis_dashboard_v2alpha2_LibraryPanelStatus(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.VersionsQueryOptions": schema_pkg_apis_dashboard_v2alpha2_VersionsQueryOptions(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.AnnotationActions": schema_pkg_apis_dashboard_v2beta1_AnnotationActions(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.AnnotationPermission": schema_pkg_apis_dashboard_v2beta1_AnnotationPermission(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.Dashboard": schema_pkg_apis_dashboard_v2beta1_Dashboard(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAccess": schema_pkg_apis_dashboard_v2beta1_DashboardAccess(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAdHocFilterWithLabels": schema_pkg_apis_dashboard_v2beta1_DashboardAdHocFilterWithLabels(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAdhocVariableKind": schema_pkg_apis_dashboard_v2beta1_DashboardAdhocVariableKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAdhocVariableSpec": schema_pkg_apis_dashboard_v2beta1_DashboardAdhocVariableSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAnnotationPanelFilter": schema_pkg_apis_dashboard_v2beta1_DashboardAnnotationPanelFilter(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAnnotationQueryKind": schema_pkg_apis_dashboard_v2beta1_DashboardAnnotationQueryKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAnnotationQuerySpec": schema_pkg_apis_dashboard_v2beta1_DashboardAnnotationQuerySpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAutoGridLayoutItemKind": schema_pkg_apis_dashboard_v2beta1_DashboardAutoGridLayoutItemKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAutoGridLayoutItemSpec": schema_pkg_apis_dashboard_v2beta1_DashboardAutoGridLayoutItemSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAutoGridLayoutKind": schema_pkg_apis_dashboard_v2beta1_DashboardAutoGridLayoutKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAutoGridLayoutSpec": schema_pkg_apis_dashboard_v2beta1_DashboardAutoGridLayoutSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAutoGridRepeatOptions": schema_pkg_apis_dashboard_v2beta1_DashboardAutoGridRepeatOptions(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingDataKind": schema_pkg_apis_dashboard_v2beta1_DashboardConditionalRenderingDataKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingDataSpec": schema_pkg_apis_dashboard_v2beta1_DashboardConditionalRenderingDataSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingGroupKind": schema_pkg_apis_dashboard_v2beta1_DashboardConditionalRenderingGroupKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingGroupSpec": schema_pkg_apis_dashboard_v2beta1_DashboardConditionalRenderingGroupSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingTimeRangeSizeKind": schema_pkg_apis_dashboard_v2beta1_DashboardConditionalRenderingTimeRangeSizeKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingTimeRangeSizeSpec": schema_pkg_apis_dashboard_v2beta1_DashboardConditionalRenderingTimeRangeSizeSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingVariableKind": schema_pkg_apis_dashboard_v2beta1_DashboardConditionalRenderingVariableKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind": schema_pkg_apis_dashboard_v2beta1_DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingVariableSpec": schema_pkg_apis_dashboard_v2beta1_DashboardConditionalRenderingVariableSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConstantVariableKind": schema_pkg_apis_dashboard_v2beta1_DashboardConstantVariableKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConstantVariableSpec": schema_pkg_apis_dashboard_v2beta1_DashboardConstantVariableSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConversionStatus": schema_pkg_apis_dashboard_v2beta1_DashboardConversionStatus(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardCustomVariableKind": schema_pkg_apis_dashboard_v2beta1_DashboardCustomVariableKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardCustomVariableSpec": schema_pkg_apis_dashboard_v2beta1_DashboardCustomVariableSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDashboardLink": schema_pkg_apis_dashboard_v2beta1_DashboardDashboardLink(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDataLink": schema_pkg_apis_dashboard_v2beta1_DashboardDataLink(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDataQueryKind": schema_pkg_apis_dashboard_v2beta1_DashboardDataQueryKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDataTransformerConfig": schema_pkg_apis_dashboard_v2beta1_DashboardDataTransformerConfig(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDatasourceVariableKind": schema_pkg_apis_dashboard_v2beta1_DashboardDatasourceVariableKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDatasourceVariableSpec": schema_pkg_apis_dashboard_v2beta1_DashboardDatasourceVariableSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDynamicConfigValue": schema_pkg_apis_dashboard_v2beta1_DashboardDynamicConfigValue(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardElementReference": schema_pkg_apis_dashboard_v2beta1_DashboardElementReference(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardFieldColor": schema_pkg_apis_dashboard_v2beta1_DashboardFieldColor(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardFieldConfig": schema_pkg_apis_dashboard_v2beta1_DashboardFieldConfig(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardFieldConfigSource": schema_pkg_apis_dashboard_v2beta1_DashboardFieldConfigSource(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutItemKind": schema_pkg_apis_dashboard_v2beta1_DashboardGridLayoutItemKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutItemSpec": schema_pkg_apis_dashboard_v2beta1_DashboardGridLayoutItemSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutKind": schema_pkg_apis_dashboard_v2beta1_DashboardGridLayoutKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind": schema_pkg_apis_dashboard_v2beta1_DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind": schema_pkg_apis_dashboard_v2beta1_DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutSpec": schema_pkg_apis_dashboard_v2beta1_DashboardGridLayoutSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGroupByVariableKind": schema_pkg_apis_dashboard_v2beta1_DashboardGroupByVariableKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGroupByVariableSpec": schema_pkg_apis_dashboard_v2beta1_DashboardGroupByVariableSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardIntervalVariableKind": schema_pkg_apis_dashboard_v2beta1_DashboardIntervalVariableKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardIntervalVariableSpec": schema_pkg_apis_dashboard_v2beta1_DashboardIntervalVariableSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardJSONCodec": schema_pkg_apis_dashboard_v2beta1_DashboardJSONCodec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardLibraryPanelKind": schema_pkg_apis_dashboard_v2beta1_DashboardLibraryPanelKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardLibraryPanelKindSpec": schema_pkg_apis_dashboard_v2beta1_DashboardLibraryPanelKindSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardLibraryPanelRef": schema_pkg_apis_dashboard_v2beta1_DashboardLibraryPanelRef(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardList": schema_pkg_apis_dashboard_v2beta1_DashboardList(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardMatcherConfig": schema_pkg_apis_dashboard_v2beta1_DashboardMatcherConfig(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardMetadata": schema_pkg_apis_dashboard_v2beta1_DashboardMetadata(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardMetricFindValue": schema_pkg_apis_dashboard_v2beta1_DashboardMetricFindValue(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardPanelKind": schema_pkg_apis_dashboard_v2beta1_DashboardPanelKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardPanelKindOrLibraryPanelKind": schema_pkg_apis_dashboard_v2beta1_DashboardPanelKindOrLibraryPanelKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardPanelQueryKind": schema_pkg_apis_dashboard_v2beta1_DashboardPanelQueryKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardPanelQuerySpec": schema_pkg_apis_dashboard_v2beta1_DashboardPanelQuerySpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardPanelSpec": schema_pkg_apis_dashboard_v2beta1_DashboardPanelSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardQueryGroupKind": schema_pkg_apis_dashboard_v2beta1_DashboardQueryGroupKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardQueryGroupSpec": schema_pkg_apis_dashboard_v2beta1_DashboardQueryGroupSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardQueryOptionsSpec": schema_pkg_apis_dashboard_v2beta1_DashboardQueryOptionsSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardQueryVariableKind": schema_pkg_apis_dashboard_v2beta1_DashboardQueryVariableKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind": schema_pkg_apis_dashboard_v2beta1_DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardQueryVariableSpec": schema_pkg_apis_dashboard_v2beta1_DashboardQueryVariableSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRangeMap": schema_pkg_apis_dashboard_v2beta1_DashboardRangeMap(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRegexMap": schema_pkg_apis_dashboard_v2beta1_DashboardRegexMap(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRepeatOptions": schema_pkg_apis_dashboard_v2beta1_DashboardRepeatOptions(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRowRepeatOptions": schema_pkg_apis_dashboard_v2beta1_DashboardRowRepeatOptions(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRowsLayoutKind": schema_pkg_apis_dashboard_v2beta1_DashboardRowsLayoutKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRowsLayoutRowKind": schema_pkg_apis_dashboard_v2beta1_DashboardRowsLayoutRowKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRowsLayoutRowSpec": schema_pkg_apis_dashboard_v2beta1_DashboardRowsLayoutRowSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRowsLayoutSpec": schema_pkg_apis_dashboard_v2beta1_DashboardRowsLayoutSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardSpec": schema_pkg_apis_dashboard_v2beta1_DashboardSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardSpecialValueMap": schema_pkg_apis_dashboard_v2beta1_DashboardSpecialValueMap(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardStatus": schema_pkg_apis_dashboard_v2beta1_DashboardStatus(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardStringOrArrayOfString": schema_pkg_apis_dashboard_v2beta1_DashboardStringOrArrayOfString(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardStringOrFloat64": schema_pkg_apis_dashboard_v2beta1_DashboardStringOrFloat64(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTabRepeatOptions": schema_pkg_apis_dashboard_v2beta1_DashboardTabRepeatOptions(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTabsLayoutKind": schema_pkg_apis_dashboard_v2beta1_DashboardTabsLayoutKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTabsLayoutSpec": schema_pkg_apis_dashboard_v2beta1_DashboardTabsLayoutSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTabsLayoutTabKind": schema_pkg_apis_dashboard_v2beta1_DashboardTabsLayoutTabKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTabsLayoutTabSpec": schema_pkg_apis_dashboard_v2beta1_DashboardTabsLayoutTabSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTextVariableKind": schema_pkg_apis_dashboard_v2beta1_DashboardTextVariableKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTextVariableSpec": schema_pkg_apis_dashboard_v2beta1_DashboardTextVariableSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardThreshold": schema_pkg_apis_dashboard_v2beta1_DashboardThreshold(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardThresholdsConfig": schema_pkg_apis_dashboard_v2beta1_DashboardThresholdsConfig(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTimeRangeOption": schema_pkg_apis_dashboard_v2beta1_DashboardTimeRangeOption(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTimeSettingsSpec": schema_pkg_apis_dashboard_v2beta1_DashboardTimeSettingsSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTransformationKind": schema_pkg_apis_dashboard_v2beta1_DashboardTransformationKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1AdhocVariableKindDatasource": schema_pkg_apis_dashboard_v2beta1_DashboardV2beta1AdhocVariableKindDatasource(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1DataQueryKindDatasource": schema_pkg_apis_dashboard_v2beta1_DashboardV2beta1DataQueryKindDatasource(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1FieldConfigSourceOverrides": schema_pkg_apis_dashboard_v2beta1_DashboardV2beta1FieldConfigSourceOverrides(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1GroupByVariableKindDatasource": schema_pkg_apis_dashboard_v2beta1_DashboardV2beta1GroupByVariableKindDatasource(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1RangeMapOptions": schema_pkg_apis_dashboard_v2beta1_DashboardV2beta1RangeMapOptions(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1RegexMapOptions": schema_pkg_apis_dashboard_v2beta1_DashboardV2beta1RegexMapOptions(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1SpecialValueMapOptions": schema_pkg_apis_dashboard_v2beta1_DashboardV2beta1SpecialValueMapOptions(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardValueMap": schema_pkg_apis_dashboard_v2beta1_DashboardValueMap(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap": schema_pkg_apis_dashboard_v2beta1_DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardValueMappingResult": schema_pkg_apis_dashboard_v2beta1_DashboardValueMappingResult(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption": schema_pkg_apis_dashboard_v2beta1_DashboardVariableOption(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVersionInfo": schema_pkg_apis_dashboard_v2beta1_DashboardVersionInfo(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVersionList": schema_pkg_apis_dashboard_v2beta1_DashboardVersionList(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVizConfigKind": schema_pkg_apis_dashboard_v2beta1_DashboardVizConfigKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVizConfigSpec": schema_pkg_apis_dashboard_v2beta1_DashboardVizConfigSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardWithAccessInfo": schema_pkg_apis_dashboard_v2beta1_DashboardWithAccessInfo(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.LibraryPanel": schema_pkg_apis_dashboard_v2beta1_LibraryPanel(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.LibraryPanelList": schema_pkg_apis_dashboard_v2beta1_LibraryPanelList(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.LibraryPanelSpec": schema_pkg_apis_dashboard_v2beta1_LibraryPanelSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.LibraryPanelStatus": schema_pkg_apis_dashboard_v2beta1_LibraryPanelStatus(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.VersionsQueryOptions": schema_pkg_apis_dashboard_v2beta1_VersionsQueryOptions(ref), } } -func schema_pkg_apis_dashboard_v2alpha2_AnnotationActions(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_AnnotationActions(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -164,7 +166,7 @@ func schema_pkg_apis_dashboard_v2alpha2_AnnotationActions(ref common.ReferenceCa } } -func schema_pkg_apis_dashboard_v2alpha2_AnnotationPermission(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_AnnotationPermission(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -173,13 +175,13 @@ func schema_pkg_apis_dashboard_v2alpha2_AnnotationPermission(ref common.Referenc "dashboard": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.AnnotationActions"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.AnnotationActions"), }, }, "organization": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.AnnotationActions"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.AnnotationActions"), }, }, }, @@ -187,11 +189,11 @@ func schema_pkg_apis_dashboard_v2alpha2_AnnotationPermission(ref common.Referenc }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.AnnotationActions"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.AnnotationActions"}, } } -func schema_pkg_apis_dashboard_v2alpha2_Dashboard(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_Dashboard(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -221,13 +223,13 @@ func schema_pkg_apis_dashboard_v2alpha2_Dashboard(ref common.ReferenceCallback) SchemaProps: spec.SchemaProps{ Description: "Spec is the spec of the Dashboard", Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardSpec"), }, }, "status": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardStatus"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardStatus"), }, }, }, @@ -235,11 +237,11 @@ func schema_pkg_apis_dashboard_v2alpha2_Dashboard(ref common.ReferenceCallback) }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardSpec", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardSpec", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardAccess(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardAccess(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -297,7 +299,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAccess(ref common.ReferenceCall }, "annotationsPermissions": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.AnnotationPermission"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.AnnotationPermission"), }, }, }, @@ -305,11 +307,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAccess(ref common.ReferenceCall }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.AnnotationPermission"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.AnnotationPermission"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardAdHocFilterWithLabels(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardAdHocFilterWithLabels(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -397,7 +399,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAdHocFilterWithLabels(ref commo } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardAdhocVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardAdhocVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -411,22 +413,34 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAdhocVariableKind(ref common.Re Format: "", }, }, + "group": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "datasource": { + SchemaProps: spec.SchemaProps{ + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1AdhocVariableKindDatasource"), + }, + }, "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAdhocVariableSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAdhocVariableSpec"), }, }, }, - Required: []string{"kind", "spec"}, + Required: []string{"kind", "group", "spec"}, }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAdhocVariableSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAdhocVariableSpec", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1AdhocVariableKindDatasource"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardAdhocVariableSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardAdhocVariableSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -440,11 +454,6 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAdhocVariableSpec(ref common.Re Format: "", }, }, - "datasource": { - SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataSourceRef"), - }, - }, "baseFilters": { SchemaProps: spec.SchemaProps{ Type: []string{"array"}, @@ -452,7 +461,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAdhocVariableSpec(ref common.Re Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAdHocFilterWithLabels"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAdHocFilterWithLabels"), }, }, }, @@ -465,7 +474,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAdhocVariableSpec(ref common.Re Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAdHocFilterWithLabels"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAdHocFilterWithLabels"), }, }, }, @@ -478,7 +487,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAdhocVariableSpec(ref common.Re Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardMetricFindValue"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardMetricFindValue"), }, }, }, @@ -522,11 +531,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAdhocVariableSpec(ref common.Re }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAdHocFilterWithLabels", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataSourceRef", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardMetricFindValue"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAdHocFilterWithLabels", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardMetricFindValue"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardAnnotationPanelFilter(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardAnnotationPanelFilter(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -561,7 +570,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAnnotationPanelFilter(ref commo } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardAnnotationQueryKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardAnnotationQueryKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -577,7 +586,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAnnotationQueryKind(ref common. "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAnnotationQuerySpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAnnotationQuerySpec"), }, }, }, @@ -585,24 +594,20 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAnnotationQueryKind(ref common. }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAnnotationQuerySpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAnnotationQuerySpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardAnnotationQuerySpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardAnnotationQuerySpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ Type: []string{"object"}, Properties: map[string]spec.Schema{ - "datasource": { - SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataSourceRef"), - }, - }, "query": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataQueryKind"), + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDataQueryKind"), }, }, "enable": { @@ -641,12 +646,12 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAnnotationQuerySpec(ref common. }, "filter": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAnnotationPanelFilter"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAnnotationPanelFilter"), }, }, "legacyOptions": { SchemaProps: spec.SchemaProps{ - Description: "Catch-all field for datasource-specific properties", + Description: "Catch-all field for datasource-specific properties. Should not be available in as code tooling.", Type: []string{"object"}, AdditionalProperties: &spec.SchemaOrBool{ Allows: true, @@ -660,15 +665,15 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAnnotationQuerySpec(ref common. }, }, }, - Required: []string{"enable", "hide", "iconColor", "name"}, + Required: []string{"query", "enable", "hide", "iconColor", "name"}, }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAnnotationPanelFilter", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataQueryKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataSourceRef"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAnnotationPanelFilter", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDataQueryKind"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardAutoGridLayoutItemKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardAutoGridLayoutItemKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -684,7 +689,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAutoGridLayoutItemKind(ref comm "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAutoGridLayoutItemSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAutoGridLayoutItemSpec"), }, }, }, @@ -692,11 +697,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAutoGridLayoutItemKind(ref comm }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAutoGridLayoutItemSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAutoGridLayoutItemSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardAutoGridLayoutItemSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardAutoGridLayoutItemSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -705,17 +710,17 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAutoGridLayoutItemSpec(ref comm "element": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardElementReference"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardElementReference"), }, }, "repeat": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAutoGridRepeatOptions"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAutoGridRepeatOptions"), }, }, "conditionalRendering": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingGroupKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingGroupKind"), }, }, }, @@ -723,11 +728,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAutoGridLayoutItemSpec(ref comm }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAutoGridRepeatOptions", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingGroupKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardElementReference"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAutoGridRepeatOptions", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingGroupKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardElementReference"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardAutoGridLayoutKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardAutoGridLayoutKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -743,7 +748,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAutoGridLayoutKind(ref common.R "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAutoGridLayoutSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAutoGridLayoutSpec"), }, }, }, @@ -751,11 +756,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAutoGridLayoutKind(ref common.R }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAutoGridLayoutSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAutoGridLayoutSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardAutoGridLayoutSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardAutoGridLayoutSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -806,7 +811,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAutoGridLayoutSpec(ref common.R Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAutoGridLayoutItemKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAutoGridLayoutItemKind"), }, }, }, @@ -817,11 +822,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAutoGridLayoutSpec(ref common.R }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAutoGridLayoutItemKind"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAutoGridLayoutItemKind"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardAutoGridRepeatOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardAutoGridRepeatOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -848,7 +853,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardAutoGridRepeatOptions(ref commo } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingDataKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardConditionalRenderingDataKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -864,7 +869,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingDataKind(re "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingDataSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingDataSpec"), }, }, }, @@ -872,11 +877,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingDataKind(re }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingDataSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingDataSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingDataSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardConditionalRenderingDataSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -896,7 +901,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingDataSpec(re } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingGroupKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardConditionalRenderingGroupKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -912,7 +917,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingGroupKind(r "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingGroupSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingGroupSpec"), }, }, }, @@ -920,11 +925,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingGroupKind(r }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingGroupSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingGroupSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingGroupSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardConditionalRenderingGroupSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -950,7 +955,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingGroupSpec(r Items: &spec.SchemaOrArray{ Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind"), }, }, }, @@ -961,11 +966,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingGroupSpec(r }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingTimeRangeSizeKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardConditionalRenderingTimeRangeSizeKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -981,7 +986,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingTimeRangeSi "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingTimeRangeSizeSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingTimeRangeSizeSpec"), }, }, }, @@ -989,11 +994,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingTimeRangeSi }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingTimeRangeSizeSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingTimeRangeSizeSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingTimeRangeSizeSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardConditionalRenderingTimeRangeSizeSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -1013,7 +1018,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingTimeRangeSi } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardConditionalRenderingVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -1029,7 +1034,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingVariableKin "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingVariableSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingVariableSpec"), }, }, }, @@ -1037,11 +1042,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingVariableKin }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingVariableSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingVariableSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -1049,28 +1054,28 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingVariableKin Properties: map[string]spec.Schema{ "ConditionalRenderingVariableKind": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingVariableKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingVariableKind"), }, }, "ConditionalRenderingDataKind": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingDataKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingDataKind"), }, }, "ConditionalRenderingTimeRangeSizeKind": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingTimeRangeSizeKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingTimeRangeSizeKind"), }, }, }, }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingDataKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingTimeRangeSizeKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingVariableKind"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingDataKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingTimeRangeSizeKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingVariableKind"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingVariableSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardConditionalRenderingVariableSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -1104,7 +1109,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardConditionalRenderingVariableSpe } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardConstantVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardConstantVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -1121,7 +1126,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardConstantVariableKind(ref common "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConstantVariableSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConstantVariableSpec"), }, }, }, @@ -1129,11 +1134,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardConstantVariableKind(ref common }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConstantVariableSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConstantVariableSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardConstantVariableSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardConstantVariableSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -1157,7 +1162,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardConstantVariableSpec(ref common "current": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption"), }, }, "label": { @@ -1191,11 +1196,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardConstantVariableSpec(ref common }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardConversionStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardConversionStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -1233,7 +1238,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardConversionStatus(ref common.Ref } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardCustomVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardCustomVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -1250,7 +1255,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardCustomVariableKind(ref common.R "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardCustomVariableSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardCustomVariableSpec"), }, }, }, @@ -1258,11 +1263,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardCustomVariableKind(ref common.R }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardCustomVariableSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardCustomVariableSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardCustomVariableSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardCustomVariableSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -1286,7 +1291,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardCustomVariableSpec(ref common.R "current": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption"), }, }, "options": { @@ -1296,7 +1301,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardCustomVariableSpec(ref common.R Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption"), }, }, }, @@ -1360,11 +1365,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardCustomVariableSpec(ref common.R }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardDashboardLink(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardDashboardLink(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -1464,7 +1469,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardDashboardLink(ref common.Refere } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardDataLink(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardDataLink(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -1497,7 +1502,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardDataLink(ref common.ReferenceCa } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardDataQueryKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardDataQueryKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -1505,10 +1510,29 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardDataQueryKind(ref common.Refere Properties: map[string]spec.Schema{ "kind": { SchemaProps: spec.SchemaProps{ - Description: "The kind of a DataQueryKind is the datasource type", - Default: "", - Type: []string{"string"}, - Format: "", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "group": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "version": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "datasource": { + SchemaProps: spec.SchemaProps{ + Description: "New type for datasource reference Not creating a new type until we figure out how to handle DS refs for group by, adhoc, and every place that uses DataSourceRef in TS.", + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1DataQueryKindDatasource"), }, }, "spec": { @@ -1526,39 +1550,15 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardDataQueryKind(ref common.Refere }, }, }, - Required: []string{"kind", "spec"}, + Required: []string{"kind", "group", "version", "spec"}, }, }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1DataQueryKindDatasource"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardDataSourceRef(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "type": { - SchemaProps: spec.SchemaProps{ - Description: "The plugin type-id", - Type: []string{"string"}, - Format: "", - }, - }, - "uid": { - SchemaProps: spec.SchemaProps{ - Description: "Specific datasource instance", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - } -} - -func schema_pkg_apis_dashboard_v2alpha2_DashboardDataTransformerConfig(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardDataTransformerConfig(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -1583,7 +1583,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardDataTransformerConfig(ref commo "filter": { SchemaProps: spec.SchemaProps{ Description: "Optional frame matcher. When missing it will be applied to all results", - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardMatcherConfig"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardMatcherConfig"), }, }, "topic": { @@ -1605,11 +1605,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardDataTransformerConfig(ref commo }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardMatcherConfig"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardMatcherConfig"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardDatasourceVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardDatasourceVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -1626,7 +1626,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardDatasourceVariableKind(ref comm "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDatasourceVariableSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDatasourceVariableSpec"), }, }, }, @@ -1634,11 +1634,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardDatasourceVariableKind(ref comm }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDatasourceVariableSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDatasourceVariableSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardDatasourceVariableSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardDatasourceVariableSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -1676,7 +1676,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardDatasourceVariableSpec(ref comm "current": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption"), }, }, "options": { @@ -1686,7 +1686,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardDatasourceVariableSpec(ref comm Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption"), }, }, }, @@ -1750,11 +1750,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardDatasourceVariableSpec(ref comm }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardDynamicConfigValue(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardDynamicConfigValue(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -1780,7 +1780,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardDynamicConfigValue(ref common.R } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardElementReference(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardElementReference(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -1807,7 +1807,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardElementReference(ref common.Ref } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardFieldColor(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardFieldColor(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -1843,7 +1843,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardFieldColor(ref common.Reference } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardFieldConfig(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardFieldConfig(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -1927,7 +1927,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardFieldConfig(ref common.Referenc Items: &spec.SchemaOrArray{ Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap"), }, }, }, @@ -1936,13 +1936,13 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardFieldConfig(ref common.Referenc "thresholds": { SchemaProps: spec.SchemaProps{ Description: "Map numeric values to states", - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardThresholdsConfig"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardThresholdsConfig"), }, }, "color": { SchemaProps: spec.SchemaProps{ Description: "Panel color configuration", - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardFieldColor"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardFieldColor"), }, }, "links": { @@ -1985,11 +1985,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardFieldConfig(ref common.Referenc }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardFieldColor", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardThresholdsConfig", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardFieldColor", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardThresholdsConfig", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardFieldConfigSource(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardFieldConfigSource(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2000,7 +2000,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardFieldConfigSource(ref common.Re SchemaProps: spec.SchemaProps{ Description: "Defaults are the options applied to all fields.", Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardFieldConfig"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardFieldConfig"), }, }, "overrides": { @@ -2011,7 +2011,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardFieldConfigSource(ref common.Re Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardV2alpha2FieldConfigSourceOverrides"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1FieldConfigSourceOverrides"), }, }, }, @@ -2022,11 +2022,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardFieldConfigSource(ref common.Re }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardFieldConfig", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardV2alpha2FieldConfigSourceOverrides"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardFieldConfig", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1FieldConfigSourceOverrides"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutItemKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardGridLayoutItemKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2042,7 +2042,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutItemKind(ref common.R "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutItemSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutItemSpec"), }, }, }, @@ -2050,11 +2050,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutItemKind(ref common.R }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutItemSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutItemSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutItemSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardGridLayoutItemSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2092,12 +2092,12 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutItemSpec(ref common.R SchemaProps: spec.SchemaProps{ Description: "reference to a PanelKind from dashboard.spec.elements Expressed as JSON Schema reference", Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardElementReference"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardElementReference"), }, }, "repeat": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRepeatOptions"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRepeatOptions"), }, }, }, @@ -2105,11 +2105,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutItemSpec(ref common.R }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardElementReference", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRepeatOptions"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardElementReference", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRepeatOptions"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardGridLayoutKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2125,7 +2125,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutKind(ref common.Refer "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutSpec"), }, }, }, @@ -2133,11 +2133,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutKind(ref common.Refer }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2145,33 +2145,33 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutKindOrAutoGridLayoutK Properties: map[string]spec.Schema{ "GridLayoutKind": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutKind"), }, }, "AutoGridLayoutKind": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAutoGridLayoutKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAutoGridLayoutKind"), }, }, "TabsLayoutKind": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTabsLayoutKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTabsLayoutKind"), }, }, "RowsLayoutKind": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRowsLayoutKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRowsLayoutKind"), }, }, }, }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAutoGridLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRowsLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTabsLayoutKind"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAutoGridLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRowsLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTabsLayoutKind"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2179,33 +2179,33 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutKindOrRowsLayoutKindO Properties: map[string]spec.Schema{ "GridLayoutKind": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutKind"), }, }, "RowsLayoutKind": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRowsLayoutKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRowsLayoutKind"), }, }, "AutoGridLayoutKind": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAutoGridLayoutKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAutoGridLayoutKind"), }, }, "TabsLayoutKind": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTabsLayoutKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTabsLayoutKind"), }, }, }, }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAutoGridLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRowsLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTabsLayoutKind"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAutoGridLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRowsLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTabsLayoutKind"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardGridLayoutSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2218,7 +2218,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutSpec(ref common.Refer Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutItemKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutItemKind"), }, }, }, @@ -2229,11 +2229,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardGridLayoutSpec(ref common.Refer }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutItemKind"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutItemKind"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardGroupByVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardGroupByVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2247,22 +2247,34 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardGroupByVariableKind(ref common. Format: "", }, }, + "group": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "datasource": { + SchemaProps: spec.SchemaProps{ + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1GroupByVariableKindDatasource"), + }, + }, "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGroupByVariableSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGroupByVariableSpec"), }, }, }, - Required: []string{"kind", "spec"}, + Required: []string{"kind", "group", "spec"}, }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGroupByVariableSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGroupByVariableSpec", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1GroupByVariableKindDatasource"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardGroupByVariableSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardGroupByVariableSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2276,20 +2288,15 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardGroupByVariableSpec(ref common. Format: "", }, }, - "datasource": { - SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataSourceRef"), - }, - }, "defaultValue": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption"), }, }, "current": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption"), }, }, "options": { @@ -2299,7 +2306,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardGroupByVariableSpec(ref common. Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption"), }, }, }, @@ -2343,11 +2350,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardGroupByVariableSpec(ref common. }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataSourceRef", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardIntervalVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardIntervalVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2364,7 +2371,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardIntervalVariableKind(ref common "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardIntervalVariableSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardIntervalVariableSpec"), }, }, }, @@ -2372,11 +2379,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardIntervalVariableKind(ref common }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardIntervalVariableSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardIntervalVariableSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardIntervalVariableSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardIntervalVariableSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2400,7 +2407,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardIntervalVariableSpec(ref common "current": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption"), }, }, "options": { @@ -2410,7 +2417,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardIntervalVariableSpec(ref common Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption"), }, }, }, @@ -2475,11 +2482,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardIntervalVariableSpec(ref common }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardJSONCodec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardJSONCodec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2490,7 +2497,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardJSONCodec(ref common.ReferenceC } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardLibraryPanelKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardLibraryPanelKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2506,7 +2513,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardLibraryPanelKind(ref common.Ref "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardLibraryPanelKindSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardLibraryPanelKindSpec"), }, }, }, @@ -2514,11 +2521,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardLibraryPanelKind(ref common.Ref }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardLibraryPanelKindSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardLibraryPanelKindSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardLibraryPanelKindSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardLibraryPanelKindSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2543,7 +2550,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardLibraryPanelKindSpec(ref common "libraryPanel": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardLibraryPanelRef"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardLibraryPanelRef"), }, }, }, @@ -2551,11 +2558,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardLibraryPanelKindSpec(ref common }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardLibraryPanelRef"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardLibraryPanelRef"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardLibraryPanelRef(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardLibraryPanelRef(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2585,7 +2592,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardLibraryPanelRef(ref common.Refe } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardList(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardList(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2618,7 +2625,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardList(ref common.ReferenceCallba Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.Dashboard"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.Dashboard"), }, }, }, @@ -2629,11 +2636,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardList(ref common.ReferenceCallba }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.Dashboard", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.Dashboard", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardMatcherConfig(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardMatcherConfig(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2662,7 +2669,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardMatcherConfig(ref common.Refere } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardMetadata(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardMetadata(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2758,7 +2765,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardMetadata(ref common.ReferenceCa } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardMetricFindValue(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardMetricFindValue(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2774,7 +2781,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardMetricFindValue(ref common.Refe }, "value": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardStringOrFloat64"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardStringOrFloat64"), }, }, "group": { @@ -2794,11 +2801,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardMetricFindValue(ref common.Refe }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardStringOrFloat64"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardStringOrFloat64"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardPanelKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardPanelKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2814,7 +2821,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardPanelKind(ref common.ReferenceC "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardPanelSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardPanelSpec"), }, }, }, @@ -2822,11 +2829,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardPanelKind(ref common.ReferenceC }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardPanelSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardPanelSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardPanelKindOrLibraryPanelKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardPanelKindOrLibraryPanelKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2834,23 +2841,23 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardPanelKindOrLibraryPanelKind(ref Properties: map[string]spec.Schema{ "PanelKind": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardPanelKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardPanelKind"), }, }, "LibraryPanelKind": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardLibraryPanelKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardLibraryPanelKind"), }, }, }, }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardLibraryPanelKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardPanelKind"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardLibraryPanelKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardPanelKind"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardPanelQueryKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardPanelQueryKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2866,7 +2873,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardPanelQueryKind(ref common.Refer "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardPanelQuerySpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardPanelQuerySpec"), }, }, }, @@ -2874,11 +2881,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardPanelQueryKind(ref common.Refer }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardPanelQuerySpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardPanelQuerySpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardPanelQuerySpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardPanelQuerySpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2887,12 +2894,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardPanelQuerySpec(ref common.Refer "query": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataQueryKind"), - }, - }, - "datasource": { - SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataSourceRef"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDataQueryKind"), }, }, "refId": { @@ -2914,11 +2916,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardPanelQuerySpec(ref common.Refer }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataQueryKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataSourceRef"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDataQueryKind"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardPanelSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardPanelSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -2952,7 +2954,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardPanelSpec(ref common.ReferenceC Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataLink"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDataLink"), }, }, }, @@ -2961,13 +2963,13 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardPanelSpec(ref common.ReferenceC "data": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardQueryGroupKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardQueryGroupKind"), }, }, "vizConfig": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVizConfigKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVizConfigKind"), }, }, "transparent": { @@ -2981,11 +2983,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardPanelSpec(ref common.ReferenceC }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataLink", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardQueryGroupKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVizConfigKind"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDataLink", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardQueryGroupKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVizConfigKind"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardQueryGroupKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardQueryGroupKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3001,7 +3003,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardQueryGroupKind(ref common.Refer "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardQueryGroupSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardQueryGroupSpec"), }, }, }, @@ -3009,11 +3011,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardQueryGroupKind(ref common.Refer }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardQueryGroupSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardQueryGroupSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardQueryGroupSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardQueryGroupSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3026,7 +3028,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardQueryGroupSpec(ref common.Refer Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardPanelQueryKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardPanelQueryKind"), }, }, }, @@ -3039,7 +3041,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardQueryGroupSpec(ref common.Refer Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTransformationKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTransformationKind"), }, }, }, @@ -3048,7 +3050,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardQueryGroupSpec(ref common.Refer "queryOptions": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardQueryOptionsSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardQueryOptionsSpec"), }, }, }, @@ -3056,11 +3058,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardQueryGroupSpec(ref common.Refer }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardPanelQueryKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardQueryOptionsSpec", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTransformationKind"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardPanelQueryKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardQueryOptionsSpec", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTransformationKind"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardQueryOptionsSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardQueryOptionsSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3114,7 +3116,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardQueryOptionsSpec(ref common.Ref } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardQueryVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardQueryVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3131,7 +3133,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardQueryVariableKind(ref common.Re "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardQueryVariableSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardQueryVariableSpec"), }, }, }, @@ -3139,11 +3141,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardQueryVariableKind(ref common.Re }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardQueryVariableSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardQueryVariableSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3151,53 +3153,53 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardQueryVariableKindOrTextVariable Properties: map[string]spec.Schema{ "QueryVariableKind": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardQueryVariableKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardQueryVariableKind"), }, }, "TextVariableKind": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTextVariableKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTextVariableKind"), }, }, "ConstantVariableKind": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConstantVariableKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConstantVariableKind"), }, }, "DatasourceVariableKind": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDatasourceVariableKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDatasourceVariableKind"), }, }, "IntervalVariableKind": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardIntervalVariableKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardIntervalVariableKind"), }, }, "CustomVariableKind": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardCustomVariableKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardCustomVariableKind"), }, }, "GroupByVariableKind": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGroupByVariableKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGroupByVariableKind"), }, }, "AdhocVariableKind": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAdhocVariableKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAdhocVariableKind"), }, }, }, }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAdhocVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConstantVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardCustomVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDatasourceVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGroupByVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardIntervalVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardQueryVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTextVariableKind"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAdhocVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConstantVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardCustomVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDatasourceVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGroupByVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardIntervalVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardQueryVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTextVariableKind"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardQueryVariableSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardQueryVariableSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3214,7 +3216,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardQueryVariableSpec(ref common.Re "current": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption"), }, }, "label": { @@ -3250,15 +3252,10 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardQueryVariableSpec(ref common.Re Format: "", }, }, - "datasource": { - SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataSourceRef"), - }, - }, "query": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataQueryKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDataQueryKind"), }, }, "regex": { @@ -3288,7 +3285,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardQueryVariableSpec(ref common.Re Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption"), }, }, }, @@ -3334,7 +3331,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardQueryVariableSpec(ref common.Re Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption"), }, }, }, @@ -3351,11 +3348,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardQueryVariableSpec(ref common.Re }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataQueryKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataSourceRef", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDataQueryKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardRangeMap(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardRangeMap(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3373,7 +3370,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardRangeMap(ref common.ReferenceCa SchemaProps: spec.SchemaProps{ Description: "Range to match against and the result to apply when the value is within the range", Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardV2alpha2RangeMapOptions"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1RangeMapOptions"), }, }, }, @@ -3381,11 +3378,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardRangeMap(ref common.ReferenceCa }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardV2alpha2RangeMapOptions"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1RangeMapOptions"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardRegexMap(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardRegexMap(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3403,7 +3400,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardRegexMap(ref common.ReferenceCa SchemaProps: spec.SchemaProps{ Description: "Regular expression to match against and the result to apply when the value matches the regex", Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardV2alpha2RegexMapOptions"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1RegexMapOptions"), }, }, }, @@ -3411,11 +3408,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardRegexMap(ref common.ReferenceCa }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardV2alpha2RegexMapOptions"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1RegexMapOptions"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardRepeatOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardRepeatOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3454,7 +3451,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardRepeatOptions(ref common.Refere } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardRowRepeatOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardRowRepeatOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3481,7 +3478,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardRowRepeatOptions(ref common.Ref } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardRowsLayoutKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardRowsLayoutKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3497,7 +3494,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardRowsLayoutKind(ref common.Refer "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRowsLayoutSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRowsLayoutSpec"), }, }, }, @@ -3505,11 +3502,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardRowsLayoutKind(ref common.Refer }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRowsLayoutSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRowsLayoutSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardRowsLayoutRowKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardRowsLayoutRowKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3525,7 +3522,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardRowsLayoutRowKind(ref common.Re "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRowsLayoutRowSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRowsLayoutRowSpec"), }, }, }, @@ -3533,11 +3530,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardRowsLayoutRowKind(ref common.Re }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRowsLayoutRowSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRowsLayoutRowSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardRowsLayoutRowSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardRowsLayoutRowSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3569,17 +3566,17 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardRowsLayoutRowSpec(ref common.Re }, "conditionalRendering": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingGroupKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingGroupKind"), }, }, "repeat": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRowRepeatOptions"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRowRepeatOptions"), }, }, "layout": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind"), }, }, }, @@ -3587,11 +3584,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardRowsLayoutRowSpec(ref common.Re }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingGroupKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRowRepeatOptions"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingGroupKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRowRepeatOptions"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardRowsLayoutSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardRowsLayoutSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3604,7 +3601,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardRowsLayoutSpec(ref common.Refer Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRowsLayoutRowKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRowsLayoutRowKind"), }, }, }, @@ -3615,11 +3612,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardRowsLayoutSpec(ref common.Refer }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRowsLayoutRowKind"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRowsLayoutRowKind"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3632,7 +3629,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardSpec(ref common.ReferenceCallba Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAnnotationQueryKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAnnotationQueryKind"), }, }, }, @@ -3667,7 +3664,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardSpec(ref common.ReferenceCallba Allows: true, Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardPanelKindOrLibraryPanelKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardPanelKindOrLibraryPanelKind"), }, }, }, @@ -3675,7 +3672,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardSpec(ref common.ReferenceCallba }, "layout": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind"), }, }, "links": { @@ -3686,7 +3683,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardSpec(ref common.ReferenceCallba Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDashboardLink"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDashboardLink"), }, }, }, @@ -3732,7 +3729,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardSpec(ref common.ReferenceCallba "timeSettings": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTimeSettingsSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTimeSettingsSpec"), }, }, "title": { @@ -3750,7 +3747,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardSpec(ref common.ReferenceCallba Items: &spec.SchemaOrArray{ Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind"), }, }, }, @@ -3761,11 +3758,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardSpec(ref common.ReferenceCallba }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAnnotationQueryKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDashboardLink", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardPanelKindOrLibraryPanelKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTimeSettingsSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAnnotationQueryKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDashboardLink", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardPanelKindOrLibraryPanelKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTimeSettingsSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardSpecialValueMap(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardSpecialValueMap(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3782,7 +3779,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardSpecialValueMap(ref common.Refe "options": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardV2alpha2SpecialValueMapOptions"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1SpecialValueMapOptions"), }, }, }, @@ -3790,11 +3787,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardSpecialValueMap(ref common.Refe }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardV2alpha2SpecialValueMapOptions"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1SpecialValueMapOptions"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3803,18 +3800,18 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardStatus(ref common.ReferenceCall "conversion": { SchemaProps: spec.SchemaProps{ Description: "Optional conversion status.", - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConversionStatus"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConversionStatus"), }, }, }, }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConversionStatus"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConversionStatus"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardStringOrArrayOfString(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardStringOrArrayOfString(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3846,7 +3843,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardStringOrArrayOfString(ref commo } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardStringOrFloat64(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardStringOrFloat64(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3870,7 +3867,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardStringOrFloat64(ref common.Refe } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardTabRepeatOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardTabRepeatOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3897,7 +3894,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardTabRepeatOptions(ref common.Ref } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardTabsLayoutKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardTabsLayoutKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3913,7 +3910,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardTabsLayoutKind(ref common.Refer "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTabsLayoutSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTabsLayoutSpec"), }, }, }, @@ -3921,11 +3918,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardTabsLayoutKind(ref common.Refer }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTabsLayoutSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTabsLayoutSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardTabsLayoutSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardTabsLayoutSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3938,7 +3935,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardTabsLayoutSpec(ref common.Refer Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTabsLayoutTabKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTabsLayoutTabKind"), }, }, }, @@ -3949,11 +3946,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardTabsLayoutSpec(ref common.Refer }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTabsLayoutTabKind"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTabsLayoutTabKind"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardTabsLayoutTabKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardTabsLayoutTabKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3969,7 +3966,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardTabsLayoutTabKind(ref common.Re "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTabsLayoutTabSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTabsLayoutTabSpec"), }, }, }, @@ -3977,11 +3974,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardTabsLayoutTabKind(ref common.Re }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTabsLayoutTabSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTabsLayoutTabSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardTabsLayoutTabSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardTabsLayoutTabSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3995,17 +3992,17 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardTabsLayoutTabSpec(ref common.Re }, "layout": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind"), }, }, "conditionalRendering": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingGroupKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingGroupKind"), }, }, "repeat": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTabRepeatOptions"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTabRepeatOptions"), }, }, }, @@ -4013,11 +4010,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardTabsLayoutTabSpec(ref common.Re }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardConditionalRenderingGroupKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTabRepeatOptions"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardConditionalRenderingGroupKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTabRepeatOptions"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardTextVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardTextVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -4034,7 +4031,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardTextVariableKind(ref common.Ref "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTextVariableSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTextVariableSpec"), }, }, }, @@ -4042,11 +4039,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardTextVariableKind(ref common.Ref }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTextVariableSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTextVariableSpec"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardTextVariableSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardTextVariableSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -4063,7 +4060,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardTextVariableSpec(ref common.Ref "current": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption"), }, }, "query": { @@ -4104,11 +4101,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardTextVariableSpec(ref common.Ref }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVariableOption"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVariableOption"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardThreshold(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardThreshold(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -4135,7 +4132,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardThreshold(ref common.ReferenceC } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardThresholdsConfig(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardThresholdsConfig(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -4155,7 +4152,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardThresholdsConfig(ref common.Ref Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardThreshold"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardThreshold"), }, }, }, @@ -4166,11 +4163,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardThresholdsConfig(ref common.Ref }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardThreshold"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardThreshold"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardTimeRangeOption(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardTimeRangeOption(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -4204,7 +4201,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardTimeRangeOption(ref common.Refe } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardTimeSettingsSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardTimeSettingsSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -4265,7 +4262,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardTimeSettingsSpec(ref common.Ref Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTimeRangeOption"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTimeRangeOption"), }, }, }, @@ -4306,11 +4303,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardTimeSettingsSpec(ref common.Ref }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardTimeRangeOption"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTimeRangeOption"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardTransformationKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardTransformationKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -4327,7 +4324,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardTransformationKind(ref common.R "spec": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataTransformerConfig"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDataTransformerConfig"), }, }, }, @@ -4335,11 +4332,47 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardTransformationKind(ref common.R }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDataTransformerConfig"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDataTransformerConfig"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardV2alpha2FieldConfigSourceOverrides(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardV2beta1AdhocVariableKindDatasource(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "name": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + } +} + +func schema_pkg_apis_dashboard_v2beta1_DashboardV2beta1DataQueryKindDatasource(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "name": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + } +} + +func schema_pkg_apis_dashboard_v2beta1_DashboardV2beta1FieldConfigSourceOverrides(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -4348,7 +4381,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardV2alpha2FieldConfigSourceOverri "matcher": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardMatcherConfig"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardMatcherConfig"), }, }, "properties": { @@ -4358,7 +4391,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardV2alpha2FieldConfigSourceOverri Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDynamicConfigValue"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDynamicConfigValue"), }, }, }, @@ -4369,11 +4402,29 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardV2alpha2FieldConfigSourceOverri }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardDynamicConfigValue", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardMatcherConfig"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDynamicConfigValue", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardMatcherConfig"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardV2alpha2RangeMapOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardV2beta1GroupByVariableKindDatasource(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "name": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + } +} + +func schema_pkg_apis_dashboard_v2beta1_DashboardV2beta1RangeMapOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -4397,7 +4448,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardV2alpha2RangeMapOptions(ref com SchemaProps: spec.SchemaProps{ Description: "Config to apply when the value is within the range", Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardValueMappingResult"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardValueMappingResult"), }, }, }, @@ -4405,11 +4456,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardV2alpha2RangeMapOptions(ref com }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardValueMappingResult"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardValueMappingResult"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardV2alpha2RegexMapOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardV2beta1RegexMapOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -4427,7 +4478,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardV2alpha2RegexMapOptions(ref com SchemaProps: spec.SchemaProps{ Description: "Config to apply when the value matches the regex", Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardValueMappingResult"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardValueMappingResult"), }, }, }, @@ -4435,11 +4486,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardV2alpha2RegexMapOptions(ref com }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardValueMappingResult"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardValueMappingResult"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardV2alpha2SpecialValueMapOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardV2beta1SpecialValueMapOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -4457,7 +4508,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardV2alpha2SpecialValueMapOptions( SchemaProps: spec.SchemaProps{ Description: "Config to apply when the value matches the special value", Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardValueMappingResult"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardValueMappingResult"), }, }, }, @@ -4465,11 +4516,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardV2alpha2SpecialValueMapOptions( }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardValueMappingResult"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardValueMappingResult"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardValueMap(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardValueMap(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -4492,7 +4543,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardValueMap(ref common.ReferenceCa Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardValueMappingResult"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardValueMappingResult"), }, }, }, @@ -4503,11 +4554,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardValueMap(ref common.ReferenceCa }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardValueMappingResult"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardValueMappingResult"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -4515,33 +4566,33 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardValueMapOrRangeMapOrRegexMapOrS Properties: map[string]spec.Schema{ "ValueMap": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardValueMap"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardValueMap"), }, }, "RangeMap": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRangeMap"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRangeMap"), }, }, "RegexMap": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRegexMap"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRegexMap"), }, }, "SpecialValueMap": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardSpecialValueMap"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardSpecialValueMap"), }, }, }, }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRangeMap", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardRegexMap", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardSpecialValueMap", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardValueMap"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRangeMap", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardRegexMap", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardSpecialValueMap", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardValueMap"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardValueMappingResult(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardValueMappingResult(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -4582,7 +4633,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardValueMappingResult(ref common.R } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardVariableOption(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardVariableOption(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -4599,13 +4650,13 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardVariableOption(ref common.Refer "text": { SchemaProps: spec.SchemaProps{ Description: "Text to be displayed for the option", - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardStringOrArrayOfString"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardStringOrArrayOfString"), }, }, "value": { SchemaProps: spec.SchemaProps{ Description: "Value of the option", - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardStringOrArrayOfString"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardStringOrArrayOfString"), }, }, }, @@ -4613,11 +4664,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardVariableOption(ref common.Refer }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardStringOrArrayOfString"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardStringOrArrayOfString"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardVersionInfo(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardVersionInfo(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -4667,7 +4718,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardVersionInfo(ref common.Referenc } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardVersionList(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardVersionList(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -4700,7 +4751,7 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardVersionList(ref common.Referenc Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVersionInfo"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVersionInfo"), }, }, }, @@ -4711,11 +4762,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardVersionList(ref common.Referenc }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVersionInfo", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVersionInfo", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardVizConfigKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardVizConfigKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -4723,41 +4774,48 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardVizConfigKind(ref common.Refere Properties: map[string]spec.Schema{ "kind": { SchemaProps: spec.SchemaProps{ - Description: "The kind of a VizConfigKind is the plugin ID", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "group": { + SchemaProps: spec.SchemaProps{ + Description: "The group is the plugin ID", Default: "", Type: []string{"string"}, Format: "", }, }, - "spec": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVizConfigSpec"), - }, - }, - }, - Required: []string{"kind", "spec"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardVizConfigSpec"}, - } -} - -func schema_pkg_apis_dashboard_v2alpha2_DashboardVizConfigSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "--- Kinds ---", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "pluginVersion": { + "version": { SchemaProps: spec.SchemaProps{ Default: "", Type: []string{"string"}, Format: "", }, }, + "spec": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVizConfigSpec"), + }, + }, + }, + Required: []string{"kind", "group", "version", "spec"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardVizConfigSpec"}, + } +} + +func schema_pkg_apis_dashboard_v2beta1_DashboardVizConfigSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "--- Kinds ---", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ "options": { SchemaProps: spec.SchemaProps{ Type: []string{"object"}, @@ -4775,19 +4833,19 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardVizConfigSpec(ref common.Refere "fieldConfig": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardFieldConfigSource"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardFieldConfigSource"), }, }, }, - Required: []string{"pluginVersion", "options", "fieldConfig"}, + Required: []string{"options", "fieldConfig"}, }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardFieldConfigSource"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardFieldConfigSource"}, } } -func schema_pkg_apis_dashboard_v2alpha2_DashboardWithAccessInfo(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_DashboardWithAccessInfo(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -4818,19 +4876,19 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardWithAccessInfo(ref common.Refer SchemaProps: spec.SchemaProps{ Description: "Spec is the spec of the Dashboard", Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardSpec"), }, }, "status": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardStatus"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardStatus"), }, }, "access": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAccess"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAccess"), }, }, }, @@ -4838,11 +4896,11 @@ func schema_pkg_apis_dashboard_v2alpha2_DashboardWithAccessInfo(ref common.Refer }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardAccess", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardSpec", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.DashboardStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAccess", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardSpec", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, } } -func schema_pkg_apis_dashboard_v2alpha2_LibraryPanel(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_LibraryPanel(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -4873,13 +4931,13 @@ func schema_pkg_apis_dashboard_v2alpha2_LibraryPanel(ref common.ReferenceCallbac SchemaProps: spec.SchemaProps{ Description: "Panel properties", Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.LibraryPanelSpec"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.LibraryPanelSpec"), }, }, "status": { SchemaProps: spec.SchemaProps{ Description: "Status will show errors", - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.LibraryPanelStatus"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.LibraryPanelStatus"), }, }, }, @@ -4887,11 +4945,11 @@ func schema_pkg_apis_dashboard_v2alpha2_LibraryPanel(ref common.ReferenceCallbac }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.LibraryPanelSpec", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.LibraryPanelStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.LibraryPanelSpec", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.LibraryPanelStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, } } -func schema_pkg_apis_dashboard_v2alpha2_LibraryPanelList(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_LibraryPanelList(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -4924,7 +4982,7 @@ func schema_pkg_apis_dashboard_v2alpha2_LibraryPanelList(ref common.ReferenceCal Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.LibraryPanel"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.LibraryPanel"), }, }, }, @@ -4935,11 +4993,11 @@ func schema_pkg_apis_dashboard_v2alpha2_LibraryPanelList(ref common.ReferenceCal }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2.LibraryPanel", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.LibraryPanel", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, } } -func schema_pkg_apis_dashboard_v2alpha2_LibraryPanelSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_LibraryPanelSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -5019,7 +5077,7 @@ func schema_pkg_apis_dashboard_v2alpha2_LibraryPanelSpec(ref common.ReferenceCal } } -func schema_pkg_apis_dashboard_v2alpha2_LibraryPanelStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_LibraryPanelStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -5054,7 +5112,7 @@ func schema_pkg_apis_dashboard_v2alpha2_LibraryPanelStatus(ref common.ReferenceC } } -func schema_pkg_apis_dashboard_v2alpha2_VersionsQueryOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2beta1_VersionsQueryOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi_violation_exceptions.list b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi_violation_exceptions.list new file mode 100644 index 00000000000..d516d5c8748 --- /dev/null +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi_violation_exceptions.list @@ -0,0 +1,67 @@ +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardAdHocFilterWithLabels,ValueLabels +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardAdHocFilterWithLabels,Values +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardAdhocVariableSpec,BaseFilters +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardAdhocVariableSpec,DefaultKeys +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardAdhocVariableSpec,Filters +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardAnnotationPanelFilter,Ids +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardAutoGridLayoutSpec,Items +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardConditionalRenderingGroupSpec,Items +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardCustomVariableSpec,Options +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardDashboardLink,Tags +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardDatasourceVariableSpec,Options +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardFieldConfig,Links +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardFieldConfig,Mappings +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardFieldConfigSource,Overrides +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardGridLayoutSpec,Items +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardGroupByVariableSpec,Options +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardIntervalVariableSpec,Options +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardMetadata,Finalizers +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardPanelSpec,Links +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardQueryGroupSpec,Queries +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardQueryGroupSpec,Transformations +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardQueryVariableSpec,Options +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardQueryVariableSpec,StaticOptions +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardRowsLayoutSpec,Rows +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardSpec,Annotations +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardSpec,Links +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardSpec,Tags +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardSpec,Variables +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardStringOrArrayOfString,ArrayOfString +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardTabsLayoutSpec,Tabs +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardThresholdsConfig,Steps +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardTimeSettingsSpec,AutoRefreshIntervals +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardTimeSettingsSpec,QuickRanges +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardV2beta1FieldConfigSourceOverrides,Properties +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,LibraryPanelStatus,Warnings +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind,ConditionalRenderingDataKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind,ConditionalRenderingTimeRangeSizeKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind,ConditionalRenderingVariableKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind,AutoGridLayoutKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind,GridLayoutKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind,RowsLayoutKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind,TabsLayoutKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind,AutoGridLayoutKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind,GridLayoutKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind,RowsLayoutKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind,TabsLayoutKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardIntervalVariableSpec,AutoCount +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardIntervalVariableSpec,AutoMin +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardPanelKindOrLibraryPanelKind,LibraryPanelKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardPanelKindOrLibraryPanelKind,PanelKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,AdhocVariableKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,ConstantVariableKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,CustomVariableKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,DatasourceVariableKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,GroupByVariableKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,IntervalVariableKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,QueryVariableKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,TextVariableKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardStringOrArrayOfString,ArrayOfString +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardStringOrArrayOfString,String +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardStringOrFloat64,Float64 +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardStringOrFloat64,String +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap,RangeMap +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap,RegexMap +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap,SpecialValueMap +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap,ValueMap +API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardList,ListMeta diff --git a/apps/dashboard/pkg/apis/dashboard_manifest.go b/apps/dashboard/pkg/apis/dashboard_manifest.go index 2a3c0408c29..9154093d2a8 100644 --- a/apps/dashboard/pkg/apis/dashboard_manifest.go +++ b/apps/dashboard/pkg/apis/dashboard_manifest.go @@ -15,11 +15,9 @@ import ( v0alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" v1beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" v2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" - v2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" + v2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" ) -var () - var appManifestData = app.ManifestData{ AppName: "dashboard", Group: "dashboard.grafana.app", @@ -64,7 +62,7 @@ var appManifestData = app.ManifestData{ }, { - Name: "v2alpha2", + Name: "v2beta1", Served: true, Kinds: []app.ManifestVersionKind{ { @@ -90,7 +88,7 @@ var kindVersionToGoType = map[string]resource.Kind{ "Dashboard/v0alpha1": v0alpha1.DashboardKind(), "Dashboard/v1beta1": v1beta1.DashboardKind(), "Dashboard/v2alpha1": v2alpha1.DashboardKind(), - "Dashboard/v2alpha2": v2alpha2.DashboardKind(), + "Dashboard/v2beta1": v2beta1.DashboardKind(), } // ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists. diff --git a/apps/dashboard/pkg/migration/conversion/conversion.go b/apps/dashboard/pkg/migration/conversion/conversion.go index 8fb35a45dbe..fb78859fc3c 100644 --- a/apps/dashboard/pkg/migration/conversion/conversion.go +++ b/apps/dashboard/pkg/migration/conversion/conversion.go @@ -7,7 +7,7 @@ import ( dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" dashv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" - dashv2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" + dashv2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" ) func RegisterConversions(s *runtime.Scheme) error { @@ -22,8 +22,8 @@ func RegisterConversions(s *runtime.Scheme) error { }); err != nil { return err } - if err := s.AddConversionFunc((*dashv0.Dashboard)(nil), (*dashv2alpha2.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error { - return Convert_V0_to_V2alpha2(a.(*dashv0.Dashboard), b.(*dashv2alpha2.Dashboard), scope) + if err := s.AddConversionFunc((*dashv0.Dashboard)(nil), (*dashv2beta1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_V0_to_V2beta1(a.(*dashv0.Dashboard), b.(*dashv2beta1.Dashboard), scope) }); err != nil { return err } @@ -39,8 +39,8 @@ func RegisterConversions(s *runtime.Scheme) error { }); err != nil { return err } - if err := s.AddConversionFunc((*dashv1.Dashboard)(nil), (*dashv2alpha2.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error { - return Convert_V1_to_V2alpha2(a.(*dashv1.Dashboard), b.(*dashv2alpha2.Dashboard), scope) + if err := s.AddConversionFunc((*dashv1.Dashboard)(nil), (*dashv2beta1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_V1_to_V2beta1(a.(*dashv1.Dashboard), b.(*dashv2beta1.Dashboard), scope) }); err != nil { return err } @@ -56,25 +56,25 @@ func RegisterConversions(s *runtime.Scheme) error { }); err != nil { return err } - if err := s.AddConversionFunc((*dashv2alpha1.Dashboard)(nil), (*dashv2alpha2.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error { - return Convert_V2alpha1_to_V2alpha2(a.(*dashv2alpha1.Dashboard), b.(*dashv2alpha2.Dashboard), scope) + if err := s.AddConversionFunc((*dashv2alpha1.Dashboard)(nil), (*dashv2beta1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_V2alpha1_to_V2beta1(a.(*dashv2alpha1.Dashboard), b.(*dashv2beta1.Dashboard), scope) }); err != nil { return err } - // v2alpha2 conversions - if err := s.AddConversionFunc((*dashv2alpha2.Dashboard)(nil), (*dashv0.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error { - return Convert_V2alpha2_to_V0(a.(*dashv2alpha2.Dashboard), b.(*dashv0.Dashboard), scope) + // v2beta1 conversions + if err := s.AddConversionFunc((*dashv2beta1.Dashboard)(nil), (*dashv0.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_V2beta1_to_V0(a.(*dashv2beta1.Dashboard), b.(*dashv0.Dashboard), scope) }); err != nil { return err } - if err := s.AddConversionFunc((*dashv2alpha2.Dashboard)(nil), (*dashv1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error { - return Convert_V2alpha2_to_V1(a.(*dashv2alpha2.Dashboard), b.(*dashv1.Dashboard), scope) + if err := s.AddConversionFunc((*dashv2beta1.Dashboard)(nil), (*dashv1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_V2beta1_to_V1(a.(*dashv2beta1.Dashboard), b.(*dashv1.Dashboard), scope) }); err != nil { return err } - if err := s.AddConversionFunc((*dashv2alpha2.Dashboard)(nil), (*dashv2alpha1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error { - return Convert_V2alpha2_to_V2alpha1(a.(*dashv2alpha2.Dashboard), b.(*dashv2alpha1.Dashboard), scope) + if err := s.AddConversionFunc((*dashv2beta1.Dashboard)(nil), (*dashv2alpha1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_V2beta1_to_V2alpha1(a.(*dashv2beta1.Dashboard), b.(*dashv2alpha1.Dashboard), scope) }); err != nil { return err } diff --git a/apps/dashboard/pkg/migration/conversion/conversion_test.go b/apps/dashboard/pkg/migration/conversion/conversion_test.go index 0efd0ab6205..ce59a99f53b 100644 --- a/apps/dashboard/pkg/migration/conversion/conversion_test.go +++ b/apps/dashboard/pkg/migration/conversion/conversion_test.go @@ -1,7 +1,10 @@ package conversion import ( + "encoding/json" "fmt" + "os" + "path/filepath" "strings" "testing" @@ -9,10 +12,11 @@ import ( v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "github.com/grafana/grafana/apps/dashboard/pkg/apis" dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" dashv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" - dashv2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" + dashv2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" "github.com/grafana/grafana/apps/dashboard/pkg/migration" "github.com/grafana/grafana/apps/dashboard/pkg/migration/testutil" common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" @@ -27,7 +31,7 @@ func TestConversionMatrixExist(t *testing.T) { &dashv0.Dashboard{Spec: common.Unstructured{Object: map[string]any{"title": "dashboardV0"}}}, &dashv1.Dashboard{Spec: common.Unstructured{Object: map[string]any{"title": "dashboardV1"}}}, &dashv2alpha1.Dashboard{Spec: dashv2alpha1.DashboardSpec{Title: "dashboardV2alpha1"}}, - &dashv2alpha2.Dashboard{Spec: dashv2alpha2.DashboardSpec{Title: "dashboardV2alpha2"}}, + &dashv2beta1.Dashboard{Spec: dashv2beta1.DashboardSpec{Title: "dashboardV2beta1"}}, } scheme := runtime.NewScheme() @@ -69,3 +73,150 @@ func TestDeepCopyValid(t *testing.T) { metaCopy.SetFolder("XYZ") require.Equal(t, "f1", meta1.GetFolder()) // 💣💣💣 } + +func TestDashboardConversionToAllVersions(t *testing.T) { + // Initialize the migrator with a test data source provider + migration.Initialize(testutil.GetTestProvider()) + + // Set up conversion scheme + scheme := runtime.NewScheme() + err := RegisterConversions(scheme) + require.NoError(t, err) + + // Read all files from input directory + files, err := os.ReadDir(filepath.Join("testdata", "input")) + require.NoError(t, err, "Failed to read input directory") + + for _, file := range files { + if file.IsDir() { + continue + } + + t.Run(fmt.Sprintf("Convert_%s", file.Name()), func(t *testing.T) { + // Read input dashboard file + inputFile := filepath.Join("testdata", "input", file.Name()) + // ignore gosec G304 as this function is only used in the test process + //nolint:gosec + inputData, err := os.ReadFile(inputFile) + require.NoError(t, err, "Failed to read input file") + + // Parse the input dashboard to get its version + var rawDash map[string]interface{} + err = json.Unmarshal(inputData, &rawDash) + require.NoError(t, err, "Failed to unmarshal dashboard JSON") + + // Extract apiVersion + apiVersion, ok := rawDash["apiVersion"].(string) + require.True(t, ok, "apiVersion not found or not a string") + + // Parse group and version from apiVersion (format: "group/version") + parts := strings.Split(apiVersion, "/") + require.Equal(t, 2, len(parts), "apiVersion should be in format 'group/version'") + sourceVersion := parts[1] + + // Create source object based on version + var sourceDash v1.Object + switch sourceVersion { + case "v0alpha1": + var dash dashv0.Dashboard + err = json.Unmarshal(inputData, &dash) + sourceDash = &dash + case "v1beta1": + var dash dashv1.Dashboard + err = json.Unmarshal(inputData, &dash) + sourceDash = &dash + case "v2alpha1": + var dash dashv2alpha1.Dashboard + err = json.Unmarshal(inputData, &dash) + sourceDash = &dash + case "v2beta1": + var dash dashv2beta1.Dashboard + err = json.Unmarshal(inputData, &dash) + sourceDash = &dash + default: + t.Fatalf("Unsupported source version: %s", sourceVersion) + } + require.NoError(t, err, "Failed to unmarshal dashboard into typed object") + + // Ensure output directory exists + outDir := filepath.Join("testdata", "output") + // ignore gosec G301 as this function is only used in the test process + //nolint:gosec + err = os.MkdirAll(outDir, 0755) + require.NoError(t, err, "Failed to create output directory") + + // Get target versions from the dashboard manifest + manifest := apis.LocalManifest() + targetVersions := make(map[string]runtime.Object) + + // Get original filename without extension + originalName := strings.TrimSuffix(file.Name(), ".json") + + // Get all Dashboard versions from the manifest + for _, kind := range manifest.ManifestData.Kinds() { + if kind.Kind == "Dashboard" { + for _, version := range kind.Versions { + // Skip converting to the same version + if version.VersionName == sourceVersion { + continue + } + + filename := fmt.Sprintf("%s.%s.json", originalName, version.VersionName) + + // Create target object based on version + switch version.VersionName { + case "v0alpha1": + targetVersions[filename] = &dashv0.Dashboard{} + case "v1beta1": + targetVersions[filename] = &dashv1.Dashboard{} + case "v2alpha1": + targetVersions[filename] = &dashv2alpha1.Dashboard{} + case "v2beta1": + targetVersions[filename] = &dashv2beta1.Dashboard{} + default: + t.Logf("Unknown version %s, skipping", version.VersionName) + } + } + break + } + } + + // Convert to each target version + for filename, target := range targetVersions { + t.Run(fmt.Sprintf("Convert_to_%s", filename), func(t *testing.T) { + // Create a copy of the input dashboard for conversion + inputCopy := sourceDash.(runtime.Object).DeepCopyObject() + + // Convert to target version + err = scheme.Convert(inputCopy, target, nil) + require.NoError(t, err, "Conversion failed for %s", filename) + + // Test the changes in the conversion result + testConversion(t, target.(v1.Object), filename, outDir) + }) + } + }) + } +} + +func testConversion(t *testing.T, convertedDash v1.Object, filename, outputDir string) { + t.Helper() + + outPath := filepath.Join(outputDir, filename) + outBytes, err := json.MarshalIndent(convertedDash, "", " ") + require.NoError(t, err, "failed to marshal converted dashboard") + + if _, err := os.Stat(outPath); os.IsNotExist(err) { + err = os.WriteFile(outPath, outBytes, 0644) + require.NoError(t, err, "failed to write new output file %s", outPath) + t.Logf("✓ Created new output file: %s", filename) + return + } + + // ignore gosec G304 as this function is only used in the test process + //nolint:gosec + existingBytes, err := os.ReadFile(outPath) + require.NoError(t, err, "failed to read existing output file") + require.JSONEq(t, string(existingBytes), string(outBytes), "%s did not match", outPath) + t.Logf("✓ Conversion to %s matches existing file", filename) +} diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v2alpha1.complete.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v2alpha1.complete.json new file mode 100644 index 00000000000..277a8b2ebc2 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v2alpha1.complete.json @@ -0,0 +1,462 @@ +{ + "apiVersion": "dashboard.grafana.app/v2alpha1", + "kind": "Dashboard", + "metadata": { + "name": "test-v2alpha1-complete", + "labels": { + "category": "test" + }, + "annotations": { + "description": "Complete example of v2alpha1 dashboard features" + } + }, + "spec": { + "title": "Test: Complete V2alpha1 Dashboard Example", + "description": "This dashboard demonstrates all features that need to be converted from v2alpha1 to v2beta1", + "editable": true, + "liveNow": true, + "preload": true, + "cursorSync": "Tooltip", + "tags": ["test", "example", "migration"], + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "builtIn": true, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": false, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "query": { + "kind": "grafana", + "spec": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + } + } + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "builtIn": false, + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, + "enable": true, + "hide": false, + "iconColor": "yellow", + "name": "Prometheus Annotations", + "query": { + "kind": "prometheus", + "spec": { + "expr": "changes(process_start_time_seconds[1m])", + "refId": "Anno" + } + } + } + } + ], + "variables": [ + { + "kind": "QueryVariable", + "spec": { + "name": "prometheus_query", + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, + "query": { + "kind": "prometheus", + "spec": { + "expr": "up" + } + }, + "refresh": "time", + "regex": "", + "sort": "alphabetical", + "multi": true, + "includeAll": true, + "current": { + "text": "All", + "value": ["$__all"] + }, + "hide": "dontHide", + "label": "Prometheus Query", + "description": "Shows all up metrics", + "skipUrlSync": false, + "definition": "up" + } + }, + { + "kind": "TextVariable", + "spec": { + "name": "text_var", + "label": "Text Variable", + "description": "A simple text variable", + "query": "server1,server2,server3", + "current": { + "selected": true, + "text": "server1", + "value": "server1" + }, + "hide": "dontHide", + "skipUrlSync": false + } + }, + { + "kind": "ConstantVariable", + "spec": { + "name": "constant_var", + "label": "Constant", + "description": "A constant value", + "query": "production", + "current": { + "selected": true, + "text": "production", + "value": "production" + }, + "hide": "dontHide", + "skipUrlSync": true + } + }, + { + "kind": "DatasourceVariable", + "spec": { + "name": "ds_var", + "label": "Datasource", + "description": "Select a datasource", + "pluginId": "prometheus", + "refresh": "load", + "regex": "/^gdev-/", + "current": { + "text": "gdev-prometheus", + "value": "gdev-prometheus" + }, + "options": [ + { + "text": "gdev-prometheus", + "value": "gdev-prometheus" + } + ], + "multi": false, + "includeAll": false, + "hide": "dontHide", + "skipUrlSync": false + } + }, + { + "kind": "IntervalVariable", + "spec": { + "name": "interval", + "label": "Interval", + "description": "Time interval selection", + "query": "1m,5m,10m,30m,1h,6h,12h,1d", + "current": { + "selected": true, + "text": "5m", + "value": "5m" + }, + "options": [ + { + "text": "1m", + "value": "1m" + }, + { + "text": "5m", + "value": "5m" + }, + { + "text": "10m", + "value": "10m" + }, + { + "text": "30m", + "value": "30m" + }, + { + "text": "1h", + "value": "1h" + }, + { + "text": "6h", + "value": "6h" + }, + { + "text": "12h", + "value": "12h" + }, + { + "text": "1d", + "value": "1d" + } + ], + "auto": true, + "auto_min": "10s", + "auto_count": 30, + "refresh": "load", + "hide": "dontHide", + "skipUrlSync": false + } + }, + { + "kind": "CustomVariable", + "spec": { + "name": "custom_var", + "label": "Custom Options", + "description": "Custom multi-value variable", + "query": "prod : Production, staging : Staging, dev : Development", + "current": { + "text": ["Production"], + "value": ["prod"] + }, + "options": [ + { + "text": "Production", + "value": "prod" + }, + { + "text": "Staging", + "value": "staging" + }, + { + "text": "Development", + "value": "dev" + } + ], + "multi": true, + "includeAll": true, + "allValue": "*", + "hide": "dontHide", + "skipUrlSync": false, + "allowCustomValue": true + } + }, + { + "kind": "GroupByVariable", + "spec": { + "name": "group_by", + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, + "current": { + "text": "instance", + "value": "instance" + }, + "description": "Group metrics by label", + "hide": "dontHide", + "label": "Group By" + } + }, + { + "kind": "AdhocVariable", + "spec": { + "name": "filters", + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, + "baseFilters": [ + { + "key": "job", + "operator": "=", + "value": "grafana", + "condition": "AND" + } + ], + "filters": [], + "hide": "dontHide", + "label": "Filters", + "defaultKeys": [ + { + "text": "job", + "value": "job", + "expandable": true + }, + { + "text": "instance", + "value": "instance", + "expandable": true + } + ] + } + } + ], + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "id": 1, + "title": "Panel with Conditional Rendering", + "description": "This panel demonstrates conditional rendering features", + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "refId": "A", + "datasource": { + "uid": "gdev-prometheus" + }, + "query": { + "kind": "prometheus", + "spec": { + "expr": "up{job=\"grafana\"}" + } + } + } + } + ], + "transformations": [ + { + "kind": "reduce", + "spec": { + "id": "reduce", + "options": { + "mode": "reduceFields", + "includeTimeField": false, + "reducers": ["mean"] + } + } + } + ] + } + }, + "vizConfig": { + "kind": "stat", + "spec": { + "pluginVersion": "12.1.0-pre", + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "textMode": "auto" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": 0 + }, + { + "color": "green", + "value": 1 + } + ] + }, + "mappings": [ + { + "type": "value", + "options": { + "0": { + "text": "Down", + "color": "red" + }, + "1": { + "text": "Up", + "color": "green" + } + } + } + ] + }, + "overrides": [] + } + } + } + } + } + }, + "layout": { + "kind": "RowsLayout", + "spec": { + "rows": [ + { + "kind": "Row", + "spec": { + "title": "Conditional Row", + "collapse": false, + "hideHeader": false, + "fillScreen": false, + "conditionalRendering": { + "kind": "ConditionalRenderingGroup", + "spec": { + "visibility": "show", + "condition": "and", + "items": [ + { + "kind": "ConditionalRenderingVariable", + "spec": { + "variable": "group_by", + "operator": "includes", + "value": "instance" + } + }, + { + "kind": "ConditionalRenderingData", + "spec": { + "value": true + } + }, + { + "kind": "ConditionalRenderingTimeRangeSize", + "spec": { + "value": "1h" + } + } + ] + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 0, + "width": 24, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-1" + } + } + } + ] + } + } + } + } + ] + } + }, + "timeSettings": { + "timezone": "browser", + "weekStart": "monday", + "fiscalYearStartMonth": 0, + "autoRefresh": "10s", + "autoRefreshIntervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], + "from": "now-6h", + "to": "now", + "hideTimepicker": false + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v2alpha1.ds-data-query.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v2alpha1.ds-data-query.json new file mode 100644 index 00000000000..408a3cc9873 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v2alpha1.ds-data-query.json @@ -0,0 +1,1001 @@ +{ + "apiVersion": "dashboard.grafana.app/v2alpha1", + "kind": "Dashboard", + "metadata": { + "name": "test-v2alpha1-annotations", + "labels": {}, + "annotations": {} + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "builtIn": true, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": false, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "query": { + "kind": "grafana", + "spec": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + } + } + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "builtIn": false, + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "enable": true, + "hide": false, + "iconColor": "blue", + "name": "testdata-annos", + "query": { + "kind": "grafana-testdata-datasource", + "spec": { + "lines": 10, + "refId": "Anno", + "scenarioId": "annotations" + } + } + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "builtIn": false, + "enable": true, + "hide": false, + "iconColor": "blue", + "name": "no-ds-testdata-annos", + "query": { + "kind": "prometheus", + "spec": { + "lines": 10, + "refId": "Anno", + "scenarioId": "annotations" + } + } + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "builtIn": false, + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, + "enable": true, + "hide": false, + "iconColor": "yellow", + "name": "prom-annos", + "query": { + "kind": "prometheus", + "spec": { + "expr": "{action=\"add_client\"}", + "interval": "", + "lines": 10, + "refId": "Anno", + "scenarioId": "annotations" + } + } + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "builtIn": false, + "enable": true, + "hide": false, + "iconColor": "yellow", + "name": "no-ds-prom-annos", + "query": { + "kind": "prometheus", + "spec": { + "expr": "{action=\"add_client\"}", + "interval": "", + "lines": 10, + "refId": "Anno", + "scenarioId": "annotations" + } + } + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "builtIn": false, + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "PBBCEC2D313BC06C3" + }, + "enable": true, + "hide": false, + "iconColor": "red", + "name": "postgress-annos", + "query": { + "kind": "grafana-postgresql-datasource", + "spec": { + "editorMode": "builder", + "format": "table", + "lines": 10, + "rawSql": "", + "refId": "Anno", + "scenarioId": "annotations", + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + } + } + } + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "builtIn": false, + "datasource": { + "type": "elasticsearch", + "uid": "gdev-elasticsearch" + }, + "enable": true, + "hide": false, + "iconColor": "red", + "legacyOptions": { + "tagsField": "asd", + "textField": "asd", + "timeEndField": "asdas", + "timeField": "asd" + }, + "name": "elastic - annos", + "query": { + "kind": "elasticsearch", + "spec": { + "lines": 10, + "query": "test query", + "refId": "Anno", + "scenarioId": "annotations" + } + } + } + } + ], + "cursorSync": "Off", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "datasource": { + "uid": "gdev-testdata" + }, + "query": { + "kind": "grafana-testdata-datasource", + "spec": { + "scenarioId": "random_walk", + "seriesCount": 3 + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 1, + "links": [], + "title": "Simple timeseries (WITH DS REF)", + "vizConfig": { + "kind": "timeseries", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.1.0-pre" + } + } + } + }, + "panel-2": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "kind": "grafana-testdata-datasource", + "spec": { + "scenarioId": "random_walk", + "seriesCount": 4 + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 2, + "links": [], + "title": "Simple stat (NO DS REF)", + "vizConfig": { + "kind": "stat", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.1.0-pre" + } + } + } + }, + "panel-3": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "kind": "prometheus", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "rate(counters_requests[$__rate_interval])", + "fullMetaSearch": false, + "includeNullMetadata": false, + "instant": false, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 3, + "links": [], + "title": "Panel with NO REF to gdev-prometheus", + "vizConfig": { + "kind": "timeseries", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.1.0-pre" + } + } + } + }, + "panel-4": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "datasource": { + "uid": "gdev-prometheus" + }, + "query": { + "kind": "prometheus", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "rate(counters_requests[$__rate_interval])", + "fullMetaSearch": false, + "includeNullMetadata": false, + "instant": false, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 4, + "links": [], + "title": "Panel with ref to gdev-prometheus", + "vizConfig": { + "kind": "timeseries", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.1.0-pre" + } + } + } + }, + "panel-5": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "datasource": { + "uid": "gdev-prometheus" + }, + "query": { + "kind": "prometheus", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "rate(counters_requests{server=\"backend-01\"}[$__rate_interval])", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "datasource": { + "uid": "gdev-testdata" + }, + "query": { + "kind": "grafana-testdata-datasource", + "spec": {}, + "version": "v0" + }, + "refId": "B" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 5, + "links": [], + "title": "Mixed DS WITH REFS", + "vizConfig": { + "kind": "timeseries", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.1.0-pre" + } + } + } + }, + "panel-6": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "kind": "prometheus", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "rate(counters_requests{server=\"backend-01\"}[$__rate_interval])", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "kind": "grafana-testdata-datasource", + "spec": {}, + "version": "v0" + }, + "refId": "B" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 6, + "links": [], + "title": "Mixed DS WITHOUT REFS", + "vizConfig": { + "kind": "timeseries", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.1.0-pre" + } + } + } + } + }, + "layout": { + "kind": "AutoGridLayout", + "spec": { + "columnWidthMode": "standard", + "items": [ + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-3" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-4" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-1" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-5" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-6" + } + } + } + ], + "maxColumnCount": 3, + "rowHeightMode": "standard" + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "autoRefresh": "", + "autoRefreshIntervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], + "fiscalYearStartMonth": 0, + "from": "now-6h", + "hideTimepicker": false, + "timezone": "browser", + "to": "now" + }, + "title": "Test: V2alpha1 dashboard with annotations", + "variables": [ + { + "kind": "QueryVariable", + "spec": { + "datasource": { + "uid": "gdev-prometheus" + }, + "enable": true, + "hide": "dontHide", + "name": "variable-ds-prometheus", + "query": { + "kind": "prometheus", + "spec": { + "expr": "up" + } + } + } + }, + { + "kind": "QueryVariable", + "spec": { + "enable": true, + "hide": "dontHide", + "name": "variable-no-ds", + "query": { + "kind": "grafana-testdata-datasource", + "spec": { + "scenarioId": "csv_metric_values", + "csv": "1,2,3,4" + } + } + } + }, + { + "kind": "QueryVariable", + "spec": { + "enable": true, + "hide": "dontHide", + "name": "variable-no-ds-empty-query", + "query": { + "kind": "grafana-testdata-datasource", + "spec": {} + } + } + }, + { + "kind": "QueryVariable", + "spec": { + "enable": true, + "hide": "dontHide", + "name": "variable-no-default-ds", + "query": { + "kind": "prometheus", + "spec": { + "expr": "up" + } + } + } + } + ] + }, + "status": {} +} diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v2alpha1.groupby-adhoc-vars.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v2alpha1.groupby-adhoc-vars.json new file mode 100644 index 00000000000..65c2b91ae72 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v2alpha1.groupby-adhoc-vars.json @@ -0,0 +1,73 @@ +{ + "apiVersion": "dashboard.grafana.app/v2alpha1", + "kind": "Dashboard", + "metadata": { + "name": "test-v2alpha1-groupby-adhoc-vars" + }, + "spec": { + "title": "Test: V2alpha1 dashboard with group by and adhoc variables", + "variables": [ + { + "kind": "GroupByVariable", + "spec": { + "current": { + "text": "text7", + "value": "value7" + }, + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, + "description": "A group by variable", + "hide": "dontHide", + "label": "Group By Variable" + } + }, + { + "kind": "AdhocVariable", + "spec": { + "baseFilters": [ + { + "condition": "AND", + "key": "key1", + "operator": "=", + "value": "value1" + }, + { + "condition": "OR", + "key": "key2", + "operator": "=", + "value": "value2" + } + ], + "datasource": { + "type": "prometheus", + "uid": "datasource-3" + }, + "defaultKeys": [ + { + "expandable": true, + "group": "defaultGroup1", + "text": "defaultKey1", + "value": "defaultKey1" + } + ], + "description": "An adhoc variable", + "filters": [ + { + "condition": "AND", + "key": "key3", + "operator": "=", + "value": "value3" + } + ], + "hide": "dontHide", + "label": "Adhoc Variable", + "name": "adhocVar", + "skipUrlSync": false, + "allowCustomValue": true + } + } + ] + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v2alpha1.viz-config.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v2alpha1.viz-config.json new file mode 100644 index 00000000000..5d591e64ca0 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v2alpha1.viz-config.json @@ -0,0 +1,202 @@ +{ + "apiVersion": "dashboard.grafana.app/v2alpha1", + "kind": "Dashboard", + "metadata": { + "name": "test-v2alpha1-viz-config", + "labels": {}, + "annotations": {} + }, + "spec": { + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "datasource": { + "uid": "gdev-testdata" + }, + "query": { + "kind": "grafana-testdata-datasource", + "spec": { + "scenarioId": "random_walk", + "seriesCount": 3 + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 1, + "links": [], + "title": "Simple timeseries (WITH DS REF)", + "vizConfig": { + "kind": "timeseries", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.1.0-pre" + } + } + } + } + }, + "layout": { + "kind": "AutoGridLayout", + "spec": { + "columnWidthMode": "standard", + "items": [ + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-3" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-4" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-1" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-5" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-6" + } + } + } + ], + "maxColumnCount": 3, + "rowHeightMode": "standard" + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "autoRefresh": "", + "autoRefreshIntervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], + "fiscalYearStartMonth": 0, + "from": "now-6h", + "hideTimepicker": false, + "timezone": "browser", + "to": "now" + }, + "title": "Test: V2alpha1 dashboard with viz config" + }, + "status": {} + } + \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v0alpha1.json new file mode 100644 index 00000000000..0c8b072ab49 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v0alpha1.json @@ -0,0 +1,20 @@ +{ + "metadata": { + "name": "test-v2alpha1-complete", + "creationTimestamp": null, + "labels": { + "category": "test" + }, + "annotations": { + "description": "Complete example of v2alpha1 dashboard features" + } + }, + "spec": null, + "status": { + "conversion": { + "failed": true, + "storedVersion": "v2alpha1", + "error": "backend conversion not yet implemented" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v1beta1.json new file mode 100644 index 00000000000..0c8b072ab49 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v1beta1.json @@ -0,0 +1,20 @@ +{ + "metadata": { + "name": "test-v2alpha1-complete", + "creationTimestamp": null, + "labels": { + "category": "test" + }, + "annotations": { + "description": "Complete example of v2alpha1 dashboard features" + } + }, + "spec": null, + "status": { + "conversion": { + "failed": true, + "storedVersion": "v2alpha1", + "error": "backend conversion not yet implemented" + } + } +} \ No newline at end of file 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 new file mode 100644 index 00000000000..7662f66d855 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v2beta1.json @@ -0,0 +1,511 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v2beta1", + "metadata": { + "name": "test-v2alpha1-complete", + "creationTimestamp": null, + "labels": { + "category": "test" + }, + "annotations": { + "description": "Complete example of v2alpha1 dashboard features" + } + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "grafana", + "version": "v0", + "datasource": { + "name": "-- Grafana --" + }, + "spec": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + } + }, + "enable": true, + "hide": false, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "builtIn": true + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "gdev-prometheus" + }, + "spec": { + "expr": "changes(process_start_time_seconds[1m])", + "refId": "Anno" + } + }, + "enable": true, + "hide": false, + "iconColor": "yellow", + "name": "Prometheus Annotations", + "builtIn": false + } + } + ], + "cursorSync": "Tooltip", + "description": "This dashboard demonstrates all features that need to be converted from v2alpha1 to v2beta1", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "id": 1, + "title": "Panel with Conditional Rendering", + "description": "This panel demonstrates conditional rendering features", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "gdev-prometheus" + }, + "spec": { + "expr": "up{job=\"grafana\"}" + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [ + { + "kind": "reduce", + "spec": { + "id": "reduce", + "options": { + "includeTimeField": false, + "mode": "reduceFields", + "reducers": [ + "mean" + ] + } + } + } + ], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "stat", + "version": "12.1.0-pre", + "spec": { + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "textMode": "auto" + }, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "value", + "options": { + "0": { + "text": "Down", + "color": "red" + }, + "1": { + "text": "Up", + "color": "green" + } + } + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "red" + }, + { + "value": 1, + "color": "green" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } + } + }, + "layout": { + "kind": "RowsLayout", + "spec": { + "rows": [ + { + "kind": "Row", + "spec": { + "title": "Conditional Row", + "collapse": false, + "hideHeader": false, + "fillScreen": false, + "conditionalRendering": { + "kind": "ConditionalRenderingGroup", + "spec": { + "visibility": "show", + "condition": "and", + "items": [ + { + "kind": "ConditionalRenderingVariable", + "spec": { + "variable": "group_by", + "operator": "includes", + "value": "instance" + } + }, + { + "kind": "ConditionalRenderingData", + "spec": { + "value": true + } + }, + { + "kind": "ConditionalRenderingTimeRangeSize", + "spec": { + "value": "1h" + } + } + ] + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 0, + "width": 24, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-1" + } + } + } + ] + } + } + } + } + ] + } + }, + "links": [], + "liveNow": true, + "preload": true, + "tags": [ + "test", + "example", + "migration" + ], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "10s", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "weekStart": "monday", + "fiscalYearStartMonth": 0 + }, + "title": "Test: Complete V2alpha1 Dashboard Example", + "variables": [ + { + "kind": "QueryVariable", + "spec": { + "name": "prometheus_query", + "current": { + "text": "All", + "value": [ + "$__all" + ] + }, + "label": "Prometheus Query", + "hide": "dontHide", + "refresh": "time", + "skipUrlSync": false, + "description": "Shows all up metrics", + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "gdev-prometheus" + }, + "spec": { + "expr": "up" + } + }, + "regex": "", + "sort": "alphabetical", + "definition": "up", + "options": null, + "multi": true, + "includeAll": true, + "allowCustomValue": false + } + }, + { + "kind": "TextVariable", + "spec": { + "name": "text_var", + "current": { + "selected": true, + "text": "server1", + "value": "server1" + }, + "query": "server1,server2,server3", + "label": "Text Variable", + "hide": "dontHide", + "skipUrlSync": false, + "description": "A simple text variable" + } + }, + { + "kind": "ConstantVariable", + "spec": { + "name": "constant_var", + "query": "production", + "current": { + "selected": true, + "text": "production", + "value": "production" + }, + "label": "Constant", + "hide": "dontHide", + "skipUrlSync": true, + "description": "A constant value" + } + }, + { + "kind": "DatasourceVariable", + "spec": { + "name": "ds_var", + "pluginId": "prometheus", + "refresh": "load", + "regex": "/^gdev-/", + "current": { + "text": "gdev-prometheus", + "value": "gdev-prometheus" + }, + "options": [ + { + "text": "gdev-prometheus", + "value": "gdev-prometheus" + } + ], + "multi": false, + "includeAll": false, + "label": "Datasource", + "hide": "dontHide", + "skipUrlSync": false, + "description": "Select a datasource", + "allowCustomValue": false + } + }, + { + "kind": "IntervalVariable", + "spec": { + "name": "interval", + "query": "1m,5m,10m,30m,1h,6h,12h,1d", + "current": { + "selected": true, + "text": "5m", + "value": "5m" + }, + "options": [ + { + "text": "1m", + "value": "1m" + }, + { + "text": "5m", + "value": "5m" + }, + { + "text": "10m", + "value": "10m" + }, + { + "text": "30m", + "value": "30m" + }, + { + "text": "1h", + "value": "1h" + }, + { + "text": "6h", + "value": "6h" + }, + { + "text": "12h", + "value": "12h" + }, + { + "text": "1d", + "value": "1d" + } + ], + "auto": true, + "auto_min": "10s", + "auto_count": 30, + "refresh": "load", + "label": "Interval", + "hide": "dontHide", + "skipUrlSync": false, + "description": "Time interval selection" + } + }, + { + "kind": "CustomVariable", + "spec": { + "name": "custom_var", + "query": "prod : Production, staging : Staging, dev : Development", + "current": { + "text": [ + "Production" + ], + "value": [ + "prod" + ] + }, + "options": [ + { + "text": "Production", + "value": "prod" + }, + { + "text": "Staging", + "value": "staging" + }, + { + "text": "Development", + "value": "dev" + } + ], + "multi": true, + "includeAll": true, + "allValue": "*", + "label": "Custom Options", + "hide": "dontHide", + "skipUrlSync": false, + "description": "Custom multi-value variable", + "allowCustomValue": true + } + }, + { + "kind": "GroupByVariable", + "group": "prometheus", + "datasource": { + "name": "gdev-prometheus" + }, + "spec": { + "name": "group_by", + "current": { + "text": "instance", + "value": "instance" + }, + "options": null, + "multi": false, + "label": "Group By", + "hide": "dontHide", + "skipUrlSync": false, + "description": "Group metrics by label" + } + }, + { + "kind": "AdhocVariable", + "group": "prometheus", + "datasource": { + "name": "gdev-prometheus" + }, + "spec": { + "name": "filters", + "baseFilters": [ + { + "key": "job", + "operator": "=", + "value": "grafana", + "condition": "AND" + } + ], + "filters": [], + "defaultKeys": [ + { + "text": "job", + "value": "job", + "expandable": true + }, + { + "text": "instance", + "value": "instance", + "expandable": true + } + ], + "label": "Filters", + "hide": "dontHide", + "skipUrlSync": false, + "allowCustomValue": false + } + } + ] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v2alpha1", + "error": "" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json new file mode 100644 index 00000000000..0a9980b5ff0 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json @@ -0,0 +1,14 @@ +{ + "metadata": { + "name": "test-v2alpha1-annotations", + "creationTimestamp": null + }, + "spec": null, + "status": { + "conversion": { + "failed": true, + "storedVersion": "v2alpha1", + "error": "backend conversion not yet implemented" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json new file mode 100644 index 00000000000..0a9980b5ff0 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json @@ -0,0 +1,14 @@ +{ + "metadata": { + "name": "test-v2alpha1-annotations", + "creationTimestamp": null + }, + "spec": null, + "status": { + "conversion": { + "failed": true, + "storedVersion": "v2alpha1", + "error": "backend conversion not yet implemented" + } + } +} \ No newline at end of file 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 new file mode 100644 index 00000000000..ba6bc946dc0 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v2beta1.json @@ -0,0 +1,1094 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v2beta1", + "metadata": { + "name": "test-v2alpha1-annotations", + "creationTimestamp": null + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "grafana", + "version": "v0", + "datasource": { + "name": "-- Grafana --" + }, + "spec": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + } + }, + "enable": true, + "hide": false, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "builtIn": true + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "grafana-testdata-datasource", + "version": "v0", + "datasource": { + "name": "PD8C576611E62080A" + }, + "spec": { + "lines": 10, + "refId": "Anno", + "scenarioId": "annotations" + } + }, + "enable": true, + "hide": false, + "iconColor": "blue", + "name": "testdata-annos", + "builtIn": false + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "spec": { + "lines": 10, + "refId": "Anno", + "scenarioId": "annotations" + } + }, + "enable": true, + "hide": false, + "iconColor": "blue", + "name": "no-ds-testdata-annos", + "builtIn": false + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "gdev-prometheus" + }, + "spec": { + "expr": "{action=\"add_client\"}", + "interval": "", + "lines": 10, + "refId": "Anno", + "scenarioId": "annotations" + } + }, + "enable": true, + "hide": false, + "iconColor": "yellow", + "name": "prom-annos", + "builtIn": false + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "spec": { + "expr": "{action=\"add_client\"}", + "interval": "", + "lines": 10, + "refId": "Anno", + "scenarioId": "annotations" + } + }, + "enable": true, + "hide": false, + "iconColor": "yellow", + "name": "no-ds-prom-annos", + "builtIn": false + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "grafana-postgresql-datasource", + "version": "v0", + "datasource": { + "name": "PBBCEC2D313BC06C3" + }, + "spec": { + "editorMode": "builder", + "format": "table", + "lines": 10, + "rawSql": "", + "refId": "Anno", + "scenarioId": "annotations", + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + } + } + }, + "enable": true, + "hide": false, + "iconColor": "red", + "name": "postgress-annos", + "builtIn": false + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "elasticsearch", + "version": "v0", + "datasource": { + "name": "gdev-elasticsearch" + }, + "spec": { + "lines": 10, + "query": "test query", + "refId": "Anno", + "scenarioId": "annotations" + } + }, + "enable": true, + "hide": false, + "iconColor": "red", + "name": "elastic - annos", + "builtIn": false, + "legacyOptions": { + "tagsField": "asd", + "textField": "asd", + "timeEndField": "asdas", + "timeField": "asd" + } + } + } + ], + "cursorSync": "Off", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "id": 1, + "title": "Simple timeseries (WITH DS REF)", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "grafana-testdata-datasource", + "version": "v0", + "datasource": { + "name": "gdev-testdata" + }, + "spec": { + "scenarioId": "random_walk", + "seriesCount": 3 + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "12.1.0-pre", + "spec": { + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + }, + "panel-2": { + "kind": "Panel", + "spec": { + "id": 2, + "title": "Simple stat (NO DS REF)", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "grafana-testdata-datasource", + "version": "v0", + "spec": { + "scenarioId": "random_walk", + "seriesCount": 4 + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "stat", + "version": "12.1.0-pre", + "spec": { + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } + }, + "panel-3": { + "kind": "Panel", + "spec": { + "id": 3, + "title": "Panel with NO REF to gdev-prometheus", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "rate(counters_requests[$__rate_interval])", + "fullMetaSearch": false, + "includeNullMetadata": false, + "instant": false, + "legendFormat": "__auto", + "range": true, + "useBackend": false + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "12.1.0-pre", + "spec": { + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + }, + "panel-4": { + "kind": "Panel", + "spec": { + "id": 4, + "title": "Panel with ref to gdev-prometheus", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "gdev-prometheus" + }, + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "rate(counters_requests[$__rate_interval])", + "fullMetaSearch": false, + "includeNullMetadata": false, + "instant": false, + "legendFormat": "__auto", + "range": true, + "useBackend": false + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "12.1.0-pre", + "spec": { + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + }, + "panel-5": { + "kind": "Panel", + "spec": { + "id": 5, + "title": "Mixed DS WITH REFS", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "gdev-prometheus" + }, + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "rate(counters_requests{server=\"backend-01\"}[$__rate_interval])", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "__auto", + "range": true, + "useBackend": false + } + }, + "refId": "A", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "grafana-testdata-datasource", + "version": "v0", + "datasource": { + "name": "gdev-testdata" + }, + "spec": {} + }, + "refId": "B", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "12.1.0-pre", + "spec": { + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + }, + "panel-6": { + "kind": "Panel", + "spec": { + "id": 6, + "title": "Mixed DS WITHOUT REFS", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "rate(counters_requests{server=\"backend-01\"}[$__rate_interval])", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "__auto", + "range": true, + "useBackend": false + } + }, + "refId": "A", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "grafana-testdata-datasource", + "version": "v0", + "spec": {} + }, + "refId": "B", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "12.1.0-pre", + "spec": { + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + } + }, + "layout": { + "kind": "AutoGridLayout", + "spec": { + "maxColumnCount": 3, + "columnWidthMode": "standard", + "rowHeightMode": "standard", + "items": [ + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-3" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-4" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-1" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-5" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-6" + } + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "Test: V2alpha1 dashboard with annotations", + "variables": [ + { + "kind": "QueryVariable", + "spec": { + "name": "variable-ds-prometheus", + "current": { + "text": null, + "value": null + }, + "hide": "dontHide", + "refresh": "", + "skipUrlSync": false, + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "gdev-prometheus" + }, + "spec": { + "expr": "up" + } + }, + "regex": "", + "sort": "", + "options": null, + "multi": false, + "includeAll": false, + "allowCustomValue": false + } + }, + { + "kind": "QueryVariable", + "spec": { + "name": "variable-no-ds", + "current": { + "text": null, + "value": null + }, + "hide": "dontHide", + "refresh": "", + "skipUrlSync": false, + "query": { + "kind": "DataQuery", + "group": "grafana-testdata-datasource", + "version": "v0", + "spec": { + "csv": "1,2,3,4", + "scenarioId": "csv_metric_values" + } + }, + "regex": "", + "sort": "", + "options": null, + "multi": false, + "includeAll": false, + "allowCustomValue": false + } + }, + { + "kind": "QueryVariable", + "spec": { + "name": "variable-no-ds-empty-query", + "current": { + "text": null, + "value": null + }, + "hide": "dontHide", + "refresh": "", + "skipUrlSync": false, + "query": { + "kind": "DataQuery", + "group": "grafana-testdata-datasource", + "version": "v0", + "spec": {} + }, + "regex": "", + "sort": "", + "options": null, + "multi": false, + "includeAll": false, + "allowCustomValue": false + } + }, + { + "kind": "QueryVariable", + "spec": { + "name": "variable-no-default-ds", + "current": { + "text": null, + "value": null + }, + "hide": "dontHide", + "refresh": "", + "skipUrlSync": false, + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "spec": { + "expr": "up" + } + }, + "regex": "", + "sort": "", + "options": null, + "multi": false, + "includeAll": false, + "allowCustomValue": false + } + } + ] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v2alpha1", + "error": "" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v0alpha1.json new file mode 100644 index 00000000000..aa3ee7b86ef --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v0alpha1.json @@ -0,0 +1,14 @@ +{ + "metadata": { + "name": "test-v2alpha1-groupby-adhoc-vars", + "creationTimestamp": null + }, + "spec": null, + "status": { + "conversion": { + "failed": true, + "storedVersion": "v2alpha1", + "error": "backend conversion not yet implemented" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v1beta1.json new file mode 100644 index 00000000000..aa3ee7b86ef --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v1beta1.json @@ -0,0 +1,14 @@ +{ + "metadata": { + "name": "test-v2alpha1-groupby-adhoc-vars", + "creationTimestamp": null + }, + "spec": null, + "status": { + "conversion": { + "failed": true, + "storedVersion": "v2alpha1", + "error": "backend conversion not yet implemented" + } + } +} \ No newline at end of file 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 new file mode 100644 index 00000000000..9d34353a32b --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v2beta1.json @@ -0,0 +1,100 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v2beta1", + "metadata": { + "name": "test-v2alpha1-groupby-adhoc-vars", + "creationTimestamp": null + }, + "spec": { + "annotations": [], + "cursorSync": "", + "elements": {}, + "layout": null, + "links": [], + "preload": false, + "tags": null, + "timeSettings": { + "from": "", + "to": "", + "autoRefresh": "", + "autoRefreshIntervals": null, + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "Test: V2alpha1 dashboard with group by and adhoc variables", + "variables": [ + { + "kind": "GroupByVariable", + "group": "prometheus", + "datasource": { + "name": "gdev-prometheus" + }, + "spec": { + "name": "", + "current": { + "text": "text7", + "value": "value7" + }, + "options": null, + "multi": false, + "label": "Group By Variable", + "hide": "dontHide", + "skipUrlSync": false, + "description": "A group by variable" + } + }, + { + "kind": "AdhocVariable", + "group": "prometheus", + "datasource": { + "name": "datasource-3" + }, + "spec": { + "name": "adhocVar", + "baseFilters": [ + { + "key": "key1", + "operator": "=", + "value": "value1", + "condition": "AND" + }, + { + "key": "key2", + "operator": "=", + "value": "value2", + "condition": "OR" + } + ], + "filters": [ + { + "key": "key3", + "operator": "=", + "value": "value3", + "condition": "AND" + } + ], + "defaultKeys": [ + { + "text": "defaultKey1", + "value": "defaultKey1", + "group": "defaultGroup1", + "expandable": true + } + ], + "label": "Adhoc Variable", + "hide": "dontHide", + "skipUrlSync": false, + "description": "An adhoc variable", + "allowCustomValue": true + } + } + ] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v2alpha1", + "error": "" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v0alpha1.json new file mode 100644 index 00000000000..9c74ecaa74f --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v0alpha1.json @@ -0,0 +1,14 @@ +{ + "metadata": { + "name": "test-v2alpha1-viz-config", + "creationTimestamp": null + }, + "spec": null, + "status": { + "conversion": { + "failed": true, + "storedVersion": "v2alpha1", + "error": "backend conversion not yet implemented" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v1beta1.json new file mode 100644 index 00000000000..9c74ecaa74f --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v1beta1.json @@ -0,0 +1,14 @@ +{ + "metadata": { + "name": "test-v2alpha1-viz-config", + "creationTimestamp": null + }, + "spec": null, + "status": { + "conversion": { + "failed": true, + "storedVersion": "v2alpha1", + "error": "backend conversion not yet implemented" + } + } +} \ No newline at end of file diff --git a/conf/provisioning/sample/dashboard-v2.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v2beta1.json similarity index 54% rename from conf/provisioning/sample/dashboard-v2.json rename to apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v2beta1.json index ad4671943c3..b867057085e 100644 --- a/conf/provisioning/sample/dashboard-v2.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v2beta1.json @@ -1,33 +1,21 @@ { - "apiVersion": "dashboard.grafana.app/v2alpha1", "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v2beta1", "metadata": { - "name": "sample-dash-v2" + "name": "test-v2alpha1-viz-config", + "creationTimestamp": null }, "spec": { - "annotations": [ - { - "kind": "AnnotationQuery", - "spec": { - "builtIn": true, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations \u0026 Alerts" - } - } - ], - "cursorSync": "Off", - "description": "", - "editable": true, + "annotations": [], + "cursorSync": "", "elements": { "panel-1": { "kind": "Panel", "spec": { + "id": 1, + "title": "Simple timeseries (WITH DS REF)", + "description": "", + "links": [], "data": { "kind": "QueryGroup", "spec": { @@ -35,28 +23,60 @@ { "kind": "PanelQuery", "spec": { - "hidden": false, "query": { - "kind": "grafana-testdata-datasource", - "spec": {} + "kind": "DataQuery", + "group": "grafana-testdata-datasource", + "version": "v0", + "datasource": { + "name": "gdev-testdata" + }, + "spec": { + "scenarioId": "random_walk", + "seriesCount": 3 + } }, - "refId": "A" + "refId": "A", + "hidden": false } } ], - "queryOptions": {}, - "transformations": [] + "transformations": [], + "queryOptions": {} } }, - "description": "", - "id": 1, - "links": [], - "title": "Simle timeseries", "vizConfig": { - "kind": "timeseries", + "kind": "VizConfig", + "group": "timeseries", + "version": "12.1.0-pre", "spec": { + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, "fieldConfig": { "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, "color": { "mode": "palette-classic" }, @@ -92,108 +112,10 @@ "thresholdsStyle": { "mode": "off" } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] } }, "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "12.0.0-pre" - } - } - } - }, - "panel-2": { - "kind": "Panel", - "spec": { - "data": { - "kind": "QueryGroup", - "spec": { - "queries": [ - { - "kind": "PanelQuery", - "spec": { - "hidden": false, - "query": { - "kind": "grafana-testdata-datasource", - "spec": {} - }, - "refId": "A" - } - } - ], - "queryOptions": {}, - "transformations": [] - } - }, - "description": "", - "id": 2, - "links": [], - "title": "Simple stat", - "vizConfig": { - "kind": "stat", - "spec": { - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "12.0.0-pre" + } } } } @@ -202,8 +124,28 @@ "layout": { "kind": "AutoGridLayout", "spec": { + "maxColumnCount": 3, "columnWidthMode": "standard", + "rowHeightMode": "standard", "items": [ + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-3" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-4" + } + } + }, { "kind": "AutoGridLayoutItem", "spec": { @@ -221,10 +163,26 @@ "name": "panel-1" } } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-5" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-6" + } + } } - ], - "maxColumnCount": 3, - "rowHeightMode": "standard" + ] } }, "links": [], @@ -232,15 +190,33 @@ "preload": false, "tags": [], "timeSettings": { - "autoRefresh": "", - "autoRefreshIntervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "fiscalYearStartMonth": 0, - "from": "now-6h", - "hideTimepicker": false, "timezone": "browser", - "to": "now" + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 }, - "title": "v2alpha1 dashboard", + "title": "Test: V2alpha1 dashboard with viz config", "variables": [] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v2alpha1", + "error": "" + } } -} +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/v0.go b/apps/dashboard/pkg/migration/conversion/v0.go index 94f3d5b57f4..a63fe00f611 100644 --- a/apps/dashboard/pkg/migration/conversion/v0.go +++ b/apps/dashboard/pkg/migration/conversion/v0.go @@ -6,7 +6,7 @@ import ( dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" dashv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" - dashv2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" + dashv2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" "github.com/grafana/grafana/apps/dashboard/pkg/migration" "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" ) @@ -61,13 +61,13 @@ func Convert_V0_to_V2alpha1(in *dashv0.Dashboard, out *dashv2alpha1.Dashboard, s return nil } -func Convert_V0_to_V2alpha2(in *dashv0.Dashboard, out *dashv2alpha2.Dashboard, scope conversion.Scope) error { +func Convert_V0_to_V2beta1(in *dashv0.Dashboard, out *dashv2beta1.Dashboard, scope conversion.Scope) error { out.ObjectMeta = in.ObjectMeta - // TODO: implement V0 to V2alpha2 conversion + // TODO: implement V0 to v2beta1 conversion - out.Status = dashv2alpha2.DashboardStatus{ - Conversion: &dashv2alpha2.DashboardConversionStatus{ + out.Status = dashv2beta1.DashboardStatus{ + Conversion: &dashv2beta1.DashboardConversionStatus{ StoredVersion: dashv0.VERSION, Failed: true, Error: "backend conversion not yet implemented", diff --git a/apps/dashboard/pkg/migration/conversion/v1.go b/apps/dashboard/pkg/migration/conversion/v1.go index d71d9e40caf..0e1bb8fc8ed 100644 --- a/apps/dashboard/pkg/migration/conversion/v1.go +++ b/apps/dashboard/pkg/migration/conversion/v1.go @@ -6,7 +6,7 @@ import ( dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" dashv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" - dashv2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" + dashv2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" ) func Convert_V1_to_V0(in *dashv1.Dashboard, out *dashv0.Dashboard, scope conversion.Scope) error { @@ -54,13 +54,13 @@ func Convert_V1_to_V2alpha1(in *dashv1.Dashboard, out *dashv2alpha1.Dashboard, s return nil } -func Convert_V1_to_V2alpha2(in *dashv1.Dashboard, out *dashv2alpha2.Dashboard, scope conversion.Scope) error { +func Convert_V1_to_V2beta1(in *dashv1.Dashboard, out *dashv2beta1.Dashboard, scope conversion.Scope) error { out.ObjectMeta = in.ObjectMeta - // TODO: implement V1 to V2alpha2 conversion + // TODO: implement V1 to v2beta1 conversion - out.Status = dashv2alpha2.DashboardStatus{ - Conversion: &dashv2alpha2.DashboardConversionStatus{ + out.Status = dashv2beta1.DashboardStatus{ + Conversion: &dashv2beta1.DashboardConversionStatus{ StoredVersion: dashv1.VERSION, Failed: true, Error: "backend conversion not yet implemented", diff --git a/apps/dashboard/pkg/migration/conversion/v2.go b/apps/dashboard/pkg/migration/conversion/v2.go index 5186f9257e2..ccdb98fdcf4 100644 --- a/apps/dashboard/pkg/migration/conversion/v2.go +++ b/apps/dashboard/pkg/migration/conversion/v2.go @@ -6,7 +6,7 @@ import ( dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" dashv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" - dashv2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" + dashv2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" ) func Convert_V2alpha1_to_V0(in *dashv2alpha1.Dashboard, out *dashv0.Dashboard, scope conversion.Scope) error { @@ -41,29 +41,40 @@ func Convert_V2alpha1_to_V1(in *dashv2alpha1.Dashboard, out *dashv1.Dashboard, s return nil } -func Convert_V2alpha1_to_V2alpha2(in *dashv2alpha1.Dashboard, out *dashv2alpha2.Dashboard, scope conversion.Scope) error { +func Convert_V2alpha1_to_V2beta1(in *dashv2alpha1.Dashboard, out *dashv2beta1.Dashboard, scope conversion.Scope) error { out.ObjectMeta = in.ObjectMeta - // TODO: implement V2alpha1 to V2alpha2 conversion + // Convert the spec + if err := ConvertDashboard_V2alpha1_to_V2beta1(in, out, scope); err != nil { + out.Status = dashv2beta1.DashboardStatus{ + Conversion: &dashv2beta1.DashboardConversionStatus{ + StoredVersion: dashv2alpha1.VERSION, + Failed: true, + Error: err.Error(), + }, + } + return err + } - out.Status = dashv2alpha2.DashboardStatus{ - Conversion: &dashv2alpha2.DashboardConversionStatus{ + // Set successful conversion status + out.Status = dashv2beta1.DashboardStatus{ + Conversion: &dashv2beta1.DashboardConversionStatus{ StoredVersion: dashv2alpha1.VERSION, - Failed: true, - Error: "backend conversion not yet implemented", + Failed: false, }, } return nil } -func Convert_V2alpha2_to_V0(in *dashv2alpha2.Dashboard, out *dashv0.Dashboard, scope conversion.Scope) error { + +func Convert_V2beta1_to_V0(in *dashv2beta1.Dashboard, out *dashv0.Dashboard, scope conversion.Scope) error { out.ObjectMeta = in.ObjectMeta - // TODO: implement V2alpha2 to V0 conversion + // TODO: implement v2beta1 to V0 conversion out.Status = dashv0.DashboardStatus{ Conversion: &dashv0.DashboardConversionStatus{ - StoredVersion: dashv2alpha2.VERSION, + StoredVersion: dashv2beta1.VERSION, Failed: true, Error: "backend conversion not yet implemented", }, @@ -72,14 +83,14 @@ func Convert_V2alpha2_to_V0(in *dashv2alpha2.Dashboard, out *dashv0.Dashboard, s return nil } -func Convert_V2alpha2_to_V1(in *dashv2alpha2.Dashboard, out *dashv1.Dashboard, scope conversion.Scope) error { +func Convert_V2beta1_to_V1(in *dashv2beta1.Dashboard, out *dashv1.Dashboard, scope conversion.Scope) error { out.ObjectMeta = in.ObjectMeta - // TODO: implement V2alpha2 to V1 conversion + // TODO: implement v2beta1 to V1 conversion out.Status = dashv1.DashboardStatus{ Conversion: &dashv1.DashboardConversionStatus{ - StoredVersion: dashv2alpha2.VERSION, + StoredVersion: dashv2beta1.VERSION, Failed: true, Error: "backend conversion not yet implemented", }, @@ -88,14 +99,14 @@ func Convert_V2alpha2_to_V1(in *dashv2alpha2.Dashboard, out *dashv1.Dashboard, s return nil } -func Convert_V2alpha2_to_V2alpha1(in *dashv2alpha2.Dashboard, out *dashv2alpha1.Dashboard, scope conversion.Scope) error { +func Convert_V2beta1_to_V2alpha1(in *dashv2beta1.Dashboard, out *dashv2alpha1.Dashboard, scope conversion.Scope) error { out.ObjectMeta = in.ObjectMeta - // TODO: implement V2alpha2 to V2alpha1 conversion + // TODO: implement v2beta1 to V2alpha1 conversion out.Status = dashv2alpha1.DashboardStatus{ Conversion: &dashv2alpha1.DashboardConversionStatus{ - StoredVersion: dashv2alpha2.VERSION, + StoredVersion: dashv2beta1.VERSION, Failed: true, Error: "backend conversion not yet implemented", }, diff --git a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go new file mode 100644 index 00000000000..ec673c80137 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go @@ -0,0 +1,981 @@ +package conversion + +import ( + "k8s.io/apimachinery/pkg/conversion" + + dashv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + dashv2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" +) + +// Schema Migration: v2alpha1 → v2beta1 +// +// This file handles the conversion from Dashboard v2alpha1 to v2beta1 schema. +// The main changes between these versions are: +// +// 1. DataQueryKind Structure Changes: +// - v2alpha1: kind = datasource type (e.g., "prometheus", "elasticsearch") +// - v2beta1: kind = "DataQuery" (hardcoded), group = datasource type, version = "v0" +// +// 2. Datasource Reference Migration: +// - v2alpha1: Datasource references stored at spec level (PanelQuerySpec.datasource, AnnotationQuerySpec.datasource, etc.) +// - v2beta1: Datasource references moved inside DataQueryKind.datasource +// - v2alpha1: {type?: string, uid?: string} → v2beta1: {name?: string} +// +// 3. Query Requirements: +// - v2alpha1: AnnotationQuerySpec.query? is optional (can be nil) +// - v2beta1: AnnotationQuerySpec.query is required +// - Conversion creates default query structure when v2alpha1 query is nil +// +// 4. DataSourceRef Usage: +// - v2alpha1: Used widely across different specs +// - v2beta1: Kept only for backward compatibility in GroupBy and Adhoc variables +// +// 5. Datasource Mapping Strategy: +// - Type → Name (if type is available) +// - Uid → Name (if type is not available but uid is) +// +// The conversion preserves all dashboard functionality while restructuring +// the data model to consolidate datasource references into the DataQueryKind. + +func ConvertDashboard_V2alpha1_to_V2beta1(in *dashv2alpha1.Dashboard, out *dashv2beta1.Dashboard, scope conversion.Scope) error { + out.ObjectMeta = in.ObjectMeta + out.APIVersion = dashv2beta1.APIVERSION + out.Kind = in.Kind + + return convertDashboardSpec_V2alpha1_to_V2beta1(&in.Spec, &out.Spec, scope) +} + +func convertDashboardSpec_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardSpec, out *dashv2beta1.DashboardSpec, scope conversion.Scope) error { + // Convert annotations + out.Annotations = make([]dashv2beta1.DashboardAnnotationQueryKind, len(in.Annotations)) + for i, annotation := range in.Annotations { + if err := convertAnnotationQuery_V2alpha1_to_V2beta1(&annotation, &out.Annotations[i], scope); err != nil { + return err + } + } + + // Copy simple fields + out.CursorSync = dashv2beta1.DashboardDashboardCursorSync(in.CursorSync) + out.Description = in.Description + out.Editable = in.Editable + out.LiveNow = in.LiveNow + out.Preload = in.Preload + out.Revision = in.Revision + out.Tags = in.Tags + out.Title = in.Title + + // Convert elements + out.Elements = make(map[string]dashv2beta1.DashboardElement, len(in.Elements)) + for key, element := range in.Elements { + var convertedElement dashv2beta1.DashboardElement + if err := convertElement_V2alpha1_to_V2beta1(&element, &convertedElement, scope); err != nil { + return err + } + out.Elements[key] = convertedElement + } + + // Convert layout + if err := convertLayout_V2alpha1_to_V2beta1(&in.Layout, &out.Layout, scope); err != nil { + return err + } + + // Convert links + out.Links = make([]dashv2beta1.DashboardDashboardLink, len(in.Links)) + for i, link := range in.Links { + convertDashboardLink_V2alpha1_to_V2beta1(&link, &out.Links[i]) + } + + // Convert time settings + if err := convertTimeSettings_V2alpha1_to_V2beta1(&in.TimeSettings, &out.TimeSettings, scope); err != nil { + return err + } + + // Convert variables + out.Variables = make([]dashv2beta1.DashboardVariableKind, len(in.Variables)) + for i, variable := range in.Variables { + if err := convertVariable_V2alpha1_to_V2beta1(&variable, &out.Variables[i], scope); err != nil { + return err + } + } + + return nil +} + +func convertAnnotationQuery_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardAnnotationQueryKind, out *dashv2beta1.DashboardAnnotationQueryKind, scope conversion.Scope) error { + out.Kind = in.Kind + + // Convert spec + out.Spec.Enable = in.Spec.Enable + out.Spec.Hide = in.Spec.Hide + out.Spec.IconColor = in.Spec.IconColor + out.Spec.Name = in.Spec.Name + out.Spec.BuiltIn = in.Spec.BuiltIn + out.Spec.Filter = (*dashv2beta1.DashboardAnnotationPanelFilter)(in.Spec.Filter) + out.Spec.LegacyOptions = in.Spec.LegacyOptions + + // Convert query - move datasource from annotation spec to query + if err := convertDataQuery_V2alpha1_to_V2beta1(in.Spec.Query, &out.Spec.Query, in.Spec.Datasource, scope); err != nil { + return err + } + + return nil +} + +func convertDataQuery_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardDataQueryKind, out *dashv2beta1.DashboardDataQueryKind, datasource *dashv2alpha1.DashboardDataSourceRef, scope conversion.Scope) error { + if in == nil { + // v2beta1 requires a query even if v2alpha1 had none, so create a default + out.Kind = "DataQuery" + out.Group = "" + out.Version = "v0" + out.Spec = make(map[string]interface{}) + } else { + out.Kind = "DataQuery" + out.Group = in.Kind + out.Version = "v0" + out.Spec = in.Spec + } + + // Convert datasource reference + if datasource != nil { + out.Datasource = &dashv2beta1.DashboardV2beta1DataQueryKindDatasource{} + if datasource.Uid != nil { + out.Datasource.Name = datasource.Uid + } + } + + return nil +} + +func convertElement_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardElement, out *dashv2beta1.DashboardElement, scope conversion.Scope) error { + if in.PanelKind != nil { + out.PanelKind = &dashv2beta1.DashboardPanelKind{} + return convertPanelKind_V2alpha1_to_V2beta1(in.PanelKind, out.PanelKind, scope) + } + + if in.LibraryPanelKind != nil { + out.LibraryPanelKind = &dashv2beta1.DashboardLibraryPanelKind{} + return convertLibraryPanelKind_V2alpha1_to_V2beta1(in.LibraryPanelKind, out.LibraryPanelKind, scope) + } + + return nil +} + +func convertPanelKind_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardPanelKind, out *dashv2beta1.DashboardPanelKind, scope conversion.Scope) error { + out.Kind = in.Kind + + // Convert spec + out.Spec.Id = in.Spec.Id + out.Spec.Title = in.Spec.Title + out.Spec.Description = in.Spec.Description + out.Spec.Transparent = in.Spec.Transparent + + // Convert links + out.Spec.Links = make([]dashv2beta1.DashboardDataLink, len(in.Spec.Links)) + for i, link := range in.Spec.Links { + convertDataLink_V2alpha1_to_V2beta1(&link, &out.Spec.Links[i]) + } + + // Convert data (QueryGroup) + if err := convertQueryGroup_V2alpha1_to_V2beta1(&in.Spec.Data, &out.Spec.Data, scope); err != nil { + return err + } + + // Convert vizConfig + if err := convertVizConfig_V2alpha1_to_V2beta1(&in.Spec.VizConfig, &out.Spec.VizConfig); err != nil { + return err + } + + return nil +} + +func convertLibraryPanelKind_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardLibraryPanelKind, out *dashv2beta1.DashboardLibraryPanelKind, scope conversion.Scope) error { + out.Kind = in.Kind + out.Spec.Id = in.Spec.Id + out.Spec.Title = in.Spec.Title + out.Spec.LibraryPanel = dashv2beta1.DashboardLibraryPanelRef{ + Name: in.Spec.LibraryPanel.Name, + Uid: in.Spec.LibraryPanel.Uid, + } + return nil +} + +func convertQueryGroup_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardQueryGroupKind, out *dashv2beta1.DashboardQueryGroupKind, scope conversion.Scope) error { + out.Kind = in.Kind + + // Convert queries + out.Spec.Queries = make([]dashv2beta1.DashboardPanelQueryKind, len(in.Spec.Queries)) + for i, query := range in.Spec.Queries { + if err := convertPanelQuery_V2alpha1_to_V2beta1(&query, &out.Spec.Queries[i], scope); err != nil { + return err + } + } + + // Convert transformations + out.Spec.Transformations = make([]dashv2beta1.DashboardTransformationKind, len(in.Spec.Transformations)) + for i, transformation := range in.Spec.Transformations { + convertTransformation_V2alpha1_to_V2beta1(&transformation, &out.Spec.Transformations[i]) + } + + // Convert query options + convertQueryOptions_V2alpha1_to_V2beta1(&in.Spec.QueryOptions, &out.Spec.QueryOptions) + + return nil +} + +func convertPanelQuery_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardPanelQueryKind, out *dashv2beta1.DashboardPanelQueryKind, scope conversion.Scope) error { + out.Kind = in.Kind // PanelQueryKind keeps "PanelQuery" as its kind + out.Spec.RefId = in.Spec.RefId + out.Spec.Hidden = in.Spec.Hidden + + // Convert query - move datasource from panel query spec to query + if err := convertDataQuery_V2alpha1_to_V2beta1(&in.Spec.Query, &out.Spec.Query, in.Spec.Datasource, scope); err != nil { + return err + } + + return nil +} + +func convertTransformation_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardTransformationKind, out *dashv2beta1.DashboardTransformationKind) { + out.Kind = in.Kind + out.Spec.Id = in.Spec.Id + out.Spec.Disabled = in.Spec.Disabled + out.Spec.Filter = (*dashv2beta1.DashboardMatcherConfig)(in.Spec.Filter) + out.Spec.Topic = (*dashv2beta1.DashboardDataTopic)(in.Spec.Topic) + out.Spec.Options = in.Spec.Options +} + +func convertQueryOptions_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardQueryOptionsSpec, out *dashv2beta1.DashboardQueryOptionsSpec) { + out.TimeFrom = in.TimeFrom + out.MaxDataPoints = in.MaxDataPoints + out.TimeShift = in.TimeShift + out.QueryCachingTTL = in.QueryCachingTTL + out.Interval = in.Interval + out.CacheTimeout = in.CacheTimeout + out.HideTimeOverride = in.HideTimeOverride +} + +func convertVizConfig_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardVizConfigKind, out *dashv2beta1.DashboardVizConfigKind) error { + out.Kind = "VizConfig" + out.Group = in.Kind + out.Version = in.Spec.PluginVersion + out.Spec = dashv2beta1.DashboardVizConfigSpec{ + Options: in.Spec.Options, + } + + // Convert field config + convertFieldConfigSource_V2alpha1_to_V2beta1(&in.Spec.FieldConfig, &out.Spec.FieldConfig) + + return nil +} + +func convertFieldConfigSource_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardFieldConfigSource, out *dashv2beta1.DashboardFieldConfigSource) { + // Convert defaults + convertFieldConfig_V2alpha1_to_V2beta1(&in.Defaults, &out.Defaults) + + // Convert overrides + out.Overrides = make([]dashv2beta1.DashboardV2beta1FieldConfigSourceOverrides, len(in.Overrides)) + for i, override := range in.Overrides { + convertFieldConfigOverride_V2alpha1_to_V2beta1(&override, &out.Overrides[i]) + } +} + +func convertFieldConfig_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardFieldConfig, out *dashv2beta1.DashboardFieldConfig) { + *out = dashv2beta1.DashboardFieldConfig{ + DisplayName: in.DisplayName, + DisplayNameFromDS: in.DisplayNameFromDS, + Description: in.Description, + Path: in.Path, + Writeable: in.Writeable, + Filterable: in.Filterable, + Unit: in.Unit, + Decimals: in.Decimals, + Min: in.Min, + Max: in.Max, + Links: in.Links, + NoValue: in.NoValue, + Custom: in.Custom, + } + + // Convert thresholds + if in.Thresholds != nil { + out.Thresholds = &dashv2beta1.DashboardThresholdsConfig{ + Mode: dashv2beta1.DashboardThresholdsMode(in.Thresholds.Mode), + Steps: make([]dashv2beta1.DashboardThreshold, len(in.Thresholds.Steps)), + } + for i, step := range in.Thresholds.Steps { + out.Thresholds.Steps[i] = dashv2beta1.DashboardThreshold{ + Value: step.Value, + Color: step.Color, + } + } + } + + // Convert color + if in.Color != nil { + out.Color = &dashv2beta1.DashboardFieldColor{ + Mode: dashv2beta1.DashboardFieldColorModeId(in.Color.Mode), + FixedColor: in.Color.FixedColor, + SeriesBy: (*dashv2beta1.DashboardFieldColorSeriesByMode)(in.Color.SeriesBy), + } + } + + // Convert mappings + if in.Mappings != nil { + out.Mappings = make([]dashv2beta1.DashboardValueMapping, len(in.Mappings)) + for i, mapping := range in.Mappings { + convertValueMapping_V2alpha1_to_V2beta1(&mapping, &out.Mappings[i]) + } + } +} + +func convertFieldConfigOverride_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardV2alpha1FieldConfigSourceOverrides, out *dashv2beta1.DashboardV2beta1FieldConfigSourceOverrides) { + out.Matcher = dashv2beta1.DashboardMatcherConfig{ + Id: in.Matcher.Id, + Options: in.Matcher.Options, + } + + out.Properties = make([]dashv2beta1.DashboardDynamicConfigValue, len(in.Properties)) + for i, prop := range in.Properties { + out.Properties[i] = dashv2beta1.DashboardDynamicConfigValue{ + Id: prop.Id, + Value: prop.Value, + } + } +} + +func convertValueMapping_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardValueMapping, out *dashv2beta1.DashboardValueMapping) { + if in.ValueMap != nil { + out.ValueMap = &dashv2beta1.DashboardValueMap{ + Type: dashv2beta1.DashboardMappingType(in.ValueMap.Type), + Options: make(map[string]dashv2beta1.DashboardValueMappingResult, len(in.ValueMap.Options)), + } + for k, v := range in.ValueMap.Options { + out.ValueMap.Options[k] = dashv2beta1.DashboardValueMappingResult{ + Text: v.Text, + Color: v.Color, + Icon: v.Icon, + Index: v.Index, + } + } + } + + if in.RangeMap != nil { + out.RangeMap = &dashv2beta1.DashboardRangeMap{ + Type: dashv2beta1.DashboardMappingType(in.RangeMap.Type), + Options: dashv2beta1.DashboardV2beta1RangeMapOptions{ + From: in.RangeMap.Options.From, + To: in.RangeMap.Options.To, + Result: dashv2beta1.DashboardValueMappingResult{ + Text: in.RangeMap.Options.Result.Text, + Color: in.RangeMap.Options.Result.Color, + Icon: in.RangeMap.Options.Result.Icon, + Index: in.RangeMap.Options.Result.Index, + }, + }, + } + } + + if in.RegexMap != nil { + out.RegexMap = &dashv2beta1.DashboardRegexMap{ + Type: dashv2beta1.DashboardMappingType(in.RegexMap.Type), + Options: dashv2beta1.DashboardV2beta1RegexMapOptions{ + Pattern: in.RegexMap.Options.Pattern, + Result: dashv2beta1.DashboardValueMappingResult{ + Text: in.RegexMap.Options.Result.Text, + Color: in.RegexMap.Options.Result.Color, + Icon: in.RegexMap.Options.Result.Icon, + Index: in.RegexMap.Options.Result.Index, + }, + }, + } + } + + if in.SpecialValueMap != nil { + out.SpecialValueMap = &dashv2beta1.DashboardSpecialValueMap{ + Type: dashv2beta1.DashboardMappingType(in.SpecialValueMap.Type), + Options: dashv2beta1.DashboardV2beta1SpecialValueMapOptions{ + Match: dashv2beta1.DashboardSpecialValueMatch(in.SpecialValueMap.Options.Match), + Result: dashv2beta1.DashboardValueMappingResult{ + Text: in.SpecialValueMap.Options.Result.Text, + Color: in.SpecialValueMap.Options.Result.Color, + Icon: in.SpecialValueMap.Options.Result.Icon, + Index: in.SpecialValueMap.Options.Result.Index, + }, + }, + } + } +} + +func convertLayout_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, out *dashv2beta1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, scope conversion.Scope) error { + if in.GridLayoutKind != nil { + out.GridLayoutKind = &dashv2beta1.DashboardGridLayoutKind{ + Kind: in.GridLayoutKind.Kind, + } + return convertGridLayoutSpec_V2alpha1_to_V2beta1(&in.GridLayoutKind.Spec, &out.GridLayoutKind.Spec, scope) + } + + if in.RowsLayoutKind != nil { + out.RowsLayoutKind = &dashv2beta1.DashboardRowsLayoutKind{ + Kind: in.RowsLayoutKind.Kind, + } + return convertRowsLayoutSpec_V2alpha1_to_V2beta1(&in.RowsLayoutKind.Spec, &out.RowsLayoutKind.Spec, scope) + } + + if in.AutoGridLayoutKind != nil { + out.AutoGridLayoutKind = &dashv2beta1.DashboardAutoGridLayoutKind{ + Kind: in.AutoGridLayoutKind.Kind, + } + return convertAutoGridLayoutSpec_V2alpha1_to_V2beta1(&in.AutoGridLayoutKind.Spec, &out.AutoGridLayoutKind.Spec, scope) + } + + if in.TabsLayoutKind != nil { + out.TabsLayoutKind = &dashv2beta1.DashboardTabsLayoutKind{ + Kind: in.TabsLayoutKind.Kind, + } + return convertTabsLayoutSpec_V2alpha1_to_V2beta1(&in.TabsLayoutKind.Spec, &out.TabsLayoutKind.Spec, scope) + } + + return nil +} + +func convertGridLayoutSpec_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardGridLayoutSpec, out *dashv2beta1.DashboardGridLayoutSpec, scope conversion.Scope) error { + out.Items = make([]dashv2beta1.DashboardGridLayoutItemKind, len(in.Items)) + for i, item := range in.Items { + out.Items[i] = dashv2beta1.DashboardGridLayoutItemKind{ + Kind: item.Kind, + Spec: dashv2beta1.DashboardGridLayoutItemSpec{ + X: item.Spec.X, + Y: item.Spec.Y, + Width: item.Spec.Width, + Height: item.Spec.Height, + Element: dashv2beta1.DashboardElementReference{ + Kind: item.Spec.Element.Kind, + Name: item.Spec.Element.Name, + }, + Repeat: convertRepeatOptions_V2alpha1_to_V2beta1(item.Spec.Repeat), + }, + } + } + return nil +} + +func convertRowsLayoutSpec_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardRowsLayoutSpec, out *dashv2beta1.DashboardRowsLayoutSpec, scope conversion.Scope) error { + out.Rows = make([]dashv2beta1.DashboardRowsLayoutRowKind, len(in.Rows)) + for i, row := range in.Rows { + out.Rows[i] = dashv2beta1.DashboardRowsLayoutRowKind{ + Kind: row.Kind, + Spec: dashv2beta1.DashboardRowsLayoutRowSpec{ + Title: row.Spec.Title, + Collapse: row.Spec.Collapse, + HideHeader: row.Spec.HideHeader, + FillScreen: row.Spec.FillScreen, + ConditionalRendering: convertConditionalRenderingGroupKind_V2alpha1_to_V2beta1(row.Spec.ConditionalRendering), + Repeat: convertRowRepeatOptions_V2alpha1_to_V2beta1(row.Spec.Repeat), + }, + } + if err := convertRowLayout_V2alpha1_to_V2beta1(&row.Spec.Layout, &out.Rows[i].Spec.Layout, scope); err != nil { + return err + } + } + return nil +} + +func convertAutoGridLayoutSpec_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardAutoGridLayoutSpec, out *dashv2beta1.DashboardAutoGridLayoutSpec, scope conversion.Scope) error { + out.MaxColumnCount = in.MaxColumnCount + out.ColumnWidthMode = dashv2beta1.DashboardAutoGridLayoutSpecColumnWidthMode(in.ColumnWidthMode) + out.ColumnWidth = in.ColumnWidth + out.RowHeightMode = dashv2beta1.DashboardAutoGridLayoutSpecRowHeightMode(in.RowHeightMode) + out.RowHeight = in.RowHeight + out.FillScreen = in.FillScreen + + out.Items = make([]dashv2beta1.DashboardAutoGridLayoutItemKind, len(in.Items)) + for i, item := range in.Items { + out.Items[i] = dashv2beta1.DashboardAutoGridLayoutItemKind{ + Kind: item.Kind, + Spec: dashv2beta1.DashboardAutoGridLayoutItemSpec{ + Element: dashv2beta1.DashboardElementReference{ + Kind: item.Spec.Element.Kind, + Name: item.Spec.Element.Name, + }, + Repeat: convertAutoGridRepeatOptions_V2alpha1_to_V2beta1(item.Spec.Repeat), + ConditionalRendering: convertConditionalRenderingGroupKind_V2alpha1_to_V2beta1(item.Spec.ConditionalRendering), + }, + } + } + return nil +} + +func convertTabsLayoutSpec_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardTabsLayoutSpec, out *dashv2beta1.DashboardTabsLayoutSpec, scope conversion.Scope) error { + out.Tabs = make([]dashv2beta1.DashboardTabsLayoutTabKind, len(in.Tabs)) + for i, tab := range in.Tabs { + out.Tabs[i] = dashv2beta1.DashboardTabsLayoutTabKind{ + Kind: tab.Kind, + Spec: dashv2beta1.DashboardTabsLayoutTabSpec{ + Title: tab.Spec.Title, + ConditionalRendering: convertConditionalRenderingGroupKind_V2alpha1_to_V2beta1(tab.Spec.ConditionalRendering), + Repeat: convertTabRepeatOptions_V2alpha1_to_V2beta1(tab.Spec.Repeat), + }, + } + if err := convertTabLayout_V2alpha1_to_V2beta1(&tab.Spec.Layout, &out.Tabs[i].Spec.Layout, scope); err != nil { + return err + } + } + return nil +} + +func convertDashboardLink_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardDashboardLink, out *dashv2beta1.DashboardDashboardLink) { + out.Title = in.Title + out.Type = dashv2beta1.DashboardDashboardLinkType(in.Type) + out.Icon = in.Icon + out.Tooltip = in.Tooltip + out.Url = in.Url + out.Tags = in.Tags + out.AsDropdown = in.AsDropdown + out.TargetBlank = in.TargetBlank + out.IncludeVars = in.IncludeVars + out.KeepTime = in.KeepTime +} + +func convertDataLink_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardDataLink, out *dashv2beta1.DashboardDataLink) { + out.Title = in.Title + out.Url = in.Url + out.TargetBlank = in.TargetBlank +} + +func convertTimeSettings_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardTimeSettingsSpec, out *dashv2beta1.DashboardTimeSettingsSpec, scope conversion.Scope) error { + out.Timezone = in.Timezone + out.From = in.From + out.To = in.To + out.AutoRefresh = in.AutoRefresh + out.AutoRefreshIntervals = in.AutoRefreshIntervals + out.HideTimepicker = in.HideTimepicker + out.WeekStart = (*dashv2beta1.DashboardTimeSettingsSpecWeekStart)(in.WeekStart) + out.FiscalYearStartMonth = in.FiscalYearStartMonth + out.NowDelay = in.NowDelay + + // Convert quick ranges + if in.QuickRanges != nil { + out.QuickRanges = make([]dashv2beta1.DashboardTimeRangeOption, len(in.QuickRanges)) + for i, qr := range in.QuickRanges { + out.QuickRanges[i] = dashv2beta1.DashboardTimeRangeOption{ + Display: qr.Display, + From: qr.From, + To: qr.To, + } + } + } + + return nil +} + +func convertVariable_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardVariableKind, out *dashv2beta1.DashboardVariableKind, scope conversion.Scope) error { + if in.QueryVariableKind != nil { + out.QueryVariableKind = &dashv2beta1.DashboardQueryVariableKind{ + Kind: in.QueryVariableKind.Kind, + } + return convertQueryVariableSpec_V2alpha1_to_V2beta1(&in.QueryVariableKind.Spec, &out.QueryVariableKind.Spec, scope) + } + + if in.TextVariableKind != nil { + out.TextVariableKind = &dashv2beta1.DashboardTextVariableKind{ + Kind: in.TextVariableKind.Kind, + Spec: dashv2beta1.DashboardTextVariableSpec{ + Name: in.TextVariableKind.Spec.Name, + Current: convertVariableOption_V2alpha1_to_V2beta1(in.TextVariableKind.Spec.Current), + Query: in.TextVariableKind.Spec.Query, + Label: in.TextVariableKind.Spec.Label, + Hide: dashv2beta1.DashboardVariableHide(in.TextVariableKind.Spec.Hide), + SkipUrlSync: in.TextVariableKind.Spec.SkipUrlSync, + Description: in.TextVariableKind.Spec.Description, + }, + } + } + + if in.ConstantVariableKind != nil { + out.ConstantVariableKind = &dashv2beta1.DashboardConstantVariableKind{ + Kind: in.ConstantVariableKind.Kind, + Spec: dashv2beta1.DashboardConstantVariableSpec{ + Name: in.ConstantVariableKind.Spec.Name, + Query: in.ConstantVariableKind.Spec.Query, + Current: convertVariableOption_V2alpha1_to_V2beta1(in.ConstantVariableKind.Spec.Current), + Label: in.ConstantVariableKind.Spec.Label, + Hide: dashv2beta1.DashboardVariableHide(in.ConstantVariableKind.Spec.Hide), + SkipUrlSync: in.ConstantVariableKind.Spec.SkipUrlSync, + Description: in.ConstantVariableKind.Spec.Description, + }, + } + } + + if in.DatasourceVariableKind != nil { + out.DatasourceVariableKind = &dashv2beta1.DashboardDatasourceVariableKind{ + Kind: in.DatasourceVariableKind.Kind, + Spec: dashv2beta1.DashboardDatasourceVariableSpec{ + Name: in.DatasourceVariableKind.Spec.Name, + PluginId: in.DatasourceVariableKind.Spec.PluginId, + Refresh: dashv2beta1.DashboardVariableRefresh(in.DatasourceVariableKind.Spec.Refresh), + Regex: in.DatasourceVariableKind.Spec.Regex, + Current: convertVariableOption_V2alpha1_to_V2beta1(in.DatasourceVariableKind.Spec.Current), + Options: convertVariableOptions_V2alpha1_to_V2beta1(in.DatasourceVariableKind.Spec.Options), + Multi: in.DatasourceVariableKind.Spec.Multi, + IncludeAll: in.DatasourceVariableKind.Spec.IncludeAll, + AllValue: in.DatasourceVariableKind.Spec.AllValue, + Label: in.DatasourceVariableKind.Spec.Label, + Hide: dashv2beta1.DashboardVariableHide(in.DatasourceVariableKind.Spec.Hide), + SkipUrlSync: in.DatasourceVariableKind.Spec.SkipUrlSync, + Description: in.DatasourceVariableKind.Spec.Description, + AllowCustomValue: in.DatasourceVariableKind.Spec.AllowCustomValue, + }, + } + } + + if in.IntervalVariableKind != nil { + out.IntervalVariableKind = &dashv2beta1.DashboardIntervalVariableKind{ + Kind: in.IntervalVariableKind.Kind, + Spec: dashv2beta1.DashboardIntervalVariableSpec{ + Name: in.IntervalVariableKind.Spec.Name, + Query: in.IntervalVariableKind.Spec.Query, + Current: convertVariableOption_V2alpha1_to_V2beta1(in.IntervalVariableKind.Spec.Current), + Options: convertVariableOptions_V2alpha1_to_V2beta1(in.IntervalVariableKind.Spec.Options), + Auto: in.IntervalVariableKind.Spec.Auto, + AutoMin: in.IntervalVariableKind.Spec.AutoMin, + AutoCount: in.IntervalVariableKind.Spec.AutoCount, + Refresh: dashv2beta1.DashboardVariableRefresh(in.IntervalVariableKind.Spec.Refresh), + Label: in.IntervalVariableKind.Spec.Label, + Hide: dashv2beta1.DashboardVariableHide(in.IntervalVariableKind.Spec.Hide), + SkipUrlSync: in.IntervalVariableKind.Spec.SkipUrlSync, + Description: in.IntervalVariableKind.Spec.Description, + }, + } + } + + if in.CustomVariableKind != nil { + out.CustomVariableKind = &dashv2beta1.DashboardCustomVariableKind{ + Kind: in.CustomVariableKind.Kind, + Spec: dashv2beta1.DashboardCustomVariableSpec{ + Name: in.CustomVariableKind.Spec.Name, + Query: in.CustomVariableKind.Spec.Query, + Current: convertVariableOption_V2alpha1_to_V2beta1(in.CustomVariableKind.Spec.Current), + Options: convertVariableOptions_V2alpha1_to_V2beta1(in.CustomVariableKind.Spec.Options), + Multi: in.CustomVariableKind.Spec.Multi, + IncludeAll: in.CustomVariableKind.Spec.IncludeAll, + AllValue: in.CustomVariableKind.Spec.AllValue, + Label: in.CustomVariableKind.Spec.Label, + Hide: dashv2beta1.DashboardVariableHide(in.CustomVariableKind.Spec.Hide), + SkipUrlSync: in.CustomVariableKind.Spec.SkipUrlSync, + Description: in.CustomVariableKind.Spec.Description, + AllowCustomValue: in.CustomVariableKind.Spec.AllowCustomValue, + }, + } + } + + if in.GroupByVariableKind != nil { + out.GroupByVariableKind = &dashv2beta1.DashboardGroupByVariableKind{ + Kind: in.GroupByVariableKind.Kind, + Group: *in.GroupByVariableKind.Spec.Datasource.Type, + Datasource: &dashv2beta1.DashboardV2beta1GroupByVariableKindDatasource{ + Name: in.GroupByVariableKind.Spec.Datasource.Uid, + }, + Spec: dashv2beta1.DashboardGroupByVariableSpec{ + Name: in.GroupByVariableKind.Spec.Name, + DefaultValue: convertVariableOptionPtr_V2alpha1_to_V2beta1(in.GroupByVariableKind.Spec.DefaultValue), + Current: convertVariableOption_V2alpha1_to_V2beta1(in.GroupByVariableKind.Spec.Current), + Options: convertVariableOptions_V2alpha1_to_V2beta1(in.GroupByVariableKind.Spec.Options), + Multi: in.GroupByVariableKind.Spec.Multi, + Label: in.GroupByVariableKind.Spec.Label, + Hide: dashv2beta1.DashboardVariableHide(in.GroupByVariableKind.Spec.Hide), + SkipUrlSync: in.GroupByVariableKind.Spec.SkipUrlSync, + Description: in.GroupByVariableKind.Spec.Description, + }, + } + } + + if in.AdhocVariableKind != nil { + out.AdhocVariableKind = &dashv2beta1.DashboardAdhocVariableKind{ + Kind: in.AdhocVariableKind.Kind, + Group: *in.AdhocVariableKind.Spec.Datasource.Type, + Datasource: &dashv2beta1.DashboardV2beta1AdhocVariableKindDatasource{ + Name: in.AdhocVariableKind.Spec.Datasource.Uid, + }, + Spec: dashv2beta1.DashboardAdhocVariableSpec{ + Name: in.AdhocVariableKind.Spec.Name, + BaseFilters: convertAdHocFilters_V2alpha1_to_V2beta1(in.AdhocVariableKind.Spec.BaseFilters), + Filters: convertAdHocFilters_V2alpha1_to_V2beta1(in.AdhocVariableKind.Spec.Filters), + DefaultKeys: convertMetricFindValues_V2alpha1_to_V2beta1(in.AdhocVariableKind.Spec.DefaultKeys), + Label: in.AdhocVariableKind.Spec.Label, + Hide: dashv2beta1.DashboardVariableHide(in.AdhocVariableKind.Spec.Hide), + SkipUrlSync: in.AdhocVariableKind.Spec.SkipUrlSync, + Description: in.AdhocVariableKind.Spec.Description, + AllowCustomValue: in.AdhocVariableKind.Spec.AllowCustomValue, + }, + } + } + + return nil +} + +func convertQueryVariableSpec_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardQueryVariableSpec, out *dashv2beta1.DashboardQueryVariableSpec, scope conversion.Scope) error { + out.Name = in.Name + out.Current = convertVariableOption_V2alpha1_to_V2beta1(in.Current) + out.Label = in.Label + out.Hide = dashv2beta1.DashboardVariableHide(in.Hide) + out.Refresh = dashv2beta1.DashboardVariableRefresh(in.Refresh) + out.SkipUrlSync = in.SkipUrlSync + out.Description = in.Description + out.Regex = in.Regex + out.Sort = dashv2beta1.DashboardVariableSort(in.Sort) + out.Definition = in.Definition + out.Options = convertVariableOptions_V2alpha1_to_V2beta1(in.Options) + out.Multi = in.Multi + out.IncludeAll = in.IncludeAll + out.AllValue = in.AllValue + out.Placeholder = in.Placeholder + out.AllowCustomValue = in.AllowCustomValue + out.StaticOptions = convertVariableOptions_V2alpha1_to_V2beta1(in.StaticOptions) + out.StaticOptionsOrder = (*dashv2beta1.DashboardQueryVariableSpecStaticOptionsOrder)(in.StaticOptionsOrder) + + // Convert query - move datasource from variable spec to query + if err := convertDataQuery_V2alpha1_to_V2beta1(&in.Query, &out.Query, in.Datasource, scope); err != nil { + return err + } + + return nil +} + +func convertVariableOption_V2alpha1_to_V2beta1(in dashv2alpha1.DashboardVariableOption) dashv2beta1.DashboardVariableOption { + return dashv2beta1.DashboardVariableOption{ + Selected: in.Selected, + Text: dashv2beta1.DashboardStringOrArrayOfString(in.Text), + Value: dashv2beta1.DashboardStringOrArrayOfString(in.Value), + } +} + +func convertVariableOptionPtr_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardVariableOption) *dashv2beta1.DashboardVariableOption { + if in == nil { + return nil + } + out := convertVariableOption_V2alpha1_to_V2beta1(*in) + return &out +} + +func convertVariableOptions_V2alpha1_to_V2beta1(in []dashv2alpha1.DashboardVariableOption) []dashv2beta1.DashboardVariableOption { + if in == nil { + return nil + } + out := make([]dashv2beta1.DashboardVariableOption, len(in)) + for i, option := range in { + out[i] = convertVariableOption_V2alpha1_to_V2beta1(option) + } + return out +} + +func convertAdHocFilters_V2alpha1_to_V2beta1(in []dashv2alpha1.DashboardAdHocFilterWithLabels) []dashv2beta1.DashboardAdHocFilterWithLabels { + if in == nil { + return nil + } + out := make([]dashv2beta1.DashboardAdHocFilterWithLabels, len(in)) + for i, filter := range in { + out[i] = dashv2beta1.DashboardAdHocFilterWithLabels{ + Key: filter.Key, + Operator: filter.Operator, + Value: filter.Value, + Values: filter.Values, + KeyLabel: filter.KeyLabel, + ValueLabels: filter.ValueLabels, + ForceEdit: filter.ForceEdit, + Origin: filter.Origin, + Condition: filter.Condition, + } + } + return out +} + +func convertMetricFindValues_V2alpha1_to_V2beta1(in []dashv2alpha1.DashboardMetricFindValue) []dashv2beta1.DashboardMetricFindValue { + if in == nil { + return nil + } + out := make([]dashv2beta1.DashboardMetricFindValue, len(in)) + for i, value := range in { + out[i] = dashv2beta1.DashboardMetricFindValue{ + Text: value.Text, + Value: (*dashv2beta1.DashboardStringOrFloat64)(value.Value), + Group: value.Group, + Expandable: value.Expandable, + } + } + return out +} + +func convertRepeatOptions_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardRepeatOptions) *dashv2beta1.DashboardRepeatOptions { + if in == nil { + return nil + } + return &dashv2beta1.DashboardRepeatOptions{ + Mode: in.Mode, + Value: in.Value, + Direction: (*dashv2beta1.DashboardRepeatOptionsDirection)(in.Direction), + MaxPerRow: in.MaxPerRow, + } +} + +func convertRowRepeatOptions_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardRowRepeatOptions) *dashv2beta1.DashboardRowRepeatOptions { + if in == nil { + return nil + } + return &dashv2beta1.DashboardRowRepeatOptions{ + Mode: in.Mode, + Value: in.Value, + } +} + +func convertAutoGridRepeatOptions_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardAutoGridRepeatOptions) *dashv2beta1.DashboardAutoGridRepeatOptions { + if in == nil { + return nil + } + return &dashv2beta1.DashboardAutoGridRepeatOptions{ + Mode: in.Mode, + Value: in.Value, + } +} + +func convertTabRepeatOptions_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardTabRepeatOptions) *dashv2beta1.DashboardTabRepeatOptions { + if in == nil { + return nil + } + return &dashv2beta1.DashboardTabRepeatOptions{ + Mode: in.Mode, + Value: in.Value, + } +} + +func convertConditionalRenderingGroupKind_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardConditionalRenderingGroupKind) *dashv2beta1.DashboardConditionalRenderingGroupKind { + if in == nil { + return nil + } + + out := &dashv2beta1.DashboardConditionalRenderingGroupKind{ + Kind: in.Kind, + Spec: dashv2beta1.DashboardConditionalRenderingGroupSpec{ + Visibility: dashv2beta1.DashboardConditionalRenderingGroupSpecVisibility(in.Spec.Visibility), + Condition: dashv2beta1.DashboardConditionalRenderingGroupSpecCondition(in.Spec.Condition), + Items: make([]dashv2beta1.DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind, len(in.Spec.Items)), + }, + } + + // Convert each item in the Items slice + for i, item := range in.Spec.Items { + out.Spec.Items[i] = dashv2beta1.DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind{} + + if item.ConditionalRenderingVariableKind != nil { + out.Spec.Items[i].ConditionalRenderingVariableKind = &dashv2beta1.DashboardConditionalRenderingVariableKind{ + Kind: item.ConditionalRenderingVariableKind.Kind, + Spec: dashv2beta1.DashboardConditionalRenderingVariableSpec{ + Variable: item.ConditionalRenderingVariableKind.Spec.Variable, + Operator: dashv2beta1.DashboardConditionalRenderingVariableSpecOperator(item.ConditionalRenderingVariableKind.Spec.Operator), + Value: item.ConditionalRenderingVariableKind.Spec.Value, + }, + } + } + + if item.ConditionalRenderingDataKind != nil { + out.Spec.Items[i].ConditionalRenderingDataKind = &dashv2beta1.DashboardConditionalRenderingDataKind{ + Kind: item.ConditionalRenderingDataKind.Kind, + Spec: dashv2beta1.DashboardConditionalRenderingDataSpec{ + Value: item.ConditionalRenderingDataKind.Spec.Value, + }, + } + } + + if item.ConditionalRenderingTimeRangeSizeKind != nil { + out.Spec.Items[i].ConditionalRenderingTimeRangeSizeKind = &dashv2beta1.DashboardConditionalRenderingTimeRangeSizeKind{ + Kind: item.ConditionalRenderingTimeRangeSizeKind.Kind, + Spec: dashv2beta1.DashboardConditionalRenderingTimeRangeSizeSpec{ + Value: item.ConditionalRenderingTimeRangeSizeKind.Spec.Value, + }, + } + } + } + + return out +} + +func convertRowLayout_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind, out *dashv2beta1.DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind, scope conversion.Scope) error { + // Handle the different union type orderings by converting through the main layout function + // Create a temporary variable with the correct type ordering + var tempIn dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind + var tempOut dashv2beta1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind + + // Copy the layout data with the correct ordering + if in.GridLayoutKind != nil { + tempIn.GridLayoutKind = in.GridLayoutKind + } + if in.RowsLayoutKind != nil { + tempIn.RowsLayoutKind = in.RowsLayoutKind + } + if in.AutoGridLayoutKind != nil { + tempIn.AutoGridLayoutKind = in.AutoGridLayoutKind + } + if in.TabsLayoutKind != nil { + tempIn.TabsLayoutKind = in.TabsLayoutKind + } + + if err := convertLayout_V2alpha1_to_V2beta1(&tempIn, &tempOut, scope); err != nil { + return err + } + + // Copy back to the output with the correct ordering + if tempOut.GridLayoutKind != nil { + out.GridLayoutKind = tempOut.GridLayoutKind + } + if tempOut.RowsLayoutKind != nil { + out.RowsLayoutKind = tempOut.RowsLayoutKind + } + if tempOut.AutoGridLayoutKind != nil { + out.AutoGridLayoutKind = tempOut.AutoGridLayoutKind + } + if tempOut.TabsLayoutKind != nil { + out.TabsLayoutKind = tempOut.TabsLayoutKind + } + + return nil +} + +func convertTabLayout_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, out *dashv2beta1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, scope conversion.Scope) error { + // Handle the different union type orderings by converting through the main layout function + // Create a temporary variable with the correct type ordering + var tempIn dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind + var tempOut dashv2beta1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind + + // Copy the layout data with the correct ordering + if in.GridLayoutKind != nil { + tempIn.GridLayoutKind = in.GridLayoutKind + } + if in.RowsLayoutKind != nil { + tempIn.RowsLayoutKind = in.RowsLayoutKind + } + if in.AutoGridLayoutKind != nil { + tempIn.AutoGridLayoutKind = in.AutoGridLayoutKind + } + if in.TabsLayoutKind != nil { + tempIn.TabsLayoutKind = in.TabsLayoutKind + } + + if err := convertLayout_V2alpha1_to_V2beta1(&tempIn, &tempOut, scope); err != nil { + return err + } + + // Copy back to the output with the correct ordering + if tempOut.GridLayoutKind != nil { + out.GridLayoutKind = tempOut.GridLayoutKind + } + if tempOut.RowsLayoutKind != nil { + out.RowsLayoutKind = tempOut.RowsLayoutKind + } + if tempOut.AutoGridLayoutKind != nil { + out.AutoGridLayoutKind = tempOut.AutoGridLayoutKind + } + if tempOut.TabsLayoutKind != nil { + out.TabsLayoutKind = tempOut.TabsLayoutKind + } + + return nil +} 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/folder/pkg/apis/folder_manifest.go b/apps/folder/pkg/apis/folder_manifest.go index 8628efe6537..e6231165862 100644 --- a/apps/folder/pkg/apis/folder_manifest.go +++ b/apps/folder/pkg/apis/folder_manifest.go @@ -15,8 +15,6 @@ import ( v1beta1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" ) -var () - var appManifestData = app.ManifestData{ AppName: "folder", Group: "folder.grafana.app", 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 9e0d9cbf6d8..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 @@ -23,6 +23,7 @@ require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/BurntSushi/toml v1.5.0 // indirect github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apache/arrow-go/v18 v18.3.0 // indirect github.com/armon/go-metrics v0.4.1 // indirect github.com/aws/aws-sdk-go-v2 v1.36.5 // indirect @@ -33,7 +34,9 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 // indirect github.com/aws/smithy-go v1.22.4 // indirect + github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver v3.5.1+incompatible // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/bluele/gcache v0.0.2 // indirect github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect @@ -45,6 +48,8 @@ require ( github.com/coreos/go-systemd/v22 v22.5.0 // indirect 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 @@ -73,14 +78,14 @@ 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.1.6 // indirect + github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 // indirect github.com/grafana/grafana-plugin-sdk-go v0.278.0 // indirect github.com/grafana/grafana/pkg/apiserver v0.0.0-20250627191313-2f1a6ae1712b // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect @@ -132,6 +137,8 @@ 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 github.com/oklog/run v1.1.0 // indirect @@ -152,11 +159,13 @@ 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 github.com/spf13/pflag v1.0.7 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/thomaspoignant/go-feature-flag v1.42.0 // indirect github.com/tjhop/slog-gokit v0.1.3 // indirect github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 // indirect github.com/unknwon/com v1.0.1 // indirect @@ -195,7 +204,7 @@ require ( gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/grpc v1.74.0 // indirect + google.golang.org/grpc v1.74.2 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect gopkg.in/inf.v0 v0.9.1 // indirect @@ -207,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 d6f3b9daad3..00347e0818c 100644 --- a/apps/investigations/go.sum +++ b/apps/investigations/go.sum @@ -8,10 +8,10 @@ dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= -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.9.0 h1:OVoM452qUFBrX+URdH3VpR299ma4kfom0yB0URYky9g= -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/azcore v1.18.1 h1:Wc1ml6QlJs2BHQ/9Bqu1jiyggbsSjramq2oUmp5WeIo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= @@ -49,6 +49,8 @@ github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vS github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/apache/arrow-go/v18 v18.3.0 h1:Xq4A6dZj9Nu33sqZibzn012LNnewkTUlfKVUFD/RX/I= github.com/apache/arrow-go/v18 v18.3.0/go.mod h1:eEM1DnUTHhgGAjf/ChvOAQbUQ+EPohtDrArffvUjPg8= github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= @@ -79,6 +81,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 h1:NFOJ/NXEGV4Rq//71Hs1jC/NvPs1 github.com/aws/aws-sdk-go-v2/service/sts v1.34.0/go.mod h1:7ph2tGpfQvwzgistp2+zga9f+bCjlQJPkPUmMgDSD7w= github.com/aws/smithy-go v1.22.4 h1:uqXzVZNuNexwc/xrh6Tb56u89WDlJY6HS+KC0S4QSjw= github.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0= +github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df/go.mod h1:hiVxq5OP2bUGBRNS3Z/bt/reCLFNbdcST6gISi1fiOM= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -86,6 +90,8 @@ github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw= @@ -143,6 +149,8 @@ github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3/go.mod h1:zA github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dhui/dktest v0.3.0/go.mod h1:cyzIUfGsBEbZ6BT7tnXqAShHSXCZhSNmFl70sZ7c1yc= +github.com/diegoholiveira/jsonlogic/v3 v3.7.4 h1:92HSmB9bwM/o0ZvrCpcvTP2EsPXSkKtAniIr2W/dcIM= +github.com/diegoholiveira/jsonlogic/v3 v3.7.4/go.mod h1:OYRb6FSTVmMM+MNQ7ElmMsczyNSepw+OU4Z8emDSi4w= github.com/dlmiddlecote/sqlstats v1.0.2 h1:gSU11YN23D/iY50A2zVYwgXgy072khatTsIW6UPjUtI= github.com/dlmiddlecote/sqlstats v1.0.2/go.mod h1:0CWaIh/Th+z2aI6Q9Jpfg/o21zmGxWhbByHgQSCUQvY= github.com/docker/distribution v2.7.0+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= @@ -162,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= @@ -261,8 +271,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= +github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-migrate/migrate/v4 v4.7.0 h1:gONcHxHApDTKXDyLH/H97gEHmpu1zcnnbAaq2zgrPrs= github.com/golang-migrate/migrate/v4 v4.7.0/go.mod h1:Qvut3N4xKWjoH3sokBccML6WyHSnggXm/DvMMnTsQIc= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -323,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= @@ -333,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.1.6 h1:OfCkitCuomzZKW1WYHrG8MxKwtMhALb7jqoj+487eTg= -github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6/go.mod h1:V7y2BmsWxS3A9Ohebwn4OiSfJJqi//4JQydQ8fHTduo= +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= @@ -405,8 +415,8 @@ github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80s github.com/jackc/pgx v3.2.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= github.com/jaegertracing/jaeger-idl v0.5.0 h1:zFXR5NL3Utu7MhPg8ZorxtCBjHrL3ReM1VoB65FOFGE= github.com/jaegertracing/jaeger-idl v0.5.0/go.mod h1:ON90zFo9eoyXrt9F/KN8YeF3zxcnujaisMweFY/rg5k= -github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= -github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= +github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= +github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= github.com/jmespath-community/go-jmespath v1.1.1 h1:bFikPhsi/FdmlZhVgSCd2jj1e7G/rw+zyQfyg5UF+L4= @@ -532,6 +542,10 @@ 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= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= @@ -563,6 +577,7 @@ github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0Mw github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= @@ -615,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= @@ -671,6 +688,10 @@ github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf h1:Z2X3Os7oRzpdJ7 github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0= github.com/tetratelabs/wazero v1.8.2 h1:yIgLR/b2bN31bjxwXHD8a3d+BogigR952csSDdLYEv4= github.com/tetratelabs/wazero v1.8.2/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs= +github.com/thejerf/slogassert v0.3.4 h1:VoTsXixRbXMrRSSxDjYTiEDCM4VWbsYPW5rB/hX24kM= +github.com/thejerf/slogassert v0.3.4/go.mod h1:0zn9ISLVKo1aPMTqcGfG1o6dWwt+Rk574GlUxHD4rs8= +github.com/thomaspoignant/go-feature-flag v1.42.0 h1:C7embmOTzaLyRki+OoU2RvtVjJE9IrvgBA2C1mRN1lc= +github.com/thomaspoignant/go-feature-flag v1.42.0/go.mod h1:y0QiWH7chHWhGATb/+XqwAwErORmPSH2MUsQlCmmWlM= github.com/tidwall/pretty v0.0.0-20180105212114-65a9db5fad51/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tjhop/slog-gokit v0.1.3 h1:6SdexP3UIeg93KLFeiM1Wp1caRwdTLgsD/THxBUy1+o= github.com/tjhop/slog-gokit v0.1.3/go.mod h1:Bbu5v2748qpAWH7k6gse/kw3076IJf6owJmh7yArmJs= @@ -701,8 +722,8 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE= go.mongodb.org/mongo-driver v1.1.0/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= -go.mongodb.org/mongo-driver v1.16.1 h1:rIVLL3q0IHM39dvE+z2ulZLp9ENZKThVfuvN/IiN4l8= -go.mongodb.org/mongo-driver v1.16.1/go.mod h1:oB6AhJQvFQL4LEHyXi6aJzQJtBiTQHiAd83l0GdFaiw= +go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= +go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= @@ -912,8 +933,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go. google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.74.0 h1:sxRSkyLxlceWQiqDofxDot3d4u7DyoHPc7SBXMj8gGY= -google.golang.org/grpc v1.74.0/go.mod h1:NZUaK8dAMUfzhK6uxZ+9511LtOrk73UGWOFoNvz7z+s= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -979,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 0198da5b976..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 @@ -75,7 +75,7 @@ require ( gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/grpc v1.74.0 // indirect + google.golang.org/grpc v1.74.2 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/apps/playlist/go.sum b/apps/playlist/go.sum index 5d053231eb3..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= @@ -197,8 +197,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1: google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.74.0 h1:sxRSkyLxlceWQiqDofxDot3d4u7DyoHPc7SBXMj8gGY= -google.golang.org/grpc v1.74.0/go.mod h1:NZUaK8dAMUfzhK6uxZ+9511LtOrk73UGWOFoNvz7z+s= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 b47cd2748cc..45567c3943a 100644 --- a/apps/secret/go.mod +++ b/apps/secret/go.mod @@ -3,10 +3,10 @@ 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.0 + google.golang.org/grpc v1.74.2 google.golang.org/protobuf v1.36.6 gopkg.in/yaml.v3 v3.0.1 k8s.io/apimachinery v0.33.3 @@ -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 eb2dfb8c73e..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= @@ -162,8 +162,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.74.0 h1:sxRSkyLxlceWQiqDofxDot3d4u7DyoHPc7SBXMj8gGY= -google.golang.org/grpc v1.74.0/go.mod h1:NZUaK8dAMUfzhK6uxZ+9511LtOrk73UGWOFoNvz7z+s= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/apps/secret/pkg/apis/secret_manifest.go b/apps/secret/pkg/apis/secret_manifest.go index e8ff6dbca3a..512e381f77d 100644 --- a/apps/secret/pkg/apis/secret_manifest.go +++ b/apps/secret/pkg/apis/secret_manifest.go @@ -15,8 +15,6 @@ import ( v1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" ) -var () - var appManifestData = app.ManifestData{ AppName: "secret", Group: "secret.grafana.app", diff --git a/conf/provisioning/sample/dashboard-v1-annotations.json b/conf/provisioning/sample/dashboard-v1-annotations.json new file mode 100644 index 00000000000..7e90452a2a5 --- /dev/null +++ b/conf/provisioning/sample/dashboard-v1-annotations.json @@ -0,0 +1,257 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v1beta1", + "metadata": { + "name": "test-v1-annotations", + "annotations": { + "hello": "world" + }, + "labels": { + "region": "west" + } + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": false, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + }, + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "enable": true, + "hide": false, + "iconColor": "blue", + "name": "testdata-annos", + "target": { + "lines": 10, + "refId": "Anno", + "scenarioId": "annotations" + } + }, + { + "enable": true, + "hide": false, + "iconColor": "blue", + "name": "no-ds-testdata-annos", + "target": { + "lines": 10, + "refId": "Anno", + "scenarioId": "annotations" + } + }, + { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, + "enable": true, + "hide": false, + "iconColor": "yellow", + "name": "prom-annos", + "target": { + "expr": "{action=\"add_client\"}", + "interval": "", + "lines": 10, + "refId": "Anno", + "scenarioId": "annotations" + } + }, + { + "enable": true, + "hide": false, + "iconColor": "yellow", + "name": "no-ds-prom-annos", + "target": { + "expr": "{action=\"add_client\"}", + "interval": "", + "lines": 10, + "refId": "Anno", + "scenarioId": "annotations" + } + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "PBBCEC2D313BC06C3" + }, + "enable": true, + "hide": false, + "iconColor": "red", + "name": "postgress-annos", + "target": { + "editorMode": "builder", + "format": "table", + "lines": 10, + "rawSql": "", + "refId": "Anno", + "scenarioId": "annotations", + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + } + } + }, + { + "datasource": { + "type": "elasticsearch", + "uid": "gdev-elasticsearch" + }, + "enable": true, + "hide": false, + "iconColor": "red", + "name": "elastic - annos", + "tagsField": "asd", + "target": { + "lines": 10, + "query": "test query", + "refId": "Anno", + "scenarioId": "annotations" + }, + "textField": "asd", + "timeEndField": "asdas", + "timeField": "asd" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.1.0-pre", + "targets": [ + { + "refId": "A" + } + ], + "title": "New panel", + "type": "timeseries" + } + ], + "preload": false, + "schemaVersion": 41, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Test: V1 dashboard with annotations", + "version": 8 + } +} diff --git a/conf/provisioning/sample/dashboard-v1beta1.json b/conf/provisioning/sample/dashboard-v1beta1.json new file mode 100644 index 00000000000..1d236620b5e --- /dev/null +++ b/conf/provisioning/sample/dashboard-v1beta1.json @@ -0,0 +1,257 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v1beta1", + "metadata": { + "name": "test-v1beta1-annotations", + "annotations": { + "hello": "world" + }, + "labels": { + "region": "west" + } + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": false, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + }, + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "enable": true, + "hide": false, + "iconColor": "blue", + "name": "testdata-annos", + "target": { + "lines": 10, + "refId": "Anno", + "scenarioId": "annotations" + } + }, + { + "enable": true, + "hide": false, + "iconColor": "blue", + "name": "no-ds-testdata-annos", + "target": { + "lines": 10, + "refId": "Anno", + "scenarioId": "annotations" + } + }, + { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, + "enable": true, + "hide": false, + "iconColor": "yellow", + "name": "prom-annos", + "target": { + "expr": "{action=\"add_client\"}", + "interval": "", + "lines": 10, + "refId": "Anno", + "scenarioId": "annotations" + } + }, + { + "enable": true, + "hide": false, + "iconColor": "yellow", + "name": "no-ds-prom-annos", + "target": { + "expr": "{action=\"add_client\"}", + "interval": "", + "lines": 10, + "refId": "Anno", + "scenarioId": "annotations" + } + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "PBBCEC2D313BC06C3" + }, + "enable": true, + "hide": false, + "iconColor": "red", + "name": "postgress-annos", + "target": { + "editorMode": "builder", + "format": "table", + "lines": 10, + "rawSql": "", + "refId": "Anno", + "scenarioId": "annotations", + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + } + } + }, + { + "datasource": { + "type": "elasticsearch", + "uid": "gdev-elasticsearch" + }, + "enable": true, + "hide": false, + "iconColor": "red", + "name": "elastic - annos", + "tagsField": "asd", + "target": { + "lines": 10, + "query": "test query", + "refId": "Anno", + "scenarioId": "annotations" + }, + "textField": "asd", + "timeEndField": "asdas", + "timeField": "asd" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.1.0-pre", + "targets": [ + { + "refId": "A" + } + ], + "title": "New panel", + "type": "timeseries" + } + ], + "preload": false, + "schemaVersion": 41, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Test: v1alpha1 dashboard with annotations", + "version": 8 + } +} diff --git a/conf/provisioning/sample/dashboard-v2alpha1.json b/conf/provisioning/sample/dashboard-v2alpha1.json new file mode 100644 index 00000000000..b078428c864 --- /dev/null +++ b/conf/provisioning/sample/dashboard-v2alpha1.json @@ -0,0 +1,1025 @@ +{ + "apiVersion": "dashboard.grafana.app/v2alpha1", + "kind": "Dashboard", + "metadata": { + "name": "test-v2alpha1-annotations" + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "builtIn": true, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": false, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "query": { + "kind": "grafana", + "spec": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + } + } + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "builtIn": false, + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "enable": true, + "hide": false, + "iconColor": "blue", + "name": "testdata-annos", + "query": { + "kind": "grafana-testdata-datasource", + "spec": { + "lines": 10, + "refId": "Anno", + "scenarioId": "annotations" + } + } + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "builtIn": false, + "enable": true, + "hide": false, + "iconColor": "blue", + "name": "no-ds-testdata-annos", + "query": { + "kind": "grafana", + "spec": { + "lines": 10, + "refId": "Anno", + "scenarioId": "annotations" + } + } + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "builtIn": false, + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, + "enable": true, + "hide": false, + "iconColor": "yellow", + "name": "prom-annos", + "query": { + "kind": "prometheus", + "spec": { + "expr": "{action=\"add_client\"}", + "interval": "", + "lines": 10, + "refId": "Anno", + "scenarioId": "annotations" + } + } + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "builtIn": false, + "enable": true, + "hide": false, + "iconColor": "yellow", + "name": "no-ds-prom-annos", + "query": { + "kind": "grafana", + "spec": { + "expr": "{action=\"add_client\"}", + "interval": "", + "lines": 10, + "refId": "Anno", + "scenarioId": "annotations" + } + } + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "builtIn": false, + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "PBBCEC2D313BC06C3" + }, + "enable": true, + "hide": false, + "iconColor": "red", + "name": "postgress-annos", + "query": { + "kind": "grafana-postgresql-datasource", + "spec": { + "editorMode": "builder", + "format": "table", + "lines": 10, + "rawSql": "", + "refId": "Anno", + "scenarioId": "annotations", + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + } + } + } + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "builtIn": false, + "datasource": { + "type": "elasticsearch", + "uid": "gdev-elasticsearch" + }, + "enable": true, + "hide": false, + "iconColor": "red", + "legacyOptions": { + "tagsField": "asd", + "textField": "asd", + "timeEndField": "asdas", + "timeField": "asd" + }, + "name": "elastic - annos", + "query": { + "kind": "elasticsearch", + "spec": { + "lines": 10, + "query": "test query", + "refId": "Anno", + "scenarioId": "annotations" + } + } + } + } + ], + "cursorSync": "Off", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "datasource": { + "uid": "gdev-testdata" + }, + "query": { + "kind": "grafana-testdata-datasource", + "spec": { + "scenarioId": "random_walk", + "seriesCount": 3 + } + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 1, + "links": [], + "title": "With ds ref (gdev-testdata) -> use ds ref", + "vizConfig": { + "kind": "timeseries", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.1.0-pre" + } + } + } + }, + "panel-2": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "kind": "grafana-testdata-datasource", + "spec": { + "scenarioId": "random_walk", + "seriesCount": 4 + } + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 2, + "links": [], + "title": "No ds ref -> fallback to first testdata datasource", + "vizConfig": { + "kind": "stat", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.1.0-pre" + } + } + } + }, + "panel-3": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "kind": "prometheus", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "rate(counters_requests[$__rate_interval])", + "fullMetaSearch": false, + "includeNullMetadata": false, + "instant": false, + "legendFormat": "__auto", + "range": true, + "useBackend": false + } + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 3, + "links": [], + "title": "No ds ref -> fallback to first prometheus datasource or default", + "vizConfig": { + "kind": "timeseries", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.1.0-pre" + } + } + } + }, + "panel-4": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "datasource": { + "uid": "gdev-prometheus" + }, + "query": { + "kind": "prometheus", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "rate(counters_requests[$__rate_interval])", + "fullMetaSearch": false, + "includeNullMetadata": false, + "instant": false, + "legendFormat": "__auto", + "range": true, + "useBackend": false + } + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 4, + "links": [], + "title": "With ds ref (gdev-prometheus) -> use ds ref", + "vizConfig": { + "kind": "timeseries", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.1.0-pre" + } + } + } + }, + "panel-5": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "datasource": { + "uid": "gdev-prometheus" + }, + "query": { + "kind": "prometheus", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "rate(counters_requests{server=\"backend-01\"}[$__rate_interval])", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "__auto", + "range": true, + "useBackend": false + } + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "datasource": { + "uid": "gdev-testdata" + }, + "query": { + "kind": "grafana-testdata-datasource", + "spec": {} + }, + "refId": "B" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 5, + "links": [], + "title": "Mixed with ds refs -> use ds ref", + "vizConfig": { + "kind": "timeseries", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.1.0-pre" + } + } + } + }, + "panel-6": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "kind": "prometheus", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "rate(counters_requests{server=\"backend-01\"}[$__rate_interval])", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "__auto", + "range": true, + "useBackend": false + } + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "kind": "grafana-testdata-datasource", + "spec": {} + }, + "refId": "B" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 6, + "links": [], + "title": "Mixed without ds refs -> use first datasource of type or default", + "vizConfig": { + "kind": "timeseries", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.1.0-pre" + } + } + } + } + }, + "layout": { + "kind": "AutoGridLayout", + "spec": { + "columnWidthMode": "standard", + "items": [ + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-3" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-4" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-1" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-5" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-6" + } + } + } + ], + "maxColumnCount": 3, + "rowHeightMode": "standard" + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "autoRefresh": "", + "autoRefreshIntervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], + "fiscalYearStartMonth": 0, + "from": "now-6h", + "hideTimepicker": false, + "timezone": "browser", + "to": "now" + }, + "title": "Test: v2alpha1 dashboard with annotations", + "description": "this dasboard contains cases for migrating to v2beta1", + "variables": [ + { + "kind": "QueryVariable", + "spec": { + "allowCustomValue": true, + "current": { + "text": "200", + "value": "200" + }, + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, + "definition": "label_values(status_code)", + "description": "Prometheus query variable with reference", + "hide": "dontHide", + "includeAll": false, + "multi": false, + "name": "promDsVar", + "options": [], + "query": { + "kind": "prometheus", + "spec": { + "qryType": 1, + "query": "label_values(status_code)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + } + }, + "refresh": "onDashboardLoad", + "regex": "", + "skipUrlSync": false, + "sort": "disabled" + } + }, + { + "kind": "AdhocVariable", + "spec": { + "allowCustomValue": true, + "baseFilters": [], + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, + "defaultKeys": [], + "filters": [], + "hide": "dontHide", + "name": "filtersProm", + "skipUrlSync": false + } + }, + { + "kind": "QueryVariable", + "spec": { + "allowCustomValue": true, + "current": { + "text": "", + "value": "" + }, + "hide": "dontHide", + "includeAll": false, + "multi": false, + "name": "query0", + "options": [], + "query": { + "kind": "grafana", + "spec": { + "__legacyStringValue": "" + } + }, + "refresh": "onDashboardLoad", + "regex": "", + "skipUrlSync": false, + "sort": "disabled" + } + }, + { + "kind": "GroupByVariable", + "spec": { + "current": { + "text": [], + "value": [] + }, + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, + "hide": "dontHide", + "multi": true, + "name": "groupByWithDs", + "options": [], + "skipUrlSync": false + } + } + ] + } +} diff --git a/devenv/docker/blocks/auth/authentik/docker-compose.yaml b/devenv/docker/blocks/auth/authentik/docker-compose.yaml index 5457ed51460..21b70ad7376 100644 --- a/devenv/docker/blocks/auth/authentik/docker-compose.yaml +++ b/devenv/docker/blocks/auth/authentik/docker-compose.yaml @@ -35,8 +35,8 @@ AUTHENTIK_HOST: http://authentik:9000 AUTHENTIK_INSECURE: "true" AUTHENTIK_TOKEN: 77vDKwFWqCAsD9ykbH6vsGuMHtloM6urfeglrR9KRhWFcABtwmGGiQWHQStw - links: - - "authentik:authentik" + depends_on: + - "authentik" authentik: image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2023.10.6} @@ -61,9 +61,6 @@ - ./certs:/certs - ./custom-templates:/templates - ./geoip:/geoip - links: - - "authentikdb:authentikdb" - - "authentikredis:authentikredis" authentik-worker: image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2023.10.6} @@ -86,6 +83,3 @@ - ./certs:/certs - ./custom-templates:/templates - ./geoip:/geoip - links: - - "authentikdb:authentikdb" - - "authentikredis:authentikredis" diff --git a/devenv/docker/blocks/auth/jwt_proxy/docker-compose.yaml b/devenv/docker/blocks/auth/jwt_proxy/docker-compose.yaml index 552664f1c97..de6fedd097f 100644 --- a/devenv/docker/blocks/auth/jwt_proxy/docker-compose.yaml +++ b/devenv/docker/blocks/auth/jwt_proxy/docker-compose.yaml @@ -25,8 +25,6 @@ - 8087:8080 depends_on: - oauthkeycloakdb - links: - - "oauthkeycloakdb:oauthkeycloakdb" restart: unless-stopped oauthproxy: @@ -54,4 +52,4 @@ - "env.grafana.local:host-gateway" ports: - 8088:8088 - restart: unless-stopped \ No newline at end of file + restart: unless-stopped diff --git a/devenv/docker/blocks/auth/oauth/docker-compose.yaml b/devenv/docker/blocks/auth/oauth/docker-compose.yaml index ba88c5d9e8f..a9ac08fd57e 100644 --- a/devenv/docker/blocks/auth/oauth/docker-compose.yaml +++ b/devenv/docker/blocks/auth/oauth/docker-compose.yaml @@ -25,6 +25,4 @@ - 8087:8080 depends_on: - oauthkeycloakdb - links: - - "oauthkeycloakdb:oauthkeycloakdb" restart: unless-stopped diff --git a/devenv/docker/blocks/collectd/docker-compose.yaml b/devenv/docker/blocks/collectd/docker-compose.yaml index c5e189b58d8..8a263eb6c54 100644 --- a/devenv/docker/blocks/collectd/docker-compose.yaml +++ b/devenv/docker/blocks/collectd/docker-compose.yaml @@ -7,5 +7,5 @@ GRAPHITE_PREFIX: collectd. REPORT_BY_CPU: 'false' COLLECT_INTERVAL: 10 - links: + depends_on: - graphite diff --git a/devenv/docker/blocks/influxdb/docker-compose.yaml b/devenv/docker/blocks/influxdb/docker-compose.yaml index 244bb472107..0a50a257e7f 100644 --- a/devenv/docker/blocks/influxdb/docker-compose.yaml +++ b/devenv/docker/blocks/influxdb/docker-compose.yaml @@ -17,8 +17,6 @@ telegraf: image: telegraf:latest - links: - - influxdb depends_on: - influxdb volumes: diff --git a/devenv/docker/blocks/influxdb1/docker-compose.yaml b/devenv/docker/blocks/influxdb1/docker-compose.yaml index 100c3b4c80a..66743f2a1a5 100644 --- a/devenv/docker/blocks/influxdb1/docker-compose.yaml +++ b/devenv/docker/blocks/influxdb1/docker-compose.yaml @@ -14,7 +14,7 @@ fake-influxdb1-data: image: grafana/fake-data-gen - links: + depends_on: - influxdb1 environment: FD_SERVER: influxdb1 @@ -23,7 +23,7 @@ telegraf-influxdb1: image: telegraf:latest - links: + depends_on: - influxdb1 volumes: - ./docker/blocks/influxdb1/telegraf.conf:/etc/telegraf/telegraf.conf:ro diff --git a/devenv/docker/blocks/tempo/docker-compose.yaml b/devenv/docker/blocks/tempo/docker-compose.yaml index bdac352a523..89ef5c43ae3 100644 --- a/devenv/docker/blocks/tempo/docker-compose.yaml +++ b/devenv/docker/blocks/tempo/docker-compose.yaml @@ -11,7 +11,7 @@ JAEGER_SAMPLER_PARAM: 1 labels: namespace: tns - logging: + logging: driver: loki options: loki-url: 'http://localhost:3100/api/prom/push' @@ -27,7 +27,7 @@ command: - '-log.level=debug' - 'http://db' - links: + depends_on: - db ports: - 0.0.0.0:8001:80 @@ -38,7 +38,7 @@ JAEGER_SAMPLER_PARAM: 1 labels: namespace: tns - logging: + logging: driver: loki options: loki-url: 'http://localhost:3100/api/prom/push' @@ -54,7 +54,7 @@ command: - '-log.level=debug' - 'http://app' - links: + depends_on: - app ports: - 0.0.0.0:8002:80 @@ -65,7 +65,7 @@ JAEGER_SAMPLER_PARAM: 1 labels: namespace: tns - logging: + logging: driver: loki options: loki-url: 'http://localhost:3100/api/prom/push' @@ -93,7 +93,7 @@ - --enable-feature=native-histograms volumes: - ./docker/blocks/tempo/prometheus.yaml:/etc/prometheus.yaml - links: + depends_on: - app - db - loadgen @@ -101,7 +101,7 @@ - "9090:9090" labels: namespace: monitoring - logging: + logging: driver: loki options: loki-url: 'http://localhost:3100/api/prom/push' @@ -117,7 +117,7 @@ - "3100:3100" labels: namespace: monitoring - logging: + logging: driver: loki options: loki-url: 'http://localhost:3100/api/prom/push' diff --git a/devenv/docker/blocks/traefik/docker-compose.yml b/devenv/docker/blocks/traefik/docker-compose.yml index e62805a37f5..b4908e36a2b 100644 --- a/devenv/docker/blocks/traefik/docker-compose.yml +++ b/devenv/docker/blocks/traefik/docker-compose.yml @@ -9,7 +9,7 @@ services: ports: - '80:80' - '8080:8080' - links: + depends_on: - grafana-subpath grafana-subpath: 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/alerting/alerting-rules/alerting-migration.md b/docs/sources/alerting/alerting-rules/alerting-migration.md index f07c874902d..328ca0eefb7 100644 --- a/docs/sources/alerting/alerting-rules/alerting-migration.md +++ b/docs/sources/alerting/alerting-rules/alerting-migration.md @@ -270,20 +270,51 @@ When you set `X-Grafana-Alerting-Notification-Settings`, the header value must b ### Compatible endpoints -The API endpoints listed in this section are supported in Grafana and are used by mimirtool and cortextool, as shown earlier. +The API endpoints listed in this section are supported in Grafana and are used by `mimirtool` and `cortextool`, as shown earlier. These endpoints are compatible with [Mimir HTTP API](/docs/mimir/latest/references/http-api/). -The `POST` endpoints can be used to import data source–managed alert rules. +In these endpoints, a "namespace" corresponds to a folder title in Grafana. -| Endpoint | Method | Summary | -| -------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| POST | /convert/prometheus/config/v1/rules | Create or update multiple rule groups across multiple namespaces. Requires [`X-Grafana-Alerting-Datasource-UID`](#x-grafana-alerting-datasource-uid). | -| POST | /convert/prometheus/config/v1/rules/:namespaceTitle | Create or update a single rule group in a namespace. Requires [`X-Grafana-Alerting-Datasource-UID`](#x-grafana-alerting-datasource-uid). | +The `POST` endpoints can be used to import data source–managed alert rules. They accept requests in both YAML and JSON. If no media type is specified, YAML is assumed. + +| Endpoint | Method | Summary | Mimir equivalent | +| -------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| POST | `/convert/prometheus/config/v1/rules` | [Create or update multiple rule groups](#create-or-update-multiple-rule-groups) across multiple namespaces. Requires [`X-Grafana-Alerting-Datasource-UID`](#x-grafana-alerting-datasource-uid). | None | +| POST | `/convert/prometheus/config/v1/rules/:namespaceTitle` | Create or update a single rule group in a namespace. Requires [`X-Grafana-Alerting-Datasource-UID`](#x-grafana-alerting-datasource-uid). | [Set rule group](/docs/mimir/latest/references/http-api/#set-rule-group) | The `GET` and `DELETE` endpoints work only with provisioned and imported alert rules. -| Endpoint | Method | Summary | -| -------- | ---------------------------------------------------------- | --------------------------------------------------- | -| GET | /convert/prometheus/config/v1/rules | Get all imported rule groups across all namespaces. | -| GET | /convert/prometheus/config/v1/rules/:namespaceTitle | Get imported rule groups in a specific namespace. | -| DELETE | /convert/prometheus/config/v1/rules/:namespaceTitle | Delete all imported alert rules in a namespace. | -| DELETE | /convert/prometheus/config/v1/rules/:namespaceTitle/:group | Delete a specific imported rule group. | +| Endpoint | Method | Summary | Mimir equivalent | +| -------- | ------------------------------------------------------------ | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| GET | `/convert/prometheus/config/v1/rules` | Get all imported rule groups across all namespaces. | [List rule groups](/docs/mimir/latest/references/http-api/#list-rule-groups) | +| GET | `/convert/prometheus/config/v1/rules/:namespaceTitle` | Get imported rule groups in a specific namespace. | [Get rule groups by namespace](/docs/mimir/latest/references/http-api/#get-rule-groups-by-namespace) | +| DELETE | `/convert/prometheus/config/v1/rules/:namespaceTitle` | Delete all imported alert rules in a namespace. | [Delete namespace](/docs/mimir/latest/references/http-api/#delete-namespace) | +| DELETE | `/convert/prometheus/config/v1/rules/:namespaceTitle/:group` | Delete a specific imported rule group. | [Delete rule group](/docs/mimir/latest/references/http-api/#delete-rule-group) | + +#### Create or update multiple rule groups + +``` +POST /convert/prometheus/config/v1/rules +``` + +Creates or updates multiple rule groups across multiple namespaces. This endpoint expects a request with a map of namespace titles to arrays of rule groups, and returns `202` on success. + +This endpoint has no Mimir equivalent and is Grafana-specific for bulk operations. + +##### Example request body + +```yaml +namespace1: + - name: MyGroupName1 + rules: + - alert: MyAlertName1 + expr: up == 0 + labels: + severity: warning +namespace2: + - name: MyGroupName2 + rules: + - alert: MyAlertName2 + expr: rate(http_requests_total[5m]) > 0.1 + labels: + severity: critical +``` diff --git a/docs/sources/alerting/set-up/meta-monitoring.md b/docs/sources/alerting/set-up/meta-monitoring.md index 2467afa1eea..250487b1816 100644 --- a/docs/sources/alerting/set-up/meta-monitoring.md +++ b/docs/sources/alerting/set-up/meta-monitoring.md @@ -128,6 +128,12 @@ This metric is a gauge that shows you the number of seconds that the scheduler i This metric is a histogram that shows you the number of seconds taken to send notifications for firing and resolved alerts. This metric lets you observe slow or over-utilized integrations, such as an SMTP server that is being given emails faster than it can send them. +#### grafana_alerting_state_history_writes_failed_total + +This metric is a counter that shows you the number of failed writes to the configured alert state history backend. It includes a `backend` label to distinguish between different backends (such as `loki` or `prometheus`). + +For example, you might want to create an alert that fires when `grafana_alerting_state_history_writes_failed_total{backend="prometheus"}` is greater than 0 to detect when Prometheus remote write is failing. + ## Logs for Grafana-managed alerts If you have configured [Loki for alert state history](/docs/grafana//alerting/set-up/configure-alert-state-history/), logs related to state changes in Grafana-managed alerts are stored in the Loki data source. 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/observability-as-code/foundation-sdk/_index.md b/docs/sources/observability-as-code/foundation-sdk/_index.md index a6fad7139fe..5464544417e 100644 --- a/docs/sources/observability-as-code/foundation-sdk/_index.md +++ b/docs/sources/observability-as-code/foundation-sdk/_index.md @@ -20,87 +20,279 @@ The [Grafana Foundation SDK](https://github.com/grafana/grafana-foundation-sdk) - **Enhance version control:** Track changes seamlessly using standard version control systems like Git. - **Automate deployments:** Integrate dashboard provisioning into your CI/CD pipelines for consistent and repeatable setups. -The SDK supports multiple programming languages, including Go, Java, PHP, Python, and TypeScript, allowing you to choose the one that best fits your development environment. +The SDK supports multiple programming languages, including Go, TypeScript, Python, PHP, and Java, so you can choose the one that best fits your development environment. + +{{< youtube id="_OKQoABmg0Q" >}} ## Before you begin Ensure you have the following prerequisites: -- **Programming environment:** Set up for your chosen language (for example, Node.js for TypeScript, Python 3.x for Python). +- **Programming environment:** Set up for your chosen language. For example: Go, Node.js for TypeScript, or Python 3.x for Python. - **Grafana instance:** A running Grafana instance compatible with the SDK version you’re using (refer to the [compatibility matrix](https://github.com/grafana/grafana-foundation-sdk#navigating-the-sdk)). -- **Package manager:** Appropriate for your language (for example, `npm` or `yarn` for JavaScript or TypeScript, `pip` for Python). +- **Package manager:** Appropriate for your language, for example, `npm` or `yarn` for TypeScript or `pip` for Python. + +To get started, clone the [intro-to-foundation-sdk repository](https://github.com/grafana/intro-to-foundation-sdk) to access examples and a `docker-compose` stack. ## Install the Grafana Foundation SDK -### TypeScript - -For TypeScript, install the SDK package via `npm`: - -```bash -npm install @grafana/grafana-foundation-sdk -``` - -Or use `yarn`: - -```bash -yarn add @grafana/grafana-foundation-sdk -``` - -### Go - -For Go, install the SDK package via `go get`: +Select the `go` or `typescript` tab to view instructions to install the SDK. +For other languages, refer to the Grafana Foundation SDK documentation for installation instructions. +{{< code >}} ```go go get github.com/grafana/grafana-foundation-sdk/go ``` -### Python - -For Python, install the SDK using `pip`: - -```bash -pip install grafana-foundation-sdk +```typescript +npm install @grafana/grafana-foundation-sdk ``` -For other languages, refer to the Grafana Foundation SDK documentation for detailed installation instructions. +{{< /code >}} + +## Grafana Foundation SDK Overview + +Here's a quick overview of how the Grafana Foundation SDK works: + +- **Builder pattern:** The SDK uses a chainable builder pattern to let you define dashboards fluently. You start with a `DashboardBuilder`, then add panels, queries, and other components step by step. +- **Strong typing:** Everything in the SDK is strongly typed. This gives you autocompletion in your IDE, catches mistakes early, and helps ensure you're always using valid configuration values. +- **Structured options:** When a configuration get complex (like data reduction or display settings), the SDK uses typed option builders to keep things readable and predictable. + +You'll see these concepts in action in the next example. These concepts are explained in more detail afterwards. ## Create a dashboard -The following example demonstrates how you can create a simple dashboard using TypeScript: +The following example demonstrates how you can create a simple dashboard: -```typescript -import { DashboardBuilder, RowBuilder } from '@grafana/grafana-foundation-sdk/dashboard'; -import { DataqueryBuilder } from '@grafana/grafana-foundation-sdk/prometheus'; -import { PanelBuilder } from '@grafana/grafana-foundation-sdk/timeseries'; -const builder = new DashboardBuilder('Sample Dashboard') - .uid('sample-dashboard') - .tags(['example', 'typescript']) - .refresh('1m') - .time({ from: 'now-30m', to: 'now' }) - .timezone('browser') - .withRow(new RowBuilder('Overview')) - .withPanel( - new PanelBuilder() - .title('Network Received') - .unit('bps') - .min(0) - .withTarget( - new DataqueryBuilder() - .expr('rate(node_network_receive_bytes_total{job="example-job", device!="lo"}[$__rate_interval]) * 8') - .legendFormat('{{ device }}') - ) - ); -console.log(JSON.stringify(builder.build(), null, 2)); +{{< code >}} + +```go +package main + +// Import the appropriate Grafana Foundation SDK packages +import ( + "encoding/json" + "log" + + "github.com/grafana/grafana-foundation-sdk/go/cog" + "github.com/grafana/grafana-foundation-sdk/go/common" + "github.com/grafana/grafana-foundation-sdk/go/dashboard" + "github.com/grafana/grafana-foundation-sdk/go/stat" + "github.com/grafana/grafana-foundation-sdk/go/testdata" + "github.com/grafana/grafana-foundation-sdk/go/timeseries" +) + +func main() { + // Define a data source reference for our testdata data source + testdataRef := dashboard.DataSourceRef{ + Type: cog.ToPtr("grafana-testdata-datasource"), + Uid: cog.ToPtr("testdata"), + } + + // Define our dashboard as strongly typed code + builder := dashboard.NewDashboardBuilder("My Dashboard"). + WithPanel( + stat.NewPanelBuilder(). + Title("Version"). + Datasource(testdataRef). + ReduceOptions(common.NewReduceDataOptionsBuilder(). + Calcs([]string{"lastNotNull"}). + Fields("/.*/")). + WithTarget( + testdata.NewDataqueryBuilder(). + ScenarioId("csv_content"). + CsvContent("version\nv1.2.3"), + ), + ). + WithPanel( + timeseries.NewPanelBuilder(). + Title("Random Time Series"). + Datasource(testdataRef). + WithTarget( + testdata.NewDataqueryBuilder(). + ScenarioId("random_walk"), + ), + ) + + // Build the dashboard - errors in configuration will be thrown here + dashboard, err := builder.Build() + if err != nil { + log.Fatalf("failed to build dashboard: %v", err) + } + + // Output the generated dashboard as JSON + dashboardJson, err := json.MarshalIndent(dashboard, "", " ") + if err != nil { + log.Fatalf("failed to marshal dashboard: %v", err) + } + + log.Printf("Dashboard JSON:\n%s", dashboardJson) +} ``` -This code defines a dashboard titled “Sample Dashboard” with a single panel displaying data received on the network. +```typescript +// Import the appropriate Grafana Foundation SDK packages +import * as common from '@grafana/grafana-foundation-sdk/common'; +import * as dashboard from '@grafana/grafana-foundation-sdk/dashboard'; +import * as stat from '@grafana/grafana-foundation-sdk/stat'; +import * as testdata from '@grafana/grafana-foundation-sdk/testdata'; +import * as timeseries from '@grafana/grafana-foundation-sdk/timeseries'; + +// Define a data source reference for our testdata data source +const testDataRef: dashboard.DataSourceRef = { + type: 'grafana-testdata-datasource', + uid: 'testdata', +}; + +// Define our dashboard as strongly typed code +const builder = new dashboard.DashboardBuilder('My Dashboard') + .withPanel( + new stat.PanelBuilder() + .title('Version') + .reduceOptions(new common.ReduceDataOptionsBuilder().calcs(['lastNotNull']).fields('/.*/')) + .datasource(testdataRef) + .withTarget(new testdata.DataqueryBuilder().scenarioId('csv_content').csvContent('version\nv1.2.3')) + ) + .withPanel( + new timeseries.PanelBuilder() + .title('Random Time Series') + .datasource(testdataRef) + .withTarget(new testdata.DataqueryBuilder().scenarioId('random_walk')) + ); + +// Build the dashboard - errors in configuration will be thrown here +const dashboard = builder.build(); + +// Output the generated dashboard as JSON +console.log(JSON.stringify(dashboard, null, 2)); +``` + +{{< /code >}} + +This code defines a dashboard titled “My Dashboard” with a two panels: + +- a simple stat panel displaying a version number, and +- a time series panel displaying randomized data from the `testdata` data source `random_walk` scenario. ## Export and use the JSON -The `build()` method generates a JSON representation of your dashboard, which you can: +After you've defined your dashboard as code, build the final dashboard representation using the dashboard builder (typically using the `build()` function depending on language choice) and output the result as a JSON. + +With the JSON payload, you can: - **Manually import:** Paste into Grafana’s dashboard import feature. -- **Automate:** Use Grafana’s API to programmatically upload the dashboard JSON. +- **Automate:** Use [Grafana’s API](../../developers/http_api/) or the [Grafana CLI](../grafana-cli/) to programmatically upload the dashboard JSON. + +## Concepts + +Now that you've seen how to define a basic dashboard using code, let's take a moment to explain how it all works behind the scenes. The Grafana Foundation SDK is built around a few core concepts that make your dashboards structured, reusable, and strongly typed. + +### Builders + +The SDK follows a builder pattern, which lets you compose dashboards step-by-step using chained method calls. +Almost every piece of the dashboard, including dashboards, panels, rows, queries, and variables, has its own `Builder` class. + +Here are a few you've already seen: + +- `DashboardBuilder` - Starts the dashboard definition and sets global configuration settings like title, UID, refresh interval, time range, etc. +- `PanelBuilder` - Creates individual visualizations like time series panels, stat panels, or log panels. +- `DataqueryBuilder` - Defines how a panel fetches data, for example, from Prometheus or the `testdata` plugin. + +Builders are chainable, so you can fluently compose dashboards in a readable, structured way: + +{{< code >}} + +```go +stat.NewPanelBuilder(). + Title("Version"). + Datasource(testdataRef). + ReduceOptions(common.NewReduceDataOptionsBuilder(). + Calcs([]string{"lastNotNull"}). + Fields("/.*/")). + WithTarget( + testdata.NewDataqueryBuilder(). + ScenarioId("csv_content"). + CsvContent("version\nv1.2.3"), + ) +``` + +```typescript +new stat.PanelBuilder() + .title('Version') + .reduceOptions(new common.ReduceDataOptionsBuilder().calcs(['lastNotNull']).fields('/.*/')) + .datasource(testdataRef) + .withTarget(new testdata.DataqueryBuilder().scenarioId('csv_content').csvContent('version\nv1.2.3')); +``` + +{{< /code >}} + +### Types + +The Grafana Foundation SDK uses strong types under the hood to help catch mistakes before you deploy a broken dashboard. + +For example: + +- When setting a unit, you'll get autocomplete suggestions for valid Grafana units like `"percent"` or `"bps"`. +- When defining a time range, you'll be guided to provide the correct structure, like `from` and `to` values. +- When referencing data sources, you'll use a structured `DataSourceRef` object with defined `type` and `uid` fields. + +This helps you: + +- Avoid typos or unsupported configuration values +- Get full autocomplete and inline documentation in your IDE +- Write dashboards that are less error-prone and easier to maintain + +Strong typing also makes it easier to build reusable patterns and components with confidence, especially in large codebases or teams. + +### Options + +Most builder methods accept simple values like strings or numbers, but others expect more structured option objects. These are used for things like: + +- `ReduceDataOptions` - How to reduce time series data into single values (e.g. last, avg). +- `VizLegendOptions` - Configure how the legend of a panel is displayed. +- `CanvasElementOptions` - Define how the the various components of a Canvas panel should be displayed. + +Example using options: + +{{< code >}} + +```go +stat.NewPanelBuilder(). + ReduceOptions(common.NewReduceDataOptionsBuilder(). + Calcs([]string{"lastNotNull"}). + Fields("/.*/")) + ) +``` + +```typescript +new stat.PanelBuilder().reduceOptions(new common.ReduceDataOptionsBuilder().calcs(['lastNotNull']).fields('/.*/')); +``` + +{{< /code >}} + +By using option builders, you don't need to manually construct deeply nested configuration objects. Instead, the SDK gives you a typed and guided API that mirrors a dashboards internal structure, making it easier to configure complex options without guesswork or referring back to the JSON schema. + +## Explore a real-world example + +If you want to explore further and see a more real-world example of using the Grafana Foundation SDK, watch the following walkthrough: + +{{< youtube id="ZjWdGVsrCiQ" >}} + +In this video, we generate a dashboard from code and deploy it using the Grafana API, covering patterns and practices you'd use in production environments. It also includes a working example of a web service that emits metrics and logs, and shows how to deploy a dashboard alongside it using Docker Compose. + +You can find the full source code for this example in the [intro-to-foundation-sdk repository](https://github.com/grafana/intro-to-foundation-sdk/tree/main/generate-and-deploy-example). + +## Summary + +The Grafana Foundation SDK is designed to make dashboard creation: + +- **Composable** through the use of chainable builders +- **Safe** with strong typing and clear APIs +- **Configurable** using structured options for fine control + +As you build more advanced dashboards, you’ll work with additional builders and types to support richer functionality. +The SDK supports not just panels and queries, but also variables, thresholds, field overrides, transformations, and more. +Refer to [the full API reference](https://grafana.github.io/grafana-foundation-sdk/) to explore what's possible. ## Next steps @@ -108,4 +300,4 @@ Now that you understand the basics of using the Grafana Foundation SDK, here are - **Explore more features:** Check out the [full API reference](https://grafana.github.io/grafana-foundation-sdk/) to learn about advanced dashboard configurations. - **Version control your dashboards:** Store your dashboard code in a Git repository to track changes over time. -- **Automate dashboard provisioning with CI/CD:** Integrate the SDK into your CI/CD pipeline to deploy dashboards automatically. +- **Automate dashboard provisioning with CI/CD:** [Integrate the SDK into your CI/CD pipeline](./dashboard-automation) to deploy dashboards automatically. diff --git a/docs/sources/observability-as-code/foundation-sdk/dashboard-automation.md b/docs/sources/observability-as-code/foundation-sdk/dashboard-automation.md index 6ae3821e58a..d4b98f46c80 100644 --- a/docs/sources/observability-as-code/foundation-sdk/dashboard-automation.md +++ b/docs/sources/observability-as-code/foundation-sdk/dashboard-automation.md @@ -29,6 +29,10 @@ This guide walks through: By the end, every change to your dashboard code will be automatically created or updated in your Grafana instance without manual intervention. +{{< youtube id="cFnO8kVOaAI" >}} + +You can find the full example source code in the [intro-to-foundation-sdk repository](https://github.com/grafana/intro-to-foundation-sdk/tree/main/github-actions-example). + ## 1. Generating the dashboard JSON Before deploying a dashboard, we need to define it in code using the Grafana Foundation SDK. We ran through an example of this in the Getting Started guide, however, in order to comply with the Kubernetes resource compatible API that Grafana exposes, we’ll make some changes to the code to output the dashboard JSON in the appropriate format. 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/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index cc448ec4ba3..fd567340110 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -43,7 +43,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `dashgpt` | Enable AI powered features in dashboards | Yes | | `panelMonitoring` | Enables panel monitoring through logs and measurements | Yes | | `formatString` | Enable format string transformer | Yes | -| `kubernetesClientDashboardsFolders` | Route the folder and dashboard service requests to k8s | Yes | | `addFieldFromCalculationStatFunctions` | Add cumulative and window functions to the add field from calculation transformation | Yes | | `annotationPermissionUpdate` | Change the way annotation permissions work by scoping them to folders and dashboards. | Yes | | `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles | Yes | diff --git a/docs/sources/setup-grafana/installation/docker/index.md b/docs/sources/setup-grafana/installation/docker/index.md index c1f4b2fa917..99201814d1c 100644 --- a/docs/sources/setup-grafana/installation/docker/index.md +++ b/docs/sources/setup-grafana/installation/docker/index.md @@ -220,14 +220,14 @@ To run the latest stable version of Grafana using Docker Compose, complete the f For example: - ```bash + ```yaml services: grafana: image: grafana/grafana-enterprise container_name: grafana restart: unless-stopped ports: - - '3000:3000' + - '3000:3000' ``` 1. To run `docker-compose.yaml`, run the following command: @@ -347,19 +347,19 @@ To use bind mounts, complete the following steps: The following example runs the latest stable version of Grafana, listening on port 3000, with the container named `grafana`, persistent storage in the `grafana-storage` docker volume, the server root URL set, and the official [clock panel](/grafana/plugins/grafana-clock-panel/) plugin installed. -```bash +```yaml services: grafana: image: grafana/grafana-enterprise container_name: grafana restart: unless-stopped environment: - - GF_SERVER_ROOT_URL=http://my.grafana.server/ - - GF_PLUGINS_PREINSTALL=grafana-clock-panel + - GF_SERVER_ROOT_URL=http://my.grafana.server/ + - GF_PLUGINS_PREINSTALL=grafana-clock-panel ports: - - '3000:3000' + - '3000:3000' volumes: - - 'grafana_storage:/var/lib/grafana' + - 'grafana_storage:/var/lib/grafana' volumes: grafana_storage: {} ``` diff --git a/docs/sources/setup-grafana/set-up-grafana-live.md b/docs/sources/setup-grafana/set-up-grafana-live.md index 18ecd7f0c43..cfd5725d708 100644 --- a/docs/sources/setup-grafana/set-up-grafana-live.md +++ b/docs/sources/setup-grafana/set-up-grafana-live.md @@ -234,3 +234,8 @@ Live currently does not support Redis Sentinel. We recommend using a Redis Clust ``` {{< /admonition >}} + +{{< admonition type="note" >}} +The Redis Live HA engine does not currently support TLS. + +{{< /admonition >}} 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-duplicate-panel.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboard-duplicate-panel.spec.ts index f42082587d8..95651fc869e 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboard-duplicate-panel.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboard-duplicate-panel.spec.ts @@ -23,6 +23,8 @@ test.describe( await page.getByTestId(selectors.components.DashboardImportPage.textarea).fill(JSON.stringify(testV2Dashboard)); await page.getByTestId(selectors.components.DashboardImportPage.submit).click(); await page.getByTestId(selectors.components.ImportDashboardForm.name).fill('Paste tab'); + 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(); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); 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 80f94f855b2..12023c9de6c 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts @@ -29,6 +29,8 @@ test.describe( await page.getByTestId(selectors.components.DashboardImportPage.textarea).fill(JSON.stringify(testV2Dashboard)); await page.getByTestId(selectors.components.DashboardImportPage.submit).click(); await page.getByTestId(selectors.components.ImportDashboardForm.name).fill(title); + 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(); } 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 2cdb847a03f..78889efbbbe 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboards-panel-layouts.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboards-panel-layouts.spec.ts @@ -69,11 +69,14 @@ test.describe( // Get initial positions - standard width should have panels on different rows const firstPanelTop = await getPanelTop(dashboardPage, selectors); const lastPanel = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')).last(); - const lastPanelBox = await lastPanel.boundingBox(); - const lastPanelTop = lastPanelBox?.y || 0; - // Verify standard layout has panels on different rows - expect(lastPanelTop).toBeGreaterThan(firstPanelTop); + expect(async () => { + const lastPanelBox = await lastPanel.boundingBox(); + const lastPanelTop = lastPanelBox?.y || 0; + + // Verify standard layout has panels on different rows + expect(lastPanelTop).toBeGreaterThan(firstPanelTop); + }).toPass(); // Change to narrow min column width await dashboardPage @@ -82,11 +85,13 @@ test.describe( await page.getByRole('option', { name: 'Narrow' }).click(); // Verify narrow layout has all panels on same row - const firstPanelTopNarrow = await getPanelTop(dashboardPage, selectors); - const lastPanelBoxNarrow = await lastPanel.boundingBox(); - const lastPanelTopNarrow = lastPanelBoxNarrow?.y || 0; + expect(async () => { + const firstPanelTopNarrow = await getPanelTop(dashboardPage, selectors); + const lastPanelBoxNarrow = await lastPanel.boundingBox(); + const lastPanelTopNarrow = lastPanelBoxNarrow?.y || 0; - expect(lastPanelTopNarrow).toBe(firstPanelTopNarrow); + expect(lastPanelTopNarrow).toBe(firstPanelTopNarrow); + }).toPass(); await saveDashboard(dashboardPage, selectors); await page.reload(); @@ -99,11 +104,13 @@ test.describe( ) ).toHaveValue('Narrow'); - const firstPanelTopReload = await getPanelTop(dashboardPage, selectors); - const lastPanelBoxReload = await lastPanel.boundingBox(); - const lastPanelTopReload = lastPanelBoxReload?.y || 0; + expect(async () => { + const firstPanelTopReload = await getPanelTop(dashboardPage, selectors); + const lastPanelBoxReload = await lastPanel.boundingBox(); + const lastPanelTopReload = lastPanelBoxReload?.y || 0; - expect(lastPanelTopReload).toBe(firstPanelTopReload); + expect(lastPanelTopReload).toBe(firstPanelTopReload); + }).toPass(); }); test('can change to custom min column width in auto grid layout', async ({ dashboardPage, selectors, page }) => { @@ -220,22 +227,28 @@ test.describe( .click(); await page.getByRole('option', { name: 'Short' }).click(); - const shortHeight = await getPanelHeight(dashboardPage, selectors); - expect(shortHeight).toBeLessThan(regularRowHeight); + await expect(async () => { + const shortHeight = await getPanelHeight(dashboardPage, selectors); + expect(shortHeight).toBeLessThan(regularRowHeight); + }).toPass(); await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.rowHeight) .click(); await page.getByRole('option', { name: 'Tall' }).click(); - const tallHeight = await getPanelHeight(dashboardPage, selectors); - expect(tallHeight).toBeGreaterThan(regularRowHeight); + await expect(async () => { + const tallHeight = await getPanelHeight(dashboardPage, selectors); + expect(tallHeight).toBeGreaterThan(regularRowHeight); + }).toPass(); await saveDashboard(dashboardPage, selectors); await page.reload(); - const tallHeightAfterReload = await getPanelHeight(dashboardPage, selectors); - expect(tallHeightAfterReload).toBeGreaterThan(regularRowHeight); + await expect(async () => { + const tallHeightAfterReload = await getPanelHeight(dashboardPage, selectors); + expect(tallHeightAfterReload).toBeGreaterThan(regularRowHeight); + }).toPass(); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); @@ -243,8 +256,10 @@ test.describe( dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.rowHeight) ).toHaveValue('Tall'); - const tallHeightAfterEdit = await getPanelHeight(dashboardPage, selectors); - expect(tallHeightAfterEdit).toBeGreaterThan(regularRowHeight); + await expect(async () => { + const tallHeightAfterEdit = await getPanelHeight(dashboardPage, selectors); + expect(tallHeightAfterEdit).toBeGreaterThan(regularRowHeight); + }).toPass(); }); test('can change to custom row height in auto grid layout', async ({ dashboardPage, selectors, page }) => { @@ -276,15 +291,19 @@ test.describe( .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.customRowHeight) .blur(); - const customHeight = await getPanelHeight(dashboardPage, selectors); - expect(customHeight).toBeCloseTo(800, 5); // Allow some tolerance for rendering differences - expect(customHeight).toBeGreaterThan(regularRowHeight); + await expect(async () => { + const customHeight = await getPanelHeight(dashboardPage, selectors); + expect(customHeight).toBeCloseTo(800, 5); // Allow some tolerance for rendering differences + expect(customHeight).toBeGreaterThan(regularRowHeight); + }).toPass(); await saveDashboard(dashboardPage, selectors); await page.reload(); - const customHeightAfterReload = await getPanelHeight(dashboardPage, selectors); - expect(customHeightAfterReload).toBeCloseTo(800, 5); + await expect(async () => { + const customHeightAfterReload = await getPanelHeight(dashboardPage, selectors); + expect(customHeightAfterReload).toBeCloseTo(800, 5); + }).toPass(); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); @@ -329,14 +348,18 @@ test.describe( .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.fillScreen) .click({ force: true }); - const fillScreenHeight = await getPanelHeight(dashboardPage, selectors); - expect(fillScreenHeight).toBeGreaterThan(initialHeight); + await expect(async () => { + const fillScreenHeight = await getPanelHeight(dashboardPage, selectors); + expect(fillScreenHeight).toBeGreaterThan(initialHeight); + }).toPass(); await saveDashboard(dashboardPage, selectors); await page.reload(); - const fillScreenHeightAfterReload = await getPanelHeight(dashboardPage, selectors); - expect(fillScreenHeightAfterReload).toBeGreaterThan(initialHeight); + await expect(async () => { + const fillScreenHeightAfterReload = await getPanelHeight(dashboardPage, selectors); + expect(fillScreenHeightAfterReload).toBeGreaterThan(initialHeight); + }).toPass(); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); @@ -344,8 +367,10 @@ test.describe( dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.fillScreen) ).toBeChecked(); - const fillScreenHeightAfterEdit = await getPanelHeight(dashboardPage, selectors); - expect(fillScreenHeightAfterEdit).toBeGreaterThan(initialHeight); + await expect(async () => { + const fillScreenHeightAfterEdit = await getPanelHeight(dashboardPage, selectors); + expect(fillScreenHeightAfterEdit).toBeGreaterThan(initialHeight); + }).toPass(); }); } ); @@ -356,6 +381,8 @@ async function importTestDashboard(page: Page, selectors: E2ESelectorGroups, tit await page.getByTestId(selectors.components.DashboardImportPage.textarea).fill(JSON.stringify(testV2Dashboard)); await page.getByTestId(selectors.components.DashboardImportPage.submit).click(); await page.getByTestId(selectors.components.ImportDashboardForm.name).fill(title); + 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(); } 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 da89762c3a6..25917da59fa 100644 --- a/e2e-playwright/dashboards/TestV2Dashboard.json +++ b/e2e-playwright/dashboards/TestV2Dashboard.json @@ -1,22 +1,12 @@ { - "apiVersion": "dashboard.grafana.app/v2alpha1", + "apiVersion": "dashboard.grafana.app/v2beta1", "kind": "Dashboard", "metadata": { - "name": "admjzp8", - "namespace": "default", - "uid": "hrbekBWXeM7nC7GtouIbcngFyuqt8Xx7KlE4AnTwV7AX", - "resourceVersion": "1", + "name": "fa400625-2a44-4add-a369-e6c972eb4bd6", "generation": 1, - "creationTimestamp": "2025-05-16T09:41:56Z", - "labels": { - "grafana.app/deprecatedInternalID": "182" - }, - "annotations": { - "grafana.app/createdBy": "user:cejvsh18uudxcf", - "grafana.app/updatedBy": "user:cejvsh18uudxcf", - "grafana.app/updatedTimestamp": "2025-05-16T09:41:56Z", - "grafana.app/folder": "" - } + "creationTimestamp": "2025-05-27T11:40:22Z", + "labels": {}, + "annotations": {} }, "spec": { "annotations": [ @@ -24,19 +14,20 @@ "kind": "AnnotationQuery", "spec": { "builtIn": true, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts" + "name": "Annotations & Alerts", + "query": { + "group": "grafana", + "kind": "DataQuery", + "spec": {}, + "version": "v0" + } } } ], "cursorSync": "Off", - "description": "", "editable": true, "elements": { "panel-1": { @@ -49,14 +40,12 @@ { "kind": "PanelQuery", "spec": { - "datasource": { - "type": "grafana-testdata-datasource", - "uid": "PD8C576611E62080A" - }, "hidden": false, "query": { - "kind": "grafana-testdata-datasource", - "spec": {} + "group": "grafana-testdata-datasource", + "kind": "DataQuery", + "spec": {}, + "version": "v0" }, "refId": "A" } @@ -155,14 +144,12 @@ { "kind": "PanelQuery", "spec": { - "datasource": { - "type": "grafana-testdata-datasource", - "uid": "PD8C576611E62080A" - }, "hidden": false, "query": { - "kind": "grafana-testdata-datasource", - "spec": {} + "group": "grafana-testdata-datasource", + "kind": "DataQuery", + "spec": {}, + "version": "v0" }, "refId": "A" } @@ -261,14 +248,12 @@ { "kind": "PanelQuery", "spec": { - "datasource": { - "type": "grafana-testdata-datasource", - "uid": "PD8C576611E62080A" - }, "hidden": false, "query": { - "kind": "grafana-testdata-datasource", - "spec": {} + "group": "grafana-testdata-datasource", + "kind": "DataQuery", + "spec": {}, + "version": "v0" }, "refId": "A" } @@ -380,7 +365,7 @@ "spec": { "element": { "kind": "ElementReference", - "name": "panel-2" + "name": "panel-3" }, "height": 8, "width": 12, @@ -393,7 +378,7 @@ "spec": { "element": { "kind": "ElementReference", - "name": "panel-3" + "name": "panel-2" }, "height": 8, "width": 12, @@ -417,7 +402,7 @@ "timezone": "browser", "to": "now" }, - "title": "New Test V2 Dashboard", + "title": "Test V2 Dashboard", "variables": [] }, "status": {} diff --git a/e2e-playwright/panels-suite/datagrid-data-change.spec.ts b/e2e-playwright/panels-suite/datagrid-data-change.spec.ts new file mode 100644 index 00000000000..0b996d82381 --- /dev/null +++ b/e2e-playwright/panels-suite/datagrid-data-change.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from '@grafana/plugin-e2e'; + +const DASHBOARD_ID = 'c01bf42b-b783-4447-a304-8554cee1843b'; +const DATAGRID_SELECT_SERIES = 'Datagrid Select series'; + +test.use({ + featureToggles: { + enableDatagridEditing: true, + }, +}); + +// TODO enable this test when panel goes live +test.describe.skip( + 'Datagrid data changes', + { + tag: ['@panels'], + }, + () => { + test('Tests changing data in the grid', async ({ gotoDashboardPage, selectors, page }) => { + const dashboardPage = await gotoDashboardPage({ + uid: DASHBOARD_ID, + queryParams: new URLSearchParams({ editPanel: '1' }), + }); + + // Check that the data is series A + await expect( + dashboardPage.getByGrafanaSelector( + selectors.components.PanelEditor.OptionsPane.fieldLabel(DATAGRID_SELECT_SERIES) + ) + ).toBeVisible(); + await expect(page.getByTestId('glide-cell-2-0')).toHaveText('1'); + await expect(page.getByTestId('glide-cell-2-1')).toHaveText('20'); + await expect(page.getByTestId('glide-cell-2-2')).toHaveText('90'); + + // Change the series to B + const seriesInput = dashboardPage + .getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel(DATAGRID_SELECT_SERIES)) + .locator('input'); + await seriesInput.fill('B'); + await seriesInput.press('Enter'); + await expect(page.getByTestId('glide-cell-2-3')).toHaveText('30'); + await expect(page.getByTestId('glide-cell-2-4')).toHaveText('40'); + await expect(page.getByTestId('glide-cell-2-5')).toHaveText('50'); + + // Edit datagrid which triggers a snapshot query + await page.locator('.dvn-scroller').click({ position: { x: 200, y: 100 } }); + await expect(page.getByTestId('glide-cell-2-1')).toHaveAttribute('aria-selected', 'true'); + await page.keyboard.type('12'); + await page.keyboard.press('Enter'); + + await page.getByTestId('data-testid Confirm Modal Danger Button').click(); + + await expect(page.getByTestId('query-editor-row')).toContainText('Snapshot'); + }); + } +); diff --git a/e2e-playwright/panels-suite/datagrid-editing-features.spec.ts b/e2e-playwright/panels-suite/datagrid-editing-features.spec.ts new file mode 100644 index 00000000000..8fb7a9eab81 --- /dev/null +++ b/e2e-playwright/panels-suite/datagrid-editing-features.spec.ts @@ -0,0 +1,183 @@ +import { test, expect } from '@grafana/plugin-e2e'; + +const DASHBOARD_ID = 'c01bf42b-b783-4447-a304-8554cee1843b'; +const DATAGRID_CANVAS = 'data-grid-canvas'; + +test.use({ + featureToggles: { + enableDatagridEditing: true, + }, +}); + +// TODO enable this test when panel goes live +test.describe.skip( + 'Datagrid data changes', + { + tag: ['@panels'], + }, + () => { + test('Tests changing data in the grid', async ({ gotoDashboardPage, selectors, page }) => { + await gotoDashboardPage({ + uid: DASHBOARD_ID, + queryParams: new URLSearchParams({ editPanel: '1' }), + }); + + // Edit datagrid which triggers a snapshot query + await page.locator('.dvn-scroller').click({ position: { x: 200, y: 100 } }); + await expect(page.getByTestId('glide-cell-2-1')).toHaveAttribute('aria-selected', 'true'); + await page.keyboard.type('123'); + await page.keyboard.press('Enter'); + + await page.getByTestId('data-testid Confirm Modal Danger Button').click(); + + // Delete a cell + await page.locator('.dvn-scroller').click({ position: { x: 200, y: 200 } }); + await page.keyboard.press('Delete'); + await expect(page.getByTestId('glide-cell-2-4')).toHaveText('0'); + + // Delete a selection + await page.locator('.dvn-scroller').click({ position: { x: 50, y: 100 }, modifiers: ['Shift'] }); + await page.keyboard.press('Delete'); + await expect(page.getByTestId('glide-cell-2-3')).toHaveText('0'); + await expect(page.getByTestId('glide-cell-2-2')).toHaveText('0'); + await expect(page.getByTestId('glide-cell-2-1')).toHaveText('0'); + await expect(page.getByTestId('glide-cell-2-0')).toHaveText('1'); + await expect(page.getByTestId('glide-cell-1-3')).toHaveText(''); + await expect(page.getByTestId('glide-cell-1-2')).toHaveText(''); + await expect(page.getByTestId('glide-cell-1-1')).toHaveText(''); + await expect(page.getByTestId('glide-cell-1-0')).not.toHaveText(''); + + // Clear column through context menu + await page.locator('.dvn-scroller').click({ button: 'right', position: { x: 200, y: 100 } }); + await page.locator('[aria-label="Context menu"]').click({ position: { x: 100, y: 120 } }); // click clear column + await expect(page.getByTestId('glide-cell-2-0')).toHaveText('0'); + await expect(page.getByTestId('glide-cell-2-4')).toHaveText('0'); + + // Clear row through context menu + await page.locator('.dvn-scroller').click({ position: { x: 200, y: 220 } }); + await page.keyboard.type('1123'); + await page.keyboard.press('Enter'); + await page.locator('.dvn-scroller').click({ button: 'right', position: { x: 200, y: 220 } }); + await page.locator('[aria-label="Context menu"]').click({ position: { x: 100, y: 100 } }); // click clear row + await expect(page.getByTestId('glide-cell-1-4')).toHaveText(''); + await expect(page.getByTestId('glide-cell-2-4')).toHaveText('0'); + + // get the data back + await page.reload(); + + // Clear row through row selector + await page.locator('.dvn-scroller').click({ position: { x: 20, y: 190 } }); + await page.locator('.dvn-scroller').click({ position: { x: 20, y: 90 }, modifiers: ['Shift'] }); // with shift to select all rows between clicks + await page.keyboard.press('Delete'); + await page.getByTestId('data-testid Confirm Modal Danger Button').click(); + await expect(page.getByTestId('glide-cell-1-4')).toHaveText(''); + await expect(page.getByTestId('glide-cell-1-3')).toHaveText(''); + await expect(page.getByTestId('glide-cell-1-2')).toHaveText(''); + await expect(page.getByTestId('glide-cell-1-1')).toHaveText(''); + await expect(page.getByTestId('glide-cell-2-4')).toHaveText('0'); + await expect(page.getByTestId('glide-cell-2-3')).toHaveText('0'); + await expect(page.getByTestId('glide-cell-2-2')).toHaveText('0'); + await expect(page.getByTestId('glide-cell-2-1')).toHaveText('0'); + await page.reload(); + await page.locator('.dvn-scroller').click({ position: { x: 20, y: 190 } }); + await page.locator('.dvn-scroller').click({ position: { x: 20, y: 90 }, modifiers: ['Meta'] }); // with cmd to select only clicked rows + await page.keyboard.press('Delete'); + + await page.getByTestId('data-testid Confirm Modal Danger Button').click(); + + await expect(page.getByTestId('glide-cell-1-1')).toHaveText(''); + await expect(page.getByTestId('glide-cell-2-1')).toHaveText('0'); + await expect(page.getByTestId('glide-cell-2-4')).toHaveText('0'); + await expect(page.getByTestId('glide-cell-1-4')).toHaveText(''); + + // Remove all data + await page.locator('.dvn-scroller').click({ button: 'right', position: { x: 100, y: 100 } }); + await page.locator('body').click({ position: { x: 150, y: 420 } }); + await expect(page.getByTestId(DATAGRID_CANVAS).locator('th')).toHaveCount(0); + + await page.reload(); + + // Delete column through header dropdown menu + await page.locator('.dvn-scroller').click({ position: { x: 250, y: 15 } }); // click header dropdown + await page.locator('body').click({ position: { x: 450, y: 420 } }); // click delete column + await page.getByTestId('data-testid Confirm Modal Danger Button').click(); + await expect(page.getByTestId(DATAGRID_CANVAS).locator('th')).toHaveCount(1); + + // Delete row through context menu + await page.locator('.dvn-scroller').click({ button: 'right', position: { x: 100, y: 100 } }); + await page.locator('[aria-label="Context menu"]').click({ position: { x: 10, y: 10 } }); + await expect(page.getByTestId(DATAGRID_CANVAS).locator('tbody tr')).toHaveCount(6); // there are 5 data rows + 1 for the add new row btns + + // Delete rows through row selector + await page.locator('.dvn-scroller').click({ position: { x: 20, y: 190 } }); + await page.locator('.dvn-scroller').click({ position: { x: 20, y: 90 }, modifiers: ['Shift'] }); // with shift to select all rows between clicks + await page.locator('.dvn-scroller').click({ button: 'right', position: { x: 100, y: 100 } }); + await page.locator('[aria-label="Context menu"]').click({ position: { x: 10, y: 10 } }); + await expect(page.getByTestId(DATAGRID_CANVAS).locator('tbody tr')).toHaveCount(2); // there are 1 data rows + 1 for the add new row btns + await page.reload(); + await page.locator('.dvn-scroller').click({ position: { x: 20, y: 190 } }); + await page.locator('.dvn-scroller').click({ position: { x: 20, y: 90 }, modifiers: ['Meta'] }); // with cmd to select only clicked rows + await page.locator('.dvn-scroller').click({ button: 'right', position: { x: 40, y: 90 } }); + await page.locator('[aria-label="Context menu"]').click({ position: { x: 10, y: 10 } }); + await page.getByTestId('data-testid Confirm Modal Danger Button').click(); + await expect(page.getByTestId(DATAGRID_CANVAS).locator('tbody tr')).toHaveCount(5); // there are 5 data rows + 1 for the add new row btns + + // Delete column through context menu + await page.locator('.dvn-scroller').click({ button: 'right', position: { x: 100, y: 100 } }); + await page.locator('[aria-label="Context menu"]').click({ position: { x: 10, y: 50 } }); + await expect(page.getByTestId(DATAGRID_CANVAS).locator('th')).toHaveCount(1); + + await page.reload(); + + // Add a new column + await page.locator('body').click({ position: { x: 350, y: 200 } }); + await page.keyboard.type('New Column'); + await page.keyboard.press('Enter'); + await page.getByTestId('data-testid Confirm Modal Danger Button').click(); + await page.locator('body').click({ position: { x: 350, y: 230 } }); + await page.keyboard.type('Value 1'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Value 2'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Value 3'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Value 4'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Value 5'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Value 6'); + await page.keyboard.press('Enter'); + await expect(page.getByTestId(DATAGRID_CANVAS).locator('th')).toHaveCount(3); + + // Rename a column + await page.locator('.dvn-scroller').click({ position: { x: 250, y: 15 } }); // click header dropdown + await page.locator('body').click({ position: { x: 450, y: 380 } }); + await page.keyboard.press('Control+a'); // select all + await page.keyboard.press('Backspace'); + await page.keyboard.type('Renamed column'); + await page.keyboard.press('Enter'); + await expect(page.getByTestId(DATAGRID_CANVAS).locator('th')).toContainText('Renamed column'); + + // Change column field type + await page.locator('.dvn-scroller').click({ position: { x: 310, y: 15 } }); + await page.locator('[aria-label="Context menu"]').click({ position: { x: 50, y: 50 } }); + await page.locator('.dvn-scroller').click({ position: { x: 200, y: 100 } }); + await page.keyboard.type('Str Value'); + await page.keyboard.press('Enter'); + await expect(page.getByTestId(DATAGRID_CANVAS).locator('tr')).toContainText('Str Value'); + + // Select all rows through row selection + await page.locator('.dvn-scroller').click({ position: { x: 10, y: 10 } }); + await expect(page.getByTestId(DATAGRID_CANVAS).locator('[aria-selected="true"]')).toHaveCount(6); + + // Add a new row + await page.locator('.dvn-scroller').click({ position: { x: 200, y: 250 } }); + await page.keyboard.type('123'); + await page.locator('.dvn-scroller').click({ position: { x: 50, y: 250 } }); + await page.keyboard.type('Val'); + await page.keyboard.press('Enter'); + await expect(page.getByTestId(DATAGRID_CANVAS).locator('tbody tr')).toContainText('Val'); + await expect(page.getByTestId(DATAGRID_CANVAS).locator('tbody tr')).toHaveCount(8); + }); + } +); diff --git a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts index 7052ccfc510..f386fb4175c 100644 --- a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts +++ b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts @@ -269,7 +269,8 @@ test.describe( expect(fourthPageStatus.total).toBe(largeRowStatus.total); }); - test('Tests DataLinks (single and multi) and actions', async ({ gotoDashboardPage, selectors, page }) => { + // TODO: skipping this test for now due to flakiness in adding DataLinks. + test.skip('Tests DataLinks (single and multi) and actions', async ({ gotoDashboardPage, selectors, page }) => { const addDataLink = async (title: string, url: string) => { await dashboardPage .getByGrafanaSelector( diff --git a/e2e-playwright/smoke-tests-suite/panels.ts b/e2e-playwright/smoke-tests-suite/panels.spec.ts similarity index 100% rename from e2e-playwright/smoke-tests-suite/panels.ts rename to e2e-playwright/smoke-tests-suite/panels.spec.ts diff --git a/e2e/dashboards-search-suite/mode0.ini b/e2e/dashboards-search-suite/mode0.ini index 752638f24f2..1e0c6c55b0a 100644 --- a/e2e/dashboards-search-suite/mode0.ini +++ b/e2e/dashboards-search-suite/mode0.ini @@ -10,7 +10,6 @@ kubernetesCliDashboards = true unifiedStorageSearchSprinkles = true kubernetesFoldersServiceV2 = true unifiedStorageSearchPermissionFiltering = true -kubernetesClientDashboardsFolders = true [unified_storage.folders.folder.grafana.app] dualWriterMode = 0 diff --git a/e2e/dashboards-search-suite/mode1.ini b/e2e/dashboards-search-suite/mode1.ini index 8f8f80e8e30..56e16cb4cff 100644 --- a/e2e/dashboards-search-suite/mode1.ini +++ b/e2e/dashboards-search-suite/mode1.ini @@ -10,7 +10,6 @@ kubernetesCliDashboards = true unifiedStorageSearchSprinkles = true kubernetesFoldersServiceV2 = true unifiedStorageSearchPermissionFiltering = true -kubernetesClientDashboardsFolders = true [unified_storage.folders.folder.grafana.app] dualWriterMode = 1 diff --git a/e2e/dashboards-search-suite/mode2-legacy-search-api.ini b/e2e/dashboards-search-suite/mode2-legacy-search-api.ini index 38590b40a67..4b671c8b78b 100644 --- a/e2e/dashboards-search-suite/mode2-legacy-search-api.ini +++ b/e2e/dashboards-search-suite/mode2-legacy-search-api.ini @@ -10,7 +10,6 @@ kubernetesCliDashboards = true unifiedStorageSearchSprinkles = true kubernetesFoldersServiceV2 = true unifiedStorageSearchPermissionFiltering = true -kubernetesClientDashboardsFolders = true [unified_storage.folders.folder.grafana.app] dualWriterMode = 2 diff --git a/e2e/dashboards-search-suite/mode2.ini b/e2e/dashboards-search-suite/mode2.ini index c1d5890d6f4..af96c552def 100644 --- a/e2e/dashboards-search-suite/mode2.ini +++ b/e2e/dashboards-search-suite/mode2.ini @@ -10,7 +10,6 @@ kubernetesCliDashboards = true unifiedStorageSearchSprinkles = true kubernetesFoldersServiceV2 = true unifiedStorageSearchPermissionFiltering = true -kubernetesClientDashboardsFolders = true [unified_storage.folders.folder.grafana.app] dualWriterMode = 2 diff --git a/e2e/dashboards-search-suite/mode3.ini b/e2e/dashboards-search-suite/mode3.ini index e027f7e429f..a4a56f9d80c 100644 --- a/e2e/dashboards-search-suite/mode3.ini +++ b/e2e/dashboards-search-suite/mode3.ini @@ -10,7 +10,6 @@ kubernetesCliDashboards = true unifiedStorageSearchSprinkles = true kubernetesFoldersServiceV2 = true unifiedStorageSearchPermissionFiltering = true -kubernetesClientDashboardsFolders = true [unified_storage.folders.folder.grafana.app] dualWriterMode = 3 diff --git a/e2e/dashboards-search-suite/mode4.ini b/e2e/dashboards-search-suite/mode4.ini index 4c00fd10c47..dd774cb2f14 100644 --- a/e2e/dashboards-search-suite/mode4.ini +++ b/e2e/dashboards-search-suite/mode4.ini @@ -10,7 +10,6 @@ kubernetesCliDashboards = true unifiedStorageSearchSprinkles = true kubernetesFoldersServiceV2 = true unifiedStorageSearchPermissionFiltering = true -kubernetesClientDashboardsFolders = true [unified_storage.folders.folder.grafana.app] dualWriterMode = 4 diff --git a/e2e/dashboards-search-suite/mode5.ini b/e2e/dashboards-search-suite/mode5.ini index 277d4cdf1c1..83437bda185 100644 --- a/e2e/dashboards-search-suite/mode5.ini +++ b/e2e/dashboards-search-suite/mode5.ini @@ -10,7 +10,6 @@ kubernetesCliDashboards = true unifiedStorageSearchSprinkles = true kubernetesFoldersServiceV2 = true unifiedStorageSearchPermissionFiltering = true -kubernetesClientDashboardsFolders = true [unified_storage.folders.folder.grafana.app] dualWriterMode = 5 diff --git a/e2e/dashboards/TestV2Dashboard.json b/e2e/dashboards/TestV2Dashboard.json index da89762c3a6..25917da59fa 100644 --- a/e2e/dashboards/TestV2Dashboard.json +++ b/e2e/dashboards/TestV2Dashboard.json @@ -1,22 +1,12 @@ { - "apiVersion": "dashboard.grafana.app/v2alpha1", + "apiVersion": "dashboard.grafana.app/v2beta1", "kind": "Dashboard", "metadata": { - "name": "admjzp8", - "namespace": "default", - "uid": "hrbekBWXeM7nC7GtouIbcngFyuqt8Xx7KlE4AnTwV7AX", - "resourceVersion": "1", + "name": "fa400625-2a44-4add-a369-e6c972eb4bd6", "generation": 1, - "creationTimestamp": "2025-05-16T09:41:56Z", - "labels": { - "grafana.app/deprecatedInternalID": "182" - }, - "annotations": { - "grafana.app/createdBy": "user:cejvsh18uudxcf", - "grafana.app/updatedBy": "user:cejvsh18uudxcf", - "grafana.app/updatedTimestamp": "2025-05-16T09:41:56Z", - "grafana.app/folder": "" - } + "creationTimestamp": "2025-05-27T11:40:22Z", + "labels": {}, + "annotations": {} }, "spec": { "annotations": [ @@ -24,19 +14,20 @@ "kind": "AnnotationQuery", "spec": { "builtIn": true, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts" + "name": "Annotations & Alerts", + "query": { + "group": "grafana", + "kind": "DataQuery", + "spec": {}, + "version": "v0" + } } } ], "cursorSync": "Off", - "description": "", "editable": true, "elements": { "panel-1": { @@ -49,14 +40,12 @@ { "kind": "PanelQuery", "spec": { - "datasource": { - "type": "grafana-testdata-datasource", - "uid": "PD8C576611E62080A" - }, "hidden": false, "query": { - "kind": "grafana-testdata-datasource", - "spec": {} + "group": "grafana-testdata-datasource", + "kind": "DataQuery", + "spec": {}, + "version": "v0" }, "refId": "A" } @@ -155,14 +144,12 @@ { "kind": "PanelQuery", "spec": { - "datasource": { - "type": "grafana-testdata-datasource", - "uid": "PD8C576611E62080A" - }, "hidden": false, "query": { - "kind": "grafana-testdata-datasource", - "spec": {} + "group": "grafana-testdata-datasource", + "kind": "DataQuery", + "spec": {}, + "version": "v0" }, "refId": "A" } @@ -261,14 +248,12 @@ { "kind": "PanelQuery", "spec": { - "datasource": { - "type": "grafana-testdata-datasource", - "uid": "PD8C576611E62080A" - }, "hidden": false, "query": { - "kind": "grafana-testdata-datasource", - "spec": {} + "group": "grafana-testdata-datasource", + "kind": "DataQuery", + "spec": {}, + "version": "v0" }, "refId": "A" } @@ -380,7 +365,7 @@ "spec": { "element": { "kind": "ElementReference", - "name": "panel-2" + "name": "panel-3" }, "height": 8, "width": 12, @@ -393,7 +378,7 @@ "spec": { "element": { "kind": "ElementReference", - "name": "panel-3" + "name": "panel-2" }, "height": 8, "width": 12, @@ -417,7 +402,7 @@ "timezone": "browser", "to": "now" }, - "title": "New Test V2 Dashboard", + "title": "Test V2 Dashboard", "variables": [] }, "status": {} diff --git a/e2e/panels-suite/dashlist.spec.ts b/e2e/panels-suite/dashlist.spec.ts deleted file mode 100644 index 002cf235e82..00000000000 --- a/e2e/panels-suite/dashlist.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { e2e } from '../utils'; -const PAGE_UNDER_TEST = 'a6801696-cc53-4196-b1f9-2403e3909185/panel-tests-dashlist-variables'; - -describe('DashList panel', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - // this is to prevent the fix for https://github.com/grafana/grafana/issues/76800 from regressing - it('should pass current variable values correctly when `Include current template variable values` is set', () => { - e2e.flows.openDashboard({ uid: PAGE_UNDER_TEST }); - - // check the initial value of the urls contain the variable value correctly - e2e.components.Panels.Panel.title('Include time range and variables enabled') - .should('be.visible') - .within(() => { - cy.get('a').each(($el) => { - cy.wrap($el).should('have.attr', 'href').and('contain', 'var-server=A'); - }); - }); - - // update variable to b - e2e.pages.Dashboard.SubMenu.submenuItemLabels('server') - .parent() - .within(() => { - cy.get('input').click(); - }); - e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('B').click(); - // blur the dropdown - cy.get('body').click(); - - // check the urls are updated with the new variable value - e2e.components.Panels.Panel.title('Include time range and variables enabled') - .should('be.visible') - .within(() => { - cy.get('a').each(($el) => { - cy.wrap($el).should('have.attr', 'href').and('contain', 'var-server=B'); - }); - }); - }); -}); diff --git a/e2e/panels-suite/datagrid-data-change.spec.ts b/e2e/panels-suite/datagrid-data-change.spec.ts deleted file mode 100644 index 583f30e267f..00000000000 --- a/e2e/panels-suite/datagrid-data-change.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { e2e } from '../utils'; - -const DASHBOARD_ID = 'c01bf42b-b783-4447-a304-8554cee1843b'; -const DATAGRID_SELECT_SERIES = 'Datagrid Select series'; - -//TODO enable this test when panel goes live -describe.skip('Datagrid data changes', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('Tests changing data in the grid', () => { - e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } }); - - // Check that the data is series A - e2e.components.PanelEditor.OptionsPane.fieldLabel(DATAGRID_SELECT_SERIES).should('be.visible'); - cy.get('[data-testid="glide-cell-2-0"]').should('have.text', '1'); - cy.get('[data-testid="glide-cell-2-1"]').should('have.text', '20'); - cy.get('[data-testid="glide-cell-2-2"]').should('have.text', '90'); - - // Change the series to B - e2e.components.PanelEditor.OptionsPane.fieldLabel(DATAGRID_SELECT_SERIES).find('input').type('B {enter}'); - cy.get('[data-testid="glide-cell-2-3"]').should('have.text', '30'); - cy.get('[data-testid="glide-cell-2-4"]').should('have.text', '40'); - cy.get('[data-testid="glide-cell-2-5"]').should('have.text', '50'); - - // Edit datagrid which triggers a snapshot query - cy.get('.dvn-scroller').click(200, 100); - cy.get('[data-testid="glide-cell-2-1"]').should('have.attr', 'aria-selected', 'true'); - cy.get('body').type('12{enter}', { delay: 500 }); - - cy.get('[data-testid="data-testid Confirm Modal Danger Button"]').click(); - - cy.get('[data-testid="query-editor-row"]').contains('Snapshot'); - }); -}); diff --git a/e2e/panels-suite/datagrid-editing-features.spec.ts b/e2e/panels-suite/datagrid-editing-features.spec.ts deleted file mode 100644 index e3592f60388..00000000000 --- a/e2e/panels-suite/datagrid-editing-features.spec.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { e2e } from '../utils'; - -const DASHBOARD_ID = 'c01bf42b-b783-4447-a304-8554cee1843b'; -const DATAGRID_CANVAS = 'data-grid-canvas'; - -//TODO enable this test when panel goes live -describe.skip('Datagrid data changes', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('Tests changing data in the grid', () => { - e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } }); - - // Edit datagrid which triggers a snapshot query - cy.get('.dvn-scroller').click(200, 100); - cy.get('[data-testid="glide-cell-2-1"]').should('have.attr', 'aria-selected', 'true'); - cy.get('body').type('123{enter}', { delay: 500 }); - - cy.get('[data-testid="data-testid Confirm Modal Danger Button"]').click(); - - // Delete a cell - cy.get('.dvn-scroller').click(200, 200); - cy.get('body').type('{del}'); - cy.get('[data-testid="glide-cell-2-4"]').should('have.text', 0); - - // Delete a selection - cy.get('.dvn-scroller').click(50, 100, { shiftKey: true }); - cy.get('body').type('{del}'); - cy.get('[data-testid="glide-cell-2-3"]').should('have.text', 0); - cy.get('[data-testid="glide-cell-2-2"]').should('have.text', 0); - cy.get('[data-testid="glide-cell-2-1"]').should('have.text', 0); - cy.get('[data-testid="glide-cell-2-0"]').should('have.text', 1); - cy.get('[data-testid="glide-cell-1-3"]').should('have.text', ''); - cy.get('[data-testid="glide-cell-1-2"]').should('have.text', ''); - cy.get('[data-testid="glide-cell-1-1"]').should('have.text', ''); - cy.get('[data-testid="glide-cell-1-0"]').should('not.have.text', ''); - - // Clear column through context menu - cy.get('.dvn-scroller').rightclick(200, 100); - cy.get('[aria-label="Context menu"]').click(100, 120); // click clear column - cy.get('[data-testid="glide-cell-2-0"]').should('have.text', 0); - cy.get('[data-testid="glide-cell-2-4"]').should('have.text', 0); - - // Clear row through context menu - cy.get('.dvn-scroller').click(200, 220); - cy.get('body').type('1123{enter}', { delay: 500 }); - cy.get('.dvn-scroller').rightclick(200, 220); - cy.get('[aria-label="Context menu"]').click(100, 100); // click clear row - cy.get('[data-testid="glide-cell-1-4"]').should('have.text', ''); - cy.get('[data-testid="glide-cell-2-4"]').should('have.text', 0); - - // get the data back - cy.reload(); - - // Clear row through row selector - cy.get('.dvn-scroller').click(20, 190, { waitForAnimations: true }); - cy.get('.dvn-scroller').click(20, 90, { shiftKey: true, waitForAnimations: true }); // with shift to select all rows between clicks - cy.get('body').type('{del}'); - cy.get('[data-testid="data-testid Confirm Modal Danger Button"]').click(); - cy.get('[data-testid="glide-cell-1-4"]').should('have.text', ''); - cy.get('[data-testid="glide-cell-1-3"]').should('have.text', ''); - cy.get('[data-testid="glide-cell-1-2"]').should('have.text', ''); - cy.get('[data-testid="glide-cell-1-1"]').should('have.text', ''); - cy.get('[data-testid="glide-cell-2-4"]').should('have.text', 0); - cy.get('[data-testid="glide-cell-2-3"]').should('have.text', 0); - cy.get('[data-testid="glide-cell-2-2"]').should('have.text', 0); - cy.get('[data-testid="glide-cell-2-1"]').should('have.text', 0); - cy.wait(1000); - cy.reload(); - cy.get('.dvn-scroller').click(20, 190, { waitForAnimations: true }); - cy.get('.dvn-scroller').click(20, 90, { commandKey: true, waitForAnimations: true }); // with cmd to select only clicked rows - cy.get('body').type('{del}'); - - cy.get('[data-testid="data-testid Confirm Modal Danger Button"]').click(); - - cy.get('[data-testid="glide-cell-1-1"]').should('have.text', ''); - cy.get('[data-testid="glide-cell-2-1"]').should('have.text', 0); - cy.get('[data-testid="glide-cell-2-4"]').should('have.text', 0); - cy.get('[data-testid="glide-cell-1-4"]').should('have.text', ''); - - // Remove all data - cy.get('.dvn-scroller').rightclick(100, 100); - cy.get('body').click(150, 420); - cy.get(`[data-testid="${DATAGRID_CANVAS}"] th`).should('have.length', 0); - - cy.reload(); - - // Delete column through header dropdown menu - cy.get('.dvn-scroller').click(250, 15); // click header dropdown - cy.get('body').click(450, 420); // click delete column - cy.get('[data-testid="data-testid Confirm Modal Danger Button"]').click(); - cy.get(`[data-testid="${DATAGRID_CANVAS}"] th`).should('have.length', 1); - - // Delete row through context menu - cy.get('.dvn-scroller').rightclick(100, 100); - cy.get('[aria-label="Context menu"]').click(10, 10); - cy.get(`[data-testid="${DATAGRID_CANVAS}"] tbody tr`).should('have.length', 6); // there are 5 data rows + 1 for the add new row btns - - // Delete rows through row selector - cy.get('.dvn-scroller').click(20, 190, { waitForAnimations: true }); - cy.get('.dvn-scroller').click(20, 90, { shiftKey: true, waitForAnimations: true }); // with shift to select all rows between clicks - cy.get('.dvn-scroller').rightclick(100, 100); - cy.get('[aria-label="Context menu"]').click(10, 10); - cy.get(`[data-testid="${DATAGRID_CANVAS}"] tbody tr`).should('have.length', 2); // there are 1 data rows + 1 for the add new row btns - cy.reload(); - cy.get('.dvn-scroller').click(20, 190, { waitForAnimations: true }); - cy.get('.dvn-scroller').click(20, 90, { commandKey: true, waitForAnimations: true }); // with shift to select all rows between clicks - cy.get('.dvn-scroller').rightclick(40, 90); - cy.get('[aria-label="Context menu"]').click(10, 10); - cy.get('[data-testid="data-testid Confirm Modal Danger Button"]').click(); - cy.get(`[data-testid="${DATAGRID_CANVAS}"] tbody tr`).should('have.length', 5); // there are 5 data rows + 1 for the add new row btns - - // Delete column through context menu - cy.get('.dvn-scroller').rightclick(100, 100); - cy.get('[aria-label="Context menu"]').click(10, 50); - cy.get(`[data-testid="${DATAGRID_CANVAS}"] th`).should('have.length', 1); - - cy.reload(); - cy.wait(3000); - - // Add a new column - cy.get('body').click(350, 200).type('New Column{enter}'); - cy.get('[data-testid="data-testid Confirm Modal Danger Button"]').click(); - cy.get('body') - .click(350, 230) - .type('Value 1{enter}') - .type('Value 2{enter}') - .type('Value 3{enter}') - .type('Value 4{enter}') - .type('Value 5{enter}') - .type('Value 6{enter}'); - cy.get(`[data-testid="${DATAGRID_CANVAS}"] th`).should('have.length', 3); - - // Rename a column - cy.get('.dvn-scroller').click(250, 15); // click header dropdown - cy.get('body').click(450, 380).type('{selectall}{backspace}Renamed column{enter}'); - cy.get(`[data-testid="${DATAGRID_CANVAS}"] th`).contains('Renamed column'); - - // Change column field type - cy.get('.dvn-scroller').click(310, 15); - cy.get('[aria-label="Context menu"]').click(50, 50); - cy.get('.dvn-scroller').click(200, 100); - cy.get('body').type('Str Value{enter}'); - cy.get(`[data-testid="${DATAGRID_CANVAS}"] tr`).contains('Str Value'); - - // Select all rows through row selection - cy.get('.dvn-scroller').click(10, 10, { waitForAnimations: true }); - cy.get(`[data-testid="${DATAGRID_CANVAS}"] [aria-selected="true"]`).should('have.length', 6); - - // Add a new row - cy.get('.dvn-scroller').click(200, 250); - cy.get('body').type('123'); - cy.get('.dvn-scroller').click(50, 250); - cy.get('body').type('Val{enter}'); - cy.get(`[data-testid="${DATAGRID_CANVAS}"] tbody tr`).contains('Val'); - cy.get(`[data-testid="${DATAGRID_CANVAS}"] tbody tr`).should('have.length', 8); - }); -}); diff --git a/e2e/panels-suite/frontend-sandbox-panel.spec.ts b/e2e/panels-suite/frontend-sandbox-panel.spec.ts deleted file mode 100644 index 4f2f0e3a192..00000000000 --- a/e2e/panels-suite/frontend-sandbox-panel.spec.ts +++ /dev/null @@ -1,135 +0,0 @@ -import panelSandboxDashboard from '../dashboards/PanelSandboxDashboard.json'; -import { e2e } from '../utils'; - -const DASHBOARD_ID = 'c46b2460-16b7-42a5-82d1-b07fbf431950'; -// Skipping due to race conditions with same old arch test e2e/panels-suite/frontend-sandbox-panel.spec.ts -describe('Panel sandbox', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true); - return e2e.flows.importDashboard(panelSandboxDashboard, 1000, true); - }); - - describe('Sandbox disabled', () => { - beforeEach(() => { - cy.window().then((win) => { - win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=0'); - }); - cy.reload(); - }); - - it('Add iframes to body', () => { - // this button adds iframes to the body - cy.get('[data-testid="button-create-iframes"]').click(); - - const iframeIds = [ - 'createElementIframe', - 'innerHTMLIframe', - 'appendIframe', - 'prependIframe', - 'afterIframe', - 'beforeIframe', - 'outerHTMLIframe', - 'parseFromStringIframe', - 'insertBeforeIframe', - 'replaceChildIframe', - ]; - iframeIds.forEach((id) => { - cy.get(`#${id}`).should('exist'); - }); - }); - - it('Reaches out of panel div', () => { - // this button reaches out of the panel div and modifies the element dataset - cy.get('[data-testid="button-reach-out"]').click(); - - cy.get('[data-sandbox-test="true"]').should('exist'); - }); - - it('Reaches out of the panel editor', () => { - e2e.flows.openDashboard({ - uid: DASHBOARD_ID, - queryParams: { - editPanel: 1, - }, - }); - - cy.get('[data-testid="panel-editor-custom-editor-input"]').should('not.be.disabled'); - cy.get('[data-testid="panel-editor-custom-editor-input"]').should('have.value', ''); - // wait because sometimes cypress is faster than react and the value doesn't change - cy.wait(1000); - cy.get('[data-testid="panel-editor-custom-editor-input"]').type('x', { force: true, delay: 500 }); - cy.wait(100); // small delay to prevent false positives from too fast tests - cy.get('[data-testid="panel-editor-custom-editor-input"]').should('have.value', 'x'); - cy.get('[data-sandbox-test="panel-editor"]').should('exist'); - }); - }); - - describe('Sandbox enabled', () => { - beforeEach(() => { - cy.window().then((win) => { - win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=1'); - }); - cy.reload(); - }); - - it('Does not add iframes to body', () => { - // this button adds 3 iframes to the body - cy.get('[data-testid="button-create-iframes"]').click(); - cy.wait(100); // small delay to prevent false positives from too fast tests - - const iframeIds = [ - 'createElementIframe', - 'innerHTMLIframe', - 'appendIframe', - 'prependIframe', - 'afterIframe', - 'beforeIframe', - 'outerHTMLIframe', - 'parseFromStringIframe', - 'insertBeforeIframe', - 'replaceChildIframe', - ]; - iframeIds.forEach((id) => { - cy.get(`#${id}`).should('not.exist'); - }); - }); - - it('Does not reaches out of panel div', () => { - // this button reaches out of the panel div and modifies the element dataset - cy.get('[data-testid="button-reach-out"]').click(); - cy.wait(100); // small delay to prevent false positives from too fast tests - cy.get('[data-sandbox-test="true"]').should('not.exist'); - }); - - it('Does not Reaches out of the panel editor', () => { - e2e.flows.openDashboard({ - uid: DASHBOARD_ID, - queryParams: { - editPanel: 1, - }, - }); - - cy.get('[data-testid="panel-editor-custom-editor-input"]').should('not.be.disabled'); - // wait because sometimes cypress is faster than react and the value doesn't change - cy.wait(1000); - cy.get('[data-testid="panel-editor-custom-editor-input"]').type('x', { force: true }); - cy.wait(100); // small delay to prevent false positives from too fast tests - cy.get('[data-sandbox-test="panel-editor"]').should('not.exist'); - }); - - it('Can access specific window global variables', () => { - cy.get('[data-testid="button-test-globals"]').click(); - cy.get('[data-sandbox-global="Prism"]').should('be.visible'); - cy.get('[data-sandbox-global="jQuery"]').should('be.visible'); - cy.get('[data-sandbox-global="location"]').should('be.visible'); - }); - }); - - afterEach(() => { - e2e.flows.revertAllChanges(); - }); - - after(() => { - return cy.clearCookies(); - }); -}); diff --git a/e2e/panels-suite/geomap-layer-types.spec.ts b/e2e/panels-suite/geomap-layer-types.spec.ts deleted file mode 100644 index 70af145c784..00000000000 --- a/e2e/panels-suite/geomap-layer-types.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { e2e } from '../utils'; - -const DASHBOARD_ID = 'P2jR04WVk'; - -const MAP_LAYERS_TYPE = 'Map layers Layer type'; -const MAP_LAYERS_DATA = 'Map layers Data'; -const MAP_LAYERS_GEOJSON = 'Map layers GeoJSON URL'; - -describe('Geomap layer types', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('Tests changing the layer type', () => { - e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } }); - cy.get('[data-testid="layer-drag-drop-list"]').should('be.visible'); - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_TYPE).should('be.visible'); - cy.get('[data-testid="layer-drag-drop-list"]').contains('markers'); - - // Heatmap - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_TYPE).type('Heatmap{enter}'); - cy.get('[data-testid="layer-drag-drop-list"]').contains('heatmap'); - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_DATA).should('be.visible'); - // e2e.components.PanelEditor.General.content().should('be.visible'); - - // GeoJSON - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_TYPE).type('GeoJSON{enter}'); - cy.get('[data-testid="layer-drag-drop-list"]').contains('geojson'); - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_DATA).should('not.exist'); - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_GEOJSON).should('be.visible'); - // e2e.components.PanelEditor.General.content().should('be.visible'); - - // Open Street Map - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_TYPE).type('Open Street Map{enter}'); - cy.get('[data-testid="layer-drag-drop-list"]').contains('osm-standard'); - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_DATA).should('not.exist'); - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_GEOJSON).should('not.exist'); - // e2e.components.PanelEditor.General.content().should('be.visible'); - - // CARTO basemap - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_TYPE).type('CARTO basemap{enter}'); - cy.get('[data-testid="layer-drag-drop-list"]').contains('carto'); - e2e.components.PanelEditor.OptionsPane.fieldLabel('Map layers Show labels').should('be.visible'); - e2e.components.PanelEditor.OptionsPane.fieldLabel('Map layers Theme').should('be.visible'); - // e2e.components.PanelEditor.General.content().should('be.visible'); - - // ArcGIS MapServer - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_TYPE).type('ArcGIS MapServer{enter}'); - cy.get('[data-testid="layer-drag-drop-list"]').contains('esri-xyz'); - e2e.components.PanelEditor.OptionsPane.fieldLabel('Map layers Server instance').should('be.visible'); - // e2e.components.PanelEditor.General.content().should('be.visible'); - - // XYZ Tile layer - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_TYPE).type('XYZ Tile layer{enter}'); - cy.get('[data-testid="layer-drag-drop-list"]').contains('xyz'); - e2e.components.PanelEditor.OptionsPane.fieldLabel('Map layers URL template').should('be.visible'); - e2e.components.PanelEditor.OptionsPane.fieldLabel('Map layers Attribution').should('be.visible'); - // e2e.components.PanelEditor.General.content().should('be.visible'); - }); - - it.skip('Tests changing the layer type (alpha)', () => { - e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } }); - cy.get('[data-testid="layer-drag-drop-list"]').should('be.visible'); - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_TYPE).should('be.visible'); - cy.get('[data-testid="layer-drag-drop-list"]').contains('markers'); - - // Icon at last point (Alpha) - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_TYPE).type('Icon at last point{enter}'); - cy.get('[data-testid="layer-drag-drop-list"]').contains('last-point-tracker'); - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_DATA).should('be.visible'); - e2e.components.PanelEditor.General.content().should('be.visible'); - - // Dynamic GeoJSON (Alpha) - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_TYPE).type('Dynamic GeoJSON{enter}'); - cy.get('[data-testid="layer-drag-drop-list"]').contains('dynamic-geojson'); - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_DATA).should('be.visible'); - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_GEOJSON).should('be.visible'); - e2e.components.PanelEditor.OptionsPane.fieldLabel('Map layers ID Field').should('be.visible'); - e2e.components.PanelEditor.General.content().should('be.visible'); - - // Night / Day (Alpha) - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_TYPE).type('Night / Day{enter}'); - cy.get('[data-testid="layer-drag-drop-list"]').contains('dayNight'); - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_DATA).should('be.visible'); - e2e.components.PanelEditor.OptionsPane.fieldLabel('Map layers Show').should('be.visible'); - e2e.components.PanelEditor.OptionsPane.fieldLabel('Map layers Night region color').should('be.visible'); - e2e.components.PanelEditor.OptionsPane.fieldLabel('Map layers Display sun').should('be.visible'); - e2e.components.PanelEditor.General.content().should('be.visible'); - - // Route (Alpha) - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_TYPE).type('Route{enter}'); - cy.get('[data-testid="layer-drag-drop-list"]').contains('route'); - e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_DATA).should('be.visible'); - e2e.components.PanelEditor.OptionsPane.fieldLabel('Map layers Location').should('be.visible'); - e2e.components.PanelEditor.OptionsPane.fieldLabel('Map layers Style').should('be.visible'); - e2e.components.PanelEditor.OptionsPane.fieldLabel('Map layers Line width').should('be.visible'); - e2e.components.PanelEditor.General.content().should('be.visible'); - }); -}); diff --git a/e2e/panels-suite/geomap-map-controls.spec.ts b/e2e/panels-suite/geomap-map-controls.spec.ts deleted file mode 100644 index b344dae5abe..00000000000 --- a/e2e/panels-suite/geomap-map-controls.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { e2e } from '../utils'; -const DASHBOARD_ID = 'P2jR04WVk'; -const TIMEOUT = 45000; - -describe('Geomap layer controls options', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('Tests map controls options', () => { - e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } }); - // Wait until the query editor has been loaded by ensuring that the QueryEditor select contains the text 'flight_info_by_state.csv' - e2e.components.Select.singleValue().contains('flight_info_by_state.csv').should('be.visible'); - e2e.components.OptionsGroup.group('Map controls').scrollIntoView().should('be.visible'); - - // Show zoom field - e2e.components.PanelEditor.showZoomField() - .should('be.visible') - .within(() => { - cy.get('input[type="checkbox"]').check({ force: true }).should('be.checked'); - }); - - // Show attribution - e2e.components.PanelEditor.showAttributionField() - .should('be.visible') - .within(() => { - cy.get('input[type="checkbox"]').check({ force: true }).should('be.checked'); - }); - - // Show scale - e2e.components.PanelEditor.showScaleField() - .should('be.visible') - .within(() => { - cy.get('input[type="checkbox"]').check({ force: true }).should('be.checked'); - }); - - // Show measure tool - e2e.components.PanelEditor.showMeasureField() - .should('be.visible') - .within(() => { - cy.get('input[type="checkbox"]').check({ force: true }).should('be.checked'); - }); - - // Show debug - e2e.components.PanelEditor.showDebugField() - .should('be.visible') - .within(() => { - cy.get('input[type="checkbox"]').check({ force: true }).should('be.checked'); - }); - - e2e.components.Panels.Panel.content({ timeout: TIMEOUT }) - .should('be.visible') - .within(() => { - // Verify zoom - cy.get('.ol-zoom', { timeout: TIMEOUT }).should('be.visible'); - - // Verify attribution - cy.get('.ol-attribution', { timeout: TIMEOUT }).should('be.visible'); - - // Verify scale - cy.get('.ol-scale-line', { timeout: TIMEOUT }).should('be.visible'); - - // Verify measure tool - e2e.components.PanelEditor.measureButton({ timeout: TIMEOUT }).should('be.visible'); - - // Verify debug tool - e2e.components.DebugOverlay.wrapper({ timeout: TIMEOUT }).should('be.visible'); - }); - }); -}); diff --git a/e2e/panels-suite/geomap-spatial-operations-transform.spec.ts b/e2e/panels-suite/geomap-spatial-operations-transform.spec.ts deleted file mode 100644 index 4eb24162791..00000000000 --- a/e2e/panels-suite/geomap-spatial-operations-transform.spec.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { e2e } from '../utils'; - -const DASHBOARD_ID = 'P2jR04WVk'; - -describe.skip('Geomap spatial operations', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('Tests location auto option', () => { - e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { '__feature.tableNextGen': false, editPanel: 1 } }); - e2e.components.Tab.title('Transformations').should('be.visible').click(); - e2e.components.Transforms.addTransformationButton().scrollIntoView().should('be.visible').click(); - - e2e.components.TransformTab.newTransform('Spatial operations').scrollIntoView().should('be.visible').click(); - e2e.components.Transforms.SpatialOperations.actionLabel().type('Prepare spatial field{enter}'); - e2e.components.Transforms.SpatialOperations.locationLabel().should('be.visible'); - e2e.components.Transforms.SpatialOperations.locationLabel().type('Auto{enter}'); - - e2e.components.PanelEditor.toggleTableView().click({ force: true }); - e2e.components.Panels.Visualization.Table.header() - .should('be.visible') - .within(() => { - cy.contains('Point').should('be.visible'); - }); - }); - - it('Tests location coords option', () => { - e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { '__feature.tableNextGen': false, editPanel: 1 } }); - e2e.components.Tab.title('Transformations').should('be.visible').click(); - e2e.components.Transforms.addTransformationButton().scrollIntoView().should('be.visible').click(); - - e2e.components.TransformTab.newTransform('Spatial operations').scrollIntoView().should('be.visible').click(); - e2e.components.Transforms.SpatialOperations.actionLabel().type('Prepare spatial field{enter}'); - e2e.components.Transforms.SpatialOperations.locationLabel().should('be.visible'); - e2e.components.Transforms.SpatialOperations.locationLabel().type('Coords{enter}'); - - e2e.components.Transforms.SpatialOperations.location.coords.latitudeFieldLabel().should('be.visible'); - e2e.components.Transforms.SpatialOperations.location.coords.latitudeFieldLabel().type('Lat{enter}'); - - e2e.components.Transforms.SpatialOperations.location.coords.longitudeFieldLabel().should('be.visible'); - e2e.components.Transforms.SpatialOperations.location.coords.longitudeFieldLabel().type('Lng{enter}'); - - e2e.components.PanelEditor.toggleTableView().click({ force: true }); - e2e.components.Panels.Visualization.Table.header() - .should('be.visible') - .within(() => { - cy.contains('Point').should('be.visible'); - }); - }); - - it('Tests geoshash field column appears in table view', () => { - e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { '__feature.tableNextGen': false, editPanel: 1 } }); - e2e.components.Tab.title('Transformations').should('be.visible').click(); - e2e.components.Transforms.addTransformationButton().scrollIntoView().should('be.visible').click(); - - e2e.components.TransformTab.newTransform('Spatial operations').scrollIntoView().should('be.visible').click(); - e2e.components.Transforms.SpatialOperations.actionLabel().type('Prepare spatial field{enter}'); - e2e.components.Transforms.SpatialOperations.locationLabel().should('be.visible'); - e2e.components.Transforms.SpatialOperations.locationLabel().type('Geohash{enter}'); - - e2e.components.Transforms.SpatialOperations.location.geohash - .geohashFieldLabel() - .should('be.visible') - .type('State{enter}'); - - e2e.components.PanelEditor.toggleTableView().click({ force: true }); - e2e.components.Panels.Visualization.Table.header() - .should('be.visible') - .within(() => { - cy.contains('State 1').should('be.visible'); - }); - }); - - it('Tests location lookup option', () => { - e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { '__feature.tableNextGen': false, editPanel: 1 } }); - e2e.components.Tab.title('Transformations').should('be.visible').click(); - e2e.components.Transforms.addTransformationButton().scrollIntoView().should('be.visible').click(); - - e2e.components.TransformTab.newTransform('Spatial operations').scrollIntoView().should('be.visible').click(); - e2e.components.Transforms.SpatialOperations.actionLabel().type('Prepare spatial field{enter}'); - e2e.components.Transforms.SpatialOperations.locationLabel().should('be.visible'); - e2e.components.Transforms.SpatialOperations.locationLabel().type('Lookup{enter}'); - - e2e.components.Transforms.SpatialOperations.location.lookup.lookupFieldLabel().should('be.visible'); - e2e.components.Transforms.SpatialOperations.location.lookup.lookupFieldLabel().type('State{enter}'); - - e2e.components.Transforms.SpatialOperations.location.lookup.gazetteerFieldLabel().should('be.visible'); - e2e.components.Transforms.SpatialOperations.location.lookup.gazetteerFieldLabel().type('USA States{enter}'); - - e2e.components.PanelEditor.toggleTableView().click({ force: true }); - e2e.components.Panels.Visualization.Table.header() - .should('be.visible') - .within(() => { - cy.contains('Geometry').should('be.visible'); - }); - }); -}); diff --git a/e2e/panels-suite/panelEdit_base.spec.ts b/e2e/panels-suite/panelEdit_base.spec.ts deleted file mode 100644 index d48879e5a8d..00000000000 --- a/e2e/panels-suite/panelEdit_base.spec.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { selectors } from '@grafana/e2e-selectors'; - -import { e2e } from '../utils'; - -const PANEL_UNDER_TEST = 'Lines 500 data points'; - -describe('Panel edit tests', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('Tests various Panel edit scenarios', () => { - cy.intercept({ - pathname: '/api/ds/query', - }).as('query'); - e2e.flows.openDashboard({ uid: 'TkZXxlNG3', queryParams: { '__feature.tableNextGen': false } }); - cy.wait('@query'); - - e2e.components.Panels.Panel.title('Lines 500 data points') - .should('be.visible') - .within(() => { - e2e.components.Panels.Panel.loadingBar().should('not.exist'); - cy.get('[data-testid="uplot-main-div"]').first().should('be.visible'); - }); - - e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Edit, PANEL_UNDER_TEST); - - // New panel editor opens when navigating from Panel menu - e2e.components.PanelEditor.General.content().should('be.visible'); - - // Queries tab is rendered and open by default - e2e.components.PanelEditor.DataPane.content() - .scrollIntoView() - .should('be.visible') - .within(() => { - e2e.components.Tab.title('Queries').should('be.visible'); - // data should be the active tab - e2e.components.Tab.active().within((li: JQuery) => { - expect(li.text()).equals('Queries1'); // there's already a query so therefore Query + 1 - }); - cy.get(`[data-testid="${selectors.components.QueryTab.content}"]`).should('be.visible'); - e2e.components.TransformTab.content().should('not.exist'); - e2e.components.AlertTab.content().should('not.exist'); - e2e.components.PanelAlertTabContent.content().should('not.exist'); - - // Bottom pane tabs - // Can change to Transform tab - e2e.components.Tab.title('Transformations').should('be.visible').click(); - e2e.components.Tab.active().within((li: JQuery) => { - expect(li.text()).equals('Transformations0'); // there's no transform so therefore Transform + 0 - }); - e2e.components.Transforms.addTransformationButton().scrollIntoView().should('be.visible'); - cy.get(`[data-testid="${selectors.components.QueryTab.content}"]`).should('not.exist'); - e2e.components.AlertTab.content().should('not.exist'); - e2e.components.PanelAlertTabContent.content().should('not.exist'); - - // Can change to Alerts tab (graph panel is the default vis so the alerts tab should be rendered) - e2e.components.Tab.title('Alert').scrollIntoView().should('be.visible').click(); - e2e.components.Tab.active().should('have.text', 'Alert0'); // there's no alert so therefore Alert + 0 - - // Needs to be disabled until Grafana EE turns unified alerting on by default - // e2e.components.AlertTab.content().should('not.exist'); - - cy.get(`[data-testid="${selectors.components.QueryTab.content}"]`).should('not.exist'); - e2e.components.TransformTab.content().should('not.exist'); - - // Needs to be disabled until Grafana EE turns unified alerting on by default - // e2e.components.PanelAlertTabContent.content().should('exist'); - // e2e.components.PanelAlertTabContent.content().should('be.visible'); - - e2e.components.Tab.title('Queries').should('be.visible').click(); - }); - - // Check that Time series is chosen - e2e.components.PanelEditor.toggleVizPicker().click(); - e2e.components.PluginVisualization.item('Time series').should('be.visible'); - e2e.components.PluginVisualization.current().should((e) => expect(e).to.contain('Time series')); - - // Check that table view works - e2e.components.Panels.Panel.loadingBar().should('not.exist'); - e2e.components.PanelEditor.toggleTableView().click({ force: true }); - e2e.components.Panels.Visualization.Table.header() - .should('be.visible') - .within(() => { - cy.contains('A-series').should('be.visible'); - }); - - // Change to Text panel - e2e.components.PluginVisualization.item('Text').scrollIntoView().should('be.visible').click(); - e2e.components.PanelEditor.toggleVizPicker().should((e) => expect(e).to.contain('Text')); - - // Data pane should not be rendered - e2e.components.PanelEditor.DataPane.content().should('not.exist'); - - // Change to Table panel - e2e.components.PanelEditor.toggleVizPicker().click(); - e2e.components.PluginVisualization.item('Table').scrollIntoView().should('be.visible').click(); - e2e.components.PanelEditor.toggleVizPicker().should((e) => expect(e).to.contain('Table')); - - // Data pane should be rendered - e2e.components.PanelEditor.DataPane.content().should('be.visible'); - - // Field & Overrides tabs (need to switch to React based vis, i.e. Table) - e2e.components.PanelEditor.toggleTableView().click({ force: true }).click({ force: true }); - - e2e.components.PanelEditor.OptionsPane.fieldLabel('Table Show table header').should('be.visible'); - e2e.components.PanelEditor.OptionsPane.fieldLabel('Table Column width').should('be.visible'); - }); -}); diff --git a/e2e/panels-suite/panelEdit_queries.spec.ts b/e2e/panels-suite/panelEdit_queries.spec.ts deleted file mode 100644 index 1700a00ea9f..00000000000 --- a/e2e/panels-suite/panelEdit_queries.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { e2e } from '../utils'; - -const flakyTimeout = 10000; - -describe('Panel edit tests - queries', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('Tests various Panel edit queries scenarios', () => { - e2e.flows.openDashboard({ uid: '5SdHCadmz', queryParams: { editPanel: 3 } }); - - // New panel editor opens when navigating from Panel menu - e2e.components.PanelEditor.General.content().should('be.visible'); - - // Queries tab is rendered and open by default - e2e.components.PanelEditor.DataPane.content().should('be.visible'); - - // We expect row with refId A to exist and be visible - e2e.components.QueryEditorRows.rows().within((rows) => { - expect(rows.length).equals(1); - }); - - // Add query button should be visible and clicking on it should create a new row - e2e.components.QueryTab.addQuery().scrollIntoView().should('be.visible').click(); - - // We expect row with refId A and B to exist and be visible - e2e.components.QueryEditorRows.rows({ timeout: flakyTimeout }).should('have.length', 2); - - // Remove refId A - e2e.components.QueryEditorRow.actionButton('Remove query').eq(0).scrollIntoView(); - e2e.components.QueryEditorRow.actionButton('Remove query').eq(0).should('be.visible').click(); - - // We expect row with refId B to exist and be visible - e2e.components.QueryEditorRows.rows({ timeout: flakyTimeout }).should('have.length', 1); - - // Duplicate refId B - e2e.components.QueryEditorRow.actionButton('Duplicate query').eq(0).should('be.visible').click(); - - // We expect row with refId Band and A to exist and be visible - e2e.components.QueryEditorRows.rows().should('have.length', 2); - - // Change to CSV Metric Values scenario for A - e2e.components.DataSource.TestData.QueryTab.scenarioSelectContainer() - .first() - .should('be.visible') - .within(() => { - cy.get('input[id*="test-data-scenario-select-"]').eq(0).should('be.visible').click(); - }); - - cy.contains('CSV Metric Values').scrollIntoView().should('be.visible').eq(0).click(); - - // Disable / enable row - expectInspectorResultAndClose((keys) => { - const length = keys.length; - const resultIds = new Set([ - keys[length - 2].innerText, // last 2 - keys[length - 1].innerText, // last 2 - ]); - - expect(resultIds.has('A:')).equals(true); - expect(resultIds.has('B:')).equals(true); - }); - - // Hide response for row with refId A - e2e.components.QueryEditorRow.actionButton('Hide response').eq(1).should('be.visible').click(); - - expectInspectorResultAndClose((keys) => { - const length = keys.length; - expect(keys[length - 1].innerText).equals('B:'); - }); - - // Show response for row with refId A - e2e.components.QueryEditorRow.actionButton('Hide response').eq(1).should('be.visible').click(); - - expectInspectorResultAndClose((keys) => { - const length = keys.length; - const resultIds = new Set([ - keys[length - 2].innerText, // last 2 - keys[length - 1].innerText, // last 2 - ]); - - expect(resultIds.has('A:')).equals(true); - expect(resultIds.has('B:')).equals(true); - }); - }); -}); - -const expectInspectorResultAndClose = (expectCallBack: (keys: JQuery) => void) => { - e2e.components.QueryTab.queryInspectorButton().should('be.visible').click(); - - e2e.components.PanelInspector.Query.refreshButton().should('be.visible').click(); - - e2e.components.PanelInspector.Query.jsonObjectKeys({ timeout: flakyTimeout }) - .should('be.visible') - .should((keys) => expectCallBack(keys)); - - e2e.components.Drawer.General.close().should('be.visible').click(); -}; diff --git a/e2e/panels-suite/panelEdit_transforms.spec.ts b/e2e/panels-suite/panelEdit_transforms.spec.ts deleted file mode 100644 index 963991a22e7..00000000000 --- a/e2e/panels-suite/panelEdit_transforms.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { e2e } from '../utils'; - -describe('Panel edit tests - transformations', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('Tests transformations editor', () => { - e2e.flows.openDashboard({ uid: 'TkZXxlNG3', queryParams: { editPanel: 47 } }); - - e2e.components.Tab.title('Transformations').should('be.visible').click(); - e2e.components.Transforms.addTransformationButton().scrollIntoView().should('be.visible').click(); - e2e.components.TransformTab.newTransform('Reduce').scrollIntoView().should('be.visible').click(); - e2e.components.Transforms.Reduce.calculationsLabel().scrollIntoView().should('be.visible'); - e2e.components.Transforms.Reduce.modeLabel().should('be.visible'); - }); - - it('Tests case where transformations can be disabled and not clear out panel data', () => { - e2e.flows.openDashboard({ uid: 'TkZXxlNG3', queryParams: { editPanel: 47 } }); - - e2e.components.Tab.title('Transformations').should('be.visible').click(); - e2e.components.Transforms.addTransformationButton().scrollIntoView().should('be.visible').click(); - e2e.components.TransformTab.newTransform('Reduce').scrollIntoView().should('be.visible').click(); - e2e.components.Transforms.disableTransformationButton().should('be.visible').click(); - - e2e.components.Panels.Panel.PanelDataErrorMessage().should('not.exist'); - }); -}); 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/e2e/utils/flows/scenes/importDashboard.ts b/e2e/utils/flows/scenes/importDashboard.ts index e0b0a8ae929..a916f733d06 100644 --- a/e2e/utils/flows/scenes/importDashboard.ts +++ b/e2e/utils/flows/scenes/importDashboard.ts @@ -17,5 +17,9 @@ export const importV2Dashboard = ({ title }: ImportDashboardConfig) => { if (title) { e2e.components.ImportDashboardForm.name().clear().type(title); } + + e2e.components.DataSourcePicker.inputV2().click(); + cy.get('div[data-testid="data-source-card"]').first().click(); + e2e.components.ImportDashboardForm.submit().click(); }; diff --git a/go.mod b/go.mod index dfddf213482..f8c2a0a7b16 100644 --- a/go.mod +++ b/go.mod @@ -12,8 +12,8 @@ require ( filippo.io/age v1.2.1 // @grafana/identity-access-team github.com/1NCE-GmbH/grpc-go-pool v0.0.0-20231117122434-2a5bb974daa2 // @grafana/grafana-search-and-storage github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // @grafana/partner-datasources - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // @grafana/identity-access-team - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 // @grafana/grafana-backend-group + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 // @grafana/identity-access-team + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 // @grafana/grafana-backend-group github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 // @grafana/grafana-backend-group github.com/Azure/azure-storage-blob-go v0.15.0 // @grafana/grafana-backend-group github.com/Azure/go-autorest/autorest v0.11.29 // @grafana/grafana-backend-group @@ -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,16 +95,16 @@ 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.1.6 // @grafana/partner-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 github.com/grafana/grafana-google-sdk-go v0.4.1 // @grafana/partner-datasources 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 @@ -158,7 +158,7 @@ require ( github.com/prometheus/common v0.65.0 // @grafana/alerting-backend github.com/prometheus/prometheus v0.303.1 // @grafana/alerting-backend github.com/prometheus/sigv4 v0.1.2 // @grafana/alerting-backend - github.com/redis/go-redis/v9 v9.7.3 // @grafana/alerting-backend + github.com/redis/go-redis/v9 v9.8.0 // @grafana/alerting-backend github.com/robfig/cron/v3 v3.0.1 // @grafana/grafana-backend-group github.com/rs/cors v1.11.1 // @grafana/identity-access-team github.com/russellhaering/goxmldsig v1.4.0 // @grafana/grafana-backend-group @@ -175,7 +175,7 @@ require ( github.com/urfave/cli/v3 v3.3.3 // @grafana/grafana-backend-group github.com/wk8/go-ordered-map v1.0.0 // @grafana/grafana-backend-group github.com/xlab/treeprint v1.2.0 // @grafana/observability-traces-and-profiling - github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // @grafana/grafana-operator-experience-squad + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // @grafana/grafana-operator-experience-squad github.com/yudai/gojsondiff v1.0.0 // @grafana/grafana-backend-group go.opencensus.io v0.24.0 // @grafana/grafana-backend-group go.opentelemetry.io/collector/pdata v1.30.0 // @grafana/grafana-backend-group @@ -206,7 +206,7 @@ require ( golang.org/x/tools v0.35.0 // indirect; @grafana/grafana-as-code gonum.org/v1/gonum v0.16.0 // @grafana/oss-big-tent google.golang.org/api v0.235.0 // @grafana/grafana-backend-group - google.golang.org/grpc v1.74.0 // @grafana/plugins-platform-backend + google.golang.org/grpc v1.74.2 // @grafana/plugins-platform-backend google.golang.org/protobuf v1.36.6 // @grafana/plugins-platform-backend gopkg.in/ini.v1 v1.67.0 // @grafana/alerting-backend gopkg.in/mail.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 @@ -245,6 +246,7 @@ require ( // Check go.work file for details github.com/grafana/grafana/pkg/promlib v0.0.8 // @grafana/oss-big-tent github.com/grafana/grafana/pkg/semconv v0.0.0-20250627191313-2f1a6ae1712b // @grafana/grafana-app-platform-squad + github.com/thomaspoignant/go-feature-flag v1.42.0 // @grafana/grafana-backend-group ) require ( @@ -311,8 +313,10 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 // indirect github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.22.0 // indirect + github.com/blang/semver v3.5.1+incompatible // indirect github.com/blevesearch/geo v0.1.20 // indirect github.com/blevesearch/go-faiss v1.0.25 // indirect github.com/blevesearch/go-porterstemmer v1.0.3 // indirect @@ -354,6 +358,7 @@ require ( github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/diegoholiveira/jsonlogic/v3 v3.7.4 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dolthub/flatbuffers/v23 v23.3.3-dh.2 // indirect github.com/dolthub/go-icu-regex v0.0.0-20250327004329-6799764f2dad // indirect @@ -386,7 +391,7 @@ require ( github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/gogo/googleapis v1.4.1 // indirect github.com/gogo/status v1.1.1 // indirect - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.3 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect @@ -433,7 +438,7 @@ require ( github.com/jcmturner/goidentity/v6 v6.0.1 // indirect github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect - github.com/jessevdk/go-flags v1.5.0 // indirect + github.com/jessevdk/go-flags v1.6.1 // indirect github.com/jhump/protoreflect v1.15.1 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -479,6 +484,7 @@ require ( github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/natefinch/wrap v0.2.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/nikunjy/rules v1.5.0 // indirect github.com/oapi-codegen/runtime v1.0.0 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect @@ -549,7 +555,7 @@ require ( go.etcd.io/etcd/api/v3 v3.5.21 // indirect go.etcd.io/etcd/client/pkg/v3 v3.5.21 // indirect go.etcd.io/etcd/client/v3 v3.5.21 // indirect - go.mongodb.org/mongo-driver v1.16.1 // indirect + go.mongodb.org/mongo-driver v1.17.3 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/bridges/prometheus v0.61.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect @@ -591,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 827f20c85c3..d3ce4af5ca7 100644 --- a/go.sum +++ b/go.sum @@ -648,10 +648,10 @@ github.com/Azure/azure-pipeline-go v0.2.3/go.mod h1:x841ezTBIMG6O3lAcl8ATHnsOPVl github.com/Azure/azure-sdk-for-go v23.2.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= -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.9.0 h1:OVoM452qUFBrX+URdH3VpR299ma4kfom0yB0URYky9g= -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/azcore v1.18.1 h1:Wc1ml6QlJs2BHQ/9Bqu1jiyggbsSjramq2oUmp5WeIo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= @@ -896,6 +896,8 @@ github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 h1:60m4tnanN1c github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0= +github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df/go.mod h1:hiVxq5OP2bUGBRNS3Z/bt/reCLFNbdcST6gISi1fiOM= github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 h1:6df1vn4bBlDDo4tARvBm7l6KA9iVMnE3NWizDeWSrps= github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3/go.mod h1:CIWtjkly68+yqLPbvwwR/fjNJA/idrtULjZWh2v1ys0= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= @@ -914,6 +916,8 @@ github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edY github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/blevesearch/bleve/v2 v2.5.0 h1:HzYqBy/5/M9Ul9ESEmXzN/3Jl7YpmWBdHM/+zzv/3k4= @@ -1083,6 +1087,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-sip13 v0.0.0-20190329191031-25c5027a8c7b/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/dhui/dktest v0.3.0/go.mod h1:cyzIUfGsBEbZ6BT7tnXqAShHSXCZhSNmFl70sZ7c1yc= +github.com/diegoholiveira/jsonlogic/v3 v3.7.4 h1:92HSmB9bwM/o0ZvrCpcvTP2EsPXSkKtAniIr2W/dcIM= +github.com/diegoholiveira/jsonlogic/v3 v3.7.4/go.mod h1:OYRb6FSTVmMM+MNQ7ElmMsczyNSepw+OU4Z8emDSi4w= github.com/digitalocean/godo v1.136.0 h1:DTxugljFJSMBPfEGq4KeXpnKeAHicggNqogcrw/YdZw= github.com/digitalocean/godo v1.136.0/go.mod h1:PU8JB6I1XYkQIdHFop8lLAY9ojp6M0XcU0TWaQSxbrc= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -1379,8 +1385,8 @@ github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzw github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= +github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-migrate/migrate/v4 v4.7.0 h1:gONcHxHApDTKXDyLH/H97gEHmpu1zcnnbAaq2zgrPrs= github.com/golang-migrate/migrate/v4 v4.7.0/go.mod h1:Qvut3N4xKWjoH3sokBccML6WyHSnggXm/DvMMnTsQIc= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= @@ -1574,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= @@ -1594,14 +1600,14 @@ 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.1.6 h1:OfCkitCuomzZKW1WYHrG8MxKwtMhALb7jqoj+487eTg= -github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6/go.mod h1:V7y2BmsWxS3A9Ohebwn4OiSfJJqi//4JQydQ8fHTduo= +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-cloud-migration-snapshot v1.9.0 h1:JOzchPgptwJdruYoed7x28lFDwhzs7kssResYsnC0iI= github.com/grafana/grafana-cloud-migration-snapshot v1.9.0/go.mod h1:nOHgq4Oa829qmBKA5KIXw5Ipo3rhLs0d6A8UI9Nw8Zk= github.com/grafana/grafana-google-sdk-go v0.4.1 h1:QdHmgDzlV3RzBTvIxd+WuxER1+afnFzUmEivbwDo27E= @@ -1614,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= @@ -1624,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= @@ -1822,8 +1828,9 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6 github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= -github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= +github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= +github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= github.com/jhump/gopoet v0.0.0-20190322174617-17282ff210b3/go.mod h1:me9yfT6IJSlOL3FCfrg+L6yzUEZ+5jW6WHt4Sk+UPUI= github.com/jhump/gopoet v0.1.0/go.mod h1:me9yfT6IJSlOL3FCfrg+L6yzUEZ+5jW6WHt4Sk+UPUI= github.com/jhump/goprotoc v0.5.0/go.mod h1:VrbvcYrQOrTi3i0Vf+m+oqQWk9l72mjkJCYo7UvLHRQ= @@ -2081,6 +2088,8 @@ github.com/natefinch/wrap v0.2.0/go.mod h1:6gMHlAl12DwYEfKP3TkuykYUfLSEAvHw67itm 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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +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= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oapi-codegen/runtime v1.0.0 h1:P4rqFX5fMFWqRzY9M/3YF9+aPSPPB06IzP2P7oOxrWo= @@ -2269,8 +2278,8 @@ github.com/protocolbuffers/txtpbfmt v0.0.0-20241112170944-20d2c9ebc01d/go.mod h1 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/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= -github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= +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/redis/rueidis v1.0.56 h1:DwPjFIgas1OMU/uCqBELOonu9TKMYt3MFPq6GtwEWNY= github.com/redis/rueidis v1.0.56/go.mod h1:g660/008FMYmAF46HG4lmcpcgFNj+jCjCAZUUM+wEbs= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= @@ -2429,6 +2438,10 @@ github.com/tetratelabs/wazero v1.8.2 h1:yIgLR/b2bN31bjxwXHD8a3d+BogigR952csSDdLY github.com/tetratelabs/wazero v1.8.2/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs= github.com/thanos-io/objstore v0.0.0-20240818203309-0363dadfdfb1 h1:z0v9BB/p7s4J6R//+0a5M3wCld8KzNjrGRLIwXfrAZk= github.com/thanos-io/objstore v0.0.0-20240818203309-0363dadfdfb1/go.mod h1:3ukSkG4rIRUGkKM4oIz+BSuUx2e3RlQVVv3Cc3W+Tv4= +github.com/thejerf/slogassert v0.3.4 h1:VoTsXixRbXMrRSSxDjYTiEDCM4VWbsYPW5rB/hX24kM= +github.com/thejerf/slogassert v0.3.4/go.mod h1:0zn9ISLVKo1aPMTqcGfG1o6dWwt+Rk574GlUxHD4rs8= +github.com/thomaspoignant/go-feature-flag v1.42.0 h1:C7embmOTzaLyRki+OoU2RvtVjJE9IrvgBA2C1mRN1lc= +github.com/thomaspoignant/go-feature-flag v1.42.0/go.mod h1:y0QiWH7chHWhGATb/+XqwAwErORmPSH2MUsQlCmmWlM= github.com/tidwall/pretty v0.0.0-20180105212114-65a9db5fad51/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tjhop/slog-gokit v0.1.3 h1:6SdexP3UIeg93KLFeiM1Wp1caRwdTLgsD/THxBUy1+o= @@ -2487,8 +2500,9 @@ github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGC github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= @@ -2539,8 +2553,8 @@ go.mongodb.org/mongo-driver v1.0.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qL go.mongodb.org/mongo-driver v1.0.4/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= go.mongodb.org/mongo-driver v1.1.0/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo= -go.mongodb.org/mongo-driver v1.16.1 h1:rIVLL3q0IHM39dvE+z2ulZLp9ENZKThVfuvN/IiN4l8= -go.mongodb.org/mongo-driver v1.16.1/go.mod h1:oB6AhJQvFQL4LEHyXi6aJzQJtBiTQHiAd83l0GdFaiw= +go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= +go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= @@ -3479,8 +3493,8 @@ google.golang.org/grpc v1.52.0/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5v google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw= google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g= google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8= -google.golang.org/grpc v1.74.0 h1:sxRSkyLxlceWQiqDofxDot3d4u7DyoHPc7SBXMj8gGY= -google.golang.org/grpc v1.74.0/go.mod h1:NZUaK8dAMUfzhK6uxZ+9511LtOrk73UGWOFoNvz7z+s= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= diff --git a/go.work.sum b/go.work.sum index 6d9b5c84368..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= @@ -515,6 +516,11 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= 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= github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus v1.8.0 h1:JNgM3Tz592fUHU2vgwgvOgKxo5s9Ki0y2wicBeckn70= @@ -563,6 +569,8 @@ github.com/Joker/jade v1.1.3 h1:Qbeh12Vq6BxURXT1qZBRHsDxeURB8ztcL6f3EXSGeHk= github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM= github.com/KimMachineGun/automemlimit v0.7.1 h1:QcG/0iCOLChjfUweIMC3YL5Xy9C3VBeNmCZHrZfJMBw= github.com/KimMachineGun/automemlimit v0.7.1/go.mod h1:QZxpHaGOQoYvFhv/r4u3U0JTC2ZcOwbSr11UZF46UBM= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k= github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k= @@ -574,7 +582,6 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV github.com/RaveNoX/go-jsoncommentstrip v1.0.0 h1:t527LHHE3HmiHrq74QMpNPZpGCIJzTx+apLkMKt4HC0= github.com/RoaringBitmap/gocroaring v0.4.0 h1:5nufXUgWpBEUNEJXw7926YAA58ZAQRpWPrQV1xCoSjc= github.com/RoaringBitmap/real-roaring-datasets v0.0.0-20190726190000-eb7c87156f76 h1:ZYlhPbqQFU+AHfgtCdHGDTtRW1a8geZyiE8c6Q+Sl1s= -github.com/RoaringBitmap/real-roaring-datasets v0.0.0-20190726190000-eb7c87156f76/go.mod h1:oM0MHmQ3nDsq609SS36p+oYbRi16+oVvU2Bw4Ipv0SE= github.com/Sereal/Sereal/Go/sereal v0.0.0-20231009093132-b9187f1a92c6 h1:5kUcJJAKWWI82Xnp/CaU0eu5hLlHkmm9acjowSkwCd0= github.com/Sereal/Sereal/Go/sereal v0.0.0-20231009093132-b9187f1a92c6/go.mod h1:JwrycNnC8+sZPDyzM3MQ86LvaGzSpfxg885KOOwFRW4= github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06 h1:KkH3I3sJuOLP3TjA/dfr4NAY8bghDwnXiU7cTKxQqo0= @@ -621,8 +628,14 @@ github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3 h1:ZSTrOEhi github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e h1:QEF07wC0T1rKkctt1RINW/+RMTVmiwxETico2l3gxJA= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6 h1:G1bPvciwNyF7IUmKXNt9Ak3m6u9DE1rF+RmtIkBpVdA= +github.com/atc0005/go-teams-notify/v2 v2.13.0 h1:nbDeHy89NjYlF/PEfLVF6lsserY9O5SnN1iOIw3AxXw= +github.com/atc0005/go-teams-notify/v2 v2.13.0/go.mod h1:WSv9moolRsBcpZbwEf6gZxj7h0uJlJskJq5zkEWKO8Y= +github.com/aws/aws-lambda-go v1.47.0 h1:0H8s0vumYx/YKs4sE7YM0ktwL2eWse+kfopsRI1sXVI= +github.com/aws/aws-lambda-go v1.47.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= github.com/aws/aws-msk-iam-sasl-signer-go v1.0.1 h1:nMp7diZObd4XEVUR0pEvn7/E13JIgManMX79Q6quV6E= github.com/aws/aws-msk-iam-sasl-signer-go v1.0.1/go.mod h1:MVYeeOhILFFemC/XlYTClvBjYZrg/EPd3ts885KrNTI= +github.com/aws/aws-sdk-go-v2/service/kinesis v1.33.0 h1:JPXkrQk5OS/+Q81fKH97Ll/Vmmy0p9vwHhxw+V+tVjg= +github.com/aws/aws-sdk-go-v2/service/kinesis v1.33.0/go.mod h1:dJngkoVMrq0K7QvRkdRZYM4NUp6cdWa2GBdpm8zoY8U= github.com/aws/aws-sdk-go-v2/service/kms v1.35.3 h1:UPTdlTOwWUX49fVi7cymEN6hDqCwe3LNv1vi7TXUutk= github.com/aws/aws-sdk-go-v2/service/kms v1.35.3/go.mod h1:gjDP16zn+WWalyaUqwCCioQ8gU8lzttCCc9jYsiQI/8= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.32.4 h1:NgRFYyFpiMD62y4VPXh4DosPFbZd4vdMVBWKk0VmWXc= @@ -641,6 +654,8 @@ github.com/aws/aws-sdk-go-v2/service/ssm v1.52.4 h1:hgSBvRT7JEWx2+vEGI9/Ld5rZtl7 github.com/aws/aws-sdk-go-v2/service/ssm v1.52.4/go.mod h1:v7NIzEFIHBiicOMaMTuEmbnzGnqW0d+6ulNALul6fYE= github.com/aws/aws-sdk-go-v2/service/ssm v1.58.0 h1:zQz6Q5uaC8s9734DV9UDAm2q1TEEfOvEejDBSulOapI= github.com/aws/aws-sdk-go-v2/service/ssm v1.58.0/go.mod h1:PUWUl5MDiYNQkUHN9Pyd9kgtA/YhbxnSnHP+yQqzrM8= +github.com/awslabs/aws-lambda-go-api-proxy v0.16.2 h1:CJyGEyO1CIwOnXTU40urf0mchf6t3voxpvUDikOU9LY= +github.com/awslabs/aws-lambda-go-api-proxy v0.16.2/go.mod h1:vxxjwBHe/KbgFeNlAP/Tvp4SsVRL3WQamcWRxqVh0z0= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/baidubce/bce-sdk-go v0.9.188 h1:8MA7ewe4VpX01uYl7Kic6ZvfIReUFdSKbY46ZqlQM7U= @@ -693,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= @@ -706,6 +722,8 @@ github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARu github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/containerd/containerd v1.6.18 h1:qZbsLvmyu+Vlty0/Ex5xc0z2YtKpIsb5n45mAMI+2Ns= github.com/containerd/containerd v1.6.18/go.mod h1:1RdCUu95+gc2v9t3IL+zIlpClSmew7/0YS8O5eQZrOw= +github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao= +github.com/containerd/containerd v1.7.18/go.mod h1:IYEk9/IO6wAPUz2bCMVUbsfXjzw5UNP5fLz4PsUygQ4= github.com/containerd/fifo v1.1.0 h1:4I2mbh5stb1u6ycIABlBw9zgtlK8viPI9QkQNRQEEmY= github.com/containerd/fifo v1.1.0/go.mod h1:bmC4NWMbXlt2EZ0Hc7Fx7QzTFxgPID13eH0Qu+MAb2o= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= @@ -814,6 +832,7 @@ github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjT github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/expr-lang/expr v1.16.9/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/expr-lang/expr v1.17.0/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= @@ -827,6 +846,8 @@ github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= github.com/form3tech-oss/jwt-go v3.2.2+incompatible h1:TcekIExNqud5crz4xD2pavyTgWiPvpYe4Xau31I0PRk= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fsouza/fake-gcs-server v1.7.0 h1:Un0BXUXrRWYSmYyC1Rqm2e2WJfTPyDy/HGMz31emTi8= +github.com/fsouza/fake-gcs-server v1.52.2 h1:j6ne83nqHrlX5EEor7WWVIKdBsztGtwJ1J2mL+k+iio= +github.com/fsouza/fake-gcs-server v1.52.2/go.mod h1:47HKyIkz6oLTes1R8vEaHLwXfzYsGfmDUk1ViHHAUsA= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/getkin/kin-openapi v0.128.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM= @@ -885,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= @@ -947,14 +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= @@ -1048,6 +1073,8 @@ github.com/joncrlsn/dque v0.0.0-20211108142734-c2ef48c5192a/go.mod h1:dNKs71rs2V github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o= github.com/jsternberg/zap-logfmt v1.2.0 h1:1v+PK4/B48cy8cfQbxL4FmmNZrjnIMr2BsnyEmXqv2o= github.com/jsternberg/zap-logfmt v1.2.0/go.mod h1:kz+1CUmCutPWABnNkOu9hOHKdT2q3TDYCcsFy9hpqb0= +github.com/jsternberg/zap-logfmt v1.3.0 h1:z1n1AOHVVydOOVuyphbOKyR4NICDQFiJMn1IK5hVQ5Y= +github.com/jsternberg/zap-logfmt v1.3.0/go.mod h1:N3DENp9WNmCZxvkBD/eReWwz1149BK6jEN9cQ4fNwZE= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d h1:c93kUJDtVAXFEhsCh5jSxyOJmFHuzcihnslQiX8Urwo= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5 h1:PJr+ZMXIecYc1Ey2zucXdR73SMBtgjPgwa31099IMv0= @@ -1071,6 +1098,7 @@ github.com/kevinmbeaulieu/eq-go v1.0.0/go.mod h1:G3S8ajA56gKBZm4UB9AOyoOS37JO3ro github.com/kisielk/errcheck v1.5.0 h1:e8esj/e4R+SAOwFwN+n3zr0nYeCyeweozKfO23MvHzY= github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46 h1:veS9QfglfvqAw2e+eeNT/SbGySq8ajECXJ9e4fPoLhY= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/cpuid v1.3.1 h1:5JNjFYYQrZeKRJ0734q51WCEEn2huer72Dc7K+R/b6s= github.com/klauspost/cpuid v1.3.1/go.mod h1:bYW4mA6ZgKPob1/Dlai2LviZJO7KGI3uoWLd42rAQw4= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= @@ -1079,8 +1107,20 @@ github.com/knadh/koanf v1.5.0 h1:q2TSd/3Pyc/5yP9ldIrSdIz26MCcyNQzW0pEAugLPNs= github.com/knadh/koanf v1.5.0/go.mod h1:Hgyjp4y8v44hpZtPzs7JZfRAW5AhN7KfZcwv1RYggDs= github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/json v0.1.0 h1:dzSZl5pf5bBcW0Acnu20Djleto19T0CfHcvZ14NJ6fU= +github.com/knadh/koanf/parsers/json v0.1.0/go.mod h1:ll2/MlXcZ2BfXD6YJcjVFzhG9P0TdJ207aIBKQhV2hY= +github.com/knadh/koanf/parsers/toml v0.1.0 h1:S2hLqS4TgWZYj4/7mI5m1CQQcWurxUz6ODgOub/6LCI= +github.com/knadh/koanf/parsers/toml v0.1.0/go.mod h1:yUprhq6eo3GbyVXFFMdbfZSo928ksS+uo0FFqNMnO18= +github.com/knadh/koanf/parsers/yaml v0.1.0 h1:ZZ8/iGfRLvKSaMEECEBPM1HQslrZADk8fP1XFUxVI5w= +github.com/knadh/koanf/parsers/yaml v0.1.0/go.mod h1:cvbUDC7AL23pImuQP0oRw/hPuccrNBS2bps8asS0CwY= github.com/knadh/koanf/providers/confmap v0.1.0 h1:gOkxhHkemwG4LezxxN8DMOFopOPghxRVp7JbIvdvqzU= github.com/knadh/koanf/providers/confmap v0.1.0/go.mod h1:2uLhxQzJnyHKfxG927awZC7+fyHFdQkd697K4MdLnIU= +github.com/knadh/koanf/providers/env v1.0.0 h1:ufePaI9BnWH+ajuxGGiJ8pdTG0uLEUWC7/HDDPGLah0= +github.com/knadh/koanf/providers/env v1.0.0/go.mod h1:mzFyRZueYhb37oPmC1HAv/oGEEuyvJDA98r3XAa8Gak= +github.com/knadh/koanf/providers/file v1.1.2 h1:aCC36YGOgV5lTtAFz2qkgtWdeQsgfxUkxDOe+2nQY3w= +github.com/knadh/koanf/providers/file v1.1.2/go.mod h1:/faSBcv2mxPVjFrXck95qeoyoZ5myJ6uxN8OOVNJJCI= +github.com/knadh/koanf/providers/posflag v0.1.0 h1:mKJlLrKPcAP7Ootf4pBZWJ6J+4wHYujwipe7Ie3qW6U= +github.com/knadh/koanf/providers/posflag v0.1.0/go.mod h1:SYg03v/t8ISBNrMBRMlojH8OsKowbkXV7giIbBVgbz0= github.com/knadh/koanf/v2 v2.1.2 h1:I2rtLRqXRy1p01m/utEtpZSSA6dcJbgGVuE27kW2PzQ= github.com/knadh/koanf/v2 v2.1.2/go.mod h1:Gphfaen0q1Fc1HTgJgSTC4oRX9R2R5ErYMZJy8fLJBo= github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= @@ -1088,10 +1128,16 @@ github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 h1:T+h1c/A9Gawja4Y9mFVWj2vyii2bbUNDw3kt9VxK2EY= github.com/kr/pty v1.1.5 h1:hyz3dwM5QLc1Rfoz4FuWJQG5BN7tc6K1MndAUnGpQr4= github.com/kshvakov/clickhouse v1.3.5 h1:PDTYk9VYgbjPAWry3AoDREeMgOVUFij6bh6IjlloHL0= +github.com/labstack/echo-contrib v0.17.2 h1:K1zivqmtcC70X9VdBFdLomjPDEVHlrcAObqmuFj1c6w= +github.com/labstack/echo-contrib v0.17.2/go.mod h1:NeDh3PX7j/u+jR4iuDt1zHmWZSCz9c/p9mxXcDpyS8E= github.com/labstack/echo/v4 v4.11.1 h1:dEpLU2FLg4UVmvCGPuk/APjlH6GDpbEPti61srUUUs4= github.com/labstack/echo/v4 v4.11.1/go.mod h1:YuYRTSM3CHs2ybfrL8Px48bO6BAnYIN4l8wSTMP6BDQ= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/leodido/go-syslog/v4 v4.1.0 h1:Wsl194qyWXr7V6DrGWC3xmxA9Ra6XgWO+toNt2fmCaI= github.com/leodido/go-syslog/v4 v4.1.0/go.mod h1:eJ8rUfDN5OS6dOkCOBYlg2a+hbAg6pJa99QXXgMrd98= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= @@ -1102,6 +1148,8 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8 github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/logrusorgru/aurora/v4 v4.0.0 h1:sRjfPpun/63iADiSvGGjgA1cAYegEWMPCJdUpJYn9JA= github.com/logrusorgru/aurora/v4 v4.0.0/go.mod h1:lP0iIa2nrnT/qoFXcOZSrZQpJ1o6n2CUf/hyHi2Q4ZQ= +github.com/luci/go-render v0.0.0-20160219211803-9a04cc21af0f h1:WVPqVsbUsrzAebTEgWRAZMdDOfkFx06iyhbIoyMgtkE= +github.com/luci/go-render v0.0.0-20160219211803-9a04cc21af0f/go.mod h1:aS446i8akEg0DAtNKTVYpNpLPMc0SzsZ0RtGhjl0uFM= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= github.com/lyft/protoc-gen-star v0.6.1 h1:erE0rdztuaDq3bpGifD95wfoPrSZc95nGA6tbiNYh6M= @@ -1204,6 +1252,8 @@ github.com/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU= github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o= +github.com/pablor21/echo-etag/v4 v4.0.3 h1:o49j5NmxbqWIMfKHtzJan33PW12LQnORDFlM6qMaMqw= +github.com/pablor21/echo-etag/v4 v4.0.3/go.mod h1:cKXqBSw57xk+jT68+CR9HZTD4yWgbmGjvDdQcxRWdY0= github.com/parquet-go/parquet-go v0.25.1-0.20250428214007-401fed3de956 h1:EqOiLPZlZ3UfC9d51fuYWP13FxwSzUCTArR0wg1LlxM= github.com/parquet-go/parquet-go v0.25.1-0.20250428214007-401fed3de956/go.mod h1:OqBBRGBl7+llplCvDMql8dEKaDqjaFA/VAPw+OJiNiw= github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU= @@ -1221,6 +1271,8 @@ github.com/pierrec/lz4 v2.0.5+incompatible h1:2xWsjqPFWcplujydGg4WmhC/6fZqK42wMM github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A= github.com/pkg/sftp v1.13.7 h1:uv+I3nNJvlKZIQGSr8JVQLNHFU9YhhNpvC14Y6KgmSM= github.com/pkg/sftp v1.13.7/go.mod h1:KMKI0t3T6hfA+lTR/ssZdunHo+uwq7ghoN09/FSu3DY= +github.com/pkg/xattr v0.4.10 h1:Qe0mtiNFHQZ296vRgUjRCoPHPqH7VdTOrZx3g0T+pGA= +github.com/pkg/xattr v0.4.10/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= @@ -1228,6 +1280,7 @@ github.com/pquerna/cachecontrol v0.1.0 h1:yJMy84ti9h/+OEWa752kBTKv4XC30OtVVHYv/8 github.com/pquerna/cachecontrol v0.1.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= github.com/pquerna/ffjson v0.0.0-20190930134022-aa0246cd15f7 h1:xoIK0ctDddBMnc74udxJYBqlo9Ylnsp1waqjLsnef20= github.com/pquerna/ffjson v0.0.0-20190930134022-aa0246cd15f7/go.mod h1:YARuvh7BUWHNhzDq2OM5tzR2RiCcN2D7sapiKyCel/M= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/common/assets v0.2.0 h1:0P5OrzoHrYBOSM1OigWL3mY8ZvV2N4zIE/5AahrSrfM= @@ -1235,14 +1288,18 @@ github.com/prometheus/statsd_exporter v0.26.1 h1:ucbIAdPmwAUcA+dU+Opok8Qt81Aw8Ha github.com/prometheus/statsd_exporter v0.26.1/go.mod h1:XlDdjAmRmx3JVvPPYuFNUg+Ynyb5kR69iPPkQjxXFMk= github.com/pterm/pterm v0.12.80 h1:mM55B+GnKUnLMUSqhdINe4s6tOuVQIetQ3my8JGyAIg= github.com/pterm/pterm v0.12.80/go.mod h1:c6DeF9bSnOSeFPZlfs4ZRAFcf5SCoTwvwQ5xaKGQlHo= +github.com/r3labs/diff/v3 v3.0.1 h1:CBKqf3XmNRHXKmdU7mZP1w7TV0pDyVCis1AUHtA4Xtg= +github.com/r3labs/diff/v3 v3.0.1/go.mod h1:f1S9bourRbiM66NskseyUdo0fTmEE0qKrikYJX63dgo= github.com/rabbitmq/amqp091-go v1.9.0 h1:qrQtyzB4H8BQgEuJwhmVQqVHB9O4+MNDJCCAcpc3Aoo= 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= github.com/richardartoul/molecule v1.0.0/go.mod h1:uvX/8buq8uVeiZiFht+0lqSLBHF+uGV8BrTv8W/SIwk= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= @@ -1253,6 +1310,12 @@ github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNl github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245 h1:K1Xf3bKttbF+koVGaX5xngRIZ5bVjbmPnaxE/dR08uY= github.com/ryanuber/columnize v2.1.2+incompatible h1:C89EOx/XBWwIXl8wm8OPJBd7kPF25UfsK2X7Ph/zCAk= github.com/sagikazarmark/crypt v0.6.0 h1:REOEXCs/NFY/1jOCEouMuT4zEniE5YoXbvpC5X/TLF8= +github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= +github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= +github.com/samber/slog-common v0.18.1 h1:c0EipD/nVY9HG5shgm/XAs67mgpWDMF+MmtptdJNCkQ= +github.com/samber/slog-common v0.18.1/go.mod h1:QNZiNGKakvrfbJ2YglQXLCZauzkI9xZBjOhWFKS3IKk= +github.com/samber/slog-zap/v2 v2.6.2 h1:IPHgVQjBfEwqu7fBxSxvvl+/E4b7TqAu/eispdQdv9M= +github.com/samber/slog-zap/v2 v2.6.2/go.mod h1:bMOphuaRcThr+2X7vE4kFaqyr1lqGkc9Js95n9X6xaU= github.com/samuel/go-zookeeper v0.0.0-20190810000440-0ceca61e4d75 h1:cA+Ubq9qEVIQhIWvP2kNuSZ2CmnfBJFSRq+kO1pu2cc= github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/schollz/closestmatch v2.1.0+incompatible h1:Uel2GXEpJqOWBrlyI+oY9LTiyyjYS17cCYRqP13/SHk= @@ -1282,12 +1345,24 @@ github.com/substrait-io/substrait v0.66.1-0.20250205013839-a30b3e2d7ec6 h1:Xqtxw github.com/substrait-io/substrait v0.66.1-0.20250205013839-a30b3e2d7ec6/go.mod h1:MPFNw6sToJgpD5Z2rj0rQrdP/Oq8HG7Z2t3CAEHtkHw= github.com/substrait-io/substrait-go/v3 v3.9.1 h1:2yfHDHpK6KMcvLd0bJVzUJoeXO+K98yS+ciBruxD9po= github.com/substrait-io/substrait-go/v3 v3.9.1/go.mod h1:VG7jCqtUm28bSngHwq86FywtU74knJ25LNX63SZ53+E= +github.com/swaggo/echo-swagger v1.4.1 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk= +github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc= +github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= +github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= +github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= +github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= github.com/tdewolff/minify/v2 v2.12.8 h1:Q2BqOTmlMjoutkuD/OPCnJUpIqrzT3nRPkw+q+KpXS0= github.com/tdewolff/minify/v2 v2.12.8/go.mod h1:YRgk7CC21LZnbuke2fmYnCTq+zhCgpb0yJACOTUNJ1E= github.com/tdewolff/parse/v2 v2.6.7 h1:WrFllrqmzAcrKHzoYgMupqgUBIfBVOb0yscFzDf8bBg= github.com/tdewolff/parse/v2 v2.6.7/go.mod h1:XHDhaU6IBgsryfdnpzUXBlT6leW/l25yrFBTEb4eIyM= github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo= github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4= +github.com/testcontainers/testcontainers-go/modules/azurite v0.35.0 h1:gUZ25e1DVE/0+ZZ0nupsIo+C1j7UNloN7Pkg3w6tceI= +github.com/testcontainers/testcontainers-go/modules/azurite v0.35.0/go.mod h1:2Fc67EpyOEexLAF99zhSuzu9H22zd83pkjxEHHTtHf4= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.35.0 h1:i1Kh9fmXgHG9z3uzJv5Arz7pDKVaaNpLrqyd+0xhYMA= +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/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= @@ -1332,6 +1407,8 @@ github.com/vertica/vertica-sql-go v1.3.3 h1:fL+FKEAEy5ONmsvya2WH5T8bhkvY27y/Ik3R github.com/vertica/vertica-sql-go v1.3.3/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4= github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/willf/bitset v1.1.11 h1:N7Z7E9UvjW+sGsEl7k/SJrvY2reP1A07MrGuCjIOjRE= @@ -1352,6 +1429,10 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xhit/go-str2duration v1.2.0 h1:BcV5u025cITWxEQKGWr1URRzrcXtu7uk8+luz3Yuhwc= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= +github.com/xitongsys/parquet-go v1.6.2 h1:MhCaXii4eqceKPu9BwrjLqyK10oX9WF+xGhwvwbw7xM= +github.com/xitongsys/parquet-go v1.6.2/go.mod h1:IulAQyalCm0rPiZVNnCgm/PCL64X2tdSVGMQ/UeKqWA= +github.com/xitongsys/parquet-go-source v0.0.0-20230830030807-0dd610dbff1d h1:VVWj8KWdzpebBaXpTVpOaQW32y2UCWy3JXJ5lVDa/e8= +github.com/xitongsys/parquet-go-source v0.0.0-20230830030807-0dd610dbff1d/go.mod h1:HaLl1OAA7RAuQURU3Enxn7aRAI9yezsPPaxiGrbzxW4= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77 h1:ESFSdwYZvkeru3RtdrYueztKhOBCSAAzS4Gf+k0tEow= @@ -1377,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= @@ -1494,6 +1576,8 @@ go.opentelemetry.io/contrib/bridges/otelzap v0.10.0/go.mod h1:oTTm4g7NEtHSV2i/0F go.opentelemetry.io/contrib/detectors/gcp v1.29.0/go.mod h1:GW2aWZNwR2ZxDLdv8OyC2G8zkRoQBuURgV7RPQgcPoU= go.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo= go.opentelemetry.io/contrib/detectors/gcp v1.35.0/go.mod h1:qGWP8/+ILwMRIUf9uIVLloR1uo5ZYAslM4O6OqUi1DA= +go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.59.0 h1:I8k9HW4yl8SRYNmECKKtjhcOvq9lAP9riqYPixBU3qw= +go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.59.0/go.mod h1:/vTiuiSKBQAerQeMB3CsVJbXd+cvTbhcdOk5AV5Z5R0= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0/go.mod h1:HDBUsEjOuRC0EzKZ1bSaRGZWUBAzo+MhAcUUORSr4D0= @@ -1525,7 +1609,9 @@ go.opentelemetry.io/otel/bridge/opentracing v1.35.0/go.mod h1:p5CbIL4v7uQz7mnQD6 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.36.0/go.mod h1:rUKCPscaRWWcqGT6HnEmYrK+YNe5+Sw64xgQTOJ5b30= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.36.0/go.mod h1:RboSDkp7N292rgu+T0MgVt2qgFGu6qa1RpZDOtpL76w= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0/go.mod h1:179AK5aar5R3eS9FucPy6rggvU0g52cvKId8pv4+v0c= go.opentelemetry.io/otel/exporters/prometheus v0.58.0/go.mod h1:7qo/4CLI+zYSNbv0GMNquzuss2FVZo3OYrGh96n4HNc= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.36.0/go.mod h1:PD57idA/AiFD5aqoxGxCvT/ILJPeHy3MjqU/NS7KogY= @@ -1554,6 +1640,7 @@ go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= +go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= @@ -1570,6 +1657,7 @@ golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0Y golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= @@ -1585,8 +1673,8 @@ golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= -golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211123203042-d83791d6bcd9/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -1601,6 +1689,7 @@ golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8= golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= @@ -1611,6 +1700,7 @@ golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -1627,12 +1717,14 @@ 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= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= @@ -1648,6 +1740,8 @@ golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0 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= @@ -1721,6 +1815,8 @@ google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= 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.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= @@ -1747,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= @@ -1783,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 d038cc0962d..8a70fc05731 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "packages:prepare": "lerna version --no-push --no-git-tag-version --force-publish --exact", "packages:pack": "mkdir -p ./npm-artifacts && lerna exec --no-private -- yarn pack --out \"../../npm-artifacts/%s-%v.tgz\"", "packages:typecheck": "nx run-many -t typecheck --projects='tag:scope:package'", + "packages:test:ci": "nx run-many -t test:ci --projects='tag:scope:package'", "prettier:check": "prettier --check --ignore-path .prettierignore --list-different=false --log-level=warn \"**/*.{ts,tsx,scss,md,mdx,json,js,cjs}\"", "prettier:checkDocs": "prettier --check --list-different=false --log-level=warn \"docs/**/*.md\" \"*.md\" \"packages/**/*.{ts,tsx,scss,md,mdx,json,js,cjs}\"", "prettier:write": "prettier --ignore-path .prettierignore --list-different \"**/*.{js,ts,tsx,scss,md,mdx,json,cjs}\" --write", @@ -67,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", @@ -92,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", + "@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": "^0.4.6", "@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", @@ -134,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", @@ -163,8 +164,8 @@ "@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", "blob-polyfill": "9.0.20240710", @@ -242,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", + "webpack": "5.101.0", "webpack-assets-manifest": "^5.1.0", "webpack-cli": "6.0.1", "webpack-livereload-plugin": "3.0.2", @@ -267,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:*", @@ -289,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.28.6", + "@grafana/scenes-react": "6.28.6", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", @@ -309,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", @@ -346,13 +347,14 @@ "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", "json-markup": "^1.1.0", "json-source-map": "0.6.1", "jsurl": "^0.1.5", - "kbar": "0.1.0-beta.45", + "kbar": "0.1.0-beta.48", "leven": "^4.0.0", "lodash": "4.17.21", "logfmt": "^1.3.2", @@ -405,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 0d4dfa1c746..a777abcc684 100644 --- a/packages/grafana-alerting/package.json +++ b/packages/grafana-alerting/package.json @@ -54,11 +54,11 @@ "scripts": { "build": "tsc -p ./tsconfig.build.json && rollup -c rollup.config.ts --configPlugin esbuild", "bundle": "rollup -c rollup.config.ts --configPlugin esbuild", - "clean": "rimraf ./dist ./compiled ./unstable ./package.tgz", + "clean": "rimraf ./dist ./compiled ./unstable ./testing ./package.tgz", "typecheck": "tsc --emitDeclarationOnly false --noEmit", "codegen": "rtk-query-codegen-openapi ./scripts/codegen.ts", - "prepack": "cp package.json package.json.bak && ALIAS_PACKAGE_NAME=unstable node ../../scripts/prepare-npm-package.js", - "postpack": "mv package.json.bak package.json && rimraf ./unstable" + "prepack": "cp package.json package.json.bak && ALIAS_PACKAGE_NAME=testing,unstable node ../../scripts/prepare-npm-package.js", + "postpack": "mv package.json.bak package.json && rimraf ./unstable ./testing" }, "devDependencies": { "@grafana/test-utils": "workspace:*", diff --git a/packages/grafana-alerting/rollup.config.ts b/packages/grafana-alerting/rollup.config.ts index 1d9b0e1ae40..1638793c474 100644 --- a/packages/grafana-alerting/rollup.config.ts +++ b/packages/grafana-alerting/rollup.config.ts @@ -18,4 +18,9 @@ export default [ output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-alerting')], treeshake: false, }, + { + input: 'src/testing.ts', + plugins, + output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-alerting')], + }, ]; diff --git a/packages/grafana-alerting/src/testing.ts b/packages/grafana-alerting/src/testing.ts index 3de18f49993..b3a0d024b32 100644 --- a/packages/grafana-alerting/src/testing.ts +++ b/packages/grafana-alerting/src/testing.ts @@ -4,3 +4,6 @@ export * from './grafana/api/v0alpha1/mocks/handlers'; // export mocks and factories export * from './grafana/api/v0alpha1/mocks/fakes/common'; export * from './grafana/api/v0alpha1/mocks/fakes/Receivers'; + +// scenarios +export * from './grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.scenario'; diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 99cf6ee0f52..10a5d2e046f 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", 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/explore.ts b/packages/grafana-data/src/types/explore.ts index 68f9b999d40..62c4fdcf62b 100644 --- a/packages/grafana-data/src/types/explore.ts +++ b/packages/grafana-data/src/types/explore.ts @@ -49,6 +49,7 @@ export interface ExploreUrlState { queries: T[]; range: URLRange; panelsState?: ExplorePanelsState; + compact?: boolean; } export interface ExplorePanelsState extends Partial> { @@ -89,6 +90,7 @@ export interface SplitOpenOptions { range?: TimeRange; panelsState?: ExplorePanelsState; correlationHelperData?: ExploreCorrelationHelperData; + compact?: boolean; } /** diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index c82fffa8ea5..87a3dee8d06 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -282,19 +282,10 @@ 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; /** - * Route the folder and dashboard service requests to k8s - * @default true - */ - kubernetesClientDashboardsFolders?: boolean; - /** * Disable schema validation for dashboards/v1 */ dashboardDisableSchemaValidationV1?: boolean; @@ -307,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; @@ -462,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 */ @@ -965,6 +965,10 @@ export interface FeatureToggles { */ metricsFromProfiles?: boolean; /** + * Enables using PGX instead of libpq for PostgreSQL datasource + */ + postgresDSUsePGX?: boolean; + /** * Enables creating alerts from Tempo data source */ tempoAlerting?: boolean; @@ -1075,8 +1079,16 @@ 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 */ alertmanagerRemoteSecondaryWithRemoteState?: boolean; + /** + * Enable adhoc filter buttons in visualization tooltips + */ + adhocFiltersInTooltips?: boolean; } diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index e64bf16cfdb..32764409a8d 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -197,6 +197,7 @@ export enum PluginExtensionPoints { ExploreToolbarAction = 'grafana/explore/toolbar/action', UserProfileTab = 'grafana/user/profile/tab', TraceViewDetails = 'grafana/traceview/details', + TraceViewHeaderActions = 'grafana/traceview/header/actions', QueryEditorRowAdaptiveTelemetryV1 = 'grafana/query-editor-row/adaptivetelemetry/v1', TraceViewResourceAttributes = 'grafana/traceview/resource-attributes', LogsViewResourceAttributes = 'grafana/logsview/resource-attributes', diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index d647d0fb88d..2e5dd9f65a4 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", diff --git a/packages/grafana-flamegraph/package.json b/packages/grafana-flamegraph/package.json index f8597f17c32..3152966e799 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,7 +79,7 @@ "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" }, diff --git a/packages/grafana-o11y-ds-frontend/package.json b/packages/grafana-o11y-ds-frontend/package.json index 1d56eb47a0e..11ef5bdce98 100644 --- a/packages/grafana-o11y-ds-frontend/package.json +++ b/packages/grafana-o11y-ds-frontend/package.json @@ -36,12 +36,12 @@ "@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" }, diff --git a/packages/grafana-plugin-configs/jest/jest-setup.js b/packages/grafana-plugin-configs/jest/jest-setup.js index 00a84b42b68..05a2b03634c 100644 --- a/packages/grafana-plugin-configs/jest/jest-setup.js +++ b/packages/grafana-plugin-configs/jest/jest-setup.js @@ -1,6 +1,10 @@ import '@testing-library/jest-dom'; import { TextEncoder, TextDecoder } from 'util'; +import { matchers } from '@grafana/test-utils/matchers'; + +expect.extend(matchers); + Object.assign(global, { TextDecoder, TextEncoder }); // https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom diff --git a/packages/grafana-plugin-configs/package.json b/packages/grafana-plugin-configs/package.json index 3ca7029e86d..a2edd65dd30 100644 --- a/packages/grafana-plugin-configs/package.json +++ b/packages/grafana-plugin-configs/package.json @@ -9,7 +9,7 @@ "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", @@ -26,7 +26,7 @@ "replace-in-file-webpack-plugin": "1.0.6", "swc-loader": "0.2.6", "typescript": "5.8.3", - "webpack": "5.97.1", + "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..b75ebc36f99 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", 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-schema/src/schema/dashboard/v2.ts b/packages/grafana-schema/src/schema/dashboard/v2.ts new file mode 100644 index 00000000000..8d4716db7da --- /dev/null +++ b/packages/grafana-schema/src/schema/dashboard/v2.ts @@ -0,0 +1,3 @@ +export * from './v2beta1/types.spec.gen'; +export * from './v2beta1/types.status.gen'; +export * from './v2beta1/types.metadata.gen'; diff --git a/packages/grafana-schema/src/schema/dashboard/v2_examples.ts b/packages/grafana-schema/src/schema/dashboard/v2_examples.ts index 4bf078783cb..651d858e799 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2_examples.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2_examples.ts @@ -1,4 +1,4 @@ -import { Spec } from './v2alpha1/types.spec.gen'; +import { defaultDataQueryKind, Spec } from './v2'; export const handyTestingSchema: Spec = { title: 'Default Dashboard', @@ -37,15 +37,16 @@ export const handyTestingSchema: Spec = { spec: { builtIn: false, query: { - kind: 'prometheus', + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'prometheus', + datasource: { + name: 'uid', + }, spec: { expr: 'test-query', }, }, - datasource: { - type: 'prometheus', - uid: 'uid', - }, filter: { ids: [1] }, enable: true, hide: false, @@ -57,15 +58,16 @@ export const handyTestingSchema: Spec = { kind: 'AnnotationQuery', spec: { builtIn: false, - datasource: { - type: 'grafana-testdata-datasource', - uid: 'uid', - }, enable: true, iconColor: 'red', name: 'Enabled', query: { - kind: 'grafana-testdata-datasource', + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'grafana-testdata-datasource', + datasource: { + name: 'uid', + }, spec: { lines: 4, refId: 'Anno', @@ -79,15 +81,16 @@ export const handyTestingSchema: Spec = { kind: 'AnnotationQuery', spec: { builtIn: false, - datasource: { - type: 'grafana-testdata-datasource', - uid: 'uid', - }, enable: false, iconColor: 'yellow', name: 'Disabled', query: { - kind: 'grafana-testdata-datasource', + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'grafana-testdata-datasource', + datasource: { + name: 'uid', + }, spec: { lines: 5, refId: 'Anno', scenarioId: 'annotations' }, }, hide: false, @@ -97,16 +100,17 @@ export const handyTestingSchema: Spec = { kind: 'AnnotationQuery', spec: { builtIn: false, - datasource: { - type: 'grafana-testdata-datasource', - uid: 'uid', - }, enable: true, hide: true, iconColor: 'dark-purple', name: 'Hidden', query: { - kind: 'grafana-testdata-datasource', + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'grafana-testdata-datasource', + datasource: { + name: 'uid', + }, spec: { lines: 6, refId: 'Anno', @@ -128,12 +132,13 @@ export const handyTestingSchema: Spec = { kind: 'PanelQuery', spec: { refId: 'A', - datasource: { - type: 'prometheus', - uid: 'datasource1', - }, query: { - kind: 'prometheus', + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'prometheus', + datasource: { + name: 'datasource1', + }, spec: { expr: 'test-query', }, @@ -179,14 +184,15 @@ export const handyTestingSchema: Spec = { title: 'Test Panel', id: 1, vizConfig: { - kind: 'timeseries', + kind: 'VizConfig', + group: 'timeseries', + version: '7.0.0', spec: { fieldConfig: { defaults: {}, overrides: [], }, options: {}, - pluginVersion: '7.0.0', }, }, }, @@ -265,10 +271,6 @@ export const handyTestingSchema: Spec = { text: 'text1', value: 'value1', }, - datasource: { - type: 'prometheus', - uid: 'datasource1', - }, definition: 'definition1', description: 'A query variable', hide: 'dontHide', @@ -278,7 +280,12 @@ export const handyTestingSchema: Spec = { name: 'queryVar', options: [], query: { - kind: 'prometheus', + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'prometheus', + datasource: { + name: 'datasource1', + }, spec: { expr: 'test-query', refId: 'A', @@ -411,15 +418,15 @@ export const handyTestingSchema: Spec = { }, { kind: 'GroupByVariable', + group: 'prometheus', + datasource: { + name: 'datasource2', + }, spec: { current: { text: 'text7', value: 'value7', }, - datasource: { - type: 'prometheus', - uid: 'datasource2', - }, description: 'A group by variable', hide: 'dontHide', label: 'Group By Variable', @@ -440,6 +447,10 @@ export const handyTestingSchema: Spec = { }, { kind: 'AdhocVariable', + group: 'prometheus', + datasource: { + name: 'datasource3', + }, spec: { baseFilters: [ { @@ -455,10 +466,6 @@ export const handyTestingSchema: Spec = { value: 'value2', }, ], - datasource: { - type: 'prometheus', - uid: 'datasource3', - }, defaultKeys: [ { expandable: true, diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha2/dashboard_object_gen.ts b/packages/grafana-schema/src/schema/dashboard/v2beta1/dashboard_object_gen.ts similarity index 100% rename from packages/grafana-schema/src/schema/dashboard/v2alpha2/dashboard_object_gen.ts rename to packages/grafana-schema/src/schema/dashboard/v2beta1/dashboard_object_gen.ts diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha2/types.metadata.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.metadata.gen.ts similarity index 100% rename from packages/grafana-schema/src/schema/dashboard/v2alpha2/types.metadata.gen.ts rename to packages/grafana-schema/src/schema/dashboard/v2beta1/types.metadata.gen.ts diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha2/types.spec.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts similarity index 98% rename from packages/grafana-schema/src/schema/dashboard/v2alpha2/types.spec.gen.ts rename to packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts index 04fc5092d8a..a6140372eb9 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha2/types.spec.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts @@ -11,19 +11,19 @@ export const defaultAnnotationQueryKind = (): AnnotationQueryKind => ({ }); export interface AnnotationQuerySpec { - datasource?: DataSourceRef; - query?: DataQueryKind; + query: DataQueryKind; enable: boolean; hide: boolean; iconColor: string; name: string; builtIn?: boolean; filter?: AnnotationPanelFilter; - // Catch-all field for datasource-specific properties + // Catch-all field for datasource-specific properties. Should not be available in as code tooling. legacyOptions?: Record; } export const defaultAnnotationQuerySpec = (): AnnotationQuerySpec => ({ + query: defaultDataQueryKind(), enable: false, hide: false, iconColor: "", @@ -31,24 +31,22 @@ export const defaultAnnotationQuerySpec = (): AnnotationQuerySpec => ({ builtIn: false, }); -export interface DataSourceRef { - // The plugin type-id - type?: string; - // Specific datasource instance - uid?: string; -} - -export const defaultDataSourceRef = (): DataSourceRef => ({ -}); - export interface DataQueryKind { - // The kind of a DataQueryKind is the datasource type - kind: string; + kind: "DataQuery"; + group: string; + version: string; + // New type for datasource reference + // Not creating a new type until we figure out how to handle DS refs for group by, adhoc, and every place that uses DataSourceRef in TS. + datasource?: { + name?: string; + }; spec: Record; } export const defaultDataQueryKind = (): DataQueryKind => ({ - kind: "", + kind: "DataQuery", + group: "", + version: "v0", spec: {}, }); @@ -151,7 +149,6 @@ export const defaultPanelQueryKind = (): PanelQueryKind => ({ export interface PanelQuerySpec { query: DataQueryKind; - datasource?: DataSourceRef; refId: string; hidden: boolean; } @@ -228,25 +225,27 @@ export const defaultQueryOptionsSpec = (): QueryOptionsSpec => ({ }); export interface VizConfigKind { - // The kind of a VizConfigKind is the plugin ID - kind: string; + kind: "VizConfig"; + // The group is the plugin ID + group: string; + version: string; spec: VizConfigSpec; } export const defaultVizConfigKind = (): VizConfigKind => ({ - kind: "", + kind: "VizConfig", + group: "", + version: "", spec: defaultVizConfigSpec(), }); // --- Kinds --- export interface VizConfigSpec { - pluginVersion: string; options: Record; fieldConfig: FieldConfigSource; } export const defaultVizConfigSpec = (): VizConfigSpec => ({ - pluginVersion: "", options: {}, fieldConfig: defaultFieldConfigSource(), }); @@ -994,7 +993,6 @@ export interface QueryVariableSpec { refresh: VariableRefresh; skipUrlSync: boolean; description?: string; - datasource?: DataSourceRef; query: DataQueryKind; regex: string; sort: VariableSort; @@ -1254,18 +1252,22 @@ export const defaultCustomVariableSpec = (): CustomVariableSpec => ({ // Group variable kind export interface GroupByVariableKind { kind: "GroupByVariable"; + group: string; + datasource?: { + name?: string; + }; spec: GroupByVariableSpec; } export const defaultGroupByVariableKind = (): GroupByVariableKind => ({ kind: "GroupByVariable", + group: "", spec: defaultGroupByVariableSpec(), }); // GroupBy variable specification export interface GroupByVariableSpec { name: string; - datasource?: DataSourceRef; defaultValue?: VariableOption; current: VariableOption; options: VariableOption[]; @@ -1288,18 +1290,22 @@ export const defaultGroupByVariableSpec = (): GroupByVariableSpec => ({ // Adhoc variable kind export interface AdhocVariableKind { kind: "AdhocVariable"; + group: string; + datasource?: { + name?: string; + }; spec: AdhocVariableSpec; } export const defaultAdhocVariableKind = (): AdhocVariableKind => ({ kind: "AdhocVariable", + group: "", spec: defaultAdhocVariableSpec(), }); // Adhoc variable specification export interface AdhocVariableSpec { name: string; - datasource?: DataSourceRef; baseFilters: AdHocFilterWithLabels[]; filters: AdHocFilterWithLabels[]; defaultKeys: MetricFindValue[]; diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha2/types.status.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.status.gen.ts similarity index 100% rename from packages/grafana-schema/src/schema/dashboard/v2alpha2/types.status.gen.ts rename to packages/grafana-schema/src/schema/dashboard/v2beta1/types.status.gen.ts diff --git a/packages/grafana-sql/package.json b/packages/grafana-sql/package.json index 574d940a658..e99c2f9a737 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,7 +51,7 @@ "@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" }, diff --git a/packages/grafana-sql/src/components/configuration/ConnectionLimits.tsx b/packages/grafana-sql/src/components/configuration/ConnectionLimits.tsx index 039425e608c..487ef88e61d 100644 --- a/packages/grafana-sql/src/components/configuration/ConnectionLimits.tsx +++ b/packages/grafana-sql/src/components/configuration/ConnectionLimits.tsx @@ -6,6 +6,8 @@ import { Field, Icon, InlineLabel, Label, Stack, Switch, Tooltip } from '@grafan import { SQLConnectionLimits, SQLOptions } from '../../types'; +import { MaxLifetimeField } from './MaxLifetimeField'; +import { MaxOpenConnectionsField } from './MaxOpenConnectionsField'; import { NumberInput } from './NumberInput'; interface Props { @@ -87,40 +89,11 @@ export const ConnectionLimits = (props: Props) - - - - Max open - - - - The maximum number of open connections to the database. If Max idle connections is greater - than 0 and the Max open connections is less than Max idle connections, then - Max idle connections will be reduced to match the Max open connections limit. If set - to 0, there is no limit on the number of open connections. - - - } - > - - - - - } - > - { - onMaxConnectionsChanged(value); - }} - width={labelWidth} - /> - + (props: Props) )} - - - - Max lifetime - - - - The maximum amount of time in seconds a connection may be reused. If set to 0, connections are - reused forever. - - - } - > - - - - - } - > - { - onJSONDataNumberChanged('connMaxLifetime')(value); - }} - width={labelWidth} - /> - + ); }; diff --git a/packages/grafana-sql/src/components/configuration/MaxLifetimeField.tsx b/packages/grafana-sql/src/components/configuration/MaxLifetimeField.tsx new file mode 100644 index 00000000000..a3317785716 --- /dev/null +++ b/packages/grafana-sql/src/components/configuration/MaxLifetimeField.tsx @@ -0,0 +1,47 @@ +import { Trans } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; +import { Field, Icon, Label, Stack, Tooltip } from '@grafana/ui'; + +import { SQLOptions } from '../../types'; + +import { NumberInput } from './NumberInput'; + +interface Props { + labelWidth: number; + onMaxLifetimeChanged: (number?: number) => void; + jsonData: SQLOptions; +} +export function MaxLifetimeField({ labelWidth, onMaxLifetimeChanged, jsonData }: Props) { + return ( + + + + Max lifetime + + + + The maximum amount of time in seconds a connection may be reused. If set to 0, connections are + reused forever. + + + } + > + + + + + } + > + + + ); +} diff --git a/packages/grafana-sql/src/components/configuration/MaxOpenConnectionsField.tsx b/packages/grafana-sql/src/components/configuration/MaxOpenConnectionsField.tsx new file mode 100644 index 00000000000..377f05f0fe0 --- /dev/null +++ b/packages/grafana-sql/src/components/configuration/MaxOpenConnectionsField.tsx @@ -0,0 +1,50 @@ +import { Trans } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; +import { Field, Icon, Label, Stack, Tooltip } from '@grafana/ui'; + +import { SQLOptions } from '../../types'; + +import { NumberInput } from './NumberInput'; + +interface Props { + labelWidth: number; + onMaxConnectionsChanged: (number?: number) => void; + jsonData: SQLOptions; +} + +export function MaxOpenConnectionsField({ labelWidth, onMaxConnectionsChanged, jsonData }: Props) { + return ( + + + + Max open + + + + The maximum number of open connections to the database. If Max idle connections is greater + than 0 and the Max open connections is less than Max idle connections, then + Max idle connections will be reduced to match the Max open connections limit. If set + to 0, there is no limit on the number of open connections. + + + } + > + + + + + } + > + + + ); +} diff --git a/packages/grafana-sql/src/index.ts b/packages/grafana-sql/src/index.ts index 0de11d1f5d6..0c24d89f46a 100644 --- a/packages/grafana-sql/src/index.ts +++ b/packages/grafana-sql/src/index.ts @@ -14,6 +14,8 @@ export { COMMON_FNS, MACRO_FUNCTIONS } from './constants'; export { SqlDatasource } from './datasource/SqlDatasource'; export { formatSQL } from './utils/formatSQL'; export { ConnectionLimits } from './components/configuration/ConnectionLimits'; +export { MaxLifetimeField } from './components/configuration/MaxLifetimeField'; +export { MaxOpenConnectionsField } from './components/configuration/MaxOpenConnectionsField'; export { Divider } from './components/configuration/Divider'; export { TLSSecretsConfig } from './components/configuration/TLSSecretsConfig'; export { useMigrateDatabaseFields } from './components/configuration/useMigrateDatabaseFields'; diff --git a/packages/grafana-test-utils/README.md b/packages/grafana-test-utils/README.md index 2e864d4ad7b..370987a8931 100644 --- a/packages/grafana-test-utils/README.md +++ b/packages/grafana-test-utils/README.md @@ -1,3 +1,24 @@ # Grafana test utils This package is a collection of test utils and a mock API (using MSW) for use with core Grafana UI development. + +## Matchers + +To add the matchers to your Jest config, import them then extend `expect`. This should be done in the `setupFilesAfterEnv` file declared in `jest.config.{js,ts}`. + +```ts +// setupTests.ts +import { matchers } from '@grafana/test-utils'; + +expect.extend(matchers); +``` + +Included in this package are the following matchers: + +### `toEmitValues` + +Tests that an Observable emits the expected values in the correct order. This matcher collects all emitted values (including errors) and compares them against the expected array using deep equality. + +### `toEmitValuesWith` + +Tests that an Observable emits values that satisfy custom expectations. This matcher collects all emitted values and passes them to a callback function where you can perform custom assertions. diff --git a/packages/grafana-test-utils/jest-setup.js b/packages/grafana-test-utils/jest-setup.js new file mode 100644 index 00000000000..57d87c0c2ad --- /dev/null +++ b/packages/grafana-test-utils/jest-setup.js @@ -0,0 +1,3 @@ +import { matchers } from '@grafana/test-utils/matchers'; + +expect.extend(matchers); diff --git a/packages/grafana-test-utils/jest.config.js b/packages/grafana-test-utils/jest.config.js new file mode 100644 index 00000000000..468cf6bae51 --- /dev/null +++ b/packages/grafana-test-utils/jest.config.js @@ -0,0 +1,29 @@ +process.env.TZ = 'Pacific/Easter'; // UTC-06:00 or UTC-05:00 depending on daylight savings + +export default { + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + setupFilesAfterEnv: ['/jest-setup.js'], + testMatch: ['/**/__tests__/**/*.{js,jsx,ts,tsx}', '/**/*.{spec,test,jest}.{js,jsx,ts,tsx}'], + testEnvironment: 'node', + transform: { + '^.+\\.(t|j)sx?$': [ + '@swc/jest', + { + sourceMaps: 'inline', + jsc: { + parser: { + syntax: 'typescript', + tsx: true, + decorators: false, + dynamicImport: true, + }, + transform: { + react: { + runtime: 'automatic', + }, + }, + }, + }, + ], + }, +}; diff --git a/packages/grafana-test-utils/package.json b/packages/grafana-test-utils/package.json index 7f58c62ac0d..fe04697966f 100644 --- a/packages/grafana-test-utils/package.json +++ b/packages/grafana-test-utils/package.json @@ -40,17 +40,28 @@ "./unstable": { "import": "./src/unstable.ts", "require": "./src/unstable.ts" + }, + "./matchers": { + "types": "./src/matchers/index.ts", + "import": "./src/matchers/index.ts", + "require": "./src/matchers/index.ts" } }, "scripts": { - "typecheck": "tsc --emitDeclarationOnly false --noEmit" + "typecheck": "tsc --emitDeclarationOnly false --noEmit", + "test": "jest --watch --onlyChanged", + "test:ci": "jest --maxWorkers 4" }, "dependencies": { "msw": "2.10.4" }, "devDependencies": { "@grafana/tsconfig": "^2.0.0", - "@types/node": "22.16.5", + "@swc/core": "1.13.3", + "@swc/jest": "^0.2.26", + "@types/jest": "29.5.14", + "@types/node": "22.17.0", + "jest": "29.7.0", "typescript": "5.8.3" } } 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..edf4c592ef2 100644 --- a/packages/grafana-test-utils/src/handlers/all-handlers.ts +++ b/packages/grafana-test-utils/src/handlers/all-handlers.ts @@ -1,5 +1,7 @@ import { HttpHandler } from 'msw'; -const allHandlers: HttpHandler[] = []; +import teamsHandlers from './api/teams/handlers'; + +const allHandlers: HttpHandler[] = [...teamsHandlers]; export default allHandlers; 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/index.ts b/packages/grafana-test-utils/src/index.ts index f5d42f308b0..55005c34d54 100644 --- a/packages/grafana-test-utils/src/index.ts +++ b/packages/grafana-test-utils/src/index.ts @@ -5,4 +5,6 @@ * @packageDocumentation */ -export {}; +// This is also exported as `@grafana/test-utils/matchers` but we cannot use that in places +// where the tsconfig is not set to moduleResolution: bundler so we export it here also. +export { matchers } from './matchers'; diff --git a/public/test/matchers/index.ts b/packages/grafana-test-utils/src/matchers/index.ts similarity index 86% rename from public/test/matchers/index.ts rename to packages/grafana-test-utils/src/matchers/index.ts index b62de8d6974..11a1891accd 100644 --- a/public/test/matchers/index.ts +++ b/packages/grafana-test-utils/src/matchers/index.ts @@ -1,4 +1,4 @@ -import { Observable } from 'rxjs'; +import type { Observable } from 'rxjs'; import { toEmitValues } from './toEmitValues'; import { toEmitValuesWith } from './toEmitValuesWith'; diff --git a/public/test/matchers/toEmitValues.test.ts b/packages/grafana-test-utils/src/matchers/toEmitValues.test.ts similarity index 100% rename from public/test/matchers/toEmitValues.test.ts rename to packages/grafana-test-utils/src/matchers/toEmitValues.test.ts diff --git a/public/test/matchers/toEmitValues.ts b/packages/grafana-test-utils/src/matchers/toEmitValues.ts similarity index 100% rename from public/test/matchers/toEmitValues.ts rename to packages/grafana-test-utils/src/matchers/toEmitValues.ts diff --git a/public/test/matchers/toEmitValuesWith.test.ts b/packages/grafana-test-utils/src/matchers/toEmitValuesWith.test.ts similarity index 100% rename from public/test/matchers/toEmitValuesWith.test.ts rename to packages/grafana-test-utils/src/matchers/toEmitValuesWith.test.ts diff --git a/public/test/matchers/toEmitValuesWith.ts b/packages/grafana-test-utils/src/matchers/toEmitValuesWith.ts similarity index 100% rename from public/test/matchers/toEmitValuesWith.ts rename to packages/grafana-test-utils/src/matchers/toEmitValuesWith.ts diff --git a/packages/grafana-test-utils/src/matchers/types.ts b/packages/grafana-test-utils/src/matchers/types.ts new file mode 100644 index 00000000000..c7c420e9355 --- /dev/null +++ b/packages/grafana-test-utils/src/matchers/types.ts @@ -0,0 +1,28 @@ +import { Observable } from 'rxjs'; + +export const OBSERVABLE_TEST_TIMEOUT_IN_MS = 1000; + +export interface ObservableMatchers extends jest.ExpectExtendMap { + toEmitValues(received: Observable, expected: T[]): Promise; + toEmitValuesWith( + received: Observable, + expectations: (received: T[]) => void + ): Promise; +} + +type ObservableType = T extends Observable ? V : never; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace jest { + interface Matchers { + toEmitValues>(expected: E[]): Promise; + /** + * Collect all the values emitted by the observables (also errors) and pass them to the expectations functions after + * the observable ended (or emitted error). If Observable does not complete within OBSERVABLE_TEST_TIMEOUT_IN_MS the + * test fails. + */ + toEmitValuesWith>(expectations: (received: E[]) => void): Promise; + } + } +} diff --git a/public/test/matchers/utils.ts b/packages/grafana-test-utils/src/matchers/utils.ts similarity index 100% rename from public/test/matchers/utils.ts rename to packages/grafana-test-utils/src/matchers/utils.ts diff --git a/packages/grafana-test-utils/src/unstable.ts b/packages/grafana-test-utils/src/unstable.ts index cb0ff5c3b54..5b60f83479c 100644 --- a/packages/grafana-test-utils/src/unstable.ts +++ b/packages/grafana-test-utils/src/unstable.ts @@ -1 +1 @@ -export {}; +export { MOCK_TEAMS } from './fixtures/teams'; diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 8738aab4d7c..2193c02d0e8 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", @@ -204,7 +204,7 @@ "storybook": "^8.6.2", "style-loader": "4.0.0", "typescript": "5.8.3", - "webpack": "5.97.1" + "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/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/VizTooltipFooter.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx index db5d1e88c37..b4eccaee66a 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx @@ -11,10 +11,16 @@ import { DataLinkButton } from '../DataLinks/DataLinkButton'; import { Icon } from '../Icon/Icon'; import { Stack } from '../Layout/Stack/Stack'; import { ResponsiveProp } from '../Layout/utils/responsiveness'; +import { AdHocFilterItem } from '../Table/TableNG/types'; + +export interface AdHocFilterModel extends AdHocFilterItem { + onClick: () => void; +} interface VizTooltipFooterProps { dataLinks: Array>; actions?: Array>; + adHocFilters?: AdHocFilterModel[]; annotate?: () => void; } @@ -41,7 +47,7 @@ function makeRenderLinksOrActions( if (oneClickItem != null) { return ( -
+
@@ -53,7 +59,7 @@ function makeRenderLinksOrActions( } return ( -
+
{items.map((item, i) => renderItem(item, i, styles))} @@ -79,7 +85,7 @@ const renderActions = makeRenderLinksOrActions( (item, i) => ); -export const VizTooltipFooter = ({ dataLinks, actions = [], annotate }: VizTooltipFooterProps) => { +export const VizTooltipFooter = ({ dataLinks, actions = [], annotate, adHocFilters = [] }: VizTooltipFooterProps) => { const styles = useStyles2(getStyles); const hasOneClickLink = useMemo(() => dataLinks.some((link) => link.oneClick === true), [dataLinks]); const hasOneClickAction = useMemo(() => actions.some((action) => action.oneClick === true), [actions]); @@ -88,8 +94,19 @@ export const VizTooltipFooter = ({ dataLinks, actions = [], annotate }: VizToolt
{!hasOneClickAction && renderDataLinks(dataLinks, styles)} {!hasOneClickLink && renderActions(actions, styles)} + {!hasOneClickLink && !hasOneClickAction && adHocFilters.length > 0 && ( +
+ {adHocFilters.map((item, index) => ( + + ))} +
+ )} {!hasOneClickLink && !hasOneClickAction && annotate != null && ( -
+
@@ -106,11 +123,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ flex: 1, padding: theme.spacing(0), }), - dataLinks: css({ - borderTop: `1px solid ${theme.colors.border.medium}`, - padding: theme.spacing(1), - }), - addAnnotations: css({ + footerSection: css({ borderTop: `1px solid ${theme.colors.border.medium}`, padding: theme.spacing(1), }), 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/components/uPlot/plugins/TooltipPlugin2.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx index e19a6230cec..703b6a85ed1 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx @@ -7,6 +7,7 @@ import uPlot from 'uplot'; import { GrafanaTheme2, LinkModel } from '@grafana/data'; import { DashboardCursorSync } from '@grafana/schema'; +import { AdHocFilterModel } from '../../../internal'; import { useStyles2 } from '../../../themes/ThemeContext'; import { RangeSelection1D, RangeSelection2D, OnSelectRangeCallback } from '../../PanelChrome'; import { getPortalContainer } from '../../Portal/Portal'; @@ -28,6 +29,7 @@ export const enum TooltipHoverMode { } type GetDataLinksCallback = (seriesIdx: number, dataIdx: number) => LinkModel[]; +type GetAdHocFiltersCallback = (seriesIdx: number, dataIdx: number) => AdHocFilterModel[]; interface TooltipPlugin2Props { config: UPlotConfigBuilder; @@ -43,6 +45,7 @@ interface TooltipPlugin2Props { onSelectRange?: OnSelectRangeCallback; getDataLinks?: GetDataLinksCallback; + getAdHocFilters?: GetAdHocFiltersCallback; render: ( u: uPlot, @@ -53,7 +56,8 @@ interface TooltipPlugin2Props { // selected time range (for annotation triggering) timeRange: TimeRange2 | null, viaSync: boolean, - dataLinks: LinkModel[] + dataLinks: LinkModel[], + adHocFilters: AdHocFilterModel[] ) => React.ReactNode; maxWidth?: number; @@ -107,6 +111,7 @@ const MIN_ZOOM_DIST = 5; const maybeZoomAction = (e?: MouseEvent | null) => e != null && !e.ctrlKey && !e.metaKey; const getDataLinksFallback: GetDataLinksCallback = () => []; +const getAdHocFiltersFallback: GetAdHocFiltersCallback = () => []; const userAgentIsMobile = /Android|iPhone|iPad/i.test(navigator.userAgent); @@ -124,6 +129,7 @@ export const TooltipPlugin2 = ({ syncMode = DashboardCursorSync.Off, syncScope = 'global', // eventsScope getDataLinks = getDataLinksFallback, + getAdHocFilters = getAdHocFiltersFallback, }: TooltipPlugin2Props) => { const domRef = useRef(null); const portalRoot = useRef(null); @@ -143,6 +149,9 @@ export const TooltipPlugin2 = ({ const getLinksRef = useRef(getDataLinks); getLinksRef.current = getDataLinks; + const getAdHocFiltersRef = useRef(getAdHocFilters); + getAdHocFiltersRef.current = getAdHocFilters; + useLayoutEffect(() => { sizeRef.current?.observer.disconnect(); @@ -202,6 +211,7 @@ export const TooltipPlugin2 = ({ let closestSeriesIdx: number | null = null; let viaSync = false; let dataLinks: LinkModel[] = []; + let adHocFilters: AdHocFilterModel[] = []; // for onceClick link rendering during mousemoves we use these pre-generated first links or actions // these will be wrong if the titles have interpolation using the hovered *value* @@ -273,7 +283,8 @@ export const TooltipPlugin2 = ({ dismiss, selectedRange, viaSync, - _isPinned ? dataLinks : closestSeriesIdx != null ? persistentLinks[closestSeriesIdx] : [] + _isPinned ? dataLinks : closestSeriesIdx != null ? persistentLinks[closestSeriesIdx] : [], + _isPinned ? adHocFilters : [] ) : null, dismiss, @@ -293,6 +304,7 @@ export const TooltipPlugin2 = ({ _isHovering = false; _plot!.setCursor({ left: -10, top: -10 }); dataLinks = []; + adHocFilters = []; scheduleRender(prevIsPinned); }; @@ -351,6 +363,7 @@ export const TooltipPlugin2 = ({ // if tooltip visible, not pinned, and within proximity to a series/point else if (_isHovering && !_isPinned && closestSeriesIdx != null) { dataLinks = getLinksRef.current(closestSeriesIdx, seriesIdxs[closestSeriesIdx]!); + adHocFilters = getAdHocFiltersRef.current(closestSeriesIdx, seriesIdxs[closestSeriesIdx]!); const oneClickLink = dataLinks.find((dataLink) => dataLink.oneClick === true); if (oneClickLink != null) { 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/packages/grafana-ui/src/internal/index.ts b/packages/grafana-ui/src/internal/index.ts index 54bf72d16b0..0a549922564 100644 --- a/packages/grafana-ui/src/internal/index.ts +++ b/packages/grafana-ui/src/internal/index.ts @@ -83,7 +83,7 @@ export { ConfirmContent, type ConfirmContentProps } from '../components/ConfirmM export { EmotionPerfTest } from '../components/ThemeDemos/EmotionPerfTest'; export { VizTooltipContent } from '../components/VizTooltip/VizTooltipContent'; -export { VizTooltipFooter } from '../components/VizTooltip/VizTooltipFooter'; +export { VizTooltipFooter, type AdHocFilterModel } from '../components/VizTooltip/VizTooltipFooter'; export { VizTooltipHeader } from '../components/VizTooltip/VizTooltipHeader'; export { VizTooltipWrapper } from '../components/VizTooltip/VizTooltipWrapper'; export { VizTooltipRow } from '../components/VizTooltip/VizTooltipRow'; diff --git a/pkg/aggregator/go.mod b/pkg/aggregator/go.mod index a6f393daf08..c601d3a8891 100644 --- a/pkg/aggregator/go.mod +++ b/pkg/aggregator/go.mod @@ -152,7 +152,7 @@ require ( google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/grpc v1.74.0 // indirect + google.golang.org/grpc v1.74.2 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect diff --git a/pkg/aggregator/go.sum b/pkg/aggregator/go.sum index ec1d4f04729..a402844e7b9 100644 --- a/pkg/aggregator/go.sum +++ b/pkg/aggregator/go.sum @@ -499,8 +499,8 @@ google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.74.0 h1:sxRSkyLxlceWQiqDofxDot3d4u7DyoHPc7SBXMj8gGY= -google.golang.org/grpc v1.74.0/go.mod h1:NZUaK8dAMUfzhK6uxZ+9511LtOrk73UGWOFoNvz7z+s= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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/dashboard.go b/pkg/api/dashboard.go index bff3ffadc7a..bde32d4ad3f 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -204,7 +204,7 @@ func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response } metrics.MFolderIDsAPICount.WithLabelValues(metrics.GetDashboard).Inc() // lookup folder title & url - if dash.FolderUID != "" && hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { + if dash.FolderUID != "" { queryResult, err := hs.folderService.Get(ctx, &folder.GetFolderQuery{ OrgID: c.GetOrgID(), UID: &dash.FolderUID, diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index e3159225c30..5e8167fa532 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -19,32 +19,23 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db/dbtest" - "github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/serverlock" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" - accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/annotations/annotationstest" - "github.com/grafana/grafana/pkg/services/apiserver" - "github.com/grafana/grafana/pkg/services/apiserver/client" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/dashboards/database" - "github.com/grafana/grafana/pkg/services/dashboards/service" dashver "github.com/grafana/grafana/pkg/services/dashboardversion" "github.com/grafana/grafana/pkg/services/dashboardversion/dashvertest" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" - "github.com/grafana/grafana/pkg/services/folder/folderimpl" "github.com/grafana/grafana/pkg/services/folder/foldertest" libraryelementsfake "github.com/grafana/grafana/pkg/services/libraryelements/fake" "github.com/grafana/grafana/pkg/services/librarypanels" @@ -59,14 +50,10 @@ import ( "github.com/grafana/grafana/pkg/services/publicdashboards/api" publicdashboardModels "github.com/grafana/grafana/pkg/services/publicdashboards/models" "github.com/grafana/grafana/pkg/services/quota/quotatest" - "github.com/grafana/grafana/pkg/services/search/sort" "github.com/grafana/grafana/pkg/services/star/startest" - "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" - "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" "github.com/grafana/grafana/pkg/web" "github.com/grafana/grafana/pkg/web/webtest" ) @@ -712,25 +699,42 @@ func TestIntegrationDashboardAPIEndpoint(t *testing.T) { t.Run("Given provisioned dashboard", func(t *testing.T) { mockSQLStore := dbtest.NewFakeDB() - dashboardStore := dashboards.NewFakeDashboardStore(t) - dashboardStore.On("GetProvisionedDataByDashboardID", mock.Anything, mock.AnythingOfType("int64")).Return(&dashboards.DashboardProvisioningSearchResults{ExternalID: "/dashboard1.json"}, nil).Once() - dashboardService := dashboards.NewFakeDashboardService(t) + dashboardProvisioningService := dashboards.NewFakeDashboardProvisioning(t) dataValue, err := simplejson.NewJson([]byte(`{"id": 1, "editable": true, "style": "dark"}`)) require.NoError(t, err) qResult := &dashboards.Dashboard{ID: 1, Data: dataValue} dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(qResult, nil) - + dashboardProvisioningService.On("GetProvisionedDashboardDataByDashboardID", mock.Anything, mock.AnythingOfType("int64")).Return(&dashboards.DashboardProvisioning{ExternalID: "/dashboard1.json"}, nil) loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/dash", "/api/dashboards/uid/:uid", org.RoleEditor, func(sc *scenarioContext) { fakeProvisioningService := provisioning.NewProvisioningServiceMock(context.Background()) fakeProvisioningService.GetDashboardProvisionerResolvedPathFunc = func(name string) string { return "/tmp/grafana/dashboards" } - dash := getDashboardShouldReturn200WithConfig(t, sc, fakeProvisioningService, dashboardStore, dashboardService, nil) + hs := &HTTPServer{ + Cfg: setting.NewCfg(), + ProvisioningService: fakeProvisioningService, + LibraryPanelService: &mockLibraryPanelService{}, + LibraryElementService: &libraryelementsfake.LibraryElementService{}, + dashboardProvisioningService: dashboardProvisioningService, + SQLStore: mockSQLStore, + AccessControl: actest.FakeAccessControl{ExpectedEvaluate: true}, + DashboardService: dashboardService, + Features: featuremgmt.WithFeatures(), + starService: startest.NewStarServiceFake(), + tracer: tracing.InitializeTracerForTest(), + } + hs.callGetDashboard(sc) - assert.Equal(t, "../../../dashboard1.json", dash.Meta.ProvisionedExternalId, mockSQLStore) + assert.Equal(t, http.StatusOK, sc.resp.Code) + + dash := dtos.DashboardFullWithMeta{} + err := json.NewDecoder(sc.resp.Body).Decode(&dash) + require.NoError(t, err) + + assert.Equal(t, "../../../dashboard1.json", dash.Meta.ProvisionedExternalId) }, mockSQLStore) loggedInUserScenarioWithRole(t, "When allowUiUpdates is true and calling GET on", "GET", "/api/dashboards/uid/dash", "/api/dashboards/uid/:uid", org.RoleEditor, func(sc *scenarioContext) { @@ -748,7 +752,7 @@ func TestIntegrationDashboardAPIEndpoint(t *testing.T) { ProvisioningService: fakeProvisioningService, LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &libraryelementsfake.LibraryElementService{}, - dashboardProvisioningService: mockDashboardProvisioningService{}, + dashboardProvisioningService: dashboardProvisioningService, SQLStore: mockSQLStore, AccessControl: actest.FakeAccessControl{ExpectedEvaluate: true}, DashboardService: dashboardService, @@ -771,6 +775,7 @@ func TestIntegrationDashboardAPIEndpoint(t *testing.T) { t.Run("v2 dashboards should not be returned in api", func(t *testing.T) { mockSQLStore := dbtest.NewFakeDB() dashboardService := dashboards.NewFakeDashboardService(t) + dashboardProvisioningService := dashboards.NewFakeDashboardProvisioning(t) dataValue, err := simplejson.NewJson([]byte(`{"id": 1, "apiVersion": "v2"}`)) require.NoError(t, err) @@ -794,7 +799,7 @@ func TestIntegrationDashboardAPIEndpoint(t *testing.T) { Features: featuremgmt.WithFeatures(), starService: startest.NewStarServiceFake(), tracer: tracing.InitializeTracerForTest(), - dashboardProvisioningService: mockDashboardProvisioningService{}, + dashboardProvisioningService: dashboardProvisioningService, folderService: foldertest.NewFakeService(), log: log.New("test"), namespacer: func(orgID int64) string { return strconv.FormatInt(orgID, 10) }, @@ -912,79 +917,6 @@ func TestDashboardVersionsAPIEndpoint(t *testing.T) { }, mockSQLStore) } -func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, provisioningService provisioning.ProvisioningService, dashboardStore dashboards.Store, dashboardService dashboards.DashboardService, folderStore folder.FolderStore) dtos.DashboardFullWithMeta { - t.Helper() - - if provisioningService == nil { - provisioningService = provisioning.NewProvisioningServiceMock(context.Background()) - } - - features := featuremgmt.WithFeatures() - var err error - if dashboardStore == nil { - sql, cfg := db.InitTestDBWithCfg(t) - dashboardStore, err = database.ProvideDashboardStore(sql, cfg, features, tagimpl.ProvideService(sql)) - require.NoError(t, err) - } - - libraryPanelsService := mockLibraryPanelService{} - libraryElementsService := libraryelementsfake.LibraryElementService{} - cfg := setting.NewCfg() - ac := accesscontrolmock.New() - folderPermissions := accesscontrolmock.NewMockedPermissionsService() - dashboardPermissions := accesscontrolmock.NewMockedPermissionsService() - - db := db.InitTestDB(t) - fStore := folderimpl.ProvideStore(db) - quotaService := quotatest.New(false, nil) - folderSvc := folderimpl.ProvideService( - fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, - nil, db, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, - dualwrite.ProvideTestService(), sort.ProvideService(), apiserver.WithoutRestConfig) - if dashboardService == nil { - dashboardService, err = service.ProvideDashboardServiceImpl( - cfg, dashboardStore, folderStore, features, folderPermissions, - ac, actest.FakeService{}, folderSvc, nil, client.MockTestRestConfig{}, nil, quotaService, nil, nil, nil, - dualwrite.ProvideTestService(), sort.ProvideService(), - serverlock.ProvideService(db, tracing.InitializeTracerForTest()), kvstore.NewFakeKVStore(), - ) - require.NoError(t, err) - dashboardService.(dashboards.PermissionsRegistrationService).RegisterDashboardPermissions(dashboardPermissions) - } - - dashboardProvisioningService, err := service.ProvideDashboardServiceImpl( - cfg, dashboardStore, folderStore, features, folderPermissions, - ac, actest.FakeService{}, folderSvc, nil, client.MockTestRestConfig{}, nil, quotaService, nil, nil, nil, - dualwrite.ProvideTestService(), sort.ProvideService(), - serverlock.ProvideService(db, tracing.InitializeTracerForTest()), kvstore.NewFakeKVStore(), - ) - require.NoError(t, err) - - hs := &HTTPServer{ - Cfg: cfg, - LibraryPanelService: &libraryPanelsService, - LibraryElementService: &libraryElementsService, - SQLStore: sc.sqlStore, - ProvisioningService: provisioningService, - AccessControl: accesscontrolmock.New(), - dashboardProvisioningService: dashboardProvisioningService, - DashboardService: dashboardService, - Features: featuremgmt.WithFeatures(), - starService: startest.NewStarServiceFake(), - tracer: tracing.InitializeTracerForTest(), - } - - hs.callGetDashboard(sc) - - require.Equal(sc.t, 200, sc.resp.Code) - - dash := dtos.DashboardFullWithMeta{} - err = json.NewDecoder(sc.resp.Body).Decode(&dash) - require.NoError(sc.t, err) - - return dash -} - func (hs *HTTPServer) callGetDashboard(sc *scenarioContext) { sc.handlerFunc = hs.GetDashboard sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 3bc0b5994fe..82ccca6c8f1 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -1,17 +1,14 @@ package api import ( - "context" "errors" "net/http" "strconv" - claims "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/api/apierrors" "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/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" @@ -20,7 +17,6 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/libraryelements/model" - "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/search" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -198,13 +194,6 @@ func (hs *HTTPServer) CreateFolder(c *contextmodel.ReqContext) response.Response return apierrors.ToFolderErrorResponse(err) } - // Only set default permissions if the Folder API Server is disabled. - if !hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - if err := hs.setDefaultFolderPermissions(c.Req.Context(), cmd.OrgID, cmd.SignedInUser, folder); err != nil { - hs.log.Error("Could not set the default folder permissions", "folder", folder.Title, "user", cmd.SignedInUser, "error", err) - } - } - folderDTO, err := hs.newToFolderDto(c, folder) if err != nil { return response.Err(err) @@ -214,46 +203,6 @@ func (hs *HTTPServer) CreateFolder(c *contextmodel.ReqContext) response.Response return response.JSON(http.StatusOK, folderDTO) } -func (hs *HTTPServer) setDefaultFolderPermissions(ctx context.Context, orgID int64, user identity.Requester, folder *folder.Folder) error { - if !hs.Cfg.RBAC.PermissionsOnCreation("folder") { - return nil - } - - var permissions []accesscontrol.SetResourcePermissionCommand - - if user.IsIdentityType(claims.TypeUser, claims.TypeServiceAccount) { - userID, err := user.GetInternalID() - if err != nil { - return err - } - - permissions = append(permissions, accesscontrol.SetResourcePermissionCommand{ - UserID: userID, Permission: dashboardaccess.PERMISSION_ADMIN.String(), - }) - } - - isNested := folder.ParentUID != "" - if !isNested || !hs.Features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) { - permissions = append(permissions, []accesscontrol.SetResourcePermissionCommand{ - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()}, - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()}, - }...) - } - - _, err := hs.folderPermissionsService.SetPermissions(ctx, orgID, folder.UID, permissions...) - if err != nil { - return err - } - - if user.IsIdentityType(claims.TypeUser, claims.TypeServiceAccount) { - // Clear permission cache for the user who's created the folder, so that new permissions are fetched for their next call - // Required for cases when caller wants to immediately interact with the newly created object - hs.accesscontrolService.ClearUserPermissionCache(user) - } - - return nil -} - // swagger:route POST /folders/{folder_uid}/move folders moveFolder // // Move folder. diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index 9a4d42274bc..c02cad02628 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -620,9 +620,6 @@ func TestGetFolderLegacyAndUnifiedStorage(t *testing.T) { } featuresArr := []any{featuremgmt.FlagNestedFolders} - if tc.unifiedStorageEnabled { - featuresArr = append(featuresArr, featuremgmt.FlagKubernetesClientDashboardsFolders) - } server := SetupAPITestServer(t, func(hs *HTTPServer) { hs.Cfg = cfg @@ -676,39 +673,6 @@ func TestGetFolderLegacyAndUnifiedStorage(t *testing.T) { } func TestSetDefaultPermissionsWhenCreatingFolder(t *testing.T) { - folderService := &foldertest.FakeService{} - folderWithoutParentInput := "{ \"uid\": \"uid\", \"title\": \"Folder\"}" - - type testCase struct { - description string - expectedCallsToSetPermissions int - expectedCode int - expectedFolder *folder.Folder - permissions []accesscontrol.Permission - featuresArr []any - input string - } - - tcs := []testCase{ - { - description: "folder creation succeeds, via legacy storage", - expectedCallsToSetPermissions: 1, - input: folderWithoutParentInput, - expectedCode: http.StatusOK, - expectedFolder: &folder.Folder{UID: "uid", Title: "Folder"}, - permissions: []accesscontrol.Permission{{Action: dashboards.ActionFoldersCreate}}, - }, - { - description: "folder creation succeeds, via API Server", - expectedCallsToSetPermissions: 0, - input: folderWithoutParentInput, - expectedCode: http.StatusOK, - expectedFolder: &folder.Folder{UID: "uid", Title: "Folder"}, - permissions: []accesscontrol.Permission{{Action: dashboards.ActionFoldersCreate}}, - featuresArr: []any{featuremgmt.FlagKubernetesClientDashboardsFolders}, - }, - } - // we need to save these values because they are defined at `setting` package level // and modified when we invoke setting.NewCfgFromINIFile prevCookieSameSiteDisabled := setting.CookieSameSiteDisabled @@ -725,42 +689,32 @@ func TestSetDefaultPermissionsWhenCreatingFolder(t *testing.T) { setting.CookieSameSiteDisabled = prevCookieSameSiteDisabled setting.CookieSameSiteMode = prevCookieSameSiteMode - for _, tc := range tcs { - t.Run(tc.description, func(t *testing.T) { - folderService.ExpectedFolder = tc.expectedFolder - folderPermService := acmock.NewMockedPermissionsService() - folderPermService.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) - - srv := SetupAPITestServer(t, func(hs *HTTPServer) { - hs.Cfg = cfg - - featuresArr := append(tc.featuresArr, featuremgmt.FlagNestedFolders) - hs.Features = featuremgmt.WithFeatures( - featuresArr..., - ) - hs.folderService = folderService - hs.folderPermissionsService = folderPermService - hs.accesscontrolService = actest.FakeService{} - }) - - input := strings.NewReader(tc.input) - req := srv.NewPostRequest("/api/folders", input) - req = webtest.RequestWithSignedInUser(req, userWithPermissions(1, tc.permissions)) - resp, err := srv.SendJSON(req) - require.NoError(t, err) - require.Equal(t, tc.expectedCode, resp.StatusCode) - - folder := dtos.Folder{} - err = json.NewDecoder(resp.Body).Decode(&folder) - require.NoError(t, err) - require.NoError(t, resp.Body.Close()) - - folderPermService.AssertNumberOfCalls(t, "SetPermissions", tc.expectedCallsToSetPermissions) - - if tc.expectedCode == http.StatusOK { - assert.Equal(t, "uid", folder.UID) - assert.Equal(t, "Folder", folder.Title) - } - }) + folderService := &foldertest.FakeService{ + ExpectedFolder: &folder.Folder{UID: "uid", Title: "Folder"}, } + folderPermService := acmock.NewMockedPermissionsService() + folderPermService.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) + + srv := SetupAPITestServer(t, func(hs *HTTPServer) { + hs.Cfg = cfg + hs.Features = featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders) + hs.folderService = folderService + hs.folderPermissionsService = folderPermService + hs.accesscontrolService = actest.FakeService{} + }) + + input := strings.NewReader("{ \"uid\": \"uid\", \"title\": \"Folder\"}") + req := srv.NewPostRequest("/api/folders", input) + req = webtest.RequestWithSignedInUser(req, userWithPermissions(1, []accesscontrol.Permission{{Action: dashboards.ActionFoldersCreate}})) + resp, err := srv.SendJSON(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + folder := dtos.Folder{} + err = json.NewDecoder(resp.Body).Decode(&folder) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + assert.Equal(t, "uid", folder.UID) + assert.Equal(t, "Folder", folder.Title) } 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/go.mod b/pkg/apimachinery/go.mod index bdfcc22c5af..d1fa3b6f5e0 100644 --- a/pkg/apimachinery/go.mod +++ b/pkg/apimachinery/go.mod @@ -49,7 +49,7 @@ require ( golang.org/x/sys v0.34.0 // indirect golang.org/x/text v0.27.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/grpc v1.74.0 // indirect + google.golang.org/grpc v1.74.2 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect diff --git a/pkg/apimachinery/go.sum b/pkg/apimachinery/go.sum index 63fa5245e75..df48bc9bb77 100644 --- a/pkg/apimachinery/go.sum +++ b/pkg/apimachinery/go.sum @@ -156,8 +156,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.74.0 h1:sxRSkyLxlceWQiqDofxDot3d4u7DyoHPc7SBXMj8gGY= -google.golang.org/grpc v1.74.0/go.mod h1:NZUaK8dAMUfzhK6uxZ+9511LtOrk73UGWOFoNvz7z+s= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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/apis/provisioning/v0alpha1/jobs.go b/pkg/apis/provisioning/v0alpha1/jobs.go index ceea231400b..4379d2365c1 100644 --- a/pkg/apis/provisioning/v0alpha1/jobs.go +++ b/pkg/apis/provisioning/v0alpha1/jobs.go @@ -40,6 +40,9 @@ const ( // JobActionDelete deletes files in the remote repository JobActionDelete JobAction = "delete" + + // JobActionMove moves files in the remote repository + JobActionMove JobAction = "move" ) // +enum @@ -87,6 +90,9 @@ type JobSpec struct { // Delete when the action is `delete` Delete *DeleteJobOptions `json:"delete,omitempty"` + + // Move when the action is `move` + Move *MoveJobOptions `json:"move,omitempty"` } type PullRequestJobOptions struct { @@ -141,6 +147,43 @@ type DeleteJobOptions struct { // - nested/deep/ (for a directory) // FIXME: we should validate this in admission hooks Paths []string `json:"paths,omitempty"` + + // Resources to delete + // This option has been created because currently the frontend does not use + // standarized app platform APIs. For performance and API consistency reasons, the preferred option + // is it to use the paths. + Resources []ResourceRef `json:"resources,omitempty"` +} + +type ResourceRef struct { + // Name is the name of the resource, such as a dashboard UID. + Name string `json:"name,omitempty"` + + // Kind is the type of resource, for example, "Dashboard". + Kind string `json:"kind,omitempty"` + + // Group is the group of the resource, such as "dashboard.grafana.app". + Group string `json:"group,omitempty"` +} + +type MoveJobOptions struct { + // Ref to the branch or commit hash that should move + Ref string `json:"ref,omitempty"` + // Paths to be deleted. Examples: + // - dashboard.json (for a file) + // - a/b/c/other-dashboard.json (for a file) + // - nested/deep/ (for a directory) + // FIXME: we should validate this in admission hooks + Paths []string `json:"paths,omitempty"` + + // Destination path for the move (e.g. "new-location/") + TargetPath string `json:"targetPath,omitempty"` + + // Resources to move + // This option has been created because currently the frontend does not use + // standarized app platform APIs. For performance and API consistency reasons, the preferred option + // is it to use the paths. + Resources []ResourceRef `json:"resources,omitempty"` } // The job status diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go index bed49953b63..c5438fccd0e 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go @@ -56,6 +56,11 @@ func (in *DeleteJobOptions) DeepCopyInto(out *DeleteJobOptions) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = make([]ResourceRef, len(*in)) + copy(*out, *in) + } return } @@ -396,6 +401,11 @@ func (in *JobSpec) DeepCopyInto(out *JobSpec) { *out = new(DeleteJobOptions) (*in).DeepCopyInto(*out) } + if in.Move != nil { + in, out := &in.Move, &out.Move + *out = new(MoveJobOptions) + (*in).DeepCopyInto(*out) + } return } @@ -494,6 +504,32 @@ func (in *MigrateJobOptions) DeepCopy() *MigrateJobOptions { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MoveJobOptions) DeepCopyInto(out *MoveJobOptions) { + *out = *in + if in.Paths != nil { + in, out := &in.Paths, &out.Paths + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = make([]ResourceRef, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MoveJobOptions. +func (in *MoveJobOptions) DeepCopy() *MoveJobOptions { + if in == nil { + return nil + } + out := new(MoveJobOptions) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PullRequestJobOptions) DeepCopyInto(out *PullRequestJobOptions) { *out = *in @@ -835,6 +871,22 @@ func (in *ResourceObjects) DeepCopy() *ResourceObjects { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourceRef) DeepCopyInto(out *ResourceRef) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceRef. +func (in *ResourceRef) DeepCopy() *ResourceRef { + if in == nil { + return nil + } + out := new(ResourceRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResourceRepositoryInfo) DeepCopyInto(out *ResourceRepositoryInfo) { *out = *in diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index b81342c30c3..e6e9e4e9ab5 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -35,6 +35,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.LocalRepositoryConfig": schema_pkg_apis_provisioning_v0alpha1_LocalRepositoryConfig(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ManagerStats": schema_pkg_apis_provisioning_v0alpha1_ManagerStats(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.MigrateJobOptions": schema_pkg_apis_provisioning_v0alpha1_MigrateJobOptions(ref), + "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.MoveJobOptions": schema_pkg_apis_provisioning_v0alpha1_MoveJobOptions(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.PullRequestJobOptions": schema_pkg_apis_provisioning_v0alpha1_PullRequestJobOptions(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.RefItem": schema_pkg_apis_provisioning_v0alpha1_RefItem(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.RefList": schema_pkg_apis_provisioning_v0alpha1_RefList(ref), @@ -48,6 +49,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceList": schema_pkg_apis_provisioning_v0alpha1_ResourceList(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceListItem": schema_pkg_apis_provisioning_v0alpha1_ResourceListItem(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceObjects": schema_pkg_apis_provisioning_v0alpha1_ResourceObjects(ref), + "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceRef": schema_pkg_apis_provisioning_v0alpha1_ResourceRef(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceRepositoryInfo": schema_pkg_apis_provisioning_v0alpha1_ResourceRepositoryInfo(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceStats": schema_pkg_apis_provisioning_v0alpha1_ResourceStats(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceType": schema_pkg_apis_provisioning_v0alpha1_ResourceType(ref), @@ -184,9 +186,25 @@ func schema_pkg_apis_provisioning_v0alpha1_DeleteJobOptions(ref common.Reference }, }, }, + "resources": { + SchemaProps: spec.SchemaProps{ + Description: "Resources to delete This option has been created because currently the frontend does not use standarized app platform APIs. For performance and API consistency reasons, the preferred option is it to use the paths.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceRef"), + }, + }, + }, + }, + }, }, }, }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceRef"}, } } @@ -878,10 +896,10 @@ func schema_pkg_apis_provisioning_v0alpha1_JobSpec(ref common.ReferenceCallback) Properties: map[string]spec.Schema{ "action": { SchemaProps: spec.SchemaProps{ - Description: "Possible enum values:\n - `\"delete\"` deletes files in the remote repository\n - `\"migrate\"` acts like JobActionExport, then JobActionPull. It also tries to preserve the history.\n - `\"pr\"` adds additional useful information to a PR, such as comments with preview links and rendered images.\n - `\"pull\"` replicates the remote branch in the local copy of the repository.\n - `\"push\"` replicates the local copy of the repository in the remote branch.", + Description: "Possible enum values:\n - `\"delete\"` deletes files in the remote repository\n - `\"migrate\"` acts like JobActionExport, then JobActionPull. It also tries to preserve the history.\n - `\"move\"` moves files in the remote repository\n - `\"pr\"` adds additional useful information to a PR, such as comments with preview links and rendered images.\n - `\"pull\"` replicates the remote branch in the local copy of the repository.\n - `\"push\"` replicates the local copy of the repository in the remote branch.", Type: []string{"string"}, Format: "", - Enum: []interface{}{"delete", "migrate", "pr", "pull", "push"}, + Enum: []interface{}{"delete", "migrate", "move", "pr", "pull", "push"}, }, }, "repository": { @@ -921,11 +939,17 @@ func schema_pkg_apis_provisioning_v0alpha1_JobSpec(ref common.ReferenceCallback) Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.DeleteJobOptions"), }, }, + "move": { + SchemaProps: spec.SchemaProps{ + Description: "Move when the action is `move`", + Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.MoveJobOptions"), + }, + }, }, }, }, Dependencies: []string{ - "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.DeleteJobOptions", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ExportJobOptions", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.MigrateJobOptions", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.PullRequestJobOptions", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.SyncJobOptions"}, + "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.DeleteJobOptions", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ExportJobOptions", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.MigrateJobOptions", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.MoveJobOptions", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.PullRequestJobOptions", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.SyncJobOptions"}, } } @@ -1091,6 +1115,63 @@ func schema_pkg_apis_provisioning_v0alpha1_MigrateJobOptions(ref common.Referenc } } +func schema_pkg_apis_provisioning_v0alpha1_MoveJobOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "ref": { + SchemaProps: spec.SchemaProps{ + Description: "Ref to the branch or commit hash that should move", + Type: []string{"string"}, + Format: "", + }, + }, + "paths": { + SchemaProps: spec.SchemaProps{ + Description: "Paths to be deleted. Examples: - dashboard.json (for a file) - a/b/c/other-dashboard.json (for a file) - nested/deep/ (for a directory) FIXME: we should validate this in admission hooks", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + "targetPath": { + SchemaProps: spec.SchemaProps{ + Description: "Destination path for the move (e.g. \"new-location/\")", + Type: []string{"string"}, + Format: "", + }, + }, + "resources": { + SchemaProps: spec.SchemaProps{ + Description: "Resources to move This option has been created because currently the frontend does not use standarized app platform APIs. For performance and API consistency reasons, the preferred option is it to use the paths.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceRef"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceRef"}, + } +} + func schema_pkg_apis_provisioning_v0alpha1_PullRequestJobOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -1824,6 +1905,39 @@ func schema_pkg_apis_provisioning_v0alpha1_ResourceObjects(ref common.ReferenceC } } +func schema_pkg_apis_provisioning_v0alpha1_ResourceRef(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "name": { + SchemaProps: spec.SchemaProps{ + Description: "Name is the name of the resource, such as a dashboard UID.", + Type: []string{"string"}, + Format: "", + }, + }, + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is the type of resource, for example, \"Dashboard\".", + Type: []string{"string"}, + Format: "", + }, + }, + "group": { + SchemaProps: spec.SchemaProps{ + Description: "Group is the group of the resource, such as \"dashboard.grafana.app\".", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + } +} + func schema_pkg_apis_provisioning_v0alpha1_ResourceRepositoryInfo(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list index fc982653184..49a2170ce4e 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -1,10 +1,13 @@ API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,DeleteJobOptions,Paths +API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,DeleteJobOptions,Resources API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,FileList,Items API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,HistoryList,Items API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,JobResourceSummary,Errors API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,JobStatus,Errors API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,JobStatus,Summary API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,ManagerStats,Stats +API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,MoveJobOptions,Paths +API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,MoveJobOptions,Resources API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,RefList,Items API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,RepositoryList,Items API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,RepositorySpec,Workflows diff --git a/pkg/apiserver/go.mod b/pkg/apiserver/go.mod index a40429d1f8d..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 @@ -94,7 +94,7 @@ require ( google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/grpc v1.74.0 // indirect + google.golang.org/grpc v1.74.2 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/pkg/apiserver/go.sum b/pkg/apiserver/go.sum index 2b015943a28..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= @@ -345,8 +345,8 @@ google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.74.0 h1:sxRSkyLxlceWQiqDofxDot3d4u7DyoHPc7SBXMj8gGY= -google.golang.org/grpc v1.74.0/go.mod h1:NZUaK8dAMUfzhK6uxZ+9511LtOrk73UGWOFoNvz7z+s= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/build/daggerbuild/artifacts/package_targz.go b/pkg/build/daggerbuild/artifacts/package_targz.go index 93e09112ceb..56ea04030b3 100644 --- a/pkg/build/daggerbuild/artifacts/package_targz.go +++ b/pkg/build/daggerbuild/artifacts/package_targz.go @@ -378,8 +378,12 @@ func verifyTarball( WithExposedPort(3000).AsService(dagger.ContainerAsServiceOpts{ Args: []string{"./bin/grafana", "server"}, }) + result, err := e2e.ValidatePackage(ctx, d, svc, src, yarnCache, nodeVersion) + if err != nil { + return err + } - if _, err := containers.ExitError(ctx, e2e.ValidatePackage(d, svc, src, yarnCache, nodeVersion)); err != nil { + if _, err := containers.ExitError(ctx, result); err != nil { return err } return nil diff --git a/pkg/build/daggerbuild/backend/builder.go b/pkg/build/daggerbuild/backend/builder.go index 9a27c60a8c0..4b49efa1ba9 100644 --- a/pkg/build/daggerbuild/backend/builder.go +++ b/pkg/build/daggerbuild/backend/builder.go @@ -69,7 +69,11 @@ func ViceroyContainer( container := d.Container(containerOpts).From(fmt.Sprintf("rfratto/viceroy:%s", viceroyVersion)) // Install Go manually, and install make, git, and curl from the package manager. - container = container.WithExec([]string{"apt-get", "update"}). + container = container. + WithExec([]string{"dpkg", "--remove-architecture", "ppc64el"}). + WithExec([]string{"dpkg", "--remove-architecture", "s390x"}). + WithExec([]string{"dpkg", "--remove-architecture", "armel"}). + WithExec([]string{"apt-get", "update", "-yq"}). WithExec([]string{"apt-get", "install", "-yq", "curl", "make", "git"}). WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("curl -L %s | tar -C /usr/local -xzf -", goURL)}). WithEnvVariable("PATH", "/bin:/usr/bin:/usr/local/bin:/usr/local/go/bin:/usr/osxcross/bin") diff --git a/pkg/build/daggerbuild/docker/verify.go b/pkg/build/daggerbuild/docker/verify.go index 52b6a5eac2c..56e8ff3118e 100644 --- a/pkg/build/daggerbuild/docker/verify.go +++ b/pkg/build/daggerbuild/docker/verify.go @@ -40,7 +40,11 @@ func Verify( WithExposedPort(3000) // TODO: Add LICENSE to containers and implement validation - container := e2e.ValidatePackage(d, service.AsService(), src, yarnCache, nodeVersion) + container, err := e2e.ValidatePackage(ctx, d, service.AsService(), src, yarnCache, nodeVersion) + if err != nil { + return err + } + _, err = containers.ExitError(ctx, container) return err } diff --git a/pkg/build/daggerbuild/e2e/validate_package.go b/pkg/build/daggerbuild/e2e/validate_package.go index c786b4a991b..3bcc1c5f936 100644 --- a/pkg/build/daggerbuild/e2e/validate_package.go +++ b/pkg/build/daggerbuild/e2e/validate_package.go @@ -1,32 +1,19 @@ package e2e import ( + "context" + "dagger.io/dagger" - "github.com/grafana/grafana/pkg/build/daggerbuild/frontend" + "github.com/grafana/grafana/pkg/build/e2eutil" ) -func CypressImage(version string) string { - return "cypress/included:13.1.0" -} - -// CypressContainer returns a docker container with everything set up that is needed to build or run e2e tests. -func CypressContainer(d *dagger.Client, base string) *dagger.Container { - container := d.Container().From(base).WithEntrypoint([]string{}) - - return container -} - -func ValidatePackage(d *dagger.Client, service *dagger.Service, src *dagger.Directory, yarnCacheVolume *dagger.CacheVolume, nodeVersion string) *dagger.Container { - // The cypress container should never be cached - c := CypressContainer(d, CypressImage(nodeVersion)) - - c = frontend.WithYarnCache(c, yarnCacheVolume) - - return c.WithDirectory("/src", src). - WithWorkdir("/src"). - WithServiceBinding("grafana", service). - WithEnvVariable("HOST", "grafana"). - WithEnvVariable("PORT", "3000"). - WithExec([]string{"yarn", "install", "--immutable"}). - WithExec([]string{"/bin/sh", "-c", "/src/e2e/verify-release"}) +func ValidatePackage(ctx context.Context, d *dagger.Client, service *dagger.Service, src *dagger.Directory, yarnCacheVolume *dagger.CacheVolume, nodeVersion string) (*dagger.Container, error) { + c, err := e2eutil.WithFrontendContainer(ctx, d, src) + if err != nil { + return nil, err + } + + return c.WithServiceBinding("grafana", service). + WithEnvVariable("GRAFANA_URL", "http://grafana:3000"). + WithExec([]string{"yarn", "e2e:acceptance"}), nil } diff --git a/pkg/build/daggerbuild/fpm/verify.go b/pkg/build/daggerbuild/fpm/verify.go index a169d9b8b75..fcaaafeba0e 100644 --- a/pkg/build/daggerbuild/fpm/verify.go +++ b/pkg/build/daggerbuild/fpm/verify.go @@ -41,7 +41,12 @@ func VerifyDeb(ctx context.Context, d *dagger.Client, file *dagger.File, src *da Args: []string{"grafana-server"}, }) - if _, err := containers.ExitError(ctx, e2e.ValidatePackage(d, svc, src, yarn, nodeVersion)); err != nil { + result, err := e2e.ValidatePackage(ctx, d, svc, src, yarn, nodeVersion) + if err != nil { + return err + } + + if _, err := containers.ExitError(ctx, result); err != nil { return err } @@ -75,7 +80,16 @@ func VerifyRpm(ctx context.Context, d *dagger.Client, file *dagger.File, src *da WithExec([]string{"grafana-server"}). WithExposedPort(3000) - if _, err := containers.ExitError(ctx, e2e.ValidatePackage(d, service.AsService(), src, yarn, nodeVersion)); err != nil { + svc := service.WithExposedPort(3000).AsService(dagger.ContainerAsServiceOpts{ + Args: []string{"grafana-server"}, + }) + + result, err := e2e.ValidatePackage(ctx, d, svc, src, yarn, nodeVersion) + if err != nil { + return err + } + + if _, err := containers.ExitError(ctx, result); err != nil { return err } if !sign { diff --git a/pkg/build/e2e-playwright/main.go b/pkg/build/e2e-playwright/main.go index 0a0c2cb62bb..c7ec56c8869 100644 --- a/pkg/build/e2e-playwright/main.go +++ b/pkg/build/e2e-playwright/main.go @@ -8,6 +8,7 @@ import ( "os/signal" "dagger.io/dagger" + "github.com/grafana/grafana/pkg/build/e2eutil" "github.com/urfave/cli/v3" ) @@ -143,7 +144,7 @@ func run(ctx context.Context, cmd *cli.Command) error { }, }) - frontendContainer, err := WithFrontendContainer(ctx, d, yarnHostSrc) + frontendContainer, err := e2eutil.WithFrontendContainer(ctx, d, yarnHostSrc) if err != nil { return fmt.Errorf("failed to create frontend container: %w", err) } diff --git a/pkg/build/e2e-playwright/frontend.go b/pkg/build/e2eutil/frontend_container.go similarity index 99% rename from pkg/build/e2e-playwright/frontend.go rename to pkg/build/e2eutil/frontend_container.go index 625ea155ae4..e9de7e6c975 100644 --- a/pkg/build/e2e-playwright/frontend.go +++ b/pkg/build/e2eutil/frontend_container.go @@ -1,4 +1,4 @@ -package main +package e2eutil import ( "context" diff --git a/pkg/build/go.mod b/pkg/build/go.mod index a10b831a97a..012923fe3c1 100644 --- a/pkg/build/go.mod +++ b/pkg/build/go.mod @@ -32,7 +32,7 @@ require ( golang.org/x/text v0.27.0 // indirect; @grafana/grafana-backend-group golang.org/x/time v0.11.0 // indirect; @grafana/grafana-backend-group google.golang.org/api v0.235.0 // @grafana/grafana-backend-group - google.golang.org/grpc v1.74.0 // indirect; @grafana/plugins-platform-backend + google.golang.org/grpc v1.74.2 // indirect; @grafana/plugins-platform-backend google.golang.org/protobuf v1.36.6 // indirect; @grafana/plugins-platform-backend gopkg.in/yaml.v3 v3.0.1 // @grafana/alerting-backend ) diff --git a/pkg/build/go.sum b/pkg/build/go.sum index ead0b353d86..d49747c61fb 100644 --- a/pkg/build/go.sum +++ b/pkg/build/go.sum @@ -388,8 +388,8 @@ google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.74.0 h1:sxRSkyLxlceWQiqDofxDot3d4u7DyoHPc7SBXMj8gGY= -google.golang.org/grpc v1.74.0/go.mod h1:NZUaK8dAMUfzhK6uxZ+9511LtOrk73UGWOFoNvz7z+s= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 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/plugins/backendplugin/coreplugin/registry.go b/pkg/plugins/backendplugin/coreplugin/registry.go index 555c1b23ec3..de6772801af 100644 --- a/pkg/plugins/backendplugin/coreplugin/registry.go +++ b/pkg/plugins/backendplugin/coreplugin/registry.go @@ -237,7 +237,7 @@ func NewPlugin(pluginID string, cfg *setting.Cfg, httpClientProvider *httpclient case Tempo: svc = tempo.ProvideService(httpClientProvider) case PostgreSQL: - svc = postgres.ProvideService(cfg) + svc = postgres.ProvideService(cfg, features) case MySQL: svc = mysql.ProvideService() case MSSQL: diff --git a/pkg/promlib/go.mod b/pkg/promlib/go.mod index 4839a4f96d5..e2355d03c5f 100644 --- a/pkg/promlib/go.mod +++ b/pkg/promlib/go.mod @@ -17,8 +17,8 @@ require ( ) require ( - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 // indirect github.com/BurntSushi/toml v1.5.0 // indirect github.com/apache/arrow-go/v18 v18.3.0 // indirect github.com/aws/aws-sdk-go v1.55.7 // indirect @@ -47,6 +47,7 @@ require ( github.com/goccy/go-json v0.10.5 // indirect github.com/gogo/googleapis v1.4.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.3 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/flatbuffers v25.2.10+incompatible // indirect github.com/google/gnostic-models v0.6.9 // indirect @@ -101,7 +102,7 @@ require ( github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect - go.mongodb.org/mongo-driver v1.16.1 // indirect + go.mongodb.org/mongo-driver v1.17.3 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.61.0 // indirect @@ -127,7 +128,7 @@ require ( google.golang.org/api v0.235.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/grpc v1.74.0 // indirect + google.golang.org/grpc v1.74.2 // indirect gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/pkg/promlib/go.sum b/pkg/promlib/go.sum index 47b36a87ee2..5973252c3ba 100644 --- a/pkg/promlib/go.sum +++ b/pkg/promlib/go.sum @@ -5,10 +5,10 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= -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.9.0 h1:OVoM452qUFBrX+URdH3VpR299ma4kfom0yB0URYky9g= -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/azcore v1.18.1 h1:Wc1ml6QlJs2BHQ/9Bqu1jiyggbsSjramq2oUmp5WeIo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= @@ -105,8 +105,8 @@ github.com/gogo/googleapis v1.4.1 h1:1Yx4Myt7BxzvUr5ldGSbwYiZG6t9wGBZ+8/fX3Wvtq0 github.com/gogo/googleapis v1.4.1/go.mod h1:2lpHqI5OcWCtVElxXnPt+s8oJvMpySlOyM6xDCrzib4= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= +github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= @@ -310,8 +310,8 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -go.mongodb.org/mongo-driver v1.16.1 h1:rIVLL3q0IHM39dvE+z2ulZLp9ENZKThVfuvN/IiN4l8= -go.mongodb.org/mongo-driver v1.16.1/go.mod h1:oB6AhJQvFQL4LEHyXi6aJzQJtBiTQHiAd83l0GdFaiw= +go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= +go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= @@ -415,8 +415,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1: google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.74.0 h1:sxRSkyLxlceWQiqDofxDot3d4u7DyoHPc7SBXMj8gGY= -google.golang.org/grpc v1.74.0/go.mod h1:NZUaK8dAMUfzhK6uxZ+9511LtOrk73UGWOFoNvz7z+s= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/registry/apis/dashboard/large.go b/pkg/registry/apis/dashboard/large.go index 21c9d5f8832..e2ff0229721 100644 --- a/pkg/registry/apis/dashboard/large.go +++ b/pkg/registry/apis/dashboard/large.go @@ -9,7 +9,7 @@ import ( dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" dashboardV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" - dashboardV2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" + dashboardV2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" commonV0 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/storage/unified/apistore" @@ -42,8 +42,8 @@ func NewDashboardLargeObjectSupport(scheme *runtime.Scheme, threshold int) *apis Description: dash.Spec.Description, Tags: dash.Spec.Tags, } - case *dashboardV2alpha2.Dashboard: - dash.Spec = dashboardV2alpha2.DashboardSpec{ + case *dashboardV2beta1.Dashboard: + dash.Spec = dashboardV2beta1.DashboardSpec{ Title: dash.Spec.Title, Description: dash.Spec.Description, Tags: dash.Spec.Tags, @@ -64,7 +64,7 @@ func NewDashboardLargeObjectSupport(scheme *runtime.Scheme, threshold int) *apis return dash.Spec.UnmarshalJSON(blob) case *dashboardV2alpha1.Dashboard: return json.Unmarshal(blob, &dash.Spec) - case *dashboardV2alpha2.Dashboard: + case *dashboardV2beta1.Dashboard: return json.Unmarshal(blob, &dash.Spec) default: return fmt.Errorf("unsupported dashboard type %T", obj) diff --git a/pkg/registry/apis/dashboard/large_test.go b/pkg/registry/apis/dashboard/large_test.go index fa62d9e62f6..94173ccce31 100644 --- a/pkg/registry/apis/dashboard/large_test.go +++ b/pkg/registry/apis/dashboard/large_test.go @@ -12,7 +12,7 @@ import ( dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" dashv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" - dashv2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" + dashv2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" ) func TestLargeDashboardSupportV1(t *testing.T) { @@ -174,47 +174,47 @@ func TestLargeDashboardSupportV2alpha1(t *testing.T) { require.True(t, exists) } -func TestLargeDashboardSupportV2alpha2(t *testing.T) { +func TestLargeDashboardSupportV2beta1(t *testing.T) { // Test RebuildSpec functionality specifically for v2 dashboards // This tests the json.Unmarshal(blob, &dash.Spec) path for structured specs // unlike v0/v1 which use the UnmarshalJSON path for unstructured specs // Create a v2 dashboard with structured spec - originalV2Dash := &dashv2alpha2.Dashboard{ + originalV2Dash := &dashv2beta1.Dashboard{ ObjectMeta: metav1.ObjectMeta{ Name: "test-v2", Namespace: "test", }, - Spec: dashv2alpha2.DashboardSpec{ + Spec: dashv2beta1.DashboardSpec{ Title: "Test V2 Dashboard", Description: stringPtr("A test dashboard for v2 large object support"), Tags: []string{"test", "v2", "large-object"}, Editable: boolPtr(true), LiveNow: boolPtr(false), Preload: false, - Annotations: []dashv2alpha2.DashboardAnnotationQueryKind{ + Annotations: []dashv2beta1.DashboardAnnotationQueryKind{ { Kind: "AnnotationQuery", - Spec: dashv2alpha2.DashboardAnnotationQuerySpec{ + Spec: dashv2beta1.DashboardAnnotationQuerySpec{ Name: "Test Annotation", }, }, }, - Elements: map[string]dashv2alpha2.DashboardElement{ + Elements: map[string]dashv2beta1.DashboardElement{ "panel-1": { - PanelKind: &dashv2alpha2.DashboardPanelKind{}, + PanelKind: &dashv2beta1.DashboardPanelKind{}, }, }, - Layout: dashv2alpha2.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{}, - TimeSettings: dashv2alpha2.DashboardTimeSettingsSpec{}, - CursorSync: dashv2alpha2.DashboardDashboardCursorSyncOff, - Variables: []dashv2alpha2.DashboardVariableKind{}, - Links: []dashv2alpha2.DashboardDashboardLink{}, + Layout: dashv2beta1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{}, + TimeSettings: dashv2beta1.DashboardTimeSettingsSpec{}, + CursorSync: dashv2beta1.DashboardDashboardCursorSyncOff, + Variables: []dashv2beta1.DashboardVariableKind{}, + Links: []dashv2beta1.DashboardDashboardLink{}, }, } scheme := runtime.NewScheme() - err := dashv2alpha2.AddToScheme(scheme) + err := dashv2beta1.AddToScheme(scheme) require.NoError(t, err) largeObject := NewDashboardLargeObjectSupport(scheme, 0) @@ -243,7 +243,7 @@ func TestLargeDashboardSupportV2alpha2(t *testing.T) { require.Empty(t, dashToReduce.Spec.Links) // Now test RebuildSpec - this is the key test for v2! - rehydratedDash := &dashv2alpha2.Dashboard{ + rehydratedDash := &dashv2beta1.Dashboard{ ObjectMeta: metav1.ObjectMeta{ Name: "test-v2-rehydrated", Namespace: "test", diff --git a/pkg/registry/apis/dashboard/legacy/migrate.go b/pkg/registry/apis/dashboard/legacy/migrate.go index bb938f7e532..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" @@ -32,8 +33,9 @@ type MigrateOptions struct { LargeObjects apistore.LargeObjectSupport BlobStore resourcepb.BlobStoreClient Resources []schema.GroupResource - WithHistory bool // only applies to dashboards - OnlyCount bool // just count the values + WithHistory bool // only applies to dashboards + OnlyCount bool // just count the values + StackID string // stack identifier for logging Progress func(count int, msg string) } @@ -50,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 { @@ -135,6 +138,7 @@ func (a *dashboardSqlAccess) Migrate(ctx context.Context, opts MigrateOptions) ( // Now run each migration blobStore := BlobStoreInfo{} + opts.StackID = fmt.Sprintf("%d", info.StackID) // Pass stack ID through options a.log.Info("start migrating legacy resources", "namespace", opts.Namespace, "orgId", info.OrgID, "stackId", info.StackID) for _, m := range migratorFuncs { blobs, err := m(ctx, info.OrgID, opts, stream) @@ -307,10 +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, - "namespace", opts.Namespace, + "version", row.Dash.Generation, + "stackId", opts.StackID, ) 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/mutate.go b/pkg/registry/apis/dashboard/mutate.go index 574cf5ea634..e1a7c05618e 100644 --- a/pkg/registry/apis/dashboard/mutate.go +++ b/pkg/registry/apis/dashboard/mutate.go @@ -12,7 +12,7 @@ import ( dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" dashboardV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" - dashboardV2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" + dashboardV2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" "github.com/grafana/grafana/apps/dashboard/pkg/migration" "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" "github.com/grafana/grafana/pkg/apimachinery/utils" @@ -71,17 +71,17 @@ func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attribute resourceInfo = dashboardV2alpha1.DashboardResourceInfo - case *dashboardV2alpha2.Dashboard: + case *dashboardV2beta1.Dashboard: // Temporary fix: The generator fails to properly initialize this property, so we'll do it here // until the generator is fixed. if v.Spec.Layout.GridLayoutKind == nil && v.Spec.Layout.RowsLayoutKind == nil && v.Spec.Layout.AutoGridLayoutKind == nil && v.Spec.Layout.TabsLayoutKind == nil { - v.Spec.Layout.GridLayoutKind = &dashboardV2alpha2.DashboardGridLayoutKind{ + v.Spec.Layout.GridLayoutKind = &dashboardV2beta1.DashboardGridLayoutKind{ Kind: "GridLayout", - Spec: dashboardV2alpha2.DashboardGridLayoutSpec{}, + Spec: dashboardV2beta1.DashboardGridLayoutSpec{}, } } - resourceInfo = dashboardV2alpha2.DashboardResourceInfo + resourceInfo = dashboardV2beta1.DashboardResourceInfo // Noop for V2 default: diff --git a/pkg/registry/apis/dashboard/mutation_test.go b/pkg/registry/apis/dashboard/mutation_test.go index bfbbb7f0109..b5a85c9d995 100644 --- a/pkg/registry/apis/dashboard/mutation_test.go +++ b/pkg/registry/apis/dashboard/mutation_test.go @@ -13,7 +13,7 @@ import ( dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" dashv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" - dashv2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" + dashv2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" "github.com/grafana/grafana/apps/dashboard/pkg/migration" "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" "github.com/grafana/grafana/apps/dashboard/pkg/migration/testutil" @@ -152,9 +152,9 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) { expectedTitle: "test123", }, { - name: "v2alpha2 should set layout if it is not set", - inputObj: &dashv2alpha2.Dashboard{ - Spec: dashv2alpha2.DashboardSpec{ + name: "v2beta1 should set layout if it is not set", + inputObj: &dashv2beta1.Dashboard{ + Spec: dashv2beta1.DashboardSpec{ Title: "test123", }, }, @@ -215,7 +215,7 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) { require.Equal(t, schemaversion.LATEST_VERSION, schemaVersion, "dashboard should be migrated to the latest version") } case *dashv2alpha1.Dashboard: - case *dashv2alpha2.Dashboard: + case *dashv2beta1.Dashboard: require.Equal(t, tt.expectedTitle, v.Spec.Title, "title should be set") require.NotNil(t, v.Spec.Layout, "layout should be set") require.NotNil(t, v.Spec.Layout.GridLayoutKind, "layout should be a GridLayout") diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index 8bc223b79f8..75e2ed5b518 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -24,7 +24,7 @@ import ( dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" dashv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" - dashv2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" + dashv2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" "github.com/grafana/grafana/apps/dashboard/pkg/migration" "github.com/grafana/grafana/apps/dashboard/pkg/migration/conversion" "github.com/grafana/grafana/pkg/apimachinery/identity" @@ -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, @@ -157,20 +157,20 @@ func RegisterAPIService( func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion { if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts) { - // If dashboards v2 is enabled, we want to use v2alpha1 as the default API version. + // If dashboards v2 is enabled, we want to use v2beta1 as the default API version. return []schema.GroupVersion{ + dashv2beta1.DashboardResourceInfo.GroupVersion(), dashv2alpha1.DashboardResourceInfo.GroupVersion(), dashv0.DashboardResourceInfo.GroupVersion(), dashv1.DashboardResourceInfo.GroupVersion(), - dashv2alpha2.DashboardResourceInfo.GroupVersion(), } } return []schema.GroupVersion{ dashv1.DashboardResourceInfo.GroupVersion(), dashv0.DashboardResourceInfo.GroupVersion(), + dashv2beta1.DashboardResourceInfo.GroupVersion(), dashv2alpha1.DashboardResourceInfo.GroupVersion(), - dashv2alpha2.DashboardResourceInfo.GroupVersion(), } } @@ -186,7 +186,7 @@ func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { return err } - if err := dashv2alpha2.AddToScheme(scheme); err != nil { + if err := dashv2beta1.AddToScheme(scheme); err != nil { return err } @@ -401,7 +401,7 @@ func getDashboardProperties(obj runtime.Object) (string, string, error) { case *dashv2alpha1.Dashboard: title = d.Spec.Title refresh = d.Spec.TimeSettings.AutoRefresh - case *dashv2alpha2.Dashboard: + case *dashv2beta1.Dashboard: title = d.Spec.Title refresh = d.Spec.TimeSettings.AutoRefresh default: @@ -483,11 +483,11 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver } if err := b.storageForVersion(apiGroupInfo, opts, largeObjects, - dashv2alpha2.DashboardResourceInfo, + dashv2beta1.DashboardResourceInfo, nil, // do not register library panel func(obj runtime.Object, access *internal.DashboardAccess) (v runtime.Object, err error) { - dto := &dashv2alpha2.DashboardWithAccessInfo{} - dash, ok := obj.(*dashv2alpha2.Dashboard) + dto := &dashv2beta1.DashboardWithAccessInfo{} + dash, ok := obj.(*dashv2beta1.Dashboard) if ok { dto.Dashboard = *dash } @@ -572,7 +572,7 @@ func (b *DashboardsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefiniti defs := dashv0.GetOpenAPIDefinitions(ref) maps.Copy(defs, dashv1.GetOpenAPIDefinitions(ref)) maps.Copy(defs, dashv2alpha1.GetOpenAPIDefinitions(ref)) - maps.Copy(defs, dashv2alpha2.GetOpenAPIDefinitions(ref)) + maps.Copy(defs, dashv2beta1.GetOpenAPIDefinitions(ref)) return defs } } diff --git a/pkg/registry/apis/dashboard/register_test.go b/pkg/registry/apis/dashboard/register_test.go index 2bccf5a1453..f788d5dbe67 100644 --- a/pkg/registry/apis/dashboard/register_test.go +++ b/pkg/registry/apis/dashboard/register_test.go @@ -14,7 +14,7 @@ import ( dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" dashv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" - dashv2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" + dashv2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -177,8 +177,8 @@ func TestDashboardAPIBuilder_GetGroupVersions(t *testing.T) { expected: []schema.GroupVersion{ dashv1.DashboardResourceInfo.GroupVersion(), dashv0.DashboardResourceInfo.GroupVersion(), + dashv2beta1.DashboardResourceInfo.GroupVersion(), dashv2alpha1.DashboardResourceInfo.GroupVersion(), - dashv2alpha2.DashboardResourceInfo.GroupVersion(), }, }, { @@ -189,8 +189,8 @@ func TestDashboardAPIBuilder_GetGroupVersions(t *testing.T) { expected: []schema.GroupVersion{ dashv1.DashboardResourceInfo.GroupVersion(), dashv0.DashboardResourceInfo.GroupVersion(), + dashv2beta1.DashboardResourceInfo.GroupVersion(), dashv2alpha1.DashboardResourceInfo.GroupVersion(), - dashv2alpha2.DashboardResourceInfo.GroupVersion(), }, }, { @@ -199,10 +199,10 @@ func TestDashboardAPIBuilder_GetGroupVersions(t *testing.T) { featuremgmt.FlagDashboardNewLayouts, }, expected: []schema.GroupVersion{ + dashv2beta1.DashboardResourceInfo.GroupVersion(), dashv2alpha1.DashboardResourceInfo.GroupVersion(), dashv0.DashboardResourceInfo.GroupVersion(), dashv1.DashboardResourceInfo.GroupVersion(), - dashv2alpha2.DashboardResourceInfo.GroupVersion(), }, }, } diff --git a/pkg/registry/apis/dashboard/schema_validation.go b/pkg/registry/apis/dashboard/schema_validation.go index 91629e5eeb5..4263d2af388 100644 --- a/pkg/registry/apis/dashboard/schema_validation.go +++ b/pkg/registry/apis/dashboard/schema_validation.go @@ -13,7 +13,7 @@ import ( v0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" v1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" v2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" - v2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" + v2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/services/featuremgmt" ) @@ -34,7 +34,7 @@ func (b *DashboardsAPIBuilder) ValidateDashboardSpec(ctx context.Context, obj ru case *v1.Dashboard: errorOnSchemaMismatches = !b.features.IsEnabled(ctx, featuremgmt.FlagDashboardDisableSchemaValidationV1) case *v2alpha1.Dashboard: - case *v2alpha2.Dashboard: + case *v2beta1.Dashboard: errorOnSchemaMismatches = !b.features.IsEnabled(ctx, featuremgmt.FlagDashboardDisableSchemaValidationV2) default: return nil, fmt.Errorf("invalid dashboard type: %T", obj) @@ -56,8 +56,8 @@ func (b *DashboardsAPIBuilder) ValidateDashboardSpec(ctx context.Context, obj ru errors, schemaVersionError = v1.ValidateDashboardSpec(v, alwaysLogSchemaValidationErrors) case *v2alpha1.Dashboard: errors = v2alpha1.ValidateDashboardSpec(v) - case *v2alpha2.Dashboard: - errors = v2alpha2.ValidateDashboardSpec(v) + case *v2beta1.Dashboard: + errors = v2beta1.ValidateDashboardSpec(v) } } diff --git a/pkg/registry/apis/ofrep/proxy.go b/pkg/registry/apis/ofrep/proxy.go index 793a099339e..efb5cc51930 100644 --- a/pkg/registry/apis/ofrep/proxy.go +++ b/pkg/registry/apis/ofrep/proxy.go @@ -14,8 +14,9 @@ import ( "strconv" "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/util/proxyutil" + + goffmodel "github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/model" ) func (b *APIBuilder) proxyAllFlagReq(isAuthedUser bool, w http.ResponseWriter, r *http.Request) { @@ -27,13 +28,13 @@ func (b *APIBuilder) proxyAllFlagReq(isAuthedUser bool, w http.ResponseWriter, r proxy.ModifyResponse = func(resp *http.Response) error { if resp.StatusCode == http.StatusOK && !isAuthedUser { - var result featuremgmt.OFREPBulkResponse + var result goffmodel.OFREPBulkEvaluateSuccessResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return err } _ = resp.Body.Close() - var filteredFlags []featuremgmt.OFREPFlag + var filteredFlags []goffmodel.OFREPFlagBulkEvaluateSuccessResponse for _, f := range result.Flags { if isPublicFlag(f.Key) { filteredFlags = append(filteredFlags, f) diff --git a/pkg/registry/apis/ofrep/static.go b/pkg/registry/apis/ofrep/static.go index 538e8d7e3da..8d56104649d 100644 --- a/pkg/registry/apis/ofrep/static.go +++ b/pkg/registry/apis/ofrep/static.go @@ -3,7 +3,7 @@ package ofrep import ( "net/http" - "github.com/grafana/grafana/pkg/services/featuremgmt" + goffmodel "github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/model" ) func (b *APIBuilder) evalAllFlagsStatic(isAuthedUser bool, w http.ResponseWriter, r *http.Request) { @@ -15,7 +15,7 @@ func (b *APIBuilder) evalAllFlagsStatic(isAuthedUser bool, w http.ResponseWriter } if !isAuthedUser { - var publicOnly []featuremgmt.OFREPFlag + var publicOnly []goffmodel.OFREPFlagBulkEvaluateSuccessResponse for _, flag := range result.Flags { if isPublicFlag(flag.Key) { diff --git a/pkg/registry/apis/provisioning/jobs/delete/worker.go b/pkg/registry/apis/provisioning/jobs/delete/worker.go index 74202643ccd..c1f6739405e 100644 --- a/pkg/registry/apis/provisioning/jobs/delete/worker.go +++ b/pkg/registry/apis/provisioning/jobs/delete/worker.go @@ -6,20 +6,25 @@ import ( "fmt" "time" + "k8s.io/apimachinery/pkg/runtime/schema" + 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" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" ) type Worker struct { - syncWorker jobs.Worker - wrapFn repository.WrapWithStageFn + syncWorker jobs.Worker + wrapFn repository.WrapWithStageFn + resourcesFactory resources.RepositoryResourcesFactory } -func NewWorker(syncWorker jobs.Worker, wrapFn repository.WrapWithStageFn) *Worker { +func NewWorker(syncWorker jobs.Worker, wrapFn repository.WrapWithStageFn, resourcesFactory resources.RepositoryResourcesFactory) *Worker { return &Worker{ - syncWorker: syncWorker, - wrapFn: wrapFn, + syncWorker: syncWorker, + wrapFn: wrapFn, + resourcesFactory: resourcesFactory, } } @@ -31,10 +36,11 @@ func (w *Worker) Process(ctx context.Context, repo repository.Repository, job pr if job.Spec.Delete == nil { return errors.New("missing delete settings") } - opts := *job.Spec.Delete + opts := *job.Spec.Delete paths := opts.Paths - progress.SetTotal(ctx, len(paths)) + + progress.SetTotal(ctx, len(paths)+len(opts.Resources)) progress.StrictMaxErrors(1) // Fail fast on any error during deletion fn := func(repo repository.Repository, _ bool) error { @@ -43,6 +49,18 @@ func (w *Worker) Process(ctx context.Context, repo repository.Repository, job pr return errors.New("delete job submitted targeting repository that is not a ReaderWriter") } + // Resolve ResourceRef entries to file paths using RepositoryResources + if len(opts.Resources) > 0 { + resolvedPaths, err := w.resolveResourcesToPaths(ctx, rw, progress, opts.Resources) + if err != nil { + return err + } + paths = append(paths, resolvedPaths...) + } + + // Deduplicate paths to avoid attempting to delete the same file multiple times + paths = deduplicatePaths(paths) + return w.deleteFiles(ctx, rw, progress, opts, paths...) } @@ -97,3 +115,67 @@ func (w *Worker) deleteFiles(ctx context.Context, rw repository.ReaderWriter, pr return nil } + +// resolveResourcesToPaths converts ResourceRef entries to file paths, recording errors for individual resources +func (w *Worker) resolveResourcesToPaths(ctx context.Context, rw repository.ReaderWriter, progress jobs.JobProgressRecorder, resources []provisioning.ResourceRef) ([]string, error) { + if len(resources) == 0 { + return nil, nil + } + + progress.SetMessage(ctx, "Resolving resource paths") + repositoryResources, err := w.resourcesFactory.Client(ctx, rw) + if err != nil { + return nil, fmt.Errorf("create repository resources client: %w", err) + } + + resolvedPaths := make([]string, 0, len(resources)) + for _, resource := range resources { + result := jobs.JobResourceResult{ + Name: resource.Name, + Group: resource.Group, + Action: repository.FileActionDeleted, // Will be used for deletion later + } + + gvk := schema.GroupVersionKind{ + Group: resource.Group, + Kind: resource.Kind, + // Version is left empty so ForKind will use the preferred version + } + + progress.SetMessage(ctx, fmt.Sprintf("Finding path for resource %s/%s/%s", resource.Group, resource.Kind, resource.Name)) + resourcePath, err := repositoryResources.FindResourcePath(ctx, resource.Name, gvk) + if err != nil { + result.Error = fmt.Errorf("find path for resource %s/%s/%s: %w", resource.Group, resource.Kind, resource.Name, err) + progress.Record(ctx, result) + // Continue with next resource instead of failing fast + if err := progress.TooManyErrors(); err != nil { + return resolvedPaths, err + } + continue + } + + result.Path = resourcePath + resolvedPaths = append(resolvedPaths, resourcePath) + } + + return resolvedPaths, nil +} + +// deduplicatePaths removes duplicate file paths from the slice while preserving order +func deduplicatePaths(paths []string) []string { + if len(paths) <= 1 { + return paths + } + + seen := make(map[string]struct{}, len(paths)) + result := make([]string, 0, len(paths)) + + for _, path := range paths { + if _, exists := seen[path]; !exists { + seen[path] = struct{}{} + result = append(result, path) + } + } + + return result +} diff --git a/pkg/registry/apis/provisioning/jobs/delete/worker_test.go b/pkg/registry/apis/provisioning/jobs/delete/worker_test.go index fbfe81a9762..5d63687dd2c 100644 --- a/pkg/registry/apis/provisioning/jobs/delete/worker_test.go +++ b/pkg/registry/apis/provisioning/jobs/delete/worker_test.go @@ -6,9 +6,13 @@ import ( "testing" "time" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + 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" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -22,6 +26,15 @@ func (m *mockReaderWriter) Delete(ctx context.Context, path, ref, message string return args.Error(0) } +// simpleRepository implements only the base Repository interface, not ReaderWriter +type simpleRepository struct{} + +func (s *simpleRepository) Config() *provisioning.Repository { return nil } +func (s *simpleRepository) Validate() field.ErrorList { return nil } +func (s *simpleRepository) Test(ctx context.Context) (*provisioning.TestResults, error) { + return nil, nil +} + func TestDeleteWorker_IsSupported(t *testing.T) { tests := []struct { name string @@ -59,7 +72,7 @@ func TestDeleteWorker_IsSupported(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - worker := NewWorker(nil, nil) + worker := NewWorker(nil, nil, nil) result := worker.IsSupported(context.Background(), tt.job) require.Equal(t, tt.expected, result) }) @@ -73,7 +86,7 @@ func TestDeleteWorker_ProcessMissingDeleteSettings(t *testing.T) { }, } - worker := NewWorker(nil, nil) + worker := NewWorker(nil, nil, nil) err := worker.Process(context.Background(), nil, job, nil) require.EqualError(t, err, "missing delete settings") } @@ -102,7 +115,7 @@ func TestDeleteWorker_ProcessNotReaderWriter(t *testing.T) { mockProgress.On("SetTotal", mock.Anything, 1).Return() mockProgress.On("StrictMaxErrors", 1).Return() - worker := NewWorker(nil, mockWrapFn.Execute) + worker := NewWorker(nil, mockWrapFn.Execute, nil) err := worker.Process(context.Background(), mockRepo, job, mockProgress) require.EqualError(t, err, "delete files from repository: delete job submitted targeting repository that is not a ReaderWriter") } @@ -125,7 +138,7 @@ func TestDeleteWorker_ProcessWrapFnError(t *testing.T) { mockProgress.On("SetTotal", mock.Anything, 1).Return() mockProgress.On("StrictMaxErrors", 1).Return() - worker := NewWorker(nil, mockWrapFn.Execute) + worker := NewWorker(nil, mockWrapFn.Execute, nil) err := worker.Process(context.Background(), mockRepo, job, mockProgress) require.EqualError(t, err, "delete files from repository: stage failed") } @@ -172,7 +185,7 @@ func TestDeleteWorker_ProcessDeleteFilesSuccess(t *testing.T) { return result.Path == "test/path2" && result.Action == repository.FileActionDeleted && result.Error == nil })).Return() - worker := NewWorker(nil, mockWrapFn.Execute) + worker := NewWorker(nil, mockWrapFn.Execute, nil) err := worker.Process(context.Background(), mockRepo, job, mockProgress) require.NoError(t, err) } @@ -210,7 +223,7 @@ func TestDeleteWorker_ProcessDeleteFilesWithError(t *testing.T) { })).Return() mockProgress.On("TooManyErrors").Return(errors.New("too many errors")) - worker := NewWorker(nil, mockWrapFn.Execute) + worker := NewWorker(nil, mockWrapFn.Execute, nil) err := worker.Process(context.Background(), mockRepo, job, mockProgress) require.EqualError(t, err, "delete files from repository: too many errors") } @@ -254,7 +267,7 @@ func TestDeleteWorker_ProcessWithSyncWorker(t *testing.T) { return syncJob.Spec.Pull != nil && !syncJob.Spec.Pull.Incremental }), mockProgress).Return(nil) - worker := NewWorker(mockSyncWorker, mockWrapFn.Execute) + worker := NewWorker(mockSyncWorker, mockWrapFn.Execute, nil) err := worker.Process(context.Background(), mockRepo, job, mockProgress) require.NoError(t, err) } @@ -294,7 +307,7 @@ func TestDeleteWorker_ProcessSyncWorkerError(t *testing.T) { syncError := errors.New("sync failed") mockSyncWorker.On("Process", mock.Anything, mockRepo, mock.Anything, mockProgress).Return(syncError) - worker := NewWorker(mockSyncWorker, mockWrapFn.Execute) + worker := NewWorker(mockSyncWorker, mockWrapFn.Execute, nil) err := worker.Process(context.Background(), mockRepo, job, mockProgress) require.EqualError(t, err, "pull resources: sync failed") } @@ -363,7 +376,7 @@ func TestDeleteWorker_deleteFiles(t *testing.T) { } } - worker := NewWorker(nil, nil) + worker := NewWorker(nil, nil, nil) err := worker.deleteFiles(context.Background(), mockRepo, mockProgress, opts, tt.paths...) if tt.expectedError != "" { @@ -377,3 +390,596 @@ func TestDeleteWorker_deleteFiles(t *testing.T) { }) } } + +func TestDeleteWorker_ProcessWithResourceRefs(t *testing.T) { + job := provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Delete: &provisioning.DeleteJobOptions{ + Paths: []string{"test/path1"}, + Resources: []provisioning.ResourceRef{ + { + Name: "test-dashboard", + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + { + Name: "test-folder", + Kind: "Folder", + Group: "folder.grafana.app", + }, + }, + Ref: "main", + }, + }, + } + + mockRepo := &mockReaderWriter{ + MockRepository: repository.NewMockRepository(t), + } + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockWrapFn := repository.NewMockWrapWithStageFn(t) + mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) + mockRepositoryResources := resources.NewMockRepositoryResources(t) + + // Mock repository resources factory and client + mockResourcesFactory.On("Client", mock.Anything, mockRepo).Return(mockRepositoryResources, nil) + + // Mock FindResourcePath calls + mockRepositoryResources.On("FindResourcePath", mock.Anything, "test-dashboard", schema.GroupVersionKind{ + Group: "dashboard.grafana.app", + Kind: "Dashboard", + // Version is empty - ForKind will discover the preferred version + }).Return("dashboards/test-dashboard.json", nil) + + mockRepositoryResources.On("FindResourcePath", mock.Anything, "test-folder", schema.GroupVersionKind{ + Group: "folder.grafana.app", + Kind: "Folder", + // Version is empty - ForKind will discover the preferred version + }).Return("folders/test-folder.json", nil) + + mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.MatchedBy(func(opts repository.StageOptions) bool { + return !opts.PushOnWrites && opts.Timeout == 10*time.Minute + }), mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockRepo, false) + }) + + // Progress tracking - expects 3 total (1 path + 2 resources) + mockProgress.On("SetTotal", mock.Anything, 3).Return() + mockProgress.On("StrictMaxErrors", 1).Return() + mockProgress.On("SetMessage", mock.Anything, "Resolving resource paths").Return() + mockProgress.On("SetMessage", mock.Anything, "Finding path for resource dashboard.grafana.app/Dashboard/test-dashboard").Return() + mockProgress.On("SetMessage", mock.Anything, "Finding path for resource folder.grafana.app/Folder/test-folder").Return() + mockProgress.On("SetMessage", mock.Anything, "Deleting test/path1").Return() + mockProgress.On("SetMessage", mock.Anything, "Deleting dashboards/test-dashboard.json").Return() + mockProgress.On("SetMessage", mock.Anything, "Deleting folders/test-folder.json").Return() + mockProgress.On("TooManyErrors").Return(nil).Times(3) + + // Mock file deletions + mockRepo.On("Delete", mock.Anything, "test/path1", "main", "Delete test/path1").Return(nil) + mockRepo.On("Delete", mock.Anything, "dashboards/test-dashboard.json", "main", "Delete dashboards/test-dashboard.json").Return(nil) + mockRepo.On("Delete", mock.Anything, "folders/test-folder.json", "main", "Delete folders/test-folder.json").Return(nil) + + // Mock progress records + mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Path == "test/path1" && result.Action == repository.FileActionDeleted && result.Error == nil + })).Return() + mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Path == "dashboards/test-dashboard.json" && result.Action == repository.FileActionDeleted && result.Error == nil + })).Return() + mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Path == "folders/test-folder.json" && result.Action == repository.FileActionDeleted && result.Error == nil + })).Return() + + worker := NewWorker(nil, mockWrapFn.Execute, mockResourcesFactory) + err := worker.Process(context.Background(), mockRepo, job, mockProgress) + require.NoError(t, err) + + mockResourcesFactory.AssertExpectations(t) + mockRepositoryResources.AssertExpectations(t) +} + +func TestDeleteWorker_ProcessResourceRefsOnly(t *testing.T) { + job := provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Delete: &provisioning.DeleteJobOptions{ + Resources: []provisioning.ResourceRef{ + { + Name: "test-dashboard", + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + Ref: "main", + }, + }, + } + + mockRepo := &mockReaderWriter{ + MockRepository: repository.NewMockRepository(t), + } + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockWrapFn := repository.NewMockWrapWithStageFn(t) + mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) + mockRepositoryResources := resources.NewMockRepositoryResources(t) + + mockResourcesFactory.On("Client", mock.Anything, mockRepo).Return(mockRepositoryResources, nil) + + mockRepositoryResources.On("FindResourcePath", mock.Anything, "test-dashboard", schema.GroupVersionKind{ + Group: "dashboard.grafana.app", + Kind: "Dashboard", + // Version is empty - ForKind will discover the preferred version + }).Return("dashboards/test-dashboard.json", nil) + + mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockRepo, false) + }) + + mockProgress.On("SetTotal", mock.Anything, 1).Return() + mockProgress.On("StrictMaxErrors", 1).Return() + mockProgress.On("SetMessage", mock.Anything, "Resolving resource paths").Return() + mockProgress.On("SetMessage", mock.Anything, "Finding path for resource dashboard.grafana.app/Dashboard/test-dashboard").Return() + mockProgress.On("SetMessage", mock.Anything, "Deleting dashboards/test-dashboard.json").Return() + mockProgress.On("TooManyErrors").Return(nil) + + mockRepo.On("Delete", mock.Anything, "dashboards/test-dashboard.json", "main", "Delete dashboards/test-dashboard.json").Return(nil) + + mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Path == "dashboards/test-dashboard.json" && result.Action == repository.FileActionDeleted && result.Error == nil + })).Return() + + worker := NewWorker(nil, mockWrapFn.Execute, mockResourcesFactory) + err := worker.Process(context.Background(), mockRepo, job, mockProgress) + require.NoError(t, err) +} + +func TestDeleteWorker_ProcessResourceResolutionError(t *testing.T) { + job := provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Delete: &provisioning.DeleteJobOptions{ + Resources: []provisioning.ResourceRef{ + { + Name: "nonexistent-dashboard", + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + }, + } + + mockRepo := &mockReaderWriter{ + MockRepository: repository.NewMockRepository(t), + } + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockWrapFn := repository.NewMockWrapWithStageFn(t) + mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) + mockRepositoryResources := resources.NewMockRepositoryResources(t) + + mockResourcesFactory.On("Client", mock.Anything, mockRepo).Return(mockRepositoryResources, nil) + + findPathError := errors.New("resource not found in repository: dashboard.grafana.app/dashboards/nonexistent-dashboard") + mockRepositoryResources.On("FindResourcePath", mock.Anything, "nonexistent-dashboard", schema.GroupVersionKind{ + Group: "dashboard.grafana.app", + Kind: "Dashboard", + // Version is empty - ForKind will discover the preferred version + }).Return("", findPathError) + + mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockRepo, false) + }) + + mockProgress.On("SetTotal", mock.Anything, 1).Return() + mockProgress.On("StrictMaxErrors", 1).Return() + mockProgress.On("SetMessage", mock.Anything, "Resolving resource paths").Return() + mockProgress.On("SetMessage", mock.Anything, "Finding path for resource dashboard.grafana.app/Dashboard/nonexistent-dashboard").Return() + + // Expect error to be recorded, not thrown + mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Name == "nonexistent-dashboard" && + result.Group == "dashboard.grafana.app" && + result.Action == repository.FileActionDeleted && + result.Error != nil + })).Return() + mockProgress.On("TooManyErrors").Return(nil) + + // Mock sync worker behavior that happens when no ref is specified + mockProgress.On("ResetResults").Return() + mockProgress.On("SetMessage", mock.Anything, "pull resources").Return() + + mockSyncWorker := jobs.NewMockWorker(t) + mockSyncWorker.On("Process", mock.Anything, mockRepo, mock.MatchedBy(func(syncJob provisioning.Job) bool { + return syncJob.Spec.Pull != nil && !syncJob.Spec.Pull.Incremental + }), mockProgress).Return(nil) + + worker := NewWorker(mockSyncWorker, mockWrapFn.Execute, mockResourcesFactory) + err := worker.Process(context.Background(), mockRepo, job, mockProgress) + require.NoError(t, err) // Should succeed even with resource resolution error +} + +func TestDeleteWorker_ProcessResourcesFactoryError(t *testing.T) { + job := provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Delete: &provisioning.DeleteJobOptions{ + Resources: []provisioning.ResourceRef{ + { + Name: "test-dashboard", + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + }, + } + + mockRepo := &mockReaderWriter{ + MockRepository: repository.NewMockRepository(t), + } + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockWrapFn := repository.NewMockWrapWithStageFn(t) + mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) + + factoryError := errors.New("failed to create repository resources client") + mockResourcesFactory.On("Client", mock.Anything, mockRepo).Return(nil, factoryError) + + mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockRepo, false) + }) + + mockProgress.On("SetTotal", mock.Anything, 1).Return() + mockProgress.On("StrictMaxErrors", 1).Return() + mockProgress.On("SetMessage", mock.Anything, "Resolving resource paths").Return() + + worker := NewWorker(nil, mockWrapFn.Execute, mockResourcesFactory) + err := worker.Process(context.Background(), mockRepo, job, mockProgress) + require.EqualError(t, err, "delete files from repository: create repository resources client: failed to create repository resources client") +} + +func TestDeleteWorker_ProcessResourceRefsNotReaderWriter(t *testing.T) { + job := provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Delete: &provisioning.DeleteJobOptions{ + Resources: []provisioning.ResourceRef{ + { + Name: "test-dashboard", + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + }, + } + + // Create a simple repository that doesn't implement ReaderWriter + mockRepo := &simpleRepository{} + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockWrapFn := repository.NewMockWrapWithStageFn(t) + mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) + + // Mock the wrap function that will call our function and get the ReaderWriter error + mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockRepo, false) + }) + + // The ReaderWriter check should fail immediately, so no resource resolution calls should happen + mockProgress.On("SetTotal", mock.Anything, 1).Return() + mockProgress.On("StrictMaxErrors", 1).Return() + + worker := NewWorker(nil, mockWrapFn.Execute, mockResourcesFactory) + err := worker.Process(context.Background(), mockRepo, job, mockProgress) + require.EqualError(t, err, "delete files from repository: delete job submitted targeting repository that is not a ReaderWriter") +} + +func TestDeleteWorker_ProcessResourceResolutionTooManyErrors(t *testing.T) { + job := provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Delete: &provisioning.DeleteJobOptions{ + Resources: []provisioning.ResourceRef{ + { + Name: "nonexistent-dashboard", + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + }, + } + + mockRepo := &mockReaderWriter{ + MockRepository: repository.NewMockRepository(t), + } + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockWrapFn := repository.NewMockWrapWithStageFn(t) + mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) + mockRepositoryResources := resources.NewMockRepositoryResources(t) + + mockResourcesFactory.On("Client", mock.Anything, mockRepo).Return(mockRepositoryResources, nil) + + findPathError := errors.New("resource not found in repository") + mockRepositoryResources.On("FindResourcePath", mock.Anything, "nonexistent-dashboard", schema.GroupVersionKind{ + Group: "dashboard.grafana.app", + Kind: "Dashboard", + // Version is empty - ForKind will discover the preferred version + }).Return("", findPathError) + + mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockRepo, false) + }) + + mockProgress.On("SetTotal", mock.Anything, 1).Return() + mockProgress.On("StrictMaxErrors", 1).Return() + mockProgress.On("SetMessage", mock.Anything, "Resolving resource paths").Return() + mockProgress.On("SetMessage", mock.Anything, "Finding path for resource dashboard.grafana.app/Dashboard/nonexistent-dashboard").Return() + + // Mock recording error and TooManyErrors returning error + mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Name == "nonexistent-dashboard" && result.Error != nil + })).Return() + mockProgress.On("TooManyErrors").Return(errors.New("too many errors")) + + worker := NewWorker(nil, mockWrapFn.Execute, mockResourcesFactory) + err := worker.Process(context.Background(), mockRepo, job, mockProgress) + require.EqualError(t, err, "delete files from repository: too many errors") +} + +func TestDeleteWorker_ProcessMixedResourcesWithPartialFailure(t *testing.T) { + job := provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Delete: &provisioning.DeleteJobOptions{ + Resources: []provisioning.ResourceRef{ + { + Name: "valid-dashboard", + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + { + Name: "nonexistent-dashboard", + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + { + Name: "valid-folder", + Kind: "Folder", + Group: "folder.grafana.app", + }, + }, + Ref: "main", + }, + }, + } + + mockRepo := &mockReaderWriter{ + MockRepository: repository.NewMockRepository(t), + } + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockWrapFn := repository.NewMockWrapWithStageFn(t) + mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) + mockRepositoryResources := resources.NewMockRepositoryResources(t) + + mockResourcesFactory.On("Client", mock.Anything, mockRepo).Return(mockRepositoryResources, nil) + + // First resource succeeds + mockRepositoryResources.On("FindResourcePath", mock.Anything, "valid-dashboard", schema.GroupVersionKind{ + Group: "dashboard.grafana.app", + Kind: "Dashboard", + // Version is empty - ForKind will discover the preferred version + }).Return("dashboards/valid-dashboard.json", nil) + + // Second resource fails + findPathError := errors.New("resource not found") + mockRepositoryResources.On("FindResourcePath", mock.Anything, "nonexistent-dashboard", schema.GroupVersionKind{ + Group: "dashboard.grafana.app", + Kind: "Dashboard", + // Version is empty - ForKind will discover the preferred version + }).Return("", findPathError) + + // Third resource succeeds + mockRepositoryResources.On("FindResourcePath", mock.Anything, "valid-folder", schema.GroupVersionKind{ + Group: "folder.grafana.app", + Kind: "Folder", + // Version is empty - ForKind will discover the preferred version + }).Return("folders/valid-folder.json", nil) + + mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockRepo, false) + }) + + mockProgress.On("SetTotal", mock.Anything, 3).Return() + mockProgress.On("StrictMaxErrors", 1).Return() + mockProgress.On("SetMessage", mock.Anything, "Resolving resource paths").Return() + mockProgress.On("SetMessage", mock.Anything, "Finding path for resource dashboard.grafana.app/Dashboard/valid-dashboard").Return() + mockProgress.On("SetMessage", mock.Anything, "Finding path for resource dashboard.grafana.app/Dashboard/nonexistent-dashboard").Return() + mockProgress.On("SetMessage", mock.Anything, "Finding path for resource folder.grafana.app/Folder/valid-folder").Return() + mockProgress.On("SetMessage", mock.Anything, "Deleting dashboards/valid-dashboard.json").Return() + mockProgress.On("SetMessage", mock.Anything, "Deleting folders/valid-folder.json").Return() + + // Record the error for the failed resource + mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Name == "nonexistent-dashboard" && result.Error != nil + })).Return() + + // Allow continuing after error + mockProgress.On("TooManyErrors").Return(nil).Times(3) // Called after each resource resolution and file deletion + + // Mock successful file deletions for resolved resources + mockRepo.On("Delete", mock.Anything, "dashboards/valid-dashboard.json", "main", "Delete dashboards/valid-dashboard.json").Return(nil) + mockRepo.On("Delete", mock.Anything, "folders/valid-folder.json", "main", "Delete folders/valid-folder.json").Return(nil) + + // Record successful deletions + mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Path == "dashboards/valid-dashboard.json" && result.Error == nil + })).Return() + mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Path == "folders/valid-folder.json" && result.Error == nil + })).Return() + + worker := NewWorker(nil, mockWrapFn.Execute, mockResourcesFactory) + err := worker.Process(context.Background(), mockRepo, job, mockProgress) + require.NoError(t, err) // Should succeed overall, with only the failed resource recorded as error +} + +func TestDeleteWorker_ProcessWithPathDeduplication(t *testing.T) { + // Test that duplicate paths from explicit paths and resource resolution are deduplicated + job := provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Delete: &provisioning.DeleteJobOptions{ + Ref: "main", // Add ref to avoid sync worker execution + Paths: []string{"dashboards/test-dashboard.json", "folders/test-folder/"}, // Explicit paths + Resources: []provisioning.ResourceRef{ + { + Name: "test-dashboard", // This will resolve to "dashboards/test-dashboard.json" (duplicate) + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + { + Name: "test-folder", // This will resolve to "folders/test-folder/" (duplicate) + Kind: "Folder", + Group: "folder.grafana.app", + }, + { + Name: "unique-dashboard", // This will resolve to "dashboards/unique-dashboard.json" (unique) + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + }, + } + + mockRepo := &mockReaderWriter{ + MockRepository: repository.NewMockRepository(t), + } + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockWrapFn := repository.NewMockWrapWithStageFn(t) + + // Mock resources factory and repository resources + mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) + mockRepositoryResources := resources.NewMockRepositoryResources(t) + + mockResourcesFactory.On("Client", mock.Anything, mockRepo).Return(mockRepositoryResources, nil) + + mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockRepo, false) + }) + + // Expect total of 5 items (2 explicit paths + 3 resources), but only 3 unique paths will be deleted + mockProgress.On("SetTotal", mock.Anything, 5).Return() + mockProgress.On("StrictMaxErrors", 1).Return() + + // Resource resolution phase + mockProgress.On("SetMessage", mock.Anything, "Resolving resource paths").Return() + + // Mock resource path resolution - note duplicates with explicit paths + mockProgress.On("SetMessage", mock.Anything, "Finding path for resource dashboard.grafana.app/Dashboard/test-dashboard").Return() + mockRepositoryResources.On("FindResourcePath", mock.Anything, "test-dashboard", schema.GroupVersionKind{ + Group: "dashboard.grafana.app", + Kind: "Dashboard", + }).Return("dashboards/test-dashboard.json", nil) // Duplicate of explicit path + + mockProgress.On("SetMessage", mock.Anything, "Finding path for resource folder.grafana.app/Folder/test-folder").Return() + mockRepositoryResources.On("FindResourcePath", mock.Anything, "test-folder", schema.GroupVersionKind{ + Group: "folder.grafana.app", + Kind: "Folder", + }).Return("folders/test-folder/", nil) // Duplicate of explicit path + + mockProgress.On("SetMessage", mock.Anything, "Finding path for resource dashboard.grafana.app/Dashboard/unique-dashboard").Return() + mockRepositoryResources.On("FindResourcePath", mock.Anything, "unique-dashboard", schema.GroupVersionKind{ + Group: "dashboard.grafana.app", + Kind: "Dashboard", + }).Return("dashboards/unique-dashboard.json", nil) // Unique path + + // Note: successful resource resolution does not call Record - only failures do + + // Deletion phase - should only delete 3 unique paths (deduplication working) + mockProgress.On("SetMessage", mock.Anything, "Deleting dashboards/test-dashboard.json").Return() + mockRepo.On("Delete", mock.Anything, "dashboards/test-dashboard.json", "main", "Delete dashboards/test-dashboard.json").Return(nil) + mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Path == "dashboards/test-dashboard.json" && result.Action == repository.FileActionDeleted && result.Error == nil + })).Return() + mockProgress.On("TooManyErrors").Return(nil) + + mockProgress.On("SetMessage", mock.Anything, "Deleting folders/test-folder/").Return() + mockRepo.On("Delete", mock.Anything, "folders/test-folder/", "main", "Delete folders/test-folder/").Return(nil) + mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Path == "folders/test-folder/" && result.Action == repository.FileActionDeleted && result.Error == nil + })).Return() + + mockProgress.On("SetMessage", mock.Anything, "Deleting dashboards/unique-dashboard.json").Return() + mockRepo.On("Delete", mock.Anything, "dashboards/unique-dashboard.json", "main", "Delete dashboards/unique-dashboard.json").Return(nil) + mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Path == "dashboards/unique-dashboard.json" && result.Action == repository.FileActionDeleted && result.Error == nil + })).Return() + + worker := NewWorker(nil, mockWrapFn.Execute, mockResourcesFactory) + err := worker.Process(context.Background(), mockRepo, job, mockProgress) + require.NoError(t, err) + + // Verify all mocks were called as expected - key point is that each file is only deleted once + mockRepo.AssertExpectations(t) + mockProgress.AssertExpectations(t) + mockResourcesFactory.AssertExpectations(t) + mockRepositoryResources.AssertExpectations(t) +} + +func TestDeduplicatePaths(t *testing.T) { + tests := []struct { + name string + input []string + expected []string + }{ + { + name: "empty slice", + input: []string{}, + expected: []string{}, + }, + { + name: "single path", + input: []string{"path1"}, + expected: []string{"path1"}, + }, + { + name: "no duplicates", + input: []string{"path1", "path2", "path3"}, + expected: []string{"path1", "path2", "path3"}, + }, + { + name: "with duplicates", + input: []string{"path1", "path2", "path1", "path3", "path2"}, + expected: []string{"path1", "path2", "path3"}, + }, + { + name: "all same paths", + input: []string{"path1", "path1", "path1"}, + expected: []string{"path1"}, + }, + { + name: "mixed paths with folder trailing slash", + input: []string{"folder/", "file.json", "folder/", "nested/file.json", "file.json"}, + expected: []string{"folder/", "file.json", "nested/file.json"}, + }, + { + name: "preserves order", + input: []string{"c", "a", "b", "a", "c"}, + expected: []string{"c", "a", "b"}, + }, + { + name: "realistic scenario - explicit paths and resource refs resolve to same paths", + input: []string{"dashboards/dashboard1.json", "folder/", "dashboards/dashboard1.json", "alerts/alert1.yaml", "folder/"}, + expected: []string{"dashboards/dashboard1.json", "folder/", "alerts/alert1.yaml"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := deduplicatePaths(tt.input) + require.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/registry/apis/provisioning/jobs/export/resources.go b/pkg/registry/apis/provisioning/jobs/export/resources.go index c226cacac00..b986392c3d6 100644 --- a/pkg/registry/apis/provisioning/jobs/export/resources.go +++ b/pkg/registry/apis/provisioning/jobs/export/resources.go @@ -54,9 +54,9 @@ func ExportResources(ctx context.Context, options provisioning.ExportJobOptions, return v2clientAlphaV1.Get(ctx, item.GetName(), metav1.GetOptions{}) } - if strings.HasPrefix(storedVersion, "v2alpha2") { + if strings.HasPrefix(storedVersion, "v2beta1") { if v2clientAlphaV2 == nil { - v2clientAlphaV2, _, err = clients.ForResource(resources.DashboardResourceV2alpha2) + v2clientAlphaV2, _, err = clients.ForResource(resources.DashboardResourceV2beta1) if err != nil { return nil, err } diff --git a/pkg/registry/apis/provisioning/jobs/export/resources_test.go b/pkg/registry/apis/provisioning/jobs/export/resources_test.go index 2e663b64e57..db8209cf414 100644 --- a/pkg/registry/apis/provisioning/jobs/export/resources_test.go +++ b/pkg/registry/apis/provisioning/jobs/export/resources_test.go @@ -434,7 +434,7 @@ func TestExportResources_Dashboards_V2Alpha1_ClientError(t *testing.T) { require.NoError(t, err) } -func TestExportResources_Dashboards_V2Alpha2(t *testing.T) { +func TestExportResources_Dashboards_V2beta1(t *testing.T) { mockItems := []unstructured.Unstructured{ { Object: map[string]interface{}{ @@ -446,7 +446,7 @@ func TestExportResources_Dashboards_V2Alpha2(t *testing.T) { "status": map[string]interface{}{ "conversion": map[string]interface{}{ "failed": true, - "storedVersion": "v2alpha2", + "storedVersion": "v2beta1", }, }, }, @@ -467,9 +467,9 @@ func TestExportResources_Dashboards_V2Alpha2(t *testing.T) { resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil) // Setup v2 client - v2Dashboard := createV2DashboardObject("v2-dashboard", "v2alpha2") + v2Dashboard := createV2DashboardObject("v2-dashboard", "v2beta1") v2Client := &mockDynamicInterface{items: []unstructured.Unstructured{v2Dashboard}} - resourceClients.On("ForResource", resources.DashboardResourceV2alpha2).Return(v2Client, gvk, nil) + resourceClients.On("ForResource", resources.DashboardResourceV2beta1).Return(v2Client, gvk, nil) options := resources.WriteOptions{ Path: "grafana", @@ -482,7 +482,7 @@ func TestExportResources_Dashboards_V2Alpha2(t *testing.T) { require.NoError(t, err) } -func TestExportResources_Dashboards_V2Alpha2_ClientError(t *testing.T) { +func TestExportResources_Dashboards_V2beta1_ClientError(t *testing.T) { mockItems := []unstructured.Unstructured{ { Object: map[string]interface{}{ @@ -494,7 +494,7 @@ func TestExportResources_Dashboards_V2Alpha2_ClientError(t *testing.T) { "status": map[string]interface{}{ "conversion": map[string]interface{}{ "failed": true, - "storedVersion": "v2alpha2", + "storedVersion": "v2beta1", }, }, }, @@ -525,7 +525,7 @@ func TestExportResources_Dashboards_V2Alpha2_ClientError(t *testing.T) { } setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { - resourceClients.On("ForResource", resources.DashboardResourceV2alpha2).Return(nil, gvk, fmt.Errorf("v2 client error")) + resourceClients.On("ForResource", resources.DashboardResourceV2beta1).Return(nil, gvk, fmt.Errorf("v2 client error")) resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil) } diff --git a/pkg/registry/apis/provisioning/jobs/move/worker.go b/pkg/registry/apis/provisioning/jobs/move/worker.go new file mode 100644 index 00000000000..5c2bba7e4e4 --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/move/worker.go @@ -0,0 +1,209 @@ +package move + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "time" + + "k8s.io/apimachinery/pkg/runtime/schema" + + 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" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath" +) + +type Worker struct { + syncWorker jobs.Worker + wrapFn repository.WrapWithStageFn + resourcesFactory resources.RepositoryResourcesFactory +} + +func NewWorker(syncWorker jobs.Worker, wrapFn repository.WrapWithStageFn, resourcesFactory resources.RepositoryResourcesFactory) *Worker { + return &Worker{ + syncWorker: syncWorker, + wrapFn: wrapFn, + resourcesFactory: resourcesFactory, + } +} + +func (w *Worker) IsSupported(ctx context.Context, job provisioning.Job) bool { + return job.Spec.Action == provisioning.JobActionMove +} + +func (w *Worker) Process(ctx context.Context, repo repository.Repository, job provisioning.Job, progress jobs.JobProgressRecorder) error { + if job.Spec.Move == nil { + return errors.New("missing move settings") + } + opts := *job.Spec.Move + + if opts.TargetPath == "" { + return errors.New("target path is required for move operation") + } + + // Validate that target path is a directory (ends with slash) + if !safepath.IsDir(opts.TargetPath) { + return errors.New("target path must be a directory (should end with '/')") + } + + paths := opts.Paths + + progress.SetTotal(ctx, len(paths)+len(opts.Resources)) + progress.StrictMaxErrors(1) // Fail fast on any error during move + + fn := func(repo repository.Repository, _ bool) error { + rw, ok := repo.(repository.ReaderWriter) + if !ok { + return errors.New("move job submitted targeting repository that is not a ReaderWriter") + } + + // Resolve ResourceRef entries to file paths using RepositoryResources + if len(opts.Resources) > 0 { + resolvedPaths, err := w.resolveResourcesToPaths(ctx, rw, progress, opts.Resources) + if err != nil { + return err + } + paths = append(paths, resolvedPaths...) + } + + // Deduplicate paths to avoid attempting to move the same file multiple times + paths = deduplicatePaths(paths) + + return w.moveFiles(ctx, rw, progress, opts, paths...) + } + + msg := fmt.Sprintf("Move files from Grafana %s", job.Name) + stageOptions := repository.StageOptions{ + Mode: repository.StageModeCommitOnlyOnce, + CommitOnlyOnceMessage: msg, + PushOnWrites: false, + Timeout: 10 * time.Minute, + } + + err := w.wrapFn(ctx, repo, stageOptions, fn) + if err != nil { + return fmt.Errorf("move files in repository: %w", err) + } + + if opts.Ref == "" { + progress.ResetResults() + progress.SetMessage(ctx, "pull resources") + + syncJob := provisioning.Job{ + Spec: provisioning.JobSpec{ + Pull: &provisioning.SyncJobOptions{ + // Full sync because it's the only one that supports empty folder deletion + Incremental: false, + }, + }, + } + + if err := w.syncWorker.Process(ctx, repo, syncJob, progress); err != nil { + return fmt.Errorf("pull resources: %w", err) + } + } + + return nil +} + +func (w *Worker) moveFiles(ctx context.Context, rw repository.ReaderWriter, progress jobs.JobProgressRecorder, opts provisioning.MoveJobOptions, paths ...string) error { + for _, path := range paths { + result := jobs.JobResourceResult{ + Path: path, + Action: repository.FileActionRenamed, + } + + // Construct the target path by combining the job's target path with the file/folder name + targetPath := w.constructTargetPath(opts.TargetPath, path) + + progress.SetMessage(ctx, "Moving "+path+" to "+targetPath) + result.Error = rw.Move(ctx, path, targetPath, opts.Ref, "Move "+path+" to "+targetPath) + progress.Record(ctx, result) + if err := progress.TooManyErrors(); err != nil { + return err + } + } + + return nil +} + +// constructTargetPath combines the job's target path with the file/folder name from the source path +func (w *Worker) constructTargetPath(jobTargetPath, sourcePath string) string { + // Extract the file/folder name from the source path + fileName := filepath.Base(sourcePath) + + // If the source path is a directory (ends with slash), preserve the trailing slash in target + if safepath.IsDir(sourcePath) { + return jobTargetPath + fileName + "/" + } + + // For files, just append the filename + return jobTargetPath + fileName +} + +// resolveResourcesToPaths converts ResourceRef entries to file paths, recording errors for individual resources +func (w *Worker) resolveResourcesToPaths(ctx context.Context, rw repository.ReaderWriter, progress jobs.JobProgressRecorder, resources []provisioning.ResourceRef) ([]string, error) { + if len(resources) == 0 { + return nil, nil + } + + progress.SetMessage(ctx, "Resolving resource paths") + repositoryResources, err := w.resourcesFactory.Client(ctx, rw) + if err != nil { + return nil, fmt.Errorf("create repository resources client: %w", err) + } + + resolvedPaths := make([]string, 0, len(resources)) + for _, resource := range resources { + result := jobs.JobResourceResult{ + Name: resource.Name, + Group: resource.Group, + Action: repository.FileActionRenamed, // Will be used for move later + } + + gvk := schema.GroupVersionKind{ + Group: resource.Group, + Kind: resource.Kind, + // Version is left empty so ForKind will use the preferred version + } + + progress.SetMessage(ctx, fmt.Sprintf("Finding path for resource %s/%s/%s", resource.Group, resource.Kind, resource.Name)) + resourcePath, err := repositoryResources.FindResourcePath(ctx, resource.Name, gvk) + if err != nil { + result.Error = fmt.Errorf("find path for resource %s/%s/%s: %w", resource.Group, resource.Kind, resource.Name, err) + progress.Record(ctx, result) + // Continue with next resource instead of failing fast + if err := progress.TooManyErrors(); err != nil { + return resolvedPaths, err + } + continue + } + + result.Path = resourcePath + resolvedPaths = append(resolvedPaths, resourcePath) + } + + return resolvedPaths, nil +} + +// deduplicatePaths removes duplicate file paths from the slice while preserving order +func deduplicatePaths(paths []string) []string { + if len(paths) <= 1 { + return paths + } + + seen := make(map[string]struct{}, len(paths)) + result := make([]string, 0, len(paths)) + + for _, path := range paths { + if _, exists := seen[path]; !exists { + seen[path] = struct{}{} + result = append(result, path) + } + } + + return result +} diff --git a/pkg/registry/apis/provisioning/jobs/move/worker_test.go b/pkg/registry/apis/provisioning/jobs/move/worker_test.go new file mode 100644 index 00000000000..3be6d2d4619 --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/move/worker_test.go @@ -0,0 +1,835 @@ +package move + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + 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" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type mockReaderWriter struct { + *repository.MockRepository +} + +func (m *mockReaderWriter) Move(ctx context.Context, oldPath, newPath, ref, message string) error { + args := m.Called(ctx, oldPath, newPath, ref, message) + return args.Error(0) +} + +func TestMoveWorker_IsSupported(t *testing.T) { + tests := []struct { + name string + job provisioning.Job + expected bool + }{ + { + name: "move action is supported", + job: provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + }, + }, + expected: true, + }, + { + name: "delete action is not supported", + job: provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + }, + }, + expected: false, + }, + { + name: "pull action is not supported", + job: provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionPull, + }, + }, + expected: false, + }, + { + name: "push action is not supported", + job: provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionPush, + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + worker := NewWorker(nil, nil, nil) + result := worker.IsSupported(context.Background(), tt.job) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestMoveWorker_ProcessMissingMoveSettings(t *testing.T) { + job := provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + }, + } + + worker := NewWorker(nil, nil, nil) + err := worker.Process(context.Background(), nil, job, nil) + require.EqualError(t, err, "missing move settings") +} + +func TestMoveWorker_ProcessMissingTargetPath(t *testing.T) { + job := provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + Paths: []string{"test/path"}, + }, + }, + } + + worker := NewWorker(nil, nil, nil) + err := worker.Process(context.Background(), nil, job, nil) + require.EqualError(t, err, "target path is required for move operation") +} + +func TestMoveWorker_ProcessInvalidTargetPath(t *testing.T) { + job := provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + Paths: []string{"test/path"}, + TargetPath: "target.txt", // This is not a directory path + }, + }, + } + + worker := NewWorker(nil, nil, nil) + err := worker.Process(context.Background(), nil, job, nil) + require.EqualError(t, err, "target path must be a directory (should end with '/')") +} + +func TestMoveWorker_ProcessNotReaderWriter(t *testing.T) { + job := provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + Paths: []string{"test/path"}, + TargetPath: "new/location/", + }, + }, + } + + mockRepo := repository.NewMockRepository(t) + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockWrapFn := repository.NewMockWrapWithStageFn(t) + + mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.MatchedBy(func(opts repository.StageOptions) bool { + return !opts.PushOnWrites && opts.Timeout == 10*time.Minute && + opts.Mode == repository.StageModeCommitOnlyOnce && + opts.CommitOnlyOnceMessage == "Move files from Grafana test-job" + }), mock.Anything).Return(errors.New("move job submitted targeting repository that is not a ReaderWriter")) + + mockProgress.On("SetTotal", mock.Anything, 1).Return() + mockProgress.On("StrictMaxErrors", 1).Return() + + worker := NewWorker(nil, mockWrapFn.Execute, nil) + err := worker.Process(context.Background(), mockRepo, job, mockProgress) + require.EqualError(t, err, "move files in repository: move job submitted targeting repository that is not a ReaderWriter") +} + +func TestMoveWorker_ProcessWrapFnError(t *testing.T) { + job := provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + Paths: []string{"test/path"}, + TargetPath: "new/location/", + }, + }, + } + + mockRepo := repository.NewMockRepository(t) + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockWrapFn := repository.NewMockWrapWithStageFn(t) + + mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(errors.New("stage failed")) + mockProgress.On("SetTotal", mock.Anything, 1).Return() + mockProgress.On("StrictMaxErrors", 1).Return() + + worker := NewWorker(nil, mockWrapFn.Execute, nil) + err := worker.Process(context.Background(), mockRepo, job, mockProgress) + require.EqualError(t, err, "move files in repository: stage failed") +} + +func TestMoveWorker_ProcessMoveFilesSuccess(t *testing.T) { + job := provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + Paths: []string{"test/path1", "test/path2"}, + TargetPath: "new/location/", + Ref: "main", + }, + }, + } + + mockRepo := &mockReaderWriter{ + MockRepository: repository.NewMockRepository(t), + } + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockWrapFn := repository.NewMockWrapWithStageFn(t) + + mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.MatchedBy(func(opts repository.StageOptions) bool { + return !opts.PushOnWrites && opts.Timeout == 10*time.Minute && + opts.Mode == repository.StageModeCommitOnlyOnce && + opts.CommitOnlyOnceMessage != "" + }), mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockRepo, false) + }) + + mockProgress.On("SetTotal", mock.Anything, 2).Return() + mockProgress.On("StrictMaxErrors", 1).Return() + mockProgress.On("SetMessage", mock.Anything, "Moving test/path1 to new/location/path1").Return() + mockProgress.On("SetMessage", mock.Anything, "Moving test/path2 to new/location/path2").Return() + mockProgress.On("TooManyErrors").Return(nil).Twice() + + mockRepo.On("Move", mock.Anything, "test/path1", "new/location/path1", "main", "Move test/path1 to new/location/path1").Return(nil) + mockRepo.On("Move", mock.Anything, "test/path2", "new/location/path2", "main", "Move test/path2 to new/location/path2").Return(nil) + + mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Path == "test/path1" && result.Action == repository.FileActionRenamed && result.Error == nil + })).Return() + mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Path == "test/path2" && result.Action == repository.FileActionRenamed && result.Error == nil + })).Return() + + worker := NewWorker(nil, mockWrapFn.Execute, nil) + err := worker.Process(context.Background(), mockRepo, job, mockProgress) + require.NoError(t, err) +} + +func TestMoveWorker_ProcessMoveFilesWithError(t *testing.T) { + job := provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + Paths: []string{"test/path1", "test/path2"}, + TargetPath: "new/location/", + Ref: "main", + }, + }, + } + + mockRepo := &mockReaderWriter{ + MockRepository: repository.NewMockRepository(t), + } + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockWrapFn := repository.NewMockWrapWithStageFn(t) + + mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockRepo, false) + }) + + mockProgress.On("SetTotal", mock.Anything, 2).Return() + mockProgress.On("StrictMaxErrors", 1).Return() + mockProgress.On("SetMessage", mock.Anything, "Moving test/path1 to new/location/path1").Return() + + moveError := errors.New("move failed") + mockRepo.On("Move", mock.Anything, "test/path1", "new/location/path1", "main", "Move test/path1 to new/location/path1").Return(moveError) + + mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Path == "test/path1" && result.Action == repository.FileActionRenamed && errors.Is(result.Error, moveError) + })).Return() + mockProgress.On("TooManyErrors").Return(errors.New("too many errors")) + + worker := NewWorker(nil, mockWrapFn.Execute, nil) + err := worker.Process(context.Background(), mockRepo, job, mockProgress) + require.EqualError(t, err, "move files in repository: too many errors") +} + +func TestMoveWorker_ProcessWithSyncWorker(t *testing.T) { + job := provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + Paths: []string{"test/path"}, + TargetPath: "new/location/", + }, + }, + } + + mockRepo := &mockReaderWriter{ + MockRepository: repository.NewMockRepository(t), + } + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockSyncWorker := jobs.NewMockWorker(t) + mockWrapFn := repository.NewMockWrapWithStageFn(t) + + mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockRepo, false) + }) + + mockProgress.On("SetTotal", mock.Anything, 1).Return() + mockProgress.On("StrictMaxErrors", 1).Return() + mockProgress.On("SetMessage", mock.Anything, "Moving test/path to new/location/path").Return() + mockProgress.On("TooManyErrors").Return(nil) + + mockRepo.On("Move", mock.Anything, "test/path", "new/location/path", "", "Move test/path to new/location/path").Return(nil) + + mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Path == "test/path" && result.Action == repository.FileActionRenamed && result.Error == nil + })).Return() + + mockProgress.On("ResetResults").Return() + mockProgress.On("SetMessage", mock.Anything, "pull resources").Return() + + mockSyncWorker.On("Process", mock.Anything, mockRepo, mock.MatchedBy(func(syncJob provisioning.Job) bool { + return syncJob.Spec.Pull != nil && !syncJob.Spec.Pull.Incremental + }), mockProgress).Return(nil) + + worker := NewWorker(mockSyncWorker, mockWrapFn.Execute, nil) + err := worker.Process(context.Background(), mockRepo, job, mockProgress) + require.NoError(t, err) +} + +func TestMoveWorker_ProcessSyncWorkerError(t *testing.T) { + job := provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + Paths: []string{"test/path"}, + TargetPath: "new/location/", + }, + }, + } + + mockRepo := &mockReaderWriter{ + MockRepository: repository.NewMockRepository(t), + } + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockSyncWorker := jobs.NewMockWorker(t) + mockWrapFn := repository.NewMockWrapWithStageFn(t) + + mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockRepo, false) + }) + + mockProgress.On("SetTotal", mock.Anything, 1).Return() + mockProgress.On("StrictMaxErrors", 1).Return() + mockProgress.On("SetMessage", mock.Anything, "Moving test/path to new/location/path").Return() + mockProgress.On("TooManyErrors").Return(nil) + + mockRepo.On("Move", mock.Anything, "test/path", "new/location/path", "", "Move test/path to new/location/path").Return(nil) + + mockProgress.On("Record", mock.Anything, mock.Anything).Return() + mockProgress.On("ResetResults").Return() + mockProgress.On("SetMessage", mock.Anything, "pull resources").Return() + + syncError := errors.New("sync failed") + mockSyncWorker.On("Process", mock.Anything, mockRepo, mock.Anything, mockProgress).Return(syncError) + + worker := NewWorker(mockSyncWorker, mockWrapFn.Execute, nil) + err := worker.Process(context.Background(), mockRepo, job, mockProgress) + require.EqualError(t, err, "pull resources: sync failed") +} + +func TestMoveWorker_moveFiles(t *testing.T) { + tests := []struct { + name string + paths []string + moveResults []error + tooManyErrors error + expectedError string + expectedCalls int + }{ + { + name: "single file success", + paths: []string{"test/file1.yaml"}, + moveResults: []error{nil}, + expectedCalls: 1, + }, + { + name: "multiple files success", + paths: []string{"test/file1.yaml", "test/file2.yaml", "test/file3.yaml"}, + moveResults: []error{nil, nil, nil}, + expectedCalls: 3, + }, + { + name: "mixed files and folders", + paths: []string{"file.json", "folder/", "nested/file.yaml"}, + moveResults: []error{nil, nil, nil}, + expectedCalls: 3, + }, + { + name: "single file with error continues", + paths: []string{"test/file1.yaml", "test/file2.yaml"}, + moveResults: []error{errors.New("move failed"), nil}, + expectedCalls: 2, + }, + { + name: "too many errors stops processing", + paths: []string{"test/file1.yaml", "test/file2.yaml", "test/file3.yaml"}, + moveResults: []error{errors.New("move failed")}, + tooManyErrors: errors.New("too many errors"), + expectedError: "too many errors", + expectedCalls: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRepo := &mockReaderWriter{ + MockRepository: repository.NewMockRepository(t), + } + mockProgress := jobs.NewMockJobProgressRecorder(t) + + opts := provisioning.MoveJobOptions{ + TargetPath: "new/location/", + Ref: "main", + } + + for i, path := range tt.paths { + if i < len(tt.moveResults) { + // Use the same logic as constructTargetPath to build expected target + expectedTarget := "new/location/" + filepath.Base(path) + if safepath.IsDir(path) { + expectedTarget += "/" + } + mockRepo.On("Move", mock.Anything, path, expectedTarget, "main", "Move "+path+" to "+expectedTarget).Return(tt.moveResults[i]).Once() + mockProgress.On("SetMessage", mock.Anything, "Moving "+path+" to "+expectedTarget).Return().Once() + mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Path == path && result.Action == repository.FileActionRenamed + })).Return().Once() + + if tt.tooManyErrors != nil && i == 0 { + mockProgress.On("TooManyErrors").Return(tt.tooManyErrors).Once() + } else { + mockProgress.On("TooManyErrors").Return(nil).Once() + } + } + } + + worker := NewWorker(nil, nil, nil) + err := worker.moveFiles(context.Background(), mockRepo, mockProgress, opts, tt.paths...) + + if tt.expectedError != "" { + require.EqualError(t, err, tt.expectedError) + } else { + require.NoError(t, err) + } + + mockRepo.AssertExpectations(t) + mockProgress.AssertExpectations(t) + }) + } +} + +func TestMoveWorker_constructTargetPath(t *testing.T) { + tests := []struct { + name string + jobTargetPath string + sourcePath string + expectedTarget string + }{ + { + name: "simple file in directory", + jobTargetPath: "moved/", + sourcePath: "dashboard.json", + expectedTarget: "moved/dashboard.json", + }, + { + name: "nested file in directory", + jobTargetPath: "archived/", + sourcePath: "folder/dashboard.json", + expectedTarget: "archived/dashboard.json", + }, + { + name: "deeply nested directory target", + jobTargetPath: "deep/nested/target/", + sourcePath: "source/file.yaml", + expectedTarget: "deep/nested/target/file.yaml", + }, + { + name: "folder to folder", + jobTargetPath: "new-location/", + sourcePath: "old-folder/", + expectedTarget: "new-location/old-folder/", + }, + { + name: "nested folder move", + jobTargetPath: "archive/", + sourcePath: "deep/nested/folder/", + expectedTarget: "archive/folder/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + worker := NewWorker(nil, nil, nil) + result := worker.constructTargetPath(tt.jobTargetPath, tt.sourcePath) + require.Equal(t, tt.expectedTarget, result) + }) + } +} + +func TestMoveWorker_ProcessWithResourceReferences(t *testing.T) { + job := provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + Paths: []string{"test/path1"}, + TargetPath: "new/location/", + Resources: []provisioning.ResourceRef{ + { + Name: "dashboard-uid", + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + }, + } + + mockRepo := &mockReaderWriter{ + MockRepository: repository.NewMockRepository(t), + } + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockWrapFn := repository.NewMockWrapWithStageFn(t) + mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) + mockRepoResources := resources.NewMockRepositoryResources(t) + mockSyncWorker := jobs.NewMockWorker(t) + + mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockRepo, false) + }) + + mockProgress.On("SetTotal", mock.Anything, 2).Return() // 1 path + 1 resource + mockProgress.On("StrictMaxErrors", 1).Return() + mockProgress.On("SetMessage", mock.Anything, "Resolving resource paths").Return() + mockProgress.On("SetMessage", mock.Anything, "Finding path for resource dashboard.grafana.app/Dashboard/dashboard-uid").Return() + mockProgress.On("SetMessage", mock.Anything, "Moving test/path1 to new/location/path1").Return() + mockProgress.On("SetMessage", mock.Anything, "Moving dashboard/file.yaml to new/location/file.yaml").Return() + mockProgress.On("TooManyErrors").Return(nil).Times(2) + + mockResourcesFactory.On("Client", mock.Anything, mockRepo).Return(mockRepoResources, nil) + mockRepoResources.On("FindResourcePath", mock.Anything, "dashboard-uid", schema.GroupVersionKind{ + Group: "dashboard.grafana.app", + Kind: "Dashboard", + }).Return("dashboard/file.yaml", nil) + + mockRepo.On("Move", mock.Anything, "test/path1", "new/location/path1", "", "Move test/path1 to new/location/path1").Return(nil) + mockRepo.On("Move", mock.Anything, "dashboard/file.yaml", "new/location/file.yaml", "", "Move dashboard/file.yaml to new/location/file.yaml").Return(nil) + + mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Path == "test/path1" && result.Action == repository.FileActionRenamed && result.Error == nil + })).Return() + mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Path == "dashboard/file.yaml" && result.Action == repository.FileActionRenamed && result.Error == nil + })).Return() + + // Add expectations for sync worker (called when ref is empty) + mockProgress.On("ResetResults").Return() + mockProgress.On("SetMessage", mock.Anything, "pull resources").Return() + mockSyncWorker.On("Process", mock.Anything, mockRepo, mock.MatchedBy(func(syncJob provisioning.Job) bool { + return syncJob.Spec.Pull != nil && !syncJob.Spec.Pull.Incremental + }), mockProgress).Return(nil) + + worker := NewWorker(mockSyncWorker, mockWrapFn.Execute, mockResourcesFactory) + err := worker.Process(context.Background(), mockRepo, job, mockProgress) + require.NoError(t, err) +} + +func TestMoveWorker_ProcessResourceReferencesError(t *testing.T) { + job := provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + TargetPath: "new/location/", + Resources: []provisioning.ResourceRef{ + { + Name: "non-existent-uid", + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + }, + } + + mockRepo := &mockReaderWriter{ + MockRepository: repository.NewMockRepository(t), + } + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockWrapFn := repository.NewMockWrapWithStageFn(t) + mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) + mockRepoResources := resources.NewMockRepositoryResources(t) + + mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockRepo, false) + }) + + mockProgress.On("SetTotal", mock.Anything, 1).Return() // 1 resource + mockProgress.On("StrictMaxErrors", 1).Return() + mockProgress.On("SetMessage", mock.Anything, "Resolving resource paths").Return() + mockProgress.On("SetMessage", mock.Anything, "Finding path for resource dashboard.grafana.app/Dashboard/non-existent-uid").Return() + mockProgress.On("TooManyErrors").Return(nil) + + mockResourcesFactory.On("Client", mock.Anything, mockRepo).Return(mockRepoResources, nil) + resourceError := errors.New("resource not found") + mockRepoResources.On("FindResourcePath", mock.Anything, "non-existent-uid", schema.GroupVersionKind{ + Group: "dashboard.grafana.app", + Kind: "Dashboard", + }).Return("", resourceError) + + mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Name == "non-existent-uid" && result.Group == "dashboard.grafana.app" && + result.Action == repository.FileActionRenamed && + result.Error != nil && result.Error.Error() == "find path for resource dashboard.grafana.app/Dashboard/non-existent-uid: resource not found" + })).Return() + + // Add expectations for sync worker (called when ref is empty) + mockSyncWorker := jobs.NewMockWorker(t) + mockProgress.On("ResetResults").Return() + mockProgress.On("SetMessage", mock.Anything, "pull resources").Return() + mockSyncWorker.On("Process", mock.Anything, mockRepo, mock.MatchedBy(func(syncJob provisioning.Job) bool { + return syncJob.Spec.Pull != nil && !syncJob.Spec.Pull.Incremental + }), mockProgress).Return(nil) + + worker := NewWorker(mockSyncWorker, mockWrapFn.Execute, mockResourcesFactory) + err := worker.Process(context.Background(), mockRepo, job, mockProgress) + require.NoError(t, err) // Should continue despite individual resource errors +} + +func TestMoveWorker_ProcessResourcesFactoryError(t *testing.T) { + job := provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + TargetPath: "new/location/", + Resources: []provisioning.ResourceRef{ + { + Name: "dashboard-uid", + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + }, + } + + mockRepo := &mockReaderWriter{ + MockRepository: repository.NewMockRepository(t), + } + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockWrapFn := repository.NewMockWrapWithStageFn(t) + mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) + + mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockRepo, false) + }) + + mockProgress.On("SetTotal", mock.Anything, 1).Return() // 1 resource + mockProgress.On("StrictMaxErrors", 1).Return() + mockProgress.On("SetMessage", mock.Anything, "Resolving resource paths").Return() + + factoryError := errors.New("failed to create resources client") + mockResourcesFactory.On("Client", mock.Anything, mockRepo).Return(nil, factoryError) + + worker := NewWorker(nil, mockWrapFn.Execute, mockResourcesFactory) + err := worker.Process(context.Background(), mockRepo, job, mockProgress) + require.EqualError(t, err, "move files in repository: create repository resources client: failed to create resources client") +} + +func TestMoveWorker_resolveResourcesToPaths(t *testing.T) { + tests := []struct { + name string + resources []provisioning.ResourceRef + resourcePaths map[string]string + resourceErrors map[string]error + tooManyErrors error + expectedPaths []string + expectedError string + expectContinue bool + }{ + { + name: "single resource success", + resources: []provisioning.ResourceRef{ + { + Name: "dashboard-uid", + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + resourcePaths: map[string]string{ + "dashboard-uid": "dashboards/test.json", + }, + expectedPaths: []string{"dashboards/test.json"}, + }, + { + name: "multiple resources success", + resources: []provisioning.ResourceRef{ + { + Name: "dashboard1", + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + { + Name: "dashboard2", + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + resourcePaths: map[string]string{ + "dashboard1": "dashboards/dash1.json", + "dashboard2": "dashboards/dash2.json", + }, + expectedPaths: []string{"dashboards/dash1.json", "dashboards/dash2.json"}, + }, + { + name: "resource not found continues", + resources: []provisioning.ResourceRef{ + { + Name: "non-existent", + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + { + Name: "existing", + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + resourcePaths: map[string]string{ + "existing": "dashboards/existing.json", + }, + resourceErrors: map[string]error{ + "non-existent": errors.New("not found"), + }, + expectedPaths: []string{"dashboards/existing.json"}, + expectContinue: true, + }, + { + name: "empty resources list", + resources: []provisioning.ResourceRef{}, + expectedPaths: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRepo := &mockReaderWriter{ + MockRepository: repository.NewMockRepository(t), + } + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) + mockRepoResources := resources.NewMockRepositoryResources(t) + + if len(tt.resources) > 0 { + mockProgress.On("SetMessage", mock.Anything, "Resolving resource paths").Return() + mockResourcesFactory.On("Client", mock.Anything, mockRepo).Return(mockRepoResources, nil) + + for _, resource := range tt.resources { + mockProgress.On("SetMessage", mock.Anything, fmt.Sprintf("Finding path for resource %s/%s/%s", resource.Group, resource.Kind, resource.Name)).Return() + + gvk := schema.GroupVersionKind{ + Group: resource.Group, + Kind: resource.Kind, + } + + if path, ok := tt.resourcePaths[resource.Name]; ok { + mockRepoResources.On("FindResourcePath", mock.Anything, resource.Name, gvk).Return(path, nil) + } else if err, ok := tt.resourceErrors[resource.Name]; ok { + mockRepoResources.On("FindResourcePath", mock.Anything, resource.Name, gvk).Return("", err) + mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Name == resource.Name && result.Group == resource.Group && + result.Action == repository.FileActionRenamed && result.Error != nil + })).Return() + if tt.tooManyErrors != nil { + mockProgress.On("TooManyErrors").Return(tt.tooManyErrors) + } else { + mockProgress.On("TooManyErrors").Return(nil) + } + } + } + } + + worker := NewWorker(nil, nil, mockResourcesFactory) + paths, err := worker.resolveResourcesToPaths(context.Background(), mockRepo, mockProgress, tt.resources) + + if tt.expectedError != "" { + require.EqualError(t, err, tt.expectedError) + } else { + require.NoError(t, err) + } + + require.Equal(t, tt.expectedPaths, paths) + + mockResourcesFactory.AssertExpectations(t) + if len(tt.resources) > 0 { + mockRepoResources.AssertExpectations(t) + } + mockProgress.AssertExpectations(t) + }) + } +} + +func TestMoveWorker_deduplicatePaths(t *testing.T) { + tests := []struct { + name string + input []string + expected []string + }{ + { + name: "no duplicates", + input: []string{"path1", "path2", "path3"}, + expected: []string{"path1", "path2", "path3"}, + }, + { + name: "with duplicates", + input: []string{"path1", "path2", "path1", "path3", "path2"}, + expected: []string{"path1", "path2", "path3"}, + }, + { + name: "empty slice", + input: []string{}, + expected: []string{}, + }, + { + name: "single item", + input: []string{"path1"}, + expected: []string{"path1"}, + }, + { + name: "all duplicates", + input: []string{"path1", "path1", "path1"}, + expected: []string{"path1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := deduplicatePaths(tt.input) + require.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index be4cf583824..9bccd675dd5 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -46,6 +46,7 @@ import ( deletepkg "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/delete" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/export" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/migrate" + movepkg "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/move" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/sync" "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/git" @@ -617,11 +618,13 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH b.storageStatus, ) - deleteWorker := deletepkg.NewWorker(syncWorker, stageIfPossible) + deleteWorker := deletepkg.NewWorker(syncWorker, stageIfPossible, b.repositoryResources) + moveWorker := movepkg.NewWorker(syncWorker, stageIfPossible, b.repositoryResources) workers := []jobs.Worker{ deleteWorker, exportWorker, migrationWorker, + moveWorker, syncWorker, } diff --git a/pkg/registry/apis/provisioning/repository/git/staged.go b/pkg/registry/apis/provisioning/repository/git/staged.go index 335193716b0..a3f79567029 100644 --- a/pkg/registry/apis/provisioning/repository/git/staged.go +++ b/pkg/registry/apis/provisioning/repository/git/staged.go @@ -91,22 +91,37 @@ func (r *stagedGitRepository) ReadTree(ctx context.Context, ref string) ([]repos return r.gitRepository.ReadTree(ctx, ref) } -// handleCommitAndPush handles the commit and push logic based on the StageMode +// handleCommitAndPush handles the commit and push logic based on the StageMode and PushOnWrites flag func (r *stagedGitRepository) handleCommitAndPush(ctx context.Context, message string) error { switch r.opts.Mode { case repository.StageModeCommitOnEach: - return r.commit(ctx, r.writer, message) + if err := r.commit(ctx, r.writer, message); err != nil { + return err + } + // Only push if PushOnWrites is enabled + if r.opts.PushOnWrites { + return r.Push(ctx) + } + return nil case repository.StageModeCommitAndPushOnEach: if err := r.commit(ctx, r.writer, message); err != nil { return err } + // Always push for this mode (explicit push-on-each mode) return r.Push(ctx) case repository.StageModeCommitOnlyOnce: // No immediate commit, will commit on Push return nil default: // Default to StageModeCommitOnEach for backward compatibility - return r.commit(ctx, r.writer, message) + if err := r.commit(ctx, r.writer, message); err != nil { + return err + } + // Only push if PushOnWrites is enabled + if r.opts.PushOnWrites { + return r.Push(ctx) + } + return nil } } @@ -180,6 +195,18 @@ func (r *stagedGitRepository) Delete(ctx context.Context, path, ref, message str return r.handleCommitAndPush(ctx, message) } +func (r *stagedGitRepository) Move(ctx context.Context, oldPath, newPath, ref, message string) error { + if !r.isRefSupported(ref) { + return errors.New("ref is not supported for staged repository") + } + + if err := r.move(ctx, oldPath, newPath, r.writer); err != nil { + return err + } + + return r.handleCommitAndPush(ctx, message) +} + func (r *stagedGitRepository) Push(ctx context.Context) error { if r.opts.Timeout > 0 { var cancel context.CancelFunc diff --git a/pkg/registry/apis/provisioning/repository/git/staged_test.go b/pkg/registry/apis/provisioning/repository/git/staged_test.go index 0a614b94c57..4501a087e7a 100644 --- a/pkg/registry/apis/provisioning/repository/git/staged_test.go +++ b/pkg/registry/apis/provisioning/repository/git/staged_test.go @@ -1028,6 +1028,319 @@ func TestStagedGitRepository_Push(t *testing.T) { } } +func TestStagedGitRepository_Move(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeStagedWriter) + opts repository.StageOptions + oldPath string + newPath string + ref string + message string + wantError error + expectPush bool + expectCommit bool + }{ + { + name: "succeeds with file move and CommitOnEach with PushOnWrites false", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.MoveTreeReturns(hash.Hash{1, 2, 3}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + }, + opts: repository.StageOptions{ + Mode: repository.StageModeCommitOnEach, + PushOnWrites: false, + }, + oldPath: "folder/", + newPath: "newfolder/", + ref: "", + message: "Move folder to newfolder", + wantError: nil, + expectPush: false, + expectCommit: true, + }, + { + name: "succeeds with file move and CommitOnEach with PushOnWrites true", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.MoveBlobReturns(hash.Hash{1, 2, 3}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(nil) + }, + opts: repository.StageOptions{ + Mode: repository.StageModeCommitOnEach, + PushOnWrites: true, + }, + oldPath: "test.yaml", + newPath: "newtest.yaml", + ref: "", + message: "Move test to newtest", + wantError: nil, + expectPush: true, + expectCommit: true, + }, + { + name: "succeeds with CommitAndPushOnEach mode", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.MoveBlobReturns(hash.Hash{1, 2, 3}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(nil) + }, + opts: repository.StageOptions{ + Mode: repository.StageModeCommitAndPushOnEach, + PushOnWrites: false, // Should be ignored in this mode + }, + oldPath: "test.yaml", + newPath: "newtest.yaml", + ref: "", + message: "Move test to newtest", + wantError: nil, + expectPush: true, + expectCommit: true, + }, + { + name: "succeeds with CommitOnlyOnce mode", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.MoveBlobReturns(hash.Hash{1, 2, 3}, nil) + }, + opts: repository.StageOptions{ + Mode: repository.StageModeCommitOnlyOnce, + PushOnWrites: true, // Should be ignored in this mode + }, + oldPath: "test.yaml", + newPath: "newtest.yaml", + ref: "", + message: "Move test to newtest", + wantError: nil, + expectPush: false, + expectCommit: false, + }, + { + name: "fails with unsupported ref", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + // No setup needed as error occurs before writer calls + }, + opts: repository.StageOptions{ + Mode: repository.StageModeCommitOnEach, + }, + oldPath: "test.yaml", + newPath: "newtest.yaml", + ref: "feature-branch", + message: "Move test to newtest", + wantError: errors.New("ref is not supported for staged repository"), + }, + { + name: "fails with move error", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.MoveBlobReturns(hash.Hash{}, errors.New("move failed")) + }, + opts: repository.StageOptions{ + Mode: repository.StageModeCommitOnEach, + }, + oldPath: "test.yaml", + newPath: "newtest.yaml", + ref: "", + message: "Move test to newtest", + wantError: errors.New("move blob: move failed"), + }, + { + name: "fails with commit error", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.MoveBlobReturns(hash.Hash{1, 2, 3}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, errors.New("commit failed")) + }, + opts: repository.StageOptions{ + Mode: repository.StageModeCommitOnEach, + }, + oldPath: "test.yaml", + newPath: "newtest.yaml", + ref: "", + message: "Move test to newtest", + wantError: errors.New("commit changes: commit failed"), + }, + { + name: "fails with push error", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.MoveBlobReturns(hash.Hash{1, 2, 3}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(errors.New("push failed")) + }, + opts: repository.StageOptions{ + Mode: repository.StageModeCommitAndPushOnEach, + PushOnWrites: false, + }, + oldPath: "test.yaml", + newPath: "newtest.yaml", + ref: "", + message: "Move test to newtest", + wantError: errors.New("push failed"), + expectPush: true, + expectCommit: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockWriter := &mocks.FakeStagedWriter{} + tt.setupMock(mockWriter) + + stagedRepo := createTestStagedRepositoryWithWriter(mockWriter, tt.opts) + + err := stagedRepo.Move(context.Background(), tt.oldPath, tt.newPath, tt.ref, tt.message) + + if tt.wantError != nil { + require.EqualError(t, err, tt.wantError.Error()) + } else { + require.NoError(t, err) + } + + // Verify push behavior + if tt.expectPush { + require.Equal(t, 1, mockWriter.PushCallCount()) + } else if tt.wantError == nil { + require.Equal(t, 0, mockWriter.PushCallCount()) + } + + // Verify commit behavior + if tt.expectCommit { + require.Equal(t, 1, mockWriter.CommitCallCount()) + } else if tt.wantError == nil { + require.Equal(t, 0, mockWriter.CommitCallCount()) + } + }) + } +} + +func TestStagedGitRepository_handleCommitAndPush(t *testing.T) { + tests := []struct { + name string + opts repository.StageOptions + setupMock func(*mocks.FakeStagedWriter) + message string + wantError error + expectCommit bool + expectPush bool + }{ + { + name: "StageModeCommitOnEach with PushOnWrites false", + opts: repository.StageOptions{ + Mode: repository.StageModeCommitOnEach, + PushOnWrites: false, + }, + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + }, + message: "test message", + wantError: nil, + expectCommit: true, + expectPush: false, + }, + { + name: "StageModeCommitOnEach with PushOnWrites true", + opts: repository.StageOptions{ + Mode: repository.StageModeCommitOnEach, + PushOnWrites: true, + }, + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(nil) + }, + message: "test message", + wantError: nil, + expectCommit: true, + expectPush: true, + }, + { + name: "StageModeCommitAndPushOnEach always pushes regardless of PushOnWrites", + opts: repository.StageOptions{ + Mode: repository.StageModeCommitAndPushOnEach, + PushOnWrites: false, // Should be ignored + }, + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(nil) + }, + message: "test message", + wantError: nil, + expectCommit: true, + expectPush: true, + }, + { + name: "StageModeCommitOnlyOnce does nothing", + opts: repository.StageOptions{ + Mode: repository.StageModeCommitOnlyOnce, + PushOnWrites: true, // Should be ignored + }, + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + // No setup needed as no calls should be made + }, + message: "test message", + wantError: nil, + expectCommit: false, + expectPush: false, + }, + { + name: "Default mode (backward compatibility) with PushOnWrites false", + opts: repository.StageOptions{ + Mode: repository.StageMode(99), // Unknown mode defaults to StageModeCommitOnEach + PushOnWrites: false, + }, + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + }, + message: "test message", + wantError: nil, + expectCommit: true, + expectPush: false, + }, + { + name: "Default mode (backward compatibility) with PushOnWrites true", + opts: repository.StageOptions{ + Mode: repository.StageMode(99), // Unknown mode defaults to StageModeCommitOnEach + PushOnWrites: true, + }, + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(nil) + }, + message: "test message", + wantError: nil, + expectCommit: true, + expectPush: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockWriter := &mocks.FakeStagedWriter{} + tt.setupMock(mockWriter) + + stagedRepo := createTestStagedRepositoryWithWriter(mockWriter, tt.opts) + + err := stagedRepo.handleCommitAndPush(context.Background(), tt.message) + + if tt.wantError != nil { + require.EqualError(t, err, tt.wantError.Error()) + } else { + require.NoError(t, err) + } + + // Verify commit behavior + if tt.expectCommit { + require.Equal(t, 1, mockWriter.CommitCallCount()) + } else { + require.Equal(t, 0, mockWriter.CommitCallCount()) + } + + // Verify push behavior + if tt.expectPush { + require.Equal(t, 1, mockWriter.PushCallCount()) + } else { + require.Equal(t, 0, mockWriter.PushCallCount()) + } + }) + } +} + func TestStagedGitRepository_Remove(t *testing.T) { t.Run("succeeds with remove", func(t *testing.T) { mockWriter := &mocks.FakeStagedWriter{} diff --git a/pkg/registry/apis/provisioning/resources/client.go b/pkg/registry/apis/provisioning/resources/client.go index afc5261b2e8..e758eaf32ff 100644 --- a/pkg/registry/apis/provisioning/resources/client.go +++ b/pkg/registry/apis/provisioning/resources/client.go @@ -12,7 +12,7 @@ import ( dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" dashboardV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" - dashboardV2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" + dashboardV2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" iam "github.com/grafana/grafana/pkg/apis/iam/v0alpha1" "github.com/grafana/grafana/pkg/services/apiserver" @@ -24,7 +24,7 @@ var ( FolderResource = folders.FolderResourceInfo.GroupVersionResource() DashboardResource = dashboardV1.DashboardResourceInfo.GroupVersionResource() DashboardResourceV2alpha1 = dashboardV2alpha1.DashboardResourceInfo.GroupVersionResource() - DashboardResourceV2alpha2 = dashboardV2alpha2.DashboardResourceInfo.GroupVersionResource() + DashboardResourceV2beta1 = dashboardV2beta1.DashboardResourceInfo.GroupVersionResource() // SupportedProvisioningResources is the list of resources that can fully managed from the UI SupportedProvisioningResources = []schema.GroupVersionResource{FolderResource, DashboardResource} @@ -107,6 +107,9 @@ type clientInfo struct { client dynamic.ResourceInterface } +// ForKind returns a client for a kind. +// If the kind has a version, it will be used. +// If the kind does not have a version, the preferred version will be used. func (c *resourceClients) ForKind(gvk schema.GroupVersionKind) (dynamic.ResourceInterface, schema.GroupVersionResource, error) { c.mutex.Lock() defer c.mutex.Unlock() @@ -116,9 +119,29 @@ func (c *resourceClients) ForKind(gvk schema.GroupVersionKind) (dynamic.Resource return info.client, info.gvr, nil } - gvr, err := c.discovery.GetResourceForKind(gvk) - if err != nil { - return nil, schema.GroupVersionResource{}, err + var err error + var gvr schema.GroupVersionResource + var versionless schema.GroupVersionKind + if gvk.Version == "" { + versionless = gvk + gvr, gvk, err = c.discovery.GetPreferredVersionForKind(schema.GroupKind{ + Group: gvk.Group, + Kind: gvk.Kind, + }) + if err != nil { + return nil, schema.GroupVersionResource{}, err + } + + info, ok := c.byKind[gvk] + if ok && info.client != nil { + c.byKind[versionless] = info + return info.client, info.gvr, nil + } + } else { + gvr, err = c.discovery.GetResourceForKind(gvk) + if err != nil { + return nil, schema.GroupVersionResource{}, err + } } info = &clientInfo{ gvk: gvk, @@ -127,6 +150,9 @@ func (c *resourceClients) ForKind(gvk schema.GroupVersionKind) (dynamic.Resource } c.byKind[gvk] = info c.byResource[gvr] = info + if versionless.Group != "" { + c.byKind[versionless] = info + } return info.client, info.gvr, nil } diff --git a/pkg/registry/apis/provisioning/resources/repository.go b/pkg/registry/apis/provisioning/resources/repository.go index 44bfe42f74e..d12747b7bc8 100644 --- a/pkg/registry/apis/provisioning/resources/repository.go +++ b/pkg/registry/apis/provisioning/resources/repository.go @@ -4,11 +4,15 @@ import ( "context" "fmt" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" + "github.com/grafana/grafana/pkg/apimachinery/utils" provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath" ) //go:generate mockery --name RepositoryResourcesFactory --structname MockRepositoryResourcesFactory --inpackage --filename repository_resources_factory_mock.go --with-expecter @@ -28,6 +32,7 @@ type RepositoryResources interface { // Resource from file WriteResourceFromFile(ctx context.Context, path, ref string) (string, schema.GroupVersionKind, error) RemoveResourceFromFile(ctx context.Context, path, ref string) (string, schema.GroupVersionKind, error) + FindResourcePath(ctx context.Context, name string, gvk schema.GroupVersionKind) (string, error) RenameResourceFile(ctx context.Context, path, previousRef, newPath, newRef string) (string, schema.GroupVersionKind, error) // Stats Stats(ctx context.Context) (*provisioning.ResourceStats, error) @@ -55,6 +60,42 @@ func (r *repositoryResources) List(ctx context.Context) (*provisioning.ResourceL return r.lister.List(ctx, r.namespace, r.repoName) } +// FindResourcePath finds the repository file path for a resource by its name and GroupVersionKind +func (r *repositoryResources) FindResourcePath(ctx context.Context, name string, gvk schema.GroupVersionKind) (string, error) { + // Use ForKind to get the dynamic client for this resource type + client, gvr, err := r.clients.ForKind(gvk) + if err != nil { + return "", fmt.Errorf("get client for kind %s: %w", gvk.Kind, err) + } + + // Get the specific resource by name using the dynamic client (already namespaced) + obj, err := client.Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + return "", fmt.Errorf("resource not found: %s/%s/%s", gvr.Group, gvr.Resource, name) + } + return "", fmt.Errorf("failed to get resource %s/%s/%s: %w", gvr.Group, gvr.Resource, name, err) + } + + // Extract the source path from annotations + annotations := obj.GetAnnotations() + if annotations == nil { + return "", fmt.Errorf("resource %s/%s/%s has no annotations", gvr.Group, gvr.Resource, name) + } + + sourcePath, exists := annotations[utils.AnnoKeySourcePath] + if !exists || sourcePath == "" { + return "", fmt.Errorf("resource %s/%s/%s has no source path annotation", gvr.Group, gvr.Resource, name) + } + + // For folder resources, ensure the path has a trailing slash for proper deletion + if gvk.Kind == "Folder" && !safepath.IsDir(sourcePath) { + sourcePath = sourcePath + "/" + } + + return sourcePath, nil +} + func NewRepositoryResourcesFactory(parsers ParserFactory, clients ClientFactory, lister ResourceLister) RepositoryResourcesFactory { return &repositoryResourcesFactory{parsers, clients, lister} } diff --git a/pkg/registry/apis/provisioning/resources/repository_resources_factory_mock.go b/pkg/registry/apis/provisioning/resources/repository_resources_factory_mock.go index 9bf9473d180..3e55762e261 100644 --- a/pkg/registry/apis/provisioning/resources/repository_resources_factory_mock.go +++ b/pkg/registry/apis/provisioning/resources/repository_resources_factory_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.4. DO NOT EDIT. +// Code generated by mockery v2.52.4. DO NOT EDIT. package resources diff --git a/pkg/registry/apis/provisioning/resources/repository_resources_mock.go b/pkg/registry/apis/provisioning/resources/repository_resources_mock.go index 7e11d9b9eda..9ff7171f68c 100644 --- a/pkg/registry/apis/provisioning/resources/repository_resources_mock.go +++ b/pkg/registry/apis/provisioning/resources/repository_resources_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.4. DO NOT EDIT. +// Code generated by mockery v2.52.4. DO NOT EDIT. package resources @@ -181,6 +181,64 @@ func (_c *MockRepositoryResources_EnsureFolderTreeExists_Call) RunAndReturn(run return _c } +// FindResourcePath provides a mock function with given fields: ctx, name, gvk +func (_m *MockRepositoryResources) FindResourcePath(ctx context.Context, name string, gvk schema.GroupVersionKind) (string, error) { + ret := _m.Called(ctx, name, gvk) + + if len(ret) == 0 { + panic("no return value specified for FindResourcePath") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, schema.GroupVersionKind) (string, error)); ok { + return rf(ctx, name, gvk) + } + if rf, ok := ret.Get(0).(func(context.Context, string, schema.GroupVersionKind) string); ok { + r0 = rf(ctx, name, gvk) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context, string, schema.GroupVersionKind) error); ok { + r1 = rf(ctx, name, gvk) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockRepositoryResources_FindResourcePath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindResourcePath' +type MockRepositoryResources_FindResourcePath_Call struct { + *mock.Call +} + +// FindResourcePath is a helper method to define mock.On call +// - ctx context.Context +// - name string +// - gvk schema.GroupVersionKind +func (_e *MockRepositoryResources_Expecter) FindResourcePath(ctx interface{}, name interface{}, gvk interface{}) *MockRepositoryResources_FindResourcePath_Call { + return &MockRepositoryResources_FindResourcePath_Call{Call: _e.mock.On("FindResourcePath", ctx, name, gvk)} +} + +func (_c *MockRepositoryResources_FindResourcePath_Call) Run(run func(ctx context.Context, name string, gvk schema.GroupVersionKind)) *MockRepositoryResources_FindResourcePath_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(schema.GroupVersionKind)) + }) + return _c +} + +func (_c *MockRepositoryResources_FindResourcePath_Call) Return(_a0 string, _a1 error) *MockRepositoryResources_FindResourcePath_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockRepositoryResources_FindResourcePath_Call) RunAndReturn(run func(context.Context, string, schema.GroupVersionKind) (string, error)) *MockRepositoryResources_FindResourcePath_Call { + _c.Call.Return(run) + return _c +} + // List provides a mock function with given fields: ctx func (_m *MockRepositoryResources) List(ctx context.Context) (*v0alpha1.ResourceList, error) { ret := _m.Called(ctx) diff --git a/pkg/registry/apis/provisioning/resources/repository_test.go b/pkg/registry/apis/provisioning/resources/repository_test.go new file mode 100644 index 00000000000..c53512c3c0a --- /dev/null +++ b/pkg/registry/apis/provisioning/resources/repository_test.go @@ -0,0 +1,437 @@ +package resources + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/dynamic" + + "github.com/grafana/grafana/pkg/apimachinery/utils" +) + +func TestRepositoryResources_FindResourcePath(t *testing.T) { + tests := []struct { + name string + resourceName string + gvk schema.GroupVersionKind + expectedGVR schema.GroupVersionResource + forKindError error + getError error + resourceObj *unstructured.Unstructured + expectedPath string + expectedError string + }{ + { + name: "dashboard found successfully", + resourceName: "test-dashboard", + gvk: schema.GroupVersionKind{ + Group: "dashboard.grafana.app", + Kind: "Dashboard", + }, + expectedGVR: schema.GroupVersionResource{ + Group: "dashboard.grafana.app", + Version: "v0alpha1", + Resource: "dashboards", + }, + resourceObj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test-dashboard", + "namespace": "test-namespace", + "annotations": map[string]interface{}{ + utils.AnnoKeySourcePath: "dashboards/test-dashboard.json", + }, + }, + }, + }, + expectedPath: "dashboards/test-dashboard.json", + }, + { + name: "folder found successfully", + resourceName: "test-folder", + gvk: schema.GroupVersionKind{ + Group: "folder.grafana.app", + Kind: "Folder", + }, + expectedGVR: schema.GroupVersionResource{ + Group: "folder.grafana.app", + Version: "v0alpha1", + Resource: "folders", + }, + resourceObj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test-folder", + "namespace": "test-namespace", + "annotations": map[string]interface{}{ + utils.AnnoKeySourcePath: "folders/test-folder", + }, + }, + }, + }, + expectedPath: "folders/test-folder/", // Trailing slash added for folder resources + }, + { + name: "ForKind fails", + resourceName: "test-dashboard", + gvk: schema.GroupVersionKind{ + Group: "dashboard.grafana.app", + Kind: "Dashboard", + }, + forKindError: errors.New("kind not found"), + expectedError: "get client for kind Dashboard: kind not found", + }, + { + name: "resource not found", + resourceName: "nonexistent-dashboard", + gvk: schema.GroupVersionKind{ + Group: "dashboard.grafana.app", + Kind: "Dashboard", + }, + expectedGVR: schema.GroupVersionResource{ + Group: "dashboard.grafana.app", + Version: "v0alpha1", + Resource: "dashboards", + }, + getError: apierrors.NewNotFound(schema.GroupResource{Group: "dashboard.grafana.app", Resource: "dashboards"}, "nonexistent-dashboard"), + expectedError: "resource not found: dashboard.grafana.app/dashboards/nonexistent-dashboard", + }, + { + name: "Get operation fails with other error", + resourceName: "test-dashboard", + gvk: schema.GroupVersionKind{ + Group: "dashboard.grafana.app", + Kind: "Dashboard", + }, + expectedGVR: schema.GroupVersionResource{ + Group: "dashboard.grafana.app", + Version: "v0alpha1", + Resource: "dashboards", + }, + getError: errors.New("internal server error"), + expectedError: "failed to get resource dashboard.grafana.app/dashboards/test-dashboard: internal server error", + }, + { + name: "resource has no annotations", + resourceName: "test-dashboard", + gvk: schema.GroupVersionKind{ + Group: "dashboard.grafana.app", + Kind: "Dashboard", + }, + expectedGVR: schema.GroupVersionResource{ + Group: "dashboard.grafana.app", + Version: "v0alpha1", + Resource: "dashboards", + }, + resourceObj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test-dashboard", + "namespace": "test-namespace", + // No annotations + }, + }, + }, + expectedError: "resource dashboard.grafana.app/dashboards/test-dashboard has no annotations", + }, + { + name: "resource has empty annotations", + resourceName: "test-dashboard", + gvk: schema.GroupVersionKind{ + Group: "dashboard.grafana.app", + Kind: "Dashboard", + }, + expectedGVR: schema.GroupVersionResource{ + Group: "dashboard.grafana.app", + Version: "v0alpha1", + Resource: "dashboards", + }, + resourceObj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test-dashboard", + "namespace": "test-namespace", + "annotations": map[string]interface{}{}, + }, + }, + }, + expectedError: "resource dashboard.grafana.app/dashboards/test-dashboard has no source path annotation", + }, + { + name: "resource has empty source path annotation", + resourceName: "test-dashboard", + gvk: schema.GroupVersionKind{ + Group: "dashboard.grafana.app", + Kind: "Dashboard", + }, + expectedGVR: schema.GroupVersionResource{ + Group: "dashboard.grafana.app", + Version: "v0alpha1", + Resource: "dashboards", + }, + resourceObj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test-dashboard", + "namespace": "test-namespace", + "annotations": map[string]interface{}{ + utils.AnnoKeySourcePath: "", // Empty path + }, + }, + }, + }, + expectedError: "resource dashboard.grafana.app/dashboards/test-dashboard has no source path annotation", + }, + { + name: "resource with nested folder path", + resourceName: "nested-dashboard", + gvk: schema.GroupVersionKind{ + Group: "dashboard.grafana.app", + Kind: "Dashboard", + }, + expectedGVR: schema.GroupVersionResource{ + Group: "dashboard.grafana.app", + Version: "v0alpha1", + Resource: "dashboards", + }, + resourceObj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "nested-dashboard", + "namespace": "test-namespace", + "annotations": map[string]interface{}{ + utils.AnnoKeySourcePath: "team-a/subfolder/nested-dashboard.json", + }, + }, + }, + }, + expectedPath: "team-a/subfolder/nested-dashboard.json", + }, + { + name: "folder without trailing slash gets slash added", + resourceName: "test-folder", + gvk: schema.GroupVersionKind{ + Group: "folder.grafana.app", + Kind: "Folder", + }, + expectedGVR: schema.GroupVersionResource{ + Group: "folder.grafana.app", + Version: "v0alpha1", + Resource: "folders", + }, + resourceObj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test-folder", + "namespace": "test-namespace", + "annotations": map[string]interface{}{ + utils.AnnoKeySourcePath: "folders/test-folder", // No trailing slash + }, + }, + }, + }, + expectedPath: "folders/test-folder/", // Should have trailing slash added + }, + { + name: "folder with trailing slash keeps slash", + resourceName: "test-folder-2", + gvk: schema.GroupVersionKind{ + Group: "folder.grafana.app", + Kind: "Folder", + }, + expectedGVR: schema.GroupVersionResource{ + Group: "folder.grafana.app", + Version: "v0alpha1", + Resource: "folders", + }, + resourceObj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test-folder-2", + "namespace": "test-namespace", + "annotations": map[string]interface{}{ + utils.AnnoKeySourcePath: "folders/test-folder-2/", // Already has trailing slash + }, + }, + }, + }, + expectedPath: "folders/test-folder-2/", // Should keep existing trailing slash + }, + { + name: "non-folder resource keeps path unchanged", + resourceName: "test-dashboard", + gvk: schema.GroupVersionKind{ + Group: "dashboard.grafana.app", + Kind: "Dashboard", // Not a folder + }, + expectedGVR: schema.GroupVersionResource{ + Group: "dashboard.grafana.app", + Version: "v0alpha1", + Resource: "dashboards", + }, + resourceObj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test-dashboard", + "namespace": "test-namespace", + "annotations": map[string]interface{}{ + utils.AnnoKeySourcePath: "dashboards/test-dashboard", // No trailing slash + }, + }, + }, + }, + expectedPath: "dashboards/test-dashboard", // Should remain unchanged for non-folders + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mocks + mockClients := NewMockResourceClients(t) + mockClient := &MockDynamicResourceInterface{} + + // Create repository resources with mocked dependencies + resourcesManager := &ResourcesManager{ + clients: mockClients, + } + + repositoryResources := &repositoryResources{ + ResourcesManager: resourcesManager, + namespace: "test-namespace", + repoName: "test-repo", + } + + // Mock ForKind call + if tt.forKindError != nil { + mockClients.On("ForKind", tt.gvk).Return(nil, schema.GroupVersionResource{}, tt.forKindError) + } else { + mockClients.On("ForKind", tt.gvk).Return(mockClient, tt.expectedGVR, nil) + + // Mock Get call if ForKind succeeds + if tt.getError != nil { + mockClient.On("Get", mock.Anything, tt.resourceName, metav1.GetOptions{}, mock.Anything).Return(nil, tt.getError) + } else { + mockClient.On("Get", mock.Anything, tt.resourceName, metav1.GetOptions{}, mock.Anything).Return(tt.resourceObj, nil) + } + } + + // Execute the method + result, err := repositoryResources.FindResourcePath(context.Background(), tt.resourceName, tt.gvk) + + // Assert results + if tt.expectedError != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedError) + require.Empty(t, result) + } else { + require.NoError(t, err) + require.Equal(t, tt.expectedPath, result) + } + + // Verify all mocks were called as expected + mockClients.AssertExpectations(t) + if tt.forKindError == nil { + mockClient.AssertExpectations(t) + } + }) + } +} + +// MockDynamicResourceInterface is a mock for dynamic.ResourceInterface +type MockDynamicResourceInterface struct { + mock.Mock +} + +func (m *MockDynamicResourceInterface) Create(ctx context.Context, obj *unstructured.Unstructured, options metav1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error) { + args := m.Called(ctx, obj, options, subresources) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*unstructured.Unstructured), args.Error(1) +} + +func (m *MockDynamicResourceInterface) Update(ctx context.Context, obj *unstructured.Unstructured, options metav1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error) { + args := m.Called(ctx, obj, options, subresources) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*unstructured.Unstructured), args.Error(1) +} + +func (m *MockDynamicResourceInterface) UpdateStatus(ctx context.Context, obj *unstructured.Unstructured, options metav1.UpdateOptions) (*unstructured.Unstructured, error) { + args := m.Called(ctx, obj, options) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*unstructured.Unstructured), args.Error(1) +} + +func (m *MockDynamicResourceInterface) Delete(ctx context.Context, name string, options metav1.DeleteOptions, subresources ...string) error { + args := m.Called(ctx, name, options, subresources) + return args.Error(0) +} + +func (m *MockDynamicResourceInterface) DeleteCollection(ctx context.Context, options metav1.DeleteOptions, listOptions metav1.ListOptions) error { + args := m.Called(ctx, options, listOptions) + return args.Error(0) +} + +func (m *MockDynamicResourceInterface) Get(ctx context.Context, name string, options metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) { + args := m.Called(ctx, name, options, subresources) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*unstructured.Unstructured), args.Error(1) +} + +func (m *MockDynamicResourceInterface) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { + args := m.Called(ctx, opts) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*unstructured.UnstructuredList), args.Error(1) +} + +func (m *MockDynamicResourceInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + args := m.Called(ctx, opts) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(watch.Interface), args.Error(1) +} + +func (m *MockDynamicResourceInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, options metav1.PatchOptions, subresources ...string) (*unstructured.Unstructured, error) { + args := m.Called(ctx, name, pt, data, options, subresources) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*unstructured.Unstructured), args.Error(1) +} + +func (m *MockDynamicResourceInterface) Apply(ctx context.Context, name string, obj *unstructured.Unstructured, options metav1.ApplyOptions, subresources ...string) (*unstructured.Unstructured, error) { + args := m.Called(ctx, name, obj, options, subresources) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*unstructured.Unstructured), args.Error(1) +} + +func (m *MockDynamicResourceInterface) ApplyStatus(ctx context.Context, name string, obj *unstructured.Unstructured, options metav1.ApplyOptions) (*unstructured.Unstructured, error) { + args := m.Called(ctx, name, obj, options) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*unstructured.Unstructured), args.Error(1) +} + +// Ensure MockDynamicResourceInterface implements dynamic.ResourceInterface +var _ dynamic.ResourceInterface = (*MockDynamicResourceInterface)(nil) diff --git a/pkg/registry/apis/provisioning/secrets/mocks/decrypt_service_mock.go b/pkg/registry/apis/provisioning/secrets/mocks/decrypt_service_mock.go index 14e2d028689..b5cca7ebe62 100644 --- a/pkg/registry/apis/provisioning/secrets/mocks/decrypt_service_mock.go +++ b/pkg/registry/apis/provisioning/secrets/mocks/decrypt_service_mock.go @@ -22,6 +22,51 @@ func (_m *MockDecryptService) EXPECT() *MockDecryptService_Expecter { return &MockDecryptService_Expecter{mock: &_m.Mock} } +// Close provides a mock function with no fields +func (_m *MockDecryptService) Close() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Close") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockDecryptService_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' +type MockDecryptService_Close_Call struct { + *mock.Call +} + +// Close is a helper method to define mock.On call +func (_e *MockDecryptService_Expecter) Close() *MockDecryptService_Close_Call { + return &MockDecryptService_Close_Call{Call: _e.mock.On("Close")} +} + +func (_c *MockDecryptService_Close_Call) Run(run func()) *MockDecryptService_Close_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDecryptService_Close_Call) Return(_a0 error) *MockDecryptService_Close_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockDecryptService_Close_Call) RunAndReturn(run func() error) *MockDecryptService_Close_Call { + _c.Call.Return(run) + return _c +} + // Decrypt provides a mock function with given fields: ctx, namespace, names func (_m *MockDecryptService) Decrypt(ctx context.Context, namespace string, names ...string) (map[string]contracts.DecryptResult, error) { _va := make([]interface{}, len(names)) diff --git a/pkg/registry/apis/provisioning/secrets/repository.go b/pkg/registry/apis/provisioning/secrets/repository.go index c933d72bf92..29a470dd993 100644 --- a/pkg/registry/apis/provisioning/secrets/repository.go +++ b/pkg/registry/apis/provisioning/secrets/repository.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/services/featuremgmt" legacysecrets "github.com/grafana/grafana/pkg/services/secrets" + "github.com/grafana/grafana/pkg/setting" ) func ProvideRepositorySecrets( @@ -18,8 +19,9 @@ func ProvideRepositorySecrets( legacySecretsSvc legacysecrets.Service, secretsSvc contracts.SecureValueClient, decryptSvc secret.DecryptService, + cfg *setting.Cfg, ) RepositorySecrets { - return NewRepositorySecrets(features, NewSecretsService(secretsSvc, decryptSvc), NewSingleTenant(legacySecretsSvc)) + return NewRepositorySecrets(features, NewSecretsService(secretsSvc, decryptSvc, cfg), NewSingleTenant(legacySecretsSvc)) } //go:generate mockery --name RepositorySecrets --structname MockRepositorySecrets --inpackage --filename repository_secrets_mock.go --with-expecter diff --git a/pkg/registry/apis/provisioning/secrets/secret.go b/pkg/registry/apis/provisioning/secrets/secret.go index de3e43deb63..9127e7b7868 100644 --- a/pkg/registry/apis/provisioning/secrets/secret.go +++ b/pkg/registry/apis/provisioning/secrets/secret.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/registry/apis/secret" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/setting" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -31,12 +32,14 @@ var _ Service = (*secretsService)(nil) type secretsService struct { secureValues SecureValueClient decryptSvc secret.DecryptService + cfg *setting.Cfg } -func NewSecretsService(secretsSvc SecureValueClient, decryptSvc secret.DecryptService) Service { +func NewSecretsService(secretsSvc SecureValueClient, decryptSvc secret.DecryptService, cfg *setting.Cfg) Service { return &secretsService{ secureValues: secretsSvc, decryptSvc: decryptSvc, + cfg: cfg, } } @@ -74,6 +77,11 @@ func (s *secretsService) Encrypt(ctx context.Context, namespace, name string, da return result.GetName(), nil } + decrypters := []string{svcName} + if s.cfg.SecretsManagement.DecryptGrafanaServiceName != "" { + decrypters = append(decrypters, s.cfg.SecretsManagement.DecryptGrafanaServiceName) + } + // Create the secret directly as unstructured secret := &unstructured.Unstructured{ Object: map[string]interface{}{ @@ -86,7 +94,7 @@ func (s *secretsService) Encrypt(ctx context.Context, namespace, name string, da "spec": map[string]interface{}{ "description": "provisioning: " + name, "value": data, - "decrypters": []string{svcName}, + "decrypters": decrypters, }, }, } diff --git a/pkg/registry/apis/provisioning/secrets/secret_test.go b/pkg/registry/apis/provisioning/secrets/secret_test.go index 25b71bef1ad..e393ac7d8dd 100644 --- a/pkg/registry/apis/provisioning/secrets/secret_test.go +++ b/pkg/registry/apis/provisioning/secrets/secret_test.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets/mocks" "github.com/grafana/grafana/pkg/registry/apis/secret" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -50,7 +51,7 @@ func TestNewSecretsService(t *testing.T) { mockSecretsSvc := NewMockSecureValueClient(t) mockDecryptSvc := &mocks.MockDecryptService{} - svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc) + svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, &setting.Cfg{}) assert.NotNil(t, svc) assert.IsType(t, &secretsService{}, svc) @@ -208,7 +209,7 @@ func TestSecretsService_Encrypt(t *testing.T) { tt.setupMocks(mockSecretsSvc, mockDecryptSvc, mockResourceInterface) - svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc) + svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, &setting.Cfg{}) ctx := context.Background() @@ -232,7 +233,7 @@ func TestSecretsService_Encrypt_ClientError(t *testing.T) { // Setup client to return error mockSecretsSvc.EXPECT().Client(mock.Anything, "test-namespace").Return(nil, errors.New("client error")) - svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc) + svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, &setting.Cfg{}) ctx := context.Background() @@ -331,7 +332,7 @@ func TestSecretsService_Decrypt(t *testing.T) { tt.setupMocks(mockSecretsSvc, mockDecryptSvc) - svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc) + svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, &setting.Cfg{}) ctx := context.Background() @@ -368,7 +369,7 @@ func TestSecretsService_Decrypt_ServiceIdentityContext(t *testing.T) { "test-secret": mockResult, }, nil) - svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc) + svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, &setting.Cfg{}) ctx := context.Background() result, err := svc.Decrypt(ctx, "test-namespace", "test-secret") @@ -430,7 +431,7 @@ func TestSecretsService_Delete(t *testing.T) { tt.setupMocks(mockSecretsSvc, mockDecryptSvc, mockResourceInterface) - svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc) + svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, &setting.Cfg{}) ctx := context.Background() err := svc.Delete(ctx, tt.namespace, tt.secretName) @@ -515,7 +516,7 @@ func TestSecretsService_Encrypt_WithK8sNotFoundError(t *testing.T) { } mockResourceInterface.createErr = nil - svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc) + svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, &setting.Cfg{}) ctx := context.Background() result, err := svc.Encrypt(ctx, "test-namespace", "test-secret", "secret-data") @@ -536,7 +537,7 @@ func TestSecretsService_Delete_WithK8sNotFoundError(t *testing.T) { k8sNotFoundErr := apierrors.NewNotFound(schema.GroupResource{Group: "secret.grafana.app", Resource: "securevalues"}, "test-secret") mockResourceInterface.deleteErr = k8sNotFoundErr - svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc) + svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, &setting.Cfg{}) ctx := context.Background() err := svc.Delete(ctx, "test-namespace", "test-secret") @@ -557,7 +558,7 @@ func TestSecretsService_Delete_WithGrafanaNotFoundError(t *testing.T) { // Mock Delete call to return Grafana not found error mockResourceInterface.deleteErr = contracts.ErrSecureValueNotFound - svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc) + svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, &setting.Cfg{}) ctx := context.Background() err := svc.Delete(ctx, "test-namespace", "test-secret") diff --git a/pkg/registry/apis/provisioning/secrets/secure_value_client_mock.go b/pkg/registry/apis/provisioning/secrets/secure_value_client_mock.go index b197c783e07..e00d66395ef 100644 --- a/pkg/registry/apis/provisioning/secrets/secure_value_client_mock.go +++ b/pkg/registry/apis/provisioning/secrets/secure_value_client_mock.go @@ -5,8 +5,9 @@ package secrets import ( context "context" - mock "github.com/stretchr/testify/mock" dynamic "k8s.io/client-go/dynamic" + + mock "github.com/stretchr/testify/mock" ) // MockSecureValueClient is an autogenerated mock type for the SecureValueClient type 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 9c0d8e8eb2b..2c5403c790d 100644 --- a/pkg/registry/apis/secret/contracts/decrypt.go +++ b/pkg/registry/apis/secret/contracts/decrypt.go @@ -8,6 +8,9 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" ) +// HeaderGrafanaServiceIdentityName is used to pass the service identity in the gRPC request metadata. +const HeaderGrafanaServiceIdentityName = "X-Grafana-Service-Identity-Name" + var ( ErrDecryptNotFound = errors.New("not found") ErrDecryptNotAuthorized = errors.New("not authorized") @@ -24,9 +27,10 @@ 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 } // DecryptResult is the (union) result of a decryption operation. 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..4f677fa5c79 --- /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 a secret in the context of a given resource (owner) + CanReference(ctx context.Context, owner common.ObjectReference, values common.InlineSecureValues) 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/decrypt/grpc_client.go b/pkg/registry/apis/secret/decrypt/grpc_client.go new file mode 100644 index 00000000000..9851eec4526 --- /dev/null +++ b/pkg/registry/apis/secret/decrypt/grpc_client.go @@ -0,0 +1,178 @@ +package decrypt + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "os" + + "github.com/fullstorydev/grpchan" + authnlib "github.com/grafana/authlib/authn" + claims "github.com/grafana/authlib/types" + "go.opentelemetry.io/otel/trace" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + + decryptv1beta1 "github.com/grafana/grafana/apps/secret/decrypt/v1beta1" + secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" +) + +type GRPCDecryptClient struct { + conn *grpc.ClientConn + client decryptv1beta1.SecureValueDecrypterClient +} + +var _ contracts.DecryptService = &GRPCDecryptClient{} + +type TLSConfig struct { + UseTLS bool + CertFile string + KeyFile string + CAFile string + ServerName string + InsecureSkipVerify bool +} + +func NewGRPCDecryptClient(tokenExchanger authnlib.TokenExchanger, tracer trace.Tracer, namespace, address string) (*GRPCDecryptClient, error) { + return NewGRPCDecryptClientWithTLS(tokenExchanger, tracer, namespace, address, TLSConfig{}) +} + +func NewGRPCDecryptClientWithTLS( + tokenExchanger authnlib.TokenExchanger, + tracer trace.Tracer, + namespace string, + address string, + tlsConfig TLSConfig, +) (*GRPCDecryptClient, error) { + var opts []grpc.DialOption + if tlsConfig.UseTLS { + creds, err := createTLSCredentials(tlsConfig) + if err != nil { + return nil, fmt.Errorf("failed to setup TLS: %w", err) + } + + opts = append(opts, grpc.WithTransportCredentials(creds)) + } else { + opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials())) + } + + conn, err := grpc.NewClient(address, opts...) + if err != nil { + return nil, fmt.Errorf("failed to connect to grpc decrypt server at %s: %w", address, err) + } + + tokenExchangerInterceptor := authnlib.NewGrpcClientInterceptor( + tokenExchanger, + authnlib.WithClientInterceptorTracer(tracer), + authnlib.WithClientInterceptorNamespace(namespace), + authnlib.WithClientInterceptorAudience([]string{secretv1beta1.APIGroup}), + ) + + clientConn := grpchan.InterceptClientConn( + conn, + tokenExchangerInterceptor.UnaryClientInterceptor, + tokenExchangerInterceptor.StreamClientInterceptor, + ) + + return &GRPCDecryptClient{ + conn: conn, + client: decryptv1beta1.NewSecureValueDecrypterClient(clientConn), + }, nil +} + +func createTLSCredentials(config TLSConfig) (credentials.TransportCredentials, error) { + tlsConfig := &tls.Config{} + + if config.CAFile != "" { + caCert, err := os.ReadFile(config.CAFile) + if err != nil { + return nil, fmt.Errorf("failed to read CA: %w", err) + } + + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + return nil, fmt.Errorf("failed to append CA") + } + tlsConfig.RootCAs = caCertPool + } + + if config.CertFile != "" && config.KeyFile != "" { + cert, err := tls.LoadX509KeyPair(config.CertFile, config.KeyFile) + if err != nil { + return nil, fmt.Errorf("failed to load client certificate: %w", err) + } + tlsConfig.Certificates = []tls.Certificate{cert} + } + + if config.ServerName != "" { + tlsConfig.ServerName = config.ServerName + } + + if config.InsecureSkipVerify { + tlsConfig.InsecureSkipVerify = true + } + + return credentials.NewTLS(tlsConfig), nil +} + +func (g *GRPCDecryptClient) Close() error { + if g.conn != nil { + return g.conn.Close() + } + return nil +} + +func (g *GRPCDecryptClient) Decrypt(ctx context.Context, namespace string, names ...string) (map[string]contracts.DecryptResult, error) { + authInfo, ok := claims.AuthInfoFrom(ctx) + if !ok { + return nil, errors.New("missing auth info in context") + } + + // Up until here the identity is the one set by the internal service, but when the request goes out to the gRPC server, + // the aggregator will use the access token which contains a different service identity for grafana as a whole. + // This is used for logging purposes only. + serviceIdentityList, ok := authInfo.GetExtra()[authnlib.ServiceIdentityKey] + if !ok || len(serviceIdentityList) != 1 { + return nil, errors.New("invalid service identity in auth info") + } + + serviceIdentity := serviceIdentityList[0] + if len(serviceIdentity) == 0 { + return nil, errors.New("empty service identity in auth info") + } + + req := &decryptv1beta1.SecureValueDecryptRequest{ + Namespace: namespace, + Names: names, + } + + // Decryption will still use the service identity from the auth token, + // but we also pass the service identity from the request metadata for auditing purposes. + md := metadata.New(map[string]string{ + contracts.HeaderGrafanaServiceIdentityName: serviceIdentity, + }) + ctx = metadata.NewOutgoingContext(ctx, md) + + resp, err := g.client.DecryptSecureValues(ctx, req) + if err != nil { + return nil, fmt.Errorf("grpc decrypt failed: %w", err) + } + + results := make(map[string]contracts.DecryptResult, len(resp.GetDecryptedValues())) + + for name, result := range resp.GetDecryptedValues() { + if result.GetErrorMessage() != "" { + results[name] = contracts.NewDecryptResultErr(errors.New(result.GetErrorMessage())) + } else { + exposedSecureValue := secretv1beta1.NewExposedSecureValue(result.GetValue()) + results[name] = contracts.NewDecryptResultValue(&exposedSecureValue) + } + } + + return results, nil +} diff --git a/pkg/registry/apis/secret/decrypt/local_client.go b/pkg/registry/apis/secret/decrypt/local_client.go new file mode 100644 index 00000000000..6aa3f89ebe6 --- /dev/null +++ b/pkg/registry/apis/secret/decrypt/local_client.go @@ -0,0 +1,39 @@ +package decrypt + +import ( + "context" + + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" +) + +type LocalDecryptClient struct { + decryptStorage contracts.DecryptStorage +} + +var _ contracts.DecryptService = &LocalDecryptClient{} + +func NewLocalDecryptClient(decryptStorage contracts.DecryptStorage) (*LocalDecryptClient, error) { + return &LocalDecryptClient{ + decryptStorage: decryptStorage, + }, nil +} + +func (c *LocalDecryptClient) Decrypt(ctx context.Context, namespace string, names ...string) (map[string]contracts.DecryptResult, error) { + results := make(map[string]contracts.DecryptResult, len(names)) + + for _, name := range names { + exposedSecureValue, err := c.decryptStorage.Decrypt(ctx, xkube.Namespace(namespace), name) + if err != nil { + results[name] = contracts.NewDecryptResultErr(err) + } else { + results[name] = contracts.NewDecryptResultValue(&exposedSecureValue) + } + } + + return results, nil +} + +func (c *LocalDecryptClient) Close() error { + return nil +} diff --git a/pkg/registry/apis/secret/decrypt/service.go b/pkg/registry/apis/secret/decrypt/service.go index 38556c0c4f9..91706a2aae1 100644 --- a/pkg/registry/apis/secret/decrypt/service.go +++ b/pkg/registry/apis/secret/decrypt/service.go @@ -1,33 +1,78 @@ package decrypt import ( - "context" + "fmt" + + authnlib "github.com/grafana/authlib/authn" + claims "github.com/grafana/authlib/types" + "go.opentelemetry.io/otel/trace" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" - "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" + "github.com/grafana/grafana/pkg/services/authn/grpcutils" + "github.com/grafana/grafana/pkg/setting" ) -type OSSDecryptService struct { - decryptStore contracts.DecryptStorage -} +func ProvideDecryptService(cfg *setting.Cfg, tracer trace.Tracer, decryptStorage contracts.DecryptStorage) (contracts.DecryptService, error) { + switch cfg.SecretsManagement.DecryptServerType { + case "grpc": + grpcClientConfig := grpcutils.ReadGrpcClientConfig(cfg) -func ProvideDecryptService(decryptStore contracts.DecryptStorage) contracts.DecryptService { - return &OSSDecryptService{ - decryptStore: decryptStore, - } -} + if cfg.SecretsManagement.DecryptServerAddress == "" { + return nil, fmt.Errorf("decrypt_server_address is required when decrypt_server_type is grpc") + } -func (d *OSSDecryptService) Decrypt(ctx context.Context, namespace string, names ...string) (map[string]contracts.DecryptResult, error) { - results := make(map[string]contracts.DecryptResult, len(names)) + if grpcClientConfig.Token == "" || grpcClientConfig.TokenExchangeURL == "" { + return nil, fmt.Errorf("grpc_client_authentication.token and grpc_client_authentication.token_exchange_url are required when secrets_manager.decrypt_server_type is grpc") + } - for _, name := range names { - exposedSecureValue, err := d.decryptStore.Decrypt(ctx, xkube.Namespace(namespace), name) + nsInfo, err := claims.ParseNamespace(grpcClientConfig.TokenNamespace) if err != nil { - results[name] = contracts.NewDecryptResultErr(err) - } else { - results[name] = contracts.NewDecryptResultValue(&exposedSecureValue) + return nil, fmt.Errorf("failed to parse token namespace %v: %w", grpcClientConfig.TokenNamespace, err) + } + if nsInfo.OrgID < 1 { + return nil, fmt.Errorf("invalid token namepsace %v", grpcClientConfig.TokenNamespace) + } + + tokenExchangeClient, err := authnlib.NewTokenExchangeClient(authnlib.TokenExchangeConfig{ + Token: grpcClientConfig.Token, + TokenExchangeURL: grpcClientConfig.TokenExchangeURL, + }) + if err != nil { + return nil, fmt.Errorf("failed to create token exchange client: %w", err) + } + + tlsConfig := readTLSFromConfig(cfg) + + client, err := NewGRPCDecryptClientWithTLS(tokenExchangeClient, tracer, grpcClientConfig.TokenNamespace, cfg.SecretsManagement.DecryptServerAddress, tlsConfig) + if err != nil { + return nil, fmt.Errorf("failed to create grpc decrypt client: %w", err) + } + + return client, nil + + case "local", "": + return NewLocalDecryptClient(decryptStorage) + } + + return nil, fmt.Errorf("unsupported storage type: %s", cfg.SecretsManagement.DecryptServerType) +} + +func readTLSFromConfig(cfg *setting.Cfg) TLSConfig { + if !cfg.SecretsManagement.DecryptServerUseTLS { + return TLSConfig{ + UseTLS: false, + InsecureSkipVerify: true, } } - return results, nil + apiServer := cfg.SectionWithEnvOverrides("grafana-apiserver") + + return TLSConfig{ + UseTLS: true, + CertFile: apiServer.Key("proxy_client_cert_file").MustString(""), + KeyFile: apiServer.Key("proxy_client_key_file").MustString(""), + CAFile: apiServer.Key("apiservice_ca_bundle_file").MustString(""), + ServerName: cfg.SecretsManagement.DecryptServerTLSServerName, + InsecureSkipVerify: cfg.SecretsManagement.DecryptServerTLSSkipVerify, + } } diff --git a/pkg/registry/apis/secret/decrypt/service_test.go b/pkg/registry/apis/secret/decrypt/service_test.go index 3203e3ebbe7..53091f27bc4 100644 --- a/pkg/registry/apis/secret/decrypt/service_test.go +++ b/pkg/registry/apis/secret/decrypt/service_test.go @@ -2,34 +2,52 @@ package decrypt import ( "context" + "crypto/tls" "errors" + "net/http" + "net/http/httptest" + "strings" "testing" + decryptv1beta1 "github.com/grafana/grafana/apps/secret/decrypt/v1beta1" 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/madflojo/testcerts" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace/noop" + "golang.org/x/net/nettest" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" + "github.com/grafana/grafana/pkg/services/authn/clients" + "github.com/grafana/grafana/pkg/setting" ) func TestDecryptService(t *testing.T) { t.Parallel() ctx := context.Background() + tracer := noop.NewTracerProvider().Tracer("test") t.Run("when there are only errors from the storage, the service returns them in the map", func(t *testing.T) { t.Parallel() mockErr := errors.New("mock error") - mockStorage := &MockDecryptStorage{} + mockStorage := &mockDecryptStorage{} mockStorage.On("Decrypt", mock.Anything, mock.Anything, mock.Anything).Return(secretv1beta1.ExposedSecureValue(""), mockErr) decryptedValuesResp := map[string]contracts.DecryptResult{ "secure-value-1": contracts.NewDecryptResultErr(mockErr), } - decryptService := &OSSDecryptService{ - decryptStore: mockStorage, - } + cfg := setting.NewCfg() + cfg.SecretsManagement.DecryptServerType = "local" + + decryptService, err := ProvideDecryptService(cfg, tracer, mockStorage) + require.NoError(t, err) resp, err := decryptService.Decrypt(ctx, "default", "secure-value-1") require.NotNil(t, resp) @@ -40,7 +58,7 @@ func TestDecryptService(t *testing.T) { t.Run("when there is no error from the storage, it returns a map of the decrypted values", func(t *testing.T) { t.Parallel() - mockStorage := &MockDecryptStorage{} + mockStorage := &mockDecryptStorage{} // Set up the mock to return a different value for each name in the test exposedSecureValue1 := secretv1beta1.NewExposedSecureValue("value1") exposedSecureValue2 := secretv1beta1.NewExposedSecureValue("value2") @@ -54,9 +72,11 @@ func TestDecryptService(t *testing.T) { "secure-value-2": contracts.NewDecryptResultValue(&exposedSecureValue2), } - decryptService := &OSSDecryptService{ - decryptStore: mockStorage, - } + cfg := setting.NewCfg() + cfg.SecretsManagement.DecryptServerType = "local" + + decryptService, err := ProvideDecryptService(cfg, tracer, mockStorage) + require.NoError(t, err) resp, err := decryptService.Decrypt(ctx, "default", "secure-value-1", "secure-value-2") require.NotNil(t, resp) @@ -68,7 +88,7 @@ func TestDecryptService(t *testing.T) { t.Parallel() mockErr := errors.New("mock error") - mockStorage := &MockDecryptStorage{} + mockStorage := &mockDecryptStorage{} exposedSecureValue := secretv1beta1.NewExposedSecureValue("value") mockStorage.On("Decrypt", mock.Anything, xkube.Namespace("default"), "secure-value-1"). Return(exposedSecureValue, nil) @@ -80,22 +100,213 @@ func TestDecryptService(t *testing.T) { "secure-value-2": contracts.NewDecryptResultErr(mockErr), } - decryptService := &OSSDecryptService{ - decryptStore: mockStorage, - } + cfg := setting.NewCfg() + cfg.SecretsManagement.DecryptServerType = "local" + + decryptService, err := ProvideDecryptService(cfg, tracer, mockStorage) + require.NoError(t, err) resp, err := decryptService.Decrypt(ctx, "default", "secure-value-1", "secure-value-2") require.NotNil(t, resp) require.NoError(t, err) require.EqualValues(t, decryptedValuesResp, resp) }) + + t.Run("when storage type is unsupported, it returns an error", func(t *testing.T) { + t.Parallel() + + cfg := setting.NewCfg() + cfg.SecretsManagement.DecryptServerType = "unsupported" + + decryptService, err := ProvideDecryptService(cfg, tracer, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "unsupported storage type") + require.Nil(t, decryptService) + }) + + t.Run("when storage type is grpc but token exchange config is missing, it returns an error", func(t *testing.T) { + t.Parallel() + + cfg := setting.NewCfg() + cfg.SecretsManagement.DecryptServerType = "grpc" + cfg.SecretsManagement.DecryptServerAddress = "127.0.0.1:10000" + + _, err := ProvideDecryptService(cfg, tracer, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "grpc_client_authentication.token and grpc_client_authentication.token_exchange_url are required") + }) + + t.Run("when storage type is grpc but storage address is missing, it returns an error", func(t *testing.T) { + t.Parallel() + + cfg := setting.NewCfg() + cfg.SecretsManagement.DecryptServerType = "grpc" + + _, err := ProvideDecryptService(cfg, tracer, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "decrypt_server_address is required") + }) + + t.Run("happy path with grpc+tls server with fake toke exchanger and server", func(t *testing.T) { + t.Parallel() + + respTokenExchanged := "test-token" + tokenExchangeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{ + "data": { + "token": "` + respTokenExchanged + `" + } + }` + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(response)) + })) + t.Cleanup(tokenExchangeServer.Close) + + // Set up gRPC Server with TLS + listener, err := nettest.NewLocalListener("tcp") + require.NoError(t, err) + + certPaths := createX509TestDir(t) + + serverCert, err := tls.LoadX509KeyPair(certPaths.serverCert, certPaths.serverKey) + require.NoError(t, err) + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{serverCert}, + ClientAuth: tls.NoClientCert, + InsecureSkipVerify: false, + ServerName: "localhost", + } + + grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsConfig))) + t.Cleanup(grpcServer.Stop) + + decryptServer := &mockDecryptServer{} + decryptServer.On("DecryptSecureValues", mock.Anything, mock.Anything).Return( + &decryptv1beta1.SecureValueDecryptResponseCollection{ + DecryptedValues: map[string]*decryptv1beta1.Result{ + "secure-value-1": { + Result: &decryptv1beta1.Result_Value{Value: "decrypted-value-1"}, + }, + }, + }, + nil, + ) + + decryptv1beta1.RegisterSecureValueDecrypterServer(grpcServer, decryptServer) + + go func() { + _ = grpcServer.Serve(listener) + <-t.Context().Done() + }() + + // Populate configuration with gRPC+TLS options and mock token exchanger + grafanaSvcIdentity := "svc-identity-decrypter" + namespace := "stacks-1234" + + cfg := setting.NewCfg() + cfg.SecretsManagement.DecryptServerType = "grpc" + cfg.SecretsManagement.DecryptServerAddress = listener.Addr().String() + cfg.SecretsManagement.DecryptGrafanaServiceName = grafanaSvcIdentity + cfg.SecretsManagement.DecryptServerUseTLS = true + cfg.SecretsManagement.DecryptServerTLSServerName = "localhost" + cfg.SecretsManagement.DecryptServerTLSSkipVerify = false + + grpcClientAuth := cfg.Raw.Section("grpc_client_authentication") + _, err = grpcClientAuth.NewKey("token", "test-token") + require.NoError(t, err) + _, err = grpcClientAuth.NewKey("token_exchange_url", tokenExchangeServer.URL) + require.NoError(t, err) + _, err = grpcClientAuth.NewKey("token_namespace", namespace) + require.NoError(t, err) + + apiServer := cfg.Raw.Section("grafana-apiserver") + _, err = apiServer.NewKey("proxy_client_cert_file", certPaths.clientCert) + require.NoError(t, err) + _, err = apiServer.NewKey("proxy_client_key_file", certPaths.clientKey) + require.NoError(t, err) + _, err = apiServer.NewKey("apiservice_ca_bundle_file", certPaths.ca) + require.NoError(t, err) + + // Create and test decryption, using the mock grpc server as we dont test the business logic here + decryptService, err := ProvideDecryptService(cfg, tracer, nil) + require.NoError(t, err) + require.NotNil(t, decryptService) + + t.Cleanup(func() { require.NoError(t, decryptService.Close()) }) + + svcIdentity := "provsysoning-test" + authCtx := identity.WithServiceIdentityContext(ctx, 1, identity.WithServiceIdentityName(svcIdentity)) + + result, err := decryptService.Decrypt(authCtx, namespace, "secure-value-1") + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, result, 1) + require.NotEmpty(t, result["secure-value-1"]) + + requestContext := decryptServer.Calls[0].Arguments[0].(context.Context) + + md, ok := metadata.FromIncomingContext(requestContext) + require.True(t, ok) + require.NotEmpty(t, md) + require.Equal(t, svcIdentity, md[strings.ToLower(contracts.HeaderGrafanaServiceIdentityName)][0]) + require.Equal(t, respTokenExchanged, md[strings.ToLower(clients.ExtJWTAuthenticationHeaderName)][0]) + }) } -type MockDecryptStorage struct { +type mockDecryptStorage struct { mock.Mock } -func (m *MockDecryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, name string) (secretv1beta1.ExposedSecureValue, error) { +func (m *mockDecryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, name string) (secretv1beta1.ExposedSecureValue, error) { args := m.Called(ctx, namespace, name) return args.Get(0).(secretv1beta1.ExposedSecureValue), args.Error(1) } + +type mockDecryptServer struct { + mock.Mock +} + +var _ decryptv1beta1.SecureValueDecrypterServer = (*mockDecryptServer)(nil) + +func (m *mockDecryptServer) DecryptSecureValues(ctx context.Context, req *decryptv1beta1.SecureValueDecryptRequest) (*decryptv1beta1.SecureValueDecryptResponseCollection, error) { + args := m.Called(ctx, req) + return args.Get(0).(*decryptv1beta1.SecureValueDecryptResponseCollection), args.Error(1) +} + +type certPaths struct { + clientCert string + clientKey string + serverCert string + serverKey string + ca string +} + +func createX509TestDir(t *testing.T) certPaths { + t.Helper() + + tmpDir := t.TempDir() + + ca := testcerts.NewCA() + caCertFile, _, err := ca.ToTempFile(tmpDir) + require.NoError(t, err) + + serverKp, err := ca.NewKeyPair("localhost") + require.NoError(t, err) + + serverCertFile, serverKeyFile, err := serverKp.ToTempFile(tmpDir) + require.NoError(t, err) + + clientKp, err := ca.NewKeyPair() + require.NoError(t, err) + clientCertFile, clientKeyFile, err := clientKp.ToTempFile(tmpDir) + require.NoError(t, err) + + return certPaths{ + clientCert: clientCertFile.Name(), + clientKey: clientKeyFile.Name(), + serverCert: serverCertFile.Name(), + serverKey: serverKeyFile.Name(), + ca: caCertFile.Name(), + } +} 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..062f601ba6e --- /dev/null +++ b/pkg/registry/apis/secret/service/inline_secure_value.go @@ -0,0 +1,166 @@ +package service + +import ( + "context" + "errors" + "fmt" + + authlib "github.com/grafana/authlib/types" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "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" +) + +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, values common.InlineSecureValues) 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), + )) + 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(values) == 0 { + return fmt.Errorf("no inline secure values provided") + } + + for field, value := range values { + if value.Name == "" { + return fmt.Errorf("field %s has an empty secure value name", field) + } + + if !value.Create.IsZero() { + return fmt.Errorf("field %s has 'create' set, which is not allowed", field) + } + + if value.Remove { + return fmt.Errorf("field %s has 'remove' set, which is not allowed", field) + } + + owned, err := s.isSecureValueOwnedByResource(ctx, owner, value.Name) + if err != nil { + return fmt.Errorf("field %s had an error checking secure value ownership: %w", field, err) + } + + if !owned { + if err := s.canIdentityReadSecureValue(ctx, xkube.Namespace(owner.Namespace), value.Name); err != nil { + return fmt.Errorf("field %s: identity cannot read secure value %s: %w", field, value.Name, 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) { + return "", fmt.Errorf("not implemented yet") +} + +func (s *inlineSecureValueService) DeleteWhenOwnedByResource(ctx context.Context, owner common.ObjectReference, name string) error { + return fmt.Errorf("not implemented yet") +} 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..a358ff13cbd --- /dev/null +++ b/pkg/registry/apis/secret/service/inline_secure_value_test.go @@ -0,0 +1,294 @@ +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/service" + "github.com/grafana/grafana/pkg/registry/apis/secret/testutils" + "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) + + values := common.InlineSecureValues{ + "fieldA": {Name: sv1}, + "fieldB": {Name: sv2}, + } + + 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, values) + 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{}, common.InlineSecureValues{}) + 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, common.InlineSecureValues{}) + 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{}, common.InlineSecureValues{}) + 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, common.InlineSecureValues{}) + require.Error(t, err) + + owner.APIGroup = "prometheus.datasource.grafana.app" + require.Error(t, svc.CanReference(ctx, owner, common.InlineSecureValues{})) + owner.APIGroup = "" + + owner.APIVersion = "v1alpha1" + require.Error(t, svc.CanReference(ctx, owner, common.InlineSecureValues{})) + owner.APIVersion = "" + + owner.Kind = "DataSourceConfig" + require.Error(t, svc.CanReference(ctx, owner, common.InlineSecureValues{})) + owner.Kind = "" + + owner.Name = "test-datasource" + require.Error(t, svc.CanReference(ctx, owner, common.InlineSecureValues{})) + 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, common.InlineSecureValues{}) + require.Error(t, err) + }) + + t.Run("when one of the secure values does not have a `name`, it returns an error", func(t *testing.T) { + t.Parallel() + + svc := service.ProvideInlineSecureValueService(tracer, nil, nil) + + values := common.InlineSecureValues{ + "fieldA": {Name: ""}, + } + + ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{}) + + err := svc.CanReference(ctx, owner, values) + require.Error(t, err) + }) + + t.Run("when one of the secure values has `create` field set, it returns an error", func(t *testing.T) { + t.Parallel() + + svc := service.ProvideInlineSecureValueService(tracer, nil, nil) + + values := common.InlineSecureValues{ + "fieldA": { + Name: "test-sv", + Create: common.NewSecretValue("test"), + }, + } + + ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{}) + + err := svc.CanReference(ctx, owner, values) + require.Error(t, err) + }) + + t.Run("when one of the secure values has `remove` field set, it returns an error", func(t *testing.T) { + t.Parallel() + + svc := service.ProvideInlineSecureValueService(tracer, nil, nil) + + values := common.InlineSecureValues{ + "fieldA": { + Name: "test-sv", + Remove: true, + }, + } + + ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{}) + + err := svc.CanReference(ctx, owner, values) + 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) + + values := common.InlineSecureValues{ + "fieldA": {Name: "non-existent-sv"}, + } + + ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{}) + + err := svc.CanReference(ctx, owner, values) + 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) + + values := common.InlineSecureValues{ + "fieldA": {Name: sv1}, + } + + ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{}) + + svc := service.ProvideInlineSecureValueService(tracer, tu.SecureValueService, nil) + + err = svc.CanReference(ctx, owner, values) + 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) + + values := common.InlineSecureValues{ + "fieldA": {Name: sv1}, + } + + ctx := identity.WithServiceIdentityContext(t.Context(), 1234) + + svc := service.ProvideInlineSecureValueService(tracer, tu.SecureValueService, nil) + + err = svc.CanReference(ctx, owner, values) + 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) + + values := common.InlineSecureValues{ + "fieldA": {Name: sv1}, + } + + 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, values) + require.Error(t, err) + + ctx = testutils.CreateUserAuthContext(t.Context(), defaultNs, nil) + + err = svc.CanReference(ctx, owner, values) + require.Error(t, err) + }) +} 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 448283abd09..ebe8b109b81 100644 --- a/pkg/registry/apis/secret/testutils/testutils.go +++ b/pkg/registry/apis/secret/testutils/testutils.go @@ -59,6 +59,7 @@ func Setup(t *testing.T, opts ...func(*SetupConfig)) Sut { } tracer := noop.NewTracerProvider().Tracer("test") + testDB := sqlstore.NewTestStore(t, sqlstore.WithMigrator(migrator.New())) database := database.ProvideDatabase(testDB, tracer) @@ -77,15 +78,18 @@ func Setup(t *testing.T, opts ...func(*SetupConfig)) Sut { }) defaultKey := "SdlklWklckeLS" - cfg := &setting.Cfg{ - SecretsManagement: setting.SecretsManagerSettings{ - CurrentEncryptionProvider: "secret_key.v1", - ConfiguredKMSProviders: map[string]map[string]string{"secret_key.v1": {"secret_key": defaultKey}}, - }, + cfg := setting.NewCfg() + cfg.SecretsManagement = setting.SecretsManagerSettings{ + DecryptServerType: "local", + CurrentEncryptionProvider: "secret_key.v1", + ConfiguredKMSProviders: map[string]map[string]string{"secret_key.v1": {"secret_key": defaultKey}}, } 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) @@ -119,14 +123,22 @@ 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) decryptStorage, err := metadata.ProvideDecryptStorage(tracer, keeperService, keeperMetadataStorage, secureValueMetadataStorage, decryptAuthorizer, nil) require.NoError(t, err) - decryptService := decrypt.ProvideDecryptService(decryptStorage) + testCfg := setting.NewCfg() + testCfg.SecretsManagement = setting.SecretsManagerSettings{ + DecryptServerType: "local", + } + + decryptService, err := decrypt.ProvideDecryptService(testCfg, tracer, decryptStorage) + require.NoError(t, err) + + consolidationService := service.ProvideConsolidationService(tracer, globalDataKeyStore, encryptedValueStorage, globalEncryptedValueStorage, encryptionManager) return Sut{ SecureValueService: secureValueService, @@ -138,6 +150,9 @@ func Setup(t *testing.T, opts ...func(*SetupConfig)) Sut { SQLKeeper: sqlKeeper, Database: database, AccessClient: accessClient, + ConsolidationService: consolidationService, + EncryptionManager: encryptionManager, + GlobalDataKeyStore: globalDataKeyStore, } } @@ -151,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 { 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 481ad2b1845..cf76c869512 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -380,7 +380,7 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser prometheusService := prometheus.ProvideService(httpclientProvider) tempoService := tempo.ProvideService(httpclientProvider) testdatasourceService := testdatasource.ProvideService() - postgresService := postgres.ProvideService(cfg) + postgresService := postgres.ProvideService(cfg, featureToggles) mysqlService := mysql.ProvideService() mssqlService := mssql.ProvideService(cfg) entityEventsService := store.ProvideEntityEventsService(cfg, sqlStore, featureToggles) @@ -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) @@ -788,8 +788,11 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser if err != nil { return nil, err } - v3 := decrypt.ProvideDecryptService(decryptStorage) - repositorySecrets := secrets.ProvideRepositorySecrets(featureToggles, secretsService, secureValueClient, v3) + v3, err := decrypt.ProvideDecryptService(cfg, tracer, decryptStorage) + if err != nil { + return nil, err + } + repositorySecrets := secrets.ProvideRepositorySecrets(featureToggles, secretsService, secureValueClient, v3, cfg) webhookExtraBuilder := webhooks.ProvideWebhooks(cfg, featureToggles, repositorySecrets, factory, renderingService, resourceClient, eventualRestConfigProvider) v4 := extras.ProvideProvisioningOSSExtras(webhookExtraBuilder) apiBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, factory, accessClient, legacyMigrator, dualwriteService, usageStats, repositorySecrets, tracingService, v4) @@ -936,7 +939,7 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface { prometheusService := prometheus.ProvideService(httpclientProvider) tempoService := tempo.ProvideService(httpclientProvider) testdatasourceService := testdatasource.ProvideService() - postgresService := postgres.ProvideService(cfg) + postgresService := postgres.ProvideService(cfg, featureToggles) mysqlService := mysql.ProvideService() mssqlService := mssql.ProvideService(cfg) entityEventsService := store.ProvideEntityEventsService(cfg, sqlStore, featureToggles) @@ -1304,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 { @@ -1338,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) @@ -1346,8 +1349,11 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface { if err != nil { return nil, err } - v3 := decrypt.ProvideDecryptService(decryptStorage) - repositorySecrets := secrets.ProvideRepositorySecrets(featureToggles, secretsService, secureValueClient, v3) + v3, err := decrypt.ProvideDecryptService(cfg, tracer, decryptStorage) + if err != nil { + return nil, err + } + repositorySecrets := secrets.ProvideRepositorySecrets(featureToggles, secretsService, secureValueClient, v3, cfg) webhookExtraBuilder := webhooks.ProvideWebhooks(cfg, featureToggles, repositorySecrets, factory, renderingService, resourceClient, eventualRestConfigProvider) v4 := extras.ProvideProvisioningOSSExtras(webhookExtraBuilder) apiBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, factory, accessClient, legacyMigrator, dualwriteService, usageStats, repositorySecrets, tracingService, v4) @@ -1447,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 } @@ -1534,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..fb5427b761f 100644 --- a/pkg/services/apiserver/appinstaller/installer.go +++ b/pkg/services/apiserver/appinstaller/installer.go @@ -134,7 +134,6 @@ func InstallAPIs( builderMetrics *builder.BuilderMetrics, ) error { logger := logging.FromContext(ctx) - for _, installer := range appInstallers { logger.Debug("Installing APIs for app installer", "app", installer.ManifestData().AppName) wrapper := &serverWrapper{ diff --git a/pkg/services/apiserver/appinstaller/server.go b/pkg/services/apiserver/appinstaller/server.go index 723c0753605..ea9b88e031e 100644 --- a/pkg/services/apiserver/appinstaller/server.go +++ b/pkg/services/apiserver/appinstaller/server.go @@ -8,6 +8,7 @@ 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" appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver" @@ -38,17 +39,9 @@ type serverWrapper struct { 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 +50,28 @@ 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, + 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 } - - 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 - } - apiGroupInfo.VersionedResourcesStorageMap[v][storagePath] = dw + apiGroupInfo.VersionedResourcesStorageMap[v][storagePath] = storage } } @@ -93,3 +85,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/client/discovery.go b/pkg/services/apiserver/client/discovery.go index 3c9b91f1ab4..db5fb26cccb 100644 --- a/pkg/services/apiserver/client/discovery.go +++ b/pkg/services/apiserver/client/discovery.go @@ -14,6 +14,7 @@ type DiscoveryClient interface { GetResourceForKind(gvk schema.GroupVersionKind) (schema.GroupVersionResource, error) GetKindForResource(gvr schema.GroupVersionResource) (schema.GroupVersionKind, error) GetPreferredVesion(gr schema.GroupResource) (schema.GroupVersionResource, schema.GroupVersionKind, error) + GetPreferredVersionForKind(gk schema.GroupKind) (schema.GroupVersionResource, schema.GroupVersionKind, error) } type DiscoveryClientImpl struct { @@ -92,3 +93,44 @@ func (d *DiscoveryClientImpl) GetPreferredVesion(gr schema.GroupResource) (schem } return schema.GroupVersionResource{}, schema.GroupVersionKind{}, fmt.Errorf("preferred version not found for %s", gr.String()) } + +func (d *DiscoveryClientImpl) GetPreferredVersionForKind(gk schema.GroupKind) (schema.GroupVersionResource, schema.GroupVersionKind, error) { + apiList, err := d.ServerPreferredResources() + if err != nil { + return schema.GroupVersionResource{}, schema.GroupVersionKind{}, err + } + for _, apis := range apiList { + // Check if this API group matches our target group + if !strings.HasPrefix(apis.GroupVersion, gk.Group) { + continue + } + + // Parse the group/version + var group, version string + if strings.Contains(apis.GroupVersion, "/") { + parts := strings.Split(apis.GroupVersion, "/") + group = parts[0] + version = parts[1] + } else { + // Core API group (e.g., "v1") + group = "" + version = apis.GroupVersion + } + + // Look for our target kind in this API group version + for _, resource := range apis.APIResources { + if resource.Kind == gk.Kind { + return schema.GroupVersionResource{ + Group: group, + Version: version, + Resource: resource.Name, + }, schema.GroupVersionKind{ + Group: group, + Version: version, + Kind: resource.Kind, + }, nil + } + } + } + return schema.GroupVersionResource{}, schema.GroupVersionKind{}, fmt.Errorf("preferred version not found for kind %s in group %s", gk.Kind, gk.Group) +} diff --git a/pkg/services/authz/rbac/mapper.go b/pkg/services/authz/rbac/mapper.go index ded19301c83..28537ad638d 100644 --- a/pkg/services/authz/rbac/mapper.go +++ b/pkg/services/authz/rbac/mapper.go @@ -105,7 +105,21 @@ func NewMapperRegistry() MapperRegistry { // Teams is a special case. We translate user permissions from id to uid based. "teams": newResourceTranslation("teams", "uid", false), "coreroles": newResourceTranslation("roles", "uid", false), - "roles": newResourceTranslation("roles", "uid", false), + "roles": translation{ + resource: "roles", + attribute: "uid", + verbMapping: map[string]string{ + utils.VerbCreate: "roles:write", + utils.VerbGet: "roles:read", + utils.VerbUpdate: "roles:write", + utils.VerbPatch: "roles:write", + utils.VerbDelete: "roles:delete", + utils.VerbDeleteCollection: "roles:delete", + utils.VerbList: "roles:read", + utils.VerbWatch: "roles:read", + }, + folderSupport: false, + }, }, "secret.grafana.app": { "securevalues": newResourceTranslation("secret.securevalues", "uid", false), diff --git a/pkg/services/authz/rbac/resolver.go b/pkg/services/authz/rbac/resolver.go index f8978302c6c..0a774844c29 100644 --- a/pkg/services/authz/rbac/resolver.go +++ b/pkg/services/authz/rbac/resolver.go @@ -81,10 +81,22 @@ func (s *Service) newTeamNameResolver(ctx context.Context, ns types.NamespaceInf }, nil } +func permissionsDelegateResolverFunc(scope string) (string, error) { + if strings.TrimPrefix(scope, "permissions:type:") == "delegate" { + // The permissions:type:delegate scope does not have any discriminating value, + // so we return a wildcard to indicate that it applies to all roles. + return "*", nil + } + return "", fmt.Errorf("unsupported scope: %s", scope) +} + func (s *Service) nameResolver(ctx context.Context, ns types.NamespaceInfo, scopePrefix string) (ScopeResolverFunc, error) { if scopePrefix == "teams:id:" { return s.newTeamNameResolver(ctx, ns) } + if scopePrefix == "permissions:type:" { + return permissionsDelegateResolverFunc, nil + } // No resolver found for the given scope prefix. return nil, nil } diff --git a/pkg/services/authz/zanzana/server/server.go b/pkg/services/authz/zanzana/server/server.go index 1a1d538cc11..f599fa6ae57 100644 --- a/pkg/services/authz/zanzana/server/server.go +++ b/pkg/services/authz/zanzana/server/server.go @@ -12,7 +12,7 @@ import ( "google.golang.org/protobuf/types/known/wrapperspb" dashboardV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" - dashboardV2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" + dashboardV2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" @@ -101,8 +101,8 @@ func (s *Server) getContextuals(subject string) (*openfgav1.ContextualTupleKeys, User: subject, Relation: common.RelationSetView, Object: common.NewGroupResourceIdent( - dashboardV2alpha2.DashboardResourceInfo.GroupResource().Group, - dashboardV2alpha2.DashboardResourceInfo.GroupResource().Resource, + dashboardV2beta1.DashboardResourceInfo.GroupResource().Group, + dashboardV2beta1.DashboardResourceInfo.GroupResource().Resource, "", ), }, diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go index 3bf16b66ef9..c8867722f5e 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go @@ -872,11 +872,9 @@ func setUpServiceTest(t *testing.T, withDashboardMock bool, cfgOverrides ...conf rr := routing.NewRouteRegister() tracer := tracing.InitializeTracerForTest() - fakeFolder := &folder.Folder{UID: "folderUID", Title: "Folder"} - mockFolder := &foldertest.FakeService{ - ExpectedFolders: []*folder.Folder{fakeFolder}, - ExpectedFolder: fakeFolder, - } + fakeFolder := &folder.Folder{UID: "folderUID", Title: "Folder", Fullpath: "Folder"} + mockFolder := foldertest.NewFakeService() + mockFolder.AddFolder(fakeFolder) cfg := setting.NewCfg() section, err := cfg.Raw.NewSection("cloud_migration") diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt_alerts_test.go b/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt_alerts_test.go index f6c43b28196..b382035c150 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt_alerts_test.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt_alerts_test.go @@ -3,6 +3,7 @@ package cloudmigrationimpl import ( "context" "encoding/json" + "errors" "fmt" "net/http" "testing" @@ -18,6 +19,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" ac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -408,6 +410,9 @@ func updateNotificationPolicyTree(t *testing.T, ctx context.Context, service *Se func createAlertRule(t *testing.T, ctx context.Context, service *Service, user *user.SignedInUser, isPaused bool, ruleGroup string) models.AlertRule { t.Helper() + // Ensure the folder exists before creating alert rules + createFolder(t, ctx, service, user, "folderUID", "Test Folder") + rule := models.AlertRule{ OrgID: user.GetOrgID(), Title: fmt.Sprintf("Alert Rule SLO (Paused: %v) - %v", isPaused, ruleGroup), @@ -437,6 +442,19 @@ func createAlertRule(t *testing.T, ctx context.Context, service *Service, user * return createdRule } +func createFolder(t *testing.T, ctx context.Context, service *Service, user *user.SignedInUser, uid, title string) { + t.Helper() + _, err := service.folderService.Create(ctx, &folder.CreateFolderCommand{ + OrgID: user.GetOrgID(), + UID: uid, + Title: title, + SignedInUser: user, + }) + if err != nil && !errors.Is(err, dashboards.ErrFolderWithSameUIDExists) { + require.NoError(t, err) + } +} + func createAlertRuleGroup(t *testing.T, ctx context.Context, service *Service, user *user.SignedInUser, title string, rules []models.AlertRule) models.AlertRuleGroup { t.Helper() diff --git a/pkg/services/dashboardimport/service/service.go b/pkg/services/dashboardimport/service/service.go index c16801e55fa..fac501f9361 100644 --- a/pkg/services/dashboardimport/service/service.go +++ b/pkg/services/dashboardimport/service/service.go @@ -146,14 +146,6 @@ func (s *ImportDashboardService) ImportDashboard(ctx context.Context, req *dashb } metrics.MFolderIDsServiceCount.WithLabelValues(metrics.DashboardImport).Inc() - // in the k8s flow, we connect the library panels in pkg/registry/apis/dashboard/legacy/sql_dashboards.go - if !s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - err = s.libraryPanelService.ConnectLibraryPanelsForDashboard(ctx, req.User, savedDashboard) - if err != nil { - return nil, err - } - } - revision := savedDashboard.Data.Get("revision").MustInt64(0) metrics.MFolderIDsServiceCount.WithLabelValues(metrics.DashboardImport).Inc() return &dashboardimport.ImportDashboardResponse{ diff --git a/pkg/services/dashboardimport/service/service_test.go b/pkg/services/dashboardimport/service/service_test.go index a4a1af485a2..16ee483fc1b 100644 --- a/pkg/services/dashboardimport/service/service_test.go +++ b/pkg/services/dashboardimport/service/service_test.go @@ -46,16 +46,11 @@ func TestImportDashboardService(t *testing.T) { } importLibraryPanelsForDashboard := false - connectLibraryPanelsForDashboardCalled := false libraryPanelService := &libraryPanelServiceMock{ importLibraryPanelsForDashboardFunc: func(ctx context.Context, signedInUser identity.Requester, libraryPanels *simplejson.Json, panels []any, folderID int64, folderUID string) error { importLibraryPanelsForDashboard = true return nil }, - connectLibraryPanelsForDashboardFunc: func(ctx context.Context, signedInUser identity.Requester, dash *dashboards.Dashboard) error { - connectLibraryPanelsForDashboardCalled = true - return nil - }, } folderService := &foldertest.FakeService{ ExpectedFolder: &folder.Folder{ @@ -98,7 +93,6 @@ func TestImportDashboardService(t *testing.T) { require.Equal(t, "prom", panel.Get("datasource").MustString()) require.True(t, importLibraryPanelsForDashboard) - require.True(t, connectLibraryPanelsForDashboardCalled) }) t.Run("When importing a non-plugin dashboard should save dashboard and sync library panels", func(t *testing.T) { diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go index 6d9bbb0e5f8..131b24f8d67 100644 --- a/pkg/services/dashboards/dashboard.go +++ b/pkg/services/dashboards/dashboard.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/folder" - "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/search/model" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" @@ -77,12 +76,8 @@ type DashboardProvisioningService interface { type Store interface { DeleteDashboard(ctx context.Context, cmd *DeleteDashboardCommand) error CleanupAfterDelete(ctx context.Context, cmd *DeleteDashboardCommand) error - DeleteAllDashboards(ctx context.Context, orgID int64) error - DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *DeleteOrphanedProvisionedDashboardsCommand) error FindDashboards(ctx context.Context, query *FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) GetDashboard(ctx context.Context, query *GetDashboardQuery) (*Dashboard, error) - GetDashboardUIDByID(ctx context.Context, query *GetDashboardRefByIDQuery) (*DashboardRef, error) - GetDashboards(ctx context.Context, query *GetDashboardsQuery) ([]*Dashboard, error) // GetDashboardsByPluginID retrieves dashboards identified by plugin. GetDashboardsByPluginID(ctx context.Context, query *GetDashboardsByPluginIDQuery) ([]*Dashboard, error) GetDashboardTags(ctx context.Context, query *GetDashboardTagsQuery) ([]*DashboardTagCloudItem, error) @@ -97,11 +92,7 @@ type Store interface { // ValidateDashboardBeforeSave validates a dashboard before save. ValidateDashboardBeforeSave(ctx context.Context, dashboard *Dashboard, overwrite bool) (bool, error) - Count(context.Context, *quota.ScopeParameters) (*quota.Map, error) CountInOrg(ctx context.Context, orgID int64, isFolder bool) (int64, error) - // CountDashboardsInFolder returns the number of dashboards associated with - // the given parent folder ID. - CountDashboardsInFolders(ctx context.Context, request *CountDashboardsInFolderRequest) (int64, error) DeleteDashboardsInFolders(ctx context.Context, request *DeleteDashboardsInFolderRequest) error GetAllDashboardsByOrgId(ctx context.Context, orgID int64) ([]*Dashboard, error) diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index 1062568a706..13e874e4fad 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -2,10 +2,8 @@ package database import ( "context" - "errors" "fmt" "strconv" - "strings" "time" "go.opentelemetry.io/otel" @@ -25,7 +23,6 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore/migrations" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" - "github.com/grafana/grafana/pkg/services/star" "github.com/grafana/grafana/pkg/services/store" "github.com/grafana/grafana/pkg/services/tag" "github.com/grafana/grafana/pkg/setting" @@ -352,34 +349,6 @@ func (d *dashboardStore) UnprovisionDashboard(ctx context.Context, id int64) err }) } -func (d *dashboardStore) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *dashboards.DeleteOrphanedProvisionedDashboardsCommand) error { - ctx, span := tracer.Start(ctx, "dashboards.database.DeleteOrphanedProvisionedDashboards") - defer span.End() - - return d.store.WithDbSession(ctx, func(sess *db.Session) error { - var result []*dashboards.DashboardProvisioning - - convertedReaderNames := make([]any, len(cmd.ReaderNames)) - for index, readerName := range cmd.ReaderNames { - convertedReaderNames[index] = readerName - } - - err := sess.NotIn("name", convertedReaderNames...).Find(&result) - if err != nil { - return err - } - - for _, deleteDashCommand := range result { - err := d.DeleteDashboard(ctx, &dashboards.DeleteDashboardCommand{ID: deleteDashCommand.DashboardID}) - if err != nil && !errors.Is(err, dashboards.ErrDashboardNotFound) { - return err - } - } - - return nil - }) -} - func (d *dashboardStore) Count(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { ctx, span := tracer.Start(ctx, "dashboards.database.Count") defer span.End() @@ -690,16 +659,6 @@ func (d *dashboardStore) CleanupAfterDelete(ctx context.Context, cmd *dashboards return err } -func (d *dashboardStore) DeleteAllDashboards(ctx context.Context, orgID int64) error { - ctx, span := tracer.Start(ctx, "dashboards.database.DeleteAllDashboards") - defer span.End() - - return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - _, err := sess.Where("org_id = ?", orgID).Delete(&dashboards.Dashboard{}) - return err - }) -} - // FIXME: Remove me and handle nested deletions in the service with the DashboardPermissionsService func (d *dashboardStore) deleteResourcePermissions(sess *db.Session, orgID int64, resourceScope string) error { // retrieve all permissions for the resource scope and org id @@ -821,58 +780,6 @@ func (d *dashboardStore) GetDashboard(ctx context.Context, query *dashboards.Get return queryResult, err } -func (d *dashboardStore) GetDashboardUIDByID(ctx context.Context, query *dashboards.GetDashboardRefByIDQuery) (*dashboards.DashboardRef, error) { - ctx, span := tracer.Start(ctx, "dashboards.database.GetDashboardUIDByID") - defer span.End() - - us := &dashboards.DashboardRef{} - err := d.store.WithDbSession(ctx, func(sess *db.Session) error { - var rawSQL = `SELECT uid, slug, folder_uid from dashboard WHERE Id=?` - exists, err := sess.SQL(rawSQL, query.ID).Get(us) - if err != nil { - return err - } else if !exists { - return dashboards.ErrDashboardNotFound - } - return nil - }) - if err != nil { - return nil, err - } - return us, nil -} - -func (d *dashboardStore) GetDashboards(ctx context.Context, query *dashboards.GetDashboardsQuery) ([]*dashboards.Dashboard, error) { - ctx, span := tracer.Start(ctx, "dashboards.database.GetDashboards") - defer span.End() - - var dashboards = make([]*dashboards.Dashboard, 0) - err := d.store.WithDbSession(ctx, func(sess *db.Session) error { - if len(query.DashboardIDs) == 0 && len(query.DashboardUIDs) == 0 { - return star.ErrCommandValidationFailed - } - - // remove soft deleted dashboards from the response - sess.Where("deleted IS NULL") - - if len(query.DashboardIDs) > 0 { - sess.In("id", query.DashboardIDs) - } else { - sess.In("uid", query.DashboardUIDs) - } - if query.OrgID > 0 { - sess.Where("org_id = ?", query.OrgID) - } - - err := sess.Find(&dashboards) - return err - }) - if err != nil { - return nil, err - } - return dashboards, nil -} - func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { ctx, span := tracer.Start(ctx, "dashboards.database.FindDashboards") defer span.End() @@ -1025,39 +932,6 @@ func (d *dashboardStore) GetDashboardTags(ctx context.Context, query *dashboards return queryResult, nil } -// CountDashboardsInFolder returns a count of all dashboards associated with the -// given parent folder ID. -func (d *dashboardStore) CountDashboardsInFolders( - ctx context.Context, req *dashboards.CountDashboardsInFolderRequest) (int64, error) { - ctx, span := tracer.Start(ctx, "dashboards.database.CountDashboardsInFolders") - defer span.End() - - if len(req.FolderUIDs) == 0 { - return 0, nil - } - var count int64 - err := d.store.WithDbSession(ctx, func(sess *db.Session) error { - metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() - s := strings.Builder{} - args := make([]any, 0, 3) - s.WriteString("SELECT COUNT(*) FROM dashboard WHERE ") - if len(req.FolderUIDs) == 1 && req.FolderUIDs[0] == "" { - s.WriteString("folder_uid IS NULL") - } else { - s.WriteString(fmt.Sprintf("folder_uid IN (%s)", strings.Repeat("?,", len(req.FolderUIDs)-1)+"?")) - for _, folderUID := range req.FolderUIDs { - args = append(args, folderUID) - } - } - s.WriteString(" AND org_id = ? AND is_folder = ? AND deleted IS NULL") - args = append(args, req.OrgID, d.store.GetDialect().BooleanValue(false)) - sql := s.String() - _, err := sess.SQL(sql, args...).Get(&count) - return err - }) - return count, err -} - func (d *dashboardStore) DeleteDashboardsInFolders( ctx context.Context, req *dashboards.DeleteDashboardsInFolderRequest) error { ctx, span := tracer.Start(ctx, "dashboards.database.DeleteDashboardsInFolders") diff --git a/pkg/services/dashboards/database/database_provisioning_test.go b/pkg/services/dashboards/database/database_provisioning_test.go index ec82181c4d3..b14100f5c6e 100644 --- a/pkg/services/dashboards/database/database_provisioning_test.go +++ b/pkg/services/dashboards/database/database_provisioning_test.go @@ -59,42 +59,6 @@ func TestIntegrationDashboardProvisioningTest(t *testing.T) { require.NotEqual(t, 0, dash.ID) dashId := dash.ID - t.Run("Deleting orphaned provisioned dashboards", func(t *testing.T) { - saveCmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - IsFolder: false, - FolderUID: dash.UID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": nil, - "title": "another_dashboard", - }), - } - - provisioning := &dashboards.DashboardProvisioning{ - Name: "another_reader", - ExternalID: "/var/grafana.json", - Updated: now.Unix(), - } - - anotherDash, err := dashboardStore.SaveProvisionedDashboard(context.Background(), saveCmd, provisioning) - require.Nil(t, err) - - query := &dashboards.GetDashboardsQuery{DashboardIDs: []int64{anotherDash.ID}} - queryResult, err := dashboardStore.GetDashboards(context.Background(), query) - require.Nil(t, err) - require.NotNil(t, queryResult) - - deleteCmd := &dashboards.DeleteOrphanedProvisionedDashboardsCommand{ReaderNames: []string{"default"}} - require.Nil(t, dashboardStore.DeleteOrphanedProvisionedDashboards(context.Background(), deleteCmd)) - - query = &dashboards.GetDashboardsQuery{DashboardIDs: []int64{dash.ID, anotherDash.ID}} - queryResult, err = dashboardStore.GetDashboards(context.Background(), query) - require.Nil(t, err) - - require.Equal(t, 1, len(queryResult)) - require.Equal(t, dashId, queryResult[0].ID) - }) - t.Run("Can query for provisioned dashboards", func(t *testing.T) { rslt, err := dashboardStore.GetProvisionedDashboardData(context.Background(), "default") require.Nil(t, err) diff --git a/pkg/services/dashboards/database/database_test.go b/pkg/services/dashboards/database/database_test.go index f45a3affda2..a940e873e0b 100644 --- a/pkg/services/dashboards/database/database_test.go +++ b/pkg/services/dashboards/database/database_test.go @@ -10,27 +10,19 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" - "github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" - "github.com/grafana/grafana/pkg/services/folder/folderimpl" libmodel "github.com/grafana/grafana/pkg/services/libraryelements/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/search/model" - "github.com/grafana/grafana/pkg/services/search/sort" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" - "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) @@ -185,15 +177,6 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { require.False(t, queryResult.IsFolder) }) - t.Run("Should be able to get a dashboard UID by ID", func(t *testing.T) { - setup() - query := dashboards.GetDashboardRefByIDQuery{ID: savedDash.ID} - queryResult, err := dashboardStore.GetDashboardUIDByID(context.Background(), &query) - require.NoError(t, err) - require.Equal(t, queryResult.UID, savedDash.UID) - require.Equal(t, queryResult.FolderUID, savedFolder.UID) - }) - t.Run("Shouldn't be able to get a dashboard with just an OrgID", func(t *testing.T) { setup() query := dashboards.GetDashboardQuery{ @@ -216,19 +199,6 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { require.Error(t, err, dashboards.ErrDashboardNotFound) }) - t.Run("Should be able to get dashboards by IDs & UIDs", func(t *testing.T) { - setup() - query := dashboards.GetDashboardsQuery{DashboardIDs: []int64{savedDash.ID, savedDash2.ID}} - queryResult, err := dashboardStore.GetDashboards(context.Background(), &query) - require.NoError(t, err) - assert.Equal(t, len(queryResult), 2) - - query = dashboards.GetDashboardsQuery{DashboardUIDs: []string{savedDash.UID, savedDash2.UID}} - queryResult, err = dashboardStore.GetDashboards(context.Background(), &query) - require.NoError(t, err) - assert.Equal(t, len(queryResult), 2) - }) - t.Run("Should be able to delete dashboard and associated tags", func(t *testing.T) { setup() dash := insertTestDashboard(t, dashboardStore, "delete me", 1, 0, "", false, "delete this") @@ -330,32 +300,6 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { require.Len(t, res, 0) }) - t.Run("Should be able to delete all dashboards for an org", func(t *testing.T) { - setup() - dash1 := insertTestDashboard(t, dashboardStore, "delete me", 1, 0, "", false, "delete this") - dash2 := insertTestDashboard(t, dashboardStore, "delete me2", 1, 0, "", false, "delete this2") - dash3 := insertTestDashboard(t, dashboardStore, "dont delete me", 2, 0, "", false, "dont delete me") - - err := dashboardStore.DeleteAllDashboards(context.Background(), 1) - require.NoError(t, err) - - // no dashboards should exist for org 1 - queryResult, err := dashboardStore.GetDashboards(context.Background(), &dashboards.GetDashboardsQuery{ - OrgID: 1, - DashboardUIDs: []string{dash1.UID, dash2.UID}, - }) - require.NoError(t, err) - assert.Equal(t, len(queryResult), 0) - - // but we should still have one for org 2 - queryResult, err = dashboardStore.GetDashboards(context.Background(), &dashboards.GetDashboardsQuery{ - OrgID: 2, - DashboardUIDs: []string{dash3.UID}, - }) - require.NoError(t, err) - assert.Equal(t, len(queryResult), 1) - }) - t.Run("Should be able to get all dashboards for an org", func(t *testing.T) { setup() dash1 := insertTestDashboard(t, dashboardStore, "org3test1", 3, 0, "", false, "org 1 test 1") @@ -679,22 +623,6 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { require.Equal(t, len(hit2.Tags), 1) }) - t.Run("Can count dashboards by parent folder", func(t *testing.T) { - setup() - // setup() saves one dashboard in the general folder and two in the "savedFolder". - count, err := dashboardStore.CountDashboardsInFolders( - context.Background(), - &dashboards.CountDashboardsInFolderRequest{FolderUIDs: []string{""}, OrgID: 1}) - require.NoError(t, err) - require.Equal(t, int64(1), count) - - count, err = dashboardStore.CountDashboardsInFolders( - context.Background(), - &dashboards.CountDashboardsInFolderRequest{FolderUIDs: []string{savedFolder.UID}, OrgID: 1}) - require.NoError(t, err) - require.Equal(t, int64(2), count) - }) - t.Run("Can delete dashboards in folder", func(t *testing.T) { setup() folder := insertTestDashboard(t, dashboardStore, "dash folder", 1, 0, "", true, "prod", "webapp") @@ -703,10 +631,6 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { err := dashboardStore.DeleteDashboardsInFolders(context.Background(), &dashboards.DeleteDashboardsInFolderRequest{OrgID: folder.OrgID, FolderUIDs: []string{folder.UID}}) require.NoError(t, err) - - count, err := dashboardStore.CountDashboardsInFolders(context.Background(), &dashboards.CountDashboardsInFolderRequest{FolderUIDs: []string{folder.UID}, OrgID: 1}) - require.NoError(t, err) - require.Equal(t, count, int64(0)) }) } @@ -830,6 +754,59 @@ func TestIntegrationDashboard_Filter(t *testing.T) { assert.Equal(t, dashB.ID, results[0].ID) } +// has to be added to both the folder & the dashboard table +func insertTestFolder(t *testing.T, dashboardStore dashboards.Store, sqlStore db.DB, title string, orgId int64, parentUID string, tags ...interface{}) *dashboards.Dashboard { + t.Helper() + + cmd := dashboards.SaveDashboardCommand{ + OrgID: orgId, + FolderUID: parentUID, + IsFolder: true, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": title, + "tags": tags, + }), + } + dash, err := dashboardStore.SaveDashboard(context.Background(), cmd) + require.NoError(t, err) + require.NotNil(t, dash) + dash.Data.Set("id", dash.ID) + dash.Data.Set("uid", dash.UID) + + err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { + type folder struct { + ID int64 `xorm:"pk autoincr 'id'"` + OrgID int64 `xorm:"org_id"` + UID string `xorm:"uid"` + ParentUID *string `xorm:"parent_uid"` + Title string `xorm:"title"` + Description string `xorm:"description"` + Created time.Time `xorm:"created"` + Updated time.Time `xorm:"updated"` + } + + f := &folder{ + OrgID: orgId, + UID: dash.UID, + Title: title, + Description: "", + Created: time.Now(), + Updated: time.Now(), + } + + if parentUID != "" { + f.ParentUID = &parentUID + } + + _, err := sess.Insert(f) + return err + }) + require.NoError(t, err) + + return dash +} + func TestIntegrationFindDashboardsByTitle(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") @@ -844,13 +821,6 @@ func TestIntegrationFindDashboardsByTitle(t *testing.T) { orgID := int64(1) insertTestDashboard(t, dashboardStore, "dashboard under general", orgID, 0, "", false, []string{"tag1", "tag2"}) - ac := acimpl.ProvideAccessControl(features) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) - fStore := folderimpl.ProvideStore(sqlStore) - folderServiceWithFlagOn := folderimpl.ProvideService( - fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, - nil, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService(), apiserver.WithoutRestConfig) - user := &user.SignedInUser{ OrgID: 1, Permissions: map[int64]map[string][]string{ @@ -863,23 +833,10 @@ func TestIntegrationFindDashboardsByTitle(t *testing.T) { }, } - f0, err := folderServiceWithFlagOn.Create(context.Background(), &folder.CreateFolderCommand{ - OrgID: orgID, - Title: "f0", - SignedInUser: user, - }) - require.NoError(t, err) - + f0 := insertTestFolder(t, dashboardStore, sqlStore, "f0", orgID, "") insertTestDashboard(t, dashboardStore, "dashboard under f0", orgID, 0, f0.UID, false, []string{"tag3"}) - subfolder, err := folderServiceWithFlagOn.Create(context.Background(), &folder.CreateFolderCommand{ - OrgID: orgID, - Title: "subfolder", - ParentUID: f0.UID, - SignedInUser: user, - }) - require.NoError(t, err) - + subfolder := insertTestFolder(t, dashboardStore, sqlStore, "subfolder", orgID, f0.UID) insertTestDashboard(t, dashboardStore, "dashboard under subfolder", orgID, 0, subfolder.UID, false) type res struct { @@ -981,14 +938,6 @@ func TestIntegrationFindDashboardsByFolder(t *testing.T) { orgID := int64(1) insertTestDashboard(t, dashboardStore, "dashboard under general", orgID, 0, "", false) - ac := acimpl.ProvideAccessControl(features) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) - fStore := folderimpl.ProvideStore(sqlStore) - - folderServiceWithFlagOn := folderimpl.ProvideService( - fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, - nil, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService(), apiserver.WithoutRestConfig) - user := &user.SignedInUser{ OrgID: 1, Permissions: map[int64]map[string][]string{ @@ -1001,31 +950,13 @@ func TestIntegrationFindDashboardsByFolder(t *testing.T) { }, } - f0, err := folderServiceWithFlagOn.Create(context.Background(), &folder.CreateFolderCommand{ - OrgID: orgID, - Title: "f0", - SignedInUser: user, - }) - require.NoError(t, err) - // nolint:staticcheck - insertTestDashboard(t, dashboardStore, "dashboard under f0", orgID, f0.ID, f0.UID, false) + f0 := insertTestFolder(t, dashboardStore, sqlStore, "f0", orgID, "") + insertTestDashboard(t, dashboardStore, "dashboard under f0", orgID, 0, f0.UID, false) - f1, err := folderServiceWithFlagOn.Create(context.Background(), &folder.CreateFolderCommand{ - OrgID: orgID, - Title: "f1", - SignedInUser: user, - }) - require.NoError(t, err) - // nolint:staticcheck - insertTestDashboard(t, dashboardStore, "dashboard under f1", orgID, f1.ID, f1.UID, false) + f1 := insertTestFolder(t, dashboardStore, sqlStore, "f1", orgID, "") + insertTestDashboard(t, dashboardStore, "dashboard under f1", orgID, 0, f1.UID, false) - subfolder, err := folderServiceWithFlagOn.Create(context.Background(), &folder.CreateFolderCommand{ - OrgID: orgID, - Title: "subfolder", - ParentUID: f0.UID, - SignedInUser: user, - }) - require.NoError(t, err) + subfolder := insertTestFolder(t, dashboardStore, sqlStore, "subfolder", orgID, f0.UID) type res struct { title string diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index de7624545c2..1eb611fa670 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -150,10 +150,6 @@ func (dr *DashboardServiceImpl) cleanupK8sDashboardResources(ctx context.Context ctx, span := tracer.Start(ctx, "dashboards.service.cleanupK8sDashboardResources") defer span.End() - if !dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - return nil - } - readingFromLegacy := dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, dr.dual) if readingFromLegacy { // Legacy does its own cleanup @@ -444,95 +440,83 @@ func (dr *DashboardServiceImpl) getPermissionsService(isFolder bool) accesscontr } func (dr *DashboardServiceImpl) Count(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - u := "a.Map{} - orgs, err := dr.orgService.Search(ctx, &org.SearchOrgsQuery{}) - if err != nil { - return u, err - } + u := "a.Map{} + orgs, err := dr.orgService.Search(ctx, &org.SearchOrgsQuery{}) + if err != nil { + return u, err + } - total := int64(0) - for _, org := range orgs { - ctx, _ := identity.WithServiceIdentity(ctx, org.ID) - orgDashboards, err := dr.CountDashboardsInOrg(ctx, org.ID) + total := int64(0) + for _, org := range orgs { + ctx, _ := identity.WithServiceIdentity(ctx, org.ID) + orgDashboards, err := dr.CountDashboardsInOrg(ctx, org.ID) + if err != nil { + return nil, err + } + total += orgDashboards + + if scopeParams != nil && scopeParams.OrgID == org.ID { + tag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.OrgScope) if err != nil { return nil, err } - total += orgDashboards - - if scopeParams != nil && scopeParams.OrgID == org.ID { - tag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.OrgScope) - if err != nil { - return nil, err - } - u.Set(tag, orgDashboards) - } + u.Set(tag, orgDashboards) } - - tag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.GlobalScope) - if err != nil { - return nil, err - } - u.Set(tag, total) - - return u, nil } - return dr.dashboardStore.Count(ctx, scopeParams) + tag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.GlobalScope) + if err != nil { + return nil, err + } + u.Set(tag, total) + + return u, nil } func (dr *DashboardServiceImpl) GetDashboardsByLibraryPanelUID(ctx context.Context, libraryPanelUID string, orgID int64) ([]*dashboards.DashboardRef, error) { - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) && 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) { - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - resp, err := dr.k8sclient.GetStats(ctx, orgID) - if err != nil { - return 0, err - } - - if len(resp.Stats) != 1 { - return 0, fmt.Errorf("expected 1 stat, got %d", len(resp.Stats)) - } - - return resp.Stats[0].Count, nil + resp, err := dr.k8sclient.GetStats(ctx, orgID) + if err != nil { + return 0, err } - return dr.dashboardStore.CountInOrg(ctx, orgID, false) + if len(resp.Stats) != 1 { + return 0, fmt.Errorf("expected 1 stat, got %d", len(resp.Stats)) + } + + return resp.Stats[0].Count, nil } func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { @@ -557,93 +541,46 @@ func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { } func (dr *DashboardServiceImpl) GetProvisionedDashboardData(ctx context.Context, name string) ([]*dashboards.DashboardProvisioning, error) { - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - orgs, err := dr.orgService.Search(ctx, &org.SearchOrgsQuery{}) - if err != nil { - return nil, err - } - - results := []*dashboards.DashboardProvisioning{} - for _, org := range orgs { - res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ - ManagedBy: utils.ManagerKindClassicFP, // nolint:staticcheck - ManagerIdentity: name, - OrgId: org.ID, - }) - if err != nil { - return nil, err - } - - for _, r := range res { - results = append(results, &r.DashboardProvisioning) - } - } - - return results, nil - } - - return dr.dashboardStore.GetProvisionedDashboardData(ctx, name) -} - -func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardID(ctx context.Context, dashboardID int64) (*dashboards.DashboardProvisioning, error) { - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - // if dashboard id is 0, it is a new dashboard - if dashboardID == 0 { - return nil, nil - } - - orgs, err := dr.orgService.Search(ctx, &org.SearchOrgsQuery{}) - if err != nil { - return nil, err - } - - for _, org := range orgs { - res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ - ManagedBy: utils.ManagerKindClassicFP, // nolint:staticcheck - OrgId: org.ID, - DashboardIds: []int64{dashboardID}, - }) - if err != nil { - return nil, err - } - - if len(res) == 1 { - return &res[0].DashboardProvisioning, nil - } else if len(res) > 1 { - return nil, fmt.Errorf("found more than one provisioned dashboard with ID %d", dashboardID) - } - } - - return nil, nil - } - - data, err := dr.dashboardStore.GetProvisionedDataByDashboardID(ctx, dashboardID) + orgs, err := dr.orgService.Search(ctx, &org.SearchOrgsQuery{}) if err != nil { return nil, err } - if data == nil { + + results := []*dashboards.DashboardProvisioning{} + for _, org := range orgs { + res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ + ManagedBy: utils.ManagerKindClassicFP, // nolint:staticcheck + ManagerIdentity: name, + OrgId: org.ID, + }) + if err != nil { + return nil, err + } + + for _, r := range res { + results = append(results, &r.DashboardProvisioning) + } + } + + return results, nil +} + +func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardID(ctx context.Context, dashboardID int64) (*dashboards.DashboardProvisioning, error) { + // if dashboard id is 0, it is a new dashboard + if dashboardID == 0 { return nil, nil } - return &dashboards.DashboardProvisioning{ - DashboardID: data.Dashboard.ID, - Name: data.Provisioner, - ExternalID: data.ExternalID, - CheckSum: data.CheckSum, - Updated: data.ProvisionUpdate, - }, nil -} - -func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardUID(ctx context.Context, orgID int64, dashboardUID string) (*dashboards.DashboardProvisioning, error) { - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - if dashboardUID == "" { - return nil, nil - } + orgs, err := dr.orgService.Search(ctx, &org.SearchOrgsQuery{}) + if err != nil { + return nil, err + } + for _, org := range orgs { res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ - ManagedBy: utils.ManagerKindClassicFP, // nolint:staticcheck - OrgId: orgID, - DashboardUIDs: []string{dashboardUID}, + ManagedBy: utils.ManagerKindClassicFP, // nolint:staticcheck + OrgId: org.ID, + DashboardIds: []int64{dashboardID}, }) if err != nil { return nil, err @@ -652,27 +589,34 @@ func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardUID(ctx co if len(res) == 1 { return &res[0].DashboardProvisioning, nil } else if len(res) > 1 { - return nil, fmt.Errorf("found more than one provisioned dashboard with UID %s", dashboardUID) + return nil, fmt.Errorf("found more than one provisioned dashboard with ID %d", dashboardID) } + } + return nil, nil +} + +func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardUID(ctx context.Context, orgID int64, dashboardUID string) (*dashboards.DashboardProvisioning, error) { + if dashboardUID == "" { return nil, nil } - data, err := dr.dashboardStore.GetProvisionedDataByDashboardUID(ctx, orgID, dashboardUID) + res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ + ManagedBy: utils.ManagerKindClassicFP, // nolint:staticcheck + OrgId: orgID, + DashboardUIDs: []string{dashboardUID}, + }) if err != nil { return nil, err } - if data == nil { - return nil, nil + + if len(res) == 1 { + return &res[0].DashboardProvisioning, nil + } else if len(res) > 1 { + return nil, fmt.Errorf("found more than one provisioned dashboard with UID %s", dashboardUID) } - return &dashboards.DashboardProvisioning{ - DashboardID: data.Dashboard.ID, - Name: data.Provisioner, - ExternalID: data.ExternalID, - CheckSum: data.CheckSum, - Updated: data.ProvisionUpdate, - }, nil + return nil, nil } func (dr *DashboardServiceImpl) ValidateBasicDashboardProperties(title string, uid string, message string) error { @@ -730,7 +674,7 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d } // Validate folder - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) && (dash.FolderID != 0 || dash.FolderUID != "") { // nolint:staticcheck + if dash.FolderID != 0 || dash.FolderUID != "" { // nolint:staticcheck folder, err := dr.folderService.Get(ctx, &folder.GetFolderQuery{ OrgID: dash.OrgID, UID: &dash.FolderUID, @@ -744,22 +688,6 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d // nolint:staticcheck dash.FolderID = folder.ID dash.FolderUID = folder.UID - } else if dash.FolderUID != "" { - folder, err := dr.folderStore.GetFolderByUID(ctx, dash.OrgID, dash.FolderUID) - if err != nil { - return nil, err - } - metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() - // nolint:staticcheck - dash.FolderID = folder.ID - } else if dash.FolderID != 0 { // nolint:staticcheck - metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() - // nolint:staticcheck - folder, err := dr.folderStore.GetFolderByID(ctx, dash.OrgID, dash.FolderID) - if err != nil { - return nil, err - } - dash.FolderUID = folder.UID } isParentFolderChanged, err := dr.ValidateDashboardBeforeSave(ctx, dash, dto.Overwrite) @@ -944,46 +872,42 @@ func (dr *DashboardServiceImpl) waitForSearchQuery(ctx context.Context, query *d } func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *dashboards.DeleteOrphanedProvisionedDashboardsCommand) error { - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - // check each org for orphaned provisioned dashboards - orgs, err := dr.orgService.Search(ctx, &org.SearchOrgsQuery{}) + // check each org for orphaned provisioned dashboards + orgs, err := dr.orgService.Search(ctx, &org.SearchOrgsQuery{}) + if err != nil { + return err + } + + for _, org := range orgs { + ctx, _ := identity.WithServiceIdentity(ctx, org.ID) + // find all dashboards in the org that have a file repo set that is not in the given readers list + foundDashs, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ + ManagedBy: utils.ManagerKindClassicFP, //nolint:staticcheck + ManagerIdentityNotIn: cmd.ReaderNames, + OrgId: org.ID, + }) if err != nil { return err } + dr.log.Debug("Found dashboards to be deleted", "orgId", org.ID, "count", len(foundDashs)) - for _, org := range orgs { - ctx, _ := identity.WithServiceIdentity(ctx, org.ID) - // find all dashboards in the org that have a file repo set that is not in the given readers list - foundDashs, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ - ManagedBy: utils.ManagerKindClassicFP, //nolint:staticcheck - ManagerIdentityNotIn: cmd.ReaderNames, - OrgId: org.ID, - }) + // delete them + var deletedUids []string + for _, foundDash := range foundDashs { + if err = dr.deleteDashboard(ctx, foundDash.DashboardID, foundDash.DashboardUID, org.ID, false); err != nil { + return err + } + deletedUids = append(deletedUids, foundDash.DashboardUID) + } + if len(deletedUids) > 0 { + // wait for deleted dashboards to be removed from the index + err = dr.waitForSearchQuery(ctx, &dashboards.FindPersistedDashboardsQuery{OrgId: org.ID, DashboardUIDs: deletedUids}, 5, 0) if err != nil { return err } - dr.log.Debug("Found dashboards to be deleted", "orgId", org.ID, "count", len(foundDashs)) - - // delete them - var deletedUids []string - for _, foundDash := range foundDashs { - if err = dr.deleteDashboard(ctx, foundDash.DashboardID, foundDash.DashboardUID, org.ID, false); err != nil { - return err - } - deletedUids = append(deletedUids, foundDash.DashboardUID) - } - if len(deletedUids) > 0 { - // wait for deleted dashboards to be removed from the index - err = dr.waitForSearchQuery(ctx, &dashboards.FindPersistedDashboardsQuery{OrgId: org.ID, DashboardUIDs: deletedUids}, 5, 0) - if err != nil { - return err - } - } } - return nil } - - return dr.dashboardStore.DeleteOrphanedProvisionedDashboards(ctx, cmd) + return nil } func (dr *DashboardServiceImpl) ValidateDashboardRefreshInterval(minRefreshInterval string, targetRefreshInterval string) error { @@ -1034,13 +958,7 @@ func (dr *DashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dt return nil, fmt.Errorf("failed to build save dashboard command. cmd is nil") } - var dash *dashboards.Dashboard - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - dash, err = dr.saveProvisionedDashboardThroughK8s(ctx, cmd, provisioning, false) - } else { - dash, err = dr.dashboardStore.SaveProvisionedDashboard(ctx, *cmd, provisioning) - } - + dash, err := dr.saveProvisionedDashboardThroughK8s(ctx, cmd, provisioning, false) if err != nil { return nil, err } @@ -1065,10 +983,6 @@ func (dr *DashboardServiceImpl) SaveFolderForProvisionedDashboards(ctx context.C return nil, err } - // Only set default permissions if the Folder API Server is disabled. - if !dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - dr.setDefaultFolderPermissions(ctx, dto, f) - } return f, nil } @@ -1103,11 +1017,7 @@ func (dr *DashboardServiceImpl) SaveDashboard(ctx context.Context, dto *dashboar } func (dr *DashboardServiceImpl) saveDashboard(ctx context.Context, cmd *dashboards.SaveDashboardCommand) (*dashboards.Dashboard, error) { - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - return dr.saveDashboardThroughK8s(ctx, cmd, cmd.OrgID) - } - - return dr.dashboardStore.SaveDashboard(ctx, *cmd) + return dr.saveDashboardThroughK8s(ctx, cmd, cmd.OrgID) } // DeleteDashboard removes dashboard from the DB. Errors out if the dashboard was provisioned. Should be used for @@ -1118,11 +1028,7 @@ func (dr *DashboardServiceImpl) DeleteDashboard(ctx context.Context, dashboardId // DeleteAllDashboards will delete all dashboards within a given org. func (dr *DashboardServiceImpl) DeleteAllDashboards(ctx context.Context, orgId int64) error { - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - return dr.deleteAllDashboardThroughK8s(ctx, orgId) - } - - return dr.dashboardStore.DeleteAllDashboards(ctx, orgId) + return dr.deleteAllDashboardThroughK8s(ctx, orgId) } func (dr *DashboardServiceImpl) GetDashboardByPublicUid(ctx context.Context, dashboardPublicUid string) (*dashboards.Dashboard, error) { @@ -1141,28 +1047,7 @@ func (dr *DashboardServiceImpl) deleteDashboard(ctx context.Context, dashboardId cmd := &dashboards.DeleteDashboardCommand{OrgID: orgId, ID: dashboardId, UID: dashboardUID} - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - return dr.deleteDashboardThroughK8s(ctx, cmd, validateProvisionedDashboard) - } - - if validateProvisionedDashboard { - provisionedData, err := dr.GetProvisionedDashboardDataByDashboardID(ctx, dashboardId) - if err != nil { - return fmt.Errorf("%v: %w", "failed to check if dashboard is provisioned", err) - } - - if provisionedData != nil { - return dashboards.ErrDashboardCannotDeleteProvisionedDashboard - } - } - - // deletes all related public dashboard entities - err := dr.publicDashboardService.DeleteByDashboardUIDs(ctx, orgId, []string{dashboardUID}) - if err != nil { - return err - } - - return dr.dashboardStore.DeleteDashboard(ctx, cmd) + return dr.deleteDashboardThroughK8s(ctx, cmd, validateProvisionedDashboard) } func (dr *DashboardServiceImpl) ImportDashboard(ctx context.Context, dto *dashboards.SaveDashboardDTO) ( @@ -1195,65 +1080,58 @@ func (dr *DashboardServiceImpl) ImportDashboard(ctx context.Context, dto *dashbo // UnprovisionDashboard removes info about dashboard being provisioned. Used after provisioning configs are changed // and provisioned dashboards are left behind but not deleted. func (dr *DashboardServiceImpl) UnprovisionDashboard(ctx context.Context, dashboardId int64) error { - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - orgs, err := dr.orgService.Search(ctx, &org.SearchOrgsQuery{}) + orgs, err := dr.orgService.Search(ctx, &org.SearchOrgsQuery{}) + if err != nil { + return err + } + + for _, org := range orgs { + ctx, _ = identity.WithServiceIdentity(ctx, org.ID) + dash, err := dr.getDashboardThroughK8s(ctx, &dashboards.GetDashboardQuery{OrgID: org.ID, ID: dashboardId}) + if err != nil { + // if we can't find it in this org, try the next one + continue + } + + _, err = dr.saveProvisionedDashboardThroughK8s(ctx, &dashboards.SaveDashboardCommand{ + OrgID: org.ID, + PluginID: dash.PluginID, + FolderUID: dash.FolderUID, + FolderID: dash.FolderID, // nolint:staticcheck + UpdatedAt: time.Now(), + Dashboard: dash.Data, + }, nil, true) if err != nil { return err } - for _, org := range orgs { - ctx, _ = identity.WithServiceIdentity(ctx, org.ID) - dash, err := dr.getDashboardThroughK8s(ctx, &dashboards.GetDashboardQuery{OrgID: org.ID, ID: dashboardId}) - if err != nil { - // if we can't find it in this org, try the next one - continue - } - - _, err = dr.saveProvisionedDashboardThroughK8s(ctx, &dashboards.SaveDashboardCommand{ - OrgID: org.ID, - PluginID: dash.PluginID, - FolderUID: dash.FolderUID, - FolderID: dash.FolderID, // nolint:staticcheck - UpdatedAt: time.Now(), - Dashboard: dash.Data, - }, nil, true) - if err != nil { - return err - } - - return dr.dashboardStore.UnprovisionDashboard(ctx, dashboardId) - } - - return dashboards.ErrDashboardNotFound + return dr.dashboardStore.UnprovisionDashboard(ctx, dashboardId) } - return dr.dashboardStore.UnprovisionDashboard(ctx, dashboardId) + return dashboards.ErrDashboardNotFound } func (dr *DashboardServiceImpl) GetDashboardsByPluginID(ctx context.Context, query *dashboards.GetDashboardsByPluginIDQuery) ([]*dashboards.Dashboard, error) { - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - dashs, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ - OrgId: query.OrgID, - ManagedBy: utils.ManagerKindPlugin, - ManagerIdentity: query.PluginID, - }) + dashs, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ + OrgId: query.OrgID, + ManagedBy: utils.ManagerKindPlugin, + ManagerIdentity: query.PluginID, + }) + if err != nil { + return nil, err + } + + // search only returns the metadata, need to get the dashboard.Data too + results := make([]*dashboards.Dashboard, len(dashs)) + for i, d := range dashs { + dash, err := dr.GetDashboard(ctx, &dashboards.GetDashboardQuery{OrgID: d.OrgID, UID: d.UID}) if err != nil { return nil, err } - - // search only returns the metadata, need to get the dashboard.Data too - results := make([]*dashboards.Dashboard, len(dashs)) - for i, d := range dashs { - dash, err := dr.GetDashboard(ctx, &dashboards.GetDashboardQuery{OrgID: d.OrgID, UID: d.UID}) - if err != nil { - return nil, err - } - results[i] = dash - } - - return results, nil + results[i] = dash } - return dr.dashboardStore.GetDashboardsByPluginID(ctx, query) + + return results, nil } // (sometimes) called by the k8s storage engine after creating an object @@ -1361,92 +1239,62 @@ func (dr *DashboardServiceImpl) SetDefaultPermissions(ctx context.Context, dto * } } -func (dr *DashboardServiceImpl) setDefaultFolderPermissions(ctx context.Context, cmd *folder.CreateFolderCommand, f *folder.Folder) { - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) || !dr.cfg.RBAC.PermissionsOnCreation("folder") || f.ParentUID != "" { - return - } - - ctx, span := tracer.Start(ctx, "dashboards.service.setDefaultFolderPermissions") - defer span.End() - - permissions := []accesscontrol.SetResourcePermissionCommand{ - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()}, - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()}, - } - - if _, err := dr.folderPermissions.SetPermissions(ctx, cmd.OrgID, f.UID, permissions...); err != nil { - dr.log.Error("Could not set default folder permissions", "folder", f.Title, "error", err) - } -} - func (dr *DashboardServiceImpl) GetDashboard(ctx context.Context, query *dashboards.GetDashboardQuery) (*dashboards.Dashboard, error) { - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - return dr.getDashboardThroughK8s(ctx, query) - } - - return dr.dashboardStore.GetDashboard(ctx, query) + return dr.getDashboardThroughK8s(ctx, query) } func (dr *DashboardServiceImpl) GetDashboardUIDByID(ctx context.Context, query *dashboards.GetDashboardRefByIDQuery) (*dashboards.DashboardRef, error) { - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - requester, err := identity.GetRequester(ctx) - if err != nil { - return nil, err - } - result, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ - OrgId: requester.GetOrgID(), - DashboardIds: []int64{query.ID}, - }) - if err != nil { - return nil, err - } - - if len(result) == 0 { - return nil, dashboards.ErrDashboardNotFound - } else if len(result) > 1 { - return nil, fmt.Errorf("unexpected number of dashboards found: %d. desired: 1", len(result)) - } - - return &dashboards.DashboardRef{UID: result[0].UID, Slug: result[0].Slug, FolderUID: result[0].FolderUID}, nil + requester, err := identity.GetRequester(ctx) + if err != nil { + return nil, err + } + result, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ + OrgId: requester.GetOrgID(), + DashboardIds: []int64{query.ID}, + }) + if err != nil { + return nil, err } - return dr.dashboardStore.GetDashboardUIDByID(ctx, query) + if len(result) == 0 { + return nil, dashboards.ErrDashboardNotFound + } else if len(result) > 1 { + return nil, fmt.Errorf("unexpected number of dashboards found: %d. desired: 1", len(result)) + } + + return &dashboards.DashboardRef{UID: result[0].UID, Slug: result[0].Slug, FolderUID: result[0].FolderUID}, nil } // expensive query in new flow !! use sparingly - only if you truly need dashboard.Data func (dr *DashboardServiceImpl) GetDashboards(ctx context.Context, query *dashboards.GetDashboardsQuery) ([]*dashboards.Dashboard, error) { - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - if query.OrgID == 0 { - requester, err := identity.GetRequester(ctx) - if err != nil { - return nil, err - } - query.OrgID = requester.GetOrgID() - } - - dashs, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ - DashboardIds: query.DashboardIDs, - OrgId: query.OrgID, - DashboardUIDs: query.DashboardUIDs, - }) + if query.OrgID == 0 { + requester, err := identity.GetRequester(ctx) if err != nil { return nil, err } - - // search only returns the metadata, need to get the dashboard.Data too - results := make([]*dashboards.Dashboard, len(dashs)) - for i, d := range dashs { - dash, err := dr.GetDashboard(ctx, &dashboards.GetDashboardQuery{OrgID: d.OrgID, UID: d.UID}) - if err != nil { - return nil, err - } - results[i] = dash - } - - return results, nil + query.OrgID = requester.GetOrgID() } - return dr.dashboardStore.GetDashboards(ctx, query) + dashs, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ + DashboardIds: query.DashboardIDs, + OrgId: query.OrgID, + DashboardUIDs: query.DashboardUIDs, + }) + if err != nil { + return nil, err + } + + // search only returns the metadata, need to get the dashboard.Data too + results := make([]*dashboards.Dashboard, len(dashs)) + for i, d := range dashs { + dash, err := dr.GetDashboard(ctx, &dashboards.GetDashboardQuery{OrgID: d.OrgID, UID: d.UID}) + if err != nil { + return nil, err + } + results[i] = dash + } + + return results, nil } func (dr *DashboardServiceImpl) getDashboardsSharedWithUser(ctx context.Context, user identity.Requester) ([]*dashboards.DashboardRef, error) { @@ -1468,21 +1316,10 @@ func (dr *DashboardServiceImpl) getDashboardsSharedWithUser(ctx context.Context, return []*dashboards.DashboardRef{}, nil } - dashboardsQuery := &dashboards.GetDashboardsQuery{ + dashs, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ DashboardUIDs: dashboardUids, - OrgID: user.GetOrgID(), - } - - var err error - var dashs []*dashboards.Dashboard - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - dashs, err = dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ - DashboardUIDs: dashboardUids, - OrgId: user.GetOrgID(), - }) - } else { - dashs, err = dr.dashboardStore.GetDashboards(ctx, dashboardsQuery) - } + OrgId: user.GetOrgID(), + }) if err != nil { return nil, err } @@ -1573,67 +1410,63 @@ func (dr *DashboardServiceImpl) FindDashboards(ctx context.Context, query *dashb }(time.Now()) } - if dr.features.IsEnabled(ctx, featuremgmt.FlagKubernetesClientDashboardsFolders) { - if query.OrgId == 0 { - requester, err := identity.GetRequester(ctx) + if query.OrgId == 0 { + requester, err := identity.GetRequester(ctx) + if err != nil { + return nil, err + } + query.OrgId = requester.GetOrgID() + } + + response, err := dr.searchDashboardsThroughK8sRaw(ctx, query) + if err != nil { + return nil, err + } + + folderNames, err := dr.fetchFolderNames(ctx, query, response.Hits) + if err != nil { + return nil, err + } + + finalResults := make([]dashboards.DashboardSearchProjection, len(response.Hits)) + for i, hit := range response.Hits { + folderTitle := "" + folderID := int64(0) + if f, ok := folderNames[hit.Folder]; ok { + folderTitle = f.Title + folderID = f.ID + } + + result := dashboards.DashboardSearchProjection{ + ID: hit.Field.GetNestedInt64(resource.SEARCH_FIELD_LEGACY_ID), + UID: hit.Name, + OrgID: query.OrgId, + Title: hit.Title, + Slug: slugify.Slugify(hit.Title), + IsFolder: false, + FolderUID: hit.Folder, + FolderTitle: folderTitle, + FolderID: folderID, + FolderSlug: slugify.Slugify(folderTitle), + Tags: hit.Tags, + } + + if hit.Field != nil && query.Sort.Name != "" { + fieldName, _, err := legacysearcher.ParseSortName(query.Sort.Name) if err != nil { return nil, err } - query.OrgId = requester.GetOrgID() + result.SortMeta = hit.Field.GetNestedInt64(fieldName) } - response, err := dr.searchDashboardsThroughK8sRaw(ctx, query) - if err != nil { - return nil, err + if hit.Resource == folderv1.RESOURCE { + result.IsFolder = true } - folderNames, err := dr.fetchFolderNames(ctx, query, response.Hits) - if err != nil { - return nil, err - } - - finalResults := make([]dashboards.DashboardSearchProjection, len(response.Hits)) - for i, hit := range response.Hits { - folderTitle := "" - folderID := int64(0) - if f, ok := folderNames[hit.Folder]; ok { - folderTitle = f.Title - folderID = f.ID - } - - result := dashboards.DashboardSearchProjection{ - ID: hit.Field.GetNestedInt64(resource.SEARCH_FIELD_LEGACY_ID), - UID: hit.Name, - OrgID: query.OrgId, - Title: hit.Title, - Slug: slugify.Slugify(hit.Title), - IsFolder: false, - FolderUID: hit.Folder, - FolderTitle: folderTitle, - FolderID: folderID, - FolderSlug: slugify.Slugify(folderTitle), - Tags: hit.Tags, - } - - if hit.Field != nil && query.Sort.Name != "" { - fieldName, _, err := legacysearcher.ParseSortName(query.Sort.Name) - if err != nil { - return nil, err - } - result.SortMeta = hit.Field.GetNestedInt64(fieldName) - } - - if hit.Resource == folderv1.RESOURCE { - result.IsFolder = true - } - - finalResults[i] = result - } - - return finalResults, nil + finalResults[i] = result } - return dr.dashboardStore.FindDashboards(ctx, query) + return finalResults, nil } type folderRes struct { @@ -1680,11 +1513,7 @@ func (dr *DashboardServiceImpl) SearchDashboards(ctx context.Context, query *das } func (dr *DashboardServiceImpl) GetAllDashboardsByOrgId(ctx context.Context, orgID int64) ([]*dashboards.Dashboard, error) { - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - return dr.listDashboardsThroughK8s(ctx, orgID) - } - - return dr.dashboardStore.GetAllDashboardsByOrgId(ctx, orgID) + return dr.listDashboardsThroughK8s(ctx, orgID) } func getHitType(item dashboards.DashboardSearchProjection) model.HitType { @@ -1743,51 +1572,43 @@ func makeQueryResult(query *dashboards.FindPersistedDashboardsQuery, res []dashb } func (dr *DashboardServiceImpl) GetDashboardTags(ctx context.Context, query *dashboards.GetDashboardTagsQuery) ([]*dashboards.DashboardTagCloudItem, error) { - if dr.features.IsEnabled(ctx, featuremgmt.FlagKubernetesClientDashboardsFolders) { - res, err := dr.k8sclient.Search(ctx, query.OrgID, &resourcepb.ResourceSearchRequest{ - Facet: map[string]*resourcepb.ResourceSearchRequest_Facet{ - "tags": { - Field: "tags", - Limit: 100000, - }, + res, err := dr.k8sclient.Search(ctx, query.OrgID, &resourcepb.ResourceSearchRequest{ + Facet: map[string]*resourcepb.ResourceSearchRequest_Facet{ + "tags": { + Field: "tags", + Limit: 100000, }, - Limit: 100000}) - if err != nil { - return nil, err - } - facet, ok := res.Facet["tags"] - if !ok { - return []*dashboards.DashboardTagCloudItem{}, nil - } - - results := make([]*dashboards.DashboardTagCloudItem, len(facet.Terms)) - for i, item := range facet.Terms { - results[i] = &dashboards.DashboardTagCloudItem{ - Term: item.Term, - Count: int(item.Count), - } - } - - return results, nil + }, + Limit: 100000}) + if err != nil { + return nil, err + } + facet, ok := res.Facet["tags"] + if !ok { + return []*dashboards.DashboardTagCloudItem{}, nil } - return dr.dashboardStore.GetDashboardTags(ctx, query) + results := make([]*dashboards.DashboardTagCloudItem, len(facet.Terms)) + for i, item := range facet.Terms { + results[i] = &dashboards.DashboardTagCloudItem{ + Term: item.Term, + Count: int(item.Count), + } + } + + return results, nil } func (dr DashboardServiceImpl) CountInFolders(ctx context.Context, orgID int64, folderUIDs []string, u identity.Requester) (int64, error) { - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - dashs, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ - OrgId: orgID, - FolderUIDs: folderUIDs, - }) - if err != nil { - return 0, err - } - - return int64(len(dashs)), nil + dashs, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ + OrgId: orgID, + FolderUIDs: folderUIDs, + }) + if err != nil { + return 0, err } - return dr.dashboardStore.CountDashboardsInFolders(ctx, &dashboards.CountDashboardsInFolderRequest{FolderUIDs: folderUIDs, OrgID: orgID}) + return int64(len(dashs)), nil } func (dr *DashboardServiceImpl) DeleteInFolders(ctx context.Context, orgID int64, folderUIDs []string, u identity.Requester) error { diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index 34a9279bec4..1cda81c5288 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -54,216 +54,94 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func TestDashboardService(t *testing.T) { - t.Run("Dashboard service tests", func(t *testing.T) { - fakeStore := dashboards.FakeDashboardStore{} - fakePublicDashboardService := publicdashboards.NewFakePublicDashboardServiceWrapper(t) - defer fakeStore.AssertExpectations(t) +func TestDashboardServiceValidation(t *testing.T) { + fakeStore := dashboards.FakeDashboardStore{} + fakePublicDashboardService := publicdashboards.NewFakePublicDashboardServiceWrapper(t) + defer fakeStore.AssertExpectations(t) - folderSvc := foldertest.NewFakeService() - service := &DashboardServiceImpl{ - cfg: setting.NewCfg(), - log: log.New("test.logger"), - dashboardStore: &fakeStore, - folderService: folderSvc, - ac: actest.FakeAccessControl{ExpectedEvaluate: true}, - features: featuremgmt.WithFeatures(), - publicDashboardService: fakePublicDashboardService, - } - folderStore := foldertest.FakeFolderStore{} - folderStore.On("GetFolderByUID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")).Return(nil, dashboards.ErrFolderNotFound).Once() - service.folderStore = &folderStore + service := &DashboardServiceImpl{ + cfg: setting.NewCfg(), + log: log.New("test.logger"), + dashboardStore: &fakeStore, + folderService: foldertest.NewFakeService(), + ac: actest.FakeAccessControl{ExpectedEvaluate: true}, + features: featuremgmt.WithFeatures(), + publicDashboardService: fakePublicDashboardService, + } - t.Run("Save dashboard validation", func(t *testing.T) { - dto := &dashboards.SaveDashboardDTO{} + ctx, k8sCliMock := setupK8sDashboardTests(service) + k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") + k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) - t.Run("When saving a dashboard with empty title it should return error", func(t *testing.T) { - titles := []string{"", " ", " \t "} + t.Run("Save dashboard validation", func(t *testing.T) { + dto := &dashboards.SaveDashboardDTO{} - for _, title := range titles { - dto.Dashboard = dashboards.NewDashboard(title) - _, err := service.SaveDashboard(context.Background(), dto, false) - require.Equal(t, err, dashboards.ErrDashboardTitleEmpty) + t.Run("When saving a dashboard with empty title it should return error", func(t *testing.T) { + titles := []string{"", " ", " \t "} + + for _, title := range titles { + dto.Dashboard = dashboards.NewDashboard(title) + _, err := service.SaveDashboard(ctx, dto, false) + require.Equal(t, err, dashboards.ErrDashboardTitleEmpty) + } + }) + + t.Run("Should return validation error if message is too long", func(t *testing.T) { + dto.Dashboard = dashboards.NewDashboard("Dash") + dto.Message = `Here we go, 500+ characters for testing. I'm sorry that you're + having to read this. I spent too long trying to come up with something clever + to say or a funny joke. Unforuntately, nothing came to mind. So instead, I'm + will share this with you, as a form of payment for having to read this: + https://youtu.be/dQw4w9WgXcQ?si=KeoTIpn9tUtQnOBk! Enjoy :) Now lets see if + this test passes or if the result is more exciting than these 500 characters + I wrote. Best of luck to the both of us!!` + _, err := service.SaveDashboard(ctx, dto, false) + require.Equal(t, err, dashboards.ErrDashboardMessageTooLong) + + // set to a shorter message for the rest of the tests + dto.Message = `message` + }) + + t.Run("Should return validation error if folder is named General", func(t *testing.T) { + dto.Dashboard = dashboards.NewDashboardFolder("General") + _, err := service.SaveDashboard(ctx, dto, false) + require.Equal(t, err, dashboards.ErrDashboardFolderNameExists) + }) + + t.Run("When saving a dashboard should validate uid", func(t *testing.T) { + testCases := []struct { + Uid string + Error error + }{ + {Uid: "", Error: nil}, + {Uid: " ", Error: nil}, + {Uid: " \t ", Error: nil}, + {Uid: "asdf90_-", Error: nil}, + {Uid: "asdf/90", Error: dashboards.ErrDashboardInvalidUid}, + {Uid: " asdfghjklqwertyuiopzxcvbnmasdfghjklqwer ", Error: nil}, + {Uid: "asdfghjklqwertyuiopzxcvbnmasdfghjklqwertyuiopzxcvbnmasdfghjklqwertyuiopzxcvbnm", Error: dashboards.ErrDashboardUidTooLong}, + } + + for _, tc := range testCases { + dto.Dashboard = dashboards.NewDashboard("title") + dto.Dashboard.SetUID(tc.Uid) + dto.User = &user.SignedInUser{} + + if tc.Error == nil { + k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Once() } - }) - - t.Run("Should return validation error if message is too long", func(t *testing.T) { - dto.Dashboard = dashboards.NewDashboard("Dash") - dto.Message = `Here we go, 500+ characters for testing. I'm sorry that you're - having to read this. I spent too long trying to come up with something clever - to say or a funny joke. Unforuntately, nothing came to mind. So instead, I'm - will share this with you, as a form of payment for having to read this: - https://youtu.be/dQw4w9WgXcQ?si=KeoTIpn9tUtQnOBk! Enjoy :) Now lets see if - this test passes or if the result is more exciting than these 500 characters - I wrote. Best of luck to the both of us!` - _, err := service.SaveDashboard(context.Background(), dto, false) - require.Equal(t, err, dashboards.ErrDashboardMessageTooLong) - - // set to a shorter message for the rest of the tests - dto.Message = `message` - }) - - t.Run("Should return validation error if folder is named General", func(t *testing.T) { - dto.Dashboard = dashboards.NewDashboardFolder("General") - _, err := service.SaveDashboard(context.Background(), dto, false) - require.Equal(t, err, dashboards.ErrDashboardFolderNameExists) - }) - - t.Run("When saving a dashboard should validate uid", func(t *testing.T) { - testCases := []struct { - Uid string - Error error - }{ - {Uid: "", Error: nil}, - {Uid: " ", Error: nil}, - {Uid: " \t ", Error: nil}, - {Uid: "asdf90_-", Error: nil}, - {Uid: "asdf/90", Error: dashboards.ErrDashboardInvalidUid}, - {Uid: " asdfghjklqwertyuiopzxcvbnmasdfghjklqwer ", Error: nil}, - {Uid: "asdfghjklqwertyuiopzxcvbnmasdfghjklqwertyuiopzxcvbnmasdfghjklqwertyuiopzxcvbnm", Error: dashboards.ErrDashboardUidTooLong}, - } - - for _, tc := range testCases { - dto.Dashboard = dashboards.NewDashboard("title") - dto.Dashboard.SetUID(tc.Uid) - dto.User = &user.SignedInUser{} - - if tc.Error == nil { - fakeStore.On("GetDashboard", mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Once() - } - _, err := service.BuildSaveDashboardCommand(context.Background(), dto, false) - require.Equal(t, err, tc.Error) - } - }) - - t.Run("Should return validation error if a folder that is specified can't be found", func(t *testing.T) { - dto.Dashboard = dashboards.NewDashboard("Dash") - dto.Dashboard.FolderUID = "non-existing-folder" - folderSvc := foldertest.FakeService{ExpectedError: dashboards.ErrFolderNotFound} - service.folderService = &folderSvc - _, err := service.SaveDashboard(context.Background(), dto, false) - require.Equal(t, err, dashboards.ErrFolderNotFound) - }) - - t.Run("Should return validation error if dashboard is provisioned", func(t *testing.T) { - fakeStore.On("GetDashboard", mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Once() - fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything, mock.AnythingOfType("int64")).Return(&dashboards.DashboardProvisioningSearchResults{}, nil).Once() - - dto.Dashboard = dashboards.NewDashboard("Dash") - dto.Dashboard.SetID(3) - dto.User = &user.SignedInUser{UserID: 1} - _, err := service.SaveDashboard(context.Background(), dto, false) - require.Equal(t, err, dashboards.ErrDashboardCannotSaveProvisionedDashboard) - }) - - t.Run("Should not return validation error if dashboard is provisioned but UI updates allowed", func(t *testing.T) { - fakeStore.On("GetDashboard", mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Once() - fakeStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("dashboards.SaveDashboardCommand")).Return(&dashboards.Dashboard{Data: simplejson.New()}, nil).Once() - - dto.Dashboard = dashboards.NewDashboard("Dash") - dto.Dashboard.SetID(3) - dto.User = &user.SignedInUser{UserID: 1} - _, err := service.SaveDashboard(context.Background(), dto, true) - require.NoError(t, err) - }) + _, err := service.BuildSaveDashboardCommand(ctx, dto, false) + require.Equal(t, err, tc.Error) + } }) - t.Run("Save provisioned dashboard validation", func(t *testing.T) { - dto := &dashboards.SaveDashboardDTO{} - - t.Run("Should not return validation error if dashboard is provisioned", func(t *testing.T) { - fakeStore.On("SaveProvisionedDashboard", mock.Anything, mock.AnythingOfType("dashboards.SaveDashboardCommand"), mock.AnythingOfType("*dashboards.DashboardProvisioning")).Return(&dashboards.Dashboard{Data: simplejson.New()}, nil).Once() - dto.Dashboard = dashboards.NewDashboard("Dash") - dto.Dashboard.SetID(3) - dto.User = &user.SignedInUser{UserID: 1} - _, err := service.SaveProvisionedDashboard(context.Background(), dto, nil) - require.NoError(t, err) - }) - - t.Run("Should override invalid refresh interval if dashboard is provisioned", func(t *testing.T) { - fakeStore.On("SaveProvisionedDashboard", mock.Anything, mock.AnythingOfType("dashboards.SaveDashboardCommand"), mock.AnythingOfType("*dashboards.DashboardProvisioning")).Return(&dashboards.Dashboard{Data: simplejson.New()}, nil).Once() - oldRefreshInterval := service.cfg.MinRefreshInterval - service.cfg.MinRefreshInterval = "5m" - defer func() { service.cfg.MinRefreshInterval = oldRefreshInterval }() - - dto.Dashboard = dashboards.NewDashboard("Dash") - dto.Dashboard.SetID(3) - dto.User = &user.SignedInUser{UserID: 1} - dto.Dashboard.Data.Set("refresh", "1s") - _, err := service.SaveProvisionedDashboard(context.Background(), dto, nil) - require.NoError(t, err) - require.Equal(t, dto.Dashboard.Data.Get("refresh").MustString(), "5m") - }) - }) - - t.Run("Import dashboard validation", func(t *testing.T) { - dto := &dashboards.SaveDashboardDTO{} - - t.Run("Should return validation error if dashboard is provisioned", func(t *testing.T) { - fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything, mock.AnythingOfType("int64")).Return(&dashboards.DashboardProvisioningSearchResults{}, nil).Once() - - dto.Dashboard = dashboards.NewDashboard("Dash") - dto.Dashboard.SetID(3) - dto.User = &user.SignedInUser{UserID: 1} - _, err := service.ImportDashboard(context.Background(), dto) - require.Equal(t, err, dashboards.ErrDashboardCannotSaveProvisionedDashboard) - }) - }) - - t.Run("Given provisioned dashboard", func(t *testing.T) { - t.Run("DeleteProvisionedDashboard should delete it", func(t *testing.T) { - args := &dashboards.DeleteDashboardCommand{OrgID: 1, ID: 1} - fakeStore.On("DeleteDashboard", mock.Anything, args).Return(nil).Once() - fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() - err := service.DeleteProvisionedDashboard(context.Background(), 1, 1) - require.NoError(t, err) - }) - - t.Run("DeleteDashboard should fail to delete it when provisioning information is missing", func(t *testing.T) { - fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything, mock.AnythingOfType("int64")).Return(&dashboards.DashboardProvisioningSearchResults{}, nil).Once() - err := service.DeleteDashboard(context.Background(), 1, "", 1) - require.Equal(t, err, dashboards.ErrDashboardCannotDeleteProvisionedDashboard) - }) - }) - - t.Run("Given non provisioned dashboard", func(t *testing.T) { - t.Run("DeleteProvisionedDashboard should delete the dashboard", func(t *testing.T) { - args := &dashboards.DeleteDashboardCommand{OrgID: 1, ID: 1} - fakeStore.On("DeleteDashboard", mock.Anything, args).Return(nil).Once() - fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() - err := service.DeleteProvisionedDashboard(context.Background(), 1, 1) - require.NoError(t, err) - }) - - t.Run("DeleteDashboard should delete it", func(t *testing.T) { - args := &dashboards.DeleteDashboardCommand{OrgID: 1, ID: 1} - fakeStore.On("DeleteDashboard", mock.Anything, args).Return(nil).Once() - fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything, mock.AnythingOfType("int64")).Return(nil, nil).Once() - fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() - err := service.DeleteDashboard(context.Background(), 1, "", 1) - require.NoError(t, err) - }) - }) - - t.Run("Count dashboards in folder", func(t *testing.T) { - fakeStore.On("CountDashboardsInFolders", mock.Anything, mock.AnythingOfType("*dashboards.CountDashboardsInFolderRequest")).Return(int64(3), nil) - folderSvc.ExpectedFolder = &folder.Folder{UID: "i am a folder"} - // set up a ctx with signed in user - usr := &user.SignedInUser{UserID: 1} - ctx := identity.WithRequester(context.Background(), usr) - - count, err := service.CountInFolders(ctx, 1, []string{"i am a folder"}, usr) - require.NoError(t, err) - require.Equal(t, int64(3), count) - }) - - t.Run("Delete dashboards in folder", func(t *testing.T) { - args := &dashboards.DeleteDashboardsInFolderRequest{OrgID: 1, FolderUIDs: []string{"uid"}} - fakeStore.On("DeleteDashboardsInFolders", mock.Anything, args).Return(nil).Once() - fakeStore.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{}, nil).Once() - fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() - err := service.DeleteInFolders(context.Background(), 1, []string{"uid"}, nil) - require.NoError(t, err) + t.Run("Should return validation error if a folder that is specified can't be found", func(t *testing.T) { + dto.Dashboard = dashboards.NewDashboard("Dash") + dto.Dashboard.FolderUID = "non-existing-folder" + folderSvc := foldertest.FakeService{ExpectedError: dashboards.ErrFolderNotFound} + service.folderService = &folderSvc + _, err := service.SaveDashboard(ctx, dto, false) + require.Equal(t, err, dashboards.ErrFolderNotFound) }) }) } @@ -271,7 +149,6 @@ func TestDashboardService(t *testing.T) { func setupK8sDashboardTests(service *DashboardServiceImpl) (context.Context, *client.MockK8sHandler) { mockCli := new(client.MockK8sHandler) service.k8sclient = mockCli - service.features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesClientDashboardsFolders) ctx := context.Background() userCtx := &user.SignedInUser{UserID: 1, OrgID: 1} @@ -281,27 +158,15 @@ func setupK8sDashboardTests(service *DashboardServiceImpl) (context.Context, *cl } func TestGetDashboard(t *testing.T) { - fakeStore := dashboards.FakeDashboardStore{} - defer fakeStore.AssertExpectations(t) service := &DashboardServiceImpl{ - cfg: setting.NewCfg(), - dashboardStore: &fakeStore, + cfg: setting.NewCfg(), } query := &dashboards.GetDashboardQuery{ UID: "test-uid", OrgID: 1, } - t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { - service.features = featuremgmt.WithFeatures() - fakeStore.On("GetDashboard", mock.Anything, query).Return(&dashboards.Dashboard{}, nil).Once() - dashboard, err := service.GetDashboard(context.Background(), query) - require.NoError(t, err) - require.NotNil(t, dashboard) - fakeStore.AssertExpectations(t) - }) - - t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { + t.Run("Should get dashboard", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) dashboardUnstructured := unstructured.Unstructured{Object: map[string]any{ "metadata": map[string]any{ @@ -421,426 +286,350 @@ func TestGetDashboard(t *testing.T) { } func TestGetAllDashboards(t *testing.T) { - fakeStore := dashboards.FakeDashboardStore{} - defer fakeStore.AssertExpectations(t) service := &DashboardServiceImpl{ - cfg: setting.NewCfg(), - dashboardStore: &fakeStore, + cfg: setting.NewCfg(), + } + ctx, k8sCliMock := setupK8sDashboardTests(service) + + dashboardUnstructured := unstructured.Unstructured{Object: map[string]any{ + "metadata": map[string]any{ + "name": "uid", + "generation": int64(1), + }, + "spec": map[string]any{ + "test": "test", + "title": "testing slugify", + }, + }} + + dashboardExpected := dashboards.Dashboard{ + UID: "uid", // uid is the name of the k8s object + Title: "testing slugify", + Slug: "testing-slugify", // slug is taken from title + OrgID: 1, // orgID is populated from the query + Version: 1, // default to version 1 + Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid", "version": int64(1)}), } - t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { - service.features = featuremgmt.WithFeatures() - fakeStore.On("GetAllDashboardsByOrgId", mock.Anything, int64(1)).Return([]*dashboards.Dashboard{}, nil).Once() - dashboard, err := service.GetAllDashboardsByOrgId(context.Background(), 1) - require.NoError(t, err) - require.NotNil(t, dashboard) - fakeStore.AssertExpectations(t) - }) + k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) + k8sCliMock.On("List", mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.UnstructuredList{Items: []unstructured.Unstructured{dashboardUnstructured}}, nil).Once() - t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { - ctx, k8sCliMock := setupK8sDashboardTests(service) - - dashboardUnstructured := unstructured.Unstructured{Object: map[string]any{ - "metadata": map[string]any{ - "name": "uid", - "generation": int64(1), - }, - "spec": map[string]any{ - "test": "test", - "title": "testing slugify", - }, - }} - - dashboardExpected := dashboards.Dashboard{ - UID: "uid", // uid is the name of the k8s object - Title: "testing slugify", - Slug: "testing-slugify", // slug is taken from title - OrgID: 1, // orgID is populated from the query - Version: 1, // default to version 1 - Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid", "version": int64(1)}), - } - - k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) - k8sCliMock.On("List", mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.UnstructuredList{Items: []unstructured.Unstructured{dashboardUnstructured}}, nil).Once() - - dashes, err := service.GetAllDashboardsByOrgId(ctx, 1) - require.NoError(t, err) - require.NotNil(t, dashes) - k8sCliMock.AssertExpectations(t) - // make sure the conversion is working - require.True(t, reflect.DeepEqual(dashes, []*dashboards.Dashboard{&dashboardExpected})) - }) + dashes, err := service.GetAllDashboardsByOrgId(ctx, 1) + require.NoError(t, err) + require.NotNil(t, dashes) + k8sCliMock.AssertExpectations(t) + // make sure the conversion is working + require.True(t, reflect.DeepEqual(dashes, []*dashboards.Dashboard{&dashboardExpected})) } func TestGetAllDashboardsByOrgId(t *testing.T) { - fakeStore := dashboards.FakeDashboardStore{} - defer fakeStore.AssertExpectations(t) service := &DashboardServiceImpl{ - cfg: setting.NewCfg(), - dashboardStore: &fakeStore, + cfg: setting.NewCfg(), + } + ctx, k8sCliMock := setupK8sDashboardTests(service) + + dashboardUnstructured := unstructured.Unstructured{Object: map[string]any{ + "metadata": map[string]any{ + "name": "uid", + "generation": int64(1), + }, + "spec": map[string]any{ + "test": "test", + "title": "testing slugify", + }, + }} + + dashboardExpected := dashboards.Dashboard{ + UID: "uid", // uid is the name of the k8s object + Title: "testing slugify", + Slug: "testing-slugify", // slug is taken from title + OrgID: 1, // orgID is populated from the query + Version: 1, // default to version 1 + Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid", "version": int64(1)}), } - t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { - service.features = featuremgmt.WithFeatures() - fakeStore.On("GetAllDashboardsByOrgId", mock.Anything, int64(1)).Return([]*dashboards.Dashboard{}, nil).Once() - dashboard, err := service.GetAllDashboardsByOrgId(context.Background(), 1) - require.NoError(t, err) - require.NotNil(t, dashboard) - fakeStore.AssertExpectations(t) - }) + k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) + k8sCliMock.On("List", mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.UnstructuredList{Items: []unstructured.Unstructured{dashboardUnstructured}}, nil).Once() - t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { - ctx, k8sCliMock := setupK8sDashboardTests(service) - - dashboardUnstructured := unstructured.Unstructured{Object: map[string]any{ - "metadata": map[string]any{ - "name": "uid", - "generation": int64(1), - }, - "spec": map[string]any{ - "test": "test", - "title": "testing slugify", - }, - }} - - dashboardExpected := dashboards.Dashboard{ - UID: "uid", // uid is the name of the k8s object - Title: "testing slugify", - Slug: "testing-slugify", // slug is taken from title - OrgID: 1, // orgID is populated from the query - Version: 1, // default to version 1 - Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid", "version": int64(1)}), - } - - k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) - k8sCliMock.On("List", mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.UnstructuredList{Items: []unstructured.Unstructured{dashboardUnstructured}}, nil).Once() - - dashes, err := service.GetAllDashboardsByOrgId(ctx, 1) - require.NoError(t, err) - require.NotNil(t, dashes) - k8sCliMock.AssertExpectations(t) - // make sure the conversion is working - require.True(t, reflect.DeepEqual(dashes, []*dashboards.Dashboard{&dashboardExpected})) - }) + dashes, err := service.GetAllDashboardsByOrgId(ctx, 1) + require.NoError(t, err) + require.NotNil(t, dashes) + k8sCliMock.AssertExpectations(t) + // make sure the conversion is working + require.True(t, reflect.DeepEqual(dashes, []*dashboards.Dashboard{&dashboardExpected})) } func TestGetProvisionedDashboardData(t *testing.T) { - fakeStore := dashboards.FakeDashboardStore{} - defer fakeStore.AssertExpectations(t) service := &DashboardServiceImpl{ - cfg: setting.NewCfg(), - dashboardStore: &fakeStore, + cfg: setting.NewCfg(), orgService: &orgtest.FakeOrgService{ ExpectedOrgs: []*org.OrgDTO{{ID: 1}, {ID: 2}}, }, } - t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { - service.features = featuremgmt.WithFeatures() - fakeStore.On("GetProvisionedDashboardData", mock.Anything, "test").Return([]*dashboards.DashboardProvisioning{}, nil).Once() - dashboard, err := service.GetProvisionedDashboardData(context.Background(), "test") - require.NoError(t, err) - require.NotNil(t, dashboard) - fakeStore.AssertExpectations(t) - }) - - t.Run("Should use Kubernetes client if feature flags are enabled and get from relevant org", func(t *testing.T) { - ctx, k8sCliMock := setupK8sDashboardTests(service) - provisioningTimestamp := int64(1234567) - k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") - repo := "test" - k8sCliMock.On("Search", mock.Anything, int64(1), - mock.MatchedBy(func(req *resourcepb.ResourceSearchRequest) bool { - // make sure the kind is added to the query - return req.Options.Fields[0].Values[0] == string(utils.ManagerKindClassicFP) && // nolint:staticcheck - req.Options.Fields[1].Values[0] == repo - })).Return(&resourcepb.ResourceSearchResponse{ - Results: &resourcepb.ResourceTable{ - Columns: []*resourcepb.ResourceTableColumnDefinition{}, - Rows: []*resourcepb.ResourceTableRow{}, - }, - TotalHits: 0, - }, nil).Once() - k8sCliMock.On("Search", mock.Anything, int64(2), mock.MatchedBy(func(req *resourcepb.ResourceSearchRequest) bool { + ctx, k8sCliMock := setupK8sDashboardTests(service) + provisioningTimestamp := int64(1234567) + k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") + repo := "test" + k8sCliMock.On("Search", mock.Anything, int64(1), + mock.MatchedBy(func(req *resourcepb.ResourceSearchRequest) bool { // make sure the kind is added to the query return req.Options.Fields[0].Values[0] == string(utils.ManagerKindClassicFP) && // nolint:staticcheck req.Options.Fields[1].Values[0] == repo })).Return(&resourcepb.ResourceSearchResponse{ - Results: &resourcepb.ResourceTable{ - Columns: []*resourcepb.ResourceTableColumnDefinition{ - { - Name: "title", - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, - { - Name: "folder", - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, - { - Name: resource.SEARCH_FIELD_LEGACY_ID, - Type: resourcepb.ResourceTableColumnDefinition_INT64, - }, - { - Name: resource.SEARCH_FIELD_MANAGER_KIND, // nolint:staticcheck - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, - { - Name: resource.SEARCH_FIELD_MANAGER_ID, - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, - { - Name: resource.SEARCH_FIELD_SOURCE_PATH, - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, - { - Name: resource.SEARCH_FIELD_SOURCE_CHECKSUM, - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, - { - Name: resource.SEARCH_FIELD_SOURCE_TIME, - Type: resourcepb.ResourceTableColumnDefinition_INT64, - }, + Results: &resourcepb.ResourceTable{ + Columns: []*resourcepb.ResourceTableColumnDefinition{}, + Rows: []*resourcepb.ResourceTableRow{}, + }, + TotalHits: 0, + }, nil).Once() + k8sCliMock.On("Search", mock.Anything, int64(2), mock.MatchedBy(func(req *resourcepb.ResourceSearchRequest) bool { + // make sure the kind is added to the query + return req.Options.Fields[0].Values[0] == string(utils.ManagerKindClassicFP) && // nolint:staticcheck + req.Options.Fields[1].Values[0] == repo + })).Return(&resourcepb.ResourceSearchResponse{ + Results: &resourcepb.ResourceTable{ + Columns: []*resourcepb.ResourceTableColumnDefinition{ + { + Name: "title", + Type: resourcepb.ResourceTableColumnDefinition_STRING, }, - Rows: []*resourcepb.ResourceTableRow{ - { - Key: &resourcepb.ResourceKey{ - Name: "uid", - Resource: "dashboard", - }, - Cells: [][]byte{ - []byte("Dashboard 1"), - []byte("folder 1"), - []byte("1"), - []byte(string(utils.ManagerKindClassicFP)), // nolint:staticcheck - []byte(repo), - []byte("path/to/file"), - []byte("hash"), - []byte("1234567"), - }, + { + Name: "folder", + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: resource.SEARCH_FIELD_LEGACY_ID, + Type: resourcepb.ResourceTableColumnDefinition_INT64, + }, + { + Name: resource.SEARCH_FIELD_MANAGER_KIND, // nolint:staticcheck + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: resource.SEARCH_FIELD_MANAGER_ID, + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: resource.SEARCH_FIELD_SOURCE_PATH, + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: resource.SEARCH_FIELD_SOURCE_CHECKSUM, + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: resource.SEARCH_FIELD_SOURCE_TIME, + Type: resourcepb.ResourceTableColumnDefinition_INT64, + }, + }, + Rows: []*resourcepb.ResourceTableRow{ + { + Key: &resourcepb.ResourceKey{ + Name: "uid", + Resource: "dashboard", + }, + Cells: [][]byte{ + []byte("Dashboard 1"), + []byte("folder 1"), + []byte("1"), + []byte(string(utils.ManagerKindClassicFP)), // nolint:staticcheck + []byte(repo), + []byte("path/to/file"), + []byte("hash"), + []byte("1234567"), }, }, }, - TotalHits: 1, - }, nil).Once() - dashes, err := service.GetProvisionedDashboardData(ctx, repo) - require.NoError(t, err) - require.Len(t, dashes, 1) - require.Equal(t, dashes[0], &dashboards.DashboardProvisioning{ - ID: 0, - DashboardID: 1, - Name: "test", - ExternalID: "path/to/file", - CheckSum: "hash", - Updated: provisioningTimestamp, - }) - k8sCliMock.AssertExpectations(t) + }, + TotalHits: 1, + }, nil).Once() + dashes, err := service.GetProvisionedDashboardData(ctx, repo) + require.NoError(t, err) + require.Len(t, dashes, 1) + require.Equal(t, dashes[0], &dashboards.DashboardProvisioning{ + ID: 0, + DashboardID: 1, + Name: "test", + ExternalID: "path/to/file", + CheckSum: "hash", + Updated: provisioningTimestamp, }) + k8sCliMock.AssertExpectations(t) } func TestGetProvisionedDashboardDataByDashboardID(t *testing.T) { - fakeStore := dashboards.FakeDashboardStore{} - defer fakeStore.AssertExpectations(t) service := &DashboardServiceImpl{ - cfg: setting.NewCfg(), - dashboardStore: &fakeStore, + cfg: setting.NewCfg(), orgService: &orgtest.FakeOrgService{ ExpectedOrgs: []*org.OrgDTO{{ID: 1}, {ID: 2}}, }, } - t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { - service.features = featuremgmt.WithFeatures() - fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything, int64(1)).Return(&dashboards.DashboardProvisioningSearchResults{}, nil).Once() - dashboard, err := service.GetProvisionedDashboardDataByDashboardID(context.Background(), 1) - require.NoError(t, err) - require.NotNil(t, dashboard) - fakeStore.AssertExpectations(t) - }) - - t.Run("Should use Kubernetes client if feature flags are enabled and get from whatever org it is in", func(t *testing.T) { - ctx, k8sCliMock := setupK8sDashboardTests(service) - provisioningTimestamp := int64(1234567) - k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") - k8sCliMock.On("Search", mock.Anything, int64(1), mock.Anything).Return(&resourcepb.ResourceSearchResponse{ - Results: &resourcepb.ResourceTable{ - Columns: []*resourcepb.ResourceTableColumnDefinition{}, - Rows: []*resourcepb.ResourceTableRow{}, - }, - TotalHits: 0, - }, nil) - k8sCliMock.On("Search", mock.Anything, int64(2), mock.Anything).Return(&resourcepb.ResourceSearchResponse{ - Results: &resourcepb.ResourceTable{ - Columns: []*resourcepb.ResourceTableColumnDefinition{ - { - Name: "title", - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, - { - Name: "folder", - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, - { - Name: resource.SEARCH_FIELD_LEGACY_ID, - Type: resourcepb.ResourceTableColumnDefinition_INT64, - }, - { - Name: resource.SEARCH_FIELD_MANAGER_KIND, // nolint:staticcheck - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, - { - Name: resource.SEARCH_FIELD_MANAGER_ID, - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, - { - Name: resource.SEARCH_FIELD_SOURCE_PATH, - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, - { - Name: resource.SEARCH_FIELD_SOURCE_CHECKSUM, - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, - { - Name: resource.SEARCH_FIELD_SOURCE_TIME, - Type: resourcepb.ResourceTableColumnDefinition_INT64, - }, + ctx, k8sCliMock := setupK8sDashboardTests(service) + provisioningTimestamp := int64(1234567) + k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") + k8sCliMock.On("Search", mock.Anything, int64(1), mock.Anything).Return(&resourcepb.ResourceSearchResponse{ + Results: &resourcepb.ResourceTable{ + Columns: []*resourcepb.ResourceTableColumnDefinition{}, + Rows: []*resourcepb.ResourceTableRow{}, + }, + TotalHits: 0, + }, nil) + k8sCliMock.On("Search", mock.Anything, int64(2), mock.Anything).Return(&resourcepb.ResourceSearchResponse{ + Results: &resourcepb.ResourceTable{ + Columns: []*resourcepb.ResourceTableColumnDefinition{ + { + Name: "title", + Type: resourcepb.ResourceTableColumnDefinition_STRING, }, - Rows: []*resourcepb.ResourceTableRow{ - { - Key: &resourcepb.ResourceKey{ - Name: "uid", - Resource: "dashboard", - }, - Cells: [][]byte{ - []byte("Dashboard 1"), - []byte("folder 1"), - []byte("1"), - []byte(string(utils.ManagerKindClassicFP)), // nolint:staticcheck - []byte("test"), - []byte("path/to/file"), - []byte("hash"), - []byte("1234567"), - }, + { + Name: "folder", + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: resource.SEARCH_FIELD_LEGACY_ID, + Type: resourcepb.ResourceTableColumnDefinition_INT64, + }, + { + Name: resource.SEARCH_FIELD_MANAGER_KIND, // nolint:staticcheck + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: resource.SEARCH_FIELD_MANAGER_ID, + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: resource.SEARCH_FIELD_SOURCE_PATH, + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: resource.SEARCH_FIELD_SOURCE_CHECKSUM, + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: resource.SEARCH_FIELD_SOURCE_TIME, + Type: resourcepb.ResourceTableColumnDefinition_INT64, + }, + }, + Rows: []*resourcepb.ResourceTableRow{ + { + Key: &resourcepb.ResourceKey{ + Name: "uid", + Resource: "dashboard", + }, + Cells: [][]byte{ + []byte("Dashboard 1"), + []byte("folder 1"), + []byte("1"), + []byte(string(utils.ManagerKindClassicFP)), // nolint:staticcheck + []byte("test"), + []byte("path/to/file"), + []byte("hash"), + []byte("1234567"), }, }, }, - TotalHits: 1, - }, nil) - dash, err := service.GetProvisionedDashboardDataByDashboardID(ctx, 1) - require.NoError(t, err) - require.Equal(t, dash, &dashboards.DashboardProvisioning{ - ID: 0, - DashboardID: 1, - Name: "test", - ExternalID: "path/to/file", - CheckSum: "hash", - Updated: provisioningTimestamp, - }) - k8sCliMock.AssertExpectations(t) + }, + TotalHits: 1, + }, nil) + dash, err := service.GetProvisionedDashboardDataByDashboardID(ctx, 1) + require.NoError(t, err) + require.Equal(t, dash, &dashboards.DashboardProvisioning{ + ID: 0, + DashboardID: 1, + Name: "test", + ExternalID: "path/to/file", + CheckSum: "hash", + Updated: provisioningTimestamp, }) + k8sCliMock.AssertExpectations(t) } func TestGetProvisionedDashboardDataByDashboardUID(t *testing.T) { - fakeStore := dashboards.FakeDashboardStore{} - defer fakeStore.AssertExpectations(t) service := &DashboardServiceImpl{ - cfg: setting.NewCfg(), - dashboardStore: &fakeStore, + cfg: setting.NewCfg(), orgService: &orgtest.FakeOrgService{ ExpectedOrgs: []*org.OrgDTO{{ID: 1}, {ID: 2}}, }, } - - t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { - service.features = featuremgmt.WithFeatures() - fakeStore.On("GetProvisionedDataByDashboardUID", mock.Anything, int64(1), "test").Return(&dashboards.DashboardProvisioningSearchResults{}, nil).Once() - dashboard, err := service.GetProvisionedDashboardDataByDashboardUID(context.Background(), 1, "test") - require.NoError(t, err) - require.NotNil(t, dashboard) - fakeStore.AssertExpectations(t) - }) - - t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { - ctx, k8sCliMock := setupK8sDashboardTests(service) - provisioningTimestamp := int64(1234567) - k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") - k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resourcepb.ResourceSearchResponse{ - Results: &resourcepb.ResourceTable{ - Columns: []*resourcepb.ResourceTableColumnDefinition{ - { - Name: "title", - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, - { - Name: "folder", - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, - { - Name: resource.SEARCH_FIELD_LEGACY_ID, - Type: resourcepb.ResourceTableColumnDefinition_INT64, - }, - { - Name: resource.SEARCH_FIELD_MANAGER_KIND, // nolint:staticcheck - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, - { - Name: resource.SEARCH_FIELD_MANAGER_ID, - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, - { - Name: resource.SEARCH_FIELD_SOURCE_PATH, - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, - { - Name: resource.SEARCH_FIELD_SOURCE_CHECKSUM, - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, - { - Name: resource.SEARCH_FIELD_SOURCE_TIME, - Type: resourcepb.ResourceTableColumnDefinition_INT64, - }, + ctx, k8sCliMock := setupK8sDashboardTests(service) + provisioningTimestamp := int64(1234567) + k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") + k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resourcepb.ResourceSearchResponse{ + Results: &resourcepb.ResourceTable{ + Columns: []*resourcepb.ResourceTableColumnDefinition{ + { + Name: "title", + Type: resourcepb.ResourceTableColumnDefinition_STRING, }, - Rows: []*resourcepb.ResourceTableRow{ - { - Key: &resourcepb.ResourceKey{ - Name: "uid", - Resource: "dashboard", - }, - Cells: [][]byte{ - []byte("Dashboard 1"), - []byte("folder 1"), - []byte("1"), - []byte(string(utils.ManagerKindClassicFP)), // nolint:staticcheck - []byte("test"), - []byte("path/to/file"), - []byte("hash"), - []byte("1234567"), - }, + { + Name: "folder", + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: resource.SEARCH_FIELD_LEGACY_ID, + Type: resourcepb.ResourceTableColumnDefinition_INT64, + }, + { + Name: resource.SEARCH_FIELD_MANAGER_KIND, // nolint:staticcheck + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: resource.SEARCH_FIELD_MANAGER_ID, + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: resource.SEARCH_FIELD_SOURCE_PATH, + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: resource.SEARCH_FIELD_SOURCE_CHECKSUM, + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: resource.SEARCH_FIELD_SOURCE_TIME, + Type: resourcepb.ResourceTableColumnDefinition_INT64, + }, + }, + Rows: []*resourcepb.ResourceTableRow{ + { + Key: &resourcepb.ResourceKey{ + Name: "uid", + Resource: "dashboard", + }, + Cells: [][]byte{ + []byte("Dashboard 1"), + []byte("folder 1"), + []byte("1"), + []byte(string(utils.ManagerKindClassicFP)), // nolint:staticcheck + []byte("test"), + []byte("path/to/file"), + []byte("hash"), + []byte("1234567"), }, }, }, - TotalHits: 1, - }, nil).Once() - dash, err := service.GetProvisionedDashboardDataByDashboardUID(ctx, 1, "uid") - require.NoError(t, err) - require.Equal(t, dash, &dashboards.DashboardProvisioning{ - ID: 0, - DashboardID: 1, - Name: "test", - ExternalID: "path/to/file", - CheckSum: "hash", - Updated: provisioningTimestamp, - }) - k8sCliMock.AssertExpectations(t) + }, + TotalHits: 1, + }, nil).Once() + dash, err := service.GetProvisionedDashboardDataByDashboardUID(ctx, 1, "uid") + require.NoError(t, err) + require.Equal(t, dash, &dashboards.DashboardProvisioning{ + ID: 0, + DashboardID: 1, + Name: "test", + ExternalID: "path/to/file", + CheckSum: "hash", + Updated: provisioningTimestamp, }) + k8sCliMock.AssertExpectations(t) } func TestDeleteOrphanedProvisionedDashboards(t *testing.T) { - fakeStore := dashboards.FakeDashboardStore{} fakePublicDashboardService := publicdashboards.NewFakePublicDashboardServiceWrapper(t) - defer fakeStore.AssertExpectations(t) service := &DashboardServiceImpl{ - cfg: setting.NewCfg(), - dashboardStore: &fakeStore, + cfg: setting.NewCfg(), orgService: &orgtest.FakeOrgService{ ExpectedOrgs: []*org.OrgDTO{{ID: 1}, {ID: 2}}, }, @@ -848,19 +637,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) { log: log.NewNopLogger(), } - t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { - service.features = featuremgmt.WithFeatures() - fakeStore.On("DeleteOrphanedProvisionedDashboards", mock.Anything, &dashboards.DeleteOrphanedProvisionedDashboardsCommand{ - ReaderNames: []string{"test"}, - }).Return(nil).Once() - err := service.DeleteOrphanedProvisionedDashboards(context.Background(), &dashboards.DeleteOrphanedProvisionedDashboardsCommand{ - ReaderNames: []string{"test"}, - }) - require.NoError(t, err) - fakeStore.AssertExpectations(t) - }) - - t.Run("Should use Kubernetes client if feature flags are enabled, delete across all orgs, but only delete file based provisioned dashboards", func(t *testing.T) { + t.Run("Should delete across all orgs, but only delete file based provisioned dashboards", func(t *testing.T) { _, k8sCliMock := setupK8sDashboardTests(service) k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") k8sCliMock.On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) @@ -1007,8 +784,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) { t.Run("Should retry until deleted dashboard not found in search", func(t *testing.T) { repo := "test" singleOrgService := &DashboardServiceImpl{ - cfg: setting.NewCfg(), - dashboardStore: &fakeStore, + cfg: setting.NewCfg(), orgService: &orgtest.FakeOrgService{ ExpectedOrgs: []*org.OrgDTO{{ID: 1}}, }, @@ -1101,8 +877,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) { t.Run("Will not wait for indexer when no dashboards were deleted", func(t *testing.T) { repo := "test" singleOrgService := &DashboardServiceImpl{ - cfg: setting.NewCfg(), - dashboardStore: &fakeStore, + cfg: setting.NewCfg(), orgService: &orgtest.FakeOrgService{ ExpectedOrgs: []*org.OrgDTO{{ID: 1}}, }, @@ -1141,92 +916,78 @@ func TestUnprovisionDashboard(t *testing.T) { }, } - t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { - service.features = featuremgmt.WithFeatures() - fakeStore.On("UnprovisionDashboard", mock.Anything, int64(1)).Return(nil).Once() - err := service.UnprovisionDashboard(context.Background(), 1) - require.NoError(t, err) - fakeStore.AssertExpectations(t) - }) - - t.Run("Should use Kubernetes client if feature flags are enabled - should remove annotations", func(t *testing.T) { - ctx, k8sCliMock := setupK8sDashboardTests(service) - dash := &unstructured.Unstructured{Object: map[string]any{ - "metadata": map[string]any{ - "name": "uid", - "annotations": map[string]any{ - utils.AnnoKeyManagerKind: utils.ManagerKindClassicFP, // nolint:staticcheck - utils.AnnoKeyManagerIdentity: "test", - utils.AnnoKeySourceChecksum: "hash", - utils.AnnoKeySourcePath: "path/to/file", - utils.AnnoKeySourceTimestamp: "2025-01-01T00:00:00Z", + ctx, k8sCliMock := setupK8sDashboardTests(service) + dash := &unstructured.Unstructured{Object: map[string]any{ + "metadata": map[string]any{ + "name": "uid", + "annotations": map[string]any{ + utils.AnnoKeyManagerKind: utils.ManagerKindClassicFP, // nolint:staticcheck + utils.AnnoKeyManagerIdentity: "test", + utils.AnnoKeySourceChecksum: "hash", + utils.AnnoKeySourcePath: "path/to/file", + utils.AnnoKeySourceTimestamp: "2025-01-01T00:00:00Z", + }, + }, + "spec": map[string]any{}, + }} + fakeStore.On("UnprovisionDashboard", mock.Anything, int64(1)).Return(nil).Once() + k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(dash, nil) + dashWithoutAnnotations := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": dashboardv0.APIVERSION, + "kind": "Dashboard", + "metadata": map[string]any{ + "name": "uid", + "namespace": "default", + "annotations": map[string]any{}, + }, + "spec": map[string]any{ + "uid": "uid", + "version": 1, + }, + }} + // should update it to be without annotations + k8sCliMock.On("Update", mock.Anything, dashWithoutAnnotations, mock.Anything, metav1.UpdateOptions{ + FieldValidation: metav1.FieldValidationIgnore, + }).Return(dashWithoutAnnotations, nil) + k8sCliMock.On("GetNamespace", mock.Anything).Return("default") + k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) + k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resourcepb.ResourceSearchResponse{ + Results: &resourcepb.ResourceTable{ + Columns: []*resourcepb.ResourceTableColumnDefinition{ + { + Name: "title", + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: "folder", + Type: resourcepb.ResourceTableColumnDefinition_STRING, }, }, - "spec": map[string]any{}, - }} - fakeStore.On("UnprovisionDashboard", mock.Anything, int64(1)).Return(nil).Once() - k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(dash, nil) - dashWithoutAnnotations := &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": dashboardv0.APIVERSION, - "kind": "Dashboard", - "metadata": map[string]any{ - "name": "uid", - "namespace": "default", - "annotations": map[string]any{}, - }, - "spec": map[string]any{ - "uid": "uid", - "version": 1, - }, - }} - // should update it to be without annotations - k8sCliMock.On("Update", mock.Anything, dashWithoutAnnotations, mock.Anything, metav1.UpdateOptions{ - FieldValidation: metav1.FieldValidationIgnore, - }).Return(dashWithoutAnnotations, nil) - k8sCliMock.On("GetNamespace", mock.Anything).Return("default") - k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) - k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resourcepb.ResourceSearchResponse{ - Results: &resourcepb.ResourceTable{ - Columns: []*resourcepb.ResourceTableColumnDefinition{ - { - Name: "title", - Type: resourcepb.ResourceTableColumnDefinition_STRING, + Rows: []*resourcepb.ResourceTableRow{ + { + Key: &resourcepb.ResourceKey{ + Name: "uid", + Resource: "dashboard", }, - { - Name: "folder", - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, - }, - Rows: []*resourcepb.ResourceTableRow{ - { - Key: &resourcepb.ResourceKey{ - Name: "uid", - Resource: "dashboard", - }, - Cells: [][]byte{ - []byte("Dashboard 1"), - []byte("folder 1"), - }, + Cells: [][]byte{ + []byte("Dashboard 1"), + []byte("folder 1"), }, }, }, - TotalHits: 1, - }, nil) - err := service.UnprovisionDashboard(ctx, 1) - require.NoError(t, err) - k8sCliMock.AssertExpectations(t) - fakeStore.AssertExpectations(t) - }) + }, + TotalHits: 1, + }, nil) + err := service.UnprovisionDashboard(ctx, 1) + require.NoError(t, err) + k8sCliMock.AssertExpectations(t) + fakeStore.AssertExpectations(t) } func TestGetDashboardsByPluginID(t *testing.T) { - fakeStore := dashboards.FakeDashboardStore{} - defer fakeStore.AssertExpectations(t) service := &DashboardServiceImpl{ - cfg: setting.NewCfg(), - dashboardStore: &fakeStore, + cfg: setting.NewCfg(), } - query := &dashboards.GetDashboardsByPluginIDQuery{ PluginID: "testing", OrgID: 1, @@ -1240,119 +1001,87 @@ func TestGetDashboardsByPluginID(t *testing.T) { }, }} - t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { - service.features = featuremgmt.WithFeatures() - fakeStore.On("GetDashboardsByPluginID", mock.Anything, mock.Anything).Return([]*dashboards.Dashboard{}, nil).Once() - _, err := service.GetDashboardsByPluginID(context.Background(), query) - require.NoError(t, err) - fakeStore.AssertExpectations(t) - }) - - t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { - ctx, k8sCliMock := setupK8sDashboardTests(service) - k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") - k8sCliMock.On("Get", mock.Anything, "uid", mock.Anything, mock.Anything, mock.Anything).Return(uidUnstructured, nil) - k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) - k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.MatchedBy(func(req *resourcepb.ResourceSearchRequest) bool { - return ( // gofmt comment helper - req.Options.Fields[0].Key == "manager.kind" && req.Options.Fields[0].Values[0] == string(utils.ManagerKindPlugin) && - req.Options.Fields[1].Key == "manager.id" && req.Options.Fields[1].Values[0] == "testing") - })).Return(&resourcepb.ResourceSearchResponse{ - Results: &resourcepb.ResourceTable{ - Columns: []*resourcepb.ResourceTableColumnDefinition{ - { - Name: "title", - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, - { - Name: "folder", - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, + ctx, k8sCliMock := setupK8sDashboardTests(service) + k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") + k8sCliMock.On("Get", mock.Anything, "uid", mock.Anything, mock.Anything, mock.Anything).Return(uidUnstructured, nil) + k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) + k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.MatchedBy(func(req *resourcepb.ResourceSearchRequest) bool { + return ( // gofmt comment helper + req.Options.Fields[0].Key == "manager.kind" && req.Options.Fields[0].Values[0] == string(utils.ManagerKindPlugin) && + req.Options.Fields[1].Key == "manager.id" && req.Options.Fields[1].Values[0] == "testing") + })).Return(&resourcepb.ResourceSearchResponse{ + Results: &resourcepb.ResourceTable{ + Columns: []*resourcepb.ResourceTableColumnDefinition{ + { + Name: "title", + Type: resourcepb.ResourceTableColumnDefinition_STRING, }, - Rows: []*resourcepb.ResourceTableRow{ - { - Key: &resourcepb.ResourceKey{ - Name: "uid", - Resource: "dashboard", - }, - Cells: [][]byte{ - []byte("Dashboard 1"), - []byte("folder 1"), - }, + { + Name: "folder", + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + }, + Rows: []*resourcepb.ResourceTableRow{ + { + Key: &resourcepb.ResourceKey{ + Name: "uid", + Resource: "dashboard", + }, + Cells: [][]byte{ + []byte("Dashboard 1"), + []byte("folder 1"), }, }, }, - TotalHits: 1, - }, nil) - dashes, err := service.GetDashboardsByPluginID(ctx, query) - require.NoError(t, err) - require.Len(t, dashes, 1) - k8sCliMock.AssertExpectations(t) - }) + }, + TotalHits: 1, + }, nil) + dashes, err := service.GetDashboardsByPluginID(ctx, query) + require.NoError(t, err) + require.Len(t, dashes, 1) + k8sCliMock.AssertExpectations(t) } func TestSetDefaultPermissionsWhenSavingFolderForProvisionedDashboards(t *testing.T) { fakeStore := dashboards.FakeDashboardStore{} defer fakeStore.AssertExpectations(t) - type testCase struct { - description string - expectedCallsToSetPermissions int - featuresArr []any - } + folderPermService := acmock.NewMockedPermissionsService() + folderPermService.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) - tcs := []testCase{ - { - description: "folder creation succeeds, via legacy storage", - expectedCallsToSetPermissions: 1, - }, - { - description: "folder creation succeeds, via API Server", - expectedCallsToSetPermissions: 0, - featuresArr: []any{featuremgmt.FlagKubernetesClientDashboardsFolders}, + cfg := setting.NewCfg() + f := ini.Empty() + f.Section("rbac").Key("resources_with_managed_permissions_on_creation").SetValue("folder") + tempCfg, err := setting.NewCfgFromINIFile(f) + require.NoError(t, err) + cfg.RBAC = tempCfg.RBAC + + service := &DashboardServiceImpl{ + cfg: cfg, + dashboardStore: &fakeStore, + folderPermissions: folderPermService, + folderService: &foldertest.FakeService{ + ExpectedFolder: &folder.Folder{ + ID: 0, + UID: "general", + }, }, + ac: actest.FakeAccessControl{ExpectedEvaluate: true}, + acService: &actest.FakeService{}, + log: log.NewNopLogger(), } - for _, tc := range tcs { - t.Run(tc.description, func(t *testing.T) { - folderPermService := acmock.NewMockedPermissionsService() - folderPermService.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) - - cfg := setting.NewCfg() - f := ini.Empty() - f.Section("rbac").Key("resources_with_managed_permissions_on_creation").SetValue("folder") - tempCfg, err := setting.NewCfgFromINIFile(f) - require.NoError(t, err) - cfg.RBAC = tempCfg.RBAC - - service := &DashboardServiceImpl{ - cfg: cfg, - dashboardStore: &fakeStore, - folderPermissions: folderPermService, - folderService: &foldertest.FakeService{ - ExpectedFolder: &folder.Folder{ - ID: 0, - UID: "general", - }, - }, - ac: actest.FakeAccessControl{ExpectedEvaluate: true}, - acService: &actest.FakeService{}, - log: log.NewNopLogger(), - } - - cmd := &folder.CreateFolderCommand{ - Title: "foo", - OrgID: 1, - } - - service.features = featuremgmt.WithFeatures(tc.featuresArr...) - folder, err := service.SaveFolderForProvisionedDashboards(context.Background(), cmd) - require.NoError(t, err) - require.NotNil(t, folder) - - folderPermService.AssertNumberOfCalls(t, "SetPermissions", tc.expectedCallsToSetPermissions) - }) + cmd := &folder.CreateFolderCommand{ + Title: "foo", + OrgID: 1, } + + service.features = featuremgmt.WithFeatures() + folder, err := service.SaveFolderForProvisionedDashboards(context.Background(), cmd) + require.NoError(t, err) + require.NotNil(t, folder) + + folderPermService.AssertNumberOfCalls(t, "SetPermissions", 0) } func TestSaveProvisionedDashboard(t *testing.T) { @@ -1382,17 +1111,6 @@ func TestSaveProvisionedDashboard(t *testing.T) { Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid"}), }, } - - t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { - service.features = featuremgmt.WithFeatures() - fakeStore.On("GetDashboard", mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil) - fakeStore.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil) - dashboard, err := service.SaveProvisionedDashboard(context.Background(), query, &dashboards.DashboardProvisioning{}) - require.NoError(t, err) - require.NotNil(t, dashboard) - fakeStore.AssertExpectations(t) - }) - dashboardUnstructured := unstructured.Unstructured{Object: map[string]any{ "metadata": map[string]any{ "name": "uid", @@ -1404,22 +1122,20 @@ func TestSaveProvisionedDashboard(t *testing.T) { }, }} - t.Run("Should use Kubernetes create if feature flags are enabled", func(t *testing.T) { - ctx, k8sCliMock := setupK8sDashboardTests(service) - fakeStore.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil) - k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) - k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, metav1.UpdateOptions{ - FieldValidation: metav1.FieldValidationIgnore, - }).Return(&dashboardUnstructured, nil) - k8sCliMock.On("GetNamespace", mock.Anything).Return("default") + ctx, k8sCliMock := setupK8sDashboardTests(service) + k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) + k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) + k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, metav1.UpdateOptions{ + FieldValidation: metav1.FieldValidationIgnore, + }).Return(&dashboardUnstructured, nil) + k8sCliMock.On("GetNamespace", mock.Anything).Return("default") - dashboard, err := service.SaveProvisionedDashboard(ctx, query, &dashboards.DashboardProvisioning{}) - require.NoError(t, err) - require.NotNil(t, dashboard) - k8sCliMock.AssertExpectations(t) - // ensure the provisioning data is still saved to the db - fakeStore.AssertExpectations(t) - }) + dashboard, err := service.SaveProvisionedDashboard(ctx, query, &dashboards.DashboardProvisioning{}) + require.NoError(t, err) + require.NotNil(t, dashboard) + k8sCliMock.AssertExpectations(t) + // ensure the provisioning data is still saved to the db + fakeStore.AssertExpectations(t) } func TestSaveDashboard(t *testing.T) { @@ -1446,17 +1162,6 @@ func TestSaveDashboard(t *testing.T) { }, } - t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { - service.features = featuremgmt.WithFeatures() - fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything, mock.Anything).Return(nil, nil) - fakeStore.On("GetDashboard", mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil) - fakeStore.On("SaveDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil) - dashboard, err := service.SaveDashboard(context.Background(), query, false) - require.NoError(t, err) - require.NotNil(t, dashboard) - fakeStore.AssertExpectations(t) - }) - dashboardUnstructured := unstructured.Unstructured{Object: map[string]any{ "metadata": map[string]any{ "name": "uid", @@ -1470,6 +1175,7 @@ func TestSaveDashboard(t *testing.T) { t.Run("Should use Kubernetes create if feature flags are enabled and dashboard doesn't exist", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) + k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) k8sCliMock.On("GetNamespace", mock.Anything).Return("default") k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, metav1.UpdateOptions{ @@ -1483,6 +1189,7 @@ func TestSaveDashboard(t *testing.T) { t.Run("Should use Kubernetes update if feature flags are enabled and dashboard exists", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) + k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) k8sCliMock.On("GetNamespace", mock.Anything).Return("default") k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, metav1.UpdateOptions{ @@ -1517,17 +1224,6 @@ func TestDeleteDashboard(t *testing.T) { publicDashboardService: fakePublicDashboardService, } - t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { - service.features = featuremgmt.WithFeatures() - fakeStore.On("DeleteDashboard", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() - fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything, mock.Anything).Return(nil, nil).Once() - fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() - - err := service.DeleteDashboard(context.Background(), 1, "uid", 1) - require.NoError(t, err) - fakeStore.AssertExpectations(t) - }) - t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) k8sCliMock.On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() @@ -1583,22 +1279,12 @@ func TestDeleteAllDashboards(t *testing.T) { dashboardStore: &fakeStore, } - t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { - service.features = featuremgmt.WithFeatures() - fakeStore.On("DeleteAllDashboards", mock.Anything, mock.Anything).Return(nil).Once() - err := service.DeleteAllDashboards(context.Background(), 1) - require.NoError(t, err) - fakeStore.AssertExpectations(t) - }) + ctx, k8sCliMock := setupK8sDashboardTests(service) + k8sCliMock.On("DeleteCollection", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() - t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { - ctx, k8sCliMock := setupK8sDashboardTests(service) - k8sCliMock.On("DeleteCollection", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() - - err := service.DeleteAllDashboards(ctx, 1) - require.NoError(t, err) - k8sCliMock.AssertExpectations(t) - }) + err := service.DeleteAllDashboards(ctx, 1) + require.NoError(t, err) + k8sCliMock.AssertExpectations(t) } func TestSearchDashboards(t *testing.T) { @@ -1612,6 +1298,7 @@ func TestSearchDashboards(t *testing.T) { defer fakeStore.AssertExpectations(t) service := &DashboardServiceImpl{ cfg: setting.NewCfg(), + features: featuremgmt.WithFeatures(), dashboardStore: &fakeStore, folderService: fakeFolders, metrics: newDashboardsMetrics(prometheus.NewRegistry()), @@ -1651,38 +1338,8 @@ func TestSearchDashboards(t *testing.T) { query := dashboards.FindPersistedDashboardsQuery{ DashboardUIDs: []string{"uid1", "uid2"}, } - t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { - service.features = featuremgmt.WithFeatures() - fakeStore.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{ - { - UID: "uid1", - Slug: "dashboard-1", - OrgID: 1, - Title: "Dashboard 1", - Tags: []string{"tag1", "tag2"}, - FolderTitle: "testing-folder-1", - FolderSlug: "testing-folder-1", - FolderUID: "f1", - FolderID: 1, - }, - { - UID: "uid2", - Slug: "dashboard-2", - OrgID: 1, - Title: "Dashboard 2", - FolderTitle: "testing-folder-1", - FolderSlug: "testing-folder-1", - FolderUID: "f1", - FolderID: 1, - }, - }, nil).Once() - result, err := service.SearchDashboards(context.Background(), &query) - require.NoError(t, err) - require.Equal(t, expectedResult, result) - fakeStore.AssertExpectations(t) - }) - t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { + t.Run("Should search correctly", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) expectedFolders := model.HitList{ { @@ -1745,7 +1402,7 @@ func TestSearchDashboards(t *testing.T) { t.Run("Should handle Shared with me folder correctly", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) - service.features = featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders, featuremgmt.FlagKubernetesClientDashboardsFolders) + service.features = featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders) k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") k8sCliMock.On("Search", mock.Anything, int64(1), mock.MatchedBy(func(req *resourcepb.ResourceSearchRequest) bool { if len(req.Options.Fields) == 0 { @@ -1848,11 +1505,8 @@ func TestSearchDashboards(t *testing.T) { } func TestGetDashboards(t *testing.T) { - fakeStore := dashboards.FakeDashboardStore{} - defer fakeStore.AssertExpectations(t) service := &DashboardServiceImpl{ - cfg: setting.NewCfg(), - dashboardStore: &fakeStore, + cfg: setting.NewCfg(), } expectedResult := []*dashboards.Dashboard{ @@ -1899,88 +1553,66 @@ func TestGetDashboards(t *testing.T) { DashboardUIDs: []string{"uid1", "uid2"}, OrgID: 1, } - t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { - service.features = featuremgmt.WithFeatures() - // by ids - fakeStore.On("GetDashboards", mock.Anything, queryByIDs).Return(expectedResult, nil).Once() - result, err := service.GetDashboards(context.Background(), queryByIDs) - require.NoError(t, err) - require.Equal(t, expectedResult, result) - fakeStore.AssertExpectations(t) - - // by uids - fakeStore.On("GetDashboards", mock.Anything, queryByUIDs).Return(expectedResult, nil).Once() - result, err = service.GetDashboards(context.Background(), queryByUIDs) - require.NoError(t, err) - require.Equal(t, expectedResult, result) - fakeStore.AssertExpectations(t) - }) - - t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { - ctx, k8sCliMock := setupK8sDashboardTests(service) - k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") - k8sCliMock.On("Get", mock.Anything, "uid1", mock.Anything, mock.Anything, mock.Anything).Return(uid1Unstructured, nil) - k8sCliMock.On("Get", mock.Anything, "uid2", mock.Anything, mock.Anything, mock.Anything).Return(uid2Unstructured, nil) - k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) - k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resourcepb.ResourceSearchResponse{ - Results: &resourcepb.ResourceTable{ - Columns: []*resourcepb.ResourceTableColumnDefinition{ - { - Name: "title", - Type: resourcepb.ResourceTableColumnDefinition_STRING, + ctx, k8sCliMock := setupK8sDashboardTests(service) + k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") + k8sCliMock.On("Get", mock.Anything, "uid1", mock.Anything, mock.Anything, mock.Anything).Return(uid1Unstructured, nil) + k8sCliMock.On("Get", mock.Anything, "uid2", mock.Anything, mock.Anything, mock.Anything).Return(uid2Unstructured, nil) + k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) + k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resourcepb.ResourceSearchResponse{ + Results: &resourcepb.ResourceTable{ + Columns: []*resourcepb.ResourceTableColumnDefinition{ + { + Name: "title", + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: "folder", + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + }, + Rows: []*resourcepb.ResourceTableRow{ + { + Key: &resourcepb.ResourceKey{ + Name: "uid1", + Resource: "dashboard", }, - { - Name: "folder", - Type: resourcepb.ResourceTableColumnDefinition_STRING, + Cells: [][]byte{ + []byte("Dashboard 1"), + []byte(""), }, }, - Rows: []*resourcepb.ResourceTableRow{ - { - Key: &resourcepb.ResourceKey{ - Name: "uid1", - Resource: "dashboard", - }, - Cells: [][]byte{ - []byte("Dashboard 1"), - []byte(""), - }, + { + Key: &resourcepb.ResourceKey{ + Name: "uid2", + Resource: "dashboard", }, - { - Key: &resourcepb.ResourceKey{ - Name: "uid2", - Resource: "dashboard", - }, - Cells: [][]byte{ - []byte("Dashboard 2"), - []byte(""), - }, + Cells: [][]byte{ + []byte("Dashboard 2"), + []byte(""), }, }, }, - TotalHits: 1, - }, nil) + }, + TotalHits: 1, + }, nil) - // by ids - result, err := service.GetDashboards(ctx, queryByIDs) - require.NoError(t, err) - require.Equal(t, expectedResult, result) - k8sCliMock.AssertExpectations(t) + // by ids + result, err := service.GetDashboards(ctx, queryByIDs) + require.NoError(t, err) + require.Equal(t, expectedResult, result) + k8sCliMock.AssertExpectations(t) - // by uids - result, err = service.GetDashboards(ctx, queryByUIDs) - require.NoError(t, err) - require.Equal(t, expectedResult, result) - k8sCliMock.AssertExpectations(t) - }) + // by uids + result, err = service.GetDashboards(ctx, queryByUIDs) + require.NoError(t, err) + require.Equal(t, expectedResult, result) + k8sCliMock.AssertExpectations(t) } func TestGetDashboardUIDByID(t *testing.T) { - fakeStore := dashboards.FakeDashboardStore{} - defer fakeStore.AssertExpectations(t) service := &DashboardServiceImpl{ - cfg: setting.NewCfg(), - dashboardStore: &fakeStore, + cfg: setting.NewCfg(), } expectedResult := &dashboards.DashboardRef{ @@ -1991,51 +1623,39 @@ func TestGetDashboardUIDByID(t *testing.T) { query := &dashboards.GetDashboardRefByIDQuery{ ID: 1, } - t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { - service.features = featuremgmt.WithFeatures() - fakeStore.On("GetDashboardUIDByID", mock.Anything, query).Return(expectedResult, nil).Once() - - result, err := service.GetDashboardUIDByID(context.Background(), query) - require.NoError(t, err) - require.Equal(t, expectedResult, result) - fakeStore.AssertExpectations(t) - }) - - t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { - ctx, k8sCliMock := setupK8sDashboardTests(service) - k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") - k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resourcepb.ResourceSearchResponse{ - Results: &resourcepb.ResourceTable{ - Columns: []*resourcepb.ResourceTableColumnDefinition{ - { - Name: "title", - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, - { - Name: "folder", - Type: resourcepb.ResourceTableColumnDefinition_STRING, - }, + ctx, k8sCliMock := setupK8sDashboardTests(service) + k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") + k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resourcepb.ResourceSearchResponse{ + Results: &resourcepb.ResourceTable{ + Columns: []*resourcepb.ResourceTableColumnDefinition{ + { + Name: "title", + Type: resourcepb.ResourceTableColumnDefinition_STRING, }, - Rows: []*resourcepb.ResourceTableRow{ - { - Key: &resourcepb.ResourceKey{ - Name: "uid1", - Resource: "dashboard", - }, - Cells: [][]byte{ - []byte("Dashboard 1"), - []byte("folder1"), - }, + { + Name: "folder", + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + }, + Rows: []*resourcepb.ResourceTableRow{ + { + Key: &resourcepb.ResourceKey{ + Name: "uid1", + Resource: "dashboard", + }, + Cells: [][]byte{ + []byte("Dashboard 1"), + []byte("folder1"), }, }, }, - TotalHits: 1, - }, nil) - result, err := service.GetDashboardUIDByID(ctx, query) - require.NoError(t, err) - require.Equal(t, expectedResult, result) - k8sCliMock.AssertExpectations(t) - }) + }, + TotalHits: 1, + }, nil) + result, err := service.GetDashboardUIDByID(ctx, query) + require.NoError(t, err) + require.Equal(t, expectedResult, result) + k8sCliMock.AssertExpectations(t) } func TestUnstructuredToLegacyDashboard(t *testing.T) { @@ -2092,11 +1712,8 @@ func TestUnstructuredToLegacyDashboard(t *testing.T) { } func TestGetDashboardTags(t *testing.T) { - fakeStore := dashboards.FakeDashboardStore{} - defer fakeStore.AssertExpectations(t) service := &DashboardServiceImpl{ - cfg: setting.NewCfg(), - dashboardStore: &fakeStore, + cfg: setting.NewCfg(), } expectedResult := []*dashboards.DashboardTagCloudItem{ @@ -2112,47 +1729,32 @@ func TestGetDashboardTags(t *testing.T) { query := &dashboards.GetDashboardTagsQuery{ OrgID: 1, } - t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { - service.features = featuremgmt.WithFeatures() - fakeStore.On("GetDashboardTags", mock.Anything, query).Return(expectedResult, nil).Once() - result, err := service.GetDashboardTags(context.Background(), query) - require.NoError(t, err) - require.Equal(t, expectedResult, result) - fakeStore.AssertExpectations(t) - }) - - t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { - ctx, k8sCliMock := setupK8sDashboardTests(service) - k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resourcepb.ResourceSearchResponse{ - Facet: map[string]*resourcepb.ResourceSearchResponse_Facet{ - "tags": { - Terms: []*resourcepb.ResourceSearchResponse_TermFacet{ - { - Term: "tag1", - Count: 1, - }, - { - Term: "tag2", - Count: 3, - }, + ctx, k8sCliMock := setupK8sDashboardTests(service) + k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resourcepb.ResourceSearchResponse{ + Facet: map[string]*resourcepb.ResourceSearchResponse_Facet{ + "tags": { + Terms: []*resourcepb.ResourceSearchResponse_TermFacet{ + { + Term: "tag1", + Count: 1, + }, + { + Term: "tag2", + Count: 3, }, }, }, - }, nil) + }, + }, nil) - result, err := service.GetDashboardTags(ctx, query) - require.NoError(t, err) - require.Equal(t, expectedResult, result) - fakeStore.AssertExpectations(t) - }) + result, err := service.GetDashboardTags(ctx, query) + require.NoError(t, err) + require.Equal(t, expectedResult, result) } func TestQuotaCount(t *testing.T) { - fakeStore := dashboards.FakeDashboardStore{} - defer fakeStore.AssertExpectations(t) service := &DashboardServiceImpl{ - cfg: setting.NewCfg(), - dashboardStore: &fakeStore, + cfg: setting.NewCfg(), } orgs := []*org.OrgDTO{ @@ -2182,44 +1784,29 @@ func TestQuotaCount(t *testing.T) { query := "a.ScopeParameters{ OrgID: 1, } - t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { - service.features = featuremgmt.WithFeatures() - fakeStore.On("Count", mock.Anything, mock.Anything).Return(nil, nil).Once() - _, err := service.Count(context.Background(), query) - require.NoError(t, err) - fakeStore.AssertExpectations(t) - }) + ctx, k8sCliMock := setupK8sDashboardTests(service) + orgSvc := orgtest.FakeOrgService{ExpectedOrgs: orgs} + service.orgService = &orgSvc + k8sCliMock.On("GetStats", mock.Anything, int64(2)).Return(&countOrg2, nil).Once() + k8sCliMock.On("GetStats", mock.Anything, int64(1)).Return(&countOrg1, nil).Once() - t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { - ctx, k8sCliMock := setupK8sDashboardTests(service) - orgSvc := orgtest.FakeOrgService{ExpectedOrgs: orgs} - service.orgService = &orgSvc - k8sCliMock.On("GetStats", mock.Anything, int64(2)).Return(&countOrg2, nil).Once() - k8sCliMock.On("GetStats", mock.Anything, int64(1)).Return(&countOrg1, nil).Once() + result, err := service.Count(ctx, query) + require.NoError(t, err) - result, err := service.Count(ctx, query) - require.NoError(t, err) + orgTag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.OrgScope) + require.NoError(t, err) + c, _ := result.Get(orgTag) + require.Equal(t, c, int64(1)) - orgTag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.OrgScope) - require.NoError(t, err) - c, _ := result.Get(orgTag) - require.Equal(t, c, int64(1)) - - globalTag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.GlobalScope) - require.NoError(t, err) - c, _ = result.Get(globalTag) - require.Equal(t, c, int64(3)) - - fakeStore.AssertExpectations(t) - }) + globalTag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.GlobalScope) + require.NoError(t, err) + c, _ = result.Get(globalTag) + require.Equal(t, c, int64(3)) } func TestCountDashboardsInOrg(t *testing.T) { - fakeStore := dashboards.FakeDashboardStore{} - defer fakeStore.AssertExpectations(t) service := &DashboardServiceImpl{ - cfg: setting.NewCfg(), - dashboardStore: &fakeStore, + cfg: setting.NewCfg(), } count := resourcepb.ResourceStatsResponse{ Stats: []*resourcepb.ResourceStatsResponse_Stats{ @@ -2229,30 +1816,18 @@ func TestCountDashboardsInOrg(t *testing.T) { }, } - t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { - service.features = featuremgmt.WithFeatures() - fakeStore.On("CountInOrg", mock.Anything, mock.Anything, false).Return(int64(1), nil).Once() - _, err := service.CountDashboardsInOrg(context.Background(), 1) - require.NoError(t, err) - fakeStore.AssertExpectations(t) - }) - - t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { - ctx, k8sCliMock := setupK8sDashboardTests(service) - k8sCliMock.On("GetStats", mock.Anything, mock.Anything).Return(&count, nil).Once() - result, err := service.CountDashboardsInOrg(ctx, 1) - require.NoError(t, err) - require.Equal(t, result, int64(3)) - }) + ctx, k8sCliMock := setupK8sDashboardTests(service) + k8sCliMock.On("GetStats", mock.Anything, mock.Anything).Return(&count, nil).Once() + result, err := service.CountDashboardsInOrg(ctx, 1) + require.NoError(t, err) + require.Equal(t, result, int64(3)) } func TestCountInFolders(t *testing.T) { - fakeStore := dashboards.FakeDashboardStore{} - defer fakeStore.AssertExpectations(t) service := &DashboardServiceImpl{ - cfg: setting.NewCfg(), - dashboardStore: &fakeStore, + cfg: setting.NewCfg(), } + ctx, k8sCliMock := setupK8sDashboardTests(service) dashs := &resourcepb.ResourceSearchResponse{ Results: &resourcepb.ResourceTable{ Columns: []*resourcepb.ResourceTableColumnDefinition{ @@ -2291,22 +1866,11 @@ func TestCountInFolders(t *testing.T) { TotalHits: 2, } - t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { - service.features = featuremgmt.WithFeatures() - fakeStore.On("CountDashboardsInFolders", mock.Anything, mock.Anything).Return(int64(1), nil).Once() - _, err := service.CountInFolders(context.Background(), 1, []string{"folder1"}, &user.SignedInUser{}) - require.NoError(t, err) - fakeStore.AssertExpectations(t) - }) - - t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { - ctx, k8sCliMock := setupK8sDashboardTests(service) - k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") - k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(dashs, nil).Once() - result, err := service.CountInFolders(ctx, 1, []string{"folder1"}, &user.SignedInUser{}) - require.NoError(t, err) - require.Equal(t, result, int64(2)) - }) + k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") + k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(dashs, nil).Once() + result, err := service.CountInFolders(ctx, 1, []string{"folder1"}, &user.SignedInUser{}) + require.NoError(t, err) + require.Equal(t, result, int64(2)) } func TestSearchDashboardsThroughK8sRaw(t *testing.T) { @@ -2654,26 +2218,18 @@ func TestIntegrationK8sDashboardCleanupJob(t *testing.T) { } tests := []struct { name string - featureEnabled bool readFromUnified bool batchSize int setupFunc func(*DashboardServiceImpl, context.Context, *client.MockK8sHandler) verifyFunc func(*testing.T, *DashboardServiceImpl, context.Context, *client.MockK8sHandler, *kvstore.FakeKVStore) }{ { - name: "Should not run cleanup when feature flag is disabled", - featureEnabled: false, - batchSize: 10, - }, - { - name: "Should not run cleanup when feature flag is enabled but we're reading from legacy", - featureEnabled: true, + name: "Should not run cleanup when we're reading from legacy", readFromUnified: false, batchSize: 10, }, { name: "Should process dashboard cleanup for all orgs", - featureEnabled: true, readFromUnified: true, batchSize: 10, setupFunc: func(service *DashboardServiceImpl, ctx context.Context, k8sCliMock *client.MockK8sHandler) { @@ -2744,7 +2300,6 @@ func TestIntegrationK8sDashboardCleanupJob(t *testing.T) { }, { name: "Should handle pagination and batching when processing large sets of dashboards", - featureEnabled: true, readFromUnified: true, batchSize: 3, setupFunc: func(service *DashboardServiceImpl, ctx context.Context, k8sCliMock *client.MockK8sHandler) { @@ -2836,11 +2391,7 @@ func TestIntegrationK8sDashboardCleanupJob(t *testing.T) { fakeStore := dashboards.FakeDashboardStore{} fakePublicDashboardService := publicdashboards.NewFakePublicDashboardServiceWrapper(t) fakeOrgService := orgtest.NewOrgServiceFake() - features := featuremgmt.WithFeatures() - if tc.featureEnabled { - features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesClientDashboardsFolders) - } service := &DashboardServiceImpl{ cfg: setting.NewCfg(), @@ -2885,7 +2436,7 @@ func TestIntegrationK8sDashboardCleanupJob(t *testing.T) { service := &DashboardServiceImpl{ cfg: cfg, log: log.New("test.logger"), - features: featuremgmt.WithFeatures(featuremgmt.FlagKubernetesClientDashboardsFolders), + features: featuremgmt.WithFeatures(), serverLockService: lockService, } @@ -2942,7 +2493,7 @@ func TestGetDashboardsByLibraryPanelUID(t *testing.T) { dashboardStore: &fakeStore, folderService: folderSvc, ac: actest.FakeAccessControl{ExpectedEvaluate: true}, - features: featuremgmt.WithFeatures(featuremgmt.FlagKubernetesClientDashboardsFolders, 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 5b208c34b7f..74259652fbf 100644 --- a/pkg/services/dashboards/store_mock.go +++ b/pkg/services/dashboards/store_mock.go @@ -62,34 +62,6 @@ func (_m *FakeDashboardStore) Count(_a0 context.Context, _a1 *quota.ScopeParamet return r0, r1 } -// CountDashboardsInFolders provides a mock function with given fields: ctx, request -func (_m *FakeDashboardStore) CountDashboardsInFolders(ctx context.Context, request *CountDashboardsInFolderRequest) (int64, error) { - ret := _m.Called(ctx, request) - - if len(ret) == 0 { - panic("no return value specified for CountDashboardsInFolders") - } - - var r0 int64 - var r1 error - if rf, ok := ret.Get(0).(func(context.Context, *CountDashboardsInFolderRequest) (int64, error)); ok { - return rf(ctx, request) - } - if rf, ok := ret.Get(0).(func(context.Context, *CountDashboardsInFolderRequest) int64); ok { - r0 = rf(ctx, request) - } else { - r0 = ret.Get(0).(int64) - } - - if rf, ok := ret.Get(1).(func(context.Context, *CountDashboardsInFolderRequest) error); ok { - r1 = rf(ctx, request) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // CountInOrg provides a mock function with given fields: ctx, orgID, isFolder func (_m *FakeDashboardStore) CountInOrg(ctx context.Context, orgID int64, isFolder bool) (int64, error) { ret := _m.Called(ctx, orgID, isFolder) @@ -118,24 +90,6 @@ func (_m *FakeDashboardStore) CountInOrg(ctx context.Context, orgID int64, isFol return r0, r1 } -// DeleteAllDashboards provides a mock function with given fields: ctx, orgID -func (_m *FakeDashboardStore) DeleteAllDashboards(ctx context.Context, orgID int64) error { - ret := _m.Called(ctx, orgID) - - if len(ret) == 0 { - panic("no return value specified for DeleteAllDashboards") - } - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok { - r0 = rf(ctx, orgID) - } else { - r0 = ret.Error(0) - } - - return r0 -} - // 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) @@ -172,24 +126,6 @@ func (_m *FakeDashboardStore) DeleteDashboardsInFolders(ctx context.Context, req return r0 } -// DeleteOrphanedProvisionedDashboards provides a mock function with given fields: ctx, cmd -func (_m *FakeDashboardStore) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *DeleteOrphanedProvisionedDashboardsCommand) error { - ret := _m.Called(ctx, cmd) - - if len(ret) == 0 { - panic("no return value specified for DeleteOrphanedProvisionedDashboards") - } - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *DeleteOrphanedProvisionedDashboardsCommand) error); ok { - r0 = rf(ctx, cmd) - } else { - r0 = ret.Error(0) - } - - 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) @@ -310,66 +246,6 @@ func (_m *FakeDashboardStore) GetDashboardTags(ctx context.Context, query *GetDa return r0, r1 } -// GetDashboardUIDByID provides a mock function with given fields: ctx, query -func (_m *FakeDashboardStore) GetDashboardUIDByID(ctx context.Context, query *GetDashboardRefByIDQuery) (*DashboardRef, error) { - ret := _m.Called(ctx, query) - - if len(ret) == 0 { - panic("no return value specified for GetDashboardUIDByID") - } - - var r0 *DashboardRef - var r1 error - if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardRefByIDQuery) (*DashboardRef, error)); ok { - return rf(ctx, query) - } - if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardRefByIDQuery) *DashboardRef); ok { - r0 = rf(ctx, query) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*DashboardRef) - } - } - - if rf, ok := ret.Get(1).(func(context.Context, *GetDashboardRefByIDQuery) error); ok { - r1 = rf(ctx, query) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// GetDashboards provides a mock function with given fields: ctx, query -func (_m *FakeDashboardStore) GetDashboards(ctx context.Context, query *GetDashboardsQuery) ([]*Dashboard, error) { - ret := _m.Called(ctx, query) - - if len(ret) == 0 { - panic("no return value specified for GetDashboards") - } - - var r0 []*Dashboard - var r1 error - if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardsQuery) ([]*Dashboard, error)); ok { - return rf(ctx, query) - } - if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardsQuery) []*Dashboard); ok { - r0 = rf(ctx, query) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]*Dashboard) - } - } - - if rf, ok := ret.Get(1).(func(context.Context, *GetDashboardsQuery) error); ok { - r1 = rf(ctx, query) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // GetDashboardsByLibraryPanelUID provides a mock function with given fields: ctx, libraryPanelUID, orgID func (_m *FakeDashboardStore) GetDashboardsByLibraryPanelUID(ctx context.Context, libraryPanelUID string, orgID int64) ([]*DashboardRef, error) { ret := _m.Called(ctx, libraryPanelUID, orgID) diff --git a/pkg/services/dashboardsnapshots/service/service_test.go b/pkg/services/dashboardsnapshots/service/service_test.go index d5621692cf9..3d19209a0b0 100644 --- a/pkg/services/dashboardsnapshots/service/service_test.go +++ b/pkg/services/dashboardsnapshots/service/service_test.go @@ -4,33 +4,18 @@ import ( "context" "encoding/json" "testing" - "time" "github.com/stretchr/testify/require" common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/kvstore" - "github.com/grafana/grafana/pkg/infra/serverlock" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/accesscontrol/actest" - "github.com/grafana/grafana/pkg/services/apiserver/client" "github.com/grafana/grafana/pkg/services/dashboards" - dashdb "github.com/grafana/grafana/pkg/services/dashboards/database" - dashsvc "github.com/grafana/grafana/pkg/services/dashboards/service" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" dashsnapdb "github.com/grafana/grafana/pkg/services/dashboardsnapshots/database" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/folder/folderimpl" - "github.com/grafana/grafana/pkg/services/folder/foldertest" - "github.com/grafana/grafana/pkg/services/quota/quotatest" - "github.com/grafana/grafana/pkg/services/search/sort" "github.com/grafana/grafana/pkg/services/secrets/database" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" - "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" "github.com/grafana/grafana/pkg/tests/testsuite" ) @@ -81,7 +66,6 @@ func TestIntegrationDashboardSnapshotsService(t *testing.T) { require.Equal(t, rawDashboard, decrypted) }) - t.Run("get dashboard snapshot should return the dashboard decrypted", func(t *testing.T) { ctx := context.Background() @@ -99,69 +83,3 @@ func TestIntegrationDashboardSnapshotsService(t *testing.T) { require.Equal(t, rawDashboard, decrypted) }) } - -func TestIntegrationValidateDashboardExists(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test in short mode") - } - sqlStore := db.InitTestDB(t) - cfg := setting.NewCfg() - dsStore := dashsnapdb.ProvideStore(sqlStore, cfg) - secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) - feats := featuremgmt.WithFeatures() - dashboardStore, err := dashdb.ProvideDashboardStore(sqlStore, cfg, feats, tagimpl.ProvideService(sqlStore)) - require.NoError(t, err) - dashSvc, err := dashsvc.ProvideDashboardServiceImpl( - cfg, - dashboardStore, - folderimpl.ProvideDashboardFolderStore(sqlStore), - feats, - nil, - actest.FakeAccessControl{}, - actest.FakeService{}, - foldertest.NewFakeService(), - nil, - client.MockTestRestConfig{}, - nil, - quotatest.New(false, nil), - nil, - nil, - nil, - dualwrite.ProvideTestService(), - sort.ProvideService(), - serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()), - kvstore.NewFakeKVStore(), - ) - require.NoError(t, err) - s := ProvideService(dsStore, secretsService, dashSvc) - ctx := context.Background() - - t.Run("returns false when dashboard does not exist", func(t *testing.T) { - err := s.ValidateDashboardExists(ctx, 1, "test") - require.Error(t, err) - require.Equal(t, dashboards.ErrDashboardNotFound, err) - }) - - t.Run("returns true when dashboard exists", func(t *testing.T) { - err := createDashboard(sqlStore) - require.NoError(t, err) - - err = s.ValidateDashboardExists(ctx, 1, "test") - require.NoError(t, err) - }) -} - -func createDashboard(store db.DB) error { - return store.WithDbSession(context.Background(), func(sess *db.Session) error { - dashboard := &dashboards.Dashboard{ - ID: 1, - UID: "test", - OrgID: 1, - Created: time.Now(), - Updated: time.Now(), - } - - _, err := sess.Insert(dashboard) - return err - }) -} diff --git a/pkg/services/dashboardsnapshots/service_test.go b/pkg/services/dashboardsnapshots/service_test.go new file mode 100644 index 00000000000..94657daa597 --- /dev/null +++ b/pkg/services/dashboardsnapshots/service_test.go @@ -0,0 +1,75 @@ +package dashboardsnapshots + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + mock "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/identity" + dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1" + "github.com/grafana/grafana/pkg/infra/log" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/web" +) + +func TestCreateDashboardSnapshot_DashboardNotFound(t *testing.T) { + mockService := &MockService{} + cfg := dashboardsnapshot.SnapshotSharingOptions{ + SnapshotsEnabled: true, + ExternalEnabled: false, + } + testUser := &user.SignedInUser{ + UserID: 1, + OrgID: 1, + Login: "testuser", + Name: "Test User", + Email: "test@example.com", + } + dashboard := &common.Unstructured{} + dashboardData := map[string]interface{}{ + "uid": "test-dashboard-uid", + "id": 123, + } + dashboardBytes, _ := json.Marshal(dashboardData) + _ = json.Unmarshal(dashboardBytes, dashboard) + + cmd := CreateDashboardSnapshotCommand{ + DashboardCreateCommand: dashboardsnapshot.DashboardCreateCommand{ + Dashboard: dashboard, + Name: "Test Snapshot", + }, + } + + mockService.On("ValidateDashboardExists", mock.Anything, int64(1), "test-dashboard-uid"). + Return(dashboards.ErrDashboardNotFound) + + req, _ := http.NewRequest("POST", "/api/snapshots", nil) + req = req.WithContext(identity.WithRequester(req.Context(), testUser)) + + recorder := httptest.NewRecorder() + ctx := &contextmodel.ReqContext{ + Context: &web.Context{ + Req: req, + Resp: web.NewResponseWriter("POST", recorder), + }, + SignedInUser: testUser, + Logger: log.NewNopLogger(), + } + + CreateDashboardSnapshot(ctx, cfg, cmd, mockService) + + mockService.AssertExpectations(t) + assert.Equal(t, http.StatusBadRequest, recorder.Code) + var response map[string]interface{} + err := json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "Dashboard not found", response["message"]) +} diff --git a/pkg/services/dashboardversion/dashverimpl/dashver.go b/pkg/services/dashboardversion/dashverimpl/dashver.go index 9b16131262e..b2063f44091 100644 --- a/pkg/services/dashboardversion/dashverimpl/dashver.go +++ b/pkg/services/dashboardversion/dashverimpl/dashver.go @@ -89,20 +89,11 @@ func (s *Service) Get(ctx context.Context, query *dashver.GetDashboardVersionQue query.DashboardID = id } - if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - version, err := s.getHistoryThroughK8s(ctx, query.OrgID, query.DashboardUID, query.Version) - if err != nil { - return nil, err - } - return version, nil - } - - version, err := s.store.Get(ctx, query) + version, err := s.getHistoryThroughK8s(ctx, query.OrgID, query.DashboardUID, query.Version) if err != nil { return nil, err } - version.Data.Set("id", version.DashboardID) - return version.ToDTO(query.DashboardUID), nil + return version, nil } func (s *Service) DeleteExpired(ctx context.Context, cmd *dashver.DeleteExpiredVersionsCommand) error { @@ -160,31 +151,17 @@ func (s *Service) List(ctx context.Context, query *dashver.ListDashboardVersions query.Limit = 1000 } - if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - versions, err := s.listHistoryThroughK8s( - ctx, - query.OrgID, - query.DashboardUID, - int64(query.Limit), - query.ContinueToken, - ) - if err != nil { - return nil, err - } - return versions, nil - } - - dvs, err := s.store.List(ctx, query) + versions, err := s.listHistoryThroughK8s( + ctx, + query.OrgID, + query.DashboardUID, + int64(query.Limit), + query.ContinueToken, + ) if err != nil { return nil, err } - dtos := make([]*dashver.DashboardVersionDTO, len(dvs)) - for i, v := range dvs { - dtos[i] = v.ToDTO(query.DashboardUID) - } - return &dashver.DashboardVersionResponse{ - Versions: dtos, - }, nil + return versions, nil } // getDashUIDMaybeEmpty is a helper function which takes a dashboardID and diff --git a/pkg/services/dashboardversion/dashverimpl/dashver_test.go b/pkg/services/dashboardversion/dashverimpl/dashver_test.go index d63a5199cb2..1b32379e1a5 100644 --- a/pkg/services/dashboardversion/dashverimpl/dashver_test.go +++ b/pkg/services/dashboardversion/dashverimpl/dashver_test.go @@ -15,7 +15,6 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/apiserver/client" "github.com/grafana/grafana/pkg/services/dashboards" dashver "github.com/grafana/grafana/pkg/services/dashboardversion" @@ -25,29 +24,12 @@ import ( ) func TestDashboardVersionService(t *testing.T) { - dashboardVersionStore := newDashboardVersionStoreFake() - dashboardService := dashboards.NewFakeDashboardService(t) - dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, features: featuremgmt.WithFeatures()} - - t.Run("Get dashboard version", func(t *testing.T) { - dashboard := &dashver.DashboardVersion{ - ID: 11, - Data: &simplejson.Json{}, - } - dashboardVersionStore.ExpectedDashboardVersion = dashboard - dashboardService.On("GetDashboardUIDByID", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")).Return(&dashboards.DashboardRef{UID: "uid"}, nil) - dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(&dashboards.Dashboard{ID: 42}, nil) - dashboardVersion, err := dashboardVersionService.Get(context.Background(), &dashver.GetDashboardVersionQuery{}) - require.NoError(t, err) - require.Equal(t, dashboard.ToDTO("uid"), dashboardVersion) - }) - - t.Run("Get dashboard versions through k8s", func(t *testing.T) { + t.Run("Get dashboard versions", func(t *testing.T) { dashboardService := dashboards.NewFakeDashboardService(t) dashboardVersionService := Service{dashSvc: dashboardService, features: featuremgmt.WithFeatures()} mockCli := new(client.MockK8sHandler) dashboardVersionService.k8sclient = mockCli - dashboardVersionService.features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesClientDashboardsFolders) + dashboardVersionService.features = featuremgmt.WithFeatures() dashboardService.On("GetDashboardUIDByID", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")).Return(&dashboards.DashboardRef{UID: "uid"}, nil) creationTimestamp := time.Now().Add(time.Hour * -24).UTC() @@ -133,7 +115,7 @@ func TestDashboardVersionService(t *testing.T) { dashboardVersionService := Service{dashSvc: dashboardService, features: featuremgmt.WithFeatures()} mockCli := new(client.MockK8sHandler) dashboardVersionService.k8sclient = mockCli - dashboardVersionService.features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesClientDashboardsFolders) + dashboardVersionService.features = featuremgmt.WithFeatures() dashboardService.On("GetDashboardUIDByID", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")).Return(&dashboards.DashboardRef{UID: "uid"}, nil) mockCli.On("List", mock.Anything, int64(1), mock.Anything).Return(nil, apierrors.NewNotFound(schema.GroupResource{Group: "dashboards.dashboard.grafana.app", Resource: "dashboard"}, "uid")) @@ -176,93 +158,12 @@ func TestDeleteExpiredVersions(t *testing.T) { } func TestListDashboardVersions(t *testing.T) { - t.Run("List all versions for a given Dashboard ID", func(t *testing.T) { - dashboardVersionStore := newDashboardVersionStoreFake() - dashboardService := dashboards.NewFakeDashboardService(t) - dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, features: featuremgmt.WithFeatures()} - dashboardVersionStore.ExpectedListVersions = []*dashver.DashboardVersion{ - {ID: 1, DashboardID: 42}, - } - dashboardService.On("GetDashboardUIDByID", mock.Anything, - mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")). - Return(&dashboards.DashboardRef{UID: "uid"}, nil) - - query := dashver.ListDashboardVersionsQuery{DashboardID: 42} - res, err := dashboardVersionService.List(context.Background(), &query) - require.Nil(t, err) - require.Equal(t, 1, len(res.Versions)) - // validate that the UID was populated - require.EqualValues(t, &dashver.DashboardVersionResponse{Versions: []*dashver.DashboardVersionDTO{{ID: 1, DashboardID: 42, DashboardUID: "uid"}}}, res) - }) - - t.Run("List all versions for a non-existent DashboardID", func(t *testing.T) { - dashboardVersionStore := newDashboardVersionStoreFake() - dashboardService := dashboards.NewFakeDashboardService(t) - dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, log: log.NewNopLogger(), features: featuremgmt.WithFeatures()} - dashboardVersionStore.ExpectedListVersions = []*dashver.DashboardVersion{ - {ID: 1, DashboardID: 42}, - } - dashboardService.On("GetDashboardUIDByID", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")). - Return(nil, dashboards.ErrDashboardNotFound).Once() - - query := dashver.ListDashboardVersionsQuery{DashboardID: 42} - res, err := dashboardVersionService.List(context.Background(), &query) - require.Nil(t, err) - require.Equal(t, 1, len(res.Versions)) - // The DashboardID remains populated with the given value, even though the dash was not found - require.EqualValues(t, &dashver.DashboardVersionResponse{Versions: []*dashver.DashboardVersionDTO{{ID: 1, DashboardID: 42}}}, res) - }) - - t.Run("List all versions for a given DashboardUID", func(t *testing.T) { - dashboardVersionStore := newDashboardVersionStoreFake() - dashboardService := dashboards.NewFakeDashboardService(t) - dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, log: log.NewNopLogger(), features: featuremgmt.WithFeatures()} - dashboardVersionStore.ExpectedListVersions = []*dashver.DashboardVersion{{DashboardID: 42, ID: 1}} - dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")). - Return(&dashboards.Dashboard{ID: 42}, nil) - - query := dashver.ListDashboardVersionsQuery{DashboardUID: "uid"} - res, err := dashboardVersionService.List(context.Background(), &query) - require.Nil(t, err) - require.Equal(t, 1, len(res.Versions)) - // validate that the dashboardID was populated from the GetDashboard method call. - require.EqualValues(t, &dashver.DashboardVersionResponse{Versions: []*dashver.DashboardVersionDTO{{ID: 1, DashboardID: 42, DashboardUID: "uid"}}}, res) - }) - - t.Run("List all versions for a given non-existent DashboardUID", func(t *testing.T) { - dashboardVersionStore := newDashboardVersionStoreFake() - dashboardService := dashboards.NewFakeDashboardService(t) - dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, log: log.NewNopLogger(), features: featuremgmt.WithFeatures()} - dashboardVersionStore.ExpectedListVersions = []*dashver.DashboardVersion{{DashboardID: 42, ID: 1}} - dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")). - Return(nil, dashboards.ErrDashboardNotFound) - - query := dashver.ListDashboardVersionsQuery{DashboardUID: "uid"} - res, err := dashboardVersionService.List(context.Background(), &query) - require.Nil(t, err) - require.Equal(t, 1, len(res.Versions)) - // validate that the dashboardUID & ID are populated, even though the dash was not found - require.EqualValues(t, &dashver.DashboardVersionResponse{Versions: []*dashver.DashboardVersionDTO{{ID: 1, DashboardID: 42, DashboardUID: "uid"}}}, res) - }) - - t.Run("List Dashboard versions - error from store", func(t *testing.T) { - dashboardVersionStore := newDashboardVersionStoreFake() - dashboardService := dashboards.NewFakeDashboardService(t) - dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, log: log.NewNopLogger(), features: featuremgmt.WithFeatures()} - dashboardVersionStore.ExpectedError = dashver.ErrDashboardVersionNotFound - - query := dashver.ListDashboardVersionsQuery{DashboardID: 42, DashboardUID: "42"} - res, err := dashboardVersionService.List(context.Background(), &query) - require.Nil(t, res) - require.ErrorIs(t, err, dashver.ErrDashboardVersionNotFound) - }) - t.Run("List all versions for a given Dashboard ID through k8s", func(t *testing.T) { dashboardService := dashboards.NewFakeDashboardService(t) dashboardVersionService := Service{dashSvc: dashboardService, features: featuremgmt.WithFeatures()} mockCli := new(client.MockK8sHandler) dashboardVersionService.k8sclient = mockCli - dashboardVersionService.features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesClientDashboardsFolders) + dashboardVersionService.features = featuremgmt.WithFeatures() dashboardService.On("GetDashboardUIDByID", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")). @@ -301,7 +202,7 @@ func TestListDashboardVersions(t *testing.T) { dashboardVersionService := Service{dashSvc: dashboardService, features: featuremgmt.WithFeatures()} mockCli := new(client.MockK8sHandler) dashboardVersionService.k8sclient = mockCli - dashboardVersionService.features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesClientDashboardsFolders) + dashboardVersionService.features = featuremgmt.WithFeatures() dashboardService.On("GetDashboardUIDByID", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")). Return(&dashboards.DashboardRef{UID: "uid"}, nil) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index b7ee60b6e55..9a0c468f0f1 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", @@ -483,13 +476,6 @@ var ( Owner: grafanaAppPlatformSquad, FrontendOnly: true, }, - { - Name: "kubernetesClientDashboardsFolders", - Description: "Route the folder and dashboard service requests to k8s", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaAppPlatformSquad, - Expression: "true", // enabled by default - }, { Name: "dashboardDisableSchemaValidationV1", Description: "Disable schema validation for dashboards/v1", @@ -508,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)", @@ -777,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.", @@ -1662,6 +1664,12 @@ var ( Owner: grafanaObservabilityTracesAndProfilingSquad, FrontendOnly: true, }, + { + Name: "postgresDSUsePGX", + Description: "Enables using PGX instead of libpq for PostgreSQL datasource", + Stage: FeatureStageExperimental, + Owner: grafanaOSSBigTent, + }, { Name: "tempoAlerting", Description: "Enables creating alerts from Tempo data source", @@ -1859,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.", @@ -1868,6 +1883,13 @@ var ( HideFromDocs: true, Expression: "false", }, + { + Name: "adhocFiltersInTooltips", + Description: "Enable adhoc filter buttons in visualization tooltips", + Stage: FeatureStageExperimental, + Owner: grafanaDataProSquad, + FrontendOnly: true, + }, } ) diff --git a/pkg/services/featuremgmt/static_evaluator.go b/pkg/services/featuremgmt/static_evaluator.go index 1d4b05d9ece..760c48c4f66 100644 --- a/pkg/services/featuremgmt/static_evaluator.go +++ b/pkg/services/featuremgmt/static_evaluator.go @@ -7,13 +7,14 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/setting" "github.com/open-feature/go-sdk/openfeature" + goffmodel "github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/model" ) // StaticFlagEvaluator provides methods for evaluating static feature flags // it is only used when static provider is configured type StaticFlagEvaluator interface { - EvalFlag(ctx context.Context, flagKey string) (openfeature.BooleanEvaluationDetails, error) - EvalAllFlags(ctx context.Context) (OFREPBulkResponse, error) + EvalFlag(ctx context.Context, flagKey string) (goffmodel.OFREPEvaluateSuccessResponse, error) + EvalAllFlags(ctx context.Context) (goffmodel.OFREPBulkEvaluateSuccessResponse, error) } // CreateStaticEvaluator is a dependency for ofrep APIBuilder @@ -53,22 +54,29 @@ type staticEvaluator struct { log log.Logger } -func (s *staticEvaluator) EvalFlag(ctx context.Context, flagKey string) (openfeature.BooleanEvaluationDetails, error) { +func (s *staticEvaluator) EvalFlag(ctx context.Context, flagKey string) (goffmodel.OFREPEvaluateSuccessResponse, error) { result, err := s.client.BooleanValueDetails(ctx, flagKey, false, openfeature.TransactionContext(ctx)) if err != nil { - return openfeature.BooleanEvaluationDetails{}, fmt.Errorf("failed to evaluate flag %s: %w", flagKey, err) + return goffmodel.OFREPEvaluateSuccessResponse{}, fmt.Errorf("failed to evaluate flag %s: %w", flagKey, err) + } + resp := goffmodel.OFREPEvaluateSuccessResponse{ + Key: flagKey, + Value: result.Value, + Reason: "static provider evaluation result", + Variant: result.Variant, + Metadata: result.FlagMetadata, } - return result, nil + return resp, nil } -func (s *staticEvaluator) EvalAllFlags(ctx context.Context) (OFREPBulkResponse, error) { +func (s *staticEvaluator) EvalAllFlags(ctx context.Context) (goffmodel.OFREPBulkEvaluateSuccessResponse, error) { flags, err := s.provider.ListFlags() if err != nil { - return OFREPBulkResponse{}, fmt.Errorf("static provider failed to list all flags: %w", err) + return goffmodel.OFREPBulkEvaluateSuccessResponse{}, fmt.Errorf("static provider failed to list all flags: %w", err) } - allFlags := make([]OFREPFlag, 0, len(flags)) + allFlags := make([]goffmodel.OFREPFlagBulkEvaluateSuccessResponse, 0, len(flags)) for _, flagKey := range flags { result, err := s.client.BooleanValueDetails(ctx, flagKey, false, openfeature.TransactionContext(ctx)) if err != nil { @@ -76,32 +84,18 @@ func (s *staticEvaluator) EvalAllFlags(ctx context.Context) (OFREPBulkResponse, continue } - allFlags = append(allFlags, OFREPFlag{ - Key: flagKey, - Value: result.Value, - Reason: "static provider evaluation result", - Variant: result.Variant, + allFlags = append(allFlags, goffmodel.OFREPFlagBulkEvaluateSuccessResponse{ + OFREPEvaluateSuccessResponse: goffmodel.OFREPEvaluateSuccessResponse{ + Key: flagKey, + Value: result.Value, + Reason: "static provider evaluation result", + Variant: result.Variant, + Metadata: result.FlagMetadata, + }, ErrorCode: string(result.ErrorCode), ErrorDetails: result.ErrorMessage, }) } - return OFREPBulkResponse{Flags: allFlags}, nil -} - -// OFREPBulkResponse represents the response for bulk flag evaluation -type OFREPBulkResponse struct { - Flags []OFREPFlag `json:"flags"` - Metadata map[string]any `json:"metadata,omitempty"` -} - -// OFREPFlag represents a single flag in the bulk response -type OFREPFlag struct { - Key string `json:"key"` - Value any `json:"value"` - Reason string `json:"reason"` - Variant string `json:"variant,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` - ErrorCode string `json:"errorCode,omitempty"` - ErrorDetails string `json:"errorDetails,omitempty"` + return goffmodel.OFREPBulkEvaluateSuccessResponse{Flags: allFlags}, nil } diff --git a/pkg/services/featuremgmt/toggles-gitlog.csv b/pkg/services/featuremgmt/toggles-gitlog.csv index 01a52394b2a..4a0ff55bc6e 100644 --- a/pkg/services/featuremgmt/toggles-gitlog.csv +++ b/pkg/services/featuremgmt/toggles-gitlog.csv @@ -440,7 +440,6 @@ alertingConversionAPI,2025-02-12T07:13:21Z,2025-04-05T08:27:02Z,9593e51da7c05ed5 alertingJiraIntegration,2025-02-14T12:22:04Z,,af8cab92109ecfbb54625ff25a3b90a4f92dc46a,Sonia Aguilar alertingRuleVersionHistoryRestore,2025-02-17T12:25:32Z,,2014d27defe5668ba07913cec6f2186c90eaaab2,Sonia Aguilar newShareReportDrawer,2025-02-17T19:05:46Z,,9df6412e92559f2329c2baac0025f56fc57c5bf9,Ezequiel Victorero -kubernetesClientDashboardsFolders,2025-02-18T23:11:26Z,,3e6f40c87386984be60e391b26a7559e3469dac3,Stephanie Hingtgen managedDualWriter,2025-02-19T14:50:39Z,,5a40c84568485da55ccb42998c6d291d310abd56,Ryan McKinley rendererDisableAppPluginsPreload,2025-02-24T14:43:06Z,,608d974585c696253ac629f3c7bfc3a0043cbd49,Agnès Toulet assetSriChecks,2025-03-04T10:56:35Z,,bbfeb8d220cc67c329aa2b5d6ed693ae1cb54325,Jack Westbrook diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index d22d7e5b728..14f65065ed9 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -60,12 +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 -kubernetesClientDashboardsFolders,GA,@grafana/grafana-app-platform-squad,false,false,false 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 @@ -102,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 @@ -216,6 +216,7 @@ localizationForPlugins,experimental,@grafana/plugins-platform-backend,false,fals unifiedNavbars,GA,@grafana/plugins-platform-backend,false,false,true logsPanelControls,preview,@grafana/observability-logs,false,false,true metricsFromProfiles,experimental,@grafana/observability-traces-and-profiling,false,false,true +postgresDSUsePGX,experimental,@grafana/oss-big-tent,false,false,false tempoAlerting,experimental,@grafana/observability-traces-and-profiling,false,false,true pluginsAutoUpdate,experimental,@grafana/plugins-platform-backend,false,false,false multiTenantFrontend,experimental,@grafana/grafana-frontend-platform,false,false,false @@ -240,4 +241,6 @@ 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 diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 7def9808abb..c74ce4befc4 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -251,18 +251,10 @@ 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" - // FlagKubernetesClientDashboardsFolders - // Route the folder and dashboard service requests to k8s - FlagKubernetesClientDashboardsFolders = "kubernetesClientDashboardsFolders" - // FlagDashboardDisableSchemaValidationV1 // Disable schema validation for dashboards/v1 FlagDashboardDisableSchemaValidationV1 = "dashboardDisableSchemaValidationV1" @@ -275,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" @@ -419,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" @@ -875,6 +875,10 @@ const ( // Enables creating metrics from profiles and storing them as recording rules FlagMetricsFromProfiles = "metricsFromProfiles" + // FlagPostgresDSUsePGX + // Enables using PGX instead of libpq for PostgreSQL datasource + FlagPostgresDSUsePGX = "postgresDSUsePGX" + // FlagTempoAlerting // Enables creating alerts from Tempo data source FlagTempoAlerting = "tempoAlerting" @@ -971,7 +975,15 @@ 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" + + // FlagAdhocFiltersInTooltips + // Enable adhoc filter buttons in visualization tooltips + FlagAdhocFiltersInTooltips = "adhocFiltersInTooltips" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 6d738596653..2324d34c0f8 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -17,6 +17,19 @@ "expression": "true" } }, + { + "metadata": { + "name": "adhocFiltersInTooltips", + "resourceVersion": "1753712755564", + "creationTimestamp": "2025-07-28T14:25:55Z" + }, + "spec": { + "description": "Enable adhoc filter buttons in visualization tooltips", + "stage": "experimental", + "codeowner": "@grafana/datapro", + "frontend": true + } + }, { "metadata": { "name": "aiGeneratedDashboardChanges", @@ -823,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", @@ -1716,19 +1742,6 @@ "hideFromDocs": true } }, - { - "metadata": { - "name": "kubernetesClientDashboardsFolders", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-02-18T23:11:26Z" - }, - "spec": { - "description": "Route the folder and dashboard service requests to k8s", - "stage": "GA", - "codeowner": "@grafana/grafana-app-platform-squad", - "expression": "true" - } - }, { "metadata": { "name": "kubernetesDashboards", @@ -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", @@ -2413,6 +2413,19 @@ "expression": "false" } }, + { + "metadata": { + "name": "postgresDSUsePGX", + "resourceVersion": "1753174666056", + "creationTimestamp": "2025-05-26T06:54:18Z", + "deletionTimestamp": "2025-06-03T12:45:07Z" + }, + "spec": { + "description": "Enables using PGX instead of libpq for PostgreSQL datasource", + "stage": "experimental", + "codeowner": "@grafana/oss-big-tent" + } + }, { "metadata": { "name": "preferLibraryPanelTitle", @@ -2747,6 +2760,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", @@ -3217,6 +3242,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/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index 277ee43d442..0bae0f65d6b 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -112,39 +112,35 @@ func ProvideService( ac.RegisterScopeAttributeResolver(dashboards.NewFolderIDScopeResolver(folderStore, srv)) ac.RegisterScopeAttributeResolver(dashboards.NewFolderUIDScopeResolver(srv)) - if features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - k8sHandler := client.NewK8sHandler( - dual, - request.GetNamespaceMapper(cfg), - folderv1.FolderResourceInfo.GroupVersionResource(), - restConfig.GetRestConfig, - dashboardStore, - userService, - resourceClient, - sorter, - features, - ) + k8sHandler := client.NewK8sHandler( + dual, + request.GetNamespaceMapper(cfg), + folderv1.FolderResourceInfo.GroupVersionResource(), + restConfig.GetRestConfig, + dashboardStore, + userService, + resourceClient, + sorter, + features, + ) - unifiedStore := ProvideUnifiedStore(k8sHandler, userService, tracer) + unifiedStore := ProvideUnifiedStore(k8sHandler, userService, tracer) - srv.unifiedStore = unifiedStore - srv.k8sclient = k8sHandler - } + srv.unifiedStore = unifiedStore + srv.k8sclient = k8sHandler - if features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - dashHandler := client.NewK8sHandler( - dual, - request.GetNamespaceMapper(cfg), - dashboardv1.DashboardResourceInfo.GroupVersionResource(), - restConfig.GetRestConfig, - dashboardStore, - userService, - resourceClient, - sorter, - features, - ) - srv.dashboardK8sClient = dashHandler - } + dashHandler := client.NewK8sHandler( + dual, + request.GetNamespaceMapper(cfg), + dashboardv1.DashboardResourceInfo.GroupVersionResource(), + restConfig.GetRestConfig, + dashboardStore, + userService, + resourceClient, + sorter, + features, + ) + srv.dashboardK8sClient = dashHandler return srv } @@ -201,32 +197,22 @@ func (s *Service) DBMigration(db db.DB) { func (s *Service) CountFoldersInOrg(ctx context.Context, orgID int64) (int64, error) { ctx, span := s.tracer.Start(ctx, "folder.CountFoldersInOrg") defer span.End() - if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - return s.unifiedStore.CountInOrg(ctx, orgID) - } - - return s.store.CountInOrg(ctx, orgID) + return s.unifiedStore.CountInOrg(ctx, orgID) } func (s *Service) SearchFolders(ctx context.Context, q folder.SearchFoldersQuery) (model.HitList, error) { ctx, span := s.tracer.Start(ctx, "folder.SearchFolders") defer span.End() - if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - // TODO: - // - implement filtering by alerting folders and k6 folders (see the dashboards store `FindDashboards` method for reference) - // - implement fallback on search client in unistore to go to legacy store (will need to read from dashboard store) - return s.searchFoldersFromApiServer(ctx, q) - } - return nil, fmt.Errorf("cannot be called on the legacy folder service") + // TODO: + // - implement filtering by alerting folders and k6 folders (see the dashboards store `FindDashboards` method for reference) + // - implement fallback on search client in unistore to go to legacy store (will need to read from dashboard store) + return s.searchFoldersFromApiServer(ctx, q) } func (s *Service) GetFolders(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) { ctx, span := s.tracer.Start(ctx, "folder.GetFolders") defer span.End() - if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - return s.getFoldersFromApiServer(ctx, q) - } - return s.GetFoldersLegacy(ctx, q) + return s.getFoldersFromApiServer(ctx, q) } func (s *Service) GetFoldersLegacy(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) { @@ -286,10 +272,7 @@ func (s *Service) GetFoldersLegacy(ctx context.Context, q folder.GetFoldersQuery func (s *Service) Get(ctx context.Context, q *folder.GetFolderQuery) (*folder.Folder, error) { ctx, span := s.tracer.Start(ctx, "folder.Get") defer span.End() - if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - return s.getFromApiServer(ctx, q) - } - return s.GetLegacy(ctx, q) + return s.getFromApiServer(ctx, q) } func (s *Service) GetLegacy(ctx context.Context, q *folder.GetFolderQuery) (*folder.Folder, error) { @@ -377,10 +360,7 @@ func (s *Service) setFullpath(ctx context.Context, f *folder.Folder, forceLegacy func (s *Service) GetChildren(ctx context.Context, q *folder.GetChildrenQuery) ([]*folder.FolderReference, error) { ctx, span := s.tracer.Start(ctx, "folder.GetChildren") defer span.End() - if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - return s.getChildrenFromApiServer(ctx, q) - } - return s.GetChildrenLegacy(ctx, q) + return s.getChildrenFromApiServer(ctx, q) } func (s *Service) GetChildrenLegacy(ctx context.Context, q *folder.GetChildrenQuery) ([]*folder.FolderReference, error) { @@ -660,10 +640,7 @@ func (s *Service) deduplicateAvailableFolders(ctx context.Context, folders []*fo func (s *Service) GetParents(ctx context.Context, q folder.GetParentsQuery) ([]*folder.Folder, error) { ctx, span := s.tracer.Start(ctx, "folder.GetParents") defer span.End() - if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - return s.getParentsFromApiServer(ctx, q) - } - return s.GetParentsLegacy(ctx, q) + return s.getParentsFromApiServer(ctx, q) } func (s *Service) GetParentsLegacy(ctx context.Context, q folder.GetParentsQuery) ([]*folder.Folder, error) { @@ -679,10 +656,7 @@ func (s *Service) GetParentsLegacy(ctx context.Context, q folder.GetParentsQuery } func (s *Service) Create(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { - if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - return s.createOnApiServer(ctx, cmd) - } - return s.CreateLegacy(ctx, cmd) + return s.createOnApiServer(ctx, cmd) } func (s *Service) CreateLegacy(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { @@ -800,10 +774,7 @@ func (s *Service) CreateLegacy(ctx context.Context, cmd *folder.CreateFolderComm func (s *Service) Update(ctx context.Context, cmd *folder.UpdateFolderCommand) (*folder.Folder, error) { ctx, span := s.tracer.Start(ctx, "folder.Update") defer span.End() - if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - return s.updateOnApiServer(ctx, cmd) - } - return s.UpdateLegacy(ctx, cmd) + return s.updateOnApiServer(ctx, cmd) } func (s *Service) UpdateLegacy(ctx context.Context, cmd *folder.UpdateFolderCommand) (*folder.Folder, error) { @@ -943,10 +914,7 @@ func prepareForUpdate(dashFolder *dashboards.Dashboard, orgId int64, userId int6 func (s *Service) Delete(ctx context.Context, cmd *folder.DeleteFolderCommand) error { ctx, span := s.tracer.Start(ctx, "folder.Delete") defer span.End() - if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - return s.deleteFromApiServer(ctx, cmd) - } - return s.DeleteLegacy(ctx, cmd) + return s.deleteFromApiServer(ctx, cmd) } func (s *Service) DeleteLegacy(ctx context.Context, cmd *folder.DeleteFolderCommand) error { @@ -1067,10 +1035,7 @@ func (s *Service) legacyDelete(ctx context.Context, cmd *folder.DeleteFolderComm func (s *Service) Move(ctx context.Context, cmd *folder.MoveFolderCommand) (*folder.Folder, error) { ctx, span := s.tracer.Start(ctx, "folder.Move") defer span.End() - if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - return s.moveOnApiServer(ctx, cmd) - } - return s.MoveLegacy(ctx, cmd) + return s.moveOnApiServer(ctx, cmd) } func (s *Service) MoveLegacy(ctx context.Context, cmd *folder.MoveFolderCommand) (*folder.Folder, error) { @@ -1296,11 +1261,7 @@ func (s *Service) nestedFolderDelete(ctx context.Context, cmd *folder.DeleteFold func (s *Service) GetDescendantCounts(ctx context.Context, q *folder.GetDescendantCountsQuery) (folder.DescendantCounts, error) { ctx, span := s.tracer.Start(ctx, "folder.GetDescendantCounts") defer span.End() - if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - return s.getDescendantCountsFromApiServer(ctx, q) - } - - return s.GetDescendantCountsLegacy(ctx, q) + return s.getDescendantCountsFromApiServer(ctx, q) } func (s *Service) GetDescendantCountsLegacy(ctx context.Context, q *folder.GetDescendantCountsQuery) (folder.DescendantCounts, error) { diff --git a/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go b/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go index 609ede53d19..feba5cbeed0 100644 --- a/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go +++ b/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go @@ -193,9 +193,7 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) { ExpectedUser: &user.User{}, } - featuresArr := []any{ - featuremgmt.FlagKubernetesClientDashboardsFolders} - features := featuremgmt.WithFeatures(featuresArr...) + features := featuremgmt.WithFeatures() tracer := noop.NewTracerProvider().Tracer("TestIntegrationFolderServiceViaUnifiedStorage") dashboardStore := dashboards.NewFakeDashboardStore(t) @@ -521,7 +519,7 @@ func TestSearchFoldersFromApiServer(t *testing.T) { tracer := noop.NewTracerProvider().Tracer("TestSearchFoldersFromApiServer") service := Service{ k8sclient: fakeK8sClient, - features: featuremgmt.WithFeatures(featuremgmt.FlagKubernetesClientDashboardsFolders), + features: featuremgmt.WithFeatures(), unifiedStore: folderStore, tracer: tracer, accessControl: actest.FakeAccessControl{ExpectedEvaluate: true}, @@ -764,7 +762,7 @@ func TestGetFoldersFromApiServer(t *testing.T) { tracer := noop.NewTracerProvider().Tracer("TestGetFoldersFromApiServer") service := Service{ k8sclient: fakeK8sClient, - features: featuremgmt.WithFeatures(featuremgmt.FlagKubernetesClientDashboardsFolders), + features: featuremgmt.WithFeatures(), unifiedStore: folderStore, accessControl: actest.FakeAccessControl{ExpectedEvaluate: true}, tracer: tracer, @@ -866,7 +864,7 @@ func TestIntegrationDeleteFoldersFromApiServer(t *testing.T) { publicDashboardService: publicDashboardFakeService, accessControl: actest.FakeAccessControl{ExpectedEvaluate: true}, registry: make(map[string]folder.RegistryService), - features: featuremgmt.WithFeatures(featuremgmt.FlagKubernetesClientDashboardsFolders), + features: featuremgmt.WithFeatures(), tracer: tracer, } user := &user.SignedInUser{OrgID: 1} diff --git a/pkg/services/folder/foldertest/foldertest.go b/pkg/services/folder/foldertest/foldertest.go index 45621c6d9a6..35e55b26d0c 100644 --- a/pkg/services/folder/foldertest/foldertest.go +++ b/pkg/services/folder/foldertest/foldertest.go @@ -15,16 +15,49 @@ type FakeService struct { ExpectedError error ExpectedDescendantCounts map[string]int64 LastQuery folder.GetFoldersQuery + foldersByUID map[string]*folder.Folder } func NewFakeService() *FakeService { - return &FakeService{} + return &FakeService{ + foldersByUID: make(map[string]*folder.Folder), + } +} + +func (s *FakeService) AddFolder(f *folder.Folder) { + if s.foldersByUID == nil { + s.foldersByUID = make(map[string]*folder.Folder) + } + s.foldersByUID[f.UID] = f + s.ExpectedFolders = append(s.ExpectedFolders, f) +} + +func (s *FakeService) SetFolders(folders map[string]*folder.Folder) { + s.foldersByUID = folders + s.ExpectedFolders = make([]*folder.Folder, 0, len(folders)) + for _, f := range folders { + s.ExpectedFolders = append(s.ExpectedFolders, f) + } } var _ folder.Service = (*FakeService)(nil) func (s *FakeService) GetChildren(ctx context.Context, q *folder.GetChildrenQuery) ([]*folder.FolderReference, error) { - return s.ExpectedFoldersRef, s.ExpectedError + if s.ExpectedError != nil { + return nil, s.ExpectedError + } + + if s.ExpectedFoldersRef != nil { + return s.ExpectedFoldersRef, nil + } + + var result []*folder.FolderReference + for _, f := range s.ExpectedFolders { + if f.OrgID == q.OrgID && f.ParentUID == q.UID { + result = append(result, f.ToFolderReference()) + } + } + return result, nil } func (s *FakeService) GetChildrenLegacy(ctx context.Context, q *folder.GetChildrenQuery) ([]*folder.FolderReference, error) { return s.ExpectedFoldersRef, s.ExpectedError @@ -45,6 +78,11 @@ func (s *FakeService) CreateLegacy(ctx context.Context, cmd *folder.CreateFolder } func (s *FakeService) Get(ctx context.Context, q *folder.GetFolderQuery) (*folder.Folder, error) { + if q.UID != nil && s.foldersByUID != nil { + if f, exists := s.foldersByUID[*q.UID]; exists { + return f, nil + } + } return s.ExpectedFolder, s.ExpectedError } func (s *FakeService) GetLegacy(ctx context.Context, q *folder.GetFolderQuery) (*folder.Folder, error) { @@ -84,7 +122,23 @@ func (s *FakeService) GetDescendantCountsLegacy(ctx context.Context, q *folder.G } func (s *FakeService) GetFolders(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) { - return s.ExpectedFolders, s.ExpectedError + if s.foldersByUID != nil && len(q.UIDs) > 0 { + var result []*folder.Folder + for _, uid := range q.UIDs { + if f, exists := s.foldersByUID[uid]; exists { + result = append(result, f) + } + } + return result, nil + } + + folders := make([]*folder.Folder, 0, len(s.ExpectedFolders)) + for _, f := range s.ExpectedFolders { + if f.OrgID == q.OrgID { + folders = append(folders, f) + } + } + return folders, s.ExpectedError } func (s *FakeService) SearchFolders(ctx context.Context, q folder.SearchFoldersQuery) (model.HitList, error) { 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/libraryelements/database.go b/pkg/services/libraryelements/database.go index 96c5f82cd22..82531e39077 100644 --- a/pkg/services/libraryelements/database.go +++ b/pkg/services/libraryelements/database.go @@ -893,39 +893,20 @@ func getFoldersWithMatchingTitles(c context.Context, l *LibraryElementService, s return nil, nil } - if l.features.IsEnabled(c, featuremgmt.FlagKubernetesClientDashboardsFolders) { - searchQuery := folder.SearchFoldersQuery{ - OrgID: signedInUser.GetOrgID(), - Title: query.SearchString, - SignedInUser: signedInUser, - } - - folderHits, err := l.folderService.SearchFolders(c, searchQuery) - if err != nil { - return nil, err - } - - foldersWithMatchingTitles := make([]string, 0, len(folderHits)) - for _, hit := range folderHits { - foldersWithMatchingTitles = append(foldersWithMatchingTitles, hit.UID) - } - return foldersWithMatchingTitles, nil + searchQuery := folder.SearchFoldersQuery{ + OrgID: signedInUser.GetOrgID(), + Title: query.SearchString, + SignedInUser: signedInUser, } - // Fallback to GetFolders - fs, err := l.folderService.GetFolders(c, folder.GetFoldersQuery{ - OrgID: signedInUser.GetOrgID(), - SignedInUser: signedInUser, - }) + folderHits, err := l.folderService.SearchFolders(c, searchQuery) if err != nil { return nil, err } - foldersWithMatchingTitles := make([]string, 0, len(fs)) - for _, f := range fs { - if strings.Contains(strings.ToLower(f.Title), strings.ToLower(query.SearchString)) { - foldersWithMatchingTitles = append(foldersWithMatchingTitles, f.UID) - } + foldersWithMatchingTitles := make([]string, 0, len(folderHits)) + for _, hit := range folderHits { + foldersWithMatchingTitles = append(foldersWithMatchingTitles, hit.UID) } return foldersWithMatchingTitles, nil } diff --git a/pkg/services/libraryelements/libraryelements_create_test.go b/pkg/services/libraryelements/libraryelements_create_test.go index 53f1ed38e68..7567a658b85 100644 --- a/pkg/services/libraryelements/libraryelements_create_test.go +++ b/pkg/services/libraryelements/libraryelements_create_test.go @@ -46,8 +46,8 @@ func TestIntegration_CreateLibraryElement(t *testing.T) { }, Version: 1, Meta: model.LibraryElementDTOMeta{ - FolderName: "ScenarioFolder", - FolderUID: "uid_for_ScenarioFolder", + FolderName: sc.folder.Title, + FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: sc.initialResult.Result.Meta.Created, Updated: sc.initialResult.Result.Meta.Updated, @@ -97,8 +97,8 @@ func TestIntegration_CreateLibraryElement(t *testing.T) { }, Version: 1, Meta: model.LibraryElementDTOMeta{ - FolderName: "ScenarioFolder", - FolderUID: "uid_for_ScenarioFolder", + FolderName: sc.folder.Title, + FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: result.Result.Meta.Created, Updated: result.Result.Meta.Updated, @@ -176,8 +176,8 @@ func TestIntegration_CreateLibraryElement(t *testing.T) { }, Version: 1, Meta: model.LibraryElementDTOMeta{ - FolderName: "ScenarioFolder", - FolderUID: "uid_for_ScenarioFolder", + FolderName: sc.folder.Title, + FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: result.Result.Meta.Created, Updated: result.Result.Meta.Updated, diff --git a/pkg/services/libraryelements/libraryelements_delete_test.go b/pkg/services/libraryelements/libraryelements_delete_test.go index 86d7a886015..44d142536f6 100644 --- a/pkg/services/libraryelements/libraryelements_delete_test.go +++ b/pkg/services/libraryelements/libraryelements_delete_test.go @@ -4,9 +4,9 @@ import ( "encoding/json" "testing" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/libraryelements/model" "github.com/grafana/grafana/pkg/services/org" @@ -25,6 +25,7 @@ func TestIntegration_DeleteLibraryElement(t *testing.T) { scenarioWithPanel(t, "When an admin tries to delete a library panel that exists, it should succeed and return correct ID", func(t *testing.T, sc scenarioContext) { + sc.dashboardSvc.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{}, nil) sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": sc.initialResult.Result.UID}) resp := sc.service.deleteHandler(sc.reqContext) require.Equal(t, 200, resp.Status()) @@ -47,39 +48,14 @@ func TestIntegration_DeleteLibraryElement(t *testing.T) { scenarioWithPanel(t, "When an admin tries to delete a library panel that is connected, it should fail", func(t *testing.T, sc scenarioContext) { - dashJSON := map[string]any{ - "panels": []any{ - map[string]any{ - "id": int64(1), - "gridPos": map[string]any{ - "h": 6, - "w": 6, - "x": 0, - "y": 0, - }, - }, - map[string]any{ - "id": int64(2), - "gridPos": map[string]any{ - "h": 6, - "w": 6, - "x": 6, - "y": 0, - }, - "libraryPanel": map[string]any{ - "uid": sc.initialResult.Result.UID, - "name": sc.initialResult.Result.Name, - }, - }, + sc.dashboardSvc.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{ + { + ID: 1, + UID: "test", }, - } - dash := dashboards.Dashboard{ - Title: "Testing deleteHandler ", - Data: simplejson.NewFromAny(dashJSON), - } + }, nil) // nolint:staticcheck - dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID, sc.folder.UID) - err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, dashInDB.ID) + err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, 1) require.NoError(t, err) sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": sc.initialResult.Result.UID}) @@ -89,6 +65,7 @@ func TestIntegration_DeleteLibraryElement(t *testing.T) { scenarioWithPanel(t, "When an admin tries to delete a library panel that is connected to a non-existent dashboard, it should succeed", func(t *testing.T, sc scenarioContext) { + sc.dashboardSvc.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{}, nil) err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, 9999999) require.NoError(t, err) diff --git a/pkg/services/libraryelements/libraryelements_get_all_test.go b/pkg/services/libraryelements/libraryelements_get_all_test.go index b3a4acb6cd3..5e1bd884258 100644 --- a/pkg/services/libraryelements/libraryelements_get_all_test.go +++ b/pkg/services/libraryelements/libraryelements_get_all_test.go @@ -8,8 +8,10 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/kinds/librarypanel" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/libraryelements/model" "github.com/grafana/grafana/pkg/services/org" + searchmodel "github.com/grafana/grafana/pkg/services/search/model" "github.com/grafana/grafana/pkg/services/search/sort" ) @@ -406,7 +408,14 @@ func TestIntegration_GetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all library panels and two exist and folderFilterUIDs is set to existing folders, it should succeed and the result should be correct", func(t *testing.T, sc scenarioContext) { - newFolder := createFolder(t, sc, "NewFolder", sc.folderSvc) + newFolder := &folder.Folder{ + ID: 2, + OrgID: 1, + UID: "uid_for_NewFolder", + Title: "NewFolder", + } + sc.folderSvc.ExpectedFolder = newFolder + sc.folderSvc.ExpectedFolders = []*folder.Folder{newFolder} // nolint:staticcheck command := getCreatePanelCommand(newFolder.ID, newFolder.UID, "Text - Library Panel2") sc.reqContext.Req.Body = mockRequestBody(command) @@ -448,7 +457,7 @@ func TestIntegration_GetAllLibraryElements(t *testing.T) { }, Version: 1, Meta: model.LibraryElementDTOMeta{ - FolderName: "NewFolder", + FolderName: newFolder.Title, FolderUID: newFolder.UID, ConnectedDashboards: 0, Created: result.Result.Elements[0].Meta.Created, @@ -1171,6 +1180,14 @@ func TestIntegration_GetAllLibraryElements(t *testing.T) { // Folder name search integration tests scenarioWithPanel(t, "When searching by folder name, it should return panels in that folder", func(t *testing.T, sc scenarioContext) { + sc.folderSvc.ExpectedHitList = searchmodel.HitList{ + { + UID: sc.folder.UID, + Title: sc.folder.Title, + Type: searchmodel.DashHitFolder, + }, + } + // Create a panel in the existing folder // nolint:staticcheck command := getCreatePanelCommand(sc.folder.ID, sc.folder.UID, "Panel in ScenarioFolder") @@ -1275,6 +1292,14 @@ func TestIntegration_GetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When searching by partial folder name, it should return panels in matching folders", func(t *testing.T, sc scenarioContext) { + sc.folderSvc.ExpectedHitList = searchmodel.HitList{ + { + UID: sc.folder.UID, + Title: sc.folder.Title, + Type: searchmodel.DashHitFolder, + }, + } + // Create a panel in the existing folder // nolint:staticcheck command := getCreatePanelCommand(sc.folder.ID, sc.folder.UID, "Test Panel") diff --git a/pkg/services/libraryelements/libraryelements_get_test.go b/pkg/services/libraryelements/libraryelements_get_test.go index b05c269ee63..8baedc13c5a 100644 --- a/pkg/services/libraryelements/libraryelements_get_test.go +++ b/pkg/services/libraryelements/libraryelements_get_test.go @@ -7,7 +7,6 @@ import ( "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/kinds/librarypanel" "github.com/grafana/grafana/pkg/services/dashboards" @@ -57,7 +56,7 @@ func TestIntegration_GetLibraryElement(t *testing.T) { }, Version: 1, Meta: model.LibraryElementDTOMeta{ - FolderName: "ScenarioFolder", + FolderName: sc.folder.Title, FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: res.Result.Meta.Created, @@ -120,6 +119,8 @@ func TestIntegration_GetLibraryElement(t *testing.T) { SignedInUser: sc.reqContext.SignedInUser, }) require.NoError(t, err) + sc.folderSvc.ExpectedFolder = nil + sc.folderSvc.ExpectedError = folder.ErrFolderNotFound err = sc.sqlStore.WithDbSession(sc.reqContext.Req.Context(), func(session *db.Session) error { elem, err := sc.service.GetLibraryElement(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, session, result.UID) require.NoError(t, err) @@ -131,39 +132,7 @@ func TestIntegration_GetLibraryElement(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get a connected library panel, it should succeed and return correct connected dashboards", func(t *testing.T, sc scenarioContext) { - dashJSON := map[string]any{ - "panels": []any{ - map[string]any{ - "id": int64(1), - "gridPos": map[string]any{ - "h": 6, - "w": 6, - "x": 0, - "y": 0, - }, - }, - map[string]any{ - "id": int64(2), - "gridPos": map[string]any{ - "h": 6, - "w": 6, - "x": 6, - "y": 0, - }, - "libraryPanel": map[string]any{ - "uid": sc.initialResult.Result.UID, - "name": sc.initialResult.Result.Name, - }, - }, - }, - } - dash := dashboards.Dashboard{ - Title: "Testing getHandler", - Data: simplejson.NewFromAny(dashJSON), - } - // nolint:staticcheck - dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID, sc.folder.UID) - err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, dashInDB.ID) + err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, 1) require.NoError(t, err) expected := func(res libraryElementResult) libraryElementResult { @@ -187,7 +156,7 @@ func TestIntegration_GetLibraryElement(t *testing.T) { }, Version: 1, Meta: model.LibraryElementDTOMeta{ - FolderName: "ScenarioFolder", + FolderName: sc.folder.Title, FolderUID: sc.folder.UID, ConnectedDashboards: 1, Created: res.Result.Meta.Created, diff --git a/pkg/services/libraryelements/libraryelements_patch_test.go b/pkg/services/libraryelements/libraryelements_patch_test.go index 1dc29d065ee..9d83f707aff 100644 --- a/pkg/services/libraryelements/libraryelements_patch_test.go +++ b/pkg/services/libraryelements/libraryelements_patch_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/grafana/grafana/pkg/kinds/librarypanel" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/libraryelements/model" "github.com/grafana/grafana/pkg/util" @@ -28,7 +29,14 @@ func TestIntegration_PatchLibraryElement(t *testing.T) { scenarioWithPanel(t, "When an admin tries to patch a library panel that exists, it should succeed", func(t *testing.T, sc scenarioContext) { - newFolder := createFolder(t, sc, "NewFolder", sc.folderSvc) + newFolder := &folder.Folder{ + ID: 2, + OrgID: 1, + UID: "uid_for_NewFolder", + Title: "NewFolder", + } + sc.folderSvc.ExpectedFolder = newFolder + sc.folderSvc.ExpectedFolders = []*folder.Folder{newFolder} cmd := model.PatchLibraryElementCommand{ FolderID: newFolder.ID, // nolint:staticcheck FolderUID: &newFolder.UID, @@ -95,7 +103,14 @@ func TestIntegration_PatchLibraryElement(t *testing.T) { scenarioWithPanel(t, "When an admin tries to patch a library panel with folder only, it should change folder successfully and return correct result", func(t *testing.T, sc scenarioContext) { - newFolder := createFolder(t, sc, "NewFolder", sc.folderSvc) + newFolder := &folder.Folder{ + ID: 2, + OrgID: 1, + UID: "uid_for_NewFolder", + Title: "NewFolder", + } + sc.folderSvc.ExpectedFolder = newFolder + sc.folderSvc.ExpectedFolders = []*folder.Folder{newFolder} cmd := model.PatchLibraryElementCommand{ FolderID: newFolder.ID, // nolint:staticcheck FolderUID: &newFolder.UID, @@ -340,26 +355,35 @@ func TestIntegration_PatchLibraryElement(t *testing.T) { scenarioWithPanel(t, "When an admin tries to patch a library panel with a folder where a library panel with the same name already exists, it should fail", func(t *testing.T, sc scenarioContext) { - newFolder := createFolder(t, sc, "NewFolder", sc.folderSvc) + newFolder := &folder.Folder{ + ID: 2, + OrgID: 1, + UID: "uid_for_NewFolder", + Title: "NewFolder", + } + sc.folderSvc.ExpectedFolder = newFolder + sc.folderSvc.ExpectedFolders = []*folder.Folder{newFolder} + // nolint:staticcheck command := getCreatePanelCommand(newFolder.ID, newFolder.UID, "Text - Library Panel") sc.ctx.Req.Body = mockRequestBody(command) - resp := sc.service.createHandler(sc.reqContext) - var result = validateAndUnMarshalResponse(t, resp) + sc.service.createHandler(sc.reqContext) cmd := model.PatchLibraryElementCommand{ - FolderID: 1, // nolint:staticcheck - FolderUID: &sc.folder.UID, + FolderID: newFolder.ID, // nolint:staticcheck + FolderUID: &newFolder.UID, Version: 1, Kind: int64(model.PanelElement), } - sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": result.Result.UID}) + sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": sc.initialResult.Result.UID}) sc.ctx.Req.Body = mockRequestBody(cmd) - resp = sc.service.patchHandler(sc.reqContext) + resp := sc.service.patchHandler(sc.reqContext) require.Equal(t, 400, resp.Status()) }) scenarioWithPanel(t, "When an admin tries to patch a library panel in another org, it should fail", func(t *testing.T, sc scenarioContext) { + sc.folderSvc.ExpectedFolder = nil + sc.folderSvc.ExpectedError = folder.ErrFolderNotFound cmd := model.PatchLibraryElementCommand{ FolderID: sc.folder.ID, // nolint:staticcheck FolderUID: &sc.folder.UID, diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index def4ff5660a..d4a88a34395 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -16,26 +16,17 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/serverlock" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/kinds/librarypanel" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" - "github.com/grafana/grafana/pkg/services/accesscontrol/actest" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" - "github.com/grafana/grafana/pkg/services/apiserver" - "github.com/grafana/grafana/pkg/services/apiserver/client" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/dashboards/database" - dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/service" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" - "github.com/grafana/grafana/pkg/services/folder/folderimpl" "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/libraryelements/model" ngstore "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -43,13 +34,9 @@ import ( "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/publicdashboards" "github.com/grafana/grafana/pkg/services/quota/quotatest" - "github.com/grafana/grafana/pkg/services/search/sort" "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" - "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/web" ) @@ -67,39 +54,7 @@ func TestIntegration_DeleteLibraryPanelsInFolder(t *testing.T) { } scenarioWithPanel(t, "When an admin tries to delete a folder that contains connected library elements, it should fail", func(t *testing.T, sc scenarioContext) { - dashJSON := map[string]any{ - "panels": []any{ - map[string]any{ - "id": int64(1), - "gridPos": map[string]any{ - "h": 6, - "w": 6, - "x": 0, - "y": 0, - }, - }, - map[string]any{ - "id": int64(2), - "gridPos": map[string]any{ - "h": 6, - "w": 6, - "x": 6, - "y": 0, - }, - "libraryPanel": map[string]any{ - "uid": sc.initialResult.Result.UID, - "name": sc.initialResult.Result.Name, - }, - }, - }, - } - dash := dashboards.Dashboard{ - Title: "Testing DeleteLibraryElementsInFolder", - Data: simplejson.NewFromAny(dashJSON), - } - // nolint:staticcheck - dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID, sc.folder.UID) - err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, dashInDB.ID) + err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, 1) require.NoError(t, err) err = sc.service.DeleteLibraryElementsInFolder(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, sc.folder.UID) @@ -148,39 +103,7 @@ func TestIntegration_GetLibraryPanelConnections(t *testing.T) { } scenarioWithPanel(t, "When an admin tries to get connections of library panel, it should succeed and return correct result", func(t *testing.T, sc scenarioContext) { - dashJSON := map[string]any{ - "panels": []any{ - map[string]any{ - "id": int64(1), - "gridPos": map[string]any{ - "h": 6, - "w": 6, - "x": 0, - "y": 0, - }, - }, - map[string]any{ - "id": int64(2), - "gridPos": map[string]any{ - "h": 6, - "w": 6, - "x": 6, - "y": 0, - }, - "libraryPanel": map[string]any{ - "uid": sc.initialResult.Result.UID, - "name": sc.initialResult.Result.Name, - }, - }, - }, - } - dash := dashboards.Dashboard{ - Title: "Testing GetLibraryPanelConnections", - Data: simplejson.NewFromAny(dashJSON), - } - // nolint:staticcheck - dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID, sc.folder.UID) - err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, dashInDB.ID) + err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, 1) require.NoError(t, err) // add a connection where the dashboard doesn't exist. Shouldn't be returned in the list @@ -191,12 +114,11 @@ func TestIntegration_GetLibraryPanelConnections(t *testing.T) { return model.LibraryElementConnectionsResponse{ Result: []model.LibraryElementConnectionDTO{ { - ID: sc.initialResult.Result.ID, - Kind: sc.initialResult.Result.Kind, - ElementID: 1, - ConnectionID: dashInDB.ID, - ConnectionUID: dashInDB.UID, - Created: res.Result[0].Created, + ID: sc.initialResult.Result.ID, + Kind: sc.initialResult.Result.Kind, + ElementID: 1, + ConnectionID: 1, + Created: res.Result[0].Created, CreatedBy: librarypanel.LibraryElementDTOMetaUser{ Id: 1, Name: userInDbName, @@ -207,6 +129,13 @@ func TestIntegration_GetLibraryPanelConnections(t *testing.T) { } } + sc.dashboardSvc.On("GetDashboardsByLibraryPanelUID", mock.Anything, mock.Anything, mock.Anything).Return([]*dashboards.DashboardRef{ + { + ID: 1, + UID: "", + }, + }, nil) + sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": sc.initialResult.Result.UID}) resp := sc.service.getConnectionsHandler(sc.reqContext) var result = validateAndUnMarshalConnectionResponse(t, resp) @@ -239,13 +168,7 @@ func TestIntegration_GetLibraryPanelConnections(t *testing.T) { }) require.NoError(t, err) - dash := dashboards.Dashboard{ - Title: "Testing create element", - Data: simplejson.NewFromAny(map[string]any{}), - } - // nolint:staticcheck - dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID, sc.folder.UID) - err = sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, dashInDB.ID) + err = sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, 1) require.NoError(t, err) }) } @@ -319,77 +242,26 @@ type scenarioContext struct { initialResult libraryElementResult sqlStore db.DB log log.Logger - folderSvc folder.Service + folderSvc *foldertest.FakeService + dashboardSvc *dashboards.FakeDashboardService } -func createDashboard(t *testing.T, sqlStore db.DB, user user.SignedInUser, dash *dashboards.Dashboard, folderID int64, folderUID string) *dashboards.Dashboard { - // nolint:staticcheck - dash.FolderID = folderID - dash.FolderUID = folderUID - dashItem := &dashboards.SaveDashboardDTO{ - Dashboard: dash, - Message: "", - OrgID: user.OrgID, - User: &user, - Overwrite: false, - } - - features := featuremgmt.WithFeatures() - cfg := setting.NewCfg() - quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore)) - require.NoError(t, err) - ac := actest.FakeAccessControl{ExpectedEvaluate: true} - folderPermissions := acmock.NewMockedPermissionsService() - folderPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) - dashboardPermissions := acmock.NewMockedPermissionsService() - dashboardPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) - fStore := folderimpl.ProvideStore(sqlStore) - folderSvc := folderimpl.ProvideService( - fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, - nil, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService(), apiserver.WithoutRestConfig) - _, err = folderSvc.Create(context.Background(), &folder.CreateFolderCommand{UID: folderUID, SignedInUser: &user, Title: folderUID + "-title"}) - require.NoError(t, err) - service, err := dashboardservice.ProvideDashboardServiceImpl( - cfg, dashboardStore, folderStore, - features, folderPermissions, ac, - actest.FakeService{}, - folderSvc, - nil, - client.MockTestRestConfig{}, - nil, - quotaService, - nil, - nil, - nil, - dualwrite.ProvideTestService(), - sort.ProvideService(), - serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()), - kvstore.NewFakeKVStore(), - ) - require.NoError(t, err) - service.RegisterDashboardPermissions(dashboardPermissions) - dashboard, err := service.SaveDashboard(context.Background(), dashItem, true) - require.NoError(t, err) - - return dashboard -} - -func createFolder(t *testing.T, sc scenarioContext, title string, folderSvc folder.Service) *folder.Folder { +func createFolder(t *testing.T, sc scenarioContext, title string, folderSvc *foldertest.FakeService) *folder.Folder { t.Helper() ctx := identity.WithRequester(context.Background(), &sc.user) - folder, err := folderSvc.Create(ctx, &folder.CreateFolderCommand{ + f, err := folderSvc.Create(ctx, &folder.CreateFolderCommand{ OrgID: sc.user.OrgID, Title: title, UID: "uid_for_" + title, SignedInUser: &sc.user, }) require.NoError(t, err) + folderSvc.ExpectedFolder = f + folderSvc.ExpectedFolders = append(folderSvc.ExpectedFolders, f) // Set user permissions on the newly created folder so that they can interact with library elements stored in it - sc.reqContext.Permissions[sc.user.OrgID][dashboards.ActionFoldersWrite] = append(sc.reqContext.Permissions[sc.user.OrgID][dashboards.ActionFoldersWrite], dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder.UID)) - sc.reqContext.Permissions[sc.user.OrgID][dashboards.ActionFoldersRead] = append(sc.reqContext.Permissions[sc.user.OrgID][dashboards.ActionFoldersRead], dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder.UID)) - sc.reqContext.Permissions[sc.user.OrgID][dashboards.ActionDashboardsCreate] = append(sc.reqContext.Permissions[sc.user.OrgID][dashboards.ActionDashboardsCreate], dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder.UID)) + sc.reqContext.Permissions[sc.user.OrgID][dashboards.ActionFoldersWrite] = append(sc.reqContext.Permissions[sc.user.OrgID][dashboards.ActionFoldersWrite], dashboards.ScopeFoldersProvider.GetResourceScopeUID(f.UID)) + sc.reqContext.Permissions[sc.user.OrgID][dashboards.ActionFoldersRead] = append(sc.reqContext.Permissions[sc.user.OrgID][dashboards.ActionFoldersRead], dashboards.ScopeFoldersProvider.GetResourceScopeUID(f.UID)) + sc.reqContext.Permissions[sc.user.OrgID][dashboards.ActionDashboardsCreate] = append(sc.reqContext.Permissions[sc.user.OrgID][dashboards.ActionDashboardsCreate], dashboards.ScopeFoldersProvider.GetResourceScopeUID(f.UID)) - return folder + return f } func validateAndUnMarshalResponse(t *testing.T, resp response.Response) libraryElementResult { @@ -465,32 +337,28 @@ func setupTestScenario(t *testing.T) scenarioContext { sqlStore, cfg := db.InitTestDBWithCfg(t) t.Cleanup(db.CleanupTestDB) quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore)) - require.NoError(t, err) ac := acimpl.ProvideAccessControl(features) folderPermissions := acmock.NewMockedPermissionsService() folderPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) - dashboardPermissions := acmock.NewMockedPermissionsService() - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) - fStore := folderimpl.ProvideStore(sqlStore) publicDash := &publicdashboards.FakePublicDashboardServiceWrapper{} publicDash.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil) - folderSvc := folderimpl.ProvideService( - fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, - nil, sqlStore, features, supportbundlestest.NewFakeBundleService(), publicDash, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService(), apiserver.WithoutRestConfig) + + folderSvc := foldertest.NewFakeService() + f := &folder.Folder{ + ID: 1, + OrgID: 1, + UID: "uid_for_ScenarioFolder", + Title: "ScenarioFolder", + } + folderSvc.ExpectedFolder = f + folderSvc.ExpectedFolders = []*folder.Folder{f} + + dashService := dashboards.NewFakeDashboardService(t) + alertStore, err := ngstore.ProvideDBStore(cfg, features, sqlStore, &foldertest.FakeService{}, &dashboards.FakeDashboardService{}, ac, bus.ProvideBus(tracing.InitializeTracerForTest())) require.NoError(t, err) err = folderSvc.RegisterService(alertStore) require.NoError(t, err) - dashService, dashSvcErr := dashboardservice.ProvideDashboardServiceImpl( - cfg, dashboardStore, folderStore, - features, folderPermissions, ac, actest.FakeService{}, folderSvc, - nil, client.MockTestRestConfig{}, nil, quotaService, nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService(), - serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()), - kvstore.NewFakeKVStore(), - ) - require.NoError(t, dashSvcErr) - dashService.RegisterDashboardPermissions(dashboardPermissions) service := LibraryElementService{ Cfg: cfg, features: featuremgmt.WithFeatures(), @@ -530,7 +398,8 @@ func setupTestScenario(t *testing.T) scenarioContext { Context: &webCtx, SignedInUser: &usr, }, - folderSvc: folderSvc, + folderSvc: folderSvc, + dashboardSvc: dashService, } sc.folder = createFolder(t, sc, "ScenarioFolder", folderSvc) diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index e7118888e67..9250d690542 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -7,43 +7,27 @@ import ( "time" "github.com/google/go-cmp/cmp" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/kvstore" - "github.com/grafana/grafana/pkg/infra/serverlock" - "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/kinds/librarypanel" - "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" - acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" - "github.com/grafana/grafana/pkg/services/apiserver" - "github.com/grafana/grafana/pkg/services/apiserver/client" "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/dashboards/database" - dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/service" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" - "github.com/grafana/grafana/pkg/services/folder/folderimpl" "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/libraryelements" "github.com/grafana/grafana/pkg/services/libraryelements/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" - "github.com/grafana/grafana/pkg/services/search/sort" "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" - "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" "github.com/grafana/grafana/pkg/tests/testsuite" ) @@ -93,7 +77,7 @@ func TestIntegrationConnectLibraryPanelsForDashboard(t *testing.T) { Title: "Testing ConnectLibraryPanelsForDashboard", Data: simplejson.NewFromAny(dashJSON), } - dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash) + dashInDB := createDashboard(t, sc, &dash) err := sc.service.ConnectLibraryPanelsForDashboard(sc.ctx, sc.user, dashInDB) require.NoError(t, err) @@ -191,7 +175,7 @@ func TestIntegrationConnectLibraryPanelsForDashboard(t *testing.T) { Title: "Testing ConnectLibraryPanelsForDashboard", Data: simplejson.NewFromAny(dashJSON), } - dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash) + dashInDB := createDashboard(t, sc, &dash) err = sc.service.ConnectLibraryPanelsForDashboard(sc.ctx, sc.user, dashInDB) require.NoError(t, err) @@ -237,7 +221,7 @@ func TestIntegrationConnectLibraryPanelsForDashboard(t *testing.T) { Title: "Testing ConnectLibraryPanelsForDashboard", Data: simplejson.NewFromAny(dashJSON), } - dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash) + dashInDB := createDashboard(t, sc, &dash) err := sc.service.ConnectLibraryPanelsForDashboard(sc.ctx, sc.user, dashInDB) require.EqualError(t, err, errLibraryPanelHeaderUIDMissing.Error()) @@ -293,7 +277,7 @@ func TestIntegrationConnectLibraryPanelsForDashboard(t *testing.T) { Title: "Testing ConnectLibraryPanelsForDashboard", Data: simplejson.NewFromAny(dashJSON), } - dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash) + dashInDB := createDashboard(t, sc, &dash) err = sc.elementService.ConnectElementsToDashboard(sc.ctx, sc.user, []string{sc.initialResult.Result.UID}, dashInDB.ID) require.NoError(t, err) @@ -411,7 +395,7 @@ func TestIntegrationImportLibraryPanelsForDashboard(t *testing.T) { element, err := sc.elementService.GetElement(sc.ctx, sc.user, model.GetLibraryElementCommand{UID: missingUID, FolderName: dashboards.RootFolderName}) require.NoError(t, err) - var expected = getExpected(t, element, missingUID, missingName, missingModel) + var expected = getExpected(t, element, missingUID, missingName, missingModel, "Test Folder") var result = toLibraryElement(t, element) if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" { t.Fatalf("Result mismatch (-want +got):\n%s", diff) @@ -452,7 +436,7 @@ func TestIntegrationImportLibraryPanelsForDashboard(t *testing.T) { element, err := sc.elementService.GetElement(sc.ctx, sc.user, model.GetLibraryElementCommand{UID: existingUID, FolderName: dashboards.RootFolderName}) require.NoError(t, err) - var expected = getExpected(t, element, existingUID, existingName, sc.initialResult.Result.Model) + var expected = getExpected(t, element, existingUID, existingName, sc.initialResult.Result.Model, "Test Folder") expected.FolderUID = sc.initialResult.Result.FolderUID expected.Description = sc.initialResult.Result.Description expected.Meta.FolderUID = sc.folder.UID @@ -567,7 +551,7 @@ func TestIntegrationImportLibraryPanelsForDashboard(t *testing.T) { element, err := sc.elementService.GetElement(sc.ctx, sc.user, model.GetLibraryElementCommand{UID: outsideUID, FolderName: dashboards.RootFolderName}) require.NoError(t, err) - expected := getExpected(t, element, outsideUID, outsideName, outsideModel) + expected := getExpected(t, element, outsideUID, outsideName, outsideModel, "Test Folder") result := toLibraryElement(t, element) if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" { t.Fatalf("Result mismatch (-want +got):\n%s", diff) @@ -575,7 +559,7 @@ func TestIntegrationImportLibraryPanelsForDashboard(t *testing.T) { element, err = sc.elementService.GetElement(sc.ctx, sc.user, model.GetLibraryElementCommand{UID: insideUID, FolderName: dashboards.RootFolderName}) require.NoError(t, err) - expected = getExpected(t, element, insideUID, insideName, insideModel) + expected = getExpected(t, element, insideUID, insideName, insideModel, "Test Folder") result = toLibraryElement(t, element) if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" { t.Fatalf("Result mismatch (-want +got):\n%s", diff) @@ -648,6 +632,8 @@ type scenarioContext struct { initialResult libraryPanelResult sqlStore db.DB lps LibraryPanelService + mockDashboard *dashboards.FakeDashboardService + mockFolder *foldertest.FakeService } func toLibraryElement(t *testing.T, res model.LibraryElementDTO) libraryElement { @@ -685,7 +671,7 @@ func toLibraryElement(t *testing.T, res model.LibraryElementDTO) libraryElement } } -func getExpected(t *testing.T, res model.LibraryElementDTO, UID string, name string, lEModel map[string]any) libraryElement { +func getExpected(t *testing.T, res model.LibraryElementDTO, UID string, name string, lEModel map[string]any, folderName string) libraryElement { marshalled, err := json.Marshal(lEModel) require.NoError(t, err) var libModel libraryElementModel @@ -704,7 +690,7 @@ func getExpected(t *testing.T, res model.LibraryElementDTO, UID string, name str Model: libModel, Version: 1, Meta: model.LibraryElementDTOMeta{ - FolderName: "General", + FolderName: folderName, FolderUID: res.FolderUID, ConnectedDashboards: 0, Created: res.Meta.Created, @@ -722,59 +708,14 @@ func getExpected(t *testing.T, res model.LibraryElementDTO, UID string, name str }, } } +func createDashboard(t *testing.T, sc scenarioContext, dash *dashboards.Dashboard) *dashboards.Dashboard { + dash.ID = 1 + dash.UID = "test-dashboard-uid" + dash.Created = time.Now() + dash.Updated = time.Now() + dash.Version = 1 -func createDashboard(t *testing.T, sqlStore db.DB, user *user.SignedInUser, dash *dashboards.Dashboard) *dashboards.Dashboard { - dashItem := &dashboards.SaveDashboardDTO{ - Dashboard: dash, - Message: "", - OrgID: user.OrgID, - User: user, - Overwrite: false, - } - - features := featuremgmt.WithFeatures() - cfg := setting.NewCfg() - quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore)) - require.NoError(t, err) - ac := actest.FakeAccessControl{ExpectedEvaluate: true} - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) - dashPermissionService := acmock.NewMockedPermissionsService() - dashPermissionService.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) - service, err := dashboardservice.ProvideDashboardServiceImpl( - cfg, dashboardStore, folderStore, - features, acmock.NewMockedPermissionsService(), ac, actest.FakeService{}, foldertest.NewFakeService(), - nil, client.MockTestRestConfig{}, nil, quotaService, nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService(), - serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()), - kvstore.NewFakeKVStore()) - require.NoError(t, err) - service.RegisterDashboardPermissions(dashPermissionService) - dashboard, err := service.SaveDashboard(context.Background(), dashItem, true) - require.NoError(t, err) - - return dashboard -} - -func createFolder(t *testing.T, sc scenarioContext, title string) *folder.Folder { - t.Helper() - - features := featuremgmt.WithFeatures() - ac := actest.FakeAccessControl{ExpectedEvaluate: true} - cfg := setting.NewCfg() - dashboardStore, err := database.ProvideDashboardStore(sc.sqlStore, cfg, features, tagimpl.ProvideService(sc.sqlStore)) - require.NoError(t, err) - folderStore := folderimpl.ProvideDashboardFolderStore(sc.sqlStore) - fStore := folderimpl.ProvideStore(sc.sqlStore) - s := folderimpl.ProvideService( - fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, - nil, sc.sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService(), apiserver.WithoutRestConfig) - - t.Logf("Creating folder with title and UID %q", title) - ctx := identity.WithRequester(context.Background(), sc.user) - folder, err := s.Create(ctx, &folder.CreateFolderCommand{OrgID: sc.user.OrgID, Title: title, UID: title, SignedInUser: sc.user}) - require.NoError(t, err) - - return folder + return dash } func scenarioWithLibraryPanel(t *testing.T, desc string, fn func(t *testing.T, sc scenarioContext)) { @@ -821,8 +762,6 @@ func scenarioWithLibraryPanel(t *testing.T, desc string, fn func(t *testing.T, s }) } -// testScenario is a wrapper around t.Run performing common setup for library panel tests. -// It takes your real test function as a callback. func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioContext)) { t.Helper() @@ -832,38 +771,29 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo sqlStore, cfg := db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) features := featuremgmt.WithFeatures() - ac := actest.FakeAccessControl{ExpectedEvaluate: true} - dashStore := &dashboards.FakeDashboardStore{} - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) - dashPermissionService := acmock.NewMockedPermissionsService() - folderSvc := foldertest.NewFakeService() - folderSvc.ExpectedFolder = &folder.Folder{ID: 1} - dashService, err := dashboardservice.ProvideDashboardServiceImpl( - cfg, dashStore, folderStore, - features, acmock.NewMockedPermissionsService(), ac, actest.FakeService{}, folderSvc, - nil, client.MockTestRestConfig{}, nil, quotaService, nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService(), - serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()), - kvstore.NewFakeKVStore()) - require.NoError(t, err) - dashService.RegisterDashboardPermissions(dashPermissionService) - - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore)) - require.NoError(t, err) - fStore := folderimpl.ProvideStore(sqlStore) - - folderService := folderimpl.ProvideService( - fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, - nil, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService(), apiserver.WithoutRestConfig) - - elementService := libraryelements.ProvideService(cfg, sqlStore, routing.NewRouteRegister(), folderService, features, ac, dashService, nil, nil) + mockDashboardService := dashboards.NewFakeDashboardService(t) + mockFolderService := foldertest.NewFakeService() + mockFolder := &folder.Folder{ + ID: 1, + UID: "test-folder-uid", + Title: "Test Folder", + URL: "/dashboards/f/test-folder-uid/test-folder", + Version: 0, + Created: time.Now(), + Updated: time.Now(), + UpdatedBy: 0, + CreatedBy: 0, + HasACL: false, + } + mockFolderService.ExpectedFolder = mockFolder + elementService := libraryelements.ProvideService(cfg, sqlStore, routing.NewRouteRegister(), mockFolderService, features, ac, mockDashboardService, nil, nil) service := LibraryPanelService{ Cfg: cfg, SQLStore: sqlStore, LibraryElementService: elementService, - FolderService: folderService, + FolderService: mockFolderService, } - usr := &user.SignedInUser{ UserID: 1, Name: "Signed In User", @@ -872,17 +802,12 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo OrgID: orgID, OrgRole: role, LastSeenAt: time.Now(), - // Allow the user to create folders Permissions: map[int64]map[string][]string{ orgID: { dashboards.ActionFoldersRead: {dashboards.ScopeFoldersAll}, }, }, } - - // deliberate difference between signed in user and user in db to make it crystal clear - // what to expect in the tests - // In the real world these are identical cmd := user.CreateUserCommand{ Email: "user.in.db@test.com", Name: "User In DB", @@ -898,6 +823,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo require.NoError(t, err) _, err = usrSvc.Create(context.Background(), &cmd) require.NoError(t, err) + sc := scenarioContext{ user: usr, ctx: ctx, @@ -905,21 +831,11 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo elementService: elementService, sqlStore: sqlStore, lps: service, + mockDashboard: mockDashboardService, + mockFolder: mockFolderService, } - foldr := createFolder(t, sc, "ScenarioFolder") - sc.folder = &folder.Folder{ - ID: foldr.ID, // nolint:staticcheck - UID: foldr.UID, - Title: foldr.Title, - URL: dashboards.GetFolderURL(foldr.UID, slugify.Slugify(foldr.Title)), - Version: 0, - Created: foldr.Created, - Updated: foldr.Updated, - UpdatedBy: 0, - CreatedBy: 0, - HasACL: false, - } + sc.folder = mockFolder fn(t, sc) }) } diff --git a/pkg/services/ngalert/api/api_convert_prometheus_test.go b/pkg/services/ngalert/api/api_convert_prometheus_test.go index 35d3da6d72a..ea3a9a16a13 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus_test.go +++ b/pkg/services/ngalert/api/api_convert_prometheus_test.go @@ -571,6 +571,7 @@ func TestRouteConvertPrometheusGetRuleGroup(t *testing.T) { fldr.ParentUID = "" folderService.ExpectedFolder = fldr folderService.ExpectedFolders = []*folder.Folder{fldr} + folderService.AddFolder(fldr) ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr) // Create rules in both folders @@ -669,6 +670,8 @@ func TestRouteConvertPrometheusGetNamespace(t *testing.T) { fldr2 := randFolder() fldr2.ParentUID = "" folderService.ExpectedFolders = []*folder.Folder{fldr, fldr2} + folderService.AddFolder(fldr) + folderService.AddFolder(fldr2) ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr, fldr2) // Create a Grafana rule for each Prometheus rule @@ -798,6 +801,7 @@ func TestRouteConvertPrometheusGetRules(t *testing.T) { // Create a folder in the root fldr := randFolder() fldr.ParentUID = "" + folderService.AddFolder(fldr) folderService.ExpectedFolders = []*folder.Folder{fldr} ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr) diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index dc60b8dad58..e21f949e4f7 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -29,14 +29,10 @@ import ( "github.com/grafana/grafana/pkg/infra/log/logtest" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/actest" - "github.com/grafana/grafana/pkg/services/apiserver" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" - "github.com/grafana/grafana/pkg/services/folder/folderimpl" "github.com/grafana/grafana/pkg/services/folder/foldertest" ac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol/fakes" @@ -47,14 +43,10 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/provisioning" "github.com/grafana/grafana/pkg/services/ngalert/store" ngalertfakes "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" - "github.com/grafana/grafana/pkg/services/search/sort" "github.com/grafana/grafana/pkg/services/secrets" secrets_fakes "github.com/grafana/grafana/pkg/services/secrets/fakes" - "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" - "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -353,14 +345,6 @@ func TestIntegrationProvisioningApi(t *testing.T) { orgID := int64(2) rule := createTestAlertRule("rule", orgID) - _, err := sut.folderSvc.Create(context.Background(), &folder.CreateFolderCommand{ - UID: rule.FolderUID, - Title: "Folder Title", - OrgID: orgID, - SignedInUser: &user.SignedInUser{OrgID: orgID}, - }) - require.NoError(t, err) - insertRuleInOrg(t, sut, rule, orgID) rule.FolderUID = "does-not-exist" @@ -462,14 +446,7 @@ func TestIntegrationProvisioningApi(t *testing.T) { rc.Req.Header = map[string][]string{"X-Disable-Provenance": {"true"}} rc.OrgID = 3 rule := createTestAlertRule("rule", 1) - - _, err := sut.folderSvc.Create(context.Background(), &folder.CreateFolderCommand{ - UID: "folder-uid", - Title: "Folder Title", - OrgID: rc.OrgID, - SignedInUser: &user.SignedInUser{OrgID: rc.OrgID}, - }) - require.NoError(t, err) + rule.FolderUID = "folder-uid3" response := sut.RoutePostAlertRule(&rc, rule) @@ -486,13 +463,7 @@ func TestIntegrationProvisioningApi(t *testing.T) { rule.UID = uid orgID := int64(3) - _, err := sut.folderSvc.Create(context.Background(), &folder.CreateFolderCommand{ - UID: "folder-uid", - Title: "Folder Title", - OrgID: orgID, - SignedInUser: &user.SignedInUser{OrgID: orgID}, - }) - require.NoError(t, err) + rule.FolderUID = "folder-uid3" insertRuleInOrg(t, sut, rule, orgID) rc := createTestRequestCtx() @@ -560,14 +531,7 @@ func TestIntegrationProvisioningApi(t *testing.T) { uid := util.GenerateShortUID() rule := createTestAlertRule("rule", 3) rule.UID = uid - - _, err := sut.folderSvc.Create(context.Background(), &folder.CreateFolderCommand{ - UID: rule.FolderUID, - Title: "Folder Title", - OrgID: rule.OrgID, - SignedInUser: &user.SignedInUser{OrgID: rule.OrgID}, - }) - require.NoError(t, err) + rule.FolderUID = "folder-uid3" insertRuleInOrg(t, sut, rule, 3) @@ -2053,7 +2017,7 @@ func createTestEnv(t *testing.T, testConfig string) testEnvironment { GetsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: string(raw), }) - sqlStore, cfg := db.InitTestDBWithCfg(t) + sqlStore, _ := db.InitTestDBWithCfg(t) quotas := &provisioning.MockQuotaChecker{} quotas.EXPECT().LimitOK() @@ -2077,14 +2041,39 @@ func createTestEnv(t *testing.T, testConfig string) testEnvironment { }}, nil).Maybe() ac := &recordingAccessControlFake{} - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore)) - require.NoError(t, err) - - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) - fStore := folderimpl.ProvideStore(sqlStore) - folderService := folderimpl.ProvideService( - fStore, actest.FakeAccessControl{ExpectedEvaluate: true}, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, - nil, sqlStore, featuremgmt.WithFeatures(), supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService(), apiserver.WithoutRestConfig) + folderService := foldertest.NewFakeService() + folder1 := &folder.Folder{ + UID: "folder-uid", + Title: "Folder Title", + Fullpath: "Folder Title", + OrgID: 1, + } + folder2 := &folder.Folder{ + UID: "folder-uid2", + Title: "Folder Title2", + ParentUID: "folder-uid", + Fullpath: "Folder Title2", + OrgID: 1, + } + folder3 := &folder.Folder{ + UID: "folder-uid3", + Title: "Folder Title3", + ParentUID: "folder-uid", + Fullpath: "Folder Title3", + OrgID: 3, + } + folderService.SetFolders(map[string]*folder.Folder{ + "folder-uid": folder1, + "folder-uid2": folder2, + "folder-uid3": folder3, + }) + folderService.ExpectedFolders = []*folder.Folder{ + folder1, + folder2, + folder3, + } + // if not one of the two above, return ErrFolderNotFound + folderService.ExpectedError = dashboards.ErrFolderNotFound store := store.DBstore{ Logger: log, SQLStore: sqlStore, @@ -2104,23 +2093,6 @@ func createTestEnv(t *testing.T, testConfig string) testEnvironment { */ } - parent, err := folderService.Create(context.Background(), &folder.CreateFolderCommand{ - OrgID: 1, - UID: "folder-uid", - Title: "Folder Title", - SignedInUser: user, - }) - require.NoError(t, err) - - _, err = folderService.Create(context.Background(), &folder.CreateFolderCommand{ - OrgID: 1, - UID: "folder-uid2", - Title: "Folder Title2", - ParentUID: parent.UID, - SignedInUser: user, - }) - require.NoError(t, err) - ruleAuthz := &fakes.FakeRuleService{} features := featuremgmt.WithFeatures() 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/provisioning/alert_rules_test.go b/pkg/services/ngalert/provisioning/alert_rules_test.go index ae9eb265b6b..ea488b83971 100644 --- a/pkg/services/ngalert/provisioning/alert_rules_test.go +++ b/pkg/services/ngalert/provisioning/alert_rules_test.go @@ -17,30 +17,23 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/expr" - "github.com/grafana/grafana/pkg/services/apiserver" - "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" - "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" - "github.com/grafana/grafana/pkg/services/search/sort" - "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" - "github.com/grafana/grafana/pkg/util" - "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" - acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" - "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" - "github.com/grafana/grafana/pkg/services/folder/folderimpl" "github.com/grafana/grafana/pkg/services/folder/foldertest" + "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" - "github.com/grafana/grafana/pkg/services/ngalert/testutil" + "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" ) +// note: additional integration tests are in /pkg/tests/api/alerting/api_provisioning_test.go + func TestIntegrationAlertRuleService(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") @@ -1989,93 +1982,6 @@ func TestDeleteRuleGroups(t *testing.T) { }) } -func TestIntegrationProvisiongWithFullpath(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test in short mode") - } - tracer := tracing.InitializeTracerForTest() - inProcBus := bus.ProvideBus(tracer) - sqlStore, cfg := db.InitTestDBWithCfg(t) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) - _, dashboardStore := testutil.SetupDashboardService(t, sqlStore, folderStore, cfg) - ac := acmock.New() - features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders) - fStore := folderimpl.ProvideStore(sqlStore) - folderService := folderimpl.ProvideService( - fStore, ac, inProcBus, dashboardStore, folderStore, - nil, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService(), apiserver.WithoutRestConfig) - - ruleService := createAlertRuleService(t, folderService) - var orgID int64 = 1 - - signedInUser := user.SignedInUser{UserID: 1, OrgID: orgID, Permissions: map[int64]map[string][]string{ - orgID: { - dashboards.ActionFoldersCreate: {dashboards.ScopeFoldersAll}, - dashboards.ActionFoldersRead: {dashboards.ScopeFoldersAll}, - dashboards.ActionFoldersWrite: {dashboards.ScopeFoldersAll}}, - }} - namespaceUID := "my-namespace" - namespaceTitle := namespaceUID - rootFolder, err := folderService.Create(context.Background(), &folder.CreateFolderCommand{ - UID: namespaceUID, - Title: namespaceTitle, - OrgID: orgID, - SignedInUser: &signedInUser, - }) - require.NoError(t, err) - - t.Run("for a rule under a root folder should set the right fullpath", func(t *testing.T) { - r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(&signedInUser), []models.AlertRule{ - createTestRule("my-cool-group", "my-cool-group", orgID, namespaceUID), - }) - require.NoError(t, err) - require.Len(t, r, 1) - - res, err := ruleService.GetAlertRuleWithFolderFullpath(context.Background(), &signedInUser, r[0].UID) - require.NoError(t, err) - assert.Equal(t, namespaceTitle, res.FolderFullpath) - - res2, err := ruleService.GetAlertRuleGroupWithFolderFullpath(context.Background(), &signedInUser, namespaceUID, "my-cool-group") - require.NoError(t, err) - assert.Equal(t, namespaceTitle, res2.FolderFullpath) - - res3, err := ruleService.GetAlertGroupsWithFolderFullpath(context.Background(), &signedInUser, &FilterOptions{NamespaceUIDs: []string{namespaceUID}}) - require.NoError(t, err) - assert.Equal(t, namespaceTitle, res3[0].FolderFullpath) - }) - - t.Run("for a rule under a subfolder should set the right fullpath", func(t *testing.T) { - otherNamespaceUID := "my-other-namespace" - otherNamespaceTitle := "my-other-namespace containing multiple //" - _, err := folderService.Create(context.Background(), &folder.CreateFolderCommand{ - UID: otherNamespaceUID, - Title: otherNamespaceTitle, - OrgID: orgID, - ParentUID: rootFolder.UID, - SignedInUser: &signedInUser, - }) - require.NoError(t, err) - - r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(&signedInUser), []models.AlertRule{ - createTestRule("my-cool-group-2", "my-cool-group-2", orgID, otherNamespaceUID), - }) - require.NoError(t, err) - require.Len(t, r, 1) - - res, err := ruleService.GetAlertRuleWithFolderFullpath(context.Background(), &signedInUser, r[0].UID) - require.NoError(t, err) - assert.Equal(t, "my-namespace/my-other-namespace containing multiple \\/\\/", res.FolderFullpath) - - res2, err := ruleService.GetAlertRuleGroupWithFolderFullpath(context.Background(), &signedInUser, otherNamespaceUID, "my-cool-group-2") - require.NoError(t, err) - assert.Equal(t, "my-namespace/my-other-namespace containing multiple \\/\\/", res2.FolderFullpath) - - res3, err := ruleService.GetAlertGroupsWithFolderFullpath(context.Background(), &signedInUser, &FilterOptions{NamespaceUIDs: []string{otherNamespaceUID}}) - require.NoError(t, err) - assert.Equal(t, "my-namespace/my-other-namespace containing multiple \\/\\/", res3[0].FolderFullpath) - }) -} - func getDeleteQueries(ruleStore *fakes.RuleStore) []fakes.GenericRecordedQuery { generic := ruleStore.GetRecordedCommands(func(cmd any) (any, bool) { a, ok := cmd.(fakes.GenericRecordedQuery) 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/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index 97c6002b853..74f51fd305a 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -27,12 +27,12 @@ import ( "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder/folderimpl" "github.com/grafana/grafana/pkg/services/ngalert/testutil" - "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/infra/db" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -242,10 +242,10 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) { } sqlStore := db.InitTestDB(t) - folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) + fakeFolderService := foldertest.NewFakeService() b := &fakeBus{} logger := &logtest.Fake{} - store := createTestStore(sqlStore, folderService, logger, cfg.UnifiedAlerting, b) + store := createTestStore(sqlStore, fakeFolderService, logger, cfg.UnifiedAlerting, b) store.FeatureToggles = featuremgmt.WithFeatures() gen := models.RuleGen @@ -258,15 +258,38 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) { parentFolderUid := uuid.NewString() parentFolderTitle := "Very Parent Folder" - createFolder(t, store, parentFolderUid, parentFolderTitle, rule1.OrgID, "") rule1FolderTitle := "folder-" + rule1.Title rule2FolderTitle := "folder-" + rule2.Title rule3FolderTitle := "folder-" + rule3.Title - createFolder(t, store, rule1.NamespaceUID, rule1FolderTitle, rule1.OrgID, parentFolderUid) - createFolder(t, store, rule2.NamespaceUID, rule2FolderTitle, rule2.OrgID, "") - createFolder(t, store, rule3.NamespaceUID, rule3FolderTitle, rule3.OrgID, "") - createFolder(t, store, rule2.NamespaceUID, "same UID folder", gen.GenerateRef().OrgID, "") // create a folder with the same UID but in the different org + fakeFolderService.AddFolder(&folder.Folder{ + UID: rule1.NamespaceUID, + Title: rule1FolderTitle, + OrgID: rule1.OrgID, + ParentUID: parentFolderUid, + Fullpath: rule1FolderTitle, + }) + fakeFolderService.AddFolder(&folder.Folder{ + UID: rule2.NamespaceUID, + Title: rule2FolderTitle, + OrgID: rule2.OrgID, + ParentUID: "", + Fullpath: rule2FolderTitle, + }) + fakeFolderService.AddFolder(&folder.Folder{ + UID: rule3.NamespaceUID, + Title: rule3FolderTitle, + OrgID: rule3.OrgID, + ParentUID: "", + Fullpath: rule3FolderTitle, + }) + fakeFolderService.AddFolder(&folder.Folder{ + UID: parentFolderUid, + Title: parentFolderTitle, + OrgID: rule1.OrgID, + ParentUID: "", + Fullpath: parentFolderTitle, + }) tc := []struct { name string @@ -343,7 +366,14 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) { } t.Run("when nested folders are enabled folders should contain full path", func(t *testing.T) { - store.FolderService = setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)) + fakeFolderService.AddFolder(&folder.Folder{ + UID: rule1.NamespaceUID, + Title: rule1FolderTitle, + OrgID: rule1.OrgID, + ParentUID: parentFolderUid, + Fullpath: parentFolderTitle + "/" + rule1FolderTitle, + }) + query := &models.GetAlertRulesForSchedulingQuery{ PopulateFolders: true, } @@ -1597,27 +1627,6 @@ func createRule(t *testing.T, store *DBstore, generator *models.AlertRuleGenerat return rule } -func createFolder(t *testing.T, store *DBstore, uid, title string, orgID int64, parentUID string) { - t.Helper() - u := &user.SignedInUser{ - UserID: 1, - OrgID: orgID, - OrgRole: org.RoleAdmin, - IsGrafanaAdmin: true, - } - - _, err := store.FolderService.Create(context.Background(), &folder.CreateFolderCommand{ - UID: uid, - OrgID: orgID, - Title: title, - Description: "", - SignedInUser: u, - ParentUID: parentUID, - }) - - require.NoError(t, err) -} - func setupFolderService(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles) folder.Service { tracer := tracing.InitializeTracerForTest() inProcBus := bus.ProvideBus(tracer) diff --git a/pkg/services/ngalert/store/namespace_test.go b/pkg/services/ngalert/store/namespace_test.go index 6cc1c610dba..03fea1b514e 100644 --- a/pkg/services/ngalert/store/namespace_test.go +++ b/pkg/services/ngalert/store/namespace_test.go @@ -10,8 +10,8 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" @@ -27,7 +27,7 @@ func TestIntegration_GetUserVisibleNamespaces(t *testing.T) { sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() - folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) + folderService := foldertest.NewFakeService() b := &fakeBus{} logger := log.New("test-dbstore") store := createTestStore(sqlStore, folderService, logger, cfg.UnifiedAlerting, b) @@ -39,18 +39,14 @@ func TestIntegration_GetUserVisibleNamespaces(t *testing.T) { IsGrafanaAdmin: true, } - folders := []struct { - uid string - title string - parentUid string - }{ - {uid: uuid.NewString(), title: "folder1", parentUid: ""}, - {uid: uuid.NewString(), title: "folder2", parentUid: ""}, - {uid: uuid.NewString(), title: "nested/folder", parentUid: ""}, + folders := []*folder.Folder{ + {UID: uuid.NewString(), Title: "folder1", ParentUID: "", OrgID: 1}, + {UID: uuid.NewString(), Title: "folder2", ParentUID: "", OrgID: 1}, + {UID: uuid.NewString(), Title: "nested/folder", ParentUID: "", OrgID: 1}, } for _, f := range folders { - createFolder(t, store, f.uid, f.title, 1, f.parentUid) + folderService.AddFolder(f) } t.Run("returns all folders", func(t *testing.T) { @@ -67,422 +63,26 @@ func TestIntegration_GetUserVisibleNamespaces(t *testing.T) { }) } -func TestIntegration_GetNamespaceByUID(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") +func TestGetNamespaceByTitle(t *testing.T) { + folderService := foldertest.NewFakeService() + folderService.ExpectedError = dashboards.ErrFolderNotFound + store := DBstore{ + FolderService: folderService, } + _, err := store.GetNamespaceByTitle(context.Background(), "Test Folder", 1, nil, folder.RootFolderUID) + require.Error(t, err) + require.ErrorIs(t, err, dashboards.ErrFolderNotFound) - sqlStore := db.InitTestDB(t) - cfg := setting.NewCfg() - folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) - b := &fakeBus{} - logger := log.New("test-dbstore") - store := createTestStore(sqlStore, folderService, logger, cfg.UnifiedAlerting, b) - - u := &user.SignedInUser{ - UserID: 1, - OrgID: 1, - OrgRole: org.RoleAdmin, - IsGrafanaAdmin: true, - } - - uid := uuid.NewString() - parentUid := uuid.NewString() - title := "folder/title" - parentTitle := "parent-title" - createFolder(t, store, parentUid, parentTitle, 1, "") - createFolder(t, store, uid, title, 1, parentUid) - - actual, err := store.GetNamespaceByUID(context.Background(), uid, 1, u) - require.NoError(t, err) - require.Equal(t, title, actual.Title) - require.Equal(t, uid, actual.UID) - require.Equal(t, title, actual.Fullpath) - - t.Run("error when user does not have permissions", func(t *testing.T) { - someUser := &user.SignedInUser{ - UserID: 2, - OrgID: 1, - OrgRole: org.RoleViewer, - } - _, err = store.GetNamespaceByUID(context.Background(), uid, 1, someUser) - require.ErrorIs(t, err, dashboards.ErrFolderAccessDenied) - }) - - t.Run("error when folder does not exist", func(t *testing.T) { - nonExistentUID := uuid.NewString() - _, err := store.GetNamespaceByUID(context.Background(), nonExistentUID, 1, u) - require.ErrorIs(t, err, dashboards.ErrFolderAccessDenied) - }) - - t.Run("when nested folders are enabled full path should be populated with correct value", func(t *testing.T) { - store.FolderService = setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)) - actual, err := store.GetNamespaceByUID(context.Background(), uid, 1, u) - require.NoError(t, err) - require.Equal(t, title, actual.Title) - require.Equal(t, uid, actual.UID) - require.Equal(t, "parent-title/folder\\/title", actual.Fullpath) - }) + // note: most tests are in /pkg/tests/api/alerting/api_namespace_test.go } -func TestIntegration_GetNamespaceByTitle(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } +func TestGetOrCreateNamespaceByTitle(t *testing.T) { + store := DBstore{} + _, err := store.GetOrCreateNamespaceByTitle(context.Background(), "", 1, nil, folder.RootFolderUID) + require.Error(t, err) + require.Contains(t, err.Error(), "title is empty") - sqlStore := db.InitTestDB(t) - cfg := setting.NewCfg() - folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) - b := &fakeBus{} - logger := log.New("test-dbstore") - store := createTestStore(sqlStore, folderService, logger, cfg.UnifiedAlerting, b) - store.FolderService = setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)) - - u := &user.SignedInUser{ - UserID: 1, - OrgID: 1, - OrgRole: org.RoleAdmin, - IsGrafanaAdmin: true, - } - - // Create parent folder - parentUID := uuid.NewString() - parentTitle := "parent-folder" - createFolder(t, store, parentUID, parentTitle, 1, "") - - // Create child folder under parent - childUID := uuid.NewString() - childTitle := "child-folder" - createFolder(t, store, childUID, childTitle, 1, parentUID) - - // Create another folder with same title but under root - sameTitleInRoot := uuid.NewString() - createFolder(t, store, sameTitleInRoot, childTitle, 1, "") - - t.Run("should find folder by title and parent UID", func(t *testing.T) { - actual, err := store.GetNamespaceByTitle(context.Background(), childTitle, 1, u, parentUID) - require.NoError(t, err) - require.Equal(t, childTitle, actual.Title) - require.Equal(t, childUID, actual.UID) - require.Equal(t, parentUID, actual.ParentUID) - }) - - t.Run("should find folder by title in root", func(t *testing.T) { - actual, err := store.GetNamespaceByTitle(context.Background(), childTitle, 1, u, folder.RootFolderUID) - require.NoError(t, err) - require.Equal(t, childTitle, actual.Title) - require.Equal(t, sameTitleInRoot, actual.UID) - require.Equal(t, folder.RootFolderUID, actual.ParentUID) - }) - - t.Run("should return ErrFolderNotFound when folder with title doesn't exist under specified parent", func(t *testing.T) { - nonExistentTitle := "non-existent-folder" - f, err := store.GetNamespaceByTitle(context.Background(), nonExistentTitle, 1, u, parentUID) - require.Nil(t, f) - require.ErrorIs(t, err, dashboards.ErrFolderNotFound) - }) -} - -func TestIntegration_GetOrCreateNamespaceByTitle(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - u := &user.SignedInUser{ - UserID: 1, - OrgID: 1, - OrgRole: org.RoleAdmin, - IsGrafanaAdmin: true, - } - - setupStore := func(t *testing.T) *DBstore { - sqlStore := db.InitTestDB(t) - cfg := setting.NewCfg() - folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) - b := &fakeBus{} - logger := log.New("test-dbstore") - store := createTestStore(sqlStore, folderService, logger, cfg.UnifiedAlerting, b) - store.FolderService = setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)) - - return store - } - - t.Run("should return error when title is empty", func(t *testing.T) { - store := setupStore(t) - _, err := store.GetOrCreateNamespaceByTitle(context.Background(), "", 1, u, folder.RootFolderUID) - require.Error(t, err) - require.Contains(t, err.Error(), "title is empty") - }) - - t.Run("should create folder when it does not exist", func(t *testing.T) { - store := setupStore(t) - - f, err := store.GetOrCreateNamespaceByTitle(context.Background(), "new folder", 1, u, folder.RootFolderUID) - require.NoError(t, err) - require.Equal(t, "new folder", f.Title) - require.NotEmpty(t, f.UID) - require.Equal(t, folder.RootFolderUID, f.ParentUID) - - folders, err := store.FolderService.GetFolders( - context.Background(), - folder.GetFoldersQuery{ - OrgID: 1, - WithFullpath: true, - SignedInUser: u, - }, - ) - require.NoError(t, err) - require.Len(t, folders, 1) - }) - - t.Run("should return existing folder when it exists", func(t *testing.T) { - store := setupStore(t) - - title := "existing folder" - createFolder(t, store, "", title, 1, "") - f, err := store.GetOrCreateNamespaceByTitle(context.Background(), title, 1, u, folder.RootFolderUID) - require.NoError(t, err) - require.Equal(t, title, f.Title) - - folders, err := store.FolderService.GetFolders( - context.Background(), - folder.GetFoldersQuery{ - OrgID: 1, - WithFullpath: true, - SignedInUser: u, - }, - ) - require.NoError(t, err) - require.Len(t, folders, 1) - }) - - t.Run("should create folder under specified parent when it does not exist", func(t *testing.T) { - store := setupStore(t) - - // Create parent folder first - parentTitle := "parent folder" - parentFolder, err := store.GetOrCreateNamespaceByTitle(context.Background(), parentTitle, 1, u, folder.RootFolderUID) - require.NoError(t, err) - - // Now create a child folder under the parent - childTitle := "child folder" - childFolder, err := store.GetOrCreateNamespaceByTitle(context.Background(), childTitle, 1, u, parentFolder.UID) - require.NoError(t, err) - - // Verify the child folder was created under the parent - folders, err := store.FolderService.GetChildren(context.Background(), &folder.GetChildrenQuery{UID: parentFolder.UID, OrgID: 1, SignedInUser: u}) - require.NoError(t, err) - require.Len(t, folders, 1) - require.Equal(t, childFolder.UID, folders[0].UID) - - folders, err = store.FolderService.GetChildren(context.Background(), &folder.GetChildrenQuery{UID: folder.RootFolderUID, OrgID: 1, SignedInUser: u}) - require.NoError(t, err) - require.Len(t, folders, 1) - require.Equal(t, parentFolder.UID, folders[0].UID) - }) - - t.Run("should get correct folder when same title exists under different parents", func(t *testing.T) { - store := setupStore(t) - - // Create first parent folder - parent1Title := "parent folder 1" - parent1, err := store.GetOrCreateNamespaceByTitle(context.Background(), parent1Title, 1, u, folder.RootFolderUID) - require.NoError(t, err) - - // Create second parent folder - parent2Title := "parent folder 2" - parent2, err := store.GetOrCreateNamespaceByTitle(context.Background(), parent2Title, 1, u, folder.RootFolderUID) - require.NoError(t, err) - - // Create folders with same title under different parents - sameTitle := "same title folder" - - // Create under first parent - folder1, err := store.GetOrCreateNamespaceByTitle(context.Background(), sameTitle, 1, u, parent1.UID) - require.NoError(t, err) - - // Create under second parent - folder2, err := store.GetOrCreateNamespaceByTitle(context.Background(), sameTitle, 1, u, parent2.UID) - require.NoError(t, err) - - // Create under root - folder3, err := store.GetOrCreateNamespaceByTitle(context.Background(), sameTitle, 1, u, folder.RootFolderUID) - require.NoError(t, err) - - // Verify we get the correct folders when specifying the parent - gotFolder1, err := store.GetOrCreateNamespaceByTitle(context.Background(), sameTitle, 1, u, parent1.UID) - require.NoError(t, err) - require.Equal(t, folder1.UID, gotFolder1.UID) - require.Equal(t, parent1.UID, gotFolder1.ParentUID) - - gotFolder2, err := store.GetOrCreateNamespaceByTitle(context.Background(), sameTitle, 1, u, parent2.UID) - require.NoError(t, err) - require.Equal(t, folder2.UID, gotFolder2.UID) - require.Equal(t, parent2.UID, gotFolder2.ParentUID) - - gotFolder3, err := store.GetOrCreateNamespaceByTitle(context.Background(), sameTitle, 1, u, folder.RootFolderUID) - require.NoError(t, err) - require.Equal(t, folder3.UID, gotFolder3.UID) - require.Equal(t, folder.RootFolderUID, gotFolder3.ParentUID) - }) - - t.Run("should create folder with deterministic UID and handle race conditions", func(t *testing.T) { - store := setupStore(t) - - folderTitle := "race condition test folder" - parentUID := folder.RootFolderUID - - // Calculate the expected UID that would be generated - expectedUID, err := generateAlertingFolderUID(folderTitle, parentUID, 1) - require.NoError(t, err) - - // Create a folder first, simulating another concurrent call that succeeded first - createFolder(t, store, expectedUID, folderTitle, 1, parentUID) - - // Now try to create a folder with the same title and parent - // This should not create a duplicate folder but return the existing one - f, err := store.GetOrCreateNamespaceByTitle(context.Background(), folderTitle, 1, u, parentUID) - require.NoError(t, err) - require.Equal(t, expectedUID, f.UID, "Should return folder with same UID as would be deterministically generated") - require.Equal(t, folderTitle, f.Title) - require.Equal(t, parentUID, f.ParentUID) - - // Verify only one folder was created - folders, err := store.FolderService.GetFolders( - context.Background(), - folder.GetFoldersQuery{ - OrgID: 1, - WithFullpath: true, - SignedInUser: u, - }, - ) - require.NoError(t, err) - require.Len(t, folders, 1, "Only one folder should exist") - }) - - t.Run("should handle special characters in folder titles", func(t *testing.T) { - store := setupStore(t) - specialTitles := []string{ - "folder/with/slashes", - "folder with spaces", - "folder-with-dashes", - "folder_with_underscores", - "folder.with.dots", - "!@#$%^&*()", - } - - for _, title := range specialTitles { - t.Run(title, func(t *testing.T) { - f, err := store.GetOrCreateNamespaceByTitle(context.Background(), title, 1, u, folder.RootFolderUID) - require.NoError(t, err) - require.Equal(t, title, f.Title) - - // Verify retrieval works - retrieved, err := store.GetNamespaceByTitle(context.Background(), title, 1, u, folder.RootFolderUID) - require.NoError(t, err) - require.Equal(t, f.UID, retrieved.UID) - }) - } - }) -} - -func TestIntegration_GetNamespaceChildren(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - sqlStore := db.InitTestDB(t) - cfg := setting.NewCfg() - folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) - b := &fakeBus{} - logger := log.New("test-dbstore") - store := createTestStore(sqlStore, folderService, logger, cfg.UnifiedAlerting, b) - store.FolderService = setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)) - - admin := &user.SignedInUser{ - UserID: 1, - OrgID: 1, - OrgRole: org.RoleAdmin, - IsGrafanaAdmin: true, - } - - // Create root folders - rootFolder1 := uuid.NewString() - rootFolder2 := uuid.NewString() - createFolder(t, store, rootFolder1, "Root Folder 1", 1, "") - createFolder(t, store, rootFolder2, "Root Folder 2", 1, "") - - // Create child folders under root folder 1 - child1 := uuid.NewString() - child2 := uuid.NewString() - createFolder(t, store, child1, "Child Folder 1", 1, rootFolder1) - createFolder(t, store, child2, "Child Folder 2", 1, rootFolder1) - - // Create nested child under child1 - nestedChild := uuid.NewString() - createFolder(t, store, nestedChild, "Nested Child", 1, child1) - - differentOrgID := int64(999) - createFolder(t, store, util.GenerateShortUID(), "Root Folder 1", differentOrgID, "") - - /* - * Folder structure: - * - * Root Folder 1 - * - Child Folder 1 - * - Nested Child - * - Child Folder 2 - * Root Folder 2 - */ - - t.Run("should return direct children of a folder", func(t *testing.T) { - children, err := store.GetNamespaceChildren(context.Background(), rootFolder1, 1, admin) - require.NoError(t, err) - require.Len(t, children, 2) - - require.ElementsMatch(t, []string{child1, child2}, []string{children[0].UID, children[1].UID}) - - // Verify parent UID - for _, child := range children { - require.Equal(t, rootFolder1, child.ParentUID) - } - }) - - t.Run("should return direct children of a nested folder", func(t *testing.T) { - children, err := store.GetNamespaceChildren(context.Background(), child1, 1, admin) - require.NoError(t, err) - require.Len(t, children, 1) - require.Equal(t, nestedChild, children[0].UID) - require.Equal(t, child1, children[0].ParentUID) - }) - - t.Run("should return nil when folder does not exist", func(t *testing.T) { - nonExistentUID := uuid.NewString() - children, err := store.GetNamespaceChildren(context.Background(), nonExistentUID, 1, admin) - require.NotNil(t, children) - require.Empty(t, children) - require.Nil(t, err) - }) - - t.Run("should return empty array for folders with no children", func(t *testing.T) { - children, err := store.GetNamespaceChildren(context.Background(), rootFolder2, 1, admin) - require.Empty(t, children) - require.NotNil(t, children) - require.Nil(t, err) - }) - - t.Run("should return no children for a different org", func(t *testing.T) { - children, err := store.GetNamespaceChildren(context.Background(), rootFolder1, differentOrgID, admin) - require.Empty(t, children) - require.Nil(t, err) - }) - - t.Run("should return children from root folder", func(t *testing.T) { - children, err := store.GetNamespaceChildren(context.Background(), "", 1, admin) - require.NoError(t, err) - require.Equal(t, len(children), 2) - require.ElementsMatch(t, []string{rootFolder1, rootFolder2}, []string{children[0].UID, children[1].UID}) - }) + // note: most tests are in /pkg/tests/api/alerting/api_namespace_test.go } func TestGenerateAlertingFolderUID(t *testing.T) { diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index 762dde12a36..60674e8153c 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -24,13 +24,12 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" - "github.com/grafana/grafana/pkg/services/folder/folderimpl" + "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/ngalert" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" ngalertfakes "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" - "github.com/grafana/grafana/pkg/services/ngalert/testutil" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/quota/quotatest" @@ -83,9 +82,8 @@ func SetupTestEnv(tb testing.TB, baseInterval time.Duration, opts ...TestEnvOpti tracer := tracing.InitializeTracerForTest() bus := bus.ProvideBus(tracer) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) - dashboardService, dashboardStore := testutil.SetupDashboardService(tb, sqlStore, folderStore, cfg) - folderService := testutil.SetupFolderService(tb, cfg, sqlStore, dashboardStore, folderStore, bus, options.featureToggles, ac) + folderService := foldertest.NewFakeService() + dashboardService := dashboards.NewFakeDashboardService(tb) ruleStore, err := store.ProvideDBStore(cfg, options.featureToggles, sqlStore, folderService, &dashboards.FakeDashboardService{}, ac, bus) require.NoError(tb, err) ng, err := ngalert.ProvideService( diff --git a/pkg/services/pluginsintegration/plugintest/plugins_test.go b/pkg/services/pluginsintegration/plugintest/plugins_test.go index 1ba9fb78fea..d22dd7ab7d5 100644 --- a/pkg/services/pluginsintegration/plugintest/plugins_test.go +++ b/pkg/services/pluginsintegration/plugintest/plugins_test.go @@ -163,7 +163,7 @@ func TestIntegrationPluginManager(t *testing.T) { pr := prometheus.ProvideService(hcp) tmpo := tempo.ProvideService(hcp) td := testdatasource.ProvideService() - pg := postgres.ProvideService(cfg) + pg := postgres.ProvideService(cfg, features) my := mysql.ProvideService() ms := mssql.ProvideService(cfg) db := db.InitTestDB(t, sqlstore.InitTestDBOpt{Cfg: cfg}) 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/quota/quotaimpl/quota_test.go b/pkg/services/quota/quotaimpl/quota_test.go index e25bcb44a4c..bf89d4aec5d 100644 --- a/pkg/services/quota/quotaimpl/quota_test.go +++ b/pkg/services/quota/quotaimpl/quota_test.go @@ -504,8 +504,10 @@ func setupEnv(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, b bus.Bus, quotaSe folderSvc := folderimpl.ProvideService( fStore, acmock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore, nil, sqlStore, featuremgmt.WithFeatures(), supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService(), apiserver.WithoutRestConfig) + orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) + require.NoError(t, err) dashService, err := dashService.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuremgmt.WithFeatures(), acmock.NewMockedPermissionsService(), - ac, actest.FakeService{}, folderSvc, nil, client.MockTestRestConfig{}, nil, quotaService, nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService(), + ac, actest.FakeService{}, folderSvc, nil, client.MockTestRestConfig{}, nil, quotaService, orgService, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService(), serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()), kvstore.NewFakeKVStore()) require.NoError(t, err) 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/permissions/dashboard_test.go b/pkg/services/sqlstore/permissions/dashboard_test.go index 46474d70df7..716323d3151 100644 --- a/pkg/services/sqlstore/permissions/dashboard_test.go +++ b/pkg/services/sqlstore/permissions/dashboard_test.go @@ -10,13 +10,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/actest" - "github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" "github.com/grafana/grafana/pkg/services/dashboards/database" @@ -25,14 +21,11 @@ import ( "github.com/grafana/grafana/pkg/services/folder/folderimpl" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/search/sort" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" - "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" "github.com/grafana/grafana/pkg/tests/testsuite" ) @@ -826,31 +819,42 @@ func setupNestedTest(t *testing.T, usr *user.SignedInUser, perms []accesscontrol // dashboard store commands that should be called. dashStore, err := database.ProvideDashboardStore(db, cfg, features, tagimpl.ProvideService(db)) require.NoError(t, err) - fStore := folderimpl.ProvideStore(db) - folderSvc := folderimpl.ProvideService( - fStore, actest.FakeAccessControl{ExpectedEvaluate: true}, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderimpl.ProvideDashboardFolderStore(db), - nil, db, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService(), apiserver.WithoutRestConfig) - - // create parent folder - parent, err := folderSvc.Create(context.Background(), &folder.CreateFolderCommand{ - UID: "parent", - OrgID: orgID, + // create in both the folder & dashboard tables + parent, err := fStore.Create(context.Background(), folder.CreateFolderCommand{ Title: "parent", - SignedInUser: usr, - }) - require.NoError(t, err) - - // create subfolder - subfolder, err := folderSvc.Create(context.Background(), &folder.CreateFolderCommand{ - UID: "subfolder", - ParentUID: "parent", OrgID: orgID, - Title: "subfolder", + UID: "parent", SignedInUser: usr, }) require.NoError(t, err) - + _, err = dashStore.SaveDashboard(context.Background(), dashboards.SaveDashboardCommand{ + OrgID: orgID, + Dashboard: simplejson.NewFromAny(map[string]any{ + "title": "parent", + "uid": parent.UID, + }), + IsFolder: true, + }) + require.NoError(t, err) + subfolder, err := fStore.Create(context.Background(), folder.CreateFolderCommand{ + Title: "subfolder", + OrgID: orgID, + UID: "subfolder", + ParentUID: parent.UID, + SignedInUser: usr, + }) + require.NoError(t, err) + _, err = dashStore.SaveDashboard(context.Background(), dashboards.SaveDashboardCommand{ + OrgID: orgID, + FolderUID: parent.UID, + Dashboard: simplejson.NewFromAny(map[string]any{ + "title": "subfolder", + "uid": subfolder.UID, + }), + IsFolder: true, + }) + require.NoError(t, err) // create a root level dashboard _, err = dashStore.SaveDashboard(context.Background(), dashboards.SaveDashboardCommand{ OrgID: orgID, 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/stats/statsimpl/stats.go b/pkg/services/stats/statsimpl/stats.go index f4e2b0db479..6399fc8b771 100644 --- a/pkg/services/stats/statsimpl/stats.go +++ b/pkg/services/stats/statsimpl/stats.go @@ -158,11 +158,6 @@ func (ss *sqlStatsService) GetSystemStats(ctx context.Context, query *stats.GetS if ss.IsUnifiedAlertingEnabled() { sb.Write(`(SELECT COUNT(DISTINCT (` + dialect.Quote("rule_group") + `)) FROM ` + dialect.Quote("alert_rule") + `) AS rule_groups,`) } - // currently not supported when dashboards are in unified storage - if !ss.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - sb.Write(`(SELECT SUM(LENGTH(data)) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS dashboard_bytes_total,`, dialect.BooleanValue(false)) - sb.Write(`(SELECT MAX(LENGTH(data)) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS dashboard_bytes_max,`, dialect.BooleanValue(false)) - } sb.Write(ss.roleCounterSQL(ctx)) diff --git a/pkg/setting/setting_secrets_manager.go b/pkg/setting/setting_secrets_manager.go index b855307635f..043706dd182 100644 --- a/pkg/setting/setting_secrets_manager.go +++ b/pkg/setting/setting_secrets_manager.go @@ -15,12 +15,26 @@ type SecretsManagerSettings struct { // ConfiguredKMSProviders is a map of KMS providers found in the config file. The keys are in the format of ., and the values are a map of the properties in that section // In OSS, the provider type can only be "secret_key". In Enterprise, it can additionally be one of: "aws_kms", "azure_keyvault", "google_kms", "hashicorp_vault" ConfiguredKMSProviders map[string]map[string]string + + DecryptServerType string // "local" or "grpc" + DecryptServerUseTLS bool // Applicable for decrypt_server_type=grpc. Whether to use TLS for the decrypt server + DecryptServerTLSSkipVerify bool // Applicable for decrypt_server_type=grpc. Whether to skip TLS verification for the decrypt server + DecryptServerTLSServerName string // Applicable for decrypt_server_type=grpc. Server name to use for TLS verification + DecryptServerAddress string // Applicable for decrypt_server_type=grpc. Address for external secrets server + DecryptGrafanaServiceName string // Service name to use for background grafana decryption } func (cfg *Cfg) readSecretsManagerSettings() { secretsMgmt := cfg.Raw.Section("secrets_manager") cfg.SecretsManagement.CurrentEncryptionProvider = secretsMgmt.Key("encryption_provider").MustString(MisconfiguredProvider) + cfg.SecretsManagement.DecryptServerType = valueAsString(secretsMgmt, "decrypt_server_type", "local") + cfg.SecretsManagement.DecryptServerUseTLS = secretsMgmt.Key("decrypt_server_use_tls").MustBool(false) + cfg.SecretsManagement.DecryptServerTLSSkipVerify = secretsMgmt.Key("decrypt_server_tls_skip_verify").MustBool(false) + cfg.SecretsManagement.DecryptServerTLSServerName = valueAsString(secretsMgmt, "decrypt_server_tls_server_name", "") + cfg.SecretsManagement.DecryptServerAddress = valueAsString(secretsMgmt, "decrypt_server_address", "") + cfg.SecretsManagement.DecryptGrafanaServiceName = valueAsString(secretsMgmt, "decrypt_grafana_service_name", "") + // Extract available KMS providers from configuration sections providers := make(map[string]map[string]string) for _, section := range cfg.Raw.Sections() { 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.go b/pkg/storage/secret/metadata/decrypt_store.go index c1b402b2224..de98a817a86 100644 --- a/pkg/storage/secret/metadata/decrypt_store.go +++ b/pkg/storage/secret/metadata/decrypt_store.go @@ -7,12 +7,13 @@ import ( "time" claims "github.com/grafana/authlib/types" + "github.com/grafana/grafana-app-sdk/logging" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" + "google.golang.org/grpc/metadata" - "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" @@ -65,14 +66,33 @@ func (s *decryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, defer func() { span.SetAttributes(attribute.String("decrypter.identity", decrypterIdentity)) + args := []any{ + "namespace", namespace.String(), + "secret_name", name, + "decrypter_identity", decrypterIdentity, + } + + // The service identity used for decryption is always what is from the signed token, but if the request is + // coming from grafana, the service identity will be grafana, but the request metadata will contain + // additional service identity information (such as coming from the provisioning service in grafana). + // we do this for auditing purposes. + if md, ok := metadata.FromIncomingContext(ctx); ok { + if svcIdentities := md.Get(contracts.HeaderGrafanaServiceIdentityName); len(svcIdentities) > 0 { + args = append(args, "grafana_decrypter_identity", svcIdentities[0]) + span.SetAttributes(attribute.String("grafana_decrypter.identity", svcIdentities[0])) + } + } + if decryptErr == nil { - logging.FromContext(ctx).Info("Audit log:", "operation", "decrypt_secret_success", "namespace", namespace, "secret_name", name, "decrypter_identity", decrypterIdentity) + args = append(args, "operation", "decrypt_secret_success") } else { span.SetStatus(codes.Error, "Decrypt failed") span.RecordError(decryptErr) - - logging.FromContext(ctx).Info("Audit log:", "operation", "decrypt_secret_error", "namespace", namespace, "secret_name", name, "decrypter_identity", decrypterIdentity, "error", decryptErr) + args = append(args, "operation", "decrypt_secret_error", "error", decryptErr.Error()) } + + logging.FromContext(ctx).Info("Secrets Audit Log", args...) + success := decryptErr == nil s.metrics.DecryptDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) s.metrics.DecryptRequestCount.WithLabelValues(strconv.FormatBool(success)).Inc() diff --git a/pkg/storage/secret/metadata/decrypt_store_test.go b/pkg/storage/secret/metadata/decrypt_store_test.go index 08e22e6bc6e..3c35a443794 100644 --- a/pkg/storage/secret/metadata/decrypt_store_test.go +++ b/pkg/storage/secret/metadata/decrypt_store_test.go @@ -6,7 +6,9 @@ import ( "github.com/grafana/authlib/authn" "github.com/grafana/authlib/types" + "github.com/grafana/grafana-app-sdk/logging" "github.com/stretchr/testify/require" + grpcmetadata "google.golang.org/grpc/metadata" "k8s.io/utils/ptr" secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" @@ -248,10 +250,72 @@ func TestIntegrationDecrypt(t *testing.T) { require.Empty(t, exposed) }) - // TODO: add more tests for keeper failure scenarios, lets see how the async work will change this though. + t.Run("happy path with grpc metadata in request, also record the metadata as part of the service identity", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + tokenSvcIdentity := "svc" + stSvcIdentity := "st-svc" + + // Create auth context with proper permissions that match the decrypters + authCtx := createAuthContext(ctx, "default", []string{"secret.grafana.app/securevalues:decrypt"}, tokenSvcIdentity, types.TypeUser) + + // Needs to be incoming because we are pretending we received the metadata from a gRPC request + ctx = grpcmetadata.NewIncomingContext(authCtx, grpcmetadata.New(map[string]string{ + contracts.HeaderGrafanaServiceIdentityName: stSvcIdentity, + })) + + // Setup service + sut := testutils.Setup(t) + + // Create a secure value + spec := secretv1beta1.SecureValueSpec{ + Description: "description", + Decrypters: []string{tokenSvcIdentity}, + Value: ptr.To(secretv1beta1.NewExposedSecureValue("value")), + } + sv := &secretv1beta1.SecureValue{Spec: spec} + sv.Name = "sv-test" + sv.Namespace = "default" + + _, err := sut.CreateSv(ctx, testutils.CreateSvWithSv(sv)) + require.NoError(t, err) + + fakeLogger := &mockLogger{} + + loggerCtx := logging.Context(ctx, fakeLogger) + + exposed, err := sut.DecryptStorage.Decrypt(loggerCtx, "default", "sv-test") + require.NoError(t, err) + require.NotEmpty(t, exposed) + require.Equal(t, "value", exposed.DangerouslyExposeAndConsumeValue()) + + 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 { + if arg == "grafana_decrypter_identity" { + require.Equal(t, stSvcIdentity, args[i+1].(string)) + } + + if arg == "decrypter_identity" { + require.Equal(t, tokenSvcIdentity, args[i+1].(string)) + } + } + }) } func createAuthContext(ctx context.Context, namespace string, permissions []string, svc string, identityType types.IdentityType) context.Context { + ctx = logging.Context(ctx, logging.DefaultLogger) + requester := &identity.StaticRequester{ Type: identityType, Namespace: namespace, @@ -269,3 +333,18 @@ func createAuthContext(ctx context.Context, namespace string, permissions []stri return types.WithAuthInfo(ctx, requester) } + +type mockLogger struct { + logging.Logger + InfoMsgs []string + InfoArgs [][]any +} + +func (m *mockLogger) Info(msg string, args ...any) { + m.InfoMsgs = append(m.InfoMsgs, msg) + m.InfoArgs = append(m.InfoArgs, args) +} + +func (m *mockLogger) WithContext(ctx context.Context) logging.Logger { + return m +} 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/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 11344724271..dd53fa622b0 100644 --- a/pkg/storage/unified/README.md +++ b/pkg/storage/unified/README.md @@ -62,7 +62,6 @@ protocol = https [feature_toggles] grafanaAPIServerWithExperimentalAPIs = true -kubernetesClientDashboardsFolders = true [unified_storage.folders.folder.grafana.app] dualWriterMode = 4 @@ -259,7 +258,6 @@ To enable it, add the following to your `custom.ini` under the `[feature_toggles [feature_toggles] ; Used by the Grafana instance unifiedStorageSearchUI = true -kubernetesClientDashboardsFolders = true ; Used by unified storage unifiedStorageSearch = true @@ -369,7 +367,6 @@ signing_keys_url = http://localhost:3011/api/signing-keys/keys mode = "on-prem" [feature_toggles] -kubernetesClientDashboardsFolders = true kubernetesDashboardsAPI = true kubernetesFolders = true unifiedStorage = true @@ -418,7 +415,6 @@ http_port = 3011 http_addr = "127.0.0.2" [feature_toggles] -kubernetesClientDashboardsFolders = true kubernetesDashboardsAPI = true kubernetesFolders = true unifiedStorageSearchUI = true @@ -781,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 96a44f6d3c3..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?)") } @@ -647,7 +643,11 @@ func (s *searchSupport) getOrCreateIndex(ctx context.Context, key NamespacedReso return idx, nil } - idxInt, err, _ := s.buildIndex.Do(key.String(), func() (interface{}, error) { + 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) } @@ -681,16 +681,30 @@ func (s *searchSupport) getOrCreateIndex(ctx context.Context, key NamespacedReso } return idx, nil }) - if err != nil { - return nil, err + + select { + case res := <-ch: + if res.Err != nil { + return nil, res.Err + } + return res.Val.(ResourceIndex), nil + case <-ctx.Done(): + return nil, fmt.Errorf("failed to get index: %w", ctx.Err()) } - return idxInt.(ResourceIndex), nil } -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) @@ -699,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{ @@ -728,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 } @@ -743,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 { @@ -756,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 { @@ -793,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 ab8cee98432..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") }() } @@ -332,3 +332,96 @@ func TestSearchGetOrCreateIndex(t *testing.T) { require.Equal(t, int64(50), search.buildIndexCalls[0].size) require.Equal(t, int64(11111111), search.buildIndexCalls[0].resourceVersion) } + +func TestSearchGetOrCreateIndexWithCancellation(t *testing.T) { + // Setup mock implementations + storage := &mockStorageBackend{ + resourceStats: []ResourceStats{ + {NamespacedResource: NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"}, Count: 50, ResourceVersion: 11111111}, + }, + } + search := &slowSearchBackendWithCache{ + mockSearchBackend: mockSearchBackend{}, + } + supplier := &TestDocumentBuilderSupplier{ + GroupsResources: map[string]string{ + "group": "resource", + }, + } + + // Create search support with the specified initMaxSize + opts := SearchOptions{ + Backend: search, + Resources: supplier, + WorkerThreads: 1, + InitMinCount: 1, // set min count to default for this test + InitMaxCount: 0, + } + + support, err := newSearchSupport(opts, storage, nil, nil, noop.NewTracerProvider().Tracer("test"), nil, nil, nil) + 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, key, "test") + // Make sure we get context deadline error + require.ErrorIs(t, err, context.DeadlineExceeded) + + // Wait until indexing is finished. + 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 slowSearchBackendWithCache struct { + mockSearchBackend + wg sync.WaitGroup + + mu sync.Mutex + cache map[NamespacedResource]ResourceIndex +} + +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) + + // 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 6956af234c7..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,27 +198,62 @@ 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() - var index bleve.Index - fileIndexName := "" // Name of the file-based index, or empty for in-memory indexes. + 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), + ) - build := true mapper, err := GetBleveMappings(fields) if err != nil { return nil, err } - cachedIndex := b.getCachedIndex(key) + // Prepare fields before opening/creating indexes, so that we don't need to deal with closing them in case of errors. + standardSearchFields := resource.StandardSearchFields() + allFields, err := getAllFields(standardSearchFields, fields) + if err != nil { + 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 + // This function is added via defer after new index has been created/opened, to make sure we close it properly when needed. + // Whether index needs closing or not is controlled by closeIndex. + closeIndexOnExit := func(index bleve.Index, indexDir string) { + if !closeIndex { + return + } + + if closeErr := index.Close(); closeErr != nil { + logWithDetails.Error("Failed to close index after index build failure", "err", closeErr) + } + if indexDir != "" { + if removeErr := os.RemoveAll(indexDir); removeErr != nil { + logWithDetails.Error("Failed to remove index directory after index build failure", "err", removeErr) + } + } + } resourceDir := filepath.Join(b.opts.Root, cleanFileSegment(key.Namespace), cleanFileSegment(fmt.Sprintf("%s.%s", key.Resource, key.Group))) + var index bleve.Index + cachedIndex := b.getCachedIndex(key) + fileIndexName := "" // Name of the file-based index, or empty for in-memory indexes. newIndexType := indexStorageMemory + build := true + if size > b.opts.FileThreshold { newIndexType = indexStorageFile @@ -231,6 +267,7 @@ func (b *bleveBackend) BuildIndex( if index != nil { build = false logWithDetails.Debug("Existing index found on filesystem", "directory", filepath.Join(resourceDir, fileIndexName)) + defer closeIndexOnExit(index, "") // Close index, but don't delete directory. } else { // Building index from scratch. Index name has a time component in it to be unique, but if // we happen to create non-unique name, we bump the time and try again. @@ -256,6 +293,7 @@ func (b *bleveBackend) BuildIndex( } logWithDetails.Info("Building index using filesystem", "directory", indexDir) + defer closeIndexOnExit(index, indexDir) // Close index, and delete new index directory. } } else { index, err = bleve.NewMemOnly(mapper) @@ -263,6 +301,7 @@ func (b *bleveBackend) BuildIndex( return nil, fmt.Errorf("error creating new in-memory bleve index: %w", err) } logWithDetails.Info("Building index using memory") + defer closeIndexOnExit(index, "") // Close index, don't cleanup directory. } // Batch all the changes @@ -271,24 +310,36 @@ func (b *bleveBackend) BuildIndex( index: index, indexStorage: newIndexType, fields: fields, - standard: resource.StandardSearchFields(), + allFields: allFields, + standard: standardSearchFields, features: b.features, tracing: b.tracer, } - idx.allFields, err = getAllFields(idx.standard, fields) - if err != nil { - return nil, err - } - if build { + if b.indexMetrics != nil { + b.indexMetrics.IndexBuilds.WithLabelValues(indexBuildReason).Inc() + } + start := time.Now() _, err = builder(idx) if err != nil { - return nil, err + 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. @@ -303,6 +354,9 @@ func (b *bleveBackend) BuildIndex( logWithDetails.Info("Storing index in cache", "key", key, "expiration", idx.expiration) } + // We're storing index in the cache, so we can't close it. + closeIndex = false + b.cacheMx.Lock() prev := b.cache[key] b.cache[key] = idx 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 b33ef468e32..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. @@ -1026,3 +1026,36 @@ func TestCleanOldIndexes(t *testing.T) { require.Len(t, files, 0) }) } + +func TestBleveIndexWithFailures(t *testing.T) { + t.Run("in-memory index", func(t *testing.T) { + testBleveIndexWithFailures(t, false) + }) + t.Run("file-based index", func(t *testing.T) { + testBleveIndexWithFailures(t, true) + }) +} + +func testBleveIndexWithFailures(t *testing.T, fileBased bool) { + backend, _ := setupBleveBackend(t, 5, time.Nanosecond, "") + + ns := resource.NamespacedResource{ + Namespace: "test", + Group: "group", + Resource: "resource", + } + + size := int64(1) + if fileBased { + // size=100 is above FileThreshold (5), make it a file-based index. + size = 100 + } + _, 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, "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/alerting/api_namespace_test.go b/pkg/tests/api/alerting/api_namespace_test.go new file mode 100644 index 00000000000..5ef2508a36b --- /dev/null +++ b/pkg/tests/api/alerting/api_namespace_test.go @@ -0,0 +1,478 @@ +package alerting + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + + "bytes" + + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotaimpl" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testinfra" +) + +func TestIntegration_NamespacingForRules(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, p) + store, cfg := env.SQLStore, env.Cfg + orgID := int64(1) + createUser(t, store, cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleEditor), + OrgID: orgID, + Password: "editor", + Login: "editor", + }) + createUser(t, store, cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleViewer), + OrgID: orgID, + Password: "viewer", + Login: "viewer", + }) + + adminClient := newAlertingApiClient(grafanaListedAddr, "admin", "admin") + editorClient := newAlertingApiClient(grafanaListedAddr, "editor", "editor") + viewerClient := newAlertingApiClient(grafanaListedAddr, "viewer", "viewer") + + // create test folders, with a rule in each folder + folder1UID := "test-folder-1" + folder2UID := "test-folder-2" + folder3UID := "test-folder-3" + adminClient.CreateFolder(t, folder1UID, "Test Folder 1") + adminClient.CreateFolder(t, folder2UID, "Test Folder 2") + adminClient.CreateFolder(t, folder3UID, "Test Folder 3") + rule1 := createTestAlertRule("Test Rule 1", folder1UID) + rule2 := createTestAlertRule("Test Rule 2", folder2UID) + rule3 := createTestAlertRule("Test Rule 3", folder3UID) + group1 := apimodels.PostableRuleGroupConfig{ + Name: "test-group-1", + Interval: 60, + Rules: []apimodels.PostableExtendedRuleNode{rule1}, + } + group2 := apimodels.PostableRuleGroupConfig{ + Name: "test-group-2", + Interval: 60, + Rules: []apimodels.PostableExtendedRuleNode{rule2}, + } + group3 := apimodels.PostableRuleGroupConfig{ + Name: "test-group-3", + Interval: 60, + Rules: []apimodels.PostableExtendedRuleNode{rule3}, + } + adminClient.PostRulesGroup(t, folder1UID, &group1, false) + adminClient.PostRulesGroup(t, folder2UID, &group2, false) + adminClient.PostRulesGroup(t, folder3UID, &group3, false) + + t.Run("admin, editor, and viewer should be able to see all rules in a given folder", func(t *testing.T) { + // admin + rules, status, _ := adminClient.GetAllRulesGroupInFolderWithStatus(t, folder1UID) + require.Equal(t, http.StatusAccepted, status) + require.Len(t, rules, 1) + + // editor + rules, status, _ = editorClient.GetAllRulesGroupInFolderWithStatus(t, folder1UID) + require.Equal(t, http.StatusAccepted, status) + require.Len(t, rules, 1) + + // viewer + rules, status, _ = viewerClient.GetAllRulesGroupInFolderWithStatus(t, folder1UID) + require.Equal(t, http.StatusAccepted, status) + require.Len(t, rules, 1) + }) + + t.Run("admin should be able to access restricted folder, but no one else can", func(t *testing.T) { + restrictedFolderUID := "restricted-folder" + adminClient.CreateFolder(t, restrictedFolderUID, "Restricted Folder") + restrictedRule := createTestAlertRule("Restricted Rule", restrictedFolderUID) + restrictedGroup := apimodels.PostableRuleGroupConfig{ + Name: "restricted-group", + Interval: 60, + Rules: []apimodels.PostableExtendedRuleNode{restrictedRule}, + } + adminClient.PostRulesGroup(t, restrictedFolderUID, &restrictedGroup, false) + setFolderPermissions(t, grafanaListedAddr, restrictedFolderUID, []map[string]interface{}{ + { + "userId": 1, + "permission": 4, + }, + }) + + // admin ok + _, status, _ := adminClient.GetAllRulesGroupInFolderWithStatus(t, restrictedFolderUID) + require.Equal(t, http.StatusAccepted, status) + + // editor and viewer forbidden + _, status, _ = editorClient.GetAllRulesGroupInFolderWithStatus(t, restrictedFolderUID) + require.Equal(t, http.StatusForbidden, status) + + _, status, _ = viewerClient.GetAllRulesGroupInFolderWithStatus(t, restrictedFolderUID) + require.Equal(t, http.StatusForbidden, status) + }) + + t.Run("errors when a folder does not exist", func(t *testing.T) { + _, status, _ := adminClient.GetAllRulesGroupInFolderWithStatus(t, "non-existent-folder") + // even if a folder does not exist, it will return a forbidden error (so users cannot enumerate folders) + require.Equal(t, http.StatusForbidden, status) + }) + + t.Run("permissions are respected for nested folders", func(t *testing.T) { + parentFolderUID := "parent-folder" + childFolderUID := "child-folder" + adminClient.CreateFolder(t, parentFolderUID, "Parent Folder") + adminClient.CreateFolder(t, childFolderUID, "Child Folder", parentFolderUID) + parentRule := createTestAlertRule("Parent Rule", parentFolderUID) + parentGroup := apimodels.PostableRuleGroupConfig{ + Name: "parent-group", + Interval: 60, + Rules: []apimodels.PostableExtendedRuleNode{parentRule}, + } + adminClient.PostRulesGroup(t, parentFolderUID, &parentGroup, false) + childRule := createTestAlertRule("Child Rule", childFolderUID) + childGroup := apimodels.PostableRuleGroupConfig{ + Name: "child-group", + Interval: 60, + Rules: []apimodels.PostableExtendedRuleNode{childRule}, + } + adminClient.PostRulesGroup(t, childFolderUID, &childGroup, false) + + // allow admin to access parent folder + setFolderPermissions(t, grafanaListedAddr, parentFolderUID, []map[string]interface{}{ + { + "userId": 1, + "permission": 4, + }, + }) + + // admin can get both folders + allRules, status, _ := adminClient.GetAllRulesWithStatus(t) + require.Equal(t, http.StatusOK, status) + require.Contains(t, allRules, "Parent Folder") + require.Contains(t, allRules, "Parent Folder/Child Folder") + + // editor cannot access either + allRules, status, _ = editorClient.GetAllRulesWithStatus(t) + require.Equal(t, http.StatusOK, status) + require.NotContains(t, allRules, "Parent Folder") + require.NotContains(t, allRules, "Parent Folder/Child Folder") + + // viewer cannot access either folder + allRules, status, _ = viewerClient.GetAllRulesWithStatus(t) + require.Equal(t, http.StatusOK, status) + require.NotContains(t, allRules, "Parent Folder") + require.NotContains(t, allRules, "Parent Folder/Child Folder") + }) + + t.Run("org separation", func(t *testing.T) { + orgService, err := orgimpl.ProvideService(store, cfg, quotaimpl.ProvideService(store, cfg)) + require.NoError(t, err) + newOrg, err := orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "Test Org 2"}) + require.NoError(t, err) + createUser(t, store, cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + OrgID: newOrg.ID, + Password: "other-admin", + Login: "other-admin-folder-perms", + }) + otherOrgFolderUID := "other-org-folder" + otherOrgClient := newAlertingApiClient(grafanaListedAddr, "other-admin-folder-perms", "other-admin") + otherOrgClient.CreateFolder(t, otherOrgFolderUID, "Other Org Folder") + otherOrgRule := createTestAlertRule("Other Org Rule", otherOrgFolderUID) + otherOrgGroup := apimodels.PostableRuleGroupConfig{ + Name: "other-org-group", + Interval: 60, + Rules: []apimodels.PostableExtendedRuleNode{otherOrgRule}, + } + otherOrgClient.PostRulesGroup(t, otherOrgFolderUID, &otherOrgGroup, false) + + // admin from org 1 cannot access org 2 alert rules + allRules, status, _ := adminClient.GetAllRulesWithStatus(t) + require.Equal(t, http.StatusOK, status) + require.NotContains(t, allRules, otherOrgFolderUID) + + // admin from org 2 cannot access org 1 alert rules + allRules, status, _ = otherOrgClient.GetAllRulesWithStatus(t) + require.Equal(t, http.StatusOK, status) + require.NotContains(t, allRules, folder1UID) + require.NotContains(t, allRules, folder2UID) + require.NotContains(t, allRules, folder3UID) + }) +} + +func TestIntegration_NamespacingForPrometheusRules(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + }) + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, p) + store, cfg := env.SQLStore, env.Cfg + orgID := int64(1) + createUser(t, store, cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleEditor), + OrgID: orgID, + Password: "editor", + Login: "editor", + }) + createUser(t, store, cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleViewer), + OrgID: orgID, + Password: "viewer", + Login: "viewer", + }) + adminClient := newAlertingApiClient(grafanaListedAddr, "admin", "admin") + editorClient := newAlertingApiClient(grafanaListedAddr, "editor", "editor") + viewerClient := newAlertingApiClient(grafanaListedAddr, "viewer", "viewer") + + // create prometheus rules in 3 separate folders + ds := adminClient.CreateDatasource(t, "prometheus") + dsUID := ds.Body.Datasource.UID + folder1UID := "prometheus-folder-1" + folder2UID := "prometheus-folder-2" + folder3UID := "prometheus-folder-3" + adminClient.CreateFolder(t, folder1UID, "Prometheus Folder 1") + adminClient.CreateFolder(t, folder2UID, "Prometheus Folder 2") + adminClient.CreateFolder(t, folder3UID, "Prometheus Folder 3") + duration1m := model.Duration(1 * 60 * 1000) + prometheusRules1 := map[string][]apimodels.PrometheusRuleGroup{ + "Prometheus Folder 1": { + { + Name: "test-group-1", + Rules: []apimodels.PrometheusRule{ + { + Alert: "HighCPUUsage", + Expr: "cpu_usage > 80", + For: &duration1m, + Labels: map[string]string{ + "severity": "warning", + }, + Annotations: map[string]string{ + "summary": "High CPU usage detected", + }, + }, + }, + }, + }, + } + prometheusRules2 := map[string][]apimodels.PrometheusRuleGroup{ + "Prometheus Folder 2": { + { + Name: "test-group-2", + Rules: []apimodels.PrometheusRule{ + { + Alert: "HighMemoryUsage", + Expr: "memory_usage > 90", + For: &duration1m, + Labels: map[string]string{ + "severity": "critical", + }, + Annotations: map[string]string{ + "summary": "High memory usage detected", + }, + }, + }, + }, + }, + } + prometheusRules3 := map[string][]apimodels.PrometheusRuleGroup{ + "Prometheus Folder 3": { + { + Name: "test-group-3", + Rules: []apimodels.PrometheusRule{ + { + Alert: "DiskSpaceLow", + Expr: "disk_usage > 95", + For: &duration1m, + Labels: map[string]string{ + "severity": "warning", + }, + Annotations: map[string]string{ + "summary": "Disk space is running low", + }, + }, + }, + }, + }, + } + // then import them + headers := map[string]string{ + "Content-Type": "application/json", + "X-Datasource-UID": dsUID, + } + adminClient.ConvertPrometheusPostRuleGroups(t, dsUID, prometheusRules1, headers) + adminClient.ConvertPrometheusPostRuleGroups(t, dsUID, prometheusRules2, headers) + adminClient.ConvertPrometheusPostRuleGroups(t, dsUID, prometheusRules3, headers) + + t.Run("admin, editor, and viewer should be able to get all Prometheus rules", func(t *testing.T) { + // admin + rules := adminClient.ConvertPrometheusGetAllRules(t, headers) + require.Len(t, rules, 3) + require.Contains(t, rules, "Prometheus Folder 1") + require.Contains(t, rules, "Prometheus Folder 2") + require.Contains(t, rules, "Prometheus Folder 3") + + // editor + rules = editorClient.ConvertPrometheusGetAllRules(t, headers) + require.Len(t, rules, 3) + require.Contains(t, rules, "Prometheus Folder 1") + require.Contains(t, rules, "Prometheus Folder 2") + require.Contains(t, rules, "Prometheus Folder 3") + + // viewer + rules = viewerClient.ConvertPrometheusGetAllRules(t, headers) + require.Len(t, rules, 3) + require.Contains(t, rules, "Prometheus Folder 1") + require.Contains(t, rules, "Prometheus Folder 2") + require.Contains(t, rules, "Prometheus Folder 3") + }) + + t.Run("only admin can view restricted folder", func(t *testing.T) { + restrictedFolderUID := "restricted-prometheus-folder" + adminClient.CreateFolder(t, restrictedFolderUID, "Restricted Prometheus Folder") + restrictedPrometheusRules := map[string][]apimodels.PrometheusRuleGroup{ + "Restricted Prometheus Folder": { + { + Name: "restricted-group", + Rules: []apimodels.PrometheusRule{ + { + Alert: "RestrictedAlert", + Expr: "restricted_metric > 100", + For: &duration1m, + Labels: map[string]string{ + "severity": "critical", + }, + }, + }, + }, + }, + } + adminClient.ConvertPrometheusPostRuleGroups(t, dsUID, restrictedPrometheusRules, headers) + setFolderPermissions(t, grafanaListedAddr, restrictedFolderUID, []map[string]interface{}{ + { + "userId": 1, + "permission": 4, + }, + }) + + // admin can see the restricted folder + rules := adminClient.ConvertPrometheusGetAllRules(t, headers) + require.Contains(t, rules, "Restricted Prometheus Folder") + + // editor and viewer cannot + rules = editorClient.ConvertPrometheusGetAllRules(t, headers) + require.NotContains(t, rules, "Restricted Prometheus Folder") + rules = viewerClient.ConvertPrometheusGetAllRules(t, headers) + require.NotContains(t, rules, "Restricted Prometheus Folder") + }) + + t.Run("should maintain org separation for Prometheus rules", func(t *testing.T) { + orgService, err := orgimpl.ProvideService(store, cfg, quotaimpl.ProvideService(store, cfg)) + require.NoError(t, err) + newOrg, err := orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "Prometheus Test Org 2"}) + require.NoError(t, err) + createUser(t, store, cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + OrgID: newOrg.ID, + Password: "other-prometheus-admin", + Login: "other-prometheus-admin", + }) + otherOrgClient := newAlertingApiClient(grafanaListedAddr, "other-prometheus-admin", "other-prometheus-admin") + otherOrgDs := otherOrgClient.CreateDatasource(t, "prometheus") + otherOrgDsUID := otherOrgDs.Body.Datasource.UID + otherOrgFolderUID := "other-org-prometheus-folder" + otherOrgClient.CreateFolder(t, otherOrgFolderUID, "Other Org Prometheus Folder") + otherOrgPrometheusRules := map[string][]apimodels.PrometheusRuleGroup{ + "Other Org Prometheus Folder": { + { + Name: "other-org-group", + Rules: []apimodels.PrometheusRule{ + { + Alert: "OtherOrgAlert", + Expr: "other_org_metric > 75", + For: &duration1m, + }, + }, + }, + }, + } + otherOrgHeaders := map[string]string{ + "Content-Type": "application/json", + "X-Datasource-UID": otherOrgDsUID, + } + otherOrgClient.ConvertPrometheusPostRuleGroups(t, otherOrgDsUID, otherOrgPrometheusRules, otherOrgHeaders) + + // admin from org 1 cannot see org 2 rules + rules := adminClient.ConvertPrometheusGetAllRules(t, headers) + require.NotContains(t, rules, "Other Org Prometheus Folder") + + // admin from org 2 cannot see org 1 rules + rules = otherOrgClient.ConvertPrometheusGetAllRules(t, otherOrgHeaders) + require.NotContains(t, rules, "Prometheus Folder 1") + require.NotContains(t, rules, "Prometheus Folder 2") + require.NotContains(t, rules, "Prometheus Folder 3") + require.Contains(t, rules, "Other Org Prometheus Folder") + }) +} + +func createTestAlertRule(title, folderUID string) apimodels.PostableExtendedRuleNode { + return apimodels.PostableExtendedRuleNode{ + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: title, + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: 600, + To: 0, + }, + DatasourceUID: "-100", + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + NoDataState: "NoData", + ExecErrState: "Error", + }, + } +} + +func setFolderPermissions(t *testing.T, grafanaListedAddr string, folderUID string, permissions []map[string]interface{}) { + t.Helper() + + permissionPayload := map[string]interface{}{ + "items": permissions, + } + + payloadBytes, err := json.Marshal(permissionPayload) + require.NoError(t, err) + + u := fmt.Sprintf("http://admin:admin@%s/api/folders/%s/permissions", grafanaListedAddr, folderUID) + resp, err := http.Post(u, "application/json", bytes.NewBuffer(payloadBytes)) // nolint:gosec + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) +} diff --git a/pkg/tests/api/alerting/api_provisioning_test.go b/pkg/tests/api/alerting/api_provisioning_test.go index 08b4e50e204..54b3e71b61e 100644 --- a/pkg/tests/api/alerting/api_provisioning_test.go +++ b/pkg/tests/api/alerting/api_provisioning_test.go @@ -1184,3 +1184,128 @@ func TestIntegrationExportFileProvisionContactPoints(t *testing.T) { }) }) } + +func TestIntegrationFullpath(t *testing.T) { + dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, p) + + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleEditor), + Password: "password", + Login: "grafana", + }) + + apiClient := newAlertingApiClient(grafanaListedAddr, "grafana", "password") + + namespaceUID := "my-namespace" + namespaceTitle := namespaceUID + apiClient.CreateFolder(t, namespaceUID, namespaceTitle) + + t.Run("for a rule under a root folder should set the right fullpath", func(t *testing.T) { + interval, err := model.ParseDuration("1m") + require.NoError(t, err) + doubleInterval := 2 * interval + rules := definitions.PostableRuleGroupConfig{ + Name: "group", + Interval: interval, + Rules: []definitions.PostableExtendedRuleNode{ + { + ApiRuleNode: &definitions.ApiRuleNode{ + For: &doubleInterval, + Labels: map[string]string{"label1": "val1"}, + Annotations: map[string]string{"annotation1": "val1"}, + }, + GrafanaManagedAlert: &definitions.PostableGrafanaRule{ + Title: "rule", + Condition: "A", + Data: []definitions.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: definitions.RelativeTimeRange{ + From: definitions.Duration(time.Duration(5) * time.Hour), + To: definitions.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + }, + }, + } + + resp, status, _ := apiClient.PostRulesGroupWithStatus(t, namespaceUID, &rules, false) + require.Equal(t, http.StatusAccepted, status) + require.Len(t, resp.Created, 1) + ruleUID := resp.Created[0] + + status, response := apiClient.GetProvisioningAlertRuleExport(t, ruleUID, &definitions.ExportQueryParams{Format: "json"}) + require.Equal(t, http.StatusOK, status) + var export definitions.AlertingFileExport + require.NoError(t, json.Unmarshal([]byte(response), &export)) + require.Len(t, export.Groups, 1) + assert.Equal(t, "", export.Groups[0].Folder) + }) + + t.Run("for a rule under a subfolder should set the right fullpath", func(t *testing.T) { + otherNamespaceUID := "my-other-namespace" + otherNamespaceTitle := "my-other-namespace containing multiple //" + apiClient.CreateFolder(t, otherNamespaceUID, otherNamespaceTitle, namespaceUID) + + interval, err := model.ParseDuration("1m") + require.NoError(t, err) + doubleInterval := 2 * interval + rules := definitions.PostableRuleGroupConfig{ + Name: "group-2", + Interval: interval, + Rules: []definitions.PostableExtendedRuleNode{ + { + ApiRuleNode: &definitions.ApiRuleNode{ + For: &doubleInterval, + Labels: map[string]string{"label1": "val1"}, + Annotations: map[string]string{"annotation1": "val1"}, + }, + GrafanaManagedAlert: &definitions.PostableGrafanaRule{ + Title: "rule-2", + Condition: "A", + Data: []definitions.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: definitions.RelativeTimeRange{ + From: definitions.Duration(time.Duration(5) * time.Hour), + To: definitions.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + }, + }, + } + + resp, status, _ := apiClient.PostRulesGroupWithStatus(t, otherNamespaceUID, &rules, false) + require.Equal(t, http.StatusAccepted, status) + require.Len(t, resp.Created, 1) + ruleUID := resp.Created[0] + + status, response := apiClient.GetProvisioningAlertRuleExport(t, ruleUID, &definitions.ExportQueryParams{Format: "json"}) + require.Equal(t, http.StatusOK, status) + var export definitions.AlertingFileExport + require.NoError(t, json.Unmarshal([]byte(response), &export)) + require.Len(t, export.Groups, 1) + assert.Equal(t, "my-namespace/my-other-namespace containing multiple //", export.Groups[0].Folder) + }) +} diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index 654369326c1..056f813cf1d 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -1473,3 +1473,34 @@ func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCom require.NoError(t, err) return u.ID } + +func (a apiClient) GetProvisioningAlertRuleExport(t *testing.T, ruleUID string, params *apimodels.ExportQueryParams) (int, string) { + t.Helper() + u, err := url.Parse(fmt.Sprintf("%s/api/v1/provisioning/alert-rules/%s/export", a.url, ruleUID)) + require.NoError(t, err) + if params != nil { + q := url.Values{} + if params.Format != "" { + q.Set("format", params.Format) + } + if params.Download { + q.Set("download", "true") + } + u.RawQuery = q.Encode() + } + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + require.NoError(t, err) + + client := &http.Client{} + resp, err := client.Do(req) + + require.NoError(t, err) + defer func() { + _ = resp.Body.Close() + }() + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(b) +} diff --git a/pkg/tests/api/dashboards/api_dashboards_test.go b/pkg/tests/api/dashboards/api_dashboards_test.go index 0d87e0c6f00..50355421314 100644 --- a/pkg/tests/api/dashboards/api_dashboards_test.go +++ b/pkg/tests/api/dashboards/api_dashboards_test.go @@ -19,7 +19,6 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/services/dashboardimport" "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/plugindashboards" @@ -41,8 +40,7 @@ func TestIntegrationDashboardServiceValidation(t *testing.T) { } dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ - DisableAnonymous: true, - EnableFeatureToggles: []string{featuremgmt.FlagKubernetesClientDashboardsFolders}, + DisableAnonymous: true, }) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) @@ -276,10 +274,9 @@ func TestIntegrationDashboardQuota(t *testing.T) { // Setup Grafana and its Database dashboardQuota := int64(1) dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ - DisableAnonymous: true, - EnableQuota: true, - DashboardOrgQuota: &dashboardQuota, - EnableFeatureToggles: []string{featuremgmt.FlagKubernetesClientDashboardsFolders}, + DisableAnonymous: true, + EnableQuota: true, + DashboardOrgQuota: &dashboardQuota, }) grafanaListedAddr, _ := testinfra.StartGrafanaEnv(t, dir, path) @@ -337,8 +334,7 @@ func TestIntegrationDashboardQuota(t *testing.T) { func TestIntegrationUpdatingProvisionionedDashboards(t *testing.T) { // Setup Grafana and its Database dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ - DisableAnonymous: true, - EnableFeatureToggles: []string{featuremgmt.FlagKubernetesClientDashboardsFolders}, + DisableAnonymous: true, }) provDashboardsDir := filepath.Join(dir, "conf", "provisioning", "dashboards") @@ -491,8 +487,7 @@ providers: func TestIntegrationCreate(t *testing.T) { // Setup Grafana and its Database dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ - DisableAnonymous: true, - EnableFeatureToggles: []string{featuremgmt.FlagKubernetesClientDashboardsFolders}, + DisableAnonymous: true, }) grafanaListedAddr, _ := testinfra.StartGrafanaEnv(t, dir, path) @@ -647,8 +642,7 @@ func intPtr(n int) *int { func TestIntegrationPreserveSchemaVersion(t *testing.T) { dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ - DisableAnonymous: true, - EnableFeatureToggles: []string{featuremgmt.FlagKubernetesClientDashboardsFolders}, + DisableAnonymous: true, }) grafanaListedAddr, _ := testinfra.StartGrafanaEnv(t, dir, path) @@ -738,8 +732,7 @@ func TestIntegrationPreserveSchemaVersion(t *testing.T) { func TestIntegrationImportDashboardWithLibraryPanels(t *testing.T) { dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ - DisableAnonymous: true, - EnableFeatureToggles: []string{featuremgmt.FlagKubernetesClientDashboardsFolders}, + DisableAnonymous: true, }) grafanaListedAddr, _ := testinfra.StartGrafanaEnv(t, dir, path) @@ -996,8 +989,7 @@ func TestIntegrationDashboardServicePermissions(t *testing.T) { } dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ - DisableAnonymous: true, - EnableFeatureToggles: []string{featuremgmt.FlagKubernetesClientDashboardsFolders}, + DisableAnonymous: true, }) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) tests.CreateUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ diff --git a/pkg/tests/api/folders/api_folder_test.go b/pkg/tests/api/folders/api_folder_test.go index 2de64723508..156097bcda8 100644 --- a/pkg/tests/api/folders/api_folder_test.go +++ b/pkg/tests/api/folders/api_folder_test.go @@ -46,7 +46,7 @@ func TestIntegrationFolderServiceGetFolder(t *testing.T) { EnableUnifiedAlerting: true, DisableAnonymous: true, AppModeProduction: true, - EnableFeatureToggles: []string{featuremgmt.FlagNestedFolders, featuremgmt.FlagKubernetesClientDashboardsFolders}, + EnableFeatureToggles: []string{featuremgmt.FlagNestedFolders}, }) grafanaListedAddr, _ := testinfra.StartGrafanaEnv(t, dir, p) @@ -114,9 +114,8 @@ func TestIntegrationUpdateFolder(t *testing.T) { } dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ - DisableAnonymous: true, - EnableQuota: true, - EnableFeatureToggles: []string{featuremgmt.FlagKubernetesClientDashboardsFolders}, + DisableAnonymous: true, + EnableQuota: true, }) grafanaListedAddr, _ := testinfra.StartGrafanaEnv(t, dir, path) @@ -157,9 +156,8 @@ func TestIntegrationUpdateFolder(t *testing.T) { func TestIntegrationCreateFolder(t *testing.T) { dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ - DisableAnonymous: true, - EnableQuota: true, - EnableFeatureToggles: []string{featuremgmt.FlagKubernetesClientDashboardsFolders}, + DisableAnonymous: true, + EnableQuota: true, }) grafanaListedAddr, _ := testinfra.StartGrafanaEnv(t, dir, path) @@ -206,9 +204,8 @@ func TestIntegrationNestedFoldersOn(t *testing.T) { t.Skip("skipping integration test") } dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ - DisableAnonymous: true, - EnableQuota: true, - EnableFeatureToggles: []string{featuremgmt.FlagKubernetesClientDashboardsFolders}, + DisableAnonymous: true, + EnableQuota: true, }) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) @@ -362,7 +359,7 @@ func TestIntegrationSharedWithMe(t *testing.T) { EnableUnifiedAlerting: true, DisableAnonymous: true, AppModeProduction: true, - EnableFeatureToggles: []string{featuremgmt.FlagNestedFolders, featuremgmt.FlagKubernetesClientDashboardsFolders}, + EnableFeatureToggles: []string{featuremgmt.FlagNestedFolders}, }) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, p) @@ -416,7 +413,6 @@ func TestIntegrationBasicRoles(t *testing.T) { EnableUnifiedAlerting: true, DisableAnonymous: true, AppModeProduction: true, - EnableFeatureToggles: []string{featuremgmt.FlagKubernetesClientDashboardsFolders}, }) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, p) @@ -554,7 +550,7 @@ func TestIntegrationFineGrainedPermissions(t *testing.T) { EnableUnifiedAlerting: true, DisableAnonymous: true, AppModeProduction: true, - EnableFeatureToggles: []string{featuremgmt.FlagNestedFolders, featuremgmt.FlagKubernetesClientDashboardsFolders}, + EnableFeatureToggles: []string{featuremgmt.FlagNestedFolders}, }) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, p) diff --git a/pkg/tests/api/folders/api_folders_test.go b/pkg/tests/api/folders/api_folders_test.go index 80e96e67edb..ddcf722cc49 100644 --- a/pkg/tests/api/folders/api_folders_test.go +++ b/pkg/tests/api/folders/api_folders_test.go @@ -35,7 +35,6 @@ func TestGetFolders(t *testing.T) { EnableUnifiedAlerting: true, DisableAnonymous: true, AppModeProduction: true, - EnableFeatureToggles: []string{featuremgmt.FlagKubernetesClientDashboardsFolders}, }) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, p) 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/dashboard/dashboards_test.go b/pkg/tests/apis/dashboard/dashboards_test.go index c20d235eea9..cd097a0503f 100644 --- a/pkg/tests/apis/dashboard/dashboards_test.go +++ b/pkg/tests/apis/dashboard/dashboards_test.go @@ -24,7 +24,7 @@ import ( dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" dashboardV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" - dashboardV2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" + dashboardV2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" ) func TestMain(m *testing.M) { @@ -191,10 +191,10 @@ func TestIntegrationDashboardsAppV2alpha1(t *testing.T) { } } -func TestIntegrationDashboardsAppV2alpha2(t *testing.T) { +func TestIntegrationDashboardsAppV2beta1(t *testing.T) { gvr := schema.GroupVersionResource{ - Group: dashboardV2alpha2.GROUP, - Version: dashboardV2alpha2.VERSION, + Group: dashboardV2beta1.GROUP, + Version: dashboardV2beta1.VERSION, Resource: "dashboards", } if testing.Short() { @@ -222,12 +222,7 @@ func TestIntegrationLegacySupport(t *testing.T) { t.Skip("skipping integration test in short mode") } ctx := context.Background() - helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ - EnableFeatureToggles: []string{ - // NOTE: when using this feature toggle, the read is always v0! - // featuremgmt.FlagKubernetesClientDashboardsFolders - }, - }) + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{}) clientV0 := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Admin, diff --git a/pkg/tests/apis/dashboard/integration/api_validation_test.go b/pkg/tests/apis/dashboard/integration/api_validation_test.go index 881ea3c1430..70214db8cc2 100644 --- a/pkg/tests/apis/dashboard/integration/api_validation_test.go +++ b/pkg/tests/apis/dashboard/integration/api_validation_test.go @@ -17,7 +17,7 @@ import ( dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" dashboardV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" - dashboardV2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" + dashboardV2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" foldersV1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -69,7 +69,6 @@ func TestIntegrationDashboardAPIValidation(t *testing.T) { helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ DisableAnonymous: true, EnableFeatureToggles: []string{ - featuremgmt.FlagKubernetesClientDashboardsFolders, // Enable dashboard feature featuremgmt.FlagUnifiedStorageSearch, featuremgmt.FlagKubernetesDashboards, // Enable FE-only dashboard feature flag }, @@ -101,7 +100,6 @@ func TestIntegrationDashboardAPIValidation(t *testing.T) { helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ DisableAnonymous: true, EnableFeatureToggles: []string{ - featuremgmt.FlagKubernetesClientDashboardsFolders, // Enable dashboard feature featuremgmt.FlagUnifiedStorageSearch, }, DisableFeatureToggles: []string{ @@ -138,7 +136,6 @@ func TestIntegrationDashboardAPIAuthorization(t *testing.T) { helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ DisableAnonymous: true, EnableFeatureToggles: []string{ - featuremgmt.FlagKubernetesClientDashboardsFolders, // Enable dashboard feature featuremgmt.FlagUnifiedStorageSearch, }, UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ @@ -189,7 +186,6 @@ func TestIntegrationDashboardAPI(t *testing.T) { helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ DisableAnonymous: true, EnableFeatureToggles: []string{ - featuremgmt.FlagKubernetesClientDashboardsFolders, // Enable dashboard feature featuremgmt.FlagUnifiedStorageSearch, featuremgmt.FlagKubernetesDashboards, }, @@ -444,12 +440,12 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { }, }, { - name: "v2alpha2 dashboard with correct spec should not throw on v2", - resourceInfo: dashboardV2alpha2.DashboardResourceInfo, + name: "v2beta1 dashboard with correct spec should not throw on v2", + resourceInfo: dashboardV2beta1.DashboardResourceInfo, expectSpecErr: false, testObject: &unstructured.Unstructured{ Object: map[string]interface{}{ - "apiVersion": dashboardV2alpha2.DashboardResourceInfo.TypeMeta().APIVersion, + "apiVersion": dashboardV2beta1.DashboardResourceInfo.TypeMeta().APIVersion, "kind": "Dashboard", "metadata": map[string]interface{}{ "generateName": "test-", diff --git a/pkg/tests/apis/dashboard/integration/library_panels_api_validation_test.go b/pkg/tests/apis/dashboard/integration/library_panels_api_validation_test.go index b2857d97a69..5f001cf3e15 100644 --- a/pkg/tests/apis/dashboard/integration/library_panels_api_validation_test.go +++ b/pkg/tests/apis/dashboard/integration/library_panels_api_validation_test.go @@ -32,7 +32,6 @@ func TestIntegrationLibraryPanelConnections(t *testing.T) { DisableAnonymous: true, EnableFeatureToggles: []string{ "unifiedStorageSearch", - "kubernetesClientDashboardsFolders", "kubernetesLibraryPanels", }, }) @@ -98,7 +97,6 @@ func TestIntegrationLibraryElementPermissions(t *testing.T) { DisableAnonymous: true, EnableFeatureToggles: []string{ "unifiedStorageSearch", - "kubernetesClientDashboardsFolders", "kubernetesLibraryPanels", "grafanaAPIServerWithExperimentalAPIs", // needed until we move it to v0beta1 at least (currently v0alpha1) }, @@ -303,7 +301,6 @@ func TestIntegrationLibraryPanelConnectionsWithFolderAccess(t *testing.T) { EnableFeatureToggles: []string{ "unifiedStorageSearch", "kubernetesLibraryPanels", - "kubernetesClientDashboardsFolders", }, }) ctx := createTestContext(t, helper, helper.Org1, dualWriterMode) diff --git a/pkg/tests/apis/features/features_test.go b/pkg/tests/apis/features/features_test.go index 1a4b05a898d..706984e9edf 100644 --- a/pkg/tests/apis/features/features_test.go +++ b/pkg/tests/apis/features/features_test.go @@ -40,14 +40,9 @@ func TestIntegrationFeatures(t *testing.T) { require.Equal(t, 200, rsp.Response.StatusCode) require.JSONEq(t, `{ - "Value": true, - "FlagKey": "`+flag+`", - "FlagType": 0, - "Variant": "enabled", - "Reason": "STATIC", - "ErrorCode": "", - "ErrorMessage": "", - "FlagMetadata": {} - }`, string(rsp.Body)) + "value": true, + "key":"`+flag+`", + "reason":"static provider evaluation result", + "variant":"enabled"}`, string(rsp.Body)) }) } diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go index 5001e90f42c..98b0509df10 100644 --- a/pkg/tests/apis/folder/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -50,10 +50,8 @@ func TestIntegrationFoldersApp(t *testing.T) { } helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ - AppModeProduction: true, - EnableFeatureToggles: []string{ - featuremgmt.FlagKubernetesClientDashboardsFolders, - }, + AppModeProduction: true, + EnableFeatureToggles: []string{}, }) t.Run("Check discovery client", func(t *testing.T) { @@ -138,9 +136,7 @@ func TestIntegrationFoldersApp(t *testing.T) { DualWriterMode: modeDw, }, }, - EnableFeatureToggles: []string{ - featuremgmt.FlagKubernetesClientDashboardsFolders, - }, + EnableFeatureToggles: []string{}, })) }) @@ -155,7 +151,6 @@ func TestIntegrationFoldersApp(t *testing.T) { }, }, EnableFeatureToggles: []string{ - featuremgmt.FlagKubernetesClientDashboardsFolders, featuremgmt.FlagNestedFolders, }, })) @@ -172,7 +167,6 @@ func TestIntegrationFoldersApp(t *testing.T) { }, }, EnableFeatureToggles: []string{ - featuremgmt.FlagKubernetesClientDashboardsFolders, featuremgmt.FlagNestedFolders, }, })) @@ -189,7 +183,6 @@ func TestIntegrationFoldersApp(t *testing.T) { }, }, EnableFeatureToggles: []string{ - featuremgmt.FlagKubernetesClientDashboardsFolders, featuremgmt.FlagNestedFolders, }, })) @@ -206,7 +199,6 @@ func TestIntegrationFoldersApp(t *testing.T) { }, }, EnableFeatureToggles: []string{ - featuremgmt.FlagKubernetesClientDashboardsFolders, featuremgmt.FlagNestedFolders, }, })) @@ -237,7 +229,6 @@ func TestIntegrationFoldersApp(t *testing.T) { // We set it to 1 here, so we always get forced pagination based on the response size. UnifiedStorageMaxPageSizeBytes: 1, EnableFeatureToggles: []string{ - featuremgmt.FlagKubernetesClientDashboardsFolders, featuremgmt.FlagNestedFolders, }, }), mode) @@ -680,7 +671,6 @@ func TestIntegrationFolderCreatePermissions(t *testing.T) { }, EnableFeatureToggles: []string{ featuremgmt.FlagNestedFolders, - featuremgmt.FlagKubernetesClientDashboardsFolders, }, }) @@ -788,7 +778,6 @@ func TestIntegrationFolderGetPermissions(t *testing.T) { }, EnableFeatureToggles: []string{ featuremgmt.FlagNestedFolders, - featuremgmt.FlagKubernetesClientDashboardsFolders, }, }) @@ -972,7 +961,6 @@ func TestIntegrationFoldersCreateAPIEndpointK8S(t *testing.T) { }, EnableFeatureToggles: []string{ featuremgmt.FlagNestedFolders, - featuremgmt.FlagKubernetesClientDashboardsFolders, }, }) @@ -1148,7 +1136,6 @@ func TestIntegrationFoldersGetAPIEndpointK8S(t *testing.T) { EnableFeatureToggles: []string{ featuremgmt.FlagNestedFolders, featuremgmt.FlagUnifiedStorageSearch, - featuremgmt.FlagKubernetesClientDashboardsFolders, }, }) diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json index bd54e67e82a..4aa160ab2e9 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -2645,6 +2645,18 @@ "ref": { "description": "Ref to the branch or commit hash to delete from", "type": "string" + }, + "resources": { + "description": "Resources to delete This option has been created because currently the frontend does not use standarized app platform APIs. For performance and API consistency reasons, the preferred option is it to use the paths.", + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceRef" + } + ] + } } } }, @@ -3066,11 +3078,12 @@ "type": "object", "properties": { "action": { - "description": "Possible enum values:\n - `\"delete\"` deletes files in the remote repository\n - `\"migrate\"` acts like JobActionExport, then JobActionPull. It also tries to preserve the history.\n - `\"pr\"` adds additional useful information to a PR, such as comments with preview links and rendered images.\n - `\"pull\"` replicates the remote branch in the local copy of the repository.\n - `\"push\"` replicates the local copy of the repository in the remote branch.", + "description": "Possible enum values:\n - `\"delete\"` deletes files in the remote repository\n - `\"migrate\"` acts like JobActionExport, then JobActionPull. It also tries to preserve the history.\n - `\"move\"` moves files in the remote repository\n - `\"pr\"` adds additional useful information to a PR, such as comments with preview links and rendered images.\n - `\"pull\"` replicates the remote branch in the local copy of the repository.\n - `\"push\"` replicates the local copy of the repository in the remote branch.", "type": "string", "enum": [ "delete", "migrate", + "move", "pr", "pull", "push" @@ -3092,6 +3105,14 @@ } ] }, + "move": { + "description": "Move when the action is `move`", + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.MoveJobOptions" + } + ] + }, "pr": { "description": "Pull request options", "allOf": [ @@ -3217,6 +3238,39 @@ } } }, + "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.MoveJobOptions": { + "type": "object", + "properties": { + "paths": { + "description": "Paths to be deleted. Examples: - dashboard.json (for a file) - a/b/c/other-dashboard.json (for a file) - nested/deep/ (for a directory) FIXME: we should validate this in admission hooks", + "type": "array", + "items": { + "type": "string", + "default": "" + } + }, + "ref": { + "description": "Ref to the branch or commit hash that should move", + "type": "string" + }, + "resources": { + "description": "Resources to move This option has been created because currently the frontend does not use standarized app platform APIs. For performance and API consistency reasons, the preferred option is it to use the paths.", + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceRef" + } + ] + } + }, + "targetPath": { + "description": "Destination path for the move (e.g. \"new-location/\")", + "type": "string" + } + } + }, "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.PullRequestJobOptions": { "type": "object", "properties": { @@ -3816,6 +3870,23 @@ } } }, + "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceRef": { + "type": "object", + "properties": { + "group": { + "description": "Group is the group of the resource, such as \"dashboard.grafana.app\".", + "type": "string" + }, + "kind": { + "description": "Kind is the type of resource, for example, \"Dashboard\".", + "type": "string" + }, + "name": { + "description": "Name is the name of the resource, such as a dashboard UID.", + "type": "string" + } + } + }, "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceRepositoryInfo": { "type": "object", "required": [ diff --git a/pkg/tests/apis/openapi_test.go b/pkg/tests/apis/openapi_test.go index 05ed40bdd34..9e5b754c4d9 100644 --- a/pkg/tests/apis/openapi_test.go +++ b/pkg/tests/apis/openapi_test.go @@ -28,8 +28,7 @@ func TestIntegrationOpenAPIs(t *testing.T) { h := NewK8sTestHelper(t, testinfra.GrafanaOpts{ AppModeProduction: true, EnableFeatureToggles: []string{ - featuremgmt.FlagKubernetesClientDashboardsFolders, // Will be default on by G12 - featuremgmt.FlagQueryService, // Query Library + featuremgmt.FlagQueryService, // Query Library featuremgmt.FlagProvisioning, featuremgmt.FlagInvestigationsBackend, featuremgmt.FlagGrafanaAdvisor, diff --git a/pkg/tests/apis/provisioning/exportunifiedtorepository/dashboard-test-v2alpha2.yaml b/pkg/tests/apis/provisioning/exportunifiedtorepository/dashboard-test-v2alpha2.yaml deleted file mode 100644 index 7a4079cee41..00000000000 --- a/pkg/tests/apis/provisioning/exportunifiedtorepository/dashboard-test-v2alpha2.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: dashboard.grafana.app/v2alpha2 -kind: Dashboard -metadata: - name: test-v2alpha2 -spec: - title: Test dashboard. Created at v2alpha2 - layout: - kind: GridLayout - spec: - items: [] diff --git a/pkg/tests/apis/provisioning/exportunifiedtorepository/dashboard-test-v2beta1.yaml b/pkg/tests/apis/provisioning/exportunifiedtorepository/dashboard-test-v2beta1.yaml new file mode 100644 index 00000000000..88f9f3385ae --- /dev/null +++ b/pkg/tests/apis/provisioning/exportunifiedtorepository/dashboard-test-v2beta1.yaml @@ -0,0 +1,10 @@ +apiVersion: dashboard.grafana.app/v2beta1 +kind: Dashboard +metadata: + name: test-v2beta1 +spec: + title: Test dashboard. Created at v2beta1 + layout: + kind: GridLayout + spec: + items: [] diff --git a/pkg/tests/apis/provisioning/helper_test.go b/pkg/tests/apis/provisioning/helper_test.go index cff6dbeeabf..2d5585b819f 100644 --- a/pkg/tests/apis/provisioning/helper_test.go +++ b/pkg/tests/apis/provisioning/helper_test.go @@ -25,7 +25,7 @@ import ( dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" dashboardsV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" - dashboardsV2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" + dashboardsV2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" folder "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" @@ -51,7 +51,7 @@ type provisioningTestHelper struct { DashboardsV0 *apis.K8sResourceClient DashboardsV1 *apis.K8sResourceClient DashboardsV2alpha1 *apis.K8sResourceClient - DashboardsV2alpha2 *apis.K8sResourceClient + DashboardsV2beta1 *apis.K8sResourceClient AdminREST *rest.RESTClient EditorREST *rest.RESTClient ViewerREST *rest.RESTClient @@ -115,6 +115,8 @@ func (h *provisioningTestHelper) AwaitJobSuccess(t *testing.T, ctx context.Conte 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{}) @@ -219,7 +221,6 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper AppModeProduction: false, // required for experimental APIs EnableFeatureToggles: []string{ featuremgmt.FlagProvisioning, - featuremgmt.FlagKubernetesClientDashboardsFolders, }, UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ "dashboards.dashboard.grafana.app": { @@ -269,10 +270,10 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper Namespace: "default", // actually org1 GVR: dashboardsV2alpha1.DashboardResourceInfo.GroupVersionResource(), }) - dashboardsV2alpha2 := helper.GetResourceClient(apis.ResourceClientArgs{ + dashboardsV2beta1 := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Admin, Namespace: "default", // actually org1 - GVR: dashboardsV2alpha2.DashboardResourceInfo.GroupVersionResource(), + GVR: dashboardsV2beta1.DashboardResourceInfo.GroupVersionResource(), }) // Repo client, but less guard rails. Useful for subresources. We'll need this later... @@ -312,7 +313,7 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper DashboardsV0: dashboardsV0, DashboardsV1: dashboardsV1, DashboardsV2alpha1: dashboardsV2alpha1, - DashboardsV2alpha2: dashboardsV2alpha2, + DashboardsV2beta1: dashboardsV2beta1, } } @@ -324,6 +325,14 @@ func mustNestedString(obj map[string]interface{}, fields ...string) string { return v } +func mustNestedStringSlice(obj map[string]interface{}, fields ...string) []string { + v, _, err := unstructured.NestedStringSlice(obj, fields...) + if err != nil { + panic(err) + } + return v +} + func asJSON(obj any) []byte { jj, _ := json.Marshal(obj) return jj diff --git a/pkg/tests/apis/provisioning/provisioning_test.go b/pkg/tests/apis/provisioning/provisioning_test.go index 15049df999d..cdf210e2133 100644 --- a/pkg/tests/apis/provisioning/provisioning_test.go +++ b/pkg/tests/apis/provisioning/provisioning_test.go @@ -658,9 +658,9 @@ func TestProvisioning_ExportUnifiedToRepository(t *testing.T) { _, err = helper.DashboardsV2alpha1.Resource.Create(ctx, dashboard, metav1.CreateOptions{}) require.NoError(t, err, "should be able to create v2alpha1 dashboard") - dashboard = helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v2alpha2.yaml") - _, err = helper.DashboardsV2alpha2.Resource.Create(ctx, dashboard, metav1.CreateOptions{}) - require.NoError(t, err, "should be able to create v2alpha2 dashboard") + dashboard = helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v2beta1.yaml") + _, err = helper.DashboardsV2beta1.Resource.Create(ctx, dashboard, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create v2beta1 dashboard") // Now for the repository. const repo = "local-repository" @@ -701,7 +701,7 @@ func TestProvisioning_ExportUnifiedToRepository(t *testing.T) { {title: "Test dashboard. Created at v0", apiVersion: "dashboard.grafana.app/v0alpha1", name: "test-v0", fileName: "test-dashboard-created-at-v0.json"}, {title: "Test dashboard. Created at v1", apiVersion: "dashboard.grafana.app/v1beta1", name: "test-v1", fileName: "test-dashboard-created-at-v1.json"}, {title: "Test dashboard. Created at v2alpha1", apiVersion: "dashboard.grafana.app/v2alpha1", name: "test-v2alpha1", fileName: "test-dashboard-created-at-v2alpha1.json"}, - {title: "Test dashboard. Created at v2alpha2", apiVersion: "dashboard.grafana.app/v2alpha2", name: "test-v2alpha2", fileName: "test-dashboard-created-at-v2alpha2.json"}, + {title: "Test dashboard. Created at v2beta1", apiVersion: "dashboard.grafana.app/v2beta1", name: "test-v2beta1", fileName: "test-dashboard-created-at-v2beta1.json"}, } { fpath := filepath.Join(helper.ProvisioningPath, test.fileName) //nolint:gosec // we are ok with reading files in testdata @@ -927,6 +927,340 @@ func TestIntegrationProvisioning_DeleteJob(t *testing.T) { require.Equal(t, 0, len(dashboards.Items), "should have 0 dashboards after deleting all") }) + t.Run("delete by resource reference", func(t *testing.T) { + // Create modified test files with unique UIDs for ResourceRef testing + // Read and modify the testdata files to have unique UIDs that don't conflict with existing resources + allPanelsContent := helper.LoadFile("testdata/all-panels.json") + textOptionsContent := helper.LoadFile("testdata/text-options.json") + timelineDemoContent := helper.LoadFile("testdata/timeline-demo.json") + + // Modify UIDs to be unique for ResourceRef tests + allPanelsModified := strings.Replace(string(allPanelsContent), `"uid": "n1jR8vnnz"`, `"uid": "resourceref1"`, 1) + textOptionsModified := strings.Replace(string(textOptionsContent), `"uid": "WZ7AhQiVz"`, `"uid": "resourceref2"`, 1) + timelineDemoModified := strings.Replace(string(timelineDemoContent), `"uid": "mIJjFy8Kz"`, `"uid": "resourceref3"`, 1) + + // Create temporary files and copy them to the provisioning path + tmpDir := t.TempDir() + tmpFile1 := filepath.Join(tmpDir, "resource-test-1.json") + tmpFile2 := filepath.Join(tmpDir, "resource-test-2.json") + tmpFile3 := filepath.Join(tmpDir, "resource-test-3.json") + + require.NoError(t, os.WriteFile(tmpFile1, []byte(allPanelsModified), 0644)) + require.NoError(t, os.WriteFile(tmpFile2, []byte(textOptionsModified), 0644)) + require.NoError(t, os.WriteFile(tmpFile3, []byte(timelineDemoModified), 0644)) + + // Copy the temporary files to the provisioning path + helper.CopyToProvisioningPath(t, tmpFile1, "resource-test-1.json") // UID: resourceref1 + helper.CopyToProvisioningPath(t, tmpFile2, "resource-test-2.json") // UID: resourceref2 + helper.CopyToProvisioningPath(t, tmpFile3, "nested/resource-test-3.json") // UID: resourceref3 + + // Trigger sync to populate the new resources + helper.SyncAndWait(t, repo, nil) + + // Verify the new resources are created + dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.GreaterOrEqual(t, len(dashboards.Items), 3, "should have at least 3 dashboards after adding test resources") + + // Debug: print the actual dashboard names/UIDs to verify they match our expectations + for i, dashboard := range dashboards.Items { + t.Logf("Dashboard %d: name=%s, UID=%s", i+1, dashboard.GetName(), dashboard.GetUID()) + } + + t.Run("delete single dashboard by resource reference", func(t *testing.T) { + // Create delete job for single dashboard using ResourceRef + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Body(asJSON(&provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Delete: &provisioning.DeleteJobOptions{ + Resources: []provisioning.ResourceRef{ + { + Name: "resourceref1", // UID from modified all-panels.json + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + })). + SetHeader("Content-Type", "application/json"). + Do(ctx) + require.NoError(t, result.Error(), "should be able to create delete job with ResourceRef") + + // Wait for job to complete + helper.AwaitJobs(t, repo) + + // Verify corresponding file is deleted from repository + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "resource-test-1.json") + require.Error(t, err, "file should be deleted from repository") + require.True(t, apierrors.IsNotFound(err), "should be not found error") + + // Verify dashboard is removed from Grafana (check count like other successful tests) + dashboards, err = helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Equal(t, 2, len(dashboards.Items), "should have 2 dashboards after deleting 1 from 3") + + // Verify other resources still exist + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "resource-test-2.json") + require.NoError(t, err, "other files should still exist") + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "nested", "resource-test-3.json") + require.NoError(t, err, "nested files should still exist") + }) + + t.Run("delete multiple resources by reference", func(t *testing.T) { + // Create delete job for multiple resources using ResourceRef + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Body(asJSON(&provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Delete: &provisioning.DeleteJobOptions{ + Resources: []provisioning.ResourceRef{ + { + Name: "resourceref2", // UID from modified text-options.json + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + { + Name: "resourceref3", // UID from modified timeline-demo.json + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + })). + SetHeader("Content-Type", "application/json"). + Do(ctx) + require.NoError(t, result.Error(), "should be able to create delete job with multiple ResourceRefs") + + // Wait for job to complete + helper.AwaitJobs(t, repo) + + // Verify both dashboards are removed from Grafana + _, err = helper.DashboardsV1.Resource.Get(ctx, "resourceref2", metav1.GetOptions{}) + require.Error(t, err, "text-options dashboard should be deleted") + require.True(t, apierrors.IsNotFound(err)) + + _, err = helper.DashboardsV1.Resource.Get(ctx, "resourceref3", metav1.GetOptions{}) + require.Error(t, err, "timeline-demo dashboard should be deleted") + require.True(t, apierrors.IsNotFound(err)) + + // Verify corresponding files are deleted from repository + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "resource-test-2.json") + require.Error(t, err, "resource-test-2.json should be deleted") + + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "nested", "resource-test-3.json") + require.Error(t, err, "nested/resource-test-3.json should be deleted") + + // Verify specific dashboards are removed from Grafana + dashboards, err = helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Equal(t, 0, len(dashboards.Items), "should have 0 dashboards after deleting 2 more (2 -> 0)") + }) + + t.Run("mixed deletion - paths and resources", func(t *testing.T) { + // Setup fresh resources for mixed test - reuse the modified content with unique UIDs + tmpMixed1 := filepath.Join(tmpDir, "mixed-test-1.json") + tmpMixed2 := filepath.Join(tmpDir, "mixed-test-2.json") + tmpMixed3 := filepath.Join(tmpDir, "mixed-test-3.json") + + require.NoError(t, os.WriteFile(tmpMixed1, []byte(allPanelsModified), 0644)) + require.NoError(t, os.WriteFile(tmpMixed2, []byte(textOptionsModified), 0644)) + require.NoError(t, os.WriteFile(tmpMixed3, []byte(timelineDemoModified), 0644)) + + helper.CopyToProvisioningPath(t, tmpMixed1, "mixed-test-1.json") // UID: resourceref1 + helper.CopyToProvisioningPath(t, tmpMixed2, "mixed-test-2.json") // UID: resourceref2 + helper.CopyToProvisioningPath(t, tmpMixed3, "mixed-test-3.json") // UID: resourceref3 + + helper.SyncAndWait(t, repo, nil) + + // Create delete job that combines both paths and resource references + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Body(asJSON(&provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Delete: &provisioning.DeleteJobOptions{ + Paths: []string{"mixed-test-1.json"}, // Delete by path + Resources: []provisioning.ResourceRef{ + { + Name: "resourceref2", // Delete by resource reference + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + })). + SetHeader("Content-Type", "application/json"). + Do(ctx) + require.NoError(t, result.Error(), "should be able to create mixed delete job") + + // Wait for job to complete + helper.AwaitJobs(t, repo) + + // Verify both targeted resources are deleted from Grafana + _, err = helper.DashboardsV1.Resource.Get(ctx, "resourceref1", metav1.GetOptions{}) + require.Error(t, err, "dashboard deleted by path should be removed") + + _, err = helper.DashboardsV1.Resource.Get(ctx, "resourceref2", metav1.GetOptions{}) + require.Error(t, err, "dashboard deleted by resource ref should be removed") + + // Verify the untargeted resource still exists + _, err = helper.DashboardsV1.Resource.Get(ctx, "resourceref3", metav1.GetOptions{}) + require.NoError(t, err, "untargeted dashboard should still exist") + + // Verify files are properly deleted/preserved in repository + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "mixed-test-1.json") + require.Error(t, err, "file deleted by path should be removed") + + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "mixed-test-2.json") + require.Error(t, err, "file for resource deleted by ref should be removed") + + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "mixed-test-3.json") + require.NoError(t, err, "untargeted file should still exist") + }) + + t.Run("delete folder by resource reference", func(t *testing.T) { + // Create a dashboard inside a folder to automatically create the folder structure + // This follows the same pattern as other tests in this file + testDashboard := strings.Replace(string(allPanelsContent), `"uid": "n1jR8vnnz"`, `"uid": "folder-dash"`, 1) + + // Write the modified dashboard to a temporary file first + tmpFolderDash := filepath.Join(tmpDir, "folder-dashboard.json") + require.NoError(t, os.WriteFile(tmpFolderDash, []byte(testDashboard), 0644)) + + // Copy it to the folder structure using the helper + helper.CopyToProvisioningPath(t, tmpFolderDash, "test-folder/dashboard-in-folder.json") + + // Sync to create the folder and its contents + helper.SyncAndWait(t, repo, nil) + + // Verify folder was created in Grafana as a Folder resource + folders, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + + var testFolder *unstructured.Unstructured + for _, folder := range folders.Items { + // Folder names are generated with suffixes, so check if it starts with "test-folder" + if strings.HasPrefix(folder.GetName(), "test-folder") { + testFolder = &folder + break + } + } + require.NotNil(t, testFolder, "test-folder should exist as a Folder resource") + testFolderName := testFolder.GetName() + + // Verify dashboard inside the folder exists + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "test-folder", "dashboard-in-folder.json") + require.NoError(t, err, "dashboard inside folder should exist") + + // Create delete job for the folder using ResourceRef (use the actual generated name) + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Body(asJSON(&provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Delete: &provisioning.DeleteJobOptions{ + Resources: []provisioning.ResourceRef{ + { + Name: testFolderName, // Use the actual generated folder name + Kind: "Folder", + Group: "folder.grafana.app", + }, + }, + }, + })). + SetHeader("Content-Type", "application/json"). + Do(ctx) + require.NoError(t, result.Error(), "should be able to create delete job for folder") + + // Wait for job to complete + helper.AwaitJobs(t, repo) + + // Verify folder is deleted from Grafana + _, err = helper.Folders.Resource.Get(ctx, testFolderName, metav1.GetOptions{}) + require.Error(t, err, "folder should be deleted from Grafana") + require.True(t, apierrors.IsNotFound(err), "should be not found error") + + // Verify folder contents are also deleted from repository + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "test-folder", "dashboard-in-folder.json") + require.Error(t, err, "dashboard inside deleted folder should also be deleted") + require.True(t, apierrors.IsNotFound(err), "should be not found error") + }) + + t.Run("delete non-existent resource by reference", func(t *testing.T) { + // Create delete job for non-existent resource + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Body(asJSON(&provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Delete: &provisioning.DeleteJobOptions{ + Resources: []provisioning.ResourceRef{ + { + Name: "non-existent-uid", + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + })). + SetHeader("Content-Type", "application/json"). + Do(ctx) + require.NoError(t, result.Error(), "should be able to create delete job") + + // Wait for job to complete - should record error but continue + require.EventuallyWithT(t, func(collect *assert.CollectT) { + list := &unstructured.UnstructuredList{} + err := helper.AdminREST.Get(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Do(ctx).Into(list) + assert.NoError(collect, err, "should be able to list jobs") + assert.NotEmpty(collect, list.Items, "expect at least one job") + + // Find the most recent delete job + var deleteJob *unstructured.Unstructured + for _, elem := range list.Items { + assert.Equal(collect, repo, elem.GetLabels()["provisioning.grafana.app/repository"], "should have repo label") + + action := mustNestedString(elem.Object, "spec", "action") + if action == "delete" { + // Get the most recent one (they should be ordered by creation time) + deleteJob = &elem + } + } + if !assert.NotNil(collect, deleteJob, "should find a delete job") { + return + } + + state := mustNestedString(deleteJob.Object, "status", "state") + // The job should complete but record errors for individual resource resolution failures + if state == "error" || state == "completed" || state == "success" { + // Any of these states is acceptable - the key is that resource resolution errors are recorded + // and don't fail the entire job due to error-tolerant implementation + return + } + assert.Fail(collect, "job should complete or error, but got state: %s", state) + }, time.Second*10, time.Millisecond*100, "Expected delete job to handle non-existent resource") + }) + + // Repository cleanup is handled by the main test function + }) + t.Run("delete non-existent file", func(t *testing.T) { // Create delete job for non-existent file result := helper.AdminREST.Post(). @@ -975,6 +1309,461 @@ func TestIntegrationProvisioning_DeleteJob(t *testing.T) { }) } +func TestIntegrationProvisioning_MoveJob(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + helper := runGrafana(t) + ctx := context.Background() + const repo = "move-test-repo" + localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ + "Name": repo, + "SyncEnabled": true, + "SyncTarget": "instance", + }) + _, err := helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{}) + require.NoError(t, err) + // Copy multiple test files to the repository + helper.CopyToProvisioningPath(t, "testdata/all-panels.json", "dashboard1.json") + helper.CopyToProvisioningPath(t, "testdata/text-options.json", "dashboard2.json") + helper.CopyToProvisioningPath(t, "testdata/timeline-demo.json", "folder/dashboard3.json") + // Trigger and wait for initial sync to populate resources + helper.SyncAndWait(t, repo, nil) + // Verify initial state - should have 3 dashboards and 1 folder + dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Equal(t, 3, len(dashboards.Items), "should have 3 dashboards after sync") + folders, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Equal(t, 1, len(folders.Items), "should have 1 folder after sync") + + t.Run("move single file", func(t *testing.T) { + // Create move job for single file + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Body(asJSON(&provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + Paths: []string{"dashboard1.json"}, + TargetPath: "moved/", + }, + })). + SetHeader("Content-Type", "application/json"). + Do(ctx) + require.NoError(t, result.Error(), "should be able to create move job") + + raw, err := result.Raw() + require.NoError(t, err) + + obj := &unstructured.Unstructured{} + err = json.Unmarshal(raw, obj) + require.NoError(t, err) + + // Wait for job to complete + helper.AwaitJobSuccess(t, ctx, obj) + + // TODO: This additional sync should not be necessary - the move job should handle sync properly + helper.SyncAndWait(t, repo, nil) + + // Verify file is moved in repository + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved", "dashboard1.json") + require.NoError(t, err, "file should exist at new location in repository") + + // Verify original file is gone from repository + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard1.json") + require.Error(t, err, "original file should be gone from repository") + require.True(t, apierrors.IsNotFound(err), "should be not found error") + + // Verify dashboard still exists in Grafana after sync + dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, dashboards.Items, 3, "should still have 3 dashboards after move") + + // Verify that dashboards have the correct source paths + foundPaths := make(map[string]bool) + for _, dashboard := range dashboards.Items { + sourcePath := dashboard.GetAnnotations()["grafana.app/sourcePath"] + foundPaths[sourcePath] = true + } + + require.True(t, foundPaths["moved/dashboard1.json"], "should have dashboard with moved source path") + require.True(t, foundPaths["dashboard2.json"], "should have dashboard2 in original location") + require.True(t, foundPaths["folder/dashboard3.json"], "should have dashboard3 in original nested location") + + // Verify other files still exist at original locations + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard2.json") + require.NoError(t, err, "other files should still exist") + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "dashboard3.json") + require.NoError(t, err, "nested files should still exist") + }) + + t.Run("move multiple files and folder", func(t *testing.T) { + // Create move job for multiple files including a folder + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Body(asJSON(&provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + Paths: []string{"dashboard2.json", "folder/"}, + TargetPath: "archived/", + }, + })). + SetHeader("Content-Type", "application/json"). + Do(ctx) + require.NoError(t, result.Error(), "should be able to create move job") + + raw, err := result.Raw() + require.NoError(t, err) + obj := &unstructured.Unstructured{} + err = json.Unmarshal(raw, obj) + require.NoError(t, err) + + // Wait for job to complete + helper.AwaitJobSuccess(t, ctx, obj) + + // TODO: This additional sync should not be necessary - the move job should handle sync properly + helper.SyncAndWait(t, repo, nil) + + // Verify files are moved in repository + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "archived", "dashboard2.json") + require.NoError(t, err, "dashboard2.json should exist at new location") + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "archived", "folder", "dashboard3.json") + require.NoError(t, err, "folder/dashboard3.json should exist at new nested location") + + // Verify original files are gone from repository + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard2.json") + require.Error(t, err, "dashboard2.json should be gone from original location") + require.True(t, apierrors.IsNotFound(err)) + + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "dashboard3.json") + require.Error(t, err, "folder should be gone from original location") + require.True(t, apierrors.IsNotFound(err), err.Error()) + + // Verify dashboards still exist in Grafana after sync + // Note: Since dashboard1.json was moved in the previous test, we now expect all 3 dashboards + // to be accessible from their moved locations (dashboard1 from moved/, dashboard2 and dashboard3 from archived/) + dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, dashboards.Items, 3, "should still have 3 dashboards after move") + + // Verify that dashboards have the correct source paths after cumulative moves + foundPaths := make(map[string]bool) + for _, dashboard := range dashboards.Items { + sourcePath := dashboard.GetAnnotations()["grafana.app/sourcePath"] + foundPaths[sourcePath] = true + } + + require.True(t, foundPaths["moved/dashboard1.json"], "should have dashboard1 from first move") + require.True(t, foundPaths["archived/dashboard2.json"], "should have dashboard2 in archived location") + require.True(t, foundPaths["archived/folder/dashboard3.json"], "should have dashboard3 in archived nested location") + }) + + t.Run("move non-existent file", func(t *testing.T) { + // Create move job for non-existent file + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Body(asJSON(&provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + Paths: []string{"non-existent.json"}, + TargetPath: "moved/", + }, + })). + SetHeader("Content-Type", "application/json"). + Do(ctx) + require.NoError(t, result.Error(), "should be able to create move job") + + // Wait for job to complete - should fail due to strict error handling + require.EventuallyWithT(t, func(collect *assert.CollectT) { + list := &unstructured.UnstructuredList{} + err := helper.AdminREST.Get(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Do(ctx).Into(list) + assert.NoError(collect, err, "should be able to list jobs") + assert.NotEmpty(collect, list.Items, "expect at least one job") + + // Find the move job specifically + var moveJob *unstructured.Unstructured + for _, elem := range list.Items { + assert.Equal(collect, repo, elem.GetLabels()["provisioning.grafana.app/repository"], "should have repo label") + + action := mustNestedString(elem.Object, "spec", "action") + if action == "move" { + // Check if this is the specific job we're looking for + paths, found, err := unstructured.NestedStringSlice(elem.Object, "spec", "move", "paths") + if err == nil && found && len(paths) > 0 && paths[0] == "non-existent.json" { + moveJob = &elem + break + } + } + } + assert.NotNil(collect, moveJob, "should find a move job for non-existent file") + + state := mustNestedString(moveJob.Object, "status", "state") + assert.Equal(collect, "error", state, "move job should have failed due to non-existent file") + }, time.Second*10, time.Millisecond*100, "Expected move job to fail with error state") + }) + + t.Run("move without target path", func(t *testing.T) { + // Create move job without target path (should fail) + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Body(asJSON(&provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + Paths: []string{"moved/dashboard1.json"}, + // TargetPath intentionally omitted + }, + })). + SetHeader("Content-Type", "application/json"). + Do(ctx) + require.NoError(t, result.Error(), "should be able to create move job") + + // Wait for job to complete - should fail due to missing target path + require.EventuallyWithT(t, func(collect *assert.CollectT) { + list := &unstructured.UnstructuredList{} + err := helper.AdminREST.Get(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Do(ctx).Into(list) + assert.NoError(collect, err, "should be able to list jobs") + assert.NotEmpty(collect, list.Items, "expect at least one job") + + // Find the move job specifically + var moveJob *unstructured.Unstructured + for _, elem := range list.Items { + assert.Equal(collect, repo, elem.GetLabels()["provisioning.grafana.app/repository"], "should have repo label") + + action := mustNestedString(elem.Object, "spec", "action") + if action == "move" { + // Check if this is the job without target path + targetPath, found, _ := unstructured.NestedString(elem.Object, "spec", "move", "targetPath") + if !found || targetPath == "" { + moveJob = &elem + break + } + } + } + assert.NotNil(collect, moveJob, "should find a move job without target path") + + state := mustNestedString(moveJob.Object, "status", "state") + assert.Equal(collect, "error", state, "move job should have failed due to missing target path") + }, time.Second*10, time.Millisecond*100, "Expected move job to fail with error state") + }) + + t.Run("move by resource reference", func(t *testing.T) { + // Create a unique repository for resource reference testing to avoid contamination + const refRepo = "move-ref-test-repo" + localRefTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ + "Name": refRepo, + "SyncEnabled": true, + "SyncTarget": "folder", + }) + _, err := helper.Repositories.Resource.Create(ctx, localRefTmp, metav1.CreateOptions{}) + require.NoError(t, err) + + // Create modified test files with unique UIDs for ResourceRef testing + allPanelsContent := helper.LoadFile("testdata/all-panels.json") + textOptionsContent := helper.LoadFile("testdata/text-options.json") + timelineDemoContent := helper.LoadFile("testdata/timeline-demo.json") + + // Modify UIDs to be unique for ResourceRef tests + allPanelsModified := strings.Replace(string(allPanelsContent), `"uid": "n1jR8vnnz"`, `"uid": "moveref1"`, 1) + textOptionsModified := strings.Replace(string(textOptionsContent), `"uid": "WZ7AhQiVz"`, `"uid": "moveref2"`, 1) + timelineDemoModified := strings.Replace(string(timelineDemoContent), `"uid": "mIJjFy8Kz"`, `"uid": "moveref3"`, 1) + + // Create temporary files and copy them to the provisioning path + tmpDir := t.TempDir() + tmpFile1 := filepath.Join(tmpDir, "move-ref-test-1.json") + tmpFile2 := filepath.Join(tmpDir, "move-ref-test-2.json") + tmpFile3 := filepath.Join(tmpDir, "move-ref-test-3.json") + + require.NoError(t, os.WriteFile(tmpFile1, []byte(allPanelsModified), 0644)) + require.NoError(t, os.WriteFile(tmpFile2, []byte(textOptionsModified), 0644)) + require.NoError(t, os.WriteFile(tmpFile3, []byte(timelineDemoModified), 0644)) + + // Copy files to provisioning path to set up test - use refRepo's path + helper.CopyToProvisioningPath(t, tmpFile1, "move-source-1.json") + helper.CopyToProvisioningPath(t, tmpFile2, "move-source-2.json") + helper.CopyToProvisioningPath(t, tmpFile3, "move-source-3.json") + + // Sync to populate resources in Grafana + helper.SyncAndWait(t, refRepo, nil) + + t.Run("move single dashboard by resource reference", func(t *testing.T) { + // Create move job for single dashboard using ResourceRef + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(refRepo). + SubResource("jobs"). + Body(asJSON(&provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + TargetPath: "moved-by-ref/", + Resources: []provisioning.ResourceRef{ + { + Name: "moveref1", // UID from modified all-panels.json + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + })). + SetHeader("Content-Type", "application/json"). + Do(ctx) + require.NoError(t, result.Error(), "should be able to create move job with ResourceRef") + + // Wait for job to complete + helper.AwaitJobs(t, refRepo) + + // Verify corresponding file is moved in repository + _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "moved-by-ref", "move-source-1.json") + require.NoError(t, err, "file should be moved to new location in repository") + + // Verify original file is deleted from repository + _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "move-source-1.json") + require.Error(t, err, "original file should be deleted from repository") + require.True(t, apierrors.IsNotFound(err), "should be not found error") + + // Verify dashboard still exists in Grafana (should be updated after sync) + helper.SyncAndWait(t, refRepo, nil) + _, err = helper.DashboardsV1.Resource.Get(ctx, "moveref1", metav1.GetOptions{}) + require.NoError(t, err, "dashboard should still exist in Grafana after move") + + // Verify other resource still exists in original location + _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "move-source-2.json") + require.NoError(t, err, "other files should still exist in original location") + }) + + t.Run("move multiple resources by reference", func(t *testing.T) { + // Create move job for remaining resource using ResourceRef + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(refRepo). + SubResource("jobs"). + Body(asJSON(&provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + TargetPath: "archived-by-ref/", + Resources: []provisioning.ResourceRef{ + { + Name: "moveref2", // UID from modified text-options.json + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + })). + SetHeader("Content-Type", "application/json"). + Do(ctx) + require.NoError(t, result.Error(), "should be able to create move job with ResourceRef") + + // Wait for job to complete + helper.AwaitJobs(t, refRepo) + + // Verify file is moved in repository + _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "archived-by-ref", "move-source-2.json") + require.NoError(t, err, "file should be moved to new location") + + // Verify original file is deleted from repository + _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "move-source-2.json") + require.Error(t, err, "original file should be deleted from repository") + require.True(t, apierrors.IsNotFound(err), "should be not found error") + + // Verify dashboard still exists in Grafana after sync + helper.SyncAndWait(t, refRepo, nil) + _, err = helper.DashboardsV1.Resource.Get(ctx, "moveref2", metav1.GetOptions{}) + require.NoError(t, err, "dashboard should still exist in Grafana after move") + }) + + t.Run("mixed move - paths and resources", func(t *testing.T) { + // Setup fresh resources for mixed test + tmpMixed1 := filepath.Join(tmpDir, "mixed-move-1.json") + tmpMixed2 := filepath.Join(tmpDir, "mixed-move-2.json") + + allPanelsMixed := strings.Replace(string(allPanelsContent), `"uid": "n1jR8vnnz"`, `"uid": "mixedmove1"`, 1) + textOptionsMixed := strings.Replace(string(textOptionsContent), `"uid": "WZ7AhQiVz"`, `"uid": "mixedmove2"`, 1) + + require.NoError(t, os.WriteFile(tmpMixed1, []byte(allPanelsMixed), 0644)) + require.NoError(t, os.WriteFile(tmpMixed2, []byte(textOptionsMixed), 0644)) + + helper.CopyToProvisioningPath(t, tmpMixed1, "mixed-move-1.json") // UID: mixedmove1 + helper.CopyToProvisioningPath(t, tmpMixed2, "mixed-move-2.json") // UID: mixedmove2 + + helper.SyncAndWait(t, refRepo, nil) + + // Create move job that combines both paths and resource references + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(refRepo). + SubResource("jobs"). + Body(asJSON(&provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + TargetPath: "mixed-target/", + Paths: []string{"mixed-move-1.json"}, // Move by path + Resources: []provisioning.ResourceRef{ + { + Name: "mixedmove2", // Move by resource reference + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + })). + SetHeader("Content-Type", "application/json"). + Do(ctx) + require.NoError(t, result.Error(), "should be able to create mixed move job") + + // Wait for job to complete + helper.AwaitJobs(t, refRepo) + + // Verify both targeted resources are moved in repository + _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "mixed-target", "mixed-move-1.json") + require.NoError(t, err, "file moved by path should exist at new location") + + _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "mixed-target", "mixed-move-2.json") + require.NoError(t, err, "file moved by resource ref should exist at new location") + + // Verify files are deleted from original locations + _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "mixed-move-1.json") + require.Error(t, err, "file moved by path should be deleted from original location") + + _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "mixed-move-2.json") + require.Error(t, err, "file moved by resource ref should be deleted from original location") + + // Verify dashboards still exist in Grafana after sync + helper.SyncAndWait(t, refRepo, nil) + _, err = helper.DashboardsV1.Resource.Get(ctx, "mixedmove1", metav1.GetOptions{}) + require.NoError(t, err, "dashboard moved by path should still exist in Grafana") + + _, err = helper.DashboardsV1.Resource.Get(ctx, "mixedmove2", metav1.GetOptions{}) + require.NoError(t, err, "dashboard moved by resource ref should still exist in Grafana") + }) + }) +} + func TestIntegrationProvisioning_MoveResources(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") @@ -1255,4 +2044,66 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) { require.Error(t, result.Error(), "should fail when source file doesn't exist") }) }) + + t.Run("move non-existent resource by reference", func(t *testing.T) { + // Create move job for non-existent resource + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Body(asJSON(&provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + TargetPath: "moved-nonexistent/", + Resources: []provisioning.ResourceRef{ + { + Name: "non-existent-move-uid", + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + })). + SetHeader("Content-Type", "application/json"). + Do(ctx) + require.NoError(t, result.Error(), "should be able to create move job") + + // Wait for job to complete - should record error but continue + require.EventuallyWithT(t, func(collect *assert.CollectT) { + list := &unstructured.UnstructuredList{} + err := helper.AdminREST.Get(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Do(ctx).Into(list) + assert.NoError(collect, err, "should be able to list jobs") + assert.NotEmpty(collect, list.Items, "expect at least one job") + + // Find the most recent move job + var moveJob *unstructured.Unstructured + for _, elem := range list.Items { + assert.Equal(collect, repo, elem.GetLabels()["provisioning.grafana.app/repository"], "should have repo label") + + action := mustNestedString(elem.Object, "spec", "action") + if action == "move" { + // Get the most recent one (they should be ordered by creation time) + moveJob = &elem + } + } + if !assert.NotNil(collect, moveJob, "should find a move job") { + return + } + + state := mustNestedString(moveJob.Object, "status", "state") + // The job should complete but record errors for individual resource resolution failures + if state == "error" || state == "completed" || state == "success" { + // Any of these states is acceptable - the key is that resource resolution errors are recorded + // and don't fail the entire job due to error-tolerant implementation + return + } + assert.Fail(collect, "job should complete or error, but got state: %s", state) + }, time.Second*10, time.Millisecond*100, "Expected move job to handle non-existent resource") + }) } diff --git a/pkg/tsdb/grafana-postgresql-datasource/pgxtlsmanager.go b/pkg/tsdb/grafana-postgresql-datasource/pgxtlsmanager.go new file mode 100644 index 00000000000..89b43263aec --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/pgxtlsmanager.go @@ -0,0 +1,151 @@ +package postgres + +import ( + "fmt" + "os" + + "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource/sqleng" +) + +var validateCertFuncPgx = validateCertFilePathsPgx + +type pgxTlsManager struct { + logger log.Logger +} + +func newPgxTlsManager(logger log.Logger) *pgxTlsManager { + return &pgxTlsManager{ + logger: logger, + } +} + +// getTLSSettings retrieves TLS settings and handles certificate file creation if needed. +func (m *pgxTlsManager) getTLSSettings(dsInfo sqleng.DataSourceInfo) (tlsSettings, error) { + tlsConfig := tlsSettings{ + Mode: dsInfo.JsonData.Mode, + } + + if tlsConfig.Mode == "disable" { + m.logger.Debug("Postgres TLS/SSL is disabled") + return tlsConfig, nil + } + + tlsConfig.ConfigurationMethod = dsInfo.JsonData.ConfigurationMethod + tlsConfig.RootCertFile = dsInfo.JsonData.RootCertFile + tlsConfig.CertFile = dsInfo.JsonData.CertFile + tlsConfig.CertKeyFile = dsInfo.JsonData.CertKeyFile + + if tlsConfig.ConfigurationMethod == "file-content" { + if err := m.createCertFiles(dsInfo, &tlsConfig); err != nil { + return tlsConfig, fmt.Errorf("failed to create TLS certificate files: %w", err) + } + } else { + if err := validateCertFuncPgx(tlsConfig.RootCertFile, tlsConfig.CertFile, tlsConfig.CertKeyFile); err != nil { + return tlsConfig, fmt.Errorf("invalid TLS certificate file paths: %w", err) + } + } + + return tlsConfig, nil +} + +// createCertFiles writes certificate files to temporary locations. +func (m *pgxTlsManager) createCertFiles(dsInfo sqleng.DataSourceInfo, tlsConfig *tlsSettings) error { + m.logger.Debug("Writing TLS certificate files to temporary locations") + + var err error + if tlsConfig.RootCertFile, err = m.writeCertFile("root-*.crt", dsInfo.DecryptedSecureJSONData["tlsCACert"]); err != nil { + return err + } + if tlsConfig.CertFile, err = m.writeCertFile("client-*.crt", dsInfo.DecryptedSecureJSONData["tlsClientCert"]); err != nil { + return err + } + if tlsConfig.CertKeyFile, err = m.writeCertFile("client-*.key", dsInfo.DecryptedSecureJSONData["tlsClientKey"]); err != nil { + return err + } + + return nil +} + +// writeCertFile writes a single certificate file to a temporary location. +func (m *pgxTlsManager) writeCertFile(pattern, content string) (string, error) { + if content == "" { + return "", nil + } + + m.logger.Debug("Writing certificate file", "pattern", pattern) + file, err := os.CreateTemp("", pattern) + if err != nil { + return "", fmt.Errorf("failed to create temporary file: %w", err) + } + defer func() { + if err := file.Close(); err != nil { + m.logger.Error("Failed to close file", "error", err) + } + }() + + if _, err := file.WriteString(content); err != nil { + return "", fmt.Errorf("failed to write to temporary file: %w", err) + } + + return file.Name(), nil +} + +// cleanupCertFiles removes temporary certificate files. +func (m *pgxTlsManager) cleanupCertFiles(tlsConfig tlsSettings) { + // Only clean up if the configuration method is "file-content" + if tlsConfig.ConfigurationMethod != "file-content" { + m.logger.Debug("Skipping cleanup of TLS certificate files") + return + } + m.logger.Debug("Cleaning up TLS certificate files") + + files := []struct { + path string + name string + }{ + {tlsConfig.RootCertFile, "root certificate"}, + {tlsConfig.CertFile, "client certificate"}, + {tlsConfig.CertKeyFile, "client key"}, + } + + for _, file := range files { + if file.path == "" { + continue + } + if err := os.Remove(file.path); err != nil { + m.logger.Error("Failed to remove file", "type", file.name, "path", file.path, "error", err) + } else { + m.logger.Debug("Successfully removed file", "type", file.name, "path", file.path) + } + } +} + +// validateCertFilePaths validates the existence of configured certificate file paths. +func validateCertFilePathsPgx(rootCert, clientCert, clientKey string) error { + for _, path := range []string{rootCert, clientCert, clientKey} { + if path == "" { + continue + } + exists, err := fileExistsPgx(path) + if err != nil { + return fmt.Errorf("error checking file existence: %w", err) + } + if !exists { + return sqleng.ErrCertFileNotExist + } + } + return nil +} + +// fileExists checks if a file exists at the given path. +func fileExistsPgx(path string) (bool, error) { + _, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return true, nil +} diff --git a/pkg/tsdb/grafana-postgresql-datasource/pgxtlsmanager_test.go b/pkg/tsdb/grafana-postgresql-datasource/pgxtlsmanager_test.go new file mode 100644 index 00000000000..4717469348f --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/pgxtlsmanager_test.go @@ -0,0 +1,245 @@ +package postgres + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource/sqleng" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test getTLSSettings. +func TestPgxGetTLSSettings(t *testing.T) { + mockValidateCertFilePathsPgx() + t.Cleanup(resetValidateCertFilePathsPgx) + + updatedTime := time.Now() + + testCases := []struct { + desc string + expErr string + jsonData sqleng.JsonData + secureJSONData map[string]string + uid string + tlsSettings tlsSettings + updated time.Time + }{ + { + desc: "Custom TLS authentication disabled", + updated: updatedTime, + jsonData: sqleng.JsonData{ + Mode: "disable", + RootCertFile: "i/am/coding/ca.crt", + CertFile: "i/am/coding/client.crt", + CertKeyFile: "i/am/coding/client.key", + ConfigurationMethod: "file-path", + }, + tlsSettings: tlsSettings{Mode: "disable"}, + }, + { + desc: "Custom TLS authentication with file path", + updated: updatedTime.Add(time.Minute), + jsonData: sqleng.JsonData{ + Mode: "verify-full", + ConfigurationMethod: "file-path", + RootCertFile: "i/am/coding/ca.crt", + CertFile: "i/am/coding/client.crt", + CertKeyFile: "i/am/coding/client.key", + }, + tlsSettings: tlsSettings{ + Mode: "verify-full", + ConfigurationMethod: "file-path", + RootCertFile: "i/am/coding/ca.crt", + CertFile: "i/am/coding/client.crt", + CertKeyFile: "i/am/coding/client.key", + }, + }, + } + for _, tt := range testCases { + t.Run(tt.desc, func(t *testing.T) { + var settings tlsSettings + var err error + mng := pgxTlsManager{ + logger: backend.NewLoggerWith("logger", "tsdb.postgres"), + } + + ds := sqleng.DataSourceInfo{ + JsonData: tt.jsonData, + DecryptedSecureJSONData: tt.secureJSONData, + UID: tt.uid, + Updated: tt.updated, + } + + settings, err = mng.getTLSSettings(ds) + + if tt.expErr == "" { + require.NoError(t, err, tt.desc) + assert.Equal(t, tt.tlsSettings, settings) + } else { + require.Error(t, err, tt.desc) + assert.True(t, strings.HasPrefix(err.Error(), tt.expErr), + fmt.Sprintf("%s: %q doesn't start with %q", tt.desc, err, tt.expErr)) + } + }) + } +} + +func mockValidateCertFilePathsPgx() { + validateCertFuncPgx = func(rootCert, clientCert, clientKey string) error { + return nil + } +} + +func resetValidateCertFilePathsPgx() { + validateCertFuncPgx = validateCertFilePathsPgx +} + +func TestTLSManager_GetTLSSettings(t *testing.T) { + logger := log.New() + tlsManager := newPgxTlsManager(logger) + + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: "require", + ConfigurationMethod: "file-content", + }, + DecryptedSecureJSONData: map[string]string{ + "tlsCACert": "root-cert-content", + "tlsClientCert": "client-cert-content", + "tlsClientKey": "client-key-content", + }, + } + + tlsConfig, err := tlsManager.getTLSSettings(dsInfo) + require.NoError(t, err) + assert.Equal(t, "require", tlsConfig.Mode) + assert.NotEmpty(t, tlsConfig.RootCertFile) + assert.NotEmpty(t, tlsConfig.CertFile) + assert.NotEmpty(t, tlsConfig.CertKeyFile) + + // Cleanup temporary files + tlsManager.cleanupCertFiles(tlsConfig) + assert.NoFileExists(t, tlsConfig.RootCertFile) + assert.NoFileExists(t, tlsConfig.CertFile) + assert.NoFileExists(t, tlsConfig.CertKeyFile) +} + +func TestTLSManager_CleanupCertFiles_FilePath(t *testing.T) { + logger := log.New() + tlsManager := newPgxTlsManager(logger) + + // Create temporary files for testing + rootCertFile, err := tlsManager.writeCertFile("root-*.crt", "root-cert-content") + require.NoError(t, err) + clientCertFile, err := tlsManager.writeCertFile("client-*.crt", "client-cert-content") + require.NoError(t, err) + clientKeyFile, err := tlsManager.writeCertFile("client-*.key", "client-key-content") + require.NoError(t, err) + + // Simulate a configuration where the method is "file-path" + tlsConfig := tlsSettings{ + ConfigurationMethod: "file-path", + RootCertFile: rootCertFile, + CertFile: clientCertFile, + CertKeyFile: clientKeyFile, + } + + // Call cleanupCertFiles + tlsManager.cleanupCertFiles(tlsConfig) + + // Verify the files are NOT deleted + assert.FileExists(t, rootCertFile, "Root certificate file should not be deleted") + assert.FileExists(t, clientCertFile, "Client certificate file should not be deleted") + assert.FileExists(t, clientKeyFile, "Client key file should not be deleted") + + // Cleanup the files manually + err = os.Remove(rootCertFile) + require.NoError(t, err) + err = os.Remove(clientCertFile) + require.NoError(t, err) + err = os.Remove(clientKeyFile) + require.NoError(t, err) +} + +func TestTLSManager_CreateCertFiles(t *testing.T) { + logger := log.New() + tlsManager := newPgxTlsManager(logger) + + dsInfo := sqleng.DataSourceInfo{ + DecryptedSecureJSONData: map[string]string{ + "tlsCACert": "root-cert-content", + "tlsClientCert": "client-cert-content", + "tlsClientKey": "client-key-content", + }, + } + + tlsConfig := tlsSettings{ + ConfigurationMethod: "file-content", + } + err := tlsManager.createCertFiles(dsInfo, &tlsConfig) + require.NoError(t, err) + + assert.FileExists(t, tlsConfig.RootCertFile) + assert.FileExists(t, tlsConfig.CertFile) + assert.FileExists(t, tlsConfig.CertKeyFile) + + // Cleanup temporary files + tlsManager.cleanupCertFiles(tlsConfig) + assert.NoFileExists(t, tlsConfig.RootCertFile) + assert.NoFileExists(t, tlsConfig.CertFile) + assert.NoFileExists(t, tlsConfig.CertKeyFile) +} + +func TestTLSManager_WriteCertFile(t *testing.T) { + logger := log.New() + tlsManager := newPgxTlsManager(logger) + + // Test writing a valid certificate file + filePath, err := tlsManager.writeCertFile("test-*.crt", "test-cert-content") + require.NoError(t, err) + assert.FileExists(t, filePath) + + content, err := os.ReadFile(filepath.Clean(filePath)) + require.NoError(t, err) + assert.Equal(t, "test-cert-content", string(content)) + + // Cleanup the file + err = os.Remove(filePath) + require.NoError(t, err) + assert.NoFileExists(t, filePath) +} + +func TestTLSManager_CleanupCertFiles(t *testing.T) { + logger := log.New() + tlsManager := newPgxTlsManager(logger) + + // Create temporary files for testing + rootCertFile, err := tlsManager.writeCertFile("root-*.crt", "root-cert-content") + require.NoError(t, err) + clientCertFile, err := tlsManager.writeCertFile("client-*.crt", "client-cert-content") + require.NoError(t, err) + clientKeyFile, err := tlsManager.writeCertFile("client-*.key", "client-key-content") + require.NoError(t, err) + + tlsConfig := tlsSettings{ + ConfigurationMethod: "file-content", + RootCertFile: rootCertFile, + CertFile: clientCertFile, + CertKeyFile: clientKeyFile, + } + + // Cleanup the files + tlsManager.cleanupCertFiles(tlsConfig) + + // Verify the files are deleted + assert.NoFileExists(t, rootCertFile) + assert.NoFileExists(t, clientCertFile) + assert.NoFileExists(t, clientKeyFile) +} diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres.go b/pkg/tsdb/grafana-postgresql-datasource/postgres.go index fde06c08172..a2ef32bbd76 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres.go @@ -15,27 +15,33 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" + "github.com/jackc/pgx/v5/pgxpool" "github.com/lib/pq" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource/sqleng" ) -func ProvideService(cfg *setting.Cfg) *Service { +func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles) *Service { logger := backend.NewLoggerWith("logger", "tsdb.postgres") s := &Service{ - tlsManager: newTLSManager(logger, cfg.DataPath), - logger: logger, + tlsManager: newTLSManager(logger, cfg.DataPath), + pgxTlsManager: newPgxTlsManager(logger), + logger: logger, + features: features, } s.im = datasource.NewInstanceManager(s.newInstanceSettings()) return s } type Service struct { - tlsManager tlsSettingsProvider - im instancemgmt.InstanceManager - logger log.Logger + tlsManager tlsSettingsProvider + pgxTlsManager *pgxTlsManager + im instancemgmt.InstanceManager + logger log.Logger + features featuremgmt.FeatureToggles } func (s *Service) getDSInfo(ctx context.Context, pluginCtx backend.PluginContext) (*sqleng.DataSourceHandler, error) { @@ -52,6 +58,11 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) if err != nil { return nil, err } + + if s.features.IsEnabled(ctx, featuremgmt.FlagPostgresDSUsePGX) { + return dsInfo.QueryDataPGX(ctx, req) + } + return dsInfo.QueryData(ctx, req) } @@ -104,6 +115,62 @@ func newPostgres(ctx context.Context, userFacingDefaultError string, rowLimit in return db, handler, nil } +func newPostgresPGX(ctx context.Context, userFacingDefaultError string, rowLimit int64, dsInfo sqleng.DataSourceInfo, cnnstr string, logger log.Logger, settings backend.DataSourceInstanceSettings) (*pgxpool.Pool, *sqleng.DataSourceHandler, error) { + pgxConf, err := pgxpool.ParseConfig(cnnstr) + if err != nil { + logger.Error("postgres config creation failed", "error", err) + return nil, nil, fmt.Errorf("postgres config creation failed") + } + + proxyClient, err := settings.ProxyClient(ctx) + if err != nil { + logger.Error("postgres proxy creation failed", "error", err) + return nil, nil, fmt.Errorf("postgres proxy creation failed") + } + + if proxyClient.SecureSocksProxyEnabled() { + dialer, err := proxyClient.NewSecureSocksProxyContextDialer() + if err != nil { + logger.Error("postgres proxy creation failed", "error", err) + return nil, nil, fmt.Errorf("postgres proxy creation failed") + } + + pgxConf.ConnConfig.DialFunc = newPgxDialFunc(dialer) + } + + // by default pgx resolves hostnames to ip addresses. we must avoid this. + // (certain socks-proxy related functionality relies on the hostname being preserved) + pgxConf.ConnConfig.LookupFunc = func(_ context.Context, host string) ([]string, error) { + return []string{host}, nil + } + + config := sqleng.DataPluginConfiguration{ + DSInfo: dsInfo, + MetricColumnTypes: []string{"unknown", "text", "varchar", "char", "bpchar"}, + RowLimit: rowLimit, + } + + queryResultTransformer := postgresQueryResultTransformer{} + pgxConf.MaxConnLifetime = time.Duration(config.DSInfo.JsonData.ConnMaxLifetime) * time.Second + pgxConf.MaxConns = int32(config.DSInfo.JsonData.MaxOpenConns) + + p, err := pgxpool.NewWithConfig(ctx, pgxConf) + if err != nil { + logger.Error("Failed connecting to Postgres", "err", err) + return nil, nil, err + } + + handler, err := sqleng.NewQueryDataHandlerPGX(userFacingDefaultError, p, config, &queryResultTransformer, newPostgresMacroEngine(dsInfo.JsonData.Timescaledb), + logger) + if err != nil { + logger.Error("Failed connecting to Postgres", "err", err) + return nil, nil, err + } + + logger.Debug("Successfully connected to Postgres") + return p, handler, nil +} + func (s *Service) newInstanceSettings() datasource.InstanceFactoryFunc { logger := s.logger return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { @@ -143,21 +210,45 @@ func (s *Service) newInstanceSettings() datasource.InstanceFactoryFunc { DecryptedSecureJSONData: settings.DecryptedSecureJSONData, } - cnnstr, err := s.generateConnectionString(dsInfo) - if err != nil { - return nil, err - } + isPGX := s.features.IsEnabled(ctx, featuremgmt.FlagPostgresDSUsePGX) userFacingDefaultError, err := cfg.UserFacingDefaultError() if err != nil { return nil, err } - _, handler, err := newPostgres(ctx, userFacingDefaultError, sqlCfg.RowLimit, dsInfo, cnnstr, logger, settings) + var handler instancemgmt.Instance + if isPGX { + pgxTlsSettings, err := s.pgxTlsManager.getTLSSettings(dsInfo) + if err != nil { + return "", err + } - if err != nil { - logger.Error("Failed connecting to Postgres", "err", err) - return nil, err + // Ensure cleanupCertFiles is called after the connection is opened + defer s.pgxTlsManager.cleanupCertFiles(pgxTlsSettings) + cnnstr, err := s.generateConnectionString(dsInfo, pgxTlsSettings, isPGX) + if err != nil { + return "", err + } + _, handler, err = newPostgresPGX(ctx, userFacingDefaultError, sqlCfg.RowLimit, dsInfo, cnnstr, logger, settings) + if err != nil { + logger.Error("Failed connecting to Postgres", "err", err) + return nil, err + } + } else { + tlsSettings, err := s.tlsManager.getTLSSettings(dsInfo) + if err != nil { + return "", err + } + cnnstr, err := s.generateConnectionString(dsInfo, tlsSettings, isPGX) + if err != nil { + return nil, err + } + _, handler, err = newPostgres(ctx, userFacingDefaultError, sqlCfg.RowLimit, dsInfo, cnnstr, logger, settings) + if err != nil { + logger.Error("Failed connecting to Postgres", "err", err) + return nil, err + } } logger.Debug("Successfully connected to Postgres") @@ -170,65 +261,100 @@ func escape(input string) string { return strings.ReplaceAll(strings.ReplaceAll(input, `\`, `\\`), "'", `\'`) } -func (s *Service) generateConnectionString(dsInfo sqleng.DataSourceInfo) (string, error) { - logger := s.logger - var host string - var port int +type connectionParams struct { + host string + port int + user string + password string + database string +} + +func parseConnectionParams(dsInfo sqleng.DataSourceInfo, logger log.Logger) (connectionParams, error) { + var params connectionParams + var err error + if strings.HasPrefix(dsInfo.URL, "/") { - host = dsInfo.URL + params.host = dsInfo.URL logger.Debug("Generating connection string with Unix socket specifier", "address", dsInfo.URL) } else { - index := strings.LastIndex(dsInfo.URL, ":") - v6Index := strings.Index(dsInfo.URL, "]") - sp := strings.SplitN(dsInfo.URL, ":", 2) - host = sp[0] - if v6Index == -1 { - if len(sp) > 1 { - var err error - port, err = strconv.Atoi(sp[1]) - if err != nil { - logger.Debug("Error parsing the IPv4 address", "address", dsInfo.URL) - return "", sqleng.ErrParsingPostgresURL - } - logger.Debug("Generating IPv4 connection string with network host/port pair", "host", host, "port", port, "address", dsInfo.URL) - } else { - logger.Debug("Generating IPv4 connection string with network host", "host", host, "address", dsInfo.URL) - } - } else { - if index == v6Index+1 { - host = dsInfo.URL[1 : index-1] - var err error - port, err = strconv.Atoi(dsInfo.URL[index+1:]) - if err != nil { - logger.Debug("Error parsing the IPv6 address", "address", dsInfo.URL) - return "", sqleng.ErrParsingPostgresURL - } - logger.Debug("Generating IPv6 connection string with network host/port pair", "host", host, "port", port, "address", dsInfo.URL) - } else { - host = dsInfo.URL[1 : len(dsInfo.URL)-1] - logger.Debug("Generating IPv6 connection string with network host", "host", host, "address", dsInfo.URL) - } + params.host, params.port, err = parseNetworkAddress(dsInfo.URL, logger) + if err != nil { + return connectionParams{}, err } } - connStr := fmt.Sprintf("user='%s' password='%s' host='%s' dbname='%s'", - escape(dsInfo.User), escape(dsInfo.DecryptedSecureJSONData["password"]), escape(host), escape(dsInfo.Database)) - if port > 0 { - connStr += fmt.Sprintf(" port=%d", port) + params.user = dsInfo.User + params.password = dsInfo.DecryptedSecureJSONData["password"] + params.database = dsInfo.Database + + return params, nil +} + +func parseNetworkAddress(url string, logger log.Logger) (string, int, error) { + index := strings.LastIndex(url, ":") + v6Index := strings.Index(url, "]") + sp := strings.SplitN(url, ":", 2) + host := sp[0] + port := 0 + + if v6Index == -1 { + if len(sp) > 1 { + var err error + port, err = strconv.Atoi(sp[1]) + if err != nil { + logger.Debug("Error parsing the IPv4 address", "address", url) + return "", 0, sqleng.ErrParsingPostgresURL + } + logger.Debug("Generating IPv4 connection string with network host/port pair", "host", host, "port", port, "address", url) + } else { + logger.Debug("Generating IPv4 connection string with network host", "host", host, "address", url) + } + } else { + if index == v6Index+1 { + host = url[1 : index-1] + var err error + port, err = strconv.Atoi(url[index+1:]) + if err != nil { + logger.Debug("Error parsing the IPv6 address", "address", url) + return "", 0, sqleng.ErrParsingPostgresURL + } + logger.Debug("Generating IPv6 connection string with network host/port pair", "host", host, "port", port, "address", url) + } else { + host = url[1 : len(url)-1] + logger.Debug("Generating IPv6 connection string with network host", "host", host, "address", url) + } } - tlsSettings, err := s.tlsManager.getTLSSettings(dsInfo) + return host, port, nil +} + +func buildBaseConnectionString(params connectionParams) string { + connStr := fmt.Sprintf("user='%s' password='%s' host='%s' dbname='%s'", + escape(params.user), escape(params.password), escape(params.host), escape(params.database)) + if params.port > 0 { + connStr += fmt.Sprintf(" port=%d", params.port) + } + return connStr +} + +func (s *Service) generateConnectionString(dsInfo sqleng.DataSourceInfo, tlsSettings tlsSettings, isPGX bool) (string, error) { + logger := s.logger + + params, err := parseConnectionParams(dsInfo, logger) if err != nil { return "", err } + connStr := buildBaseConnectionString(params) + connStr += fmt.Sprintf(" sslmode='%s'", escape(tlsSettings.Mode)) // there is an issue with the lib/pq module, the `verify-ca` tls mode // does not work correctly. ( see https://github.com/lib/pq/issues/1106 ) // to workaround the problem, if the `verify-ca` mode is chosen, // we disable sslsni. - if tlsSettings.Mode == "verify-ca" { + if tlsSettings.Mode == "verify-ca" && !isPGX { + logger.Debug("Disabling sslsni for verify-ca mode") connStr += " sslsni=0" } @@ -262,7 +388,7 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque if err != nil { return sqleng.ErrToHealthCheckResult(err) } - return dsHandler.CheckHealth(ctx, req) + return dsHandler.CheckHealth(ctx, req, s.features) } func (t *postgresQueryResultTransformer) GetConverterList() []sqlutil.StringConverter { diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_snapshot_test.go b/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_snapshot_test.go new file mode 100644 index 00000000000..5ff81b615a0 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_snapshot_test.go @@ -0,0 +1,195 @@ +package postgres + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/grafana/grafana-plugin-sdk-go/experimental" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource/sqleng" +) + +// These tests require a real postgres database: +// - make devenv sources=postgres_tests +// - either set the env variable GRAFANA_TEST_DB = postgres +// - or set `forceRun := true` below +// +// The tests require a PostgreSQL db named grafanadstest and a user/password grafanatest/grafanatest! +// Use the docker/blocks/postgres_tests/docker-compose.yaml to spin up a +// preconfigured Postgres server suitable for running these tests. +func TestIntegrationPostgresPGXSnapshots(t *testing.T) { + // the logic in this function is copied from postgres_tests.go + shouldRunTest := func() bool { + if testing.Short() { + return false + } + + testDbName, present := os.LookupEnv("GRAFANA_TEST_DB") + + if present && testDbName == "postgres" { + return true + } + + return false + } + + if !shouldRunTest() { + t.Skip() + } + + getCnnStr := func() string { + host := os.Getenv("POSTGRES_HOST") + if host == "" { + host = "localhost" + } + port := os.Getenv("POSTGRES_PORT") + if port == "" { + port = "5432" + } + + return fmt.Sprintf("user=grafanatest password=grafanatest host=%s port=%s dbname=grafanadstest sslmode=disable", + host, port) + } + + sqlQueryCommentRe := regexp.MustCompile(`^-- (.+)\n`) + + readSqlFile := func(path string) (string, string) { + // the file-path is not coming from the outside, + // it is hardcoded in this file. + //nolint:gosec + sqlBytes, err := os.ReadFile(path) + require.NoError(t, err) + + sql := string(sqlBytes) + + // first line of the file contains the sql query to run, commented out + match := sqlQueryCommentRe.FindStringSubmatch(sql) + require.Len(t, match, 2) + + rawSQL := strings.TrimSpace(match[1]) + + return rawSQL, sql + } + + makeQuery := func(rawSQL string, format string) backend.QueryDataRequest { + queryData := map[string]string{ + "rawSql": rawSQL, + "format": format, + } + + queryBytes, err := json.Marshal(queryData) + require.NoError(t, err) + + return backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: queryBytes, + RefID: "A", + TimeRange: backend.TimeRange{ + From: time.Date(2023, 12, 24, 14, 15, 22, 123456, time.UTC), + To: time.Date(2023, 12, 24, 14, 45, 13, 876543, time.UTC), + }, + }, + }, + } + } + + tt := []struct { + name string + format string + }{ + {format: "time_series", name: "simple"}, + {format: "time_series", name: "no_rows_long"}, + {format: "time_series", name: "no_rows_wide"}, + {format: "time_series", name: "7x_compat_metric_label"}, + {format: "time_series", name: "convert_to_float64"}, + {format: "time_series", name: "convert_to_float64_not"}, + {format: "time_series", name: "fill_null"}, + {format: "time_series", name: "fill_previous"}, + {format: "time_series", name: "fill_value"}, + {format: "time_series", name: "fill_value_wide"}, + {format: "table", name: "simple"}, + {format: "table", name: "multi_stat1"}, + {format: "table", name: "multi_stat2"}, + {format: "table", name: "no_rows"}, + {format: "table", name: "types_numeric"}, + {format: "table", name: "types_char"}, + {format: "table", name: "types_datetime_pgx"}, + {format: "table", name: "types_other"}, + {format: "table", name: "timestamp_convert_bigint"}, + {format: "table", name: "timestamp_convert_integer"}, + {format: "table", name: "timestamp_convert_real"}, + {format: "table", name: "timestamp_convert_double"}, + {format: "table", name: "time_group_compat_case1"}, + {format: "table", name: "time_group_compat_case2"}, + } + + for _, test := range tt { + require.True(t, test.format == "table" || test.format == "time_series") + t.Run(test.name, func(t *testing.T) { + origInterpolate := sqleng.Interpolate + t.Cleanup(func() { + sqleng.Interpolate = origInterpolate + }) + + sqleng.Interpolate = func(query backend.DataQuery, timeRange backend.TimeRange, timeInterval string, sql string) string { + return sql + } + + jsonData := sqleng.JsonData{ + MaxOpenConns: 10, + MaxIdleConns: 2, + ConnMaxLifetime: 14400, + Timescaledb: false, + Mode: "disable", + ConfigurationMethod: "file-path", + } + + dsInfo := sqleng.DataSourceInfo{ + JsonData: jsonData, + DecryptedSecureJSONData: map[string]string{}, + } + + logger := log.New() + + cnnstr := getCnnStr() + + p, handler, err := newPostgresPGX(context.Background(), "error", 10000, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{}) + + t.Cleanup((func() { + _, err := p.Exec(context.Background(), "DROP TABLE tbl") + require.NoError(t, err) + p.Close() + })) + + require.NoError(t, err) + + sqlFilePath := filepath.Join("testdata", test.format, test.name+".sql") + goldenFileName := filepath.Join(test.format, test.name+".golden") + + rawSQL, sql := readSqlFile(sqlFilePath) + + _, err = p.Exec(context.Background(), sql) + require.NoError(t, err) + + query := makeQuery(rawSQL, test.format) + + result, err := handler.QueryDataPGX(context.Background(), &query) + require.Len(t, result.Responses, 1) + response, found := result.Responses["A"] + require.True(t, found) + require.NoError(t, err) + experimental.CheckGoldenJSONResponse(t, "testdata", goldenFileName, &response, updateGoldenFiles) + }) + } +} diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_test.go b/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_test.go new file mode 100644 index 00000000000..3e7263d1de2 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_test.go @@ -0,0 +1,1382 @@ +package postgres + +import ( + "context" + "fmt" + "math/rand" + "strings" + "testing" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource/sqleng" + + _ "github.com/lib/pq" +) + +// Test generateConnectionString. +func TestIntegrationGenerateConnectionStringPGX(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + testCases := []struct { + desc string + host string + user string + password string + database string + tlsSettings tlsSettings + expConnStr string + expErr string + uid string + }{ + { + desc: "Unix socket host", + host: "/var/run/postgresql", + user: "user", + password: "password", + database: "database", + tlsSettings: tlsSettings{Mode: "verify-full"}, + expConnStr: "user='user' password='password' host='/var/run/postgresql' dbname='database' sslmode='verify-full'", + }, + { + desc: "TCP host", + host: "host", + user: "user", + password: "password", + database: "database", + tlsSettings: tlsSettings{Mode: "verify-full"}, + expConnStr: "user='user' password='password' host='host' dbname='database' sslmode='verify-full'", + }, + { + desc: "verify-ca automatically adds disable-sni", + host: "host:1234", + user: "user", + password: "password", + database: "database", + tlsSettings: tlsSettings{Mode: "verify-ca"}, + expConnStr: "user='user' password='password' host='host' dbname='database' port=1234 sslmode='verify-ca' sslsni=0", + }, + { + desc: "TCP/port host", + host: "host:1234", + user: "user", + password: "password", + database: "database", + tlsSettings: tlsSettings{Mode: "verify-full"}, + expConnStr: "user='user' password='password' host='host' dbname='database' port=1234 sslmode='verify-full'", + }, + { + desc: "Ipv6 host", + host: "[::1]", + user: "user", + password: "password", + database: "database", + tlsSettings: tlsSettings{Mode: "verify-full"}, + expConnStr: "user='user' password='password' host='::1' dbname='database' sslmode='verify-full'", + }, + { + desc: "Ipv6/port host", + host: "[::1]:1234", + user: "user", + password: "password", + database: "database", + tlsSettings: tlsSettings{Mode: "verify-full"}, + expConnStr: "user='user' password='password' host='::1' dbname='database' port=1234 sslmode='verify-full'", + }, + { + desc: "Invalid port", + host: "host:invalid", + user: "user", + database: "database", + tlsSettings: tlsSettings{}, + expErr: "error parsing postgres url", + }, + { + desc: "Password with single quote and backslash", + host: "host", + user: "user", + password: `p'\assword`, + database: "database", + tlsSettings: tlsSettings{Mode: "verify-full"}, + expConnStr: `user='user' password='p\'\\assword' host='host' dbname='database' sslmode='verify-full'`, + }, + { + desc: "User/DB with single quote and backslash", + host: "host", + user: `u'\ser`, + password: `password`, + database: `d'\atabase`, + tlsSettings: tlsSettings{Mode: "verify-full"}, + expConnStr: `user='u\'\\ser' password='password' host='host' dbname='d\'\\atabase' sslmode='verify-full'`, + }, + { + desc: "Custom TLS mode disabled", + host: "host", + user: "user", + password: "password", + database: "database", + tlsSettings: tlsSettings{Mode: "disable"}, + expConnStr: "user='user' password='password' host='host' dbname='database' sslmode='disable'", + }, + { + desc: "Custom TLS mode verify-full with certificate files", + host: "host", + user: "user", + password: "password", + database: "database", + tlsSettings: tlsSettings{ + Mode: "verify-full", + RootCertFile: "i/am/coding/ca.crt", + CertFile: "i/am/coding/client.crt", + CertKeyFile: "i/am/coding/client.key", + }, + expConnStr: "user='user' password='password' host='host' dbname='database' sslmode='verify-full' " + + "sslrootcert='i/am/coding/ca.crt' sslcert='i/am/coding/client.crt' sslkey='i/am/coding/client.key'", + }, + } + for _, tt := range testCases { + t.Run(tt.desc, func(t *testing.T) { + svc := Service{ + logger: backend.NewLoggerWith("logger", "tsdb.postgres"), + } + + ds := sqleng.DataSourceInfo{ + URL: tt.host, + User: tt.user, + DecryptedSecureJSONData: map[string]string{"password": tt.password}, + Database: tt.database, + UID: tt.uid, + } + + connStr, err := svc.generateConnectionString(ds, tt.tlsSettings, false) + + if tt.expErr == "" { + require.NoError(t, err, tt.desc) + assert.Equal(t, tt.expConnStr, connStr) + } else { + require.Error(t, err, tt.desc) + assert.True(t, strings.HasPrefix(err.Error(), tt.expErr), + fmt.Sprintf("%s: %q doesn't start with %q", tt.desc, err, tt.expErr)) + } + }) + } +} + +// To run this test, set runPostgresTests=true +// Or from the commandline: GRAFANA_TEST_DB=postgres go test -tags=integration -v ./pkg/tsdb/grafana-postgresql-datasource +// The tests require a PostgreSQL db named grafanadstest and a user/password grafanatest/grafanatest! +// Use the docker/blocks/postgres_tests/docker-compose.yaml to spin up a +// preconfigured Postgres server suitable for running these tests. +// There is also a datasource and dashboard provisioned by devenv scripts that you can +// use to verify that the generated data are visualized as expected, see +// devenv/README.md for setup instructions. +func TestIntegrationPostgresPGX(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + // change to true to run the PostgreSQL tests + const runPostgresTests = false + + if !isTestDbPostgres() && !runPostgresTests { + t.Skip() + } + + origInterpolate := sqleng.Interpolate + t.Cleanup(func() { + sqleng.Interpolate = origInterpolate + }) + sqleng.Interpolate = func(query backend.DataQuery, timeRange backend.TimeRange, timeInterval string, sql string) string { + return sql + } + + jsonData := sqleng.JsonData{ + MaxOpenConns: 10, + MaxIdleConns: 2, + ConnMaxLifetime: 14400, + Timescaledb: false, + Mode: "disable", + ConfigurationMethod: "file-path", + } + + dsInfo := sqleng.DataSourceInfo{ + JsonData: jsonData, + DecryptedSecureJSONData: map[string]string{}, + } + + logger := backend.NewLoggerWith("logger", "postgres.test") + + cnnstr := postgresTestDBConnString() + + p, exe, err := newPostgresPGX(context.Background(), "error", 10000, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{}) + + require.NoError(t, err) + + fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local) + + t.Run("Given a table with different native data types", func(t *testing.T) { + sql := ` + DROP TABLE IF EXISTS postgres_types; + CREATE TABLE postgres_types( + c00_smallint smallint, + c01_integer integer, + c02_bigint bigint, + + c03_real real, + c04_double double precision, + c05_decimal decimal(10,2), + c06_numeric numeric(10,2), + + c07_char char(10), + c08_varchar varchar(10), + c09_text text, + + c10_timestamp timestamp without time zone, + c11_timestamptz timestamp with time zone, + c12_date date, + c13_time time without time zone, + c14_timetz time with time zone, + time date, + c15_interval interval, + c16_smallint smallint + ); + ` + _, err := p.Exec(context.Background(), sql) + require.NoError(t, err) + + sql = ` + INSERT INTO postgres_types VALUES( + 1,2,3, + 4.5,6.7,1.1,1.2, + 'char10','varchar10','text', + + now(),now(),now(),now(),now(),now(),'15m'::interval, + null + ); + ` + _, err = p.Exec(context.Background(), sql) + require.NoError(t, err) + + t.Run("When doing a table query should map Postgres column types to Go types", func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT * FROM postgres_types", + "format": "table" + }`), + RefID: "A", + }, + }, + } + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Len(t, frames, 1) + require.Len(t, frames[0].Fields, 18) + + require.Equal(t, int16(1), *frames[0].Fields[0].At(0).(*int16)) + require.Equal(t, int32(2), *frames[0].Fields[1].At(0).(*int32)) + require.Equal(t, int64(3), *frames[0].Fields[2].At(0).(*int64)) + + require.Equal(t, float64(4.5), *frames[0].Fields[3].At(0).(*float64)) + require.Equal(t, float64(6.7), *frames[0].Fields[4].At(0).(*float64)) + require.Equal(t, float64(1.1), *frames[0].Fields[5].At(0).(*float64)) + require.Equal(t, float64(1.2), *frames[0].Fields[6].At(0).(*float64)) + + require.Equal(t, "char10 ", *frames[0].Fields[7].At(0).(*string)) + require.Equal(t, "varchar10", *frames[0].Fields[8].At(0).(*string)) + require.Equal(t, "text", *frames[0].Fields[9].At(0).(*string)) + + _, ok := frames[0].Fields[10].At(0).(*time.Time) + require.True(t, ok) + _, ok = frames[0].Fields[11].At(0).(*time.Time) + require.True(t, ok) + _, ok = frames[0].Fields[12].At(0).(*time.Time) + require.True(t, ok) + _, ok = frames[0].Fields[13].At(0).(*string) + require.True(t, ok) + _, ok = frames[0].Fields[14].At(0).(*string) + require.True(t, ok) + _, ok = frames[0].Fields[15].At(0).(*time.Time) + require.True(t, ok) + require.Equal(t, "00:15:00", *frames[0].Fields[16].At(0).(*string)) + require.Nil(t, frames[0].Fields[17].At(0)) + }) + }) + + t.Run("Given a table with metrics that lacks data for some series ", func(t *testing.T) { + sql := ` + DROP TABLE IF EXISTS metric; + CREATE TABLE metric ( + time timestamp, + value integer + ) + ` + + _, err := p.Exec(context.Background(), sql) + require.NoError(t, err) + + type metric struct { + Time time.Time + Value int64 + } + + series := []*metric{} + firstRange := genTimeRangeByInterval(fromStart, 10*time.Minute, 10*time.Second) + secondRange := genTimeRangeByInterval(fromStart.Add(20*time.Minute), 10*time.Minute, 10*time.Second) + + for _, t := range firstRange { + series = append(series, &metric{ + Time: t, + Value: 15, + }) + } + + for _, t := range secondRange { + series = append(series, &metric{ + Time: t, + Value: 20, + }) + } + + for _, m := range series { + _, err := p.Exec(context.Background(), `INSERT INTO metric ("time", value) VALUES ($1, $2)`, m.Time.UTC(), m.Value) + require.NoError(t, err) + } + + t.Run("When doing a metric query using timeGroup", func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT $__timeGroup(time, '5m') AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", + "format": "time_series" + }`), + RefID: "A", + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Len(t, frames, 1) + require.Equal(t, 4, frames[0].Fields[0].Len()) + + // without fill this should result in 4 buckets + + dt := fromStart + + for i := 0; i < 2; i++ { + aValue := *frames[0].Fields[1].At(i).(*float64) + aTime := *frames[0].Fields[0].At(i).(*time.Time) + require.Equal(t, float64(15), aValue) + require.Equal(t, dt, aTime) + dt = dt.Add(5 * time.Minute) + } + + // adjust for 10 minute gap between first and second set of points + dt = dt.Add(10 * time.Minute) + for i := 2; i < 4; i++ { + aValue := *frames[0].Fields[1].At(i).(*float64) + aTime := *frames[0].Fields[0].At(i).(*time.Time) + require.Equal(t, float64(20), aValue) + require.Equal(t, dt, aTime) + dt = dt.Add(5 * time.Minute) + } + }) + + t.Run("When doing a metric query using timeGroup and $__interval", func(t *testing.T) { + mockInterpolate := sqleng.Interpolate + sqleng.Interpolate = origInterpolate + t.Cleanup(func() { + sqleng.Interpolate = mockInterpolate + }) + + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT $__timeGroup(time, $__interval) AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", + "format": "time_series" + }`), + RefID: "A", + Interval: time.Second * 60, + TimeRange: backend.TimeRange{ + From: fromStart, + To: fromStart.Add(30 * time.Minute), + }, + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + frames := queryResult.Frames + + require.NoError(t, queryResult.Error) + require.Equal(t, + "SELECT floor(extract(epoch from time)/60)*60 AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", + frames[0].Meta.ExecutedQueryString) + }) + + t.Run("When doing a metric query using timeGroup with NULL fill enabled", func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT $__timeGroup(time, '5m', NULL) AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", + "format": "time_series" + }`), + RefID: "A", + TimeRange: backend.TimeRange{ + From: fromStart, + To: fromStart.Add(34 * time.Minute), + }, + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Equal(t, 1, len(frames)) + require.Equal(t, 7, frames[0].Fields[0].Len()) + + dt := fromStart + + for i := 0; i < 2; i++ { + aValue := *frames[0].Fields[1].At(i).(*float64) + aTime := *frames[0].Fields[0].At(i).(*time.Time) + require.Equal(t, float64(15), aValue) + require.True(t, aTime.Equal(dt)) + dt = dt.Add(5 * time.Minute) + } + + // check for NULL values inserted by fill + require.Nil(t, frames[0].Fields[1].At(2)) + require.Nil(t, frames[0].Fields[1].At(3)) + + // adjust for 10 minute gap between first and second set of points + dt = dt.Add(10 * time.Minute) + for i := 4; i < 6; i++ { + aValue := *frames[0].Fields[1].At(i).(*float64) + aTime := *frames[0].Fields[0].At(i).(*time.Time) + require.Equal(t, float64(20), aValue) + require.True(t, aTime.Equal(dt)) + dt = dt.Add(5 * time.Minute) + } + + // check for NULL values inserted by fill + require.Nil(t, frames[0].Fields[1].At(6)) + }) + + t.Run("When doing a metric query using timeGroup with value fill enabled", func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT $__timeGroup(time, '5m', 1.5) AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", + "format": "time_series" + }`), + RefID: "A", + TimeRange: backend.TimeRange{ + From: fromStart, + To: fromStart.Add(34 * time.Minute), + }, + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Equal(t, 1, len(frames)) + require.Equal(t, 1.5, *frames[0].Fields[1].At(3).(*float64)) + }) + }) + + t.Run("Given a table with one data point", func(t *testing.T) { + type metric struct { + Time time.Time + Value int64 + } + + startTime := time.Now().UTC().Add(-time.Minute * 5) + series := []*metric{ + { + Time: startTime, + Value: 33, + }, + } + + for _, m := range series { + _, err := p.Exec(context.Background(), `INSERT INTO metric ("time", value) VALUES ($1, $2)`, m.Time.UTC(), m.Value) + require.NoError(t, err) + } + + t.Run("querying with time group with default value", func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "WITH data AS (SELECT now()-'3m'::interval AS ts, 42 AS n) SELECT $__timeGroup(ts, '1m', 0), n FROM data", + "format": "time_series" + }`), + RefID: "A", + TimeRange: backend.TimeRange{ + From: startTime, + To: startTime.Add(5 * time.Minute), + }, + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Equal(t, 1, len(frames)) + require.Equal(t, "Time", frames[0].Fields[0].Name) + require.Equal(t, "n", frames[0].Fields[1].Name) + require.Equal(t, float64(0), *frames[0].Fields[1].At(0).(*float64)) + require.Equal(t, float64(0), *frames[0].Fields[1].At(1).(*float64)) + require.Equal(t, float64(42), *frames[0].Fields[1].At(2).(*float64)) + require.Equal(t, float64(0), *frames[0].Fields[1].At(3).(*float64)) + require.Equal(t, float64(0), *frames[0].Fields[1].At(4).(*float64)) + require.Equal(t, float64(0), *frames[0].Fields[1].At(5).(*float64)) + }) + }) + + t.Run("When doing a metric query using timeGroup with previous fill enabled", func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT $__timeGroup(time, '5m', previous), avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", + "format": "time_series" + }`), + RefID: "A", + TimeRange: backend.TimeRange{ + From: fromStart, + To: fromStart.Add(34 * time.Minute), + }, + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Equal(t, 1, len(frames)) + require.Equal(t, float64(15.0), *frames[0].Fields[1].At(2).(*float64)) + require.Equal(t, float64(15.0), *frames[0].Fields[1].At(3).(*float64)) + require.Equal(t, float64(20.0), *frames[0].Fields[1].At(6).(*float64)) + }) + + t.Run("Given a table with metrics having multiple values and measurements", func(t *testing.T) { + type metric_values struct { + Time time.Time + TimeInt64 int64 + TimeInt64Nullable *int64 + TimeFloat64 float64 + TimeFloat64Nullable *float64 + TimeInt32 int32 + TimeInt32Nullable *int32 + TimeFloat32 float32 + TimeFloat32Nullable *float32 + Measurement string + ValueOne int64 + ValueTwo int64 + } + + _, err := p.Exec(context.Background(), "DROP TABLE IF EXISTS metric_values") + require.NoError(t, err) + + _, err = p.Exec(context.Background(), `CREATE TABLE metric_values ( + "time" TIMESTAMP NULL, + "timeInt64" BIGINT NOT NULL, "timeInt64Nullable" BIGINT NULL, + "timeFloat64" DOUBLE PRECISION NOT NULL, "timeFloat64Nullable" DOUBLE PRECISION NULL, + "timeInt32" INTEGER NOT NULL, "timeInt32Nullable" INTEGER NULL, + "timeFloat32" DOUBLE PRECISION NOT NULL, "timeFloat32Nullable" DOUBLE PRECISION NULL, + measurement VARCHAR(255) NULL, + "valueOne" INTEGER NULL, "valueTwo" INTEGER NULL + )`) + require.NoError(t, err) + + rng := rand.New(rand.NewSource(time.Now().Unix())) + rnd := func(min, max int64) int64 { + return rng.Int63n(max-min) + min + } + + var tInitial time.Time + + series := []*metric_values{} + for i, t := range genTimeRangeByInterval(fromStart.Add(-30*time.Minute), 90*time.Minute, 5*time.Minute) { + if i == 0 { + tInitial = t + } + tSeconds := t.Unix() + tSecondsInt32 := int32(tSeconds) + tSecondsFloat32 := float32(tSeconds) + tMilliseconds := tSeconds * 1e3 + tMillisecondsFloat := float64(tMilliseconds) + first := metric_values{ + Time: t, + TimeInt64: tMilliseconds, + TimeInt64Nullable: &(tMilliseconds), + TimeFloat64: tMillisecondsFloat, + TimeFloat64Nullable: &tMillisecondsFloat, + TimeInt32: tSecondsInt32, + TimeInt32Nullable: &tSecondsInt32, + TimeFloat32: tSecondsFloat32, + TimeFloat32Nullable: &tSecondsFloat32, + Measurement: "Metric A", + ValueOne: rnd(0, 100), + ValueTwo: rnd(0, 100), + } + second := first + second.Measurement = "Metric B" + second.ValueOne = rnd(0, 100) + second.ValueTwo = rnd(0, 100) + + series = append(series, &first) + series = append(series, &second) + } + + // _, err = session.InsertMulti(series) + for _, m := range series { + _, err := p.Exec(context.Background(), `INSERT INTO "metric_values" ( + time, + "timeInt64", "timeInt64Nullable", + "timeFloat64", "timeFloat64Nullable", + "timeInt32", "timeInt32Nullable", + "timeFloat32", "timeFloat32Nullable", + measurement, "valueOne", "valueTwo" + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, + m.Time, + m.TimeInt64, m.TimeInt64Nullable, + m.TimeFloat64, m.TimeFloat64Nullable, + m.TimeInt32, m.TimeInt32Nullable, + m.TimeFloat32, m.TimeFloat32Nullable, + m.Measurement, m.ValueOne, m.ValueTwo, + ) + require.NoError(t, err) + } + + t.Run( + "When doing a metric query using epoch (int64) as time column and value column (int64) should return metric with time in time.Time", + func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT \"timeInt64\" as time, \"timeInt64\" FROM metric_values ORDER BY time LIMIT 1", + "format": "time_series" + }`), + RefID: "A", + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Len(t, frames, 1) + require.True(t, tInitial.Equal(*frames[0].Fields[0].At(0).(*time.Time))) + }) + + t.Run("When doing a metric query using epoch (int64 nullable) as time column and value column (int64 nullable,) should return metric with time in time.Time", + func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT \"timeInt64Nullable\" as time, \"timeInt64Nullable\" FROM metric_values ORDER BY time LIMIT 1", + "format": "time_series" + }`), + RefID: "A", + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Len(t, frames, 1) + require.True(t, tInitial.Equal(*frames[0].Fields[0].At(0).(*time.Time))) + }) + + t.Run("When doing a metric query using epoch (float64) as time column and value column (float64), should return metric with time in time.Time", + func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT \"timeFloat64\" as time, \"timeFloat64\" FROM metric_values ORDER BY time LIMIT 1", + "format": "time_series" + }`), + RefID: "A", + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Len(t, frames, 1) + require.True(t, tInitial.Equal(*frames[0].Fields[0].At(0).(*time.Time))) + }) + + t.Run("When doing a metric query using epoch (float64 nullable) as time column and value column (float64 nullable), should return metric with time in time.Time", + func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT \"timeFloat64Nullable\" as time, \"timeFloat64Nullable\" FROM metric_values ORDER BY time LIMIT 1", + "format": "time_series" + }`), + RefID: "A", + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Equal(t, 1, len(frames)) + require.True(t, tInitial.Equal(*frames[0].Fields[0].At(0).(*time.Time))) + }) + + t.Run("When doing a metric query using epoch (int32) as time column and value column (int32), should return metric with time in time.Time", + func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT \"timeInt32\" as time, \"timeInt32\" FROM metric_values ORDER BY time LIMIT 1", + "format": "time_series" + }`), + RefID: "A", + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Equal(t, 1, len(frames)) + require.True(t, tInitial.Equal(*frames[0].Fields[0].At(0).(*time.Time))) + }) + + t.Run("When doing a metric query using epoch (int32 nullable) as time column and value column (int32 nullable), should return metric with time in time.Time", + func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT \"timeInt32Nullable\" as time, \"timeInt32Nullable\" FROM metric_values ORDER BY time LIMIT 1", + "format": "time_series" + }`), + RefID: "A", + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Equal(t, 1, len(frames)) + require.True(t, tInitial.Equal(*frames[0].Fields[0].At(0).(*time.Time))) + }) + + t.Run("When doing a metric query using epoch (float32) as time column and value column (float32), should return metric with time in time.Time", + func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT \"timeFloat32\" as time, \"timeFloat32\" FROM metric_values ORDER BY time LIMIT 1", + "format": "time_series" + }`), + RefID: "A", + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Equal(t, 1, len(frames)) + aTime := time.Unix(0, int64(float64(float32(tInitial.Unix()))*1e3)*int64(time.Millisecond)) + require.True(t, aTime.Equal(*frames[0].Fields[0].At(0).(*time.Time))) + }) + + t.Run("When doing a metric query using epoch (float32 nullable) as time column and value column (float32 nullable), should return metric with time in time.Time", + func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT \"timeFloat32Nullable\" as time, \"timeFloat32Nullable\" FROM metric_values ORDER BY time LIMIT 1", + "format": "time_series" + }`), + RefID: "A", + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Equal(t, 1, len(frames)) + aTime := time.Unix(0, int64(float64(float32(tInitial.Unix()))*1e3)*int64(time.Millisecond)) + require.True(t, aTime.Equal(*frames[0].Fields[0].At(0).(*time.Time))) + }) + + t.Run("When doing a metric query grouping by time and select metric column should return correct series", func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT $__timeEpoch(time), measurement || ' - value one' as metric, \"valueOne\" FROM metric_values ORDER BY 1", + "format": "time_series" + }`), + RefID: "A", + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Equal(t, 1, len(frames)) + require.Equal(t, 3, len(frames[0].Fields)) + require.Equal(t, "Metric A - value one", frames[0].Fields[1].Name) + require.Equal(t, "Metric B - value one", frames[0].Fields[2].Name) + }) + + t.Run("When doing a metric query with metric column and multiple value columns", func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT $__timeEpoch(time), measurement as metric, \"valueOne\", \"valueTwo\" FROM metric_values ORDER BY 1", + "format": "time_series" + }`), + RefID: "A", + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.NoError(t, err) + require.Equal(t, 1, len(frames)) + require.Equal(t, 5, len(frames[0].Fields)) + require.Equal(t, "valueOne", frames[0].Fields[1].Name) + require.Equal(t, data.Labels{"metric": "Metric A"}, frames[0].Fields[1].Labels) + require.Equal(t, "valueOne", frames[0].Fields[2].Name) + require.Equal(t, data.Labels{"metric": "Metric B"}, frames[0].Fields[2].Labels) + require.Equal(t, "valueTwo", frames[0].Fields[3].Name) + require.Equal(t, data.Labels{"metric": "Metric A"}, frames[0].Fields[3].Labels) + require.Equal(t, "valueTwo", frames[0].Fields[4].Name) + require.Equal(t, data.Labels{"metric": "Metric B"}, frames[0].Fields[4].Labels) + }) + + t.Run("When doing a metric query grouping by time should return correct series", func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT $__timeEpoch(time), \"valueOne\", \"valueTwo\" FROM metric_values ORDER BY 1", + "format": "time_series" + }`), + RefID: "A", + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Equal(t, 1, len(frames)) + require.Equal(t, 3, len(frames[0].Fields)) + require.Equal(t, "valueOne", frames[0].Fields[1].Name) + require.Equal(t, "valueTwo", frames[0].Fields[2].Name) + }) + + t.Run("When doing a query with timeFrom,timeTo,unixEpochFrom,unixEpochTo macros", func(t *testing.T) { + fakeInterpolate := sqleng.Interpolate + t.Cleanup(func() { + sqleng.Interpolate = fakeInterpolate + }) + sqleng.Interpolate = origInterpolate + + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT time FROM metric_values WHERE time > $__timeFrom() OR time < $__timeFrom() OR 1 < $__unixEpochFrom() OR $__unixEpochTo() > 1 ORDER BY 1", + "format": "time_series" + }`), + RefID: "A", + TimeRange: backend.TimeRange{ + From: fromStart.Add(-5 * time.Minute), + To: fromStart, + }, + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + frames := queryResult.Frames + require.Len(t, frames, 1) + require.Equal(t, + "SELECT time FROM metric_values WHERE time > '2018-03-15T12:55:00Z' OR time < '2018-03-15T12:55:00Z' OR 1 < 1521118500 OR 1521118800 > 1 ORDER BY 1", + frames[0].Meta.ExecutedQueryString) + }) + }) + + t.Run("Given a table with event data", func(t *testing.T) { + type event struct { + TimeSec int64 + Description string + Tags string + } + + _, err := p.Exec(context.Background(), "DROP TABLE IF EXISTS event") + require.NoError(t, err) + _, err = p.Exec(context.Background(), `CREATE TABLE event (time_sec BIGINT NULL, description VARCHAR(255) NULL, tags VARCHAR(255) NULL)`) + require.NoError(t, err) + + events := []*event{} + for _, t := range genTimeRangeByInterval(fromStart.Add(-20*time.Minute), time.Hour, 25*time.Minute) { + events = append(events, &event{ + TimeSec: t.Unix(), + Description: "Someone deployed something", + Tags: "deploy", + }) + events = append(events, &event{ + TimeSec: t.Add(5 * time.Minute).Unix(), + Description: "New support ticket registered", + Tags: "ticket", + }) + } + + for _, e := range events { + _, err := p.Exec(context.Background(), "INSERT INTO event (time_sec, description, tags) VALUES ($1, $2, $3)", e.TimeSec, e.Description, e.Tags) + require.NoError(t, err) + } + + t.Run("When doing an annotation query of deploy events should return expected result", func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT \"time_sec\" as time, description as text, tags FROM event WHERE $__unixEpochFilter(time_sec) AND tags='deploy' ORDER BY 1 ASC", + "format": "table" + }`), + RefID: "Deploys", + TimeRange: backend.TimeRange{ + From: fromStart.Add(-20 * time.Minute), + To: fromStart.Add(40 * time.Minute), + }, + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + + queryResult := resp.Responses["Deploys"] + + frames := queryResult.Frames + require.Len(t, frames, 1) + require.Len(t, frames[0].Fields, 3) + }) + + t.Run("When doing an annotation query of ticket events should return expected result", func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT \"time_sec\" as time, description as text, tags FROM event WHERE $__unixEpochFilter(time_sec) AND tags='ticket' ORDER BY 1 ASC", + "format": "table" + }`), + RefID: "Tickets", + TimeRange: backend.TimeRange{ + From: fromStart.Add(-20 * time.Minute), + To: fromStart.Add(40 * time.Minute), + }, + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + + queryResult := resp.Responses["Tickets"] + frames := queryResult.Frames + require.Equal(t, 1, len(frames)) + require.Equal(t, 3, len(frames[0].Fields)) + }) + + t.Run("When doing an annotation query with a time column in datetime format", func(t *testing.T) { + dt := time.Date(2018, 3, 14, 21, 20, 6, 527e6, time.UTC) + dtFormat := "2006-01-02 15:04:05.999999999" + + queryjson := fmt.Sprintf("{\"rawSql\": \"SELECT CAST('%s' AS TIMESTAMP) as time, 'message' as text, 'tag1,tag2' as tags\", \"format\": \"table\" }", dt.Format(dtFormat)) + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(queryjson), + RefID: "A", + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + frames := queryResult.Frames + require.Equal(t, 1, len(frames)) + require.Equal(t, 3, len(frames[0].Fields)) + + // Should be in time.Time + require.Equal(t, dt.Unix(), (*frames[0].Fields[0].At(0).(*time.Time)).Unix()) + }) + + t.Run("When doing an annotation query with a time column in epoch second format should return time.Time", func(t *testing.T) { + dt := time.Date(2018, 3, 14, 21, 20, 6, 527e6, time.UTC) + + queryjson := fmt.Sprintf("{\"rawSql\": \"SELECT %d as time, 'message' as text, 'tag1,tag2' as tags\", \"format\": \"table\"}", dt.Unix()) + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(queryjson), + RefID: "A", + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Equal(t, 1, len(frames)) + require.Equal(t, 3, len(frames[0].Fields)) + + // Should be in time.Time + require.Equal(t, dt.Unix(), (*frames[0].Fields[0].At(0).(*time.Time)).Unix()) + }) + + t.Run("When doing an annotation query with a time column in epoch second format (t *testing.Tint) should return time.Time", func(t *testing.T) { + dt := time.Date(2018, 3, 14, 21, 20, 6, 527e6, time.UTC) + queryjson := fmt.Sprintf("{\"rawSql\": \"SELECT cast(%d as bigint) as time, 'message' as text, 'tag1,tag2' as tags\", \"format\": \"table\"}", dt.Unix()) + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(queryjson), + RefID: "A", + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Equal(t, 1, len(frames)) + require.Equal(t, 3, len(frames[0].Fields)) + + // Should be in time.Time + require.Equal(t, dt.Unix(), (*frames[0].Fields[0].At(0).(*time.Time)).Unix()) + }) + + t.Run("When doing an annotation query with a time column in epoch millisecond format should return time.Time", func(t *testing.T) { + dt := time.Date(2018, 3, 14, 21, 20, 6, 527e6, time.UTC) + + queryjson := fmt.Sprintf("{\"rawSql\":\"SELECT %d as time, 'message' as text, 'tag1,tag2' as tags\", \"format\": \"table\"}", dt.Unix()*1000) + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(queryjson), + RefID: "A", + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Equal(t, 1, len(frames)) + require.Equal(t, 3, len(frames[0].Fields)) + + // Should be in time.Time + require.Equal(t, dt.Unix(), (*frames[0].Fields[0].At(0).(*time.Time)).Unix()) + }) + + t.Run("When doing an annotation query with a time column holding a bigint null value should return nil", func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT cast(null as bigint) as time, 'message' as text, 'tag1,tag2' as tags", + "format": "table" + }`), + RefID: "A", + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Equal(t, 1, len(frames)) + require.Equal(t, 3, len(frames[0].Fields)) + + // Should be in time.Time + require.Nil(t, frames[0].Fields[0].At(0)) + }) + + t.Run("When doing an annotation query with a time column holding a timestamp null value should return nil", func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT cast(null as timestamp) as time, 'message' as text, 'tag1,tag2' as tags", + "format": "table" + }`), + RefID: "A", + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Equal(t, 1, len(frames)) + require.Equal(t, 3, len(frames[0].Fields)) + + // Should be in time.Time + assert.Nil(t, frames[0].Fields[0].At(0)) + }) + + t.Run("When doing an annotation query with a time and timeend column should return two fields of type time", func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT 1631053772276 as time, 1631054012276 as timeend, '' as text, '' as tags", + "format": "table" + }`), + RefID: "A", + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Equal(t, 1, len(frames)) + require.Equal(t, 4, len(frames[0].Fields)) + + require.Equal(t, data.FieldTypeNullableTime, frames[0].Fields[0].Type()) + require.Equal(t, data.FieldTypeNullableTime, frames[0].Fields[1].Type()) + }) + + t.Run("When row limit set to 1", func(t *testing.T) { + jsonData := sqleng.JsonData{ + MaxOpenConns: 10, + MaxIdleConns: 2, + ConnMaxLifetime: 14400, + Timescaledb: false, + Mode: "disable", + ConfigurationMethod: "file-path", + } + + dsInfo := sqleng.DataSourceInfo{ + JsonData: jsonData, + DecryptedSecureJSONData: map[string]string{}, + } + _, handler, err := newPostgresPGX(context.Background(), "error", 1, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{}) + + require.NoError(t, err) + + t.Run("When doing a table query that returns 2 rows should limit the result to 1 row", func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT 1 as value UNION ALL select 2 as value", + "format": "table" + }`), + RefID: "A", + TimeRange: backend.TimeRange{ + From: time.Now(), + To: time.Now(), + }, + }, + }, + } + + resp, err := handler.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + frames := queryResult.Frames + require.NoError(t, err) + require.Equal(t, 1, len(frames)) + require.Equal(t, 1, len(frames[0].Fields)) + require.Equal(t, 1, frames[0].Rows()) + require.Len(t, frames[0].Meta.Notices, 1) + require.Equal(t, data.NoticeSeverityWarning, frames[0].Meta.Notices[0].Severity) + }) + + t.Run("When doing a time series query that returns 2 rows should limit the result to 1 row", func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT 1 as time, 1 as value UNION ALL select 2 as time, 2 as value", + "format": "time_series" + }`), + RefID: "A", + TimeRange: backend.TimeRange{ + From: time.Now(), + To: time.Now(), + }, + }, + }, + } + + resp, err := handler.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + frames := queryResult.Frames + require.NoError(t, err) + require.Equal(t, 1, len(frames)) + require.Equal(t, 2, len(frames[0].Fields)) + require.Equal(t, 1, frames[0].Rows()) + require.Len(t, frames[0].Meta.Notices, 1) + require.Equal(t, data.NoticeSeverityWarning, frames[0].Meta.Notices[0].Severity) + }) + }) + }) + + t.Run("Given an empty table", func(t *testing.T) { + _, err := p.Exec(context.Background(), "DROP TABLE IF EXISTS empty_obj") + require.NoError(t, err) + _, err = p.Exec(context.Background(), "CREATE TABLE empty_obj (empty_key VARCHAR(255) NULL, empty_val BIGINT NULL)") + require.NoError(t, err) + + t.Run("When no rows are returned, should return an empty frame", func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT empty_key, empty_val FROM empty_obj", + "format": "table" + }`), + RefID: "A", + TimeRange: backend.TimeRange{ + From: time.Now(), + To: time.Now().Add(1 * time.Minute), + }, + }, + }, + } + + resp, err := exe.QueryDataPGX(context.Background(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + + frames := queryResult.Frames + require.Len(t, frames, 1) + require.Equal(t, 0, frames[0].Rows()) + require.NotNil(t, frames[0].Fields) + require.Empty(t, frames[0].Fields) + }) + }) +} diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go b/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go index 47eee7b1a6b..2197270cb98 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go @@ -159,7 +159,7 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { UID: tt.uid, } - connStr, err := svc.generateConnectionString(ds) + connStr, err := svc.generateConnectionString(ds, tt.tlsSettings, false) if tt.expErr == "" { require.NoError(t, err, tt.desc) diff --git a/pkg/tsdb/grafana-postgresql-datasource/proxy.go b/pkg/tsdb/grafana-postgresql-datasource/proxy.go index d06d8b68152..dca66df38e9 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/proxy.go +++ b/pkg/tsdb/grafana-postgresql-datasource/proxy.go @@ -33,3 +33,11 @@ func (p *postgresProxyDialer) DialTimeout(network, address string, timeout time. return p.d.(proxy.ContextDialer).DialContext(ctx, network, address) } + +type PgxDialFunc = func(ctx context.Context, network string, address string) (net.Conn, error) + +func newPgxDialFunc(dialer proxy.Dialer) PgxDialFunc { + return func(ctx context.Context, network string, addr string) (net.Conn, error) { + return dialer.Dial(network, addr) + } +} diff --git a/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth.go b/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth.go index 25b4ee0fdf8..99d36aaaa85 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth.go +++ b/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth.go @@ -10,11 +10,17 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/lib/pq" ) -func (e *DataSourceHandler) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { - err := e.Ping() +func (e *DataSourceHandler) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest, features featuremgmt.FeatureToggles) (*backend.CheckHealthResult, error) { + var err error + if features.IsEnabled(ctx, featuremgmt.FlagPostgresDSUsePGX) { + err = e.PingPGX(ctx) + } else { + err = e.Ping() + } if err != nil { logCheckHealthError(ctx, e.dsInfo, err) if strings.EqualFold(req.PluginContext.User.Role, "Admin") { @@ -63,6 +69,7 @@ func ErrToHealthCheckResult(err error) (*backend.CheckHealthResult, error) { res.Message += fmt.Sprintf(". Error message: %s", errMessage) } } + if errors.Is(err, pq.ErrSSLNotSupported) { res.Message = "SSL error: Failed to connect to the server" } @@ -125,10 +132,10 @@ func logCheckHealthError(ctx context.Context, dsInfo DataSourceInfo, err error) "config_tls_client_cert_length": len(dsInfo.DecryptedSecureJSONData["tlsClientCert"]), "config_tls_client_key_length": len(dsInfo.DecryptedSecureJSONData["tlsClientKey"]), } - configSummaryJson, marshalError := json.Marshal(configSummary) + configSummaryJSON, marshalError := json.Marshal(configSummary) if marshalError != nil { logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error") return } - logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error_detailed", "details", string(configSummaryJson)) + logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error_detailed", "details", string(configSummaryJSON)) } diff --git a/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine.go b/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine.go index 7712fe7c3d5..8f354497689 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine.go +++ b/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" + "github.com/jackc/pgx/v5/pgxpool" ) // MetaKeyExecutedQueryString is the key where the executed query should get stored @@ -88,6 +89,7 @@ type DataSourceHandler struct { dsInfo DataSourceInfo rowLimit int64 userError string + pool *pgxpool.Pool } type QueryJson struct { @@ -489,6 +491,7 @@ type dataQueryModel struct { Interval time.Duration columnNames []string columnTypes []*sql.ColumnType + columnTypesPGX []string timeIndex int timeEndIndex int metricIndex int diff --git a/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine_pgx.go b/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine_pgx.go new file mode 100644 index 00000000000..df182a3119f --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine_pgx.go @@ -0,0 +1,554 @@ +package sqleng + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "runtime/debug" + "strings" + "sync" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +func NewQueryDataHandlerPGX(userFacingDefaultError string, p *pgxpool.Pool, config DataPluginConfiguration, queryResultTransformer SqlQueryResultTransformer, + macroEngine SQLMacroEngine, log log.Logger) (*DataSourceHandler, error) { + queryDataHandler := DataSourceHandler{ + queryResultTransformer: queryResultTransformer, + macroEngine: macroEngine, + timeColumnNames: []string{"time"}, + log: log, + dsInfo: config.DSInfo, + rowLimit: config.RowLimit, + userError: userFacingDefaultError, + } + + if len(config.TimeColumnNames) > 0 { + queryDataHandler.timeColumnNames = config.TimeColumnNames + } + + if len(config.MetricColumnTypes) > 0 { + queryDataHandler.metricColumnTypes = config.MetricColumnTypes + } + + queryDataHandler.pool = p + return &queryDataHandler, nil +} + +func (e *DataSourceHandler) DisposePGX() { + e.log.Debug("Disposing DB...") + + if e.pool != nil { + e.pool.Close() + } + + e.log.Debug("DB disposed") +} + +func (e *DataSourceHandler) PingPGX(ctx context.Context) error { + return e.pool.Ping(ctx) +} + +func (e *DataSourceHandler) QueryDataPGX(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + result := backend.NewQueryDataResponse() + ch := make(chan DBDataResponse, len(req.Queries)) + var wg sync.WaitGroup + // Execute each query in a goroutine and wait for them to finish afterwards + for _, query := range req.Queries { + queryjson := QueryJson{ + Fill: false, + Format: "time_series", + } + err := json.Unmarshal(query.JSON, &queryjson) + if err != nil { + return nil, fmt.Errorf("error unmarshal query json: %w", err) + } + + // the fill-params are only stored inside this function, during query-interpolation. we do not support + // sending them in "from the outside" + if queryjson.Fill || queryjson.FillInterval != 0.0 || queryjson.FillMode != "" || queryjson.FillValue != 0.0 { + return nil, fmt.Errorf("query fill-parameters not supported") + } + + if queryjson.RawSql == "" { + continue + } + + wg.Add(1) + go e.executeQueryPGX(ctx, query, &wg, ch, queryjson) + } + + wg.Wait() + + // Read results from channels + close(ch) + result.Responses = make(map[string]backend.DataResponse) + for queryResult := range ch { + result.Responses[queryResult.refID] = queryResult.dataResponse + } + + return result, nil +} + +func (e *DataSourceHandler) handleQueryError(frameErr string, err error, query string, source backend.ErrorSource, ch chan DBDataResponse, queryResult DBDataResponse) { + var emptyFrame data.Frame + emptyFrame.SetMeta(&data.FrameMeta{ExecutedQueryString: query}) + if backend.IsDownstreamError(err) { + source = backend.ErrorSourceDownstream + } + queryResult.dataResponse.Error = fmt.Errorf("%s: %w", frameErr, err) + queryResult.dataResponse.ErrorSource = source + queryResult.dataResponse.Frames = data.Frames{&emptyFrame} + ch <- queryResult +} + +func (e *DataSourceHandler) handlePanic(logger log.Logger, queryResult *DBDataResponse, ch chan DBDataResponse) { + if r := recover(); r != nil { + logger.Error("ExecuteQuery panic", "error", r, "stack", string(debug.Stack())) + if theErr, ok := r.(error); ok { + queryResult.dataResponse.Error = theErr + queryResult.dataResponse.ErrorSource = backend.ErrorSourcePlugin + } else if theErrString, ok := r.(string); ok { + queryResult.dataResponse.Error = errors.New(theErrString) + queryResult.dataResponse.ErrorSource = backend.ErrorSourcePlugin + } else { + queryResult.dataResponse.Error = fmt.Errorf("unexpected error - %s", e.userError) + queryResult.dataResponse.ErrorSource = backend.ErrorSourceDownstream + } + ch <- *queryResult + } +} + +func (e *DataSourceHandler) execQuery(ctx context.Context, query string, logger log.Logger) ([]*pgconn.Result, error) { + c, err := e.pool.Acquire(ctx) + if err != nil { + return nil, fmt.Errorf("failed to acquire connection: %w", err) + } + defer c.Release() + + mrr := c.Conn().PgConn().Exec(ctx, query) + defer func() { + if err := mrr.Close(); err != nil { + logger.Warn("Failed to close multi-result reader", "error", err) + } + }() + return mrr.ReadAll() +} + +func (e *DataSourceHandler) executeQueryPGX(queryContext context.Context, query backend.DataQuery, wg *sync.WaitGroup, + ch chan DBDataResponse, queryJSON QueryJson) { + defer wg.Done() + queryResult := DBDataResponse{ + dataResponse: backend.DataResponse{}, + refID: query.RefID, + } + + logger := e.log.FromContext(queryContext) + defer e.handlePanic(logger, &queryResult, ch) + + if queryJSON.RawSql == "" { + panic("Query model property rawSql should not be empty at this point") + } + + // global substitutions + interpolatedQuery := Interpolate(query, query.TimeRange, e.dsInfo.JsonData.TimeInterval, queryJSON.RawSql) + + // data source specific substitutions + interpolatedQuery, err := e.macroEngine.Interpolate(&query, query.TimeRange, interpolatedQuery) + if err != nil { + e.handleQueryError("interpolation failed", e.TransformQueryError(logger, err), interpolatedQuery, backend.ErrorSourcePlugin, ch, queryResult) + return + } + + results, err := e.execQuery(queryContext, interpolatedQuery, logger) + if err != nil { + e.handleQueryError("db query error", e.TransformQueryError(logger, err), interpolatedQuery, backend.ErrorSourcePlugin, ch, queryResult) + return + } + + qm, err := e.newProcessCfgPGX(queryContext, query, results, interpolatedQuery) + if err != nil { + e.handleQueryError("failed to get configurations", err, interpolatedQuery, backend.ErrorSourcePlugin, ch, queryResult) + return + } + + frame, err := convertResultsToFrame(results, e.rowLimit) + if err != nil { + e.handleQueryError("convert frame from rows error", err, interpolatedQuery, backend.ErrorSourcePlugin, ch, queryResult) + return + } + + e.processFrame(frame, qm, queryResult, ch, logger) +} + +func (e *DataSourceHandler) processFrame(frame *data.Frame, qm *dataQueryModel, queryResult DBDataResponse, ch chan DBDataResponse, logger log.Logger) { + if frame.Meta == nil { + frame.Meta = &data.FrameMeta{} + } + frame.Meta.ExecutedQueryString = qm.InterpolatedQuery + + // If no rows were returned, clear any previously set `Fields` with a single empty `data.Field` slice. + // Then assign `queryResult.dataResponse.Frames` the current single frame with that single empty Field. + // This assures 1) our visualization doesn't display unwanted empty fields, and also that 2) + // additionally-needed frame data stays intact and is correctly passed to our visulization. + if frame.Rows() == 0 { + frame.Fields = []*data.Field{} + queryResult.dataResponse.Frames = data.Frames{frame} + ch <- queryResult + return + } + + if err := convertSQLTimeColumnsToEpochMS(frame, qm); err != nil { + e.handleQueryError("converting time columns failed", err, qm.InterpolatedQuery, backend.ErrorSourcePlugin, ch, queryResult) + return + } + + if qm.Format == dataQueryFormatSeries { + // time series has to have time column + if qm.timeIndex == -1 { + e.handleQueryError("db has no time column", errors.New("time column is missing; make sure your data includes a time column for time series format or switch to a table format that doesn't require it"), qm.InterpolatedQuery, backend.ErrorSourceDownstream, ch, queryResult) + return + } + + // Make sure to name the time field 'Time' to be backward compatible with Grafana pre-v8. + frame.Fields[qm.timeIndex].Name = data.TimeSeriesTimeFieldName + + for i := range qm.columnNames { + if i == qm.timeIndex || i == qm.metricIndex { + continue + } + + if t := frame.Fields[i].Type(); t == data.FieldTypeString || t == data.FieldTypeNullableString { + continue + } + + var err error + if frame, err = convertSQLValueColumnToFloat(frame, i); err != nil { + e.handleQueryError("convert value to float failed", err, qm.InterpolatedQuery, backend.ErrorSourcePlugin, ch, queryResult) + return + } + } + + tsSchema := frame.TimeSeriesSchema() + if tsSchema.Type == data.TimeSeriesTypeLong { + var err error + originalData := frame + frame, err = data.LongToWide(frame, qm.FillMissing) + if err != nil { + e.handleQueryError("failed to convert long to wide series when converting from dataframe", err, qm.InterpolatedQuery, backend.ErrorSourcePlugin, ch, queryResult) + return + } + + // Before 8x, a special metric column was used to name time series. The LongToWide transforms that into a metric label on the value field. + // But that makes series name have both the value column name AND the metric name. So here we are removing the metric label here and moving it to the + // field name to get the same naming for the series as pre v8 + if len(originalData.Fields) == 3 { + for _, field := range frame.Fields { + if len(field.Labels) == 1 { // 7x only supported one label + name, ok := field.Labels["metric"] + if ok { + field.Name = name + field.Labels = nil + } + } + } + } + } + if qm.FillMissing != nil { + // we align the start-time + startUnixTime := qm.TimeRange.From.Unix() / int64(qm.Interval.Seconds()) * int64(qm.Interval.Seconds()) + alignedTimeRange := backend.TimeRange{ + From: time.Unix(startUnixTime, 0), + To: qm.TimeRange.To, + } + + var err error + frame, err = sqlutil.ResampleWideFrame(frame, qm.FillMissing, alignedTimeRange, qm.Interval) + if err != nil { + logger.Error("Failed to resample dataframe", "err", err) + frame.AppendNotices(data.Notice{Text: "Failed to resample dataframe", Severity: data.NoticeSeverityWarning}) + return + } + } + } + + queryResult.dataResponse.Frames = data.Frames{frame} + ch <- queryResult +} + +func (e *DataSourceHandler) newProcessCfgPGX(queryContext context.Context, query backend.DataQuery, + results []*pgconn.Result, interpolatedQuery string) (*dataQueryModel, error) { + columnNames := []string{} + columnTypesPGX := []string{} + + // The results will contain column information in the metadata + for _, result := range results { + // Get column names from the result metadata + for _, field := range result.FieldDescriptions { + columnNames = append(columnNames, field.Name) + pqtype, ok := pgtype.NewMap().TypeForOID(field.DataTypeOID) + if !ok { + // Handle special cases for field types + switch field.DataTypeOID { + case pgtype.TimetzOID: + columnTypesPGX = append(columnTypesPGX, "timetz") + case 790: + columnTypesPGX = append(columnTypesPGX, "money") + default: + return nil, fmt.Errorf("unknown data type oid: %d", field.DataTypeOID) + } + } else { + columnTypesPGX = append(columnTypesPGX, pqtype.Name) + } + } + } + + qm := &dataQueryModel{ + columnTypesPGX: columnTypesPGX, + columnNames: columnNames, + timeIndex: -1, + timeEndIndex: -1, + metricIndex: -1, + metricPrefix: false, + queryContext: queryContext, + } + + queryJSON := QueryJson{} + err := json.Unmarshal(query.JSON, &queryJSON) + if err != nil { + return nil, err + } + + if queryJSON.Fill { + qm.FillMissing = &data.FillMissing{} + qm.Interval = time.Duration(queryJSON.FillInterval * float64(time.Second)) + switch strings.ToLower(queryJSON.FillMode) { + case "null": + qm.FillMissing.Mode = data.FillModeNull + case "previous": + qm.FillMissing.Mode = data.FillModePrevious + case "value": + qm.FillMissing.Mode = data.FillModeValue + qm.FillMissing.Value = queryJSON.FillValue + default: + } + } + + qm.TimeRange.From = query.TimeRange.From.UTC() + qm.TimeRange.To = query.TimeRange.To.UTC() + + switch queryJSON.Format { + case "time_series": + qm.Format = dataQueryFormatSeries + case "table": + qm.Format = dataQueryFormatTable + default: + panic(fmt.Sprintf("Unrecognized query model format: %q", queryJSON.Format)) + } + + for i, col := range qm.columnNames { + for _, tc := range e.timeColumnNames { + if col == tc { + qm.timeIndex = i + break + } + } + + if qm.Format == dataQueryFormatTable && strings.EqualFold(col, "timeend") { + qm.timeEndIndex = i + continue + } + + switch col { + case "metric": + qm.metricIndex = i + default: + if qm.metricIndex == -1 { + columnType := qm.columnTypesPGX[i] + for _, mct := range e.metricColumnTypes { + if columnType == mct { + qm.metricIndex = i + continue + } + } + } + } + } + qm.InterpolatedQuery = interpolatedQuery + return qm, nil +} + +func convertResultsToFrame(results []*pgconn.Result, rowLimit int64) (*data.Frame, error) { + frame := data.Frame{} + m := pgtype.NewMap() + + for _, result := range results { + // Skip non-select statements + if !result.CommandTag.Select() { + continue + } + fields := make(data.Fields, len(result.FieldDescriptions)) + + fieldTypes, err := getFieldTypesFromDescriptions(result.FieldDescriptions, m) + if err != nil { + return nil, err + } + + for i, v := range result.FieldDescriptions { + fields[i] = data.NewFieldFromFieldType(fieldTypes[i], 0) + fields[i].Name = v.Name + } + // Create a new frame + frame = *data.NewFrame("", fields...) + } + + // Add rows to the frame + for _, result := range results { + // Skip non-select statements + if !result.CommandTag.Select() { + continue + } + fieldDescriptions := result.FieldDescriptions + for rowIdx := range result.Rows { + if rowIdx == int(rowLimit) { + frame.AppendNotices(data.Notice{ + Severity: data.NoticeSeverityWarning, + Text: fmt.Sprintf("Results have been limited to %v because the SQL row limit was reached", rowLimit), + }) + break + } + row := make([]interface{}, len(fieldDescriptions)) + for colIdx, fd := range fieldDescriptions { + rawValue := result.Rows[rowIdx][colIdx] + dataTypeOID := fd.DataTypeOID + format := fd.Format + + if rawValue == nil { + row[colIdx] = nil + continue + } + + // Convert based on type + switch fd.DataTypeOID { + case pgtype.Int2OID: + var d *int16 + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + row[colIdx] = d + case pgtype.Int4OID: + var d *int32 + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + row[colIdx] = d + case pgtype.Int8OID: + var d *int64 + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + row[colIdx] = d + case pgtype.NumericOID, pgtype.Float8OID, pgtype.Float4OID: + var d *float64 + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + row[colIdx] = d + case pgtype.BoolOID: + var d *bool + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + row[colIdx] = d + case pgtype.ByteaOID: + d, err := pgtype.ByteaCodec.DecodeValue(pgtype.ByteaCodec{}, m, dataTypeOID, format, rawValue) + if err != nil { + return nil, err + } + str := string(d.([]byte)) + row[colIdx] = &str + case pgtype.TimestampOID, pgtype.TimestamptzOID, pgtype.DateOID: + var d *time.Time + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + row[colIdx] = d + case pgtype.TimeOID, pgtype.TimetzOID: + var d *string + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + row[colIdx] = d + default: + var d *string + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + row[colIdx] = d + } + } + frame.AppendRow(row...) + } + } + + return &frame, nil +} + +func getFieldTypesFromDescriptions(fieldDescriptions []pgconn.FieldDescription, m *pgtype.Map) ([]data.FieldType, error) { + fieldTypes := make([]data.FieldType, len(fieldDescriptions)) + for i, v := range fieldDescriptions { + typeName, ok := m.TypeForOID(v.DataTypeOID) + if !ok { + // Handle special cases for field types + if v.DataTypeOID == pgtype.TimetzOID || v.DataTypeOID == 790 { + fieldTypes[i] = data.FieldTypeNullableString + } else { + return nil, fmt.Errorf("unknown data type oid: %d", v.DataTypeOID) + } + } else { + switch typeName.Name { + case "int2": + fieldTypes[i] = data.FieldTypeNullableInt16 + case "int4": + fieldTypes[i] = data.FieldTypeNullableInt32 + case "int8": + fieldTypes[i] = data.FieldTypeNullableInt64 + case "float4", "float8", "numeric": + fieldTypes[i] = data.FieldTypeNullableFloat64 + case "bool": + fieldTypes[i] = data.FieldTypeNullableBool + case "timestamptz", "timestamp", "date": + fieldTypes[i] = data.FieldTypeNullableTime + case "json", "jsonb": + fieldTypes[i] = data.FieldTypeNullableJSON + default: + fieldTypes[i] = data.FieldTypeNullableString + } + } + } + return fieldTypes, nil +} diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime_pgx.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime_pgx.golden.jsonc new file mode 100644 index 00000000000..5754a588b10 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime_pgx.golden.jsonc @@ -0,0 +1,214 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "typeVersion": [ +// 0, +// 0 +// ], +// "executedQueryString": "SELECT * FROM tbl" +// } +// Name: +// Dimensions: 12 Fields by 2 Rows +// +--------------------------------------+--------------------------------------+----------------------------------------+----------------------------------------+-------------------------------+-------------------------------+-----------------+-----------------+--------------------+--------------------+-----------------+-----------------+ +// | Name: ts | Name: tsnn | Name: tsz | Name: tsznn | Name: d | Name: dnn | Name: t | Name: tnn | Name: tz | Name: tznn | Name: i | Name: inn | +// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | +// | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*string | Type: []*string | Type: []*string | Type: []*string | Type: []*string | Type: []*string | +// +--------------------------------------+--------------------------------------+----------------------------------------+----------------------------------------+-------------------------------+-------------------------------+-----------------+-----------------+--------------------+--------------------+-----------------+-----------------+ +// | 2023-11-15 05:06:07.123456 +0000 UTC | 2023-11-15 05:06:08.123456 +0000 UTC | 2021-07-22 11:22:33.654321 +0000 +0000 | 2021-07-22 11:22:34.654321 +0000 +0000 | 2023-12-20 00:00:00 +0000 UTC | 2023-12-21 00:00:00 +0000 UTC | 12:34:56.234567 | 12:34:57.234567 | 23:12:36.765432+01 | 23:12:37.765432+01 | 00:00:00.987654 | 00:00:00.887654 | +// | null | 2023-11-15 05:06:09.123456 +0000 UTC | null | 2021-07-22 11:22:35.654321 +0000 +0000 | null | 2023-12-22 00:00:00 +0000 UTC | null | 12:34:58.234567 | null | 23:12:38.765432+01 | null | 00:00:00.787654 | +// +--------------------------------------+--------------------------------------+----------------------------------------+----------------------------------------+-------------------------------+-------------------------------+-----------------+-----------------+--------------------+--------------------+-----------------+-----------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "typeVersion": [ + 0, + 0 + ], + "executedQueryString": "SELECT * FROM tbl" + }, + "fields": [ + { + "name": "ts", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "tsnn", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "tsz", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "tsznn", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "d", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "dnn", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "t", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "tnn", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "tz", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "tznn", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "i", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "inn", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + 1700024767123, + null + ], + [ + 1700024768123, + 1700024769123 + ], + [ + 1626952953654, + null + ], + [ + 1626952954654, + 1626952955654 + ], + [ + 1703030400000, + null + ], + [ + 1703116800000, + 1703203200000 + ], + [ + "12:34:56.234567", + null + ], + [ + "12:34:57.234567", + "12:34:58.234567" + ], + [ + "23:12:36.765432+01", + null + ], + [ + "23:12:37.765432+01", + "23:12:38.765432+01" + ], + [ + "00:00:00.987654", + null + ], + [ + "00:00:00.887654", + "00:00:00.787654" + ] + ], + "nanos": [ + [ + 456000, + 0 + ], + [ + 456000, + 456000 + ], + [ + 321000, + 0 + ], + [ + 321000, + 321000 + ], + null, + null, + null, + null, + null, + null, + null, + null + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime_pgx.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime_pgx.sql new file mode 100644 index 00000000000..d81b3ca235b --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime_pgx.sql @@ -0,0 +1,44 @@ +-- SELECT * FROM tbl +-- test all date/time-based postgres data types +CREATE TEMPORARY TABLE tbl ( + ts timestamp, + tsnn timestamp NOT NULL, + tsz timestamp with time zone, + tsznn timestamp with time zone NOT NULL, + d date, + dnn date NOT NULL, + t time, + tnn time NOT NULL, + tz time with time zone, + tznn time with time zone NOT NULL, + i interval, + inn interval NOT NULL +); + +INSERT INTO tbl (ts, tsnn, tsz, tsznn, d, dnn, t, tnn, tz, tznn, i, inn) VALUES ( +'2023-11-15 05:06:07.123456', +'2023-11-15 05:06:08.123456', +'2021-07-22 13:22:33.654321 Europe/Berlin', +'2021-07-22 13:22:34.654321 Europe/Berlin', +'2023-12-20', +'2023-12-21', +'12:34:56.234567', +'12:34:57.234567', +'23:12:36.765432+1', +'23:12:37.765432+1', +'987654 microsecond', +'887654 microsecond' +), ( +NULL, +'2023-11-15 05:06:09.123456', +NULL, +'2021-07-22 13:22:35.654321 Europe/Berlin', +NULL, +'2023-12-22', +NULL, +'12:34:58.234567', +NULL, +'23:12:38.765432+1', +NULL, +'787654 microsecond' +); \ No newline at end of file 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/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/provisioning/v0alpha1/endpoints.gen.ts b/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts index 20a6c603f32..63f4e3af895 100644 --- a/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts +++ b/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts @@ -758,11 +758,21 @@ export type ObjectMeta = { Populated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ uid?: string; }; +export type ResourceRef = { + /** Group is the group of the resource, such as "dashboard.grafana.app". */ + group?: string; + /** Kind is the type of resource, for example, "Dashboard". */ + kind?: string; + /** Name is the name of the resource, such as a dashboard UID. */ + name?: string; +}; export type DeleteJobOptions = { /** Paths to be deleted. Examples: - dashboard.json (for a file) - a/b/c/other-dashboard.json (for a file) - nested/deep/ (for a directory) FIXME: we should validate this in admission hooks */ paths?: string[]; /** Ref to the branch or commit hash to delete from */ ref?: string; + /** Resources to delete This option has been created because currently the frontend does not use standarized app platform APIs. For performance and API consistency reasons, the preferred option is it to use the paths. */ + resources?: ResourceRef[]; }; export type MigrateJobOptions = { /** Preserve history (if possible) */ @@ -770,6 +780,16 @@ export type MigrateJobOptions = { /** Message to use when committing the changes in a single commit */ message?: string; }; +export type MoveJobOptions = { + /** Paths to be deleted. Examples: - dashboard.json (for a file) - a/b/c/other-dashboard.json (for a file) - nested/deep/ (for a directory) FIXME: we should validate this in admission hooks */ + paths?: string[]; + /** Ref to the branch or commit hash that should move */ + ref?: string; + /** Resources to move This option has been created because currently the frontend does not use standarized app platform APIs. For performance and API consistency reasons, the preferred option is it to use the paths. */ + resources?: ResourceRef[]; + /** Destination path for the move (e.g. "new-location/") */ + targetPath?: string; +}; export type PullRequestJobOptions = { /** The specific commit hash that triggered this notice */ hash?: string; @@ -792,21 +812,23 @@ export type ExportJobOptions = { /** Message to use when committing the changes in a single commit */ message?: string; /** FIXME: we should validate this in admission hooks Prefix in target file system */ - /** Prefix in target file system */ path?: string; }; export type JobSpec = { /** Possible enum values: - `"delete"` deletes files in the remote repository - `"migrate"` acts like JobActionExport, then JobActionPull. It also tries to preserve the history. + - `"move"` moves files in the remote repository - `"pr"` adds additional useful information to a PR, such as comments with preview links and rendered images. - `"pull"` replicates the remote branch in the local copy of the repository. - `"push"` replicates the local copy of the repository in the remote branch. */ - action?: 'delete' | 'migrate' | 'pr' | 'pull' | 'push'; + action?: 'delete' | 'migrate' | 'move' | 'pr' | 'pull' | 'push'; /** Delete when the action is `delete` */ delete?: DeleteJobOptions; /** Required when the action is `migrate` */ migrate?: MigrateJobOptions; + /** Move when the action is `move` */ + move?: MoveJobOptions; /** Pull request options */ pr?: PullRequestJobOptions; /** Required when the action is `pull` */ 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/Select/DashboardPicker.test.tsx b/public/app/core/components/Select/DashboardPicker.test.tsx index b46c1f9d2df..8fbe7599830 100644 --- a/public/app/core/components/Select/DashboardPicker.test.tsx +++ b/public/app/core/components/Select/DashboardPicker.test.tsx @@ -6,7 +6,7 @@ import { defaultDashboard as defaultDashboardData } from '@grafana/schema'; import { Spec as DashboardV2Spec, defaultSpec as defaultDashboardV2Spec, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { backendSrv } from 'app/core/services/backend_srv'; import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { DashboardSearchItemType } from 'app/features/search/types'; @@ -80,7 +80,7 @@ const mockDashboard: DashboardDTO = { }; const mockDashboardV2: DashboardWithAccessInfo = { - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', spec: { ...defaultDashboardV2Spec(), diff --git a/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx b/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx index e6dd4a486f8..77a6a75d650 100644 --- a/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx +++ b/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx @@ -11,8 +11,11 @@ import { NoAlertManagerWarning } from './NoAlertManagerWarning'; /** * This is the main alerting page wrapper, used by the alertmanager page wrapper and the alert rules list view + * + * NOTE: we're omitting "title" here because it's not actually rendering the title (it's the html attribute "title"). + * Use "renderTitle" instead for custom page titles. */ -type AlertingPageWrapperProps = Omit & { +type AlertingPageWrapperProps = Omit & { isLoading?: boolean; children?: ReactNode; }; 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/group-details/GroupEditPage.tsx b/public/app/features/alerting/unified/group-details/GroupEditPage.tsx index a68f230d8cb..7d34486d59f 100644 --- a/public/app/features/alerting/unified/group-details/GroupEditPage.tsx +++ b/public/app/features/alerting/unified/group-details/GroupEditPage.tsx @@ -99,7 +99,7 @@ function GroupEditPage() { if (!!dsFeatures && !dsFeatures.rulerConfig) { return ( - + This group belongs to a data source that does not support editing. @@ -124,12 +124,7 @@ function GroupEditPage() { }; return ( - + <> {Boolean(dsFeaturesError) && ( + diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.ts index 7cd831550d2..544b0db9975 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.ts @@ -1,4 +1,3 @@ -import uFuzzy from '@leeoniya/ufuzzy'; import { produce } from 'immer'; import { chain, compact, isEmpty } from 'lodash'; import { useCallback, useDeferredValue, useEffect, useMemo } from 'react'; @@ -13,22 +12,13 @@ import { RulesFilter, applySearchFilterToQuery, getSearchFilterFromQuery } from import { labelsMatchMatchers, matcherToMatcherField } from '../utils/alertmanager'; import { Annotation } from '../utils/constants'; import { isCloudRulesSource } from '../utils/datasource'; +import { fuzzyFilter } from '../utils/fuzzySearch'; import { parseMatcher, parsePromQLStyleMatcherLoose } from '../utils/matchers'; import { getRuleHealth, isPluginProvidedRule, isPromRuleType, prometheusRuleType, rulerRuleType } from '../utils/rules'; import { calculateGroupTotals, calculateRuleFilteredTotals, calculateRuleTotals } from './useCombinedRuleNamespaces'; import { useURLSearchParams } from './useURLSearchParams'; -// if the search term is longer than MAX_NEEDLE_SIZE we disable Levenshtein distance -const MAX_NEEDLE_SIZE = 25; -const INFO_THRESHOLD = Infinity; -const MAX_FUZZY_TERMS = 5; -// https://catonmat.net/my-favorite-regex :) -const REGEXP_NON_ASCII = /[^ -~]/m; -// https://www.asciitable.com/ -// matches only these: `~!@#$%^&*()_+-=[]\{}|;':",./<>? -const REGEXP_ONLY_SYMBOLS = /^[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]+$/m; - export function useRulesFilter() { const [queryParams, updateQueryParams] = useURLSearchParams(); const searchQuery = queryParams.get('search') ?? ''; @@ -311,42 +301,6 @@ function looseParseMatcher(matcherQuery: string): Matcher | undefined { } } -function fuzzyFilter(items: TItem[], filterBy: (item: TItem) => string, searchTerm: string) { - let filteredItems = items; - - // Options details can be found here https://github.com/leeoniya/uFuzzy#options - // The following configuration complies with Damerau-Levenshtein distance - // https://en.wikipedia.org/wiki/Damerau%E2%80%93Levenshtein_distance - const ufuzzy = new uFuzzy({ intraMode: 1 }); - const needleTermsCount = ufuzzy.split(searchTerm).length; - - // If the search term is very long or contains non-ascii characters or only special characters we don't use fuzzy search - // and need to fallback to simple string search - const fuzzySearchNotApplicable = - REGEXP_NON_ASCII.test(searchTerm) || - REGEXP_ONLY_SYMBOLS.test(searchTerm) || - searchTerm.length > MAX_NEEDLE_SIZE || - needleTermsCount > MAX_FUZZY_TERMS; - - if (fuzzySearchNotApplicable) { - return items.filter((item) => filterBy(item).toLowerCase().includes(searchTerm.toLowerCase())); - } - - const haystack = items.map(filterBy); - // apply an outOfOrder limit which helps to limit the number of permutations to search for - // and prevents the browser from hanging - const outOfOrderLimit = needleTermsCount < 5 ? 4 : 0; - - const [idxs, info, order] = ufuzzy.search(haystack, searchTerm, outOfOrderLimit, INFO_THRESHOLD); - if (info && order) { - filteredItems = order.map((idx) => filteredItems[info.idx[idx]]); - } else if (idxs) { - filteredItems = idxs.map((idx) => filteredItems[idx]); - } - - return filteredItems; -} - const isQueryingDataSource = (rulerRule: RulerGrafanaRuleDTO, filterState: RulesFilter): boolean => { if (!filterState.dataSourceNames?.length) { return true; diff --git a/public/app/features/alerting/unified/hooks/usePagination.ts b/public/app/features/alerting/unified/hooks/usePagination.ts index a57955fa5bb..d1d685c2df7 100644 --- a/public/app/features/alerting/unified/hooks/usePagination.ts +++ b/public/app/features/alerting/unified/hooks/usePagination.ts @@ -27,3 +27,21 @@ export function usePagination(items: T[], initialPage = 1, itemsPerPage: numb return { page, onPageChange, numberOfPages, pageItems, pageStart, pageEnd, nextPage, previousPage }; } + +export function useContinuousPagination(items: T[], itemsPerPage: number) { + const [pageIndex, setPageIndex] = useState(1); + + const hasMore = items.length > itemsPerPage * pageIndex; + const pageItems = items.slice(0, itemsPerPage * pageIndex); + + const loadMore = useCallback(() => { + setPageIndex((index) => index + 1); + }, []); + + return { + pageItems, + pageIndex, + loadMore, + hasMore, + }; +} diff --git a/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.tsx b/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.tsx index a9430c488e1..9842f651ec3 100644 --- a/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.tsx @@ -1,9 +1,11 @@ +import { css } from '@emotion/css'; import { skipToken } from '@reduxjs/toolkit/query'; import { useMemo } from 'react'; +import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; import { isFetchError } from '@grafana/runtime'; -import { Alert } from '@grafana/ui'; +import { Alert, useStyles2 } from '@grafana/ui'; import { DataSourceRuleGroupIdentifier } from 'app/types/unified-alerting'; import { PromRuleDTO, @@ -16,13 +18,15 @@ import { import { alertRuleApi } from '../api/alertRuleApi'; import { featureDiscoveryApi } from '../api/featureDiscoveryApi'; import { prometheusApi } from '../api/prometheusApi'; -import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants'; +import { useContinuousPagination } from '../hooks/usePagination'; +import { DEFAULT_PER_PAGE_PAGINATION_RULES_PER_GROUP, RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants'; import { hashRule } from '../utils/rule-id'; import { getRuleName, isCloudRulerGroup } from '../utils/rules'; import { DataSourceRuleListItem } from './DataSourceRuleListItem'; import { RuleOperationListItem } from './components/AlertRuleListItem'; import { AlertRuleListItemSkeleton } from './components/AlertRuleListItemLoader'; +import { LoadMoreButton } from './components/LoadMoreButton'; import { RuleActionsButtons } from './components/RuleActionsButtons.V2'; import { RuleOperation } from './components/RuleListIcon'; import { matchRulesGroup } from './ruleMatching'; @@ -174,15 +178,21 @@ export function RulerBasedGroupRules({ promGroup, rulerGroup, }: RulerBasedGroupRulesProps) { + const styles = useStyles2(getStyles); const { namespace, groupName } = groupIdentifier; const { matches, promOnlyRules } = useMemo(() => { return matchRulesGroup(rulerGroup, promGroup); }, [promGroup, rulerGroup]); + const { pageItems, hasMore, loadMore } = useContinuousPagination( + rulerGroup.rules, + DEFAULT_PER_PAGE_PAGINATION_RULES_PER_GROUP + ); + return ( <> - {rulerGroup.rules.map((rulerRule) => { + {pageItems.map((rulerRule) => { const promRule = matches.get(rulerRule); return promRule ? ( @@ -222,6 +232,18 @@ export function RulerBasedGroupRules({ showLocation={false} /> ))} + {hasMore && ( +
  • + +
  • + )} ); } + +const getStyles = (theme: GrafanaTheme2) => ({ + loadMoreWrapper: css({ + listStyle: 'none', + paddingTop: theme.spacing(1), + }), +}); 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 b37e08dda81..3a7348048da 100644 --- a/public/app/features/alerting/unified/rule-list/FilterView.test.tsx +++ b/public/app/features/alerting/unified/rule-list/FilterView.test.tsx @@ -57,36 +57,44 @@ describe('RuleList - FilterView', () => { it('should filter results by group and rule name ', async () => { render( ); await loadMoreResults(); const matchingRule = await screen.findByRole('treeitem', { - name: /mimir-test-rule-2/, + name: /mimir-test-rule-1/, }); - expect(matchingRule).toHaveTextContent('mimir-test-rule-2'); + expect(matchingRule).toHaveTextContent('mimir-test-rule-1'); expect(matchingRule).toHaveTextContent('test-mimir-namespace'); expect(matchingRule).toHaveTextContent('test-group-4501'); expect(await screen.findByText(/No more results/)).toBeInTheDocument(); }); it('should display rules from multiple datasources', async () => { - render(); + render(); await loadMoreResults(); - // Mimir has 11 matching rules, 181, 1810, 1811 ... 1819 + // Fuzzy search for 'test-group-123' matches: + // Mimir: 15 groups (123, 1123, 1230-1239, 2123, 3123, 4123) + // Prometheus: 1 group (123) const matchingMimirRules = await screen.findAllByRole('treeitem', { - name: /mimir-test-rule-2/, + name: /mimir-test-rule-3/, }); const matchingPrometheusRule = await screen.findByRole('treeitem', { - name: /prometheus-test-rule-2/, + name: /prometheus-test-rule-3/, }); - expect(matchingMimirRules).toHaveLength(11); + // Mimir: 15 groups × 1 rule each = 15 rules + // Prometheus: 1 group × 1 rule = 1 rule + expect(matchingMimirRules).toHaveLength(15); expect(matchingPrometheusRule).toBeInTheDocument(); expect(await screen.findByText(/No more results/)).toBeInTheDocument(); @@ -99,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/GrafanaGroupLoader.tsx b/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.tsx index 7d9a8d184de..a92b67822d2 100644 --- a/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.tsx @@ -3,17 +3,16 @@ import { useMemo } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { Alert, Pagination, Stack, useStyles2 } from '@grafana/ui'; +import { Alert, Stack, useStyles2 } from '@grafana/ui'; import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting'; import { prometheusApi } from '../api/prometheusApi'; -import { usePagination } from '../hooks/usePagination'; -import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants'; +import { useContinuousPagination } from '../hooks/usePagination'; +import { DEFAULT_PER_PAGE_PAGINATION_RULES_PER_GROUP, RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants'; import { GrafanaRuleListItem } from './GrafanaRuleListItem'; import { AlertRuleListItemSkeleton } from './components/AlertRuleListItemLoader'; - -const DEFAULT_PER_PAGE_PAGINATION_RULES_PER_GROUP_LIST_VIEW_V2 = 100; +import { LoadMoreButton } from './components/LoadMoreButton'; const { useGetGrafanaGroupsQuery } = prometheusApi; @@ -53,11 +52,7 @@ export function GrafanaGroupLoader({ return promResponse?.data.groups.at(0)?.rules ?? []; }, [promResponse]); - const { pageItems, page, numberOfPages, onPageChange } = usePagination( - rules, - 1, - DEFAULT_PER_PAGE_PAGINATION_RULES_PER_GROUP_LIST_VIEW_V2 - ); + const { pageItems, hasMore, loadMore } = useContinuousPagination(rules, DEFAULT_PER_PAGE_PAGINATION_RULES_PER_GROUP); if (isPromResponseLoading) { return ( @@ -101,34 +96,18 @@ export function GrafanaGroupLoader({ /> ); })} -
    - {numberOfPages > 1 && ( - - )} -
    + {hasMore && ( +
  • + +
  • + )}
    ); } const getStyles = (theme: GrafanaTheme2) => ({ - pagination: css({ - display: 'flex', - margin: 0, + loadMoreWrapper: css({ + listStyle: 'none', paddingTop: theme.spacing(1), - paddingBottom: theme.spacing(0.25), - justifyContent: 'center', - float: 'none', - }), - paginationWrapper: css({ - display: 'flex', - justifyContent: 'flex-start', - alignItems: 'center', - marginLeft: theme.spacing(2.5), }), }); diff --git a/public/app/features/alerting/unified/rule-list/GrafanaRuleListItem.tsx b/public/app/features/alerting/unified/rule-list/GrafanaRuleListItem.tsx index 3113c991e09..5667069082b 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, 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..5117a8516bd 100644 --- a/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx +++ b/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx @@ -4,7 +4,7 @@ import { ReactNode, useEffect, useId } from 'react'; import { 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'; @@ -20,8 +20,8 @@ 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 @@ -65,6 +66,7 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => { instancesCount = 0, namespace, group, + groupUrl, rulesSource, application, contactPoint, @@ -81,7 +83,13 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => { if (namespace && group && showLocation) { metadata.push( - + ); } @@ -160,6 +168,7 @@ export function RecordingRuleListItem({ name, namespace, group, + groupUrl, rulesSource, application, href, @@ -175,7 +184,13 @@ export function RecordingRuleListItem({ if (namespace && group && showLocation) { metadata.push( - + ); } @@ -206,6 +221,7 @@ interface RuleOperationListItemProps { name: string; namespace: string; group: string; + groupUrl?: string; rulesSource?: RulesSourceIdentifier; application?: RulesSourceApplication; operation: RuleOperation; @@ -216,6 +232,7 @@ export function RuleOperationListItem({ name, namespace, group, + groupUrl, rulesSource, application, operation, @@ -227,7 +244,13 @@ export function RuleOperationListItem({ if (namespace && group && showLocation) { metadata.push( - + ); } @@ -368,38 +391,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', diff --git a/public/app/features/alerting/unified/rule-list/components/ListSection.tsx b/public/app/features/alerting/unified/rule-list/components/ListSection.tsx index 4feb8535036..8157525e1c6 100644 --- a/public/app/features/alerting/unified/rule-list/components/ListSection.tsx +++ b/public/app/features/alerting/unified/rule-list/components/ListSection.tsx @@ -65,6 +65,8 @@ const getStyles = (theme: GrafanaTheme2) => ({ // unfortunately we have to resort to this since we can't overwrite the styles of the list items individually // unless we clone the React Elements and modify className 'li[role=treeitem]': { + listStyle: 'none', + position: 'relative', paddingLeft: theme.spacing(6.5), '&:before': { 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 975216e498d..3602081e7c0 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/filters.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/filters.ts @@ -8,6 +8,7 @@ import { RulesFilter } from '../../search/rulesSearchParser'; import { labelsMatchMatchers } from '../../utils/alertmanager'; import { Annotation } from '../../utils/constants'; import { getDatasourceAPIUid } from '../../utils/datasource'; +import { fuzzyMatches } from '../../utils/fuzzySearch'; import { parseMatcher } from '../../utils/matchers'; import { isPluginProvidedRule, prometheusRuleType } from '../../utils/rules'; @@ -21,13 +22,13 @@ export function groupFilter( const { name, file } = group; const { namespace, groupName } = filterState; - // Add fuzzy search for namespace - if (namespace && !file.toLocaleLowerCase().includes(namespace.toLocaleLowerCase())) { + // Use fuzzy search for namespace + if (namespace && !fuzzyMatches(file, namespace)) { return false; } - // Add fuzzy search for group name - if (groupName && !name.toLocaleLowerCase().includes(groupName.toLocaleLowerCase())) { + // Use fuzzy search for group name + if (groupName && !fuzzyMatches(name, groupName)) { return false; } @@ -40,15 +41,13 @@ export function groupFilter( export function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter) { const { name, labels = {}, health, type } = rule; - const nameLower = name.toLowerCase(); - - // Free form words filter (matches if any word is part of the rule name) - if (filterState.freeFormWords.length > 0 && !filterState.freeFormWords.some((word) => nameLower.includes(word))) { + // Free form words filter (uses fuzzy matching for each word) + if (filterState.freeFormWords.length > 0 && !filterState.freeFormWords.some((word) => fuzzyMatches(name, word))) { return false; } - // Rule name filter (exact match) - if (filterState.ruleName && !nameLower.includes(filterState.ruleName)) { + // Rule name filter (uses fuzzy matching) + if (filterState.ruleName && !fuzzyMatches(name, filterState.ruleName)) { return false; } diff --git a/public/app/features/alerting/unified/utils/constants.ts b/public/app/features/alerting/unified/utils/constants.ts index eaeeb23c590..1c901e56800 100644 --- a/public/app/features/alerting/unified/utils/constants.ts +++ b/public/app/features/alerting/unified/utils/constants.ts @@ -8,6 +8,8 @@ export const SILENCES_POLL_INTERVAL_MS = 20000; export const NOTIFICATIONS_POLL_INTERVAL_MS = 20000; export const CONTACT_POINTS_STATE_INTERVAL_MS = 20000; +export const DEFAULT_PER_PAGE_PAGINATION_RULES_PER_GROUP = 100; + export const TIMESERIES = 'timeseries'; export const TABLE = 'table'; export const STAT = 'stat'; diff --git a/public/app/features/alerting/unified/utils/fuzzySearch.test.ts b/public/app/features/alerting/unified/utils/fuzzySearch.test.ts new file mode 100644 index 00000000000..61098ec699f --- /dev/null +++ b/public/app/features/alerting/unified/utils/fuzzySearch.test.ts @@ -0,0 +1,217 @@ +import { fuzzyFilter, fuzzyMatches } from './fuzzySearch'; + +describe('fuzzySearch', () => { + describe('fuzzyMatches', () => { + describe('should match with typos and fuzzy logic', () => { + it.each([ + ['High CPU usage', 'cpu'], + ['High CPU usage', 'hi usage'], + ['High CPU usage', 'usge'], // typo + ['Memory Alert Rule', 'memory'], + ['Memory Alert Rule', 'alrt'], // typo + ['k8s-pod-memory-high', 'pod memory'], + ['API-Response-Time[5xx]', 'response'], + ])('matches "%s" with search term "%s"', (target, searchTerm) => { + expect(fuzzyMatches(target, searchTerm)).toBe(true); + }); + }); + + describe('should be case insensitive', () => { + it.each([ + ['High CPU Usage', 'cpu'], + ['high cpu usage', 'CPU'], + ['Memory Alert', 'MEMORY'], + ['DISK ALERT', 'disk'], + ])('matches "%s" with search term "%s"', (target, searchTerm) => { + expect(fuzzyMatches(target, searchTerm)).toBe(true); + }); + }); + + describe('should handle edge cases with fallback', () => { + it('matches with non-ASCII characters using fallback', () => { + expect(fuzzyMatches('Café Alert', 'café')).toBe(true); + expect(fuzzyMatches('règle alerte', 'règle')).toBe(true); + }); + + it('matches with symbol-only searches using fallback', () => { + expect(fuzzyMatches('API[5xx] Error', '[5xx]')).toBe(true); + expect(fuzzyMatches('Memory > 90%', '> 90%')).toBe(true); + }); + + it('matches long search terms using fallback', () => { + const longTarget = 'This:is:a:very:long:rule:name:that:definitely:exceeds:max:needle:length'; + const longSearchTerm = 'very:long:rule:name:that:definitely:exceeds:max:needle:length'; + expect(fuzzyMatches(longTarget, longSearchTerm)).toBe(true); + }); + }); + + describe('should handle empty and whitespace searches', () => { + it('returns true for empty search terms', () => { + expect(fuzzyMatches('Any Rule Name', '')).toBe(true); + expect(fuzzyMatches('Any Rule Name', ' ')).toBe(true); + }); + }); + + describe('should not match unrelated terms', () => { + it.each([ + ['CPU Alert', 'memory'], + ['Memory Usage', 'disk'], + ['API Response', 'database'], + ])('does not match "%s" with search term "%s"', (target, searchTerm) => { + expect(fuzzyMatches(target, searchTerm)).toBe(false); + }); + }); + }); + + describe('fuzzyFilter', () => { + const testRules = [ + { name: 'High CPU usage', id: '1' }, + { name: 'Memory too low', id: '2' }, + { name: 'Disk space alert', id: '3' }, + { name: 'API Response Time', id: '4' }, + { name: 'k8s-pod-memory-high', id: '5' }, + ]; + + describe('should filter with fuzzy matching', () => { + it('filters by exact matches', () => { + const result = fuzzyFilter(testRules, (rule) => rule.name, 'CPU'); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('High CPU usage'); + }); + + it('filters with typos', () => { + const result = fuzzyFilter(testRules, (rule) => rule.name, 'usge'); // typo for "usage" + expect(result).toHaveLength(1); + expect(result[0].name).toBe('High CPU usage'); + }); + + it('filters with partial matches', () => { + const result = fuzzyFilter(testRules, (rule) => rule.name, 'memory'); + expect(result).toHaveLength(2); + expect(result.map((r) => r.name)).toContain('Memory too low'); + expect(result.map((r) => r.name)).toContain('k8s-pod-memory-high'); + }); + + it('filters with multiple words', () => { + const result = fuzzyFilter(testRules, (rule) => rule.name, 'api response'); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('API Response Time'); + }); + + it('filters with non-consecutive words', () => { + const result = fuzzyFilter(testRules, (rule) => rule.name, 'api time'); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('API Response Time'); + }); + }); + + describe('should handle edge cases', () => { + it('returns all items for empty search', () => { + const result = fuzzyFilter(testRules, (rule) => rule.name, ''); + expect(result).toHaveLength(testRules.length); + }); + + it('returns all items for whitespace search', () => { + const result = fuzzyFilter(testRules, (rule) => rule.name, ' '); + expect(result).toHaveLength(testRules.length); + }); + + it('uses fallback for non-ASCII characters', () => { + const rulesWithAccents = [ + { name: 'Café Alert', id: '1' }, + { name: 'Regular Alert', id: '2' }, + ]; + const result = fuzzyFilter(rulesWithAccents, (rule) => rule.name, 'café'); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('Café Alert'); + }); + + it('uses fallback for symbol-only searches', () => { + const rulesWithSymbols = [ + { name: 'API[5xx] Error', id: '1' }, + { name: 'Normal Error', id: '2' }, + ]; + const result = fuzzyFilter(rulesWithSymbols, (rule) => rule.name, '[5xx]'); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('API[5xx] Error'); + }); + + it('uses fallback for very long search terms', () => { + const longRuleName = 'This:is:a:very:long:rule:name:that:definitely:exceeds:max:needle:length'; + const rulesWithLongName = [ + { name: longRuleName, id: '1' }, + { name: 'Short rule name', id: '2' }, + ]; + const longSearchTerm = 'very:long:rule:name:that:definitely:exceeds:max:needle:length'; + const result = fuzzyFilter(rulesWithLongName, (rule) => rule.name, longSearchTerm); + expect(result).toHaveLength(1); + expect(result[0].name).toBe(longRuleName); + }); + + it('handles complex searches without hanging', () => { + const result = fuzzyFilter( + testRules, + (rule) => rule.name, + 'cpu high memory low disk space alert response time' + ); + // Should not hang and should return some results + expect(Array.isArray(result)).toBe(true); + }); + }); + }); + + describe('real-world scenarios', () => { + const realWorldRules = [ + { name: 'grafana_dashboard_sync_failed', id: '1' }, + { name: 'k8s-pod-cpu-usage-high', id: '2' }, + { name: 'PostgreSQL Connection Pool Exhausted', id: '3' }, + { name: 'HTTP 5xx Error Rate High', id: '4' }, + { name: 'Memory Usage > 90%', id: '5' }, + { name: 'Disk I/O Latency Critical', id: '6' }, + { name: 'API Response Time P99 > 500ms', id: '7' }, + ]; + + it('handles common alerting rule patterns', () => { + // Test various real-world search patterns + expect(fuzzyFilter(realWorldRules, (r) => r.name, 'grafana sync')).toContainEqual({ + name: 'grafana_dashboard_sync_failed', + id: '1', + }); + expect(fuzzyFilter(realWorldRules, (r) => r.name, 'k8s cpu')).toContainEqual({ + name: 'k8s-pod-cpu-usage-high', + id: '2', + }); + expect(fuzzyFilter(realWorldRules, (r) => r.name, 'postgres pool')).toContainEqual({ + name: 'PostgreSQL Connection Pool Exhausted', + id: '3', + }); + expect(fuzzyFilter(realWorldRules, (r) => r.name, '5xx error')).toContainEqual({ + name: 'HTTP 5xx Error Rate High', + id: '4', + }); + expect(fuzzyFilter(realWorldRules, (r) => r.name, 'memory 90')).toContainEqual({ + name: 'Memory Usage > 90%', + id: '5', + }); + expect(fuzzyFilter(realWorldRules, (r) => r.name, 'disk latency')).toContainEqual({ + name: 'Disk I/O Latency Critical', + id: '6', + }); + expect(fuzzyFilter(realWorldRules, (r) => r.name, 'api p99')).toContainEqual({ + name: 'API Response Time P99 > 500ms', + id: '7', + }); + }); + + it('handles typos in common alert terms', () => { + expect(fuzzyMatches('PostgreSQL Connection Pool Exhausted', 'postgrs')).toBe(true); + expect(fuzzyMatches('HTTP 5xx Error Rate High', 'eror rate')).toBe(true); + expect(fuzzyMatches('Disk I/O Latency Critical', 'latentcy')).toBe(true); + }); + + it('handles mixed case and symbols', () => { + expect(fuzzyMatches('Memory Usage > 90%', 'memory > 90')).toBe(true); + expect(fuzzyMatches('API Response Time P99 > 500ms', 'p99 500ms')).toBe(true); + }); + }); +}); diff --git a/public/app/features/alerting/unified/utils/fuzzySearch.ts b/public/app/features/alerting/unified/utils/fuzzySearch.ts new file mode 100644 index 00000000000..7c67c823e9c --- /dev/null +++ b/public/app/features/alerting/unified/utils/fuzzySearch.ts @@ -0,0 +1,29 @@ +import { fuzzySearch } from '@grafana/data'; + +/** + * Applies fuzzy search to a list of items using the provided filter function. + * Uses @grafana/data fuzzySearch implementation with all built-in optimizations. + */ +export function fuzzyFilter(items: TItem[], filterBy: (item: TItem) => string, searchTerm: string): TItem[] { + if (!searchTerm.trim()) { + return items; + } + + const haystack = items.map(filterBy); + const matchingIndices = fuzzySearch(haystack, searchTerm); + return matchingIndices.map((idx) => items[idx]); +} + +/** + * Checks if a search term matches a target string using fuzzy matching. + * Returns true if there's a match, false otherwise. + */ +export function fuzzyMatches(target: string, searchTerm: string): boolean { + if (!searchTerm.trim()) { + return true; + } + + const haystack = [target]; + const matchingIndices = fuzzySearch(haystack, searchTerm); + return matchingIndices.length > 0; +} diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index d71d0c57be2..50334b939e5 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -4,7 +4,7 @@ import { AppEvents, isTruthy, locationUtil } from '@grafana/data'; import { t } from '@grafana/i18n'; import { config, getBackendSrv, isFetchError, locationService } from '@grafana/runtime'; import { Dashboard } from '@grafana/schema'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { folderAPIv1beta1 as folderAPI } from 'app/api/clients/folder/v1beta1'; import { createBaseQuery, handleRequestError } from 'app/api/createBaseQuery'; import appEvents from 'app/core/app_events'; diff --git a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx index 85b50db08c0..6bc4095ff27 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx @@ -15,6 +15,7 @@ import { useActionSelectionState } from '../../state/hooks'; import { setAllSelection } from '../../state/slice'; import { DashboardTreeSelection } from '../../types'; import { BulkDeleteProvisionedResource } from '../BulkActions/BulkDeleteProvisionedResource'; +import { BulkMoveProvisionedResource } from '../BulkActions/BulkMoveProvisionedResource'; import { DeleteModal } from './DeleteModal'; import { MoveModal } from './MoveModal'; @@ -27,6 +28,7 @@ export interface Props { export function BrowseActions({ folderDTO }: Props) { const [showBulkDeleteProvisionedResource, setShowBulkDeleteProvisionedResource] = useState(false); + const [showBulkMoveProvisionedResource, setShowBulkMoveProvisionedResource] = useState(false); const dispatch = useDispatch(); const selectedItems = useActionSelectionState(); @@ -70,6 +72,24 @@ export function BrowseActions({ folderDTO }: Props) { }; const showMoveModal = () => { + if (provisioningEnabled && hasProvisioned && hasNonProvisioned) { + // Mixed selection + appEvents.publish( + new ShowModalReactEvent({ + component: SelectedMixResourcesMsgModal, + props: {}, + }) + ); + return; + } + + if (provisioningEnabled && hasProvisioned) { + // Only provisioned items + setShowBulkMoveProvisionedResource(true); + return; + } + + // only non-provisioned items appEvents.publish( new ShowModalReactEvent({ component: MoveModal, @@ -128,6 +148,7 @@ export function BrowseActions({ folderDTO }: Props) { Delete + {/* bulk delete */} {showBulkDeleteProvisionedResource && ( )} + + {/* bulk move */} + {showBulkMoveProvisionedResource && ( + setShowBulkMoveProvisionedResource(false)} + size="md" + > + { + setShowBulkMoveProvisionedResource(false); + onActionComplete(); + }} + /> + + )} ); } diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkActionPostSubmitStep.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkActionPostSubmitStep.tsx new file mode 100644 index 00000000000..c3a03aac1c9 --- /dev/null +++ b/public/app/features/browse-dashboards/components/BulkActions/BulkActionPostSubmitStep.tsx @@ -0,0 +1,54 @@ +import { t, Trans } from '@grafana/i18n'; +import { Alert, Button, Stack } from '@grafana/ui'; + +import { BulkActionFailureBanner, MoveResultFailed } from './BulkActionFailureBanner'; +import { BulkActionProgress, ProgressState } from './BulkActionProgress'; +import { MoveResultSuccessState } from './utils'; + +interface Props { + action: 'move' | 'delete'; + progress: ProgressState | null; + successState: MoveResultSuccessState; + failureResults: MoveResultFailed[] | undefined; + handleSuccess: () => void; + setFailureResults: (results: MoveResultFailed[] | undefined) => void; +} + +export function BulkActionPostSubmitStep({ + action, + progress, + successState, + failureResults, + handleSuccess, + setFailureResults, +}: Props) { + if (progress) { + return ; + } + + if (successState.allSuccess) { + return ( + <> + + {action === 'move' + ? t('browse-dashboards.bulk-action-resources-form.all-moved', 'All resources have been moved successfully') + : t( + 'browse-dashboards.bulk-action-resources-form.all-deleted', + 'All resources have been deleted successfully' + )} + + + + + + ); + } + + if (failureResults) { + return setFailureResults(undefined)} />; + } + + return null; +} diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.test.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.test.tsx index e1dcbc41a94..47a6fabfb6a 100644 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.test.tsx +++ b/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import { BulkActionProgress, ProgressState } from './BulkActionProgress'; -const setup = (progressOverrides: Partial = {}) => { +const setup = (progressOverrides: Partial = {}, action: 'delete' | 'move' = 'delete') => { const defaultProgress: ProgressState = { current: 5, total: 10, @@ -15,7 +15,7 @@ const setup = (progressOverrides: Partial = {}) => { }; return { - ...render(), + ...render(), props, }; }; @@ -59,4 +59,11 @@ describe('BulkActionProgress', () => { expect(screen.getByText(/Deleting:/)).toBeInTheDocument(); expect(screen.getByText(/Complex Dashboard Name/)).toBeInTheDocument(); }); + + it('should render moving action text when action is move', () => { + setup({ current: 2, total: 4, item: 'Moving Dashboard' }, 'move'); + + expect(screen.getByText(/Moving:/)).toBeInTheDocument(); + expect(screen.getByText(/Moving Dashboard/)).toBeInTheDocument(); + }); }); diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.tsx index fd8ac5196a8..f022554d52a 100644 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.tsx +++ b/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.tsx @@ -2,30 +2,38 @@ import { Trans } from '@grafana/i18n'; import { Box, Text, Stack, Spinner } from '@grafana/ui'; import ProgressBar from 'app/features/provisioning/Shared/ProgressBar'; -export interface ProgressState { +export type ProgressState = { current: number; total: number; item: string; +}; + +interface Props { + progress: ProgressState; + action: 'move' | 'delete'; } -export function BulkActionProgress({ progress }: { progress: ProgressState }) { +export function BulkActionProgress({ progress, action }: Props) { const progressPercentage = Math.round((progress.current / progress.total) * 100); return ( - + + Progress: {{ current: progress.current }} of {{ total: progress.total }} + - {progress.item} + {action === 'move' ? ( + Moving + ) : ( + Deleting + )} + : {progress.item} ); diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx index d5fd35ff1bd..14ea787fc81 100644 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx +++ b/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx @@ -1,9 +1,8 @@ import { useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; -import { useNavigate } from 'react-router-dom-v5-compat'; import { Trans, t } from '@grafana/i18n'; -import { Alert, Box, Button, Stack } from '@grafana/ui'; +import { Box, Button, Stack } from '@grafana/ui'; import { DeleteRepositoryFilesWithPathApiArg, DeleteRepositoryFilesWithPathApiResponse, @@ -15,50 +14,32 @@ import { AnnoKeySourcePath } from 'app/features/apiserver/types'; import { ResourceEditFormSharedFields } from 'app/features/dashboard-scene/components/Provisioned/ResourceEditFormSharedFields'; import { getDefaultWorkflow, getWorkflowOptions } from 'app/features/dashboard-scene/saving/provisioned/defaults'; import { generateTimestamp } from 'app/features/dashboard-scene/saving/provisioned/utils/timestamp'; -import { buildResourceBranchRedirectUrl } from 'app/features/dashboard-scene/settings/utils'; import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView'; -import { WorkflowOption } from 'app/features/provisioning/types'; import { useSelector } from 'app/types/store'; import { useChildrenByParentUIDState, rootItemsSelector } from '../../state/hooks'; import { findItem } from '../../state/utils'; -import { DashboardTreeSelection } from '../../types'; import { DescendantCount } from '../BrowseActions/DescendantCount'; import { collectSelectedItems, fetchProvisionedDashboardPath } from '../utils'; -import { BulkActionFailureBanner, MoveResultFailed } from './BulkActionFailureBanner'; -import { BulkActionProgress, ProgressState } from './BulkActionProgress'; +import { MoveResultFailed } from './BulkActionFailureBanner'; +import { BulkActionPostSubmitStep } from './BulkActionPostSubmitStep'; +import { ProgressState } from './BulkActionProgress'; +import { useBulkActionRequest } from './useBulkActionRequest'; +import { + BulkActionFormData, + BulkActionProvisionResourceProps, + BulkSuccessResponse, + MoveResultSuccessState, +} from './utils'; -interface BulkDeleteFormData { - comment: string; - ref: string; - workflow?: WorkflowOption; -} - -interface FormProps extends BulkDeleteProvisionResourceProps { - initialValues: BulkDeleteFormData; +interface FormProps extends BulkActionProvisionResourceProps { + initialValues: BulkActionFormData; repository: RepositoryView; workflowOptions: Array<{ label: string; value: string }>; folderPath?: string; } -interface BulkDeleteProvisionResourceProps { - folderUid?: string; - selectedItems: Omit; - onDismiss?: () => void; -} - -type BulkSuccessResponse = Array<{ - index: number; - item: DeleteRepositoryFilesWithPathApiArg; - data: DeleteRepositoryFilesWithPathApiResponse; -}>; - -type MoveResultSuccessState = { - allSuccess: boolean; - repoUrl?: string; -}; - function FormContent({ initialValues, selectedItems, repository, workflowOptions, folderPath, onDismiss }: FormProps) { // States const [progress, setProgress] = useState(null); @@ -71,12 +52,12 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions // Hooks const [deleteRepoFile, request] = useDeleteRepositoryFilesWithPathMutation(); - const methods = useForm({ defaultValues: initialValues }); + const methods = useForm({ defaultValues: initialValues }); const childrenByParentUID = useChildrenByParentUIDState(); const rootItems = useSelector(rootItemsSelector); const { handleSubmit, watch } = methods; const workflow = watch('workflow'); - const navigate = useNavigate(); + const { handleSuccess } = useBulkActionRequest({ workflow, repository, successState, onDismiss }); const getResourcePath = async (uid: string, isFolder: boolean): Promise => { const item = findItem(rootItems?.items || [], childrenByParentUID, uid); @@ -86,27 +67,7 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions return isFolder ? `${folderPath}/${item.title}/` : fetchProvisionedDashboardPath(uid); }; - const handleSuccess = () => { - if (workflow === 'branch') { - onDismiss?.(); - if (successState.repoUrl) { - const url = buildResourceBranchRedirectUrl({ - paramName: 'repo_url', - paramValue: successState.repoUrl, - repoType: repository.type, - }); - - navigate(url); - return; - } - window.location.reload(); - } else { - onDismiss?.(); - window.location.reload(); - } - }; - - const handleSubmitForm = async (data: BulkDeleteFormData) => { + const handleSubmitForm = async (data: BulkActionFormData) => { setFailureResults(undefined); setHasSubmitted(true); @@ -120,7 +81,10 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions }); } - const successes: BulkSuccessResponse = []; + const successes: BulkSuccessResponse< + DeleteRepositoryFilesWithPathApiArg, + DeleteRepositoryFilesWithPathApiResponse + > = []; const failures: MoveResultFailed[] = []; // Iterate through each selected item and delete it @@ -177,40 +141,13 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions // handleSuccess(successes); setSuccessState({ allSuccess: true, - repoUrl: successes[0].data.urls?.repositoryURL, + repoUrl: successes[0].data.urls?.newPullRequestURL, }); } else if (failures.length > 0) { setFailureResults(failures); } }; - const getPostSubmitContent = () => { - if (progress) { - return ; - } - - if (successState.allSuccess) { - return ( - <> - - - All resources have been deleted successfully. - - - - - - - ); - } else if (failureResults) { - return setFailureResults(undefined)} />; - } - - return null; - }; - return (
    @@ -223,7 +160,14 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions {hasSubmitted ? ( - getPostSubmitContent() + ) : ( <> ; + folderPath?: string; +} + +function FormContent({ initialValues, selectedItems, repository, workflowOptions, folderPath, onDismiss }: FormProps) { + // States + const [targetFolderUID, setTargetFolderUID] = useState(undefined); + const [progress, setProgress] = useState(null); + const [failureResults, setFailureResults] = useState(); + const [successState, setSuccessState] = useState({ + allSuccess: false, + repoUrl: '', + }); + const [hasSubmitted, setHasSubmitted] = useState(false); + + // Hooks + const [moveFile, moveRequest] = useCreateRepositoryFilesWithPathMutation(); + const methods = useForm({ defaultValues: initialValues }); + const childrenByParentUID = useChildrenByParentUIDState(); + const rootItems = useSelector(rootItemsSelector); + const { handleSubmit, watch } = methods; + const workflow = watch('workflow'); + const { handleSuccess } = useBulkActionRequest({ workflow, repository, successState, onDismiss }); + + // Get target folder data + const { data: targetFolder } = useGetFolderQuery(targetFolderUID ? { name: targetFolderUID } : skipToken); + + const getResourceCurrentPath = async (uid: string, isFolder: boolean): Promise => { + const item = findItem(rootItems?.items || [], childrenByParentUID, uid); + if (!item) { + return undefined; + } + return isFolder ? `${folderPath}/${item.title}/` : fetchProvisionedDashboardPath(uid); + }; + + const getDashboardBody = async (currentPath: string) => { + const repositoryClient = new ScopedResourceClient({ + group: 'provisioning.grafana.app', + version: 'v0alpha1', + resource: 'repositories', + }); + const fileResponse = await repositoryClient.subresource(repository.name, `files/${currentPath}`); + return fileResponse.resource?.file; + }; + + const setupMoveOperation = () => { + const targetFolderPathInRepo = getTargetFolderPathInRepo({ targetFolder }); + const targets = collectSelectedItems(selectedItems, childrenByParentUID, rootItems?.items || []); + + if (targets.length > 0) { + setProgress({ + current: 0, + total: targets.length, + item: targets[0].displayName || 'Unknown', + }); + } + + return { targetFolderPathInRepo, targets }; + }; + + const createFileBody = async (isFolder: boolean, displayName: string, currentPath: string) => { + if (isFolder) { + return { + title: displayName, + type: 'folder', + }; + } + + const fileBody = await getDashboardBody(currentPath); + if (!fileBody) { + throw new Error( + t('browse-dashboards.bulk-move-resources-form.error-file-content-not-found', 'File content not found') + ); + } + + return fileBody; + }; + + const handleSubmitForm = async (data: BulkActionFormData) => { + setFailureResults(undefined); + setHasSubmitted(true); + + // 1. Validate + if (!targetFolder) { + setFailureResults([ + { + status: 'failed', + title: t('browse-dashboards.bulk-move-resources-form.error-title', 'Target Folder Error'), + }, + ]); + return; + } + + // 2. Setup + const { targetFolderPathInRepo, targets } = setupMoveOperation(); + + // 3. Process items + const successes: BulkSuccessResponse< + CreateRepositoryFilesWithPathApiArg, + CreateRepositoryFilesWithPathApiResponse + > = []; + const failures: MoveResultFailed[] = []; + + // Iterate through each selected item and move it + // We want sequential processing to avoid overwhelming the API + for (let i = 0; i < targets.length; i++) { + const { uid, isFolder, displayName } = targets[i]; + setProgress({ + current: i, + total: targets.length, + item: displayName, + }); + + try { + // 1. Get source path in repository + const currentPath = await getResourceCurrentPath(uid, isFolder); + if (!currentPath) { + failures.push({ + status: 'failed', + title: `${isFolder ? 'Folder' : 'Dashboard'}: ${displayName}`, + errorMessage: t('browse-dashboards.bulk-move-resources-form.error-path-not-found', 'Path not found'), + }); + continue; + } + + if (!targetFolderPathInRepo) { + failures.push({ + status: 'failed', + title: `${isFolder ? 'Folder' : 'Dashboard'}: ${displayName}`, + errorMessage: t( + 'browse-dashboards.bulk-move-resources-form.error-target-folder-path-missing', + 'Target folder path is missing' + ), + }); + continue; + } + + const newPath = getResourceTargetPath(currentPath, targetFolderPathInRepo); + const fileBody = await createFileBody(isFolder, displayName, currentPath); + + // Build move parameters + const moveParams: CreateRepositoryFilesWithPathApiArg = { + name: repository.name, + path: newPath, // NEW target path + ref: workflow === 'write' ? undefined : data.ref, + message: data.comment || `Move resource ${displayName}`, + originalPath: currentPath, // CURRENT path (source) + body: fileBody, // File content + }; + + // Call endpoint to move resource + const response = await moveFile(moveParams).unwrap(); + successes.push({ index: i, item: moveParams, data: response }); + } catch (error: unknown) { + failures.push({ + status: 'failed', + title: `${isFolder ? 'Folder' : 'Dashboard'}: ${displayName}`, + errorMessage: extractErrorMessage(error), + }); + } + + setProgress({ + current: i + 1, + total: targets.length, + item: targets[i + 1]?.displayName, + }); + } + + setProgress(null); + + if (successes.length > 0 && failures.length === 0) { + // handleSuccess(successes); + setSuccessState({ + allSuccess: true, + repoUrl: successes[0].data.urls?.newPullRequestURL, + }); + } else if (failures.length > 0) { + setFailureResults(failures); + } + }; + + return ( + + + + + + This will move selected folders and their descendants. In total, this will affect: + + + + + {hasSubmitted ? ( + + ) : ( + <> + {/* Target folder selection */} + + + + + + + + + + + )} + + + + ); +} + +export function BulkMoveProvisionedResource({ folderUid, selectedItems, onDismiss }: BulkActionProvisionResourceProps) { + const { repository, folder } = useGetResourceRepositoryView({ folderName: folderUid }); + + const workflowOptions = getWorkflowOptions(repository); + const folderPath = folder?.metadata?.annotations?.[AnnoKeySourcePath] || ''; + const timestamp = generateTimestamp(); + + const initialValues = { + comment: '', + ref: `bulk-move/${timestamp}`, + workflow: getDefaultWorkflow(repository), + }; + + if (!repository) { + return null; + } + + return ( + + ); +} diff --git a/public/app/features/browse-dashboards/components/BulkActions/useBulkActionRequest.ts b/public/app/features/browse-dashboards/components/BulkActions/useBulkActionRequest.ts new file mode 100644 index 00000000000..0e0ddfcaf2c --- /dev/null +++ b/public/app/features/browse-dashboards/components/BulkActions/useBulkActionRequest.ts @@ -0,0 +1,39 @@ +import { useNavigate } from 'react-router-dom-v5-compat'; + +import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1'; +import { buildResourceBranchRedirectUrl } from 'app/features/dashboard-scene/settings/utils'; + +import { MoveResultSuccessState } from './utils'; + +interface Props { + workflow?: 'branch' | 'write'; + repository: RepositoryView; + successState: MoveResultSuccessState; + onDismiss?: () => void; +} +export function useBulkActionRequest({ workflow, repository, successState, onDismiss }: Props) { + const navigate = useNavigate(); + + const handleSuccess = () => { + if (workflow === 'branch') { + onDismiss?.(); + if (successState.repoUrl) { + const url = buildResourceBranchRedirectUrl({ + paramName: 'repo_url', + paramValue: successState.repoUrl, + repoType: repository.type, + }); + + navigate(url); + return; + } + window.location.reload(); + } else { + onDismiss?.(); + window.location.reload(); + } + }; + return { + handleSuccess, + }; +} diff --git a/public/app/features/browse-dashboards/components/BulkActions/utils.ts b/public/app/features/browse-dashboards/components/BulkActions/utils.ts new file mode 100644 index 00000000000..48fc4892c73 --- /dev/null +++ b/public/app/features/browse-dashboards/components/BulkActions/utils.ts @@ -0,0 +1,50 @@ +import { Folder } from 'app/api/clients/folder/v1beta1'; +import { AnnoKeySourcePath } from 'app/features/apiserver/types'; +import { WorkflowOption } from 'app/features/provisioning/types'; + +import { DashboardTreeSelection } from '../../types'; + +export type BulkActionFormData = { + comment: string; + ref: string; + workflow?: WorkflowOption; +}; + +export interface BulkActionProvisionResourceProps { + folderUid?: string; + selectedItems: Omit; + onDismiss?: () => void; +} + +export type BulkSuccessResponse = Array<{ + index: number; + item: T; + data: K; +}>; + +export type MoveResultSuccessState = { + allSuccess: boolean; + repoUrl?: string; +}; + +export function getTargetFolderPathInRepo({ targetFolder }: { targetFolder?: Folder }): string | undefined { + if (!targetFolder) { + return undefined; + } + const folderAnnotations = targetFolder.metadata.annotations || {}; + return folderAnnotations[AnnoKeySourcePath] || targetFolder.metadata.name || ''; +} + +export function getResourceTargetPath(currentPath: string, targetFolderPath: string): string { + // Handle folder paths that end with '/' + const cleanCurrentPath = currentPath.replace(/\/$/, ''); // Remove trailing slash + const filename = cleanCurrentPath.split('/').pop(); + + if (!filename) { + throw new Error(`Invalid path: ${currentPath}`); + } + + // For folders, add back the trailing slash + const isFolder = currentPath.endsWith('/'); + return isFolder ? `${targetFolderPath}/${filename}/` : `${targetFolderPath}/${filename}`; +} 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/canvas/runtime/SceneTransformWrapper.tsx b/public/app/features/canvas/runtime/SceneTransformWrapper.tsx deleted file mode 100644 index eed1b429193..00000000000 --- a/public/app/features/canvas/runtime/SceneTransformWrapper.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import * as React from 'react'; -import { TransformWrapper, TransformComponent, ReactZoomPanPinchRef } from 'react-zoom-pan-pinch'; - -import { config } from '@grafana/runtime'; - -import { Scene } from './scene'; - -type SceneTransformWrapperProps = { - scene: Scene; - children: React.ReactNode; -}; - -export const SceneTransformWrapper = ({ scene, children: sceneDiv }: SceneTransformWrapperProps) => { - 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/conditional-rendering/ConditionalRendering.tsx b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRendering.tsx index ca0183e0e37..afbcbda926a 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/ConditionalRendering.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRendering.tsx @@ -1,6 +1,6 @@ import { t } from '@grafana/i18n'; import { SceneComponentProps, sceneGraph, SceneObject, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; -import { ConditionalRenderingGroupKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { ConditionalRenderingGroupKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { ConditionalRenderingChangedEvent } from '../edit-pane/shared'; diff --git a/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingData.tsx b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingData.tsx index b39090bb1df..930fcd500f0 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingData.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingData.tsx @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { PanelData } from '@grafana/data'; import { t } from '@grafana/i18n'; import { SceneComponentProps, sceneGraph } from '@grafana/scenes'; -import { ConditionalRenderingDataKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { ConditionalRenderingDataKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { Combobox, ComboboxOption } from '@grafana/ui'; import { dashboardEditActions } from '../edit-pane/shared'; diff --git a/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingGroup.tsx b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingGroup.tsx index ee949871a8f..8ebba87cab3 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingGroup.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingGroup.tsx @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { t } from '@grafana/i18n'; import { SceneComponentProps, sceneGraph } from '@grafana/scenes'; -import { ConditionalRenderingGroupKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { ConditionalRenderingGroupKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { Stack } from '@grafana/ui'; import { dashboardEditActions } from '../edit-pane/shared'; diff --git a/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingTimeRangeSize.tsx b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingTimeRangeSize.tsx index 28033cf18bb..82282dee713 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingTimeRangeSize.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingTimeRangeSize.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { rangeUtil, SelectableValue } from '@grafana/data'; import { t } from '@grafana/i18n'; import { SceneComponentProps, sceneGraph } from '@grafana/scenes'; -import { ConditionalRenderingTimeRangeSizeKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { ConditionalRenderingTimeRangeSizeKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { Field, Select, useStyles2 } from '@grafana/ui'; import { dashboardEditActions } from '../edit-pane/shared'; diff --git a/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingVariable.tsx b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingVariable.tsx index 5045f76d885..92dee9f7c0f 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingVariable.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingVariable.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { t } from '@grafana/i18n'; import { SceneComponentProps, sceneGraph, VariableDependencyConfig } from '@grafana/scenes'; -import { ConditionalRenderingVariableKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { ConditionalRenderingVariableKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { Box, Combobox, ComboboxOption, Input, Stack } from '@grafana/ui'; import { dashboardEditActions } from '../edit-pane/shared'; diff --git a/public/app/features/dashboard-scene/conditional-rendering/types.ts b/public/app/features/dashboard-scene/conditional-rendering/types.ts index 9330812b55b..6a1f94876fd 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/types.ts +++ b/public/app/features/dashboard-scene/conditional-rendering/types.ts @@ -4,7 +4,7 @@ import { ConditionalRenderingGroupKind, ConditionalRenderingTimeRangeSizeKind, ConditionalRenderingVariableKind, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { ConditionalRenderingData } from './ConditionalRenderingData'; import { ConditionalRenderingGroup } from './ConditionalRenderingGroup'; diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts index c6a484837c9..a6bab169cc6 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -4,7 +4,7 @@ import { BackendSrv, config, locationService, setBackendSrv } from '@grafana/run import { Spec as DashboardV2Spec, defaultSpec as defaultDashboardV2Spec, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; import store from 'app/core/store'; import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; import { DashboardVersionError, DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; @@ -37,6 +37,39 @@ jest.mock('@grafana/runtime', () => { ...original.config.featureToggles, dashboardNewLayouts: false, // Default value }, + + bootData: { + ...original.config.bootData, + settings: { + ...original.config.bootData.settings, + datasources: { + 'gdev-testdata': { + id: 7, + uid: 'abc', + type: 'grafana-testdata-datasource', + name: 'gdev-testdata', + meta: { + id: 'grafana-testdata-datasource', + type: 'datasource', + name: 'TestData', + aliasIDs: ['testdata'], + }, + }, + '-- Grafana --': { + id: -1, + uid: 'grafana', + type: 'datasource', + name: '-- Grafana --', + meta: { + id: 'grafana', + type: 'datasource', + name: '-- Grafana --', + }, + }, + }, + defaultDatasource: 'gdev-testdata', + }, + }, }, }; }); @@ -64,7 +97,7 @@ const setupDashboardAPI = ( const setupV1FailureV2Success = ( v2Response: DashboardWithAccessInfo = { access: {}, - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', metadata: { name: 'fake-dash', @@ -75,7 +108,7 @@ const setupV1FailureV2Success = ( } ) => { const getDashSpy = jest.fn(); - setupLoadDashboardMockReject(new DashboardVersionError('v2alpha1')); + setupLoadDashboardMockReject(new DashboardVersionError('v2beta1')); setupDashboardAPI(v2Response, getDashSpy); return getDashSpy; }; @@ -331,7 +364,7 @@ describe('DashboardScenePageStateManager v2', () => { setupDashboardAPI( { access: {}, - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', metadata: { name: 'fake-dash', @@ -378,7 +411,7 @@ describe('DashboardScenePageStateManager v2', () => { setupDashboardAPI( { access: {}, - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', metadata: { name: 'fake-dash', @@ -398,7 +431,7 @@ describe('DashboardScenePageStateManager v2', () => { setupDashboardAPI( { access: {}, - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', metadata: { name: 'fake-dash2', @@ -421,7 +454,7 @@ describe('DashboardScenePageStateManager v2', () => { setupDashboardAPI( { access: {}, - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', metadata: { name: 'fake-dash', @@ -446,7 +479,7 @@ describe('DashboardScenePageStateManager v2', () => { setupDashboardAPI( { access: {}, - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', metadata: { name: 'fake-dash', @@ -543,7 +576,7 @@ describe('DashboardScenePageStateManager v2', () => { setupDashboardAPI( { access: {}, - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', metadata: { name: 'fake-dash', @@ -577,7 +610,7 @@ describe('DashboardScenePageStateManager v2', () => { loader.setDashboardCache('fake-dash', { access: {}, - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', metadata: { name: 'fake-dash', @@ -600,7 +633,7 @@ describe('DashboardScenePageStateManager v2', () => { setupDashboardAPI( { access: {}, - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', metadata: { name: 'fake-dash', @@ -626,7 +659,7 @@ describe('DashboardScenePageStateManager v2', () => { setupDashboardAPI( { access: {}, - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', metadata: { name: 'fake-dash', @@ -661,7 +694,7 @@ describe('DashboardScenePageStateManager v2', () => { setupDashboardAPI( { access: {}, - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', metadata: { name: 'fake-dash', @@ -684,7 +717,7 @@ describe('DashboardScenePageStateManager v2', () => { setupDashboardAPI( { access: {}, - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', metadata: { name: 'fake-dash', @@ -709,7 +742,7 @@ describe('DashboardScenePageStateManager v2', () => { setupDashboardAPI( { access: {}, - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', metadata: { name: 'fake-dash', @@ -740,7 +773,7 @@ describe('DashboardScenePageStateManager v2', () => { setupDashboardAPI( { access: {}, - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', metadata: { name: 'fake-dash', @@ -761,7 +794,7 @@ describe('DashboardScenePageStateManager v2', () => { setupDashboardAPI( { access: {}, - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', metadata: { name: 'fake-dash', @@ -791,7 +824,7 @@ describe('DashboardScenePageStateManager v2', () => { setupDashboardAPI( { access: {}, - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', metadata: { name: 'fake-dash', @@ -825,7 +858,7 @@ describe('DashboardScenePageStateManager v2', () => { setupDashboardAPI( { access: {}, - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', metadata: { name: 'fake-dash', @@ -842,7 +875,7 @@ describe('DashboardScenePageStateManager v2', () => { await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); const mockLoader = { - loadDashboard: jest.fn().mockRejectedValue(new DashboardVersionError('v2alpha1')), + loadDashboard: jest.fn().mockRejectedValue(new DashboardVersionError('v2beta1')), }; loader['dashboardLoader'] = mockLoader as unknown as DashboardLoaderSrvV2; @@ -915,7 +948,7 @@ describe('UnifiedDashboardScenePageStateManager', () => { const originalFetchDashboard = v2Manager.fetchDashboard; v2Manager.fetchDashboard = jest.fn().mockResolvedValue({ access: {}, - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', metadata: { name: 'fake-dash', @@ -951,7 +984,7 @@ describe('UnifiedDashboardScenePageStateManager', () => { // V2 dashboard response const v2Response: DashboardWithAccessInfo = { access: {}, - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', metadata: { name: 'v2-dash', @@ -1215,7 +1248,7 @@ const customHomeDashboardV1Spec = { content: '# Welcome to the home dashboard!\n\n## Example of v2 schema home dashboard', mode: 'markdown', }, - pluginVersion: '', + pluginVersion: '1.0.0', targets: [{ refId: 'A' }], title: 'Welcome', transformations: [], @@ -1272,9 +1305,10 @@ const customHomeDashboardV2Spec = { }, }, vizConfig: { - kind: 'text', + kind: 'VizConfig', + version: '1.0.0', + group: 'text', spec: { - pluginVersion: '', options: { mode: 'markdown', content: '# Welcome to the home dashboard!\n\n## Example of v2 schema home dashboard', @@ -1557,7 +1591,7 @@ const v2ProvisionedDashboardResource = { resource: { type: { group: 'dashboard.grafana.app', - version: 'v2alpha1', + version: 'v2beta1', kind: 'Dashboard', resource: 'dashboards', }, @@ -1565,7 +1599,7 @@ const v2ProvisionedDashboardResource = { existing: {}, action: 'update', dryRun: { - apiVersion: 'dashboard.grafana.app/v2alpha1', + apiVersion: 'dashboard.grafana.app/v2beta1', kind: 'Dashboard', metadata: { annotations: { @@ -1588,9 +1622,11 @@ const v2ProvisionedDashboardResource = { kind: 'AnnotationQuery', spec: { builtIn: true, - datasource: { - type: 'grafana', - uid: '-- Grafana --', + query: { + kind: 'DataQuery', + group: 'grafana', + spec: {}, + version: 'v0', }, enable: true, hide: true, @@ -1613,13 +1649,14 @@ const v2ProvisionedDashboardResource = { { kind: 'PanelQuery', spec: { - datasource: { - type: 'grafana-testdata-datasource', - uid: 'PD8C576611E62080A', - }, hidden: false, query: { - kind: 'grafana-testdata-datasource', + kind: 'DataQuery', + group: 'grafana-testdata-datasource', + version: 'v0', + datasource: { + name: 'PD8C576611E62080A', + }, spec: { scenarioId: 'random_walk', seriesCount: 2, diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 1b2d7f80c2a..63f1aaa0bcd 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -2,7 +2,7 @@ import { locationUtil, UrlQueryMap } from '@grafana/data'; import { t } from '@grafana/i18n'; import { config, getBackendSrv, isFetchError, locationService } from '@grafana/runtime'; import { sceneGraph } from '@grafana/scenes'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { BASE_URL } from 'app/api/clients/provisioning/v0alpha1/baseAPI'; import { StateManagerBase } from 'app/core/services/StateManagerBase'; import { getMessageFromError, getMessageIdFromError, getStatusFromError } from 'app/core/utils/errors'; @@ -15,7 +15,7 @@ import { } from 'app/features/apiserver/types'; import { transformDashboardV2SpecToV1 } from 'app/features/dashboard/api/ResponseTransformers'; import { DashboardVersionError, DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; -import { isDashboardV2Resource, isDashboardV2Spec } from 'app/features/dashboard/api/utils'; +import { isDashboardV2Resource, isDashboardV2Spec, isV2StoredVersion } from 'app/features/dashboard/api/utils'; import { dashboardLoaderSrv, DashboardLoaderSrvV2 } from 'app/features/dashboard/services/DashboardLoaderSrv'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { emitDashboardViewEvent } from 'app/features/dashboard/state/analyticsProcessor'; @@ -204,7 +204,7 @@ abstract class DashboardScenePageStateManagerBase dryRun: any, provisioningPreview: ProvisioningPreview ) { - if (dryRun.apiVersion.split('/')[1] === 'v2alpha1') { + if (dryRun.apiVersion.split('/')[1] === 'v2beta1') { return { ...dryRun, kind: 'DashboardWithAccessInfo', @@ -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({ @@ -751,7 +751,7 @@ export class UnifiedDashboardScenePageStateManager extends DashboardScenePageSta return await operation(this.activeManager); } catch (error) { if (error instanceof DashboardVersionError) { - const manager = error.data.storedVersion === 'v2alpha1' ? this.v2Manager : this.v1Manager; + const manager = isV2StoredVersion(error.data.storedVersion) ? this.v2Manager : this.v1Manager; this.activeManager = manager; return await operation(manager); } else { @@ -797,7 +797,7 @@ export class UnifiedDashboardScenePageStateManager extends DashboardScenePageSta try { return await this.v1Manager.loadSnapshotScene(slug); } catch (error) { - if (error instanceof DashboardVersionError && error.data.storedVersion === 'v2alpha1') { + if (error instanceof DashboardVersionError && isV2StoredVersion(error.data.storedVersion)) { return await this.v2Manager.loadSnapshotScene(slug); } throw new Error('Snapshot not found'); 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/DetectChangesWorker.ts b/public/app/features/dashboard-scene/saving/DetectChangesWorker.ts index 563fe0ef0f4..cb4f8d2f3ec 100644 --- a/public/app/features/dashboard-scene/saving/DetectChangesWorker.ts +++ b/public/app/features/dashboard-scene/saving/DetectChangesWorker.ts @@ -1,5 +1,5 @@ import { Dashboard } from '@grafana/schema'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { jsonDiff } from '../settings/version-history/utils'; diff --git a/public/app/features/dashboard-scene/saving/getDashboardChanges.ts b/public/app/features/dashboard-scene/saving/getDashboardChanges.ts index 17ef35baa60..33c046e84b9 100644 --- a/public/app/features/dashboard-scene/saving/getDashboardChanges.ts +++ b/public/app/features/dashboard-scene/saving/getDashboardChanges.ts @@ -7,7 +7,7 @@ import { AdhocVariableSpec, Spec as DashboardV2Spec, VariableKind, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { ResponseTransformers } from 'app/features/dashboard/api/ResponseTransformers'; import { isDashboardV2Spec } from 'app/features/dashboard/api/utils'; import { DashboardDataDTO, DashboardDTO } from 'app/types/dashboard'; 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/saving/shared.tsx b/public/app/features/dashboard-scene/saving/shared.tsx index 25b636144b3..fece190d860 100644 --- a/public/app/features/dashboard-scene/saving/shared.tsx +++ b/public/app/features/dashboard-scene/saving/shared.tsx @@ -4,7 +4,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { config, isFetchError } from '@grafana/runtime'; import { Dashboard } from '@grafana/schema'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { Alert, Box, Button, Stack } from '@grafana/ui'; import { WorkflowOption } from 'app/features/provisioning/types'; diff --git a/public/app/features/dashboard-scene/saving/useSaveDashboard.ts b/public/app/features/dashboard-scene/saving/useSaveDashboard.ts index 8f75ecf0751..c5e2be5151b 100644 --- a/public/app/features/dashboard-scene/saving/useSaveDashboard.ts +++ b/public/app/features/dashboard-scene/saving/useSaveDashboard.ts @@ -4,7 +4,7 @@ import { locationUtil } from '@grafana/data'; import { t } from '@grafana/i18n'; import { locationService, reportInteraction } from '@grafana/runtime'; import { Dashboard } from '@grafana/schema'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import appEvents from 'app/core/app_events'; import { useAppNotification } from 'app/core/copy/appNotification'; import { updateDashboardName } from 'app/core/reducers/navBarTree'; 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 46e67142648..662d92a32df 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -16,7 +16,7 @@ import { VizPanel, } from '@grafana/scenes'; import { Dashboard, DashboardLink, LibraryPanel } from '@grafana/schema'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import appEvents from 'app/core/app_events'; import { ScrollRefElement } from 'app/core/components/NativeScrollbar'; import { LS_PANEL_COPY_KEY } from 'app/core/constants'; @@ -383,13 +383,8 @@ export class DashboardScene extends SceneObjectBase impleme } public onRestore = async (version: DecoratedRevisionModel): Promise => { - let versionRsp; - if (config.featureToggles.kubernetesClientDashboardsFolders) { - // the id here is the resource version in k8s, use this instead to get the specific version - versionRsp = await historySrv.restoreDashboard(version.uid, version.id); - } else { - versionRsp = await historySrv.restoreDashboard(version.uid, version.version); - } + // the id here is the resource version in k8s, use this instead to get the specific version + let versionRsp = await historySrv.restoreDashboard(version.uid, version.id); if (!Number.isInteger(versionRsp.version)) { return false; @@ -757,7 +752,7 @@ export class DashboardScene extends SceneObjectBase impleme const { meta } = this.state; const spec = this.getSaveAsModel(options); - const apiVersion = this.serializer instanceof V2DashboardSerializer ? 'v2alpha1' : 'v1beta1'; // get from the dashboard? + const apiVersion = this.serializer instanceof V2DashboardSerializer ? 'v2beta1' : 'v1beta1'; // get from the dashboard? return { apiVersion: `dashboard.grafana.app/${apiVersion}`, kind: 'Dashboard', 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/export/exporters.test.ts b/public/app/features/dashboard-scene/scene/export/exporters.test.ts index 23f8ad327af..5194233f54a 100644 --- a/public/app/features/dashboard-scene/scene/export/exporters.test.ts +++ b/public/app/features/dashboard-scene/scene/export/exporters.test.ts @@ -2,13 +2,13 @@ import { find } from 'lodash'; import { DataSourceInstanceSettings, DataSourceRef, PanelPluginMeta, TypedVariableModel } from '@grafana/data'; import { Dashboard, DashboardCursorSync, ThresholdsMode } from '@grafana/schema'; -import { handyTestingSchema } from '@grafana/schema/dist/esm/schema/dashboard/v2_examples'; import { DatasourceVariableKind, LibraryPanelKind, PanelKind, QueryVariableKind, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import { handyTestingSchema } from '@grafana/schema/dist/esm/schema/dashboard/v2_examples'; import config from 'app/core/config'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { createAdHocVariableAdapter } from 'app/features/variables/adhoc/adapter'; @@ -600,13 +600,12 @@ describe('dashboard exporter v2', () => { { kind: 'PanelQuery', spec: { - datasource: { - type: 'prometheus', - uid: '${datasourceVar}', - }, hidden: false, query: { - kind: 'prometheus', + datasource: { + name: '${datasourceVar}', + }, + group: 'prometheus', spec: { editorMode: 'builder', expr: 'go_goroutines{job="prometheus"}', @@ -634,7 +633,7 @@ describe('dashboard exporter v2', () => { it('should replace datasource in a query variable', async () => { const { dashboard } = await setup(); const variable = dashboard.variables[0] as QueryVariableKind; - expect(variable.spec.datasource?.uid).toBeUndefined(); + expect(variable.spec.query.datasource?.name).toBeUndefined(); }); it('do not expose datasource name and id in datasource variable', async () => { @@ -648,7 +647,7 @@ describe('dashboard exporter v2', () => { const { dashboard } = await setup(); const annotationQuery = dashboard.annotations[0]; - expect(annotationQuery.spec.datasource?.uid).toBeUndefined(); + expect(annotationQuery.spec.query?.datasource?.name).toBeUndefined(); }); it('should not remove datasource ref from panel that uses a datasource variable', async () => { @@ -658,11 +657,8 @@ describe('dashboard exporter v2', () => { if (panel.kind !== 'Panel') { throw new Error('Panel should be a Panel'); } - - expect(panel.spec.data.spec.queries[0].spec.datasource).toEqual({ - type: 'prometheus', - uid: '${datasourceVar}', - }); + expect(panel.spec.data.spec.queries[0].spec.query.datasource?.name).toBe('${datasourceVar}'); + expect(panel.spec.data.spec.queries[0].spec.query.group).toBe('prometheus'); }); it('should convert library panels to inline panels when sharing externally', async () => { @@ -698,7 +694,7 @@ describe('dashboard exporter v2', () => { expect(convertedPanel.spec.id).toBe(123); // Check that the panel was properly converted - expect(convertedPanel.spec.data.spec.queries[0].spec.query.kind).toBe('testdb'); + expect(convertedPanel.spec.data.spec.queries[0].spec.query.kind).toBe('DataQuery'); expect(convertedPanel.spec.data.spec.queries[0].spec.refId).toBe('A'); }); @@ -771,7 +767,8 @@ describe('dashboard exporter v2', () => { expect(placeholderPanel.kind).toBe('Panel'); expect((placeholderPanel as PanelKind).spec.id).toBe(125); expect((placeholderPanel as PanelKind).spec.title).toBe('Invalid Library Panel'); - expect((placeholderPanel as PanelKind).spec.vizConfig.kind).toBe('text'); + expect((placeholderPanel as PanelKind).spec.vizConfig.kind).toBe('VizConfig'); + expect((placeholderPanel as PanelKind).spec.vizConfig.group).toBe('text'); // Verify console.error was called expect(consoleSpy).toHaveBeenCalledWith('Failed to load library panel invalid-uid:', expect.any(Error)); diff --git a/public/app/features/dashboard-scene/scene/export/exporters.ts b/public/app/features/dashboard-scene/scene/export/exporters.ts index 5778960dd8b..785cb8dd529 100644 --- a/public/app/features/dashboard-scene/scene/export/exporters.ts +++ b/public/app/features/dashboard-scene/scene/export/exporters.ts @@ -11,7 +11,7 @@ import { QueryVariableKind, LibraryPanelRef, LibraryPanelKind, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { notifyApp } from 'app/core/actions'; import config from 'app/core/config'; import { createErrorNotification } from 'app/core/copy/appNotification'; @@ -378,9 +378,10 @@ async function convertLibraryPanelToInlinePanel(libraryPanelElement: LibraryPane }, }, vizConfig: { - kind: 'text', + kind: 'VizConfig', + group: 'text', + version: '', spec: { - pluginVersion: '', options: { content: `**Library Panel Load Error**\n\nUnable to load library panel: ${libraryPanel.name} (${libraryPanel.uid})\n\nError: ${error instanceof Error ? error.message : 'Unknown error'}`, mode: 'markdown', @@ -406,7 +407,8 @@ export async function makeExportableV2(dashboard: DashboardV2Spec, isSharingExte const removeDataSourceRefs = ( obj: AnnotationQueryKind['spec'] | QueryVariableKind['spec'] | PanelQueryKind['spec'] ) => { - const datasourceUid = obj.datasource?.uid; + const datasourceUid = obj.query?.datasource?.name; + if (datasourceUid?.startsWith('${') && datasourceUid?.endsWith('}')) { const varName = datasourceUid.slice(2, -1); // if there's a match we don't want to remove the datasource ref @@ -416,7 +418,7 @@ export async function makeExportableV2(dashboard: DashboardV2Spec, isSharingExte } } - obj.datasource = undefined; + obj.query && (obj.query.datasource = undefined); }; const processPanel = (panel: PanelKind) => { diff --git a/public/app/features/dashboard-scene/scene/export/utils.ts b/public/app/features/dashboard-scene/scene/export/utils.ts index ef754171363..6f3a35003e9 100644 --- a/public/app/features/dashboard-scene/scene/export/utils.ts +++ b/public/app/features/dashboard-scene/scene/export/utils.ts @@ -1,4 +1,4 @@ -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; /** * Removes a panel reference from a layout. diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx index 8e665bc524a..69ee13494ac 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx @@ -1,7 +1,7 @@ import { t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; import { SceneComponentProps, SceneObject, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { GRID_CELL_VMARGIN } from 'app/core/constants'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; 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 c4e1c481b0e..b85cb2bcf4b 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -17,7 +17,7 @@ import { SceneGridLayoutDragStartEvent, SceneObject, } from '@grafana/scenes'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { useStyles2 } from '@grafana/ui'; import { GRID_COLUMN_COUNT } from 'app/core/constants'; import DashboardEmpty from 'app/features/dashboard/dashgrid/DashboardEmpty'; @@ -237,7 +237,11 @@ export class DefaultGridLayoutManager // when we duplicate a panel we don't want to clone the alert state delete panelData.state.data?.alertState; - const newPanel = new VizPanel({ ...panelState, $data: panelData, key: getVizPanelKeyForPanelId(newPanelId) }); + const newPanel = new VizPanel({ + ...panelState, + $data: panelData, + key: getVizPanelKeyForPanelId(newPanelId), + }); newGridItem = new DashboardGridItem({ x: gridItem.state.x, @@ -252,16 +256,38 @@ export class DefaultGridLayoutManager body: newPanel, }); - if (gridItem.parent instanceof SceneGridRow) { - const row = gridItem.parent; + // No undo/redo support in legacy edit mode + if (!config.featureToggles.dashboardNewLayouts) { + if (gridItem.parent instanceof SceneGridRow) { + const row = gridItem.parent; - row.setState({ children: [...row.state.children, newGridItem] }); - grid.forceRender(); + row.setState({ children: [...row.state.children, newGridItem] }); + grid.forceRender(); + return; + } + + grid.setState({ children: [...grid.state.children, newGridItem] }); + this.publishEvent(new NewObjectAddedToCanvasEvent(newPanel), true); return; } - grid.setState({ children: [...grid.state.children, newGridItem] }); - this.publishEvent(new NewObjectAddedToCanvasEvent(newPanel), true); + const parent = gridItem.parent instanceof SceneGridRow ? gridItem.parent : grid; + dashboardEditActions.edit({ + description: t('dashboard.edit-actions.duplicate-panel', 'Duplicate panel'), + addedObject: newGridItem.state.body, + source: this, + perform: () => { + const oldGridItemIndex = parent.state.children.indexOf(gridItem); + const newChildrenArray = [...parent.state.children]; + newChildrenArray.splice(oldGridItemIndex + 1, 0, newGridItem); + parent.setState({ children: newChildrenArray }); + }, + undo: () => { + parent.setState({ + children: parent.state.children.filter((child) => child !== newGridItem), + }); + }, + }); } public duplicate(): DashboardLayoutManager { diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx index c1b81b2138e..7bbc2ffebe3 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx @@ -9,7 +9,7 @@ import { VariableDependencyConfig, VizPanel, } from '@grafana/scenes'; -import { RowsLayoutRowKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { RowsLayoutRowKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import appEvents from 'app/core/app_events'; import { LS_ROW_COPY_KEY } from 'app/core/constants'; import store from 'app/core/store'; 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 f72262af37b..34b2a9e3a1a 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx @@ -8,7 +8,7 @@ import { SceneObjectState, VizPanel, } from '@grafana/scenes'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { dashboardEditActions, ObjectsReorderedOnCanvasEvent } from '../../edit-pane/shared'; import { serializeRowsLayout } from '../../serialization/layoutSerializers/RowsLayoutSerializer'; diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx index 7335bb7563e..7ebc9e07b1c 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx @@ -9,7 +9,7 @@ import { SceneObject, VizPanel, } from '@grafana/scenes'; -import { TabsLayoutTabKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { TabsLayoutTabKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { LS_TAB_COPY_KEY } from 'app/core/constants'; import { appEvents } from 'app/core/core'; import store from 'app/core/store'; diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx index 3abfb5e937d..f83a91748ed 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx @@ -7,7 +7,7 @@ import { SceneObjectUrlValues, VizPanel, } from '@grafana/scenes'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { dashboardEditActions, ObjectsReorderedOnCanvasEvent } from '../../edit-pane/shared'; import { serializeTabsLayout } from '../../serialization/layoutSerializers/TabsLayoutSerializer'; diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/paste.ts b/public/app/features/dashboard-scene/scene/layouts-shared/paste.ts index 9b6300db217..9e8328f14c4 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/paste.ts +++ b/public/app/features/dashboard-scene/scene/layouts-shared/paste.ts @@ -4,7 +4,7 @@ import { GridLayoutItemKind, RowsLayoutRowKind, TabsLayoutTabKind, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { LS_PANEL_COPY_KEY, LS_ROW_COPY_KEY, LS_TAB_COPY_KEY } from 'app/core/constants'; import store from 'app/core/store'; diff --git a/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts b/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts index 679404b6cec..73a9d7329e0 100644 --- a/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts +++ b/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts @@ -1,5 +1,5 @@ import { SceneObject, VizPanel } from '@grafana/scenes'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; import { LayoutRegistryItem } from './LayoutRegistryItem'; diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts index 86dfce4fc4e..5987962f3ff 100644 --- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts +++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts @@ -10,11 +10,14 @@ import { Dashboard, VariableModel } from '@grafana/schema'; import { Spec as DashboardV2Spec, defaultSpec as defaultDashboardV2Spec, + defaultDataQueryKind, defaultPanelSpec, defaultTimeSettingsSpec, GridLayoutKind, + PanelKind, PanelSpec, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; + QueryVariableKind, +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui'; import { AnnoKeyDashboardSnapshotOriginalUrl } from 'app/features/apiserver/types'; import { SaveDashboardAsOptions } from 'app/features/dashboard/components/SaveDashboard/types'; @@ -50,11 +53,13 @@ jest.mock('@grafana/runtime', () => ({ name: 'Grafana', meta: { id: 'grafana' }, type: 'datasource', + uid: 'grafana', }, prometheus: { name: 'prometheus', meta: { id: 'prometheus' }, type: 'datasource', + uid: 'prometheus-uid', }, }, }, @@ -563,15 +568,15 @@ describe('DashboardSceneSerializer', () => { variables: [ { kind: 'GroupByVariable', + group: 'ds', + datasource: { + name: 'ds-uid', + }, spec: { current: { text: 'Host', value: 'host', }, - datasource: { - type: 'ds', - uid: 'ds-uid', - }, name: 'GroupBy', options: [ { @@ -605,14 +610,14 @@ describe('DashboardSceneSerializer', () => { variables: [ { kind: 'AdhocVariable', + group: 'prometheus', + datasource: { + name: 'gdev-prometheus', + }, spec: { name: 'adhoc', label: 'Adhoc Label', description: 'Adhoc Description', - datasource: { - uid: 'gdev-prometheus', - type: 'prometheus', - }, hide: 'dontHide', skipUrlSync: false, filters: [], @@ -726,9 +731,14 @@ describe('DashboardSceneSerializer', () => { spec: { builtIn: true, name: 'Annotations & Alerts', - datasource: { - uid: '-- Grafana --', - type: 'grafana', + query: { + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'grafana', + datasource: { + name: '-- Grafana --', + }, + spec: {}, }, enable: true, hide: true, @@ -857,14 +867,15 @@ describe('DashboardSceneSerializer', () => { id: 1, title: 'Panel 1', vizConfig: { - kind: 'graph', + kind: 'VizConfig', + group: 'graph', + version: '1.0.0', spec: { fieldConfig: { defaults: { custom: { lineWidth: 2 } }, overrides: [], }, options: { legend: { show: true } }, - pluginVersion: '1.0.0', }, }, }, @@ -876,17 +887,238 @@ describe('DashboardSceneSerializer', () => { const panelSpec = saveAsModel.elements['panel-1'].spec as PanelSpec; expect(panelSpec.vizConfig).toMatchObject({ - kind: 'graph', + kind: 'VizConfig', + group: 'graph', + version: '1.0.0', spec: { fieldConfig: { defaults: { custom: { lineWidth: 2 } }, overrides: [], }, options: { legend: { show: true } }, - pluginVersion: '1.0.0', }, }); }); + + describe('data source references persistence', () => { + it('should not fill data source references for annotations when input did not contain it', () => { + const dashboard = setupV2({ + annotations: [ + { + kind: 'AnnotationQuery', + spec: { + builtIn: false, + enable: true, + hide: false, + iconColor: 'blue', + name: 'prom-annotations', + query: { + group: 'prometheus', + kind: 'DataQuery', + spec: { + refId: 'Anno', + }, + version: 'v0', + }, + }, + }, + ], + }); + const saveAsModel = serializer.getSaveAsModel(dashboard, baseOptions); + // referencing index 1 as transformation adds built in annotation query + expect(saveAsModel.annotations[1].spec.query.datasource).toBeUndefined(); + }); + + it('should fill data source references for annotations when input did contain it', () => { + const dashboard = setupV2({ + annotations: [ + { + kind: 'AnnotationQuery', + spec: { + builtIn: false, + enable: true, + hide: false, + iconColor: 'blue', + name: 'prom-annotations', + query: { + group: 'prometheus', + kind: 'DataQuery', + datasource: { + name: 'prometheus-uid', + }, + spec: { + refId: 'Anno', + }, + version: 'v0', + }, + }, + }, + ], + }); + const saveAsModel = serializer.getSaveAsModel(dashboard, baseOptions); + // referencing index 1 as transformation adds built in annotation query + expect(saveAsModel.annotations[1].spec.query?.datasource).toEqual({ + name: 'prometheus-uid', + }); + }); + it('should not fill data source references for panel queries when input did not contain it', () => { + const dashboard = setupV2({ + elements: { + 'panel-1': { + kind: 'Panel', + spec: { + ...defaultPanelSpec(), + data: { + kind: 'QueryGroup', + spec: { + transformations: [], + queryOptions: {}, + queries: [ + { + kind: 'PanelQuery', + spec: { + refId: 'A', + hidden: false, + query: { + kind: 'DataQuery', + group: 'prometheus', + version: 'v0', + spec: {}, + }, + }, + }, + ], + }, + }, + }, + }, + }, + }); + const saveAsModel = serializer.getSaveAsModel(dashboard, baseOptions); + expect( + (saveAsModel.elements['panel-1'] as PanelKind).spec.data.spec.queries[0].spec.query.datasource + ).toBeUndefined(); + }); + + it('should fill data source references for panel queries when input did contain it', () => { + const dashboard = setupV2({ + elements: { + 'panel-1': { + kind: 'Panel', + spec: { + ...defaultPanelSpec(), + data: { + kind: 'QueryGroup', + spec: { + transformations: [], + queryOptions: {}, + queries: [ + { + kind: 'PanelQuery', + spec: { + refId: 'A', + hidden: false, + query: { + kind: 'DataQuery', + group: 'prometheus', + version: 'v0', + datasource: { + name: 'prometheus-uid', + }, + spec: {}, + }, + }, + }, + ], + }, + }, + }, + }, + }, + }); + const saveAsModel = serializer.getSaveAsModel(dashboard, baseOptions); + expect( + (saveAsModel.elements['panel-1'] as PanelKind).spec.data.spec.queries[0].spec.query.datasource + ).toEqual({ + name: 'prometheus-uid', + }); + }); + + it('should not fill data source references for query variables when input did contain it', () => { + const queryVariable: QueryVariableKind = { + kind: 'QueryVariable', + spec: { + name: 'app', + current: { + text: 'app1', + value: 'app1', + }, + hide: 'dontHide', + includeAll: false, + label: 'Query Variable', + skipUrlSync: false, + regex: '', + definition: '', + options: [], + refresh: 'never', + sort: 'alphabeticalAsc', + multi: false, + allowCustomValue: true, + query: { + kind: 'DataQuery', + group: 'prometheus', + version: 'v0', + spec: {}, + }, + }, + }; + const dashboard = setupV2({ + variables: [queryVariable], + }); + const saveAsModel = serializer.getSaveAsModel(dashboard, baseOptions); + expect((saveAsModel.variables[0] as QueryVariableKind).spec.query.datasource).toBeUndefined(); + }); + + it('should fill data source references for query variables when input did contain it', () => { + const queryVariable: QueryVariableKind = { + kind: 'QueryVariable', + spec: { + name: 'app', + current: { + text: 'app1', + value: 'app1', + }, + hide: 'dontHide', + includeAll: false, + label: 'Query Variable', + skipUrlSync: false, + regex: '', + definition: '', + options: [], + refresh: 'never', + sort: 'alphabeticalAsc', + multi: false, + allowCustomValue: true, + query: { + kind: 'DataQuery', + group: 'prometheus', + version: 'v0', + datasource: { + name: 'prometheus-uid', + }, + spec: {}, + }, + }, + }; + const dashboard = setupV2({ + variables: [queryVariable], + }); + const saveAsModel = serializer.getSaveAsModel(dashboard, baseOptions); + expect((saveAsModel.variables[0] as QueryVariableKind).spec.query.datasource).toEqual({ + name: 'prometheus-uid', + }); + }); + }); }); describe('panel mapping methods', () => { @@ -1000,9 +1232,10 @@ describe('DashboardSceneSerializer', () => { description: '', links: [], vizConfig: { - kind: 'timeseries', + kind: 'VizConfig', + group: 'timeseries', + version: '1.0.0', spec: { - pluginVersion: '1.0.0', options: {}, fieldConfig: { defaults: {}, overrides: [] }, }, @@ -1017,7 +1250,7 @@ describe('DashboardSceneSerializer', () => { refId: 'A', hidden: false, // No datasource defined - query: { kind: 'sql', spec: {} }, + query: { kind: 'DataQuery', version: defaultDataQueryKind().version, group: 'sql', spec: {} }, }, }, { @@ -1025,8 +1258,15 @@ describe('DashboardSceneSerializer', () => { spec: { refId: 'B', hidden: false, - datasource: { uid: 'datasource-1', type: 'prometheus' }, - query: { kind: 'prometheus', spec: {} }, + query: { + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'prometheus', + datasource: { + name: 'datasource-1', + }, + spec: {}, + }, }, }, ], @@ -1044,9 +1284,10 @@ describe('DashboardSceneSerializer', () => { description: '', links: [], vizConfig: { - kind: 'timeseries', + kind: 'VizConfig', + group: 'timeseries', + version: '1.0.0', spec: { - pluginVersion: '1.0.0', options: {}, fieldConfig: { defaults: {}, overrides: [] }, }, @@ -1061,7 +1302,7 @@ describe('DashboardSceneSerializer', () => { refId: 'C', hidden: false, // No datasource defined - query: { kind: 'sql', spec: {} }, + query: { kind: 'DataQuery', version: defaultDataQueryKind().version, group: 'sql', spec: {} }, }, }, ], @@ -1109,7 +1350,7 @@ describe('DashboardSceneSerializer', () => { kind: 'AnnotationQuery', spec: { name: 'Annotation 1', - query: { kind: 'prometheus', spec: {} }, + query: { kind: 'DataQuery', version: defaultDataQueryKind().version, group: 'prometheus', spec: {} }, enable: true, hide: false, iconColor: 'red', diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts index 66d73dd2ff9..20dafa839b0 100644 --- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts @@ -1,5 +1,5 @@ import { Dashboard } from '@grafana/schema'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { AnnoKeyDashboardSnapshotOriginalUrl, ObjectMeta } from 'app/features/apiserver/types'; import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { isDashboardV2Spec } from 'app/features/dashboard/api/utils'; @@ -288,7 +288,7 @@ export class V2DashboardSerializer const panelQueries = elementPanel.spec.data.spec.queries; for (const query of panelQueries) { - if (!query.spec.datasource) { + if (!query.spec.query.datasource?.name) { const elementId = this.getElementIdForPanel(elementPanel.spec.id); if (!this.defaultDsReferencesMap.panels.has(elementId)) { this.defaultDsReferencesMap.panels.set(elementId, new Set()); @@ -306,7 +306,7 @@ export class V2DashboardSerializer if (saveModel?.variables) { for (const variable of saveModel.variables) { // for query variables that dont have a ds defined add them to the list - if (variable.kind === 'QueryVariable' && !variable.spec.datasource) { + if (variable.kind === 'QueryVariable' && !variable.spec.query.datasource?.name) { this.defaultDsReferencesMap.variables.add(variable.spec.name); } } @@ -315,7 +315,7 @@ export class V2DashboardSerializer // initialize annotations ds references map if (saveModel?.annotations) { for (const annotation of saveModel.annotations) { - if (!annotation.spec.datasource) { + if (!annotation.spec.query?.datasource?.name) { this.defaultDsReferencesMap.annotations.add(annotation.spec.name); } } @@ -409,7 +409,7 @@ export class V2DashboardSerializer 'elements' in this.initialSaveModel ? Object.values(this.initialSaveModel.elements) .filter((e) => e.kind === 'Panel') - .map((p) => p.spec.vizConfig.kind) + .map((p) => p.spec.vizConfig.group) : []; const panels = getPanelPluginCounts(panelPluginIds); const variables = diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap index cc3a40896c7..6bddc68fb09 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap @@ -7,38 +7,38 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model "kind": "AnnotationQuery", "spec": { "builtIn": false, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --", - }, "enable": true, "hide": false, "iconColor": "red", "name": "query1", + "query": { + "datasource": { + "name": "-- Grafana --", + }, + "group": "grafana", + "kind": "DataQuery", + "spec": {}, + "version": "v0", + }, }, }, { "kind": "AnnotationQuery", "spec": { "builtIn": false, - "datasource": { - "type": "prometheus", - "uid": "abcdef", - }, "enable": true, "hide": true, "iconColor": "blue", "name": "query2", - }, - }, - { - "kind": "AnnotationQuery", - "spec": { - "builtIn": false, - "enable": true, - "hide": true, - "iconColor": "green", - "name": "query3", + "query": { + "datasource": { + "name": "abcdef", + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": {}, + "version": "v0", + }, }, }, ], @@ -73,15 +73,16 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model "title": "Test Panel", "transparent": true, "vizConfig": { - "kind": "timeseries", + "group": "timeseries", + "kind": "VizConfig", "spec": { "fieldConfig": { "defaults": {}, "overrides": [], }, "options": {}, - "pluginVersion": "7.0.0", }, + "version": "7.0.0", }, }, }, @@ -152,10 +153,6 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model "text": "text1", "value": "value1", }, - "datasource": { - "type": "prometheus", - "uid": "datasource1", - }, "definition": "definition1", "description": "A query variable", "hide": "hideLabel", @@ -165,11 +162,16 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model "name": "queryVar", "options": [], "query": { - "kind": "prometheus", + "datasource": { + "name": "datasource1", + }, + "group": "prometheus", + "kind": "DataQuery", "spec": { "expr": "label_values(node_boot_time_seconds)", "refId": "A", }, + "version": "v0", }, "refresh": "onDashboardLoad", "regex": "regex1", @@ -296,16 +298,16 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model }, }, { + "datasource": { + "name": "datasource2", + }, + "group": "prometheus", "kind": "GroupByVariable", "spec": { "current": { "text": "text7", "value": "value7", }, - "datasource": { - "type": "prometheus", - "uid": "datasource2", - }, "description": "A group by variable", "hide": "dontHide", "label": "Group By Variable", @@ -325,6 +327,10 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model }, }, { + "datasource": { + "name": "datasource3", + }, + "group": "prometheus", "kind": "AdhocVariable", "spec": { "allowCustomValue": true, @@ -342,10 +348,6 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model "value": "value2", }, ], - "datasource": { - "type": "prometheus", - "uid": "datasource3", - }, "defaultKeys": [ { "expandable": true, diff --git a/public/app/features/dashboard-scene/serialization/annotations.test.ts b/public/app/features/dashboard-scene/serialization/annotations.test.ts new file mode 100644 index 00000000000..0c92314324e --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/annotations.test.ts @@ -0,0 +1,226 @@ +import { AnnotationQuery } from '@grafana/data'; +import { AnnotationQueryKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; + +import { transformV1ToV2AnnotationQuery, transformV2ToV1AnnotationQuery } from './annotations'; + +describe('V1<->V2 annotation convertions', () => { + test('given grafana-built in annotations', () => { + // test case + const annotationDefinition: AnnotationQuery = { + builtIn: 1, + datasource: { + type: 'grafana', + uid: 'grafana', + }, + enable: true, + hide: false, + iconColor: 'yellow', + name: 'Annotations \u0026 Alerts', + target: { + // @ts-expect-error + limit: 100, + matchAny: false, + tags: [], + type: 'dashboard', + }, + type: 'dashboard', + }; + + const expectedV2: AnnotationQueryKind = { + kind: 'AnnotationQuery', + spec: { + builtIn: true, + enable: true, + hide: false, + iconColor: 'yellow', + name: 'Annotations \u0026 Alerts', + query: { + kind: 'DataQuery', + group: 'grafana', + version: 'v0', + datasource: { + name: 'grafana', + }, + spec: { + limit: 100, + matchAny: false, + tags: [], + type: 'dashboard', + }, + }, + }, + }; + + const resultV2: AnnotationQueryKind = transformV1ToV2AnnotationQuery(annotationDefinition, 'grafana', 'grafana'); + + expect(resultV2).toEqual(expectedV2); + + const resultV1: AnnotationQuery = transformV2ToV1AnnotationQuery(expectedV2); + expect(resultV1).toEqual(annotationDefinition); + }); + + test('given annotations with datasource', () => { + const annotationDefinition = { + datasource: { + type: 'grafana-testdata-datasource', + uid: 'uid', + }, + enable: true, + hide: false, + iconColor: 'blue', + name: 'testdata-annos', + target: { + lines: 10, + refId: 'Anno', + scenarioId: 'annotations', + }, + }; + + const expectedV2: AnnotationQueryKind = { + kind: 'AnnotationQuery', + spec: { + enable: true, + hide: false, + iconColor: 'blue', + name: 'testdata-annos', + builtIn: false, + query: { + kind: 'DataQuery', + group: 'grafana-testdata-datasource', + version: 'v0', + datasource: { + name: 'uid', + }, + spec: { + lines: 10, + refId: 'Anno', + scenarioId: 'annotations', + }, + }, + }, + }; + + const resultV2: AnnotationQueryKind = transformV1ToV2AnnotationQuery( + annotationDefinition, + 'grafana-testdata-datasource', + 'uid' + ); + + expect(resultV2).toEqual(expectedV2); + + const resultV1: AnnotationQuery = transformV2ToV1AnnotationQuery(expectedV2); + expect(resultV1).toEqual(annotationDefinition); + }); + + test('given annotations with target', () => { + const annotationDefinition = { + datasource: { + type: 'prometheus', + uid: 'uid', + }, + enable: true, + hide: false, + iconColor: 'yellow', + name: 'prom-annos', + target: { + expr: '{action="add_client"}', + interval: '', + lines: 10, + refId: 'Anno', + scenarioId: 'annotations', + }, + }; + + const expectedV2: AnnotationQueryKind = { + kind: 'AnnotationQuery', + spec: { + enable: true, + hide: false, + iconColor: 'yellow', + name: 'prom-annos', + builtIn: false, + query: { + kind: 'DataQuery', + group: 'prometheus', + version: 'v0', + datasource: { + name: 'uid', + }, + spec: { + expr: '{action="add_client"}', + interval: '', + lines: 10, + refId: 'Anno', + scenarioId: 'annotations', + }, + }, + }, + }; + + const resultV2: AnnotationQueryKind = transformV1ToV2AnnotationQuery(annotationDefinition, 'prometheus', 'uid'); + expect(resultV2).toEqual(expectedV2); + + const resultV1: AnnotationQuery = transformV2ToV1AnnotationQuery(expectedV2); + expect(resultV1).toEqual(annotationDefinition); + }); + + test('given annotations with non-schematised options / legacyOptions', () => { + const annotationDefinition = { + datasource: { + type: 'elasticsearch', + uid: 'uid', + }, + enable: true, + hide: false, + iconColor: 'red', + name: 'elastic - annos', + tagsField: 'asd', + target: { + lines: 10, + query: 'test query', + refId: 'Anno', + scenarioId: 'annotations', + }, + textField: 'asd', + timeEndField: 'asdas', + timeField: 'asd', + }; + + const expectedV2: AnnotationQueryKind = { + kind: 'AnnotationQuery', + spec: { + enable: true, + hide: false, + iconColor: 'red', + name: 'elastic - annos', + builtIn: false, + query: { + kind: 'DataQuery', + group: 'elasticsearch', + version: 'v0', + datasource: { + name: 'uid', + }, + spec: { + lines: 10, + query: 'test query', + refId: 'Anno', + scenarioId: 'annotations', + }, + }, + legacyOptions: { + tagsField: 'asd', + textField: 'asd', + timeEndField: 'asdas', + timeField: 'asd', + }, + }, + }; + + const resultV2: AnnotationQueryKind = transformV1ToV2AnnotationQuery(annotationDefinition, 'elasticsearch', 'uid'); + expect(resultV2).toEqual(expectedV2); + + const resultV1: AnnotationQuery = transformV2ToV1AnnotationQuery(expectedV2); + expect(resultV1).toEqual(annotationDefinition); + }); +}); diff --git a/public/app/features/dashboard-scene/serialization/annotations.ts b/public/app/features/dashboard-scene/serialization/annotations.ts new file mode 100644 index 00000000000..4e9d296f46c --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/annotations.ts @@ -0,0 +1,115 @@ +import { AnnotationQuery } from '@grafana/data'; +import { AnnotationQueryKind, defaultDataQueryKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; + +import { getRuntimePanelDataSource } from './layoutSerializers/utils'; + +export function transformV1ToV2AnnotationQuery( + annotation: AnnotationQuery, + + dsType: string, + dsUID?: string, + // Overrides are used to provide properties based on scene's annotations data layer object state + override?: Partial +): AnnotationQueryKind { + const group = annotation.builtIn ? 'grafana' : dsType; + + const { + // known properties documented in v1 schema + enable, + hide, + iconColor, + name, + builtIn, + filter, + mappings, + datasource, + target, + snapshotData, + type, + + // unknown properties that are still available for configuration through API + ...legacyOptions + } = annotation; + + const result: AnnotationQueryKind = { + kind: 'AnnotationQuery', + spec: { + builtIn: Boolean(annotation.builtIn), + name: annotation.name, + enable: Boolean(override?.enable) || Boolean(annotation.enable), + hide: Boolean(override?.hide) || Boolean(annotation.hide), + iconColor: annotation.iconColor, + + query: { + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group, // Annotation layer has a datasource type provided in runtime. + spec: target || {}, + }, + }, + }; + + if (dsUID) { + result.spec.query.datasource = { + name: dsUID, + }; + } + + // if legacy options is not an empty object, add it to the result + if (Object.keys(legacyOptions).length > 0) { + result.spec.legacyOptions = legacyOptions; + } + + if (annotation.filter?.ids?.length) { + result.spec.filter = annotation.filter; + } + + // TODO: add mappings + + return result; +} + +export function transformV2ToV1AnnotationQuery(annotation: AnnotationQueryKind): AnnotationQuery { + let { query: dataQuery, ...annotationQuery } = annotation.spec; + + // Mapping from AnnotationQueryKind to AnnotationQuery used by scenes. + let annoQuerySpec: AnnotationQuery = { + enable: annotation.spec.enable, + hide: annotation.spec.hide, + iconColor: annotation.spec.iconColor, + name: annotation.spec.name, + // TOOO: mappings + }; + + if (Object.keys(dataQuery.spec).length > 0) { + // @ts-expect-error DataQueryKind spec should be typed as DataQuery interface + annoQuerySpec.target = { + ...dataQuery?.spec, + }; + } + + if (annotation.spec.builtIn) { + annoQuerySpec.type = 'dashboard'; + annoQuerySpec.builtIn = 1; + } + + if (annotation.spec.filter) { + annoQuerySpec.filter = annotation.spec.filter; + } + + // some annotations will contain in the legacyOptions properties that need to be + // added to the root level AnnotationQuery + if (annotationQuery.legacyOptions) { + annoQuerySpec = { + ...annoQuerySpec, + ...annotationQuery.legacyOptions, + }; + } + + // get data source from annotation query + const datasource = getRuntimePanelDataSource(dataQuery); + + annoQuerySpec.datasource = datasource; + + return annoQuerySpec; +} diff --git a/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts b/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts index 436563c8379..f690d58b035 100644 --- a/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts @@ -9,7 +9,7 @@ import { defaultTimeSettingsSpec, GroupByVariableKind, Spec as DashboardV2Spec, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { AnnoKeyFolder } from 'app/features/apiserver/types'; import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; @@ -92,14 +92,21 @@ export async function buildNewDashboardSaveModelV2( const filterVariable: AdhocVariableKind = { kind: 'AdhocVariable', - spec: { ...defaultAdhocVariableSpec(), name: 'Filter', datasource: datasourceRef }, + group: datasourceRef.type, + datasource: { + name: datasourceRef.uid, + }, + spec: { ...defaultAdhocVariableSpec(), name: 'Filter' }, }; const groupByVariable: GroupByVariableKind = { kind: 'GroupByVariable', + group: datasourceRef.type, + datasource: { + name: datasourceRef.uid, + }, spec: { ...defaultGroupByVariableSpec(), - datasource: datasourceRef, name: 'Group by', }, }; @@ -109,7 +116,7 @@ export async function buildNewDashboardSaveModelV2( } const data: DashboardWithAccessInfo = { - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', spec: { ...defaultDashboardV2Spec(), diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/AutoGridLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/AutoGridLayoutSerializer.ts index 543cff7dfad..f095e54f962 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/AutoGridLayoutSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/AutoGridLayoutSerializer.ts @@ -2,7 +2,7 @@ import { Spec as DashboardV2Spec, defaultAutoGridLayoutSpec, AutoGridLayoutItemKind, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { AutoGridItem } from '../../scene/layout-auto-grid/AutoGridItem'; import { AutoGridLayout } from '../../scene/layout-auto-grid/AutoGridLayout'; diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/DefaultGridLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/DefaultGridLayoutSerializer.ts index a1eea785230..8688d1cbb08 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/DefaultGridLayoutSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/DefaultGridLayoutSerializer.ts @@ -8,7 +8,7 @@ import { GridLayoutItemSpec, PanelKind, LibraryPanelKind, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { contextSrv } from 'app/core/core'; import { DashboardGridItem } from '../../scene/layout-default/DashboardGridItem'; diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts index 9a41a35bb2b..e8506e21278 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts @@ -1,5 +1,5 @@ import { SceneGridLayout } from '@grafana/scenes'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { AutoGridLayout } from '../../scene/layout-auto-grid/AutoGridLayout'; import { AutoGridLayoutManager } from '../../scene/layout-auto-grid/AutoGridLayoutManager'; diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts index 0b039b09a63..284d0a4fec2 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts @@ -1,7 +1,4 @@ -import { - Spec as DashboardV2Spec, - RowsLayoutRowKind, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec, RowsLayoutRowKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { RowItem } from '../../scene/layout-rows/RowItem'; import { RowsLayoutManager } from '../../scene/layout-rows/RowsLayoutManager'; diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.test.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.test.ts index 7132e4dc118..26fd92e1dce 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.test.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.test.ts @@ -1,4 +1,4 @@ -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { AutoGridLayoutManager } from '../../scene/layout-auto-grid/AutoGridLayoutManager'; import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGridLayoutManager'; diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts index 9a8cbe50bdb..bfcec4787b3 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts @@ -1,7 +1,4 @@ -import { - Spec as DashboardV2Spec, - TabsLayoutTabKind, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec, TabsLayoutTabKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { TabItem } from '../../scene/layout-tabs/TabItem'; import { TabItemRepeaterBehavior } from '../../scene/layout-tabs/TabItemRepeaterBehavior'; diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/layoutSerializerRegistry.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/layoutSerializerRegistry.ts index dfb8555e44f..d8e20de967d 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/layoutSerializerRegistry.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/layoutSerializerRegistry.ts @@ -1,5 +1,5 @@ import { Registry, RegistryItem } from '@grafana/data'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { DashboardLayoutManager } from '../../scene/types/DashboardLayoutManager'; diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.test.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.test.ts index fce584cf4f7..1c177f84996 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.test.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.test.ts @@ -1,4 +1,4 @@ -import { PanelQueryKind, PanelKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { defaultDataQueryKind, PanelQueryKind, PanelKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { CustomTimeRangeCompare } from '../../scene/CustomTimeRangeCompare'; @@ -31,6 +31,12 @@ jest.mock('@grafana/runtime', () => ({ meta: { id: 'loki' }, type: 'datasource', }, + '-- Grafana --': { + uid: 'grafana', + name: 'Grafana', + meta: { id: 'grafana' }, + type: 'datasource', + }, }, }, }, @@ -53,11 +59,12 @@ const createTestPanel = (): PanelKind => ({ title: 'Test Panel', description: '', vizConfig: { - kind: 'timeseries', + kind: 'VizConfig', + group: 'timeseries', + version: '1.0.0', spec: { options: {}, fieldConfig: { defaults: {}, overrides: [] }, - pluginVersion: '1.0.0', }, }, data: { @@ -116,22 +123,23 @@ describe('getRuntimePanelDataSource', () => { spec: { refId: 'A', hidden: false, - datasource: { - uid: 'test-ds-uid', - type: 'test-ds-type', - }, query: { - kind: 'prometheus', + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'prometheus', + datasource: { + name: 'prometheus-uid', + }, spec: {}, }, }, }; - const result = getRuntimePanelDataSource(query); + const result = getRuntimePanelDataSource(query.spec.query); expect(result).toEqual({ - uid: 'test-ds-uid', - type: 'test-ds-type', + uid: 'prometheus-uid', + type: 'prometheus', }); }); @@ -141,15 +149,16 @@ describe('getRuntimePanelDataSource', () => { spec: { refId: 'A', hidden: false, - datasource: undefined, query: { - kind: 'prometheus', + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'prometheus', spec: {}, }, }, }; - const result = getRuntimePanelDataSource(query); + const result = getRuntimePanelDataSource(query.spec.query); expect(result).toEqual({ uid: 'default-prometheus-uid', @@ -163,15 +172,16 @@ describe('getRuntimePanelDataSource', () => { spec: { refId: 'A', hidden: false, - datasource: undefined, query: { - kind: 'loki', + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'loki', spec: {}, }, }, }; - const result = getRuntimePanelDataSource(query); + const result = getRuntimePanelDataSource(query.spec.query); expect(result).toEqual({ uid: 'loki-uid', @@ -186,15 +196,16 @@ describe('getRuntimePanelDataSource', () => { spec: { refId: 'A', hidden: false, - datasource: undefined, query: { - kind: 'unknown-type', + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'unknown-type', spec: {}, }, }, }; - const result = getRuntimePanelDataSource(query); + const result = getRuntimePanelDataSource(query.spec.query); expect(result).toEqual({ uid: 'default-prometheus-uid', @@ -212,18 +223,19 @@ describe('getRuntimePanelDataSource', () => { spec: { refId: 'A', hidden: false, - datasource: { - uid: '', - type: 'test-ds-type', - }, query: { - kind: 'prometheus', + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'prometheus', + datasource: { + name: '', + }, spec: {}, }, }, }; - const result = getRuntimePanelDataSource(query); + const result = getRuntimePanelDataSource(query.spec.query); expect(result).toEqual({ uid: 'default-prometheus-uid', diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts index b3b3325ab21..2a3208bac14 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts @@ -19,7 +19,8 @@ import { PanelQueryKind, QueryVariableKind, TabsLayoutTabKind, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; + DataQueryKind, +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { ConditionalRendering } from '../../conditional-rendering/ConditionalRendering'; @@ -61,10 +62,10 @@ export function buildVizPanel(panel: PanelKind, id?: number): VizPanel { key: getVizPanelKeyForPanelId(id ?? panel.spec.id), title: panel.spec.title?.substring(0, 5000), description: panel.spec.description, - pluginId: panel.spec.vizConfig.kind, + pluginId: panel.spec.vizConfig.group, options: panel.spec.vizConfig.spec.options, fieldConfig: transformMappingsToV1(panel.spec.vizConfig.spec.fieldConfig), - pluginVersion: panel.spec.vizConfig.spec.pluginVersion, + pluginVersion: panel.spec.vizConfig.version, displayMode: panel.spec.transparent ? 'transparent' : 'default', hoverHeader: !panel.spec.title && !timeOverrideShown, hoverHeaderOffset: 0, @@ -189,12 +190,15 @@ function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined { panel.spec.data.spec.queries.forEach((query) => { if (!datasource) { - if (!query.spec.datasource?.uid) { - datasource = getRuntimePanelDataSource(query); + if (!query.spec.query.datasource?.name) { + datasource = getRuntimePanelDataSource(query.spec.query); } else { - datasource = query.spec.datasource; + datasource = { + uid: query.spec.query.datasource?.name, + type: query.spec.query.group, + }; } - } else if (datasource.uid !== query.spec.datasource?.uid || datasource.type !== query.spec.datasource?.type) { + } else if (datasource.uid !== query.spec.query.datasource?.name || datasource.type !== query.spec.query.group) { isMixedDatasource = true; } }); @@ -203,11 +207,19 @@ function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined { } export function getRuntimeVariableDataSource(variable: QueryVariableKind): DataSourceRef | undefined { - return getDataSourceForQuery(variable.spec.datasource, variable.spec.query.kind); + const ds: DataSourceRef = { + uid: variable.spec.query.datasource?.name, + type: variable.spec.query.group, + }; + return getDataSourceForQuery(ds, variable.spec.query.group); } -export function getRuntimePanelDataSource(query: PanelQueryKind): DataSourceRef | undefined { - return getDataSourceForQuery(query.spec.datasource, query.spec.query.kind); +export function getRuntimePanelDataSource(query: DataQueryKind): DataSourceRef { + const ds: DataSourceRef = { + uid: query.datasource?.name, + type: query.group, + }; + return getDataSourceForQuery(ds, query.group); } /** @@ -215,10 +227,7 @@ export function getRuntimePanelDataSource(query: PanelQueryKind): DataSourceRef * @param queryKind - The kind of query being performed * @returns The resolved DataSourceRef */ -function getDataSourceForQuery( - querySpecDS: DataSourceRef | undefined | null, - queryKind: string -): DataSourceRef | undefined { +export function getDataSourceForQuery(querySpecDS: DataSourceRef | undefined | null, queryKind: string): DataSourceRef { // If datasource is specified and has a uid, use it if (querySpecDS?.uid) { return querySpecDS; @@ -257,14 +266,22 @@ function getDataSourceForQuery( }; } - // If we don't find a default datasource, return undefined - return undefined; + if (dsList && !dsList[defaultDatasource]) { + throw new Error(`Default datasource ${defaultDatasource} not found in datasource list`); + } + + // In the datasource list from bootData "id" is the type and the uid could be uid or the name + // in cases like grafana, dashboard or mixed datasource + return { + uid: dsList[defaultDatasource].uid || dsList[defaultDatasource].name, + type: dsList[defaultDatasource].meta.id, + }; } function panelQueryKindToSceneQuery(query: PanelQueryKind): SceneDataQuery { return { refId: query.spec.refId, - datasource: getRuntimePanelDataSource(query), + datasource: getRuntimePanelDataSource(query.spec.query), hide: query.spec.hidden, ...query.spec.query.spec, }; diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts index e0a3cfe305d..931ee847770 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts @@ -45,8 +45,8 @@ const getDataSourceMock = jest.fn(); const fakeDsMock: DataSourceApi = { name: 'fake-std', - type: 'fake-std', - getRef: () => ({ type: 'fake-std', uid: 'fake-std' }), + type: 'fake-type', + getRef: () => ({ type: 'fake-type', uid: 'fake-uid' }), query: () => Promise.resolve({ data: [], @@ -74,7 +74,7 @@ const fakeDsMock: DataSourceApi = { toDataQuery: (q) => ({ ...q, refId: 'FakeDataSource-refId' }), }, id: 1, - uid: 'fake-std', + uid: 'fake-uid', }; jest.mock('@grafana/runtime', () => ({ @@ -95,7 +95,7 @@ describe('sceneVariablesSetToVariables', () => { description: 'test-desc', value: ['selected-value'], text: ['selected-value-text'], - datasource: { uid: 'fake-std', type: 'fake-std' }, + datasource: { uid: 'fake-uid', type: 'fake-type' }, query: 'query', includeAll: true, allowCustomValue: true, @@ -125,8 +125,8 @@ describe('sceneVariablesSetToVariables', () => { ], }, "datasource": { - "type": "fake-std", - "uid": "fake-std", + "type": "fake-type", + "uid": "fake-uid", }, "definition": undefined, "description": "test-desc", @@ -157,7 +157,7 @@ describe('sceneVariablesSetToVariables', () => { description: 'test-desc', value: ['selected-value'], text: ['selected-value-text'], - datasource: { uid: 'fake-std', type: 'fake-std' }, + datasource: { uid: 'fake-uid', type: 'fake-type' }, query: 'query', definition: 'query', includeAll: true, @@ -187,8 +187,8 @@ describe('sceneVariablesSetToVariables', () => { ], }, "datasource": { - "type": "fake-std", - "uid": "fake-std", + "type": "fake-type", + "uid": "fake-uid", }, "definition": "query", "description": "test-desc", @@ -219,7 +219,7 @@ describe('sceneVariablesSetToVariables', () => { description: 'test-desc', value: ['selected-value'], text: ['selected-value-text'], - datasource: { uid: 'fake-std', type: 'fake-std' }, + datasource: { uid: 'fake-uid', type: 'fake-type' }, query: 'query', options: [ { label: 'test', value: 'test' }, @@ -246,7 +246,7 @@ describe('sceneVariablesSetToVariables', () => { description: 'test-desc', value: ['test'], text: ['test'], - datasource: { uid: 'fake-std', type: 'fake-std' }, + datasource: { uid: 'fake-uid', type: 'fake-type' }, query: 'query', options: [ { label: 'test', value: 'test' }, @@ -517,7 +517,7 @@ describe('sceneVariablesSetToVariables', () => { allowCustomValue: true, label: 'test-label', description: 'test-desc', - datasource: { uid: 'fake-std', type: 'fake-std' }, + datasource: { uid: 'fake-uid', type: 'fake-type' }, filters: [ { key: 'filterTest', @@ -551,8 +551,8 @@ describe('sceneVariablesSetToVariables', () => { }, ], "datasource": { - "type": "fake-std", - "uid": "fake-std", + "type": "fake-type", + "uid": "fake-uid", }, "defaultKeys": undefined, "description": "test-desc", @@ -680,7 +680,7 @@ describe('sceneVariablesSetToVariables', () => { allowCustomValue: true, label: 'test-label', description: 'test-desc', - datasource: { uid: 'fake-std', type: 'fake-std' }, + datasource: { uid: 'fake-uid', type: 'fake-type' }, defaultKeys: [ { text: 'some', @@ -728,8 +728,8 @@ describe('sceneVariablesSetToVariables', () => { }, ], "datasource": { - "type": "fake-std", - "uid": "fake-std", + "type": "fake-type", + "uid": "fake-uid", }, "defaultKeys": [ { @@ -775,7 +775,7 @@ describe('sceneVariablesSetToVariables', () => { label: 'test-label', description: 'test-desc', allowCustomValue: true, - datasource: { uid: 'fake-std', type: 'fake-std' }, + datasource: { uid: 'fake-uid', type: 'fake-type' }, defaultOptions: [ { text: 'Foo', @@ -802,8 +802,8 @@ describe('sceneVariablesSetToVariables', () => { "value": [], }, "datasource": { - "type": "fake-std", - "uid": "fake-std", + "type": "fake-type", + "uid": "fake-uid", }, "defaultValue": undefined, "description": "test-desc", @@ -831,7 +831,7 @@ describe('sceneVariablesSetToVariables', () => { name: 'test', label: 'test-label', description: 'test-desc', - datasource: { uid: 'fake-std', type: 'fake-std' }, + datasource: { uid: 'fake-uid', type: 'fake-type' }, defaultOptions: [ { text: 'Foo', @@ -859,7 +859,7 @@ describe('sceneVariablesSetToVariables', () => { description: 'test-desc', value: ['selected-value'], text: ['selected-value-text'], - datasource: { uid: 'fake-std', type: 'fake-std' }, + datasource: { uid: 'fake-uid', type: 'fake-type' }, query: 'query', includeAll: true, allValue: 'test-all', @@ -876,51 +876,52 @@ describe('sceneVariablesSetToVariables', () => { expect(result).toHaveLength(1); expect(result[0]).toMatchInlineSnapshot(` - { - "kind": "QueryVariable", - "spec": { - "allValue": "test-all", - "allowCustomValue": true, - "current": { - "text": [ - "selected-value-text", - ], - "value": [ - "selected-value", - ], - }, - "datasource": { - "type": "fake-std", - "uid": "fake-std", - }, - "definition": undefined, - "description": "test-desc", - "hide": "dontHide", - "includeAll": true, - "label": "test-label", - "multi": true, - "name": "test", - "options": [], - "query": { - "kind": "fake-std", + { + "kind": "QueryVariable", "spec": { - "__legacyStringValue": "query", + "allValue": "test-all", + "allowCustomValue": true, + "current": { + "text": [ + "selected-value-text", + ], + "value": [ + "selected-value", + ], + }, + "definition": undefined, + "description": "test-desc", + "hide": "dontHide", + "includeAll": true, + "label": "test-label", + "multi": true, + "name": "test", + "options": [], + "query": { + "datasource": { + "name": "fake-uid", + }, + "group": "fake-type", + "kind": "DataQuery", + "spec": { + "__legacyStringValue": "query", + }, + "version": "v0", + }, + "refresh": "onDashboardLoad", + "regex": "", + "skipUrlSync": false, + "sort": "disabled", + "staticOptions": [ + { + "text": "test", + "value": "test", + }, + ], + "staticOptionsOrder": "after", }, - }, - "refresh": "onDashboardLoad", - "regex": "", - "skipUrlSync": false, - "sort": "disabled", - "staticOptions": [ - { - "text": "test", - "value": "test", - }, - ], - "staticOptionsOrder": "after", - }, - } - `); + } + `); }); it('should handle CustomVariable', () => { @@ -1179,7 +1180,7 @@ describe('sceneVariablesSetToVariables', () => { name: 'test', label: 'test-label', description: 'test-desc', - datasource: { uid: 'fake-std', type: 'fake-std' }, + datasource: { uid: 'fake-uid', type: 'fake-type' }, filters: [ { key: 'filterTest', @@ -1204,6 +1205,10 @@ describe('sceneVariablesSetToVariables', () => { expect(result).toHaveLength(1); expect(result[0]).toMatchInlineSnapshot(` { + "datasource": { + "name": "fake-uid", + }, + "group": "fake-type", "kind": "AdhocVariable", "spec": { "allowCustomValue": true, @@ -1214,10 +1219,6 @@ describe('sceneVariablesSetToVariables', () => { "value": "test", }, ], - "datasource": { - "type": "fake-std", - "uid": "fake-std", - }, "defaultKeys": [], "description": "test-desc", "filters": [ @@ -1241,7 +1242,7 @@ describe('sceneVariablesSetToVariables', () => { name: 'test', label: 'test-label', description: 'test-desc', - datasource: { uid: 'fake-std', type: 'fake-std' }, + datasource: { uid: 'fake-uid', type: 'fake-type' }, defaultKeys: [ { text: 'some', @@ -1280,6 +1281,10 @@ describe('sceneVariablesSetToVariables', () => { expect(result).toHaveLength(1); expect(result[0]).toMatchInlineSnapshot(` { + "datasource": { + "name": "fake-uid", + }, + "group": "fake-type", "kind": "AdhocVariable", "spec": { "allowCustomValue": true, @@ -1290,10 +1295,6 @@ describe('sceneVariablesSetToVariables', () => { "value": "test", }, ], - "datasource": { - "type": "fake-std", - "uid": "fake-std", - }, "defaultKeys": [ { "text": "some", @@ -1339,7 +1340,7 @@ describe('sceneVariablesSetToVariables', () => { name: 'test', label: 'test-label', description: 'test-desc', - datasource: { uid: 'fake-std', type: 'fake-std' }, + datasource: { uid: 'fake-uid', type: 'fake-type' }, defaultOptions: [ { text: 'Foo', @@ -1360,16 +1361,16 @@ describe('sceneVariablesSetToVariables', () => { expect(result).toHaveLength(1); expect(result[0]).toMatchInlineSnapshot(` { + "datasource": { + "name": "fake-uid", + }, + "group": "fake-type", "kind": "GroupByVariable", "spec": { "current": { "text": [], "value": [], }, - "datasource": { - "type": "fake-std", - "uid": "fake-std", - }, "defaultValue": undefined, "description": "test-desc", "hide": "dontHide", @@ -1399,7 +1400,7 @@ describe('sceneVariablesSetToVariables', () => { name: 'test', label: 'test-label', description: 'test-desc', - datasource: { uid: 'fake-std', type: 'fake-std' }, + datasource: { uid: 'fake-uid', type: 'fake-type' }, defaultOptions: [ { text: 'Foo', diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts index d90254a5de7..87cde15a7ea 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts @@ -24,12 +24,15 @@ import { GroupByVariableKind, defaultVariableHide, VariableOption, + defaultDataQueryKind, AdHocFilterWithLabels, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import { getDefaultDatasource } from 'app/features/dashboard/api/ResponseTransformers'; import { getIntervalsQueryFromNewIntervalModel } from '../utils/utils'; import { DSReferencesMapping } from './DashboardSceneSerializer'; +import { getDataSourceForQuery } from './layoutSerializers/utils'; import { getDataQueryKind, getDataQuerySpec, getElementDatasource } from './transformSceneToSaveModelSchemaV2'; import { transformVariableRefreshToEnum, @@ -299,14 +302,30 @@ export function sceneVariablesSetToSchemaV2Variables( } const query = variable.state.query; let dataQuery: DataQueryKind | string; + const datasource = getElementDatasource(set, variable, 'variable', undefined, dsReferencesMapping); + if (typeof query !== 'string') { dataQuery = { - kind: variable.state.datasource?.type ?? getDataQueryKind(query), + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: datasource?.type ?? getDataQueryKind(query), + ...(datasource?.uid && { + datasource: { + name: datasource.uid, + }, + }), spec: getDataQuerySpec(query), }; } else { dataQuery = { - kind: variable.state.datasource?.type ?? getDataQueryKind(query), + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: datasource?.type ?? getDataQueryKind(query), + ...(datasource?.uid && { + datasource: { + name: datasource.uid, + }, + }), spec: { [LEGACY_STRING_VALUE_KEY]: query, }, @@ -320,7 +339,6 @@ export function sceneVariablesSetToSchemaV2Variables( options, query: dataQuery, definition: variable.state.definition, - datasource: getElementDatasource(set, variable, 'variable', undefined, dsReferencesMapping), sort: transformSortVariableToEnum(variable.state.sort), refresh: transformVariableRefreshToEnum(variable.state.refresh), regex: variable.state.regex, @@ -440,11 +458,19 @@ export function sceneVariablesSetToSchemaV2Variables( } : undefined; + const ds = getDataSourceForQuery( + variable.state.datasource, + variable.state.datasource?.type || getDefaultDatasource().type! + ); + const groupVariable: GroupByVariableKind = { kind: 'GroupByVariable', + group: ds.type!, + datasource: { + name: ds.uid, + }, spec: { ...commonProperties, - datasource: variable.state.datasource || {}, // FIXME what is the default value?, // Only persist the statically defined options options: variable.state.defaultOptions?.map((option) => ({ @@ -458,12 +484,20 @@ export function sceneVariablesSetToSchemaV2Variables( }; variables.push(groupVariable); } else if (sceneUtils.isAdHocVariable(variable)) { + const ds = getDataSourceForQuery( + variable.state.datasource, + variable.state.datasource?.type || getDefaultDatasource().type! + ); const adhocVariable: AdhocVariableKind = { kind: 'AdhocVariable', + group: ds.type!, + datasource: { + name: ds.uid, + }, spec: { ...commonProperties, name: variable.state.name, - datasource: variable.state.datasource || {}, //FIXME what is the default value? + baseFilters: validateFiltersOrigin(variable.state.baseFilters) || [], filters: [ ...validateFiltersOrigin(variable.state.originFilters), diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts index ce31ae57d0e..87391044813 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts @@ -15,20 +15,21 @@ import { SceneDataTransformer, SceneGridItem, } from '@grafana/scenes'; -import { handyTestingSchema } from '@grafana/schema/dist/esm/schema/dashboard/v2_examples'; import { AdhocVariableKind, ConstantVariableKind, CustomVariableKind, Spec as DashboardV2Spec, DatasourceVariableKind, + defaultDataQueryKind, GridLayoutItemSpec, GridLayoutSpec, GroupByVariableKind, IntervalVariableKind, QueryVariableKind, TextVariableKind, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import { handyTestingSchema } from '@grafana/schema/dist/esm/schema/dashboard/v2_examples'; import { AnnoKeyDashboardIsSnapshot } from 'app/features/apiserver/types'; import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; @@ -138,6 +139,7 @@ describe('transformSaveModelSchemaV2ToScene', () => { // Variables const variables = scene.state?.$variables; expect(variables?.state.variables).toHaveLength(dash.variables.length); + validateVariable({ sceneVariable: variables?.state.variables[0], variableKind: dash.variables[0] as QueryVariableKind, @@ -206,36 +208,26 @@ describe('transformSaveModelSchemaV2ToScene', () => { // Annotations expect(scene.state.$data).toBeInstanceOf(DashboardDataLayerSet); const dataLayers = scene.state.$data as DashboardDataLayerSet; - // we should get two annotations, Grafana built-in and the custom ones expect(dataLayers.state.annotationLayers).toHaveLength(dash.annotations.length); - expect(dataLayers.state.annotationLayers).toHaveLength(5); - - // Built-in - const builtInAnnotation = dataLayers.state.annotationLayers[0] as unknown as DashboardAnnotationsDataLayer; - expect(builtInAnnotation.state.name).toBe('Annotations & Alerts'); - expect(builtInAnnotation.state.isEnabled).toBe(true); - expect(builtInAnnotation.state.isHidden).toBe(true); - expect(builtInAnnotation.state?.query.builtIn).toBe(1); + expect(dataLayers.state.annotationLayers[0].state.name).toBe(dash.annotations[0].spec.name); + expect(dataLayers.state.annotationLayers[0].state.isEnabled).toBe(dash.annotations[0].spec.enable); + expect(dataLayers.state.annotationLayers[0].state.isHidden).toBe(dash.annotations[0].spec.hide); // Enabled expect(dataLayers.state.annotationLayers[1].state.name).toBe(dash.annotations[1].spec.name); expect(dataLayers.state.annotationLayers[1].state.isEnabled).toBe(dash.annotations[1].spec.enable); expect(dataLayers.state.annotationLayers[1].state.isHidden).toBe(dash.annotations[1].spec.hide); + // Disabled expect(dataLayers.state.annotationLayers[2].state.name).toBe(dash.annotations[2].spec.name); expect(dataLayers.state.annotationLayers[2].state.isEnabled).toBe(dash.annotations[2].spec.enable); expect(dataLayers.state.annotationLayers[2].state.isHidden).toBe(dash.annotations[2].spec.hide); - // Disabled + // Hidden expect(dataLayers.state.annotationLayers[3].state.name).toBe(dash.annotations[3].spec.name); expect(dataLayers.state.annotationLayers[3].state.isEnabled).toBe(dash.annotations[3].spec.enable); expect(dataLayers.state.annotationLayers[3].state.isHidden).toBe(dash.annotations[3].spec.hide); - // Hidden - expect(dataLayers.state.annotationLayers[4].state.name).toBe(dash.annotations[4].spec.name); - expect(dataLayers.state.annotationLayers[4].state.isEnabled).toBe(dash.annotations[4].spec.enable); - expect(dataLayers.state.annotationLayers[4].state.isHidden).toBe(dash.annotations[4].spec.hide); - // VizPanel const vizPanels = (scene.state.body as DashboardLayoutManager).getVizPanels(); expect(vizPanels).toHaveLength(2); @@ -279,13 +271,14 @@ describe('transformSaveModelSchemaV2ToScene', () => { kind: 'PanelQuery', spec: { refId: 'A', - datasource: { - type: 'graphite', - uid: 'datasource1', - }, hidden: false, query: { - kind: 'prometheus', + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'graphite', + datasource: { + name: 'datasource1', + }, spec: { expr: 'test-query', }, @@ -307,13 +300,14 @@ describe('transformSaveModelSchemaV2ToScene', () => { kind: 'PanelQuery', spec: { refId: 'A', - datasource: { - type: 'prometheus', - uid: 'datasource1', - }, hidden: false, query: { - kind: 'prometheus', + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'prometheus', + datasource: { + name: 'datasource1', + }, spec: { expr: 'test-query', }, @@ -340,7 +334,12 @@ describe('transformSaveModelSchemaV2ToScene', () => { refId: 'A', hidden: false, query: { - kind: 'prometheus', + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'prometheus', + datasource: { + name: 'abc123', + }, spec: { expr: 'test-query', }, @@ -679,7 +678,7 @@ describe('transformSaveModelSchemaV2ToScene', () => { // Create a dashboard with an annotation that has options const dashboardWithAnnotationOptions: DashboardWithAccessInfo = { kind: 'DashboardWithAccessInfo', - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', metadata: { name: 'test-dashboard', namespace: 'default', @@ -722,10 +721,6 @@ describe('transformSaveModelSchemaV2ToScene', () => { enable: true, hide: false, iconColor: 'purple', - datasource: { - type: 'prometheus', - uid: 'abc123', - }, legacyOptions: { expr: 'rate(http_requests_total[5m])', queryType: 'range', @@ -733,6 +728,15 @@ describe('transformSaveModelSchemaV2ToScene', () => { useValueAsTime: true, step: '1m', }, + query: { + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'prometheus', + datasource: { + name: 'abc123', + }, + spec: {}, + }, }, }, ], @@ -764,23 +768,35 @@ describe('transformSaveModelSchemaV2ToScene', () => { // Get the annotation layers const dataLayerSet = scene.state.$data as DashboardDataLayerSet; expect(dataLayerSet).toBeDefined(); - // it should have two annotation layers, built-in and custom expect(dataLayerSet.state.annotationLayers.length).toBe(2); + const defaultAnnotationLayer = dataLayerSet.state.annotationLayers[0] as DashboardAnnotationsDataLayer; + + // Verify that the default annotation layer has been correctly initialized + expect(defaultAnnotationLayer.state.query).toEqual({ + datasource: { + uid: '-- Grafana --', + type: 'grafana', + }, + builtIn: 1, + enable: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotations & Alerts', + hide: true, + type: 'dashboard', + }); const annotationLayer = dataLayerSet.state.annotationLayers[1] as DashboardAnnotationsDataLayer; // Verify that the legacyOptions have been merged into the query object expect(annotationLayer.state.query).toMatchObject({ + datasource: { + uid: 'abc123', + type: 'prometheus', + }, name: 'Annotation with legacy options', - expr: 'rate(http_requests_total[5m])', - queryType: 'range', - legendFormat: '{{method}} {{endpoint}}', - useValueAsTime: true, - step: '1m', - }); - - // Verify the original legacyOptions object is also preserved - expect(annotationLayer.state.query.legacyOptions).toMatchObject({ + enable: true, + hide: false, + iconColor: 'purple', expr: 'rate(http_requests_total[5m])', queryType: 'range', legendFormat: '{{method}} {{endpoint}}', diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index ae132430e0c..2b6dc7d5864 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -28,6 +28,7 @@ import { defaultAdhocVariableKind, defaultConstantVariableKind, defaultCustomVariableKind, + defaultDataQueryKind, defaultDatasourceVariableKind, defaultGroupByVariableKind, defaultIntervalVariableKind, @@ -39,7 +40,7 @@ import { PanelKind, QueryVariableKind, TextVariableKind, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui'; import { AnnoKeyCreatedBy, @@ -50,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'; @@ -62,9 +67,10 @@ import { DashboardScene } from '../scene/DashboardScene'; import { DashboardLayoutManager } from '../scene/types/DashboardLayoutManager'; import { getIntervalsFromQueryString } from '../utils/utils'; +import { transformV2ToV1AnnotationQuery } from './annotations'; import { SnapshotVariable } from './custom-variables/SnapshotVariable'; import { layoutDeserializerRegistry } from './layoutSerializers/layoutSerializerRegistry'; -import { getRuntimeVariableDataSource } from './layoutSerializers/utils'; +import { getDataSourceForQuery, getRuntimeVariableDataSource } from './layoutSerializers/utils'; import { registerPanelInteractionsReporter } from './transformSaveModelToScene'; import { transformCursorSyncV2ToV1, @@ -90,32 +96,23 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo { - let annoQuerySpec = annotation.spec; - // some annotations will contain in the legacyOptions properties that need to be - // added to the root level annotation spec - if (annoQuerySpec?.legacyOptions) { - annoQuerySpec = { - ...annoQuerySpec, - ...annoQuerySpec.legacyOptions, - }; - } - return new DashboardAnnotationsDataLayer({ + const annotationQuerySpec = transformV2ToV1AnnotationQuery(annotation); + + const layerState = { key: uniqueId('annotations-'), - query: { - ...annoQuerySpec, - builtIn: annotation.spec.builtIn ? 1 : 0, - }, + query: annotationQuerySpec, name: annotation.spec.name, isEnabled: Boolean(annotation.spec.enable), isHidden: Boolean(annotation.spec.hide), - }); + }; + + return new DashboardAnnotationsDataLayer(layerState); }); const isDashboardEditable = Boolean(dashboard.editable); @@ -164,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, @@ -191,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 }), @@ -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/serialization/transformSceneToSaveModelSchemaV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts index 0bc40f8d9d6..56fcdbaef42 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts @@ -31,7 +31,7 @@ import { AutoGridLayoutSpec, RowsLayoutSpec, TabsLayoutSpec, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { DashboardEditPane } from '../edit-pane/DashboardEditPane'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; @@ -57,6 +57,7 @@ import { validateDashboardSchemaV2, getDataQueryKind, getAutoAssignedDSRef, + getVizPanelQueries, } from './transformSceneToSaveModelSchemaV2'; // Mock dependencies @@ -410,9 +411,7 @@ describe('transformSceneToSaveModelSchemaV2', () => { expect(result).toMatchSnapshot(); // Check that the annotation layers are correctly transformed - expect(result.annotations).toHaveLength(3); - // Check annotation layer 3 without initial data source isn't updated with runtime default - expect(result.annotations?.[2].spec.datasource?.type).toBe(undefined); + expect(result.annotations).toHaveLength(2); }); it('should transform the minimum scene to save model schema v2', () => { @@ -562,16 +561,8 @@ describe('transformSceneToSaveModelSchemaV2', () => { name: 'annotation-with-options', enable: true, iconColor: 'red', - legacyOptions: { - expr: 'rate(http_requests_total[5m])', - queryType: 'range', - legendFormat: '{{method}} {{endpoint}}', - useValueAsTime: true, - }, - // Some other properties that aren't in the annotation spec - // and should be moved to options - customProp1: 'value1', - customProp2: 'value2', + customProp1: true, + customProp2: 'test', }, name: 'layerWithOptions', isEnabled: true, @@ -593,19 +584,11 @@ describe('transformSceneToSaveModelSchemaV2', () => { expect(result.annotations.length).toBe(1); expect(result.annotations[0].spec.legacyOptions).toBeDefined(); expect(result.annotations[0].spec.legacyOptions).toEqual({ - expr: 'rate(http_requests_total[5m])', - queryType: 'range', - legendFormat: '{{method}} {{endpoint}}', - useValueAsTime: true, - customProp1: 'value1', - customProp2: 'value2', + customProp1: true, + customProp2: 'test', }); // Ensure these properties are not at the root level - expect(result).not.toHaveProperty('annotations[0].spec.expr'); - expect(result).not.toHaveProperty('annotations[0].spec.queryType'); - expect(result).not.toHaveProperty('annotations[0].spec.legendFormat'); - expect(result).not.toHaveProperty('annotations[0].spec.useValueAsTime'); expect(result).not.toHaveProperty('annotations[0].spec.customProp1'); expect(result).not.toHaveProperty('annotations[0].spec.customProp2'); }); @@ -834,6 +817,50 @@ describe('getElementDatasource', () => { }); }); +describe('getVizPanelQueries', () => { + it('should handle panel query datasources correctly', () => { + const queryWithDS: SceneDataQuery = { + refId: 'B', + datasource: { uid: 'prometheus-uid', type: 'prometheus' }, + }; + + const queryWithoutDS: SceneDataQuery = { + refId: 'A', + }; + + // Mock query runner + const queryRunner = new SceneQueryRunner({ + queries: [queryWithoutDS, queryWithDS], + datasource: { uid: 'default-ds', type: 'default' }, + }); + // Create test elements + const vizPanel = new VizPanel({ + key: 'panel-1', + pluginId: 'timeseries', + $data: queryRunner, + }); + + // Mock dsReferencesMapping + const dsReferencesMapping = { + panels: new Map(new Set([['panel-1', new Set(['A'])]])), + variables: new Set(), + annotations: new Set(), + }; + + const result = getVizPanelQueries(vizPanel, dsReferencesMapping); + expect(result.length).toBe(2); + expect(result[0].spec.query.kind).toBe('DataQuery'); + expect(result[0].spec.query.datasource).toBeUndefined(); // ignore datasource if it wasn't provided + expect(result[0].spec.query.group).toBe('default'); + expect(result[0].spec.query.version).toBe('v0'); + + expect(result[1].spec.query.kind).toBe('DataQuery'); + expect(result[1].spec.query.datasource?.name).toBe('prometheus-uid'); + expect(result[1].spec.query.group).toBe('prometheus'); + expect(result[1].spec.query.version).toBe('v0'); + }); +}); + function getMinimalSceneState(body: DashboardLayoutManager): Partial { return { id: 1, @@ -1065,18 +1092,6 @@ function createAnnotationLayers() { isEnabled: true, isHidden: true, }), - // this could happen if a dahboard was created from code and the datasource was not defined - new DashboardAnnotationsDataLayer({ - key: 'layer3', - query: { - name: 'query3', - enable: true, - iconColor: 'green', - }, - name: 'layer3', - isEnabled: true, - isHidden: true, - }), ]; } diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index b8a0bfd3b6e..d0fe90719f1 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -43,7 +43,8 @@ import { DashboardCursorSync, FieldConfig, FieldColor, -} from '../../../../../packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen'; + defaultDataQueryKind, +} from '../../../../../packages/grafana-schema/src/schema/dashboard/v2'; import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet'; import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene'; import { PanelTimeRange } from '../scene/PanelTimeRange'; @@ -51,6 +52,7 @@ import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { getLibraryPanelBehavior, getPanelIdForVizPanel, getQueryRunnerFor, isLibraryPanel } from '../utils/utils'; import { DSReferencesMapping } from './DashboardSceneSerializer'; +import { transformV1ToV2AnnotationQuery } from './annotations'; import { sceneVariablesSetToSchemaV2Variables } from './sceneVariablesSetToVariables'; import { colorIdEnumToColorIdV2, transformCursorSynctoEnum } from './transformToV2TypesUtils'; @@ -230,9 +232,10 @@ export function vizPanelToSchemaV2( }, }, vizConfig: { - kind: vizPanel.state.pluginId, + kind: 'VizConfig', + group: vizPanel.state.pluginId, + version: vizPanel.state.pluginVersion ?? '', spec: { - pluginVersion: vizPanel.state.pluginVersion ?? '', options: vizPanel.state.options, fieldConfig: vizFieldConfig ?? defaultFieldConfigSource(), }, @@ -250,7 +253,7 @@ function getPanelLinks(panel: VizPanel): DataLink[] { return []; } -function getVizPanelQueries(vizPanel: VizPanel, dsReferencesMapping?: DSReferencesMapping): PanelQueryKind[] { +export function getVizPanelQueries(vizPanel: VizPanel, dsReferencesMapping?: DSReferencesMapping): PanelQueryKind[] { const queries: PanelQueryKind[] = []; const queryRunner = getQueryRunnerFor(vizPanel); const vizPanelQueries = queryRunner?.state.queries; @@ -258,12 +261,22 @@ function getVizPanelQueries(vizPanel: VizPanel, dsReferencesMapping?: DSReferenc if (vizPanelQueries) { vizPanelQueries.forEach((query) => { const queryDatasource = getElementDatasource(vizPanel, query, 'panel', queryRunner, dsReferencesMapping); + const dataQuery: DataQueryKind = { - kind: getDataQueryKind(query, queryRunner), + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: getDataQueryKind(query, queryRunner), + datasource: { + name: queryDatasource?.uid, + }, spec: omit(query, 'datasource', 'refId', 'hide'), }; + + if (!dataQuery.datasource?.name) { + delete dataQuery.datasource; + } + const querySpec: PanelQuerySpec = { - datasource: queryDatasource, query: dataQuery, refId: query.refId, hidden: Boolean(query.hide), @@ -407,78 +420,46 @@ function getAnnotations(state: DashboardSceneState, dsReferencesMapping?: DSRefe if (!(layer instanceof dataLayers.AnnotationsDataLayer)) { continue; } - const result: AnnotationQueryKind = { - kind: 'AnnotationQuery', - spec: { - builtIn: Boolean(layer.state.query.builtIn), - name: layer.state.query.name, - datasource: getElementDatasource(layer, layer.state.query, 'annotation', undefined, dsReferencesMapping), - enable: Boolean(layer.state.isEnabled), - hide: Boolean(layer.state.isHidden), - iconColor: layer.state.query.iconColor, - }, - }; - // Transform v1 dashboard (using target) to v2 structure - // adds extra condition to prioritize query over target - // if query is defined, use it - if (layer.state.query.target && !layer.state.query.query) { - // Handle built-in annotations - if (layer.state.query.builtIn) { - result.spec.query = { - kind: 'grafana', // built-in annotations are always of type grafana - spec: { - ...layer.state.query.target, - }, - }; - } else { - result.spec.query = { - kind: getAnnotationQueryKind(layer.state.query), - spec: { - ...layer.state.query.target, - }, - }; - } - } - // For annotations without query.query defined (e.g., grafana annotations without tags) - else if (layer.state.query.query?.kind) { - result.spec.query = { - kind: layer.state.query.query.kind, - spec: { - ...layer.state.query.query.spec, - }, - }; - } - // Collect datasource-specific properties not in standard annotation spec - let otherProps = omit( - layer.state.query, - 'type', - 'target', - 'builtIn', - 'name', - 'datasource', - 'iconColor', - 'enable', - 'hide', - 'filter', - 'query' - ); + const datasource = getElementDatasource(layer, layer.state.query, 'annotation', undefined, dsReferencesMapping); - // Store extra properties in the legacyOptions field instead of directly in the spec - if (Object.keys(otherProps).length > 0) { - // Extract options property and get the rest of the properties - const { legacyOptions, ...restProps } = otherProps; - // Merge options with the rest of the properties - result.spec.legacyOptions = { ...legacyOptions, ...restProps }; + let layerDs = layer.state.query.datasource; + + if (!layerDs) { + // This can happen only if we are transforming a scene that was created + // from a v1 spec. In v1 annotation layer can contain no datasource ref, which is guaranteed + // for layers created for v2 schema. See transform transformSaveModelSchemaV2ToScene.ts. + // In this case we will resolve default data source + layerDs = getDefaultDataSourceRef(); + console.error( + 'Misconfigured AnnotationsDataLayer: Data source is required for annotations. Resolving default data source', + layer, + layerDs + ); } + const result = transformV1ToV2AnnotationQuery(layer.state.query, layerDs.type!, layerDs.uid!, { + enable: layer.state.isEnabled, + hide: layer.state.isHidden, + }); + + const annotationQuery = layer.state.query; + // If filter is an empty array, don't save it - if (layer.state.query.filter?.ids?.length) { - result.spec.filter = layer.state.query.filter; + if (annotationQuery.filter?.ids?.length) { + result.spec.filter = annotationQuery.filter; + } + + // Finally, if the datasource references mapping did not containt data source ref, + // this means that the original model that was fetched did not contain it. In such scenario we don't want to save + // the explicit data source reference, so lets remove it from the save model. + if (!datasource) { + delete result.spec.query.datasource; } annotations.push(result); } + return annotations; } diff --git a/public/app/features/dashboard-scene/serialization/transformToV1TypesUtils.ts b/public/app/features/dashboard-scene/serialization/transformToV1TypesUtils.ts index 86b049d309b..64fe31b8426 100644 --- a/public/app/features/dashboard-scene/serialization/transformToV1TypesUtils.ts +++ b/public/app/features/dashboard-scene/serialization/transformToV1TypesUtils.ts @@ -16,7 +16,7 @@ import { FieldConfigSource, SpecialValueMatch, ThresholdsMode, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; export function transformVariableRefreshToEnumV1(refresh?: VariableRefresh): VariableRefreshV1 { switch (refresh) { diff --git a/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.test.ts b/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.test.ts index d085f80da5f..b1633283c97 100644 --- a/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.test.ts @@ -3,7 +3,7 @@ import { defaultVariableSort, defaultVariableRefresh, defaultDashboardCursorSync, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { transformCursorSynctoEnum, diff --git a/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.ts b/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.ts index 6c9ea39546f..cbdf630619c 100644 --- a/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.ts +++ b/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.ts @@ -17,7 +17,7 @@ import { VariableRefresh, VariableSort, FieldColorModeId as FieldColorModeIdV2, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; // used for QueryVariableKind's query prop - in schema V2 we've deprecated string type and support only DataQuery export const LEGACY_STRING_VALUE_KEY = '__legacyStringValue'; 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/JsonModelEditView.tsx b/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx index c6b40b81ff0..a109dd7f808 100644 --- a/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx +++ b/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx @@ -5,7 +5,7 @@ import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { SceneComponentProps, SceneObjectBase, sceneUtils } from '@grafana/scenes'; import { Dashboard } from '@grafana/schema'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { Alert, Box, Button, CodeEditor, Stack, useStyles2 } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; diff --git a/public/app/features/dashboard-scene/settings/MoveProvisionedDashboardForm.tsx b/public/app/features/dashboard-scene/settings/MoveProvisionedDashboardForm.tsx index 5a2a07cc478..2ad4f936309 100644 --- a/public/app/features/dashboard-scene/settings/MoveProvisionedDashboardForm.tsx +++ b/public/app/features/dashboard-scene/settings/MoveProvisionedDashboardForm.tsx @@ -13,11 +13,12 @@ import { useGetRepositoryFilesWithPathQuery, } from 'app/api/clients/provisioning/v0alpha1'; import { AnnoKeySourcePath } from 'app/features/apiserver/types'; +import { getTargetFolderPathInRepo } from 'app/features/browse-dashboards/components/BulkActions/utils'; 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'; @@ -74,8 +75,7 @@ export function MoveProvisionedDashboardForm({ return; } - const folderAnnotations = targetFolder.metadata.annotations || {}; - const targetFolderPath = folderAnnotations[AnnoKeySourcePath] || targetFolderTitle; + const targetFolderPath = getTargetFolderPathInRepo({ targetFolder }); const filename = currentSourcePath.split('/').pop(); const newPath = `${targetFolderPath}/${filename}`; @@ -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/settings/VersionsEditView.test.tsx b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx index 501b6d7dfae..997ed92fb55 100644 --- a/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx +++ b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx @@ -1,4 +1,3 @@ -import { config } from '@grafana/runtime'; import { SceneTimeRange } from '@grafana/scenes'; import { DashboardScene } from '../scene/DashboardScene'; @@ -149,10 +148,7 @@ describe('VersionsEditView', () => { expect(versionsView.versions.find((rev) => rev.version === 1)).toBeUndefined(); }); - it('should correctly identify last page when kubernetesClientDashboardsFolders is enabled and continueToken is empty', async () => { - // @ts-ignore - config.featureToggles.kubernetesClientDashboardsFolders = true; - + it('should correctly identify last page when continueToken is empty', async () => { jest.mocked(historySrv.getHistoryList).mockResolvedValueOnce({ continueToken: '', versions: [ @@ -190,10 +186,6 @@ describe('VersionsEditView', () => { expect(versionsView.versions.length).toBeLessThan(VERSIONS_FETCH_LIMIT); expect(versionsView.versions.find((rev) => rev.version === 1)).toBeUndefined(); expect(versionsView.continueToken).toBe(''); - - // reset feature flag - // @ts-ignore - config.featureToggles.kubernetesClientDashboardsFolders = false; }); }); }); diff --git a/public/app/features/dashboard-scene/settings/VersionsEditView.tsx b/public/app/features/dashboard-scene/settings/VersionsEditView.tsx index 1620111aecd..e21c7c00cf1 100644 --- a/public/app/features/dashboard-scene/settings/VersionsEditView.tsx +++ b/public/app/features/dashboard-scene/settings/VersionsEditView.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { PageLayoutType, dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data'; -import { config } from '@grafana/runtime'; import { SceneComponentProps, SceneObjectBase, sceneGraph } from '@grafana/scenes'; import { Spinner, Stack } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; @@ -136,15 +135,9 @@ export class VersionsEditView extends SceneObjectBase imp if (!this._dashboard.state.uid) { return; } - let lhs, rhs; - if (config.featureToggles.kubernetesClientDashboardsFolders) { - // the id here is the resource version in k8s, use this instead to get the specific version - lhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, baseInfo.id); - rhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, newInfo.id); - } else { - lhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, baseInfo.version); - rhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, newInfo.version); - } + // the id here is the resource version in k8s, use this instead to get the specific version + let lhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, baseInfo.id); + let rhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, newInfo.id); this.setState({ baseInfo, @@ -204,10 +197,10 @@ function VersionsEditorSettingsListView({ model }: SceneComponentProps 1; const hasMore = model.versions.length >= model.limit; // older versions may have been cleaned up in the db, so also check if the last page is less than the limit, if so, we are at the end - let isLastPage = model.versions.find((rev) => rev.version === 1) || model.versions.length % model.limit !== 0; - if (config.featureToggles.kubernetesClientDashboardsFolders) { - isLastPage = isLastPage || model.continueToken === ''; - } + let isLastPage = + model.versions.find((rev) => rev.version === 1) || + model.versions.length % model.limit !== 0 || + model.continueToken === ''; const viewModeCompare = ( <> 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/ResourceExport.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx index 3ec58d8aecb..6543b641927 100644 --- a/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx @@ -3,7 +3,7 @@ import { AsyncState } from 'react-use/lib/useAsync'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { Dashboard } from '@grafana/schema/dist/esm/index.gen'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { Alert, Label, RadioButtonGroup, Stack, Switch } from '@grafana/ui'; import { DashboardJson } from 'app/features/manage-dashboards/types'; 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/sharing/ShareExportTab.test.tsx b/public/app/features/dashboard-scene/sharing/ShareExportTab.test.tsx index 19791d70670..8fa8c102ee6 100644 --- a/public/app/features/dashboard-scene/sharing/ShareExportTab.test.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareExportTab.test.tsx @@ -5,7 +5,7 @@ import { Spec as DashboardV2Spec, defaultQueryGroupKind, defaultVizConfigSpec, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; import * as ResponseTransformers from 'app/features/dashboard/api/ResponseTransformers'; import { DashboardJson } from 'app/features/manage-dashboards/types'; import { DashboardDataDTO } from 'app/types/dashboard'; @@ -180,7 +180,7 @@ describe('ShareExportTab', () => { // Should use V2 API version expect(result.json).toMatchObject({ - apiVersion: 'dashboard.grafana.app/v2alpha1', + apiVersion: 'dashboard.grafana.app/v2beta1', kind: 'Dashboard', status: {}, }); @@ -394,7 +394,9 @@ describe('ShareExportTab', () => { links: [], data: defaultQueryGroupKind(), vizConfig: { - kind: 'stat', + kind: 'VizConfig', + group: '', + version: '1.0.0', spec: defaultVizConfigSpec(), }, }, @@ -443,7 +445,7 @@ describe('ShareExportTab', () => { // Set up the scene based on current version const currentDashboard = version === 'v1' ? mockV1Dashboard : mockV2Dashboard; const initialSaveModel = initialSaveModelVersion === 'v1' ? mockV1Dashboard : mockV2Dashboard; - const apiVersion = version === 'v1' ? 'dashboard.grafana.app/v1beta1' : 'dashboard.grafana.app/v2alpha1'; + const apiVersion = version === 'v1' ? 'dashboard.grafana.app/v1beta1' : 'dashboard.grafana.app/v2beta1'; scene.serializer.getSaveModel = jest.fn(() => currentDashboard); scene.serializer.makeExportableExternally = jest.fn(() => diff --git a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx index 8a9087f118b..ea52c8b0da3 100644 --- a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx @@ -8,7 +8,7 @@ import { Trans, t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes'; import { Dashboard } from '@grafana/schema/dist/esm/index.gen'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { Button, ClipboardButton, CodeEditor, Field, Modal, Stack, Switch } from '@grafana/ui'; import { ObjectMeta } from 'app/features/apiserver/types'; import { transformDashboardV2SpecToV1 } from 'app/features/dashboard/api/ResponseTransformers'; 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-scene/v2schema/ImportDashboardFormV2.tsx b/public/app/features/dashboard-scene/v2schema/ImportDashboardFormV2.tsx index f9dd24a99b2..0882b80310f 100644 --- a/public/app/features/dashboard-scene/v2schema/ImportDashboardFormV2.tsx +++ b/public/app/features/dashboard-scene/v2schema/ImportDashboardFormV2.tsx @@ -4,7 +4,7 @@ import { Controller, FieldErrors, UseFormReturn } from 'react-hook-form'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { ExpressionDatasourceRef } from '@grafana/runtime/internal'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { Button, Field, FormFieldErrors, FormsOnSubmit, Stack, Input } from '@grafana/ui'; import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types'; diff --git a/public/app/features/dashboard-scene/v2schema/ImportDashboardOverviewV2.tsx b/public/app/features/dashboard-scene/v2schema/ImportDashboardOverviewV2.tsx index c5cd4e4d15f..6f193e243a1 100644 --- a/public/app/features/dashboard-scene/v2schema/ImportDashboardOverviewV2.tsx +++ b/public/app/features/dashboard-scene/v2schema/ImportDashboardOverviewV2.tsx @@ -5,7 +5,8 @@ import { locationService, reportInteraction } from '@grafana/runtime'; import { AnnotationQueryKind, Spec as DashboardV2Spec, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; + defaultDataQueryKind, +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { Form } from 'app/core/components/Form/Form'; import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types'; @@ -16,6 +17,8 @@ import { ImportDashboardFormV2 } from './ImportDashboardFormV2'; const IMPORT_FINISHED_EVENT_NAME = 'dashboard_import_imported'; +type FormData = SaveDashboardCommand & { [key: `datasource-${string}`]: string }; + export function ImportDashboardOverviewV2() { const [uidReset, setUidReset] = useState(false); const dispatch = useDispatch(); @@ -34,24 +37,29 @@ export function ImportDashboardOverviewV2() { dispatch(clearLoadedDashboard()); } - async function onSubmit(form: SaveDashboardCommand) { + async function onSubmit(form: FormData) { reportInteraction(IMPORT_FINISHED_EVENT_NAME); const dashboardWithDataSources: DashboardV2Spec = { ...dashboard, title: form.dashboard.title, annotations: dashboard.annotations?.map((annotation: AnnotationQueryKind) => { - if (annotation.spec.query?.kind) { - const dsType = annotation.spec.query.kind; + const dsType = annotation.spec.query?.spec.group; + if (dsType) { if (form[`datasource-${dsType}` as keyof typeof form]) { const ds = form[`datasource-${dsType}` as keyof typeof form] as { uid: string; type: string }; return { ...annotation, spec: { ...annotation.spec, - datasource: { - uid: ds.uid, - type: ds.type, + query: { + kind: 'DataQuery', + group: dsType, + version: defaultDataQueryKind().version, + datasource: { name: ds.uid }, + spec: { + ...annotation.spec.query?.spec, + }, }, }, }; @@ -61,18 +69,23 @@ export function ImportDashboardOverviewV2() { }), variables: dashboard.variables?.map((variable) => { if (variable.kind === 'QueryVariable') { - if (variable.spec.query?.kind) { - const dsType = variable.spec.query.kind; + const dsType = variable.spec.query?.spec.group; + if (dsType) { if (form[`datasource-${dsType}` as keyof typeof form]) { const ds = form[`datasource-${dsType}` as keyof typeof form] as { uid: string; type: string }; return { ...variable, spec: { ...variable.spec, - datasource: { - ...variable.spec.datasource, - uid: ds.uid, - type: ds.type, + query: { + ...variable.spec.query, + spec: { + ...variable.spec.query.spec, + group: ds.type, + datasource: { + name: ds.uid, + }, + }, }, options: [], current: { @@ -157,7 +170,7 @@ export function ImportDashboardOverviewV2() { return ( <> - & { [key: `datasource-${string}`]: string }> + onSubmit={onSubmit} defaultValues={{ dashboard, k8s: { annotations: { 'grafana.app/folder': folder.uid } } }} validateOnMount diff --git a/public/app/features/dashboard-scene/v2schema/test-helpers.ts b/public/app/features/dashboard-scene/v2schema/test-helpers.ts index 815762fdd31..b26066d1ea3 100644 --- a/public/app/features/dashboard-scene/v2schema/test-helpers.ts +++ b/public/app/features/dashboard-scene/v2schema/test-helpers.ts @@ -10,7 +10,7 @@ import { SceneVariableState, VizPanel, } from '@grafana/scenes'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { DashboardScene } from '../scene/DashboardScene'; import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; @@ -41,7 +41,8 @@ export function validateVariable< expect(scene.state?.$variables?.getByName(dashSpec.variables[index].spec.name)?.getValue()).toBe( `${variableKind.spec.filters[0].key}="${variableKind.spec.filters[0].value}"` ); - expect(sceneVariable?.state.datasource).toEqual(variableKind.spec.datasource); + expect(sceneVariable?.state.datasource?.type).toEqual(variableKind.group); + expect(sceneVariable?.state.datasource?.uid).toEqual(variableKind.datasource?.name); } else if (variableKind.kind !== 'AdhocVariable') { expect(sceneVariable).toBeInstanceOf(sceneVariableClass); expect(scene.state?.$variables?.getByName(dashSpec.variables[index].spec.name)?.getValue()).toBe( @@ -52,7 +53,8 @@ export function validateVariable< expect(sceneVariable?.state.pluginId).toBe(variableKind.spec.pluginId); } if (sceneVariable instanceof QueryVariable && variableKind.kind === 'QueryVariable') { - expect(sceneVariable?.state.datasource).toBe(variableKind.spec.datasource); + expect(sceneVariable?.state.datasource?.type).toBe(variableKind.spec.query?.group); + expect(sceneVariable?.state.datasource?.uid).toBe(variableKind.spec.query?.datasource?.name); expect(sceneVariable?.state.query).toEqual(variableKind.spec.query.spec); } if (sceneVariable instanceof CustomVariable && variableKind.kind === 'CustomVariable') { @@ -66,8 +68,8 @@ export function validateVizPanel(vizPanel: VizPanel, dash: DashboardV2Spec) { if (panel.kind === 'Panel') { expect(vizPanel.state.title).toBe(panel.spec.title); expect(vizPanel.state.description).toBe(panel.spec.description); - expect(vizPanel.state.pluginId).toBe(panel.spec.vizConfig.kind); - expect(vizPanel.state.pluginVersion).toBe(panel.spec.vizConfig.spec.pluginVersion); + expect(vizPanel.state.pluginId).toBe(panel.spec.vizConfig.group); + expect(vizPanel.state.pluginVersion).toBe(panel.spec.vizConfig.version); expect(vizPanel.state.options).toEqual(panel.spec.vizConfig.spec.options); expect(vizPanel.state.fieldConfig).toEqual(panel.spec.vizConfig.spec.fieldConfig); expect(getPanelIdForVizPanel(vizPanel)).toBe(panel.spec.id); diff --git a/public/app/features/dashboard-scene/v2schema/transformers.test.ts b/public/app/features/dashboard-scene/v2schema/transformers.test.ts index cc6b4999746..65924b4cf85 100644 --- a/public/app/features/dashboard-scene/v2schema/transformers.test.ts +++ b/public/app/features/dashboard-scene/v2schema/transformers.test.ts @@ -1,8 +1,8 @@ import { config } from '@grafana/runtime'; import { CustomVariable, GroupByVariable } from '@grafana/scenes'; import { LibraryPanel } from '@grafana/schema'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { handyTestingSchema } from '@grafana/schema/dist/esm/schema/dashboard/v2_examples'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import * as libpanels from 'app/features/library-panels/state/api'; diff --git a/public/app/features/dashboard/api/ResponseTransformers.test.ts b/public/app/features/dashboard/api/ResponseTransformers.test.ts index 9187b6fc9c8..89e2d70a5a9 100644 --- a/public/app/features/dashboard/api/ResponseTransformers.test.ts +++ b/public/app/features/dashboard/api/ResponseTransformers.test.ts @@ -1,14 +1,15 @@ import { AnnotationQuery, DataQuery, VariableModel, VariableRefresh, Panel } from '@grafana/schema'; -import { handyTestingSchema } from '@grafana/schema/dist/esm/schema/dashboard/v2_examples'; import { Spec as DashboardV2Spec, + defaultDataQueryKind, GridLayoutItemKind, GridLayoutKind, PanelKind, RowsLayoutKind, RowsLayoutRowKind, VariableKind, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import { handyTestingSchema } from '@grafana/schema/dist/esm/schema/dashboard/v2_examples'; import { AnnoKeyCreatedBy, AnnoKeyDashboardGnetId, @@ -393,7 +394,7 @@ describe('ResponseTransformers', () => { const transformed = ResponseTransformers.ensureV2Response(dto); // Metadata - expect(transformed.apiVersion).toBe('v2alpha1'); + expect(transformed.apiVersion).toBe('v2beta1'); expect(transformed.kind).toBe('DashboardWithAccessInfo'); expect(transformed.metadata.annotations?.[AnnoKeyCreatedBy]).toEqual('user1'); expect(transformed.metadata.annotations?.[AnnoKeyUpdatedBy]).toEqual('user2'); @@ -449,14 +450,15 @@ describe('ResponseTransformers', () => { id: 1, links: [], vizConfig: { - kind: 'timeseries', + kind: 'VizConfig', + group: 'timeseries', + version: undefined, spec: { fieldConfig: { defaults: {}, overrides: [], }, options: {}, - pluginVersion: undefined, }, }, data: { @@ -466,10 +468,14 @@ describe('ResponseTransformers', () => { { kind: 'PanelQuery', spec: { - datasource: 'datasource1', hidden: false, query: { - kind: 'prometheus', + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'prometheus', + datasource: { + name: 'datasource1', + }, spec: { expr: 'test-query', }, @@ -793,7 +799,7 @@ describe('ResponseTransformers', () => { it('should transform DashboardWithAccessInfo to DashboardDTO', () => { const dashboardV2: DashboardWithAccessInfo = { - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', metadata: { creationTimestamp: '2023-01-01T00:00:00Z', @@ -973,11 +979,9 @@ describe('ResponseTransformers', () => { result.forEach((query) => { expect(query.kind).toBe('PanelQuery'); - expect(query.spec.datasource).toEqual({ - type: 'theoretical-ds', - uid: 'theoretical-uid', - }); - expect(query.spec.query.kind).toBe('theoretical-ds'); + expect(query.spec.query.group).toEqual('theoretical-ds'); + expect(query.spec.query.datasource?.name).toEqual('theoretical-uid'); + expect(query.spec.query.kind).toBe('DataQuery'); }); }); @@ -1003,11 +1007,9 @@ describe('ResponseTransformers', () => { result.forEach((query) => { expect(query.kind).toBe('PanelQuery'); - expect(query.spec.datasource).toEqual({ - type: 'theoretical-ds', - uid: 'theoretical-uid', - }); - expect(query.spec.query.kind).toBe('theoretical-ds'); + expect(query.spec.query.group).toEqual('theoretical-ds'); + expect(query.spec.query.datasource?.name).toEqual('theoretical-uid'); + expect(query.spec.query.kind).toBe('DataQuery'); }); }); }); @@ -1015,14 +1017,14 @@ describe('ResponseTransformers', () => { function validateAnnotation(v1: AnnotationQuery, v2: DashboardV2Spec['annotations'][0]) { const { spec: v2Spec } = v2; - expect(v1.name).toBe(v2Spec.name); - expect(v1.datasource).toBe(v2Spec.datasource); + expect(v1.datasource?.type).toBe(v2Spec.query.group); + expect(v1.datasource?.uid).toBe(v2Spec.query.datasource?.name); expect(v1.enable).toBe(v2Spec.enable); expect(v1.hide).toBe(v2Spec.hide); expect(v1.iconColor).toBe(v2Spec.iconColor); - expect(v1.builtIn).toBe(v2Spec.builtIn ? 1 : 0); - expect(v1.target).toBe(v2Spec.query?.spec); + expect(v1.builtIn).toBe(v2Spec.builtIn ? 1 : undefined); + expect(v1.target).toEqual(v2Spec.query.spec); expect(v1.filter).toEqual(v2Spec.filter); } @@ -1031,19 +1033,22 @@ describe('ResponseTransformers', () => { expect(v1.id).toBe(v2Spec.id); expect(v1.id).toBe(v2Spec.id); - expect(v1.type).toBe(v2Spec.vizConfig.kind); + expect(v1.type).toBe(v2Spec.vizConfig.group); expect(v1.title).toBe(v2Spec.title); expect(v1.description).toBe(v2Spec.description); expect(v1.fieldConfig).toEqual(transformMappingsToV1(v2Spec.vizConfig.spec.fieldConfig)); expect(v1.options).toBe(v2Spec.vizConfig.spec.options); - expect(v1.pluginVersion).toBe(v2Spec.vizConfig.spec.pluginVersion); + expect(v1.pluginVersion).toBe(v2Spec.vizConfig.version); expect(v1.links).toEqual(v2Spec.links); expect(v1.targets).toEqual( v2Spec.data.spec.queries.map((q) => { return { refId: q.spec.refId, hide: q.spec.hidden, - datasource: q.spec.datasource, + datasource: { + type: q.spec.query.spec.group, + uid: q.spec.query.spec.datasource?.uid, + }, ...q.spec.query.spec, }; }) @@ -1093,18 +1098,24 @@ describe('ResponseTransformers', () => { }; expect(v2Common).toEqual(v1Common); - if (v2.kind === 'QueryVariable') { - expect(v2.spec.datasource).toEqual(v1.datasource); - - if (typeof v1.query === 'string') { - expect(v2.spec.query.spec[LEGACY_STRING_VALUE_KEY]).toEqual(v1.query); - } else { - expect(v2.spec.query).toEqual({ - kind: v1.datasource?.type, - spec: { - ...(typeof v1.query === 'object' ? v1.query : {}), + expect(v2.spec.query).toMatchObject({ + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: (v1.datasource?.type || getDefaultDataSourceRef()?.type) ?? 'grafana', + ...(v1.datasource?.uid && { + datasource: { + name: v1.datasource?.uid, }, + }), + }); + if (typeof v1.query === 'string') { + expect(v2.spec.query.spec).toEqual({ + [LEGACY_STRING_VALUE_KEY]: v1.query, + }); + } else { + expect(v2.spec.query.spec).toEqual({ + ...(typeof v1.query === 'object' ? v1.query : {}), }); } } @@ -1120,7 +1131,8 @@ describe('ResponseTransformers', () => { } if (v2.kind === 'AdhocVariable') { - expect(v2.spec.datasource).toEqual(v1.datasource); + expect(v2.datasource?.name).toEqual(v1.datasource?.uid); + expect(v2.group).toEqual(v1.datasource?.type); // @ts-expect-error expect(v2.spec.filters).toEqual(v1.filters); // @ts-expect-error @@ -1149,7 +1161,8 @@ describe('ResponseTransformers', () => { } if (v2.kind === 'GroupByVariable') { - expect(v2.spec.datasource).toEqual(v1.datasource); + expect(v2.datasource?.name).toEqual(v1.datasource?.uid); + expect(v2.group).toEqual(v1.datasource?.type); expect(v2.spec.options).toEqual(v1.options); } } diff --git a/public/app/features/dashboard/api/ResponseTransformers.ts b/public/app/features/dashboard/api/ResponseTransformers.ts index 2299729a8d7..a8960b0874e 100644 --- a/public/app/features/dashboard/api/ResponseTransformers.ts +++ b/public/app/features/dashboard/api/ResponseTransformers.ts @@ -38,9 +38,10 @@ import { LibraryPanelKind, PanelKind, GridLayoutItemKind, + defaultDataQueryKind, RowsLayoutRowKind, GridLayoutKind, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { DashboardLink, DataTransformerConfig } from '@grafana/schema/src/raw/dashboard/x/dashboard_types.gen'; import { isWeekStart, WeekStart } from '@grafana/ui'; import { @@ -55,6 +56,7 @@ import { DeprecatedInternalId, ObjectMeta, } from 'app/features/apiserver/types'; +import { transformV2ToV1AnnotationQuery } from 'app/features/dashboard-scene/serialization/annotations'; import { GRID_ROW_HEIGHT } from 'app/features/dashboard-scene/serialization/const'; import { validateFiltersOrigin } from 'app/features/dashboard-scene/serialization/sceneVariablesSetToVariables'; import { TypedVariableModelV2 } from 'app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene'; @@ -181,7 +183,7 @@ export function ensureV2Response( }; return { - apiVersion: 'v2alpha1', + apiVersion: 'v2beta1', kind: 'DashboardWithAccessInfo', metadata: { creationTimestamp: creationTimestamp || '', // TODO verify this empty string is valid @@ -457,9 +459,13 @@ export function getPanelQueries(targets: DataQuery[], panelDatasource: DataSourc spec: { refId: t.refId, hidden: t.hide ?? false, - datasource: t.datasource ? t.datasource : panelDatasource, query: { - kind: t.datasource?.type || panelDatasource.type!, + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: t.datasource?.type || panelDatasource.type!, + datasource: { + name: t.datasource?.uid || panelDatasource.uid!, + }, spec: { ...query, }, @@ -481,11 +487,12 @@ export function buildPanelKind(p: Panel): PanelKind { title: p.title || '', description: p.description || '', vizConfig: { - kind: p.type, + kind: 'VizConfig', + group: p.type, + version: p.pluginVersion!, spec: { fieldConfig: (p.fieldConfig as any) || defaultFieldConfigSource(), options: p.options as any, - pluginVersion: p.pluginVersion!, }, }, links: @@ -539,6 +546,9 @@ function getVariables(vars: TypedVariableModel[]): DashboardV2Spec['variables'] hide: transformVariableHideToEnum(v.hide), }; + let ds: DataSourceRef | undefined; + let dsType: string | undefined; + switch (v.type) { case 'query': let query = v.query || {}; @@ -569,7 +579,12 @@ function getVariables(vars: TypedVariableModel[]): DashboardV2Spec['variables'] regex: v.regex || '', sort: transformSortVariableToEnum(v.sort), query: { - kind: v.datasource?.type || getDefaultDatasourceType(), + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: v.datasource?.type ?? getDefaultDatasourceType(), + datasource: { + name: v.datasource?.uid, + }, spec: query, }, allowCustomValue: v.allowCustomValue ?? true, @@ -624,17 +639,27 @@ function getVariables(vars: TypedVariableModel[]): DashboardV2Spec['variables'] variables.push(cv); break; case 'adhoc': + ds = v.datasource || getDefaultDatasource(); + dsType = ds.type ?? getDefaultDatasourceType(); + const av: AdhocVariableKind = { kind: 'AdhocVariable', + group: dsType, spec: { ...commonProperties, - datasource: v.datasource || getDefaultDatasource(), baseFilters: validateFiltersOrigin(v.baseFilters) || [], filters: validateFiltersOrigin(v.filters) || [], defaultKeys: v.defaultKeys || [], allowCustomValue: v.allowCustomValue ?? true, }, }; + + if (ds.uid) { + av.datasource = { + name: ds.uid, + }; + } + variables.push(av); break; case 'constant': @@ -688,11 +713,14 @@ function getVariables(vars: TypedVariableModel[]): DashboardV2Spec['variables'] variables.push(tx); break; case 'groupby': + ds = v.datasource || getDefaultDatasource(); + dsType = ds.type ?? getDefaultDatasourceType(); + const gb: GroupByVariableKind = { kind: 'GroupByVariable', + group: dsType, spec: { ...commonProperties, - datasource: v.datasource || getDefaultDatasource(), options: v.options, current: { value: v.current.value, @@ -701,6 +729,13 @@ function getVariables(vars: TypedVariableModel[]): DashboardV2Spec['variables'] multi: v.multi, }, }; + + if (ds.uid) { + gb.datasource = { + name: ds.uid, + }; + } + variables.push(gb); break; default: @@ -723,7 +758,12 @@ function getAnnotations(annotations: AnnotationQuery[]): DashboardV2Spec['annota iconColor: a.iconColor, builtIn: Boolean(a.builtIn), query: { - kind: a.datasource?.type || getDefaultDatasourceType(), + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: a.datasource?.type || getDefaultDatasourceType(), + datasource: { + name: a.datasource?.uid, + }, spec: { ...a.target, }, @@ -758,7 +798,10 @@ function getVariablesV1(vars: DashboardV2Spec['variables']): VariableModel[] { LEGACY_STRING_VALUE_KEY in v.spec.query.spec ? v.spec.query.spec[LEGACY_STRING_VALUE_KEY] : v.spec.query.spec, - datasource: v.spec.datasource, + datasource: { + type: v.spec.query?.spec.group, + uid: v.spec.query?.spec.datasource?.name, + }, sort: transformSortVariableToEnumV1(v.spec.sort), refresh: transformVariableRefreshToEnumV1(v.spec.refresh), regex: v.spec.regex, @@ -850,7 +893,10 @@ function getVariablesV1(vars: DashboardV2Spec['variables']): VariableModel[] { case 'GroupByVariable': const gv: VariableModel = { ...commonProperties, - datasource: v.spec.datasource, + datasource: { + uid: v.datasource?.name, + type: v.group, + }, current: v.spec.current, options: v.spec.options, }; @@ -859,7 +905,10 @@ function getVariablesV1(vars: DashboardV2Spec['variables']): VariableModel[] { case 'AdhocVariable': const av: VariableModel = { ...commonProperties, - datasource: v.spec.datasource, + datasource: { + uid: v.datasource?.name, + type: v.group, + }, // @ts-expect-error baseFilters: v.spec.baseFilters, filters: v.spec.filters, @@ -875,22 +924,6 @@ function getVariablesV1(vars: DashboardV2Spec['variables']): VariableModel[] { return variables; } -function getAnnotationsV1(annotations: DashboardV2Spec['annotations']): AnnotationQuery[] { - // @ts-expect-error - target v2 query is not compatible with v1 target - return annotations.map((a) => { - return { - name: a.spec.name, - datasource: a.spec.datasource, - enable: a.spec.enable, - hide: a.spec.hide, - iconColor: a.spec.iconColor, - builtIn: a.spec.builtIn ? 1 : 0, - target: a.spec.query?.spec, - filter: a.spec.filter, - }; - }); -} - interface LibraryPanelDTO extends Pick {} function getPanelsV1( @@ -934,12 +967,12 @@ function transformV2PanelToV1Panel( const panel = p.spec; return { id: panel.id, - type: panel.vizConfig.kind, + type: panel.vizConfig.group, title: panel.title, description: panel.description, fieldConfig: transformMappingsToV1(panel.vizConfig.spec.fieldConfig), options: panel.vizConfig.spec.options, - pluginVersion: panel.vizConfig.spec.pluginVersion, + pluginVersion: panel.vizConfig.version, links: // @ts-expect-error - Panel link is wrongly typed as DashboardLink panel.links?.map((l) => ({ @@ -951,7 +984,10 @@ function transformV2PanelToV1Panel( return { refId: q.spec.refId, hide: q.spec.hidden, - datasource: q.spec.datasource, + datasource: { + uid: q.spec.query.spec.datasource?.uid, + type: q.spec.query.spec.group, + }, ...q.spec.query.spec, }; }), @@ -1135,7 +1171,8 @@ function transformToV1VariableTypes(variable: TypedVariableModelV2): VariableTyp } export function transformDashboardV2SpecToV1(spec: DashboardV2Spec, metadata: ObjectMeta): DashboardDataDTO { - const annotations = getAnnotationsV1(spec.annotations); + const annotations = spec.annotations.map(transformV2ToV1AnnotationQuery); + const variables = getVariablesV1(spec.variables); const panels = getPanelsV1(spec.elements, spec.layout); return { diff --git a/public/app/features/dashboard/api/UnifiedDashboardAPI.test.ts b/public/app/features/dashboard/api/UnifiedDashboardAPI.test.ts index 8b9e3cd2f47..737fda747ab 100644 --- a/public/app/features/dashboard/api/UnifiedDashboardAPI.test.ts +++ b/public/app/features/dashboard/api/UnifiedDashboardAPI.test.ts @@ -2,7 +2,7 @@ import { Dashboard } from '@grafana/schema'; import { Spec as DashboardV2Spec, defaultSpec as defaultDashboardV2Spec, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { ResourceList } from 'app/features/apiserver/types'; import { DashboardDataDTO, DashboardDTO } from 'app/types/dashboard'; @@ -64,7 +64,7 @@ describe('UnifiedDashboardAPI', () => { it('should fallback to v2 if v1 throws DashboardVersionError', async () => { const mockV2Response = { spec: { title: 'test' } }; - v1Client.getDashboardDTO.mockRejectedValue(new DashboardVersionError('v2alpha1', 'Dashboard is V1 format')); + v1Client.getDashboardDTO.mockRejectedValue(new DashboardVersionError('v2beta1', 'Dashboard is V1 format')); v2Client.getDashboardDTO.mockResolvedValue(mockV2Response as DashboardWithAccessInfo); const result = await api.getDashboardDTO('123'); @@ -88,7 +88,7 @@ describe('UnifiedDashboardAPI', () => { }, }; - v1Client.getDashboardDTO.mockRejectedValue(new DashboardVersionError('v2alpha1', 'Dashboard is V1 format')); + v1Client.getDashboardDTO.mockRejectedValue(new DashboardVersionError('v2beta1', 'Dashboard is V1 format')); v2Client.getDashboardDTO.mockImplementation((params) => { const actualClient = jest.requireActual('./v2').K8sDashboardV2API; const client = new actualClient(); @@ -152,7 +152,7 @@ describe('UnifiedDashboardAPI', () => { describe('deleteDashboard', () => { it('should not try other version if fails', async () => { - v1Client.deleteDashboard.mockRejectedValue(new DashboardVersionError('v2alpha1', 'Dashboard is V1 format')); + v1Client.deleteDashboard.mockRejectedValue(new DashboardVersionError('v2beta1', 'Dashboard is V1 format')); try { await api.deleteDashboard('123', true); @@ -188,7 +188,7 @@ describe('UnifiedDashboardAPI', () => { { metadata: { name: 'v2-dash', resourceVersion: '123', creationTimestamp: '2023-01-01T00:00:00Z' }, spec: null, - status: { conversion: { failed: true, storedVersion: 'v2alpha1', error: 'conversion failed' } }, + status: { conversion: { failed: true, storedVersion: 'v2beta1', error: 'conversion failed' } }, }, { kind: 'Dashboard', @@ -200,13 +200,13 @@ describe('UnifiedDashboardAPI', () => { ], }; const mockV2Response = { - apiVersion: 'dashboard.grafana.app/v2alpha1', + apiVersion: 'dashboard.grafana.app/v2beta1', kind: 'DashboardList', metadata: { resourceVersion: '456' }, items: [ { kind: 'Dashboard', - apiVersion: 'dashboard.grafana.app/v2alpha1', + apiVersion: 'dashboard.grafana.app/v2beta1', metadata: { name: 'v2-dash', resourceVersion: '456', creationTimestamp: '2023-01-01T00:00:00Z' }, spec: { title: 'v2', elements: {} }, status: {}, @@ -273,7 +273,7 @@ describe('UnifiedDashboardAPI', () => { it('should use v2 client for v2 dashboard resource', async () => { const mockV2Dashboard = { - apiVersion: 'dashboard.grafana.app/v2alpha1', + apiVersion: 'dashboard.grafana.app/v2beta1', kind: 'Dashboard', metadata: { name: 'dash-1', diff --git a/public/app/features/dashboard/api/UnifiedDashboardAPI.ts b/public/app/features/dashboard/api/UnifiedDashboardAPI.ts index 6851a6c0f76..a76049421d2 100644 --- a/public/app/features/dashboard/api/UnifiedDashboardAPI.ts +++ b/public/app/features/dashboard/api/UnifiedDashboardAPI.ts @@ -1,5 +1,5 @@ import { Dashboard } from '@grafana/schema'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { isResource } from 'app/features/apiserver/guards'; import { Resource, ResourceList } from 'app/features/apiserver/types'; import { DashboardDataDTO, DashboardDTO } from 'app/types/dashboard'; @@ -7,7 +7,13 @@ import { DashboardDataDTO, DashboardDTO } from 'app/types/dashboard'; import { SaveDashboardCommand } from '../components/SaveDashboard/types'; import { DashboardAPI, DashboardVersionError, DashboardWithAccessInfo, ListDeletedDashboardsOptions } from './types'; -import { isDashboardV2Spec, isV1DashboardCommand, isV2DashboardCommand, failedFromVersion } from './utils'; +import { + isDashboardV2Spec, + isV1DashboardCommand, + isV2DashboardCommand, + failedFromVersion, + isV2StoredVersion, +} from './utils'; import { K8sDashboardAPI } from './v1'; import { K8sDashboardV2API } from './v2'; @@ -27,7 +33,7 @@ export class UnifiedDashboardAPI try { return await this.v1Client.getDashboardDTO(uid); } catch (error) { - if (error instanceof DashboardVersionError && error.data.storedVersion === 'v2alpha1') { + if (error instanceof DashboardVersionError && isV2StoredVersion(error.data.storedVersion)) { return await this.v2Client.getDashboardDTO(uid); } throw error; diff --git a/public/app/features/dashboard/api/dashboard_api.ts b/public/app/features/dashboard/api/dashboard_api.ts index c4a73deccb7..966de322f62 100644 --- a/public/app/features/dashboard/api/dashboard_api.ts +++ b/public/app/features/dashboard/api/dashboard_api.ts @@ -1,5 +1,5 @@ import { Dashboard } from '@grafana/schema'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { DashboardDTO } from 'app/types/dashboard'; import { UnifiedDashboardAPI } from './UnifiedDashboardAPI'; diff --git a/public/app/features/dashboard/api/types.ts b/public/app/features/dashboard/api/types.ts index 90c16d527dc..ff4037da318 100644 --- a/public/app/features/dashboard/api/types.ts +++ b/public/app/features/dashboard/api/types.ts @@ -1,5 +1,5 @@ import { UrlQueryMap } from '@grafana/data'; -import { Status } from '@grafana/schema/src/schema/dashboard/v2alpha1/types.status.gen'; +import { Status } from '@grafana/schema/src/schema/dashboard/v2'; import { ListOptions, Resource, ResourceList } from 'app/features/apiserver/types'; import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; import { AnnotationsPermissions, SaveDashboardResponseDTO } from 'app/types/dashboard'; @@ -40,7 +40,7 @@ export interface DashboardVersionError extends Error { status: number; data: { // The version which was stored when the dashboard was created / updated. - // Currently known versions are: 'v2alpha1' | 'v1beta1' | 'v0alpha1' + // Currently known versions are: 'v2beta1' | 'v1beta1' | 'v0alpha1' storedVersion: string; message: string; }; diff --git a/public/app/features/dashboard/api/utils.ts b/public/app/features/dashboard/api/utils.ts index d027960bce7..b79cfbacf9a 100644 --- a/public/app/features/dashboard/api/utils.ts +++ b/public/app/features/dashboard/api/utils.ts @@ -1,7 +1,7 @@ import { config, locationService } from '@grafana/runtime'; import { Dashboard } from '@grafana/schema/dist/esm/index.gen'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; -import { Status } from '@grafana/schema/src/schema/dashboard/v2alpha1/types.status.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import { Status } from '@grafana/schema/src/schema/dashboard/v2'; import { Resource } from 'app/features/apiserver/types'; import { DashboardDataDTO, DashboardDTO } from 'app/types/dashboard'; @@ -9,6 +9,10 @@ import { SaveDashboardCommand } from '../components/SaveDashboard/types'; import { DashboardWithAccessInfo } from './types'; +export function isV2StoredVersion(version: string): boolean { + return version === 'v2alpha1' || version === 'v2beta1'; +} + export function getDashboardsApiVersion(responseFormat?: 'v1' | 'v2') { const isDashboardSceneEnabled = config.featureToggles.dashboardScene; const isKubernetesDashboardsEnabled = config.featureToggles.kubernetesDashboards; diff --git a/public/app/features/dashboard/api/v1.test.ts b/public/app/features/dashboard/api/v1.test.ts index 7cdadd0995a..675010235ef 100644 --- a/public/app/features/dashboard/api/v1.test.ts +++ b/public/app/features/dashboard/api/v1.test.ts @@ -281,14 +281,14 @@ describe('v1 dashboard API', () => { }); describe('version error handling', () => { - it('should throw DashboardVersionError for v2alpha1 conversion error', async () => { + it('should throw DashboardVersionError for v2beta1 conversion error', async () => { const mockDashboardWithError = { ...mockDashboardDto, status: { conversion: { failed: true, error: 'backend conversion not yet implemented', - storedVersion: 'v2alpha1', + storedVersion: 'v2beta1', }, }, }; diff --git a/public/app/features/dashboard/api/v1.ts b/public/app/features/dashboard/api/v1.ts index f196e9d71e2..7b6423adc3e 100644 --- a/public/app/features/dashboard/api/v1.ts +++ b/public/app/features/dashboard/api/v1.ts @@ -1,7 +1,7 @@ import { locationUtil } from '@grafana/data'; import { t } from '@grafana/i18n'; import { Dashboard } from '@grafana/schema'; -import { Status } from '@grafana/schema/src/schema/dashboard/v2alpha1/types.status.gen'; +import { Status } from '@grafana/schema/src/schema/dashboard/v2'; import { backendSrv } from 'app/core/services/backend_srv'; import { getMessageFromError, getStatusFromError } from 'app/core/utils/errors'; import kbn from 'app/core/utils/kbn'; @@ -26,6 +26,7 @@ import { DashboardDataDTO, DashboardDTO, SaveDashboardResponseDTO } from 'app/ty import { SaveDashboardCommand } from '../components/SaveDashboard/types'; import { DashboardAPI, DashboardVersionError, DashboardWithAccessInfo, ListDeletedDashboardsOptions } from './types'; +import { isV2StoredVersion } from './utils'; export const K8S_V1_DASHBOARD_API_CONFIG = { group: 'dashboard.grafana.app', @@ -116,7 +117,7 @@ export class K8sDashboardAPI implements DashboardAPI { const dash = await this.client.subresource>(uid, 'dto'); // This could come as conversion error from v0 or v2 to V1. - if (dash.status?.conversion?.failed && dash.status.conversion.storedVersion === 'v2alpha1') { + if (dash.status?.conversion?.failed && isV2StoredVersion(dash.status.conversion.storedVersion)) { throw new DashboardVersionError(dash.status.conversion.storedVersion, dash.status.conversion.error); } diff --git a/public/app/features/dashboard/api/v2.test.ts b/public/app/features/dashboard/api/v2.test.ts index a3b540ae6b6..cb86f4d7467 100644 --- a/public/app/features/dashboard/api/v2.test.ts +++ b/public/app/features/dashboard/api/v2.test.ts @@ -1,7 +1,7 @@ import { Spec as DashboardV2Spec, defaultSpec as defaultDashboardV2Spec, -} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { backendSrv } from 'app/core/services/backend_srv'; import { AnnoKeyFolder, @@ -37,7 +37,7 @@ const mockGet = jest.fn().mockResolvedValue(mockDashboardDto); const mockPut = jest.fn().mockImplementation((url, data) => { return { - apiVersion: 'dashboard.grafana.app/v2alpha1', + apiVersion: 'dashboard.grafana.app/v2beta1', kind: 'Dashboard', metadata: { name: data.metadata?.name, @@ -53,7 +53,7 @@ const mockPut = jest.fn().mockImplementation((url, data) => { const mockPost = jest.fn().mockImplementation((url, data) => { return { - apiVersion: 'dashboard.grafana.app/v2alpha1', + apiVersion: 'dashboard.grafana.app/v2beta1', kind: 'Dashboard', metadata: { name: data.metadata?.name || 'restored-dash', @@ -220,7 +220,7 @@ describe('v2 dashboard API', () => { }); expect(mockPut).toHaveBeenCalledTimes(1); expect(mockPut).toHaveBeenCalledWith( - '/apis/dashboard.grafana.app/v2alpha1/namespaces/default/dashboards/existing-dash', + '/apis/dashboard.grafana.app/v2beta1/namespaces/default/dashboards/existing-dash', { metadata: { name: 'existing-dash', @@ -329,7 +329,7 @@ describe('v2 dashboard API', () => { conversion: { failed: true, error: 'other-error', - storedVersion: 'v2alpha1', + storedVersion: 'v2beta1', }, }, }; @@ -381,7 +381,7 @@ describe('v2 dashboard API', () => { expect(dashboardToRestore.metadata.resourceVersion).toBe(''); expect(mockPost).toHaveBeenCalledWith( - expect.stringContaining('/apis/dashboard.grafana.app/v2alpha1/'), + expect.stringContaining('/apis/dashboard.grafana.app/v2beta1/'), expect.objectContaining({ metadata: expect.objectContaining({ resourceVersion: '', diff --git a/public/app/features/dashboard/api/v2.ts b/public/app/features/dashboard/api/v2.ts index 676f7b26e27..3cb7dc51468 100644 --- a/public/app/features/dashboard/api/v2.ts +++ b/public/app/features/dashboard/api/v2.ts @@ -1,7 +1,7 @@ import { locationUtil } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; -import { Status } from '@grafana/schema/src/schema/dashboard/v2alpha1/types.status.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import { Status } from '@grafana/schema/src/schema/dashboard/v2'; import { backendSrv } from 'app/core/services/backend_srv'; import { getMessageFromError, getStatusFromError } from 'app/core/utils/errors'; import kbn from 'app/core/utils/kbn'; @@ -28,7 +28,7 @@ import { isDashboardV2Spec } from './utils'; export const K8S_V2_DASHBOARD_API_CONFIG = { group: 'dashboard.grafana.app', - version: 'v2alpha1', + version: 'v2beta1', resource: 'dashboards', }; diff --git a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx index 6fc2e3c5334..b679d1ccf69 100644 --- a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx @@ -2,7 +2,6 @@ import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { render } from 'test/test-utils'; -import { config } from '@grafana/runtime'; import { historySrv } from 'app/features/dashboard-scene/settings/version-history/HistorySrv'; import { createDashboardModelFixture } from '../../state/__fixtures__/dashboardFixtures'; @@ -110,7 +109,7 @@ describe('VersionSettings', () => { test('renders buttons if versions >= VERSIONS_FETCH_LIMIT', async () => { historySrv.getHistoryList = jest.fn().mockResolvedValue({ - continueToken: versions.continueToken, + continueToken: 'next-page-token', versions: versions.versions.slice(0, VERSIONS_FETCH_LIMIT), }); @@ -135,13 +134,13 @@ describe('VersionSettings', () => { .fn() .mockImplementationOnce(() => Promise.resolve({ - continueToken: versions.continueToken, + continueToken: 'next-page-token', versions: versions.versions.slice(0, VERSIONS_FETCH_LIMIT), }) ) .mockImplementationOnce(() => Promise.resolve({ - continueToken: versions.continueToken, + continueToken: '', versions: versions.versions.slice(VERSIONS_FETCH_LIMIT), }) ); @@ -184,8 +183,7 @@ describe('VersionSettings', () => { expect(screen.getByRole('button', { name: /compare versions/i })).toBeInTheDocument(); }); - test('does not show more button when kubernetesClientDashboardsFolders is enabled and continueToken is empty', async () => { - config.featureToggles.kubernetesClientDashboardsFolders = true; + test('does not show more button when continueToken is empty', async () => { historySrv.getHistoryList = jest.fn().mockResolvedValueOnce({ continueToken: '', versions: versions.versions.slice(0, VERSIONS_FETCH_LIMIT - 1), @@ -197,8 +195,6 @@ describe('VersionSettings', () => { expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: /compare versions/i })).toBeInTheDocument(); - - config.featureToggles.kubernetesClientDashboardsFolders = false; }); test('selecting two versions and clicking compare button should render compare view', async () => { diff --git a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx index fad6f79bffc..9c20ba24802 100644 --- a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx @@ -1,7 +1,6 @@ import { PureComponent } from 'react'; import * as React from 'react'; -import { config } from '@grafana/runtime'; import { Spinner, HorizontalGroup } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { historySrv, RevisionsModel } from 'app/features/dashboard-scene/settings/version-history/HistorySrv'; @@ -90,15 +89,9 @@ export class VersionsSettings extends PureComponent { isLoading: true, }); - let lhs, rhs; - if (config.featureToggles.kubernetesClientDashboardsFolders) { - // the id here is the resource version in k8s, use this instead to get the specific version - lhs = await historySrv.getDashboardVersion(this.props.dashboard.uid, baseInfo.id); - rhs = await historySrv.getDashboardVersion(this.props.dashboard.uid, newInfo.id); - } else { - lhs = await historySrv.getDashboardVersion(this.props.dashboard.uid, baseInfo.version); - rhs = await historySrv.getDashboardVersion(this.props.dashboard.uid, newInfo.version); - } + // the id here is the resource version in k8s, use this instead to get the specific version + let lhs = await historySrv.getDashboardVersion(this.props.dashboard.uid, baseInfo.id); + let rhs = await historySrv.getDashboardVersion(this.props.dashboard.uid, newInfo.id); this.setState({ baseInfo, @@ -122,14 +115,11 @@ export class VersionsSettings extends PureComponent { })); isLastPage() { - if (config.featureToggles.kubernetesClientDashboardsFolders) { - return ( - this.state.versions.find((rev) => rev.version === 1) || - this.state.versions.length % this.limit !== 0 || - this.continueToken === '' - ); - } - return this.state.versions.find((rev) => rev.version === 1) || this.state.versions.length % this.limit !== 0; + return ( + this.state.versions.find((rev) => rev.version === 1) || + this.state.versions.length % this.limit !== 0 || + this.continueToken === '' + ); } onCheck = (ev: React.FormEvent, versionId: number) => { diff --git a/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx b/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx index 53c7633fbaa..ff08bf44b6c 100644 --- a/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx +++ b/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react'; import { useAsyncFn } from 'react-use'; import { locationUtil } from '@grafana/data'; -import { config, locationService } from '@grafana/runtime'; +import { locationService } from '@grafana/runtime'; import { useAppNotification } from 'app/core/copy/appNotification'; import { historySrv } from 'app/features/dashboard-scene/settings/version-history/HistorySrv'; import { useSelector } from 'app/types/store'; @@ -18,11 +18,7 @@ const restoreDashboard = async (version: number, dashboard: DashboardModel) => { export const useDashboardRestore = (id: number, version: number) => { const dashboard = useSelector((state) => state.dashboard.getModel()); - const [state, onRestoreDashboard] = useAsyncFn( - async () => - await restoreDashboard(config.featureToggles.kubernetesClientDashboardsFolders ? id : version, dashboard!), - [] - ); + const [state, onRestoreDashboard] = useAsyncFn(async () => await restoreDashboard(id, dashboard!), []); const notifyApp = useAppNotification(); useEffect(() => { 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/DashboardLoaderSrv.ts b/public/app/features/dashboard/services/DashboardLoaderSrv.ts index d28f575ce86..ef3ec5fb279 100644 --- a/public/app/features/dashboard/services/DashboardLoaderSrv.ts +++ b/public/app/features/dashboard/services/DashboardLoaderSrv.ts @@ -4,7 +4,7 @@ import moment from 'moment'; // eslint-disable-line no-restricted-imports import { AppEvents, dateMath, UrlQueryMap, UrlQueryValue } from '@grafana/data'; import { getBackendSrv, isFetchError, locationService } from '@grafana/runtime'; -import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { backendSrv } from 'app/core/services/backend_srv'; import impressionSrv from 'app/core/services/impression_srv'; import kbn from 'app/core/utils/kbn'; 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/dashboard/utils/tracking.ts b/public/app/features/dashboard/utils/tracking.ts index 1bd574edb38..0d5e1ecc30c 100644 --- a/public/app/features/dashboard/utils/tracking.ts +++ b/public/app/features/dashboard/utils/tracking.ts @@ -1,5 +1,5 @@ import { VariableModel } from '@grafana/schema/dist/esm/index'; -import { VariableKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { VariableKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions'; diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index c1bcd2a7d97..cb47295d59d 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -108,6 +108,8 @@ const dummyProps: Props = { dsToExplore: [], }, changeDatasource: jest.fn(), + compact: false, + changeCompactMode: jest.fn(), }; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 57ef823de4d..a88d594c6d1 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -58,7 +58,7 @@ import { SecondaryActions } from './SecondaryActions'; import TableContainer from './Table/TableContainer'; import { TraceViewContainer } from './TraceView/TraceViewContainer'; import { changeDatasource } from './state/datasource'; -import { changeSize } from './state/explorePane'; +import { changeSize, changeCompactMode } from './state/explorePane'; import { splitOpen } from './state/main'; import { addQueryRow, @@ -182,14 +182,16 @@ export class Explore extends PureComponent { onContentOutlineToogle = () => { store.set(CONTENT_OUTLINE_LOCAL_STORAGE_KEYS.visible, !this.state.contentOutlineVisible); this.setState((state) => { + const newContentOutlineVisible = this.props.compact ? true : !state.contentOutlineVisible; reportInteraction('explore_toolbar_contentoutline_clicked', { item: 'outline', - type: state.contentOutlineVisible ? 'close' : 'open', + type: newContentOutlineVisible ? 'open' : 'close', }); return { - contentOutlineVisible: !state.contentOutlineVisible, + contentOutlineVisible: newContentOutlineVisible, }; }); + this.props.changeCompactMode(this.props.exploreId, false); }; /** @@ -306,9 +308,12 @@ export class Explore extends PureComponent { updateTimeRange({ exploreId, absoluteRange }); }; + /** + * Used for interaction from the visualizations. Will open split view in compact mode. + */ onSplitOpen = (panelType: string) => { return async (options?: SplitOpenOptions) => { - this.props.splitOpen(options); + this.props.splitOpen(options ? { ...options, compact: true } : options); if (options && this.props.datasourceInstance) { const target = (await getDataSourceSrv().get(options.datasourceUid)).type; const source = @@ -573,6 +578,8 @@ export class Explore extends PureComponent { correlationEditorHelperData, showQueryInspector, setShowQueryInspector, + splitted, + compact, } = this.props; const { contentOutlineVisible } = this.state; const styles = getStyles(theme); @@ -614,7 +621,7 @@ export class Explore extends PureComponent { }} >
    - {contentOutlineVisible && ( + {contentOutlineVisible && !compact && ( )} { mergeSingleChild={true} > - + {!splitted && } {correlationsBox} - + + this.props.changeCompactMode(this.props.exploreId, false) + } + /> size && updateSplitSize(size)} > - {panes.map(([exploreId]) => { + {panes.map(([exploreId, pane]) => { return ( - + {pane.initialized ? ( + + ) : ( + + )} ); })} diff --git a/public/app/features/explore/QueryLibrary/QueryLibraryContext.tsx b/public/app/features/explore/QueryLibrary/QueryLibraryContext.tsx index 90640203d48..eeb7c3d9b1d 100644 --- a/public/app/features/explore/QueryLibrary/QueryLibraryContext.tsx +++ b/public/app/features/explore/QueryLibrary/QueryLibraryContext.tsx @@ -1,5 +1,6 @@ import { createContext, ReactNode, useContext } from 'react'; +import { CoreApp } from '@grafana/data'; import { DataQuery } from '@grafana/schema'; import { OnSelectQueryType } from './types'; @@ -17,13 +18,17 @@ export type QueryLibraryContextType = { * @param datasourceFilters Data source names that will be used for initial filter in the library. * @param queryActionButton Action button will be shown in the library next to the query and can implement context * specific actions with the library, like running the query or updating some query in the current app. - * @param options.context Used for tracking. Should identify the context this is called from, like 'explore' or + * @param options.context Used for QueryEditor. Should identify the context this is called from, like 'explore' or * 'dashboard'. */ openDrawer: ( datasourceFilters: string[], onSelectQuery: OnSelectQueryType, - options?: { isReplacingQuery?: boolean; context?: string } + options?: { + isReplacingQuery?: boolean; + context?: string; + highlightQuery?: string; + } ) => 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/QueryRows.test.tsx b/public/app/features/explore/QueryRows.test.tsx index 03989ec2950..cd11f943811 100644 --- a/public/app/features/explore/QueryRows.test.tsx +++ b/public/app/features/explore/QueryRows.test.tsx @@ -79,7 +79,7 @@ describe('Explore QueryRows', () => { render( - + ); @@ -100,7 +100,7 @@ describe('Explore QueryRows', () => { render( - + ); @@ -117,7 +117,7 @@ describe('Explore QueryRows', () => { render( - + ); diff --git a/public/app/features/explore/QueryRows.tsx b/public/app/features/explore/QueryRows.tsx index 084c180544f..7e74a736f8b 100644 --- a/public/app/features/explore/QueryRows.tsx +++ b/public/app/features/explore/QueryRows.tsx @@ -4,6 +4,7 @@ import { useCallback, useMemo } from 'react'; import { CoreApp, getNextRefId } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; import { DataQuery, DataSourceRef } from '@grafana/schema'; +import { ExploreItemState } from 'app/types/explore'; import { useDispatch, useSelector } from 'app/types/store'; import { getDatasourceSrv } from '../plugins/datasource_srv'; @@ -16,23 +17,25 @@ import { getExploreItemSelector } from './state/selectors'; interface Props { exploreId: string; + changeCompactMode: (compact: boolean) => void; + isOpen?: boolean; } const makeSelectors = (exploreId: string) => { const exploreItemSelector = getExploreItemSelector(exploreId); return { - getQueries: createSelector(exploreItemSelector, (s) => s!.queries), - getQueryResponse: createSelector(exploreItemSelector, (s) => s!.queryResponse), - getHistory: createSelector(exploreItemSelector, (s) => s!.history), - getEventBridge: createSelector(exploreItemSelector, (s) => s!.eventBridge), + getQueries: createSelector(exploreItemSelector, (s: ExploreItemState | undefined) => s!.queries), + getQueryResponse: createSelector(exploreItemSelector, (s: ExploreItemState | undefined) => s!.queryResponse), + getHistory: createSelector(exploreItemSelector, (s: ExploreItemState | undefined) => s!.history), + getEventBridge: createSelector(exploreItemSelector, (s: ExploreItemState | undefined) => s!.eventBridge), getDatasourceInstanceSettings: createSelector( exploreItemSelector, - (s) => getDatasourceSrv().getInstanceSettings(s!.datasourceInstance?.uid)! + (s: ExploreItemState | undefined) => getDatasourceSrv().getInstanceSettings(s!.datasourceInstance?.uid)! ), }; }; -export const QueryRows = ({ exploreId }: Props) => { +export const QueryRows = ({ exploreId, isOpen, changeCompactMode }: Props) => { const dispatch = useDispatch(); const { getQueries, getDatasourceInstanceSettings, getQueryResponse, getHistory, getEventBridge } = useMemo( () => makeSelectors(exploreId), @@ -86,6 +89,12 @@ export const QueryRows = ({ exploreId }: Props) => { reportInteraction('grafana_query_row_toggle', queryStatus === undefined ? {} : { queryEnabled: queryStatus }); }; + const onQueryOpenChanged = () => { + // Disables compact mode when query is opened. + // Compact mode can also be disabled by opening Content Outline. + changeCompactMode(false); + }; + return ( { onQueryRemoved={onQueryRemoved} onQueryToggled={onQueryToggled} onQueryReplacedFromLibrary={onQueryReplacedFromLibrary} + onQueryOpenChanged={onQueryOpenChanged} data={queryResponse} app={CoreApp.Explore} history={history} eventBus={eventBridge} + isOpen={isOpen} queryRowWrapper={(children, refId) => ( { 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/TraceView.tsx b/public/app/features/explore/TraceView/TraceView.tsx index 19203ec818a..28a47518b81 100644 --- a/public/app/features/explore/TraceView/TraceView.tsx +++ b/public/app/features/explore/TraceView/TraceView.tsx @@ -167,6 +167,7 @@ export function TraceView(props: Props) { ); const timeZone = useSelector((state) => getTimeZone(state.user)); const datasourceType = datasource ? datasource?.type : 'unknown'; + const datasourceName = datasource ? datasource?.name : 'unknown'; const datasourceUid = datasource ? datasource?.uid : ''; const scrollElement = props.scrollElement ? props.scrollElement @@ -189,6 +190,8 @@ export function TraceView(props: Props) { setFocusedSpanIdForSearch={setFocusedSpanIdForSearch} spanFilterMatches={spanFilterMatches} datasourceType={datasourceType} + datasourceName={datasourceName} + datasourceUid={datasourceUid} setHeaderHeight={setHeaderHeight} app={exploreId ? CoreApp.Explore : CoreApp.Unknown} /> diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/Actions/TracePageActions.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/Actions/TracePageActions.tsx deleted file mode 100644 index e1448a77f69..00000000000 --- a/public/app/features/explore/TraceView/components/TracePageHeader/Actions/TracePageActions.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { css } from '@emotion/css'; -import { useState } from 'react'; - -import { GrafanaTheme2, CoreApp, DataFrame } from '@grafana/data'; -import { Trans, t } from '@grafana/i18n'; -import { reportInteraction } from '@grafana/runtime'; -import { Icon, useTheme2 } from '@grafana/ui'; - -import { config } from '../../../../../../core/config'; -import { downloadTraceAsJson } from '../../../../../inspector/utils/download'; - -import ActionButton from './ActionButton'; - -export const getStyles = (theme: GrafanaTheme2) => { - return { - TracePageActions: css({ - label: 'TracePageActions', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - gap: '4px', - marginBottom: '10px', - }), - feedbackContainer: css({ - color: theme.colors.text.link, - }), - feedback: css({ - margin: '6px', - color: theme.colors.text.link, - fontSize: theme.typography.bodySmall.fontSize, - '&:hover': { - textDecoration: 'underline', - }, - }), - }; -}; - -export type TracePageActionsProps = { - traceId: string; - data: DataFrame; - app?: CoreApp; -}; - -export default function TracePageActions(props: TracePageActionsProps) { - const { traceId, data, app } = props; - const theme = useTheme2(); - - const styles = getStyles(theme); - const [copyTraceIdClicked, setCopyTraceIdClicked] = useState(false); - - const copyTraceId = () => { - navigator.clipboard.writeText(traceId); - setCopyTraceIdClicked(true); - setTimeout(() => { - setCopyTraceIdClicked(false); - }, 5000); - }; - - const exportTrace = () => { - const traceFormat = downloadTraceAsJson(data, 'Trace-' + traceId.substring(traceId.length - 6)); - reportInteraction('grafana_traces_download_traces_clicked', { - app, - grafana_version: config.buildInfo.version, - trace_format: traceFormat, - location: 'trace-view', - }); - }; - - return ( -
    - {config.feedbackLinksEnabled && ( - - )} - - - -
    - ); -} diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.test.tsx index 95d78fff092..97fc884d659 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.test.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.test.tsx @@ -12,15 +12,86 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { getByText, render } from '@testing-library/react'; +import { fireEvent, getByText, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; -import { MutableDataFrame } from '@grafana/data'; +import { + IconName, + MutableDataFrame, + PluginExtensionLink, + PluginExtensionPoints, + PluginExtensionTypes, +} from '@grafana/data'; +import { usePluginLinks } from '@grafana/runtime'; import { DEFAULT_SPAN_FILTERS } from 'app/features/explore/state/constants'; +import { TraceViewPluginExtensionContext } from '../types/trace'; + import { TracePageHeader } from './TracePageHeader'; import { trace } from './mocks'; -const setup = () => { +// Mock @grafana/runtime +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + usePluginLinks: jest.fn(), + reportInteraction: jest.fn(), +})); + +// Mock useAppNotification +jest.mock('app/core/copy/appNotification', () => ({ + useAppNotification: jest.fn(() => ({ + success: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + })), +})); + +// Mock config +jest.mock('../../../../../core/config', () => ({ + config: { + feedbackLinksEnabled: false, // Default to false to avoid interference with tests + }, +})); + +// Mock navigator.clipboard +Object.assign(navigator, { + clipboard: { + writeText: jest.fn().mockResolvedValue(undefined), + }, +}); + +// Mock window.open +const mockWindowOpen = jest.fn(); +Object.defineProperty(window, 'open', { + value: mockWindowOpen, + writable: true, +}); + +// Helper function to create properly typed mock plugin extension links +const createMockExtension = ( + id: string, + title: string, + description = '', + options: { + icon?: string; + path?: string; + onClick?: () => void; + } = {} +): PluginExtensionLink => ({ + id, + type: PluginExtensionTypes.link, + title, + description, + pluginId: 'test-plugin', + icon: options.icon as IconName, + path: options.path, + onClick: options.onClick, +}); + +const setup = (pluginLinks: { links: PluginExtensionLink[]; isLoading: boolean } = { links: [], isLoading: false }) => { + const mockUsePluginLinks = usePluginLinks as jest.MockedFunction; + mockUsePluginLinks.mockReturnValue(pluginLinks); + const defaultProps = { trace, timeZone: '', @@ -37,12 +108,22 @@ const setup = () => { datasourceType: 'tempo', setHeaderHeight: jest.fn(), data: new MutableDataFrame(), + datasourceName: 'test-datasource', + datasourceUid: 'test-datasource-uid', }; - return render(); + return { + ...render(), + mockUsePluginLinks, + }; }; describe('TracePageHeader test', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockWindowOpen.mockClear(); + }); + it('should render the new trace header', () => { setup(); @@ -51,13 +132,359 @@ describe('TracePageHeader test', () => { const status = getByText(header!, '200'); const url = getByText(header!, '/v2/gamma/792edh2w897y2huehd2h89'); const duration = getByText(header!, '2.36s'); - const timestampPart1 = getByText(header!, '2023-02-05 08:50'); - const timestampPart2 = getByText(header!, ':56.289'); + const timestampElement = getByText(header!, '2023-02-05 08:50:56.289'); expect(method).toBeInTheDocument(); expect(status).toBeInTheDocument(); expect(url).toBeInTheDocument(); expect(duration).toBeInTheDocument(); - expect(timestampPart1).toBeInTheDocument(); - expect(timestampPart2).toBeInTheDocument(); + expect(timestampElement).toBeInTheDocument(); + }); + + describe('Plugin Extensions', () => { + it('should call usePluginLinks with correct parameters including datasource context', () => { + const { mockUsePluginLinks } = setup(); + + expect(mockUsePluginLinks).toHaveBeenCalledWith({ + extensionPointId: PluginExtensionPoints.TraceViewHeaderActions, + context: { + ...trace, + datasource: { + name: 'test-datasource', + uid: 'test-datasource-uid', + type: 'tempo', + }, + }, + limitPerPlugin: 2, + }); + }); + + it('should not render plugin extension buttons when no extensions are available', () => { + setup({ links: [], isLoading: false }); + + const extensionButtons = screen.queryByTestId('plugin-extension-button'); + expect(extensionButtons).not.toBeInTheDocument(); + }); + + it('should render plugin extension buttons when extensions are available', () => { + const mockExtensions: PluginExtensionLink[] = [ + createMockExtension('test-extension-1', 'Test Extension 1', 'Test extension description', { + icon: 'external-link-alt', + path: 'https://example.com', + onClick: jest.fn(), + }), + createMockExtension('test-extension-2', 'Test Extension 2', 'Another test extension', { + icon: 'cloud', + onClick: jest.fn(), + }), + ]; + + setup({ links: mockExtensions, isLoading: false }); + + expect(screen.getByText('Test Extension 1')).toBeInTheDocument(); + expect(screen.getByText('Test Extension 2')).toBeInTheDocument(); + }); + + it('should display tooltips for extension buttons', async () => { + const user = userEvent.setup(); + const mockExtensions: PluginExtensionLink[] = [ + createMockExtension('test-extension-1', 'Test Extension', 'This is a test extension description', { + icon: 'external-link-alt', + onClick: jest.fn(), + }), + ]; + + setup({ links: mockExtensions, isLoading: false }); + + const button = screen.getByText('Test Extension'); + await user.hover(button); + + await waitFor(() => { + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + expect(screen.getByText('This is a test extension description')).toBeInTheDocument(); + }); + }); + + it('should use title as tooltip when description is not provided', async () => { + const user = userEvent.setup(); + const mockExtensions: PluginExtensionLink[] = [ + createMockExtension('test-extension-1', 'Test Extension Title', 'Test Extension Title', { + icon: 'external-link-alt', + onClick: jest.fn(), + }), + ]; + + setup({ links: mockExtensions, isLoading: false }); + + const button = screen.getByRole('button', { name: /Test Extension Title/i }); + await user.hover(button); + + await waitFor(() => { + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + expect(screen.getByRole('tooltip')).toHaveTextContent('Test Extension Title'); + }); + }); + + it('should handle extension button clicks with onClick handler', async () => { + const user = userEvent.setup(); + const mockOnClick = jest.fn(); + const mockExtensions: PluginExtensionLink[] = [ + createMockExtension('test-extension-1', 'Test Extension', 'Test extension', { + icon: 'external-link-alt', + onClick: mockOnClick, + }), + ]; + + setup({ links: mockExtensions, isLoading: false }); + + const button = screen.getByText('Test Extension'); + await user.click(button); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + expect(mockOnClick).toHaveBeenCalledWith(expect.any(Object)); + }); + + it('should handle extension button clicks with path navigation', async () => { + const user = userEvent.setup(); + const mockExtensions: PluginExtensionLink[] = [ + createMockExtension('test-extension-1', 'Test Extension', 'Test extension', { + icon: 'external-link-alt', + path: 'https://example.com/trace-details', + }), + ]; + + setup({ links: mockExtensions, isLoading: false }); + + const button = screen.getByText('Test Extension'); + await user.click(button); + + expect(mockWindowOpen).toHaveBeenCalledTimes(1); + expect(mockWindowOpen).toHaveBeenCalledWith('https://example.com/trace-details', '_blank'); + }); + + it('should handle extension with both path and onClick', async () => { + const user = userEvent.setup(); + const mockOnClick = jest.fn(); + const mockExtensions: PluginExtensionLink[] = [ + createMockExtension('test-extension-1', 'Test Extension', 'Test extension', { + icon: 'external-link-alt', + path: 'https://example.com/trace-details', + onClick: mockOnClick, + }), + ]; + + setup({ links: mockExtensions, isLoading: false }); + + const button = screen.getByText('Test Extension'); + await user.click(button); + + expect(mockWindowOpen).toHaveBeenCalledTimes(1); + expect(mockWindowOpen).toHaveBeenCalledWith('https://example.com/trace-details', '_blank'); + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + it('should render extension buttons with correct styling', () => { + const mockExtensions: PluginExtensionLink[] = [ + createMockExtension('test-extension-1', 'Test Extension', 'Test extension', { + icon: 'external-link-alt', + onClick: jest.fn(), + }), + ]; + + setup({ links: mockExtensions, isLoading: false }); + + const button = screen.getByRole('button', { name: /Test Extension/i }); + expect(button).toBeInTheDocument(); + expect(button).toHaveClass('css-7byezq-button'); // Grafana button primary class + }); + + it('should render extension icons when provided', () => { + const mockExtensions: PluginExtensionLink[] = [ + createMockExtension('test-extension-1', 'Test Extension', 'Test extension', { + icon: 'external-link-alt', + onClick: jest.fn(), + }), + ]; + + setup({ links: mockExtensions, isLoading: false }); + + const button = screen.getByRole('button', { name: /Test Extension/i }); + const iconElement = button.querySelector('svg'); + expect(iconElement).toBeInTheDocument(); + }); + + it('should handle multiple extensions correctly', () => { + const mockExtensions: PluginExtensionLink[] = [ + createMockExtension('test-extension-1', 'Extension 1', 'First extension', { + icon: 'external-link-alt', + onClick: jest.fn(), + }), + createMockExtension('test-extension-2', 'Extension 2', 'Second extension', { + icon: 'cloud', + path: 'https://example.com', + }), + createMockExtension('test-extension-3', 'Extension 3', 'Third extension', { + icon: 'apps', + onClick: jest.fn(), + }), + ]; + + setup({ links: mockExtensions, isLoading: false }); + + expect(screen.getByText('Extension 1')).toBeInTheDocument(); + expect(screen.getByText('Extension 2')).toBeInTheDocument(); + expect(screen.getByText('Extension 3')).toBeInTheDocument(); + }); + + it('should maintain extension context with trace data and datasource information', () => { + const { mockUsePluginLinks } = setup(); + + const [callArgs] = mockUsePluginLinks.mock.calls; + expect(callArgs[0]).toEqual({ + extensionPointId: PluginExtensionPoints.TraceViewHeaderActions, + context: { + ...trace, + datasource: { + name: 'test-datasource', + uid: 'test-datasource-uid', + type: 'tempo', + }, + }, + limitPerPlugin: 2, + }); + + // Verify the context contains the expected trace properties + expect(callArgs[0].context).toHaveProperty('traceID', trace.traceID); + expect(callArgs[0].context).toHaveProperty('spans'); + expect(callArgs[0].context).toHaveProperty('duration', trace.duration); + expect(callArgs[0].context).toHaveProperty('startTime', trace.startTime); + + // Verify the context contains the datasource information + expect(callArgs[0].context).toHaveProperty('datasource'); + const contextWithDatasource = callArgs[0].context as TraceViewPluginExtensionContext; + expect(contextWithDatasource.datasource).toEqual({ + name: 'test-datasource', + uid: 'test-datasource-uid', + type: 'tempo', + }); + }); + + it('should handle loading state gracefully', () => { + setup({ links: [], isLoading: true }); + + // Should not crash when loading and should not show any extension buttons + const extensionButtons = screen.queryByTestId('plugin-extension-button'); + expect(extensionButtons).not.toBeInTheDocument(); + }); + + it('should handle extensions without icons', () => { + const mockExtensions: PluginExtensionLink[] = [ + createMockExtension('test-extension-1', 'Extension Without Icon', 'Extension without icon', { + onClick: jest.fn(), + }), + ]; + + setup({ links: mockExtensions, isLoading: false }); + + const button = screen.getByText('Extension Without Icon'); + expect(button).toBeInTheDocument(); + // Should render the button even without an icon + }); + + it('should handle extension click without event parameter', async () => { + const mockOnClick = jest.fn(); + const mockExtensions: PluginExtensionLink[] = [ + createMockExtension('test-extension-1', 'Test Extension', 'Test extension', { + onClick: mockOnClick, + }), + ]; + + setup({ links: mockExtensions, isLoading: false }); + + const button = screen.getByText('Test Extension'); + + // Simulate a click that might not pass event + fireEvent.click(button); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + it('should provide datasource context to plugin extensions', () => { + const { mockUsePluginLinks } = setup(); + + const contextArg = mockUsePluginLinks.mock.calls[0][0].context as TraceViewPluginExtensionContext; + + // Verify that plugin extensions receive datasource information in context + expect(contextArg.datasource).toBeDefined(); + expect(contextArg.datasource.name).toBe('test-datasource'); + expect(contextArg.datasource.uid).toBe('test-datasource-uid'); + expect(contextArg.datasource.type).toBe('tempo'); + + // Verify that trace data is still available + expect(contextArg.traceID).toBe(trace.traceID); + expect(contextArg.spans).toBe(trace.spans); + }); + }); + + describe('Feedback Button', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not render feedback button when feedbackLinksEnabled is false', () => { + // config.feedbackLinksEnabled is already mocked to false + setup(); + + const feedbackButton = screen.queryByText('Feedback'); + expect(feedbackButton).not.toBeInTheDocument(); + }); + + it('should render feedback button when feedbackLinksEnabled is true', () => { + // Mock config with feedbackLinksEnabled = true + const mockConfig = require('../../../../../core/config'); + mockConfig.config.feedbackLinksEnabled = true; + + setup(); + + const feedbackButton = screen.getByText('Feedback'); + expect(feedbackButton).toBeInTheDocument(); + expect(feedbackButton.closest('a')).toHaveAttribute('href', 'https://forms.gle/RZDEx8ScyZNguDoC8'); + expect(feedbackButton.closest('a')).toHaveAttribute('target', '_blank'); + }); + + it('should display tooltip for feedback button', async () => { + const user = userEvent.setup(); + + // Mock config with feedbackLinksEnabled = true + const mockConfig = require('../../../../../core/config'); + mockConfig.config.feedbackLinksEnabled = true; + + setup(); + + const feedbackButton = screen.getByText('Feedback'); + await user.hover(feedbackButton); + + await waitFor(() => { + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + expect(screen.getByText('Share your thoughts about tracing in Grafana.')).toBeInTheDocument(); + }); + }); + + it('should render feedback button with correct styling and icon', () => { + // Mock config with feedbackLinksEnabled = true + const mockConfig = require('../../../../../core/config'); + mockConfig.config.feedbackLinksEnabled = true; + + setup(); + + const feedbackButton = screen.getByText('Feedback'); + const buttonElement = feedbackButton.closest('a'); + + expect(buttonElement).toBeInTheDocument(); + expect(buttonElement).toHaveClass('css-125ehy6-button'); // Secondary variant class + + // Check for icon + const iconElement = buttonElement?.querySelector('svg'); + expect(iconElement).toBeInTheDocument(); + }); }); }); diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx index 72d3a035401..f75fda8b582 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx @@ -12,24 +12,43 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { css } from '@emotion/css'; -import cx from 'classnames'; -import { memo, useEffect, useMemo } from 'react'; +import { css, cx } from '@emotion/css'; +import { memo, useEffect, useMemo, useState } from 'react'; import * as React from 'react'; -import { TraceSearchProps, CoreApp, DataFrame, dateTimeFormat, GrafanaTheme2 } from '@grafana/data'; +import { + CoreApp, + TraceSearchProps, + DataFrame, + dateTimeFormat, + dateTimeFormatTimeAgo, + GrafanaTheme2, + PluginExtensionPoints, +} from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; +import { reportInteraction, usePluginLinks } from '@grafana/runtime'; import { TimeZone } from '@grafana/schema'; -import { Badge, BadgeColor, Tooltip, useStyles2 } from '@grafana/ui'; +import { + Badge, + BadgeColor, + Button, + ButtonGroup, + Dropdown, + Icon, + LinkButton, + Menu, + Tooltip, + useStyles2, + useTheme2, +} from '@grafana/ui'; +import { useAppNotification } from 'app/core/copy/appNotification'; -import ExternalLinks from '../common/ExternalLinks'; -import TraceName from '../common/TraceName'; -import { getTraceLinks } from '../model/link-patterns'; +import { config } from '../../../../../core/config'; +import { downloadTraceAsJson } from '../../../../inspector/utils/download'; import { getHeaderTags, getTraceName } from '../model/trace-viewer'; -import { Trace } from '../types/trace'; +import { Trace, TraceViewPluginExtensionContext } from '../types/trace'; import { formatDuration } from '../utils/date'; -import TracePageActions from './Actions/TracePageActions'; import { SpanFilters } from './SpanFilters/SpanFilters'; export type TracePageHeaderProps = { @@ -44,6 +63,8 @@ export type TracePageHeaderProps = { setFocusedSpanIdForSearch: React.Dispatch>; spanFilterMatches: Set | undefined; datasourceType: string; + datasourceName: string; + datasourceUid: string; setHeaderHeight: (height: number) => void; }; @@ -60,47 +81,51 @@ export const TracePageHeader = memo((props: TracePageHeaderProps) => { setFocusedSpanIdForSearch, spanFilterMatches, datasourceType, + datasourceName, + datasourceUid, setHeaderHeight, } = props; - const styles = useStyles2(getNewStyles); + + const styles = useStyles2(getStyles); + const theme = useTheme2(); + const notifyApp = useAppNotification(); + const [copyTraceIdClicked, setCopyTraceIdClicked] = useState(false); useEffect(() => { setHeaderHeight(document.querySelector('.' + styles.header)?.scrollHeight ?? 0); }, [setHeaderHeight, showSpanFilters, styles.header]); - const links = useMemo(() => { - if (!trace) { - return []; - } - return getTraceLinks(trace); - }, [trace]); - if (!trace) { return null; } - const timestamp = (trace: Trace, timeZone: TimeZone) => { - // Convert date from micro to milli seconds - const dateStr = dateTimeFormat(trace.startTime / 1000, { timeZone, defaultWithMS: true }); - const match = dateStr.match(/^(.+)(:\d\d\.\d+)$/); - return match ? ( - - {match[1]} - {match[2]} - - ) : ( - dateStr - ); + const { method, status, url } = getHeaderTags(trace.spans); + const traceName = getTraceName(trace.spans); + + // Convert date from micro to milli seconds + const formattedTimestamp = dateTimeFormat(trace.startTime / 1000, { timeZone, defaultWithMS: true }); + + // Memoize service count to avoid recomputing on every render + const serviceCount = useMemo(() => { + return new Set(trace.spans.map((span) => span.process?.serviceName)).size; + }, [trace.spans]); + + // Get plugin extensions for trace view header actions + const traceContext: TraceViewPluginExtensionContext = { + ...trace, + datasource: { + name: datasourceName, + uid: datasourceUid, + type: datasourceType, + }, }; - const title = ( -

    - - {formatDuration(trace.duration)} -

    - ); + const { links: extensionLinks } = usePluginLinks({ + extensionPointId: PluginExtensionPoints.TraceViewHeaderActions, + context: traceContext, + limitPerPlugin: 2, + }); - const { method, status, url } = getHeaderTags(trace.spans); let statusColor: BadgeColor = 'green'; if (status && status.length > 0) { if (status[0].value.toString().charAt(0) === '4') { @@ -110,76 +135,198 @@ export const TracePageHeader = memo((props: TracePageHeaderProps) => { } } - const urlTooltip = (url: string) => { - return ( - <> -
    - - {'{{url}}'} or {'{{target}}'} or {'{{path}}'} - -
    -
    ({url})
    - - ); + const copyTraceId = () => { + navigator.clipboard.writeText(trace.traceID); + setCopyTraceIdClicked(true); + setTimeout(() => { + setCopyTraceIdClicked(false); + }, 5000); }; + const exportTrace = () => { + const traceFormat = downloadTraceAsJson(data, 'Trace-' + trace.traceID.substring(trace.traceID.length - 6)); + reportInteraction('grafana_traces_download_traces_clicked', { + app, + grafana_version: config.buildInfo.version, + trace_format: traceFormat, + location: 'trace-view', + }); + }; + + const shareDropdownMenu = ( + + { + navigator.clipboard.writeText(window.location.href); + notifyApp.success(t('explore.trace-page-header.link-copied', 'Link copied to clipboard')); + }} + /> + { + exportTrace(); + notifyApp.success(t('explore.trace-page-header.export-started', 'Export started')); + }} + /> + + ); + return (
    + {/* Main title row */}
    - {links && links.length > 0 && } - {title} - +
    +

    {traceName}

    +
    + {method && method.length > 0 && } + {status && status.length > 0 && } +
    +
    + + {/* Action buttons */} +
    + {/* Plugin extension actions */} + {extensionLinks.length > 0 && ( +
    + {extensionLinks.map((link) => ( + + + + ))} +
    + )} + + {config.feedbackLinksEnabled && ( + + + Feedback + + + )} + + + + + + + +
    -
    - {timestamp(trace, timeZone)} - - {data.meta?.custom?.partial && ( - - - - - - )} - {method && method.length > 0 && ( - - - - - - )} - {status && status.length > 0 && ( - - - - - - )} - {url && url.length > 0 && ( - - {url[0].value} - - )} - + {/* Metadata row */} +
    +
    + {t('explore.trace-page-header.trace-id', 'Trace ID')} + + + +
    + +
    + {t('explore.trace-page-header.start-time', 'Start time')} + + {formattedTimestamp} + ({dateTimeFormatTimeAgo(trace.startTime / 1000)}) + +
    + +
    + {t('explore.trace-page-header.duration', 'Duration')} + {formatDuration(trace.duration)} +
    + +
    + {t('explore.trace-page-header.services', 'Services')} + {serviceCount} +
    + + {url && url.length > 0 && ( +
    + + {url[0].key === 'http.route' && t('explore.trace-page-header.route', 'Route')} + {url[0].key === 'http.url' && t('explore.trace-page-header.url', 'URL')} + {url[0].key === 'http.target' && t('explore.trace-page-header.target', 'Target')} + {url[0].key === 'http.path' && t('explore.trace-page-header.path', 'Path')} + + + +
    + + {'{{route}}'} or {'{{url}}'} or {'{{target}}'} or {'{{path}}'} + +
    +
    ({url[0].value})
    +
    + } + interactive={true} + > + {url[0].value} + + +
    + )}
    { TracePageHeader.displayName = 'TracePageHeader'; -const getNewStyles = (theme: GrafanaTheme2) => { +const getStyles = (theme: GrafanaTheme2) => { return { - TracePageHeaderBack: css({ - label: 'TracePageHeaderBack', - alignItems: 'center', - alignSelf: 'stretch', - backgroundColor: '#fafafa', - borderBottom: '1px solid #ddd', - borderRight: '1px solid #ddd', - color: 'inherit', - display: 'flex', - fontSize: '1.4rem', - padding: '0 1rem', - marginBottom: '-1px', - '&:hover': { - backgroundColor: '#f0f0f0', - borderColor: '#ccc', - }, - }), - TracePageHeaderOverviewItemValueDetail: cx( - css({ - label: 'TracePageHeaderOverviewItemValueDetail', - color: '#aaa', - }), - 'trace-item-value-detail' - ), - TracePageHeaderOverviewItemValue: css({ - label: 'TracePageHeaderOverviewItemValue', - '&:hover > .trace-item-value-detail': { - color: 'unset', - }, - }), header: css({ label: 'TracePageHeader', backgroundColor: theme.colors.background.primary, - padding: '0.5em 0 0 0', + padding: '0.5em', position: 'sticky', top: 0, zIndex: 5, textAlign: 'left', }), + titleRow: css({ - alignItems: 'flex-start', display: 'flex', - padding: '0 8px', - flexWrap: 'wrap', + alignItems: 'flex-start', + justifyContent: 'space-between', + marginBottom: theme.spacing(1), + gap: theme.spacing(2), }), + + titleSection: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(2), + flex: 1, + minWidth: 0, // Allow text truncation + }), + title: css({ - color: 'inherit', - flex: 1, - fontSize: '1.7em', - lineHeight: '1em', - marginBottom: 0, - minWidth: '200px', - }), - subtitle: css({ - flex: 1, - lineHeight: '1em', - margin: '-0.5em 0.5em 0.75em 0.5em', - }), - tag: css({ - margin: '0 0.5em 0 0', - }), - duration: css({ - color: '#aaa', - margin: '0 0.75em', - }), - timestamp: css({ - verticalAlign: 'middle', - }), - tagMeta: css({ - margin: '0 0.75em', - verticalAlign: 'text-top', - }), - url: css({ - margin: '-2.5px 0.3em', - height: '15px', + color: theme.colors.text.primary, + fontSize: theme.typography.h3.fontSize, + fontWeight: theme.typography.h3.fontWeight, + lineHeight: theme.typography.h3.lineHeight, + margin: 0, + minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', - maxWidth: '700px', - display: 'inline-block', - }), - TracePageHeaderTraceId: css({ - label: 'TracePageHeaderTraceId', whiteSpace: 'nowrap', + }), + + badges: css({ + display: 'flex', + gap: theme.spacing(1), + alignItems: 'center', + flexShrink: 0, + }), + + actions: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + flexShrink: 0, + }), + + metadataRow: css({ + display: 'flex', + alignItems: 'center', + columnGap: theme.spacing(3), + marginBottom: theme.spacing(1), + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.secondary, + flexWrap: 'wrap', + }), + + metadataItem: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + }), + + metadataLabel: css({ + fontWeight: theme.typography.fontWeightMedium, + color: theme.colors.text.secondary, + }), + + metadataValue: css({ + color: theme.colors.text.primary, + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + }), + + traceIdButton: css({ + background: 'none', + border: 'none', + color: theme.colors.text.primary, + cursor: 'pointer', + textDecoration: 'underline', + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + padding: 0, + font: 'inherit', + + '&:hover': { + color: theme.colors.emphasize(theme.colors.text.primary, 0.15), + }, + }), + + copyIcon: css({ + opacity: 0.7, + }), + + copiedText: css({ + color: theme.colors.success.text, + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.fontWeightMedium, + }), + + timestampDetail: css({ + color: theme.colors.text.disabled, + }), + + url: css({ + maxWidth: '700px', + overflow: 'hidden', textOverflow: 'ellipsis', - maxWidth: '30%', + whiteSpace: 'nowrap', display: 'inline-block', + color: theme.colors.text.primary, }), }; }; 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}