diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ef476f4d272..6b40b814064 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -94,7 +94,6 @@ /apps/shorturl/ @grafana/sharing-squad /apps/secret/ @grafana/grafana-operator-experience-squad /apps/scope/ @grafana/grafana-operator-experience-squad -/apps/investigations/ @fcjack @matryer @svennergr /apps/advisor/ @grafana/plugins-platform-backend /apps/iam/ @grafana/access-squad /apps/sdk.mk @grafana/grafana-app-platform-squad @@ -441,6 +440,7 @@ i18next.config.ts @grafana/grafana-frontend-platform /e2e-playwright/dashboards/TestDashboard.json @grafana/dashboards-squad @grafana/grafana-search-navigate-organise /e2e-playwright/dashboards/TestV2Dashboard.json @grafana/dashboards-squad /e2e-playwright/dashboards/V2DashWithRepeats.json @grafana/dashboards-squad +/e2e-playwright/dashboards/V2DashWithRowRepeats.json @grafana/dashboards-squad /e2e-playwright/dashboards/V2DashWithTabRepeats.json @grafana/dashboards-squad /e2e-playwright/dashboards-suite/adhoc-filter-from-panel.spec.ts @grafana/datapro /e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts @grafana/grafana-search-navigate-organise @@ -543,6 +543,7 @@ i18next.config.ts @grafana/grafana-frontend-platform /packages/grafana-data/tsconfig.json @grafana/grafana-frontend-platform /packages/grafana-data/test/ @grafana/grafana-frontend-platform /packages/grafana-data/typings/ @grafana/grafana-frontend-platform +/packages/grafana-data/scripts/ @grafana/grafana-frontend-platform /packages/grafana-data/src/**/*logs* @grafana/observability-logs /packages/grafana-data/src/context/plugins/ @grafana/plugins-platform-frontend @@ -658,6 +659,7 @@ i18next.config.ts @grafana/grafana-frontend-platform /packages/grafana-runtime/src/services/LocationService.tsx @grafana/grafana-search-navigate-organise /packages/grafana-runtime/src/services/LocationSrv.ts @grafana/grafana-search-navigate-organise /packages/grafana-runtime/src/services/live.ts @grafana/dashboards-squad +/packages/grafana-runtime/src/services/pluginMeta @grafana/plugins-platform-frontend /packages/grafana-runtime/src/utils/chromeHeaderHeight.ts @grafana/grafana-search-navigate-organise /packages/grafana-runtime/src/utils/DataSourceWithBackend* @grafana/grafana-datasources-core-services /packages/grafana-runtime/src/utils/licensing.ts @grafana/grafana-operator-experience-squad @@ -1276,6 +1278,7 @@ embed.go @grafana/grafana-as-code /.github/workflows/i18n-crowdin-download.yml @grafana/grafana-frontend-platform /.github/workflows/i18n-crowdin-create-tasks.yml @grafana/grafana-frontend-platform /.github/workflows/i18n-verify.yml @grafana/grafana-frontend-platform +/.github/workflows/deploy-storybook.yml @grafana/grafana-frontend-platform /.github/workflows/deploy-storybook-preview.yml @grafana/grafana-frontend-platform /.github/workflows/scripts/crowdin/create-tasks.ts @grafana/grafana-frontend-platform /.github/workflows/scripts/publish-frontend-metrics.mts @grafana/grafana-frontend-platform diff --git a/.github/actions/change-detection/action.yml b/.github/actions/change-detection/action.yml index 2b8484d7cf6..1fd10ae5540 100644 --- a/.github/actions/change-detection/action.yml +++ b/.github/actions/change-detection/action.yml @@ -14,6 +14,9 @@ outputs: frontend: description: Whether the frontend or self has changed in any way value: ${{ steps.changed-files.outputs.frontend_any_changed || 'true' }} + frontend-packages: + description: Whether any frontend packages have changed + value: ${{ steps.changed-files.outputs.frontend_packages_any_changed || 'true' }} e2e: description: Whether the e2e tests or self have changed in any way value: ${{ steps.changed-files.outputs.e2e_any_changed == 'true' || @@ -97,6 +100,12 @@ runs: - '.yarn/**' - 'apps/dashboard/pkg/migration/**' - '${{ inputs.self }}' + frontend_packages: + - '.github/actions/checkout/**' + - '.github/actions/change-detection/**' + - 'packages/**' + - './scripts/validate-npm-packages.sh' + - '${{ inputs.self }}' e2e: - 'e2e/**' - 'e2e-playwright/**' @@ -153,6 +162,8 @@ runs: echo " --> ${{ steps.changed-files.outputs.backend_all_changed_files }}" echo "Frontend: ${{ steps.changed-files.outputs.frontend_any_changed || 'true' }}" echo " --> ${{ steps.changed-files.outputs.frontend_all_changed_files }}" + echo "Frontend packages: ${{ steps.changed-files.outputs.frontend_packages_any_changed || 'true' }}" + echo " --> ${{ steps.changed-files.outputs.frontend_packages_all_changed_files }}" echo "E2E: ${{ steps.changed-files.outputs.e2e_any_changed || 'true' }}" echo " --> ${{ steps.changed-files.outputs.e2e_all_changed_files }}" echo " --> ${{ steps.changed-files.outputs.backend_all_changed_files }}" diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml index 5762389f83b..92ffd43593c 100644 --- a/.github/actions/setup-node/action.yml +++ b/.github/actions/setup-node/action.yml @@ -4,8 +4,8 @@ description: Sets up a node.js environment with presets for the Grafana reposito runs: using: "composite" steps: - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' cache: 'yarn' - cache-dependency-path: 'yarn.lock' \ No newline at end of file + cache-dependency-path: 'yarn.lock' diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 79987600b9a..6356a793b36 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -19,7 +19,6 @@ updates: - "/apps/dashboard" - "/apps/folder" - "/apps/iam" - - "/apps/investigations" - "/apps/playlist" - "/apps/plugins" - "/apps/preferences" diff --git a/.github/workflows/deploy-storybook.yml b/.github/workflows/deploy-storybook.yml new file mode 100644 index 00000000000..08bffaeb891 --- /dev/null +++ b/.github/workflows/deploy-storybook.yml @@ -0,0 +1,79 @@ +name: Deploy Storybook + +on: + workflow_dispatch: + # push: + # branches: + # - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + detect-changes: + # Only run in grafana/grafana + if: github.repository == 'grafana/grafana' + name: Detect whether code changed + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + changed-frontend-packages: ${{ steps.detect-changes.outputs.frontend-packages }} + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: true # required to get more history in the changed-files action + fetch-depth: 2 + - name: Detect changes + id: detect-changes + uses: ./.github/actions/change-detection + with: + self: .github/workflows/deploy-storybook.yml + deploy-storybook: + name: Deploy Storybook + runs-on: ubuntu-latest + needs: detect-changes + # Only run in grafana/grafana + if: github.repository == 'grafana/grafana' && needs.detect-changes.outputs.changed-frontend-packages == 'true' + permissions: + contents: read + id-token: write + + env: + BUCKET_NAME: grafana-storybook + + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Setup Node.js + uses: ./.github/actions/setup-node + + - name: Install dependencies + run: yarn install --immutable + + - name: Build storybook + run: yarn storybook:build + + # Create the GCS folder name + # Right now, this just returns "canary" + # But we'll expand this to work for "latest" as well in the future + - name: Create deploy name + id: create-deploy-name + run: | + echo "deploy-name=canary" >> "$GITHUB_OUTPUT" + + - name: Upload Storybook + uses: grafana/shared-workflows/actions/push-to-gcs@main + with: + environment: prod + bucket: ${{ env.BUCKET_NAME }} + bucket_path: ${{ steps.create-deploy-name.outputs.deploy-name }} + path: packages/grafana-ui/dist/storybook + service_account: github-gf-storybook-deploy@grafanalabs-workload-identity.iam.gserviceaccount.com + parent: false diff --git a/.github/workflows/frontend-lint.yml b/.github/workflows/frontend-lint.yml index 02833d61149..539d57b1742 100644 --- a/.github/workflows/frontend-lint.yml +++ b/.github/workflows/frontend-lint.yml @@ -17,6 +17,7 @@ jobs: outputs: changed: ${{ steps.detect-changes.outputs.frontend }} prettier: ${{ steps.detect-changes.outputs.frontend == 'true' || steps.detect-changes.outputs.docs == 'true' }} + changed-frontend-packages: ${{ steps.detect-changes.outputs.frontend-packages }} steps: - uses: actions/checkout@v5 with: @@ -42,11 +43,8 @@ jobs: - uses: actions/checkout@v5 with: persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' + - name: Setup Node + uses: ./.github/actions/setup-node - run: yarn install --immutable --check-cache - run: yarn run prettier:check - run: yarn run lint @@ -63,11 +61,8 @@ jobs: - uses: actions/checkout@v5 with: persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' + - name: Setup Node + uses: ./.github/actions/setup-node - name: Setup Enterprise uses: ./.github/actions/setup-enterprise with: @@ -89,11 +84,8 @@ jobs: - uses: actions/checkout@v5 with: persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' + - name: Setup Node + uses: ./.github/actions/setup-node - run: yarn install --immutable --check-cache - run: yarn run typecheck lint-frontend-typecheck-enterprise: @@ -109,11 +101,8 @@ jobs: - uses: actions/checkout@v5 with: persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' + - name: Setup Node + uses: ./.github/actions/setup-node - name: Setup Enterprise uses: ./.github/actions/setup-enterprise with: @@ -133,11 +122,8 @@ jobs: - uses: actions/checkout@v5 with: persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' + - name: Setup Node + uses: ./.github/actions/setup-node - run: yarn install --immutable --check-cache - name: Generate API clients run: | @@ -164,11 +150,8 @@ jobs: - uses: actions/checkout@v5 with: persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' + - name: Setup Node + uses: ./.github/actions/setup-node - name: Setup Enterprise uses: ./.github/actions/setup-enterprise with: @@ -187,3 +170,26 @@ jobs: echo "${uncommited_error_message}" exit 1 fi + lint-frontend-packed-packages: + needs: detect-changes + permissions: + contents: read + id-token: write + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.changed-frontend-packages == 'true' + name: Verify packed frontend packages + runs-on: ubuntu-latest + steps: + - name: Checkout build commit + uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Setup Node + uses: ./.github/actions/setup-node + - name: Install dependencies + run: yarn install --immutable + - name: Build and pack packages + run: | + yarn run packages:build + yarn run packages:pack + - name: Validate packages + run: ./scripts/validate-npm-packages.sh diff --git a/.golangci.yml b/.golangci.yml index b52a986d435..d7037bf6fac 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -67,14 +67,6 @@ linters: deny: - pkg: github.com/grafana/grafana/pkg desc: apiserver is not allowed to import grafana core - apps-investigation: - list-mode: lax - files: - - ./apps/investigations/* - - ./apps/investigations/**/* - deny: - - pkg: github.com/grafana/grafana/pkg - desc: apps/investigations is not allowed to import grafana core apps-playlist: list-mode: lax files: @@ -129,6 +121,8 @@ linters: - '**/pkg/tsdb/zipkin/**/*' - '**/pkg/tsdb/jaeger/*' - '**/pkg/tsdb/jaeger/**/*' + - '**/pkg/tsdb/elasticsearch/*' + - '**/pkg/tsdb/elasticsearch/**/*' deny: - pkg: github.com/grafana/grafana/pkg/api desc: Core plugins are not allowed to depend on Grafana core packages diff --git a/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch b/.yarn/patches/@storybook-core-npm-8.6.15-a468a35170.patch similarity index 73% rename from .yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch rename to .yarn/patches/@storybook-core-npm-8.6.15-a468a35170.patch index 730ecce8fb2..f3b7cb48e98 100644 --- a/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch +++ b/.yarn/patches/@storybook-core-npm-8.6.15-a468a35170.patch @@ -1,8 +1,8 @@ diff --git a/dist/builder-manager/index.js b/dist/builder-manager/index.js -index 3d7f9b213dae1801bda62b31db31b9113e382ccd..212501c63d20146c29db63fb0f6300c6779eecb5 100644 +index ac8ac6a5f6a3b7852c4064e93dc9acd3201289e6..34a0a5a5c38dd7fe525c9ebd382a10a451d4d4f3 100644 --- a/dist/builder-manager/index.js +++ b/dist/builder-manager/index.js -@@ -1970,7 +1970,7 @@ var pa = /^\/($|\?)/, G, C, xt = /* @__PURE__ */ o(async (e) => { +@@ -1974,7 +1974,7 @@ var pa = /^\/($|\?)/, G, C, xt = /* @__PURE__ */ o(async (e) => { bundle: !0, minify: !0, sourcemap: !1, diff --git a/Dockerfile b/Dockerfile index 2b16926a836..d3c2fc9ef5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -91,6 +91,7 @@ COPY pkg/storage/unified/resource pkg/storage/unified/resource COPY pkg/storage/unified/resourcepb pkg/storage/unified/resourcepb COPY pkg/storage/unified/apistore pkg/storage/unified/apistore COPY pkg/semconv pkg/semconv +COPY pkg/plugins pkg/plugins COPY pkg/aggregator pkg/aggregator COPY apps/playlist apps/playlist COPY apps/quotas apps/quotas @@ -103,7 +104,6 @@ COPY apps/collections apps/collections COPY apps/provisioning apps/provisioning COPY apps/secret apps/secret COPY apps/scope apps/scope -COPY apps/investigations apps/investigations COPY apps/logsdrilldown apps/logsdrilldown COPY apps/advisor apps/advisor COPY apps/dashboard apps/dashboard diff --git a/Makefile b/Makefile index a5353e95567..e4a261acb4b 100644 --- a/Makefile +++ b/Makefile @@ -135,7 +135,7 @@ i18n-extract-enterprise: @echo "Skipping i18n extract for Enterprise: not enabled" else i18n-extract-enterprise: - @echo "Extracting i18n strings for Enterprise" + @echo "Extracting i18n strings for Enterprise" cd public/locales/enterprise && yarn run i18next-cli extract --sync-primary endif @@ -227,6 +227,10 @@ fix-cue: gen-jsonnet: go generate ./devenv/jsonnet +.PHONY: gen-themes +gen-themes: + go generate ./pkg/services/preference + .PHONY: update-workspace update-workspace: gen-go @echo "updating workspace" @@ -244,6 +248,7 @@ build-go-fast: ## Build all Go binaries without updating workspace. .PHONY: build-backend build-backend: ## Build Grafana backend. @echo "build backend" + $(MAKE) gen-themes $(GO) run build.go $(GO_BUILD_FLAGS) build-backend .PHONY: build-air diff --git a/apps/advisor/pkg/app/checks/datasourcecheck/check.go b/apps/advisor/pkg/app/checks/datasourcecheck/check.go index cae29e181fd..c35c47e45f6 100644 --- a/apps/advisor/pkg/app/checks/datasourcecheck/check.go +++ b/apps/advisor/pkg/app/checks/datasourcecheck/check.go @@ -28,7 +28,7 @@ type check struct { PluginStore pluginstore.Store PluginContextProvider PluginContextProvider PluginClient plugins.Client - PluginRepo repo.Service + PluginRepo checks.PluginInfoGetter GrafanaVersion string pluginCanBeInstalledCache map[string]bool pluginExistsCacheMu sync.RWMutex @@ -39,7 +39,7 @@ func New( pluginStore pluginstore.Store, pluginContextProvider PluginContextProvider, pluginClient plugins.Client, - pluginRepo repo.Service, + pluginRepo checks.PluginInfoGetter, grafanaVersion string, ) checks.Check { return &check{ diff --git a/apps/advisor/pkg/app/checks/datasourcecheck/missing_plugin_step.go b/apps/advisor/pkg/app/checks/datasourcecheck/missing_plugin_step.go index 1d784f0a544..9b70f5d0896 100644 --- a/apps/advisor/pkg/app/checks/datasourcecheck/missing_plugin_step.go +++ b/apps/advisor/pkg/app/checks/datasourcecheck/missing_plugin_step.go @@ -15,7 +15,7 @@ import ( type missingPluginStep struct { PluginStore pluginstore.Store - PluginRepo repo.Service + PluginRepo checks.PluginInfoGetter GrafanaVersion string } diff --git a/apps/advisor/pkg/app/checks/ifaces.go b/apps/advisor/pkg/app/checks/ifaces.go index 6573253b557..2b205933151 100644 --- a/apps/advisor/pkg/app/checks/ifaces.go +++ b/apps/advisor/pkg/app/checks/ifaces.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana-app-sdk/logging" advisorv0alpha1 "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1" + "github.com/grafana/grafana/pkg/plugins/repo" ) // Check returns metadata about the check being executed and the list of Steps @@ -37,3 +38,10 @@ type Step interface { // Run executes the step for an item and returns a report Run(ctx context.Context, log logging.Logger, obj *advisorv0alpha1.CheckSpec, item any) ([]advisorv0alpha1.CheckReportFailure, error) } + +// PluginInfoGetter is a minimal interface for retrieving plugin information from a repository. +// It contains only the GetPluginsInfo method used by plugincheck and datasourcecheck. +type PluginInfoGetter interface { + // GetPluginsInfo will return a list of plugins from grafana.com/api/plugins. + GetPluginsInfo(ctx context.Context, options repo.GetPluginsInfoOptions, compatOpts repo.CompatOpts) ([]repo.PluginInfo, error) +} diff --git a/apps/advisor/pkg/app/checks/plugincheck/check.go b/apps/advisor/pkg/app/checks/plugincheck/check.go index 3d261f81b67..00bc293e86c 100644 --- a/apps/advisor/pkg/app/checks/plugincheck/check.go +++ b/apps/advisor/pkg/app/checks/plugincheck/check.go @@ -17,7 +17,7 @@ const ( func New( pluginStore pluginstore.Store, - pluginRepo repo.Service, + pluginRepo checks.PluginInfoGetter, updateChecker pluginchecker.PluginUpdateChecker, pluginErrorResolver plugins.ErrorResolver, grafanaVersion string, @@ -33,7 +33,7 @@ func New( type check struct { PluginStore pluginstore.Store - PluginRepo repo.Service + PluginRepo checks.PluginInfoGetter updateChecker pluginchecker.PluginUpdateChecker pluginErrorResolver plugins.ErrorResolver GrafanaVersion string diff --git a/apps/alerting/historian/go.mod b/apps/alerting/historian/go.mod index a79829d45c2..9bce171ed16 100644 --- a/apps/alerting/historian/go.mod +++ b/apps/alerting/historian/go.mod @@ -4,7 +4,7 @@ go 1.25.5 require ( github.com/go-kit/log v0.2.1 - github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f + github.com/grafana/alerting v0.0.0-20260112172717-98a49ed9557f github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 github.com/grafana/grafana-app-sdk v0.48.7 github.com/grafana/grafana-app-sdk/logging v0.48.7 diff --git a/apps/alerting/historian/go.sum b/apps/alerting/historian/go.sum index d45d418dfb8..73b2a5c991b 100644 --- a/apps/alerting/historian/go.sum +++ b/apps/alerting/historian/go.sum @@ -243,8 +243,8 @@ 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/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts= -github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20260112172717-98a49ed9557f h1:3bXOyht68qkfvD6Y8z8XoenFbytSSOIkr/s+AqRzj0o= +github.com/grafana/alerting v0.0.0-20260112172717-98a49ed9557f/go.mod h1:Ji0SfJChcwjgq8ljy6Y5CcYfHfAYKXjKYeysOoDS/6s= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfUHB32u2ZMo= diff --git a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue index 6488de41c96..55094fe3447 100644 --- a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue @@ -254,8 +254,18 @@ FieldConfig: { // custom is specified by the FieldConfig field // in panel plugin schemas. custom?: {...} + + // Calculate min max per field + fieldMinMax?: bool + + // How null values should be handled when calculating field stats + // "null" - Include null values, "connected" - Ignore nulls, "null as zero" - Treat nulls as zero + nullValueMode?: NullValueMode } +// How null values should be handled +NullValueMode: "null" | "connected" | "null as zero" + DynamicConfigValue: { id: string | *"" value?: _ diff --git a/apps/dashboard/kinds/v2beta1/dashboard_spec.cue b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue index a8e1f121213..0802430907e 100644 --- a/apps/dashboard/kinds/v2beta1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue @@ -250,8 +250,18 @@ FieldConfig: { // custom is specified by the FieldConfig field // in panel plugin schemas. custom?: {...} + + // Calculate min max per field + fieldMinMax?: bool + + // How null values should be handled when calculating field stats + // "null" - Include null values, "connected" - Ignore nulls, "null as zero" - Treat nulls as zero + nullValueMode?: NullValueMode } +// How null values should be handled +NullValueMode: "null" | "connected" | "null as zero" + DynamicConfigValue: { id: string | *"" value?: _ diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue index 2b027ff98e1..293082ab82f 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue @@ -258,8 +258,18 @@ FieldConfig: { // custom is specified by the FieldConfig field // in panel plugin schemas. custom?: {...} + + // Calculate min max per field + fieldMinMax?: bool + + // How null values should be handled when calculating field stats + // "null" - Include null values, "connected" - Ignore nulls, "null as zero" - Treat nulls as zero + nullValueMode?: NullValueMode } +// How null values should be handled +NullValueMode: "null" | "connected" | "null as zero" + DynamicConfigValue: { id: string | *"" value?: _ diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go index 3f594306ef5..f7ccfdd4925 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go @@ -419,6 +419,11 @@ type DashboardFieldConfig struct { // custom is specified by the FieldConfig field // in panel plugin schemas. Custom map[string]interface{} `json:"custom,omitempty"` + // Calculate min max per field + FieldMinMax *bool `json:"fieldMinMax,omitempty"` + // How null values should be handled when calculating field stats + // "null" - Include null values, "connected" - Ignore nulls, "null as zero" - Treat nulls as zero + NullValueMode *DashboardNullValueMode `json:"nullValueMode,omitempty"` } // NewDashboardFieldConfig creates a new DashboardFieldConfig object. @@ -745,6 +750,16 @@ func NewDashboardActionVariable() *DashboardActionVariable { // +k8s:openapi-gen=true const DashboardActionVariableType = "string" +// How null values should be handled +// +k8s:openapi-gen=true +type DashboardNullValueMode string + +const ( + DashboardNullValueModeNull DashboardNullValueMode = "null" + DashboardNullValueModeConnected DashboardNullValueMode = "connected" + DashboardNullValueModeNullAsZero DashboardNullValueMode = "null as zero" +) + // +k8s:openapi-gen=true type DashboardDynamicConfigValue struct { Id string `json:"id"` diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go index 4c6f3f5ed20..926d50cb49d 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go @@ -2277,6 +2277,20 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardFieldConfig(ref common.Referenc }, }, }, + "fieldMinMax": { + SchemaProps: spec.SchemaProps{ + Description: "Calculate min max per field", + Type: []string{"boolean"}, + Format: "", + }, + }, + "nullValueMode": { + SchemaProps: spec.SchemaProps{ + Description: "How null values should be handled when calculating field stats \"null\" - Include null values, \"connected\" - Ignore nulls, \"null as zero\" - Treat nulls as zero", + Type: []string{"string"}, + Format: "", + }, + }, }, }, }, diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue index 375ba67f003..41ab7bc3fa7 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue @@ -254,8 +254,18 @@ FieldConfig: { // custom is specified by the FieldConfig field // in panel plugin schemas. custom?: {...} + + // Calculate min max per field + fieldMinMax?: bool + + // How null values should be handled when calculating field stats + // "null" - Include null values, "connected" - Ignore nulls, "null as zero" - Treat nulls as zero + nullValueMode?: NullValueMode } +// How null values should be handled +NullValueMode: "null" | "connected" | "null as zero" + DynamicConfigValue: { id: string | *"" value?: _ diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go index 96054cb2fc4..06f1e1df599 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go @@ -423,6 +423,11 @@ type DashboardFieldConfig struct { // custom is specified by the FieldConfig field // in panel plugin schemas. Custom map[string]interface{} `json:"custom,omitempty"` + // Calculate min max per field + FieldMinMax *bool `json:"fieldMinMax,omitempty"` + // How null values should be handled when calculating field stats + // "null" - Include null values, "connected" - Ignore nulls, "null as zero" - Treat nulls as zero + NullValueMode *DashboardNullValueMode `json:"nullValueMode,omitempty"` } // NewDashboardFieldConfig creates a new DashboardFieldConfig object. @@ -749,6 +754,16 @@ func NewDashboardActionVariable() *DashboardActionVariable { // +k8s:openapi-gen=true const DashboardActionVariableType = "string" +// How null values should be handled +// +k8s:openapi-gen=true +type DashboardNullValueMode string + +const ( + DashboardNullValueModeNull DashboardNullValueMode = "null" + DashboardNullValueModeConnected DashboardNullValueMode = "connected" + DashboardNullValueModeNullAsZero DashboardNullValueMode = "null as zero" +) + // +k8s:openapi-gen=true type DashboardDynamicConfigValue struct { Id string `json:"id"` diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go index 402810f6e53..73c4d1f7349 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go @@ -2284,6 +2284,20 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardFieldConfig(ref common.Reference }, }, }, + "fieldMinMax": { + SchemaProps: spec.SchemaProps{ + Description: "Calculate min max per field", + Type: []string{"boolean"}, + Format: "", + }, + }, + "nullValueMode": { + SchemaProps: spec.SchemaProps{ + Description: "How null values should be handled when calculating field stats \"null\" - Include null values, \"connected\" - Ignore nulls, \"null as zero\" - Treat nulls as zero", + Type: []string{"string"}, + Format: "", + }, + }, }, }, }, diff --git a/apps/dashboard/pkg/apis/dashboard_manifest.go b/apps/dashboard/pkg/apis/dashboard_manifest.go index e94d66fec82..aee89730dc1 100644 --- a/apps/dashboard/pkg/apis/dashboard_manifest.go +++ b/apps/dashboard/pkg/apis/dashboard_manifest.go @@ -32,10 +32,10 @@ var ( rawSchemaDashboardv1beta1 = []byte(`{"ConversionStatus":{"additionalProperties":false,"description":"ConversionStatus is the status of the conversion of the dashboard.","properties":{"error":{"description":"The error message from the conversion.\nEmpty if the conversion has not failed.","type":"string"},"failed":{"description":"Whether from another version has failed.\nIf true, means that the dashboard is not valid,\nand the caller should instead fetch the stored version.","type":"boolean"},"source":{"additionalProperties":{},"description":"The original value map[string]any","type":"object"},"storedVersion":{"description":"The version which was stored when the dashboard was created / updated.\nFetching this version should always succeed.","type":"string"}},"required":["failed"],"type":"object"},"Dashboard":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"spec":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"status":{"additionalProperties":false,"properties":{"conversion":{"$ref":"#/components/schemas/ConversionStatus","description":"Optional conversion status."}},"type":"object"}}`) versionSchemaDashboardv1beta1 app.VersionSchema _ = json.Unmarshal(rawSchemaDashboardv1beta1, &versionSchemaDashboardv1beta1) - rawSchemaDashboardv2alpha1 = []byte(`{"Action":{"additionalProperties":false,"properties":{"confirmation":{"type":"string"},"fetch":{"$ref":"#/components/schemas/FetchOptions"},"infinity":{"$ref":"#/components/schemas/InfinityOptions"},"oneClick":{"type":"boolean"},"style":{"additionalProperties":false,"properties":{"backgroundColor":{"type":"string"}},"type":"object"},"title":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionType"},"variables":{"items":{"$ref":"#/components/schemas/ActionVariable"},"type":"array"}},"required":["type","title"],"type":"object"},"ActionType":{"enum":["fetch","infinity"],"type":"string"},"ActionVariable":{"additionalProperties":false,"properties":{"key":{"type":"string"},"name":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionVariableType"}},"required":["key","name","type"],"type":"object"},"ActionVariableType":{"const":"string","description":"Action variable type","type":"string"},"AdHocFilterWithLabels":{"additionalProperties":false,"description":"Define the AdHocFilterWithLabels type","properties":{"condition":{"description":"@deprecated","type":"string"},"forceEdit":{"type":"boolean"},"key":{"type":"string"},"keyLabel":{"type":"string"},"operator":{"type":"string"},"origin":{"$ref":"#/components/schemas/FilterOrigin"},"value":{"type":"string"},"valueLabels":{"items":{"type":"string"},"type":"array"},"values":{"items":{"type":"string"},"type":"array"}},"required":["key","operator","value"],"type":"object"},"AdhocVariableKind":{"additionalProperties":false,"description":"Adhoc variable kind","properties":{"kind":{"const":"AdhocVariable","type":"string"},"spec":{"$ref":"#/components/schemas/AdhocVariableSpec"}},"required":["kind","spec"],"type":"object"},"AdhocVariableSpec":{"additionalProperties":false,"description":"Adhoc variable specification","properties":{"allowCustomValue":{"default":true,"type":"boolean"},"baseFilters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"defaultKeys":{"items":{"$ref":"#/components/schemas/MetricFindValue"},"type":"array"},"description":{"type":"string"},"filters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","baseFilters","filters","defaultKeys","hide","skipUrlSync","allowCustomValue"],"type":"object"},"AnnotationEventFieldMapping":{"additionalProperties":false,"description":"Annotation event field mapping. Defines how to map a data frame field to an annotation event field.","properties":{"regex":{"description":"Regular expression to apply to the field value","type":"string"},"source":{"default":"field","description":"Source type for the field value","type":"string"},"value":{"description":"Constant value to use when source is \"text\"","type":"string"}},"type":"object"},"AnnotationPanelFilter":{"additionalProperties":false,"properties":{"exclude":{"default":false,"description":"Should the specified panels be included or excluded","type":"boolean"},"ids":{"description":"Panel IDs that should be included or excluded","items":{"type":"integer"},"type":"array"}},"required":["ids"],"type":"object"},"AnnotationQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"AnnotationQuery","type":"string"},"spec":{"$ref":"#/components/schemas/AnnotationQuerySpec"}},"required":["kind","spec"],"type":"object"},"AnnotationQuerySpec":{"additionalProperties":false,"properties":{"builtIn":{"default":false,"type":"boolean"},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"enable":{"type":"boolean"},"filter":{"$ref":"#/components/schemas/AnnotationPanelFilter"},"hide":{"type":"boolean"},"iconColor":{"type":"string"},"legacyOptions":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"Catch-all field for datasource-specific properties","type":"object"},"mappings":{"additionalProperties":{"$ref":"#/components/schemas/AnnotationEventFieldMapping"},"description":"Mappings define how to convert data frame fields to annotation event fields.","type":"object"},"name":{"type":"string"},"query":{"$ref":"#/components/schemas/DataQueryKind"}},"required":["enable","hide","iconColor","name"],"type":"object"},"AutoGridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutItemSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"element":{"$ref":"#/components/schemas/ElementReference"},"repeat":{"$ref":"#/components/schemas/AutoGridRepeatOptions"}},"required":["element"],"type":"object"},"AutoGridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutSpec":{"additionalProperties":false,"properties":{"columnWidth":{"type":"number"},"columnWidthMode":{"default":"standard","enum":["narrow","standard","wide","custom"],"type":"string"},"fillScreen":{"default":false,"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/AutoGridLayoutItemKind"},"type":"array"},"maxColumnCount":{"default":3,"type":"number"},"rowHeight":{"type":"number"},"rowHeightMode":{"default":"standard","enum":["short","standard","tall","custom"],"type":"string"}},"required":["columnWidthMode","rowHeightMode","items"],"type":"object"},"AutoGridRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"ConditionalRenderingDataKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingData","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingDataSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingDataSpec":{"additionalProperties":false,"properties":{"value":{"type":"boolean"}},"required":["value"],"type":"object"},"ConditionalRenderingGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingGroup","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingGroupSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingGroupSpec":{"additionalProperties":false,"properties":{"condition":{"enum":["and","or"],"type":"string"},"items":{"items":{"oneOf":[{"$ref":"#/components/schemas/ConditionalRenderingVariableKind"},{"$ref":"#/components/schemas/ConditionalRenderingDataKind"},{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeKind"}]},"type":"array"},"visibility":{"enum":["show","hide"],"type":"string"}},"required":["visibility","condition","items"],"type":"object"},"ConditionalRenderingTimeRangeSizeKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingTimeRangeSize","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingTimeRangeSizeSpec":{"additionalProperties":false,"properties":{"value":{"type":"string"}},"required":["value"],"type":"object"},"ConditionalRenderingVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingVariableSpec":{"additionalProperties":false,"properties":{"operator":{"enum":["equals","notEquals","matches","notMatches"],"type":"string"},"value":{"type":"string"},"variable":{"type":"string"}},"required":["variable","operator","value"],"type":"object"},"ConstantVariableKind":{"additionalProperties":false,"description":"Constant variable kind","properties":{"kind":{"const":"ConstantVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConstantVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConstantVariableSpec":{"additionalProperties":false,"description":"Constant variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","hide","skipUrlSync"],"type":"object"},"ConversionStatus":{"additionalProperties":false,"description":"ConversionStatus is the status of the conversion of the dashboard.","properties":{"error":{"description":"The error message from the conversion.\nEmpty if the conversion has not failed.","type":"string"},"failed":{"description":"Whether from another version has failed.\nIf true, means that the dashboard is not valid,\nand the caller should instead fetch the stored version.","type":"boolean"},"source":{"additionalProperties":{},"description":"The original value map[string]any","type":"object"},"storedVersion":{"description":"The version which was stored when the dashboard was created / updated.\nFetching this version should always succeed.","type":"string"}},"required":["failed"],"type":"object"},"CustomVariableKind":{"additionalProperties":false,"description":"Custom variable kind","properties":{"kind":{"const":"CustomVariable","type":"string"},"spec":{"$ref":"#/components/schemas/CustomVariableSpec"}},"required":["kind","spec"],"type":"object"},"CustomVariableSpec":{"additionalProperties":false,"description":"Custom variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"},"valuesFormat":{"enum":["csv","json"],"type":"string"}},"required":["name","query","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"Dashboard":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"DashboardCursorSync":{"description":"\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip.","enum":["Crosshair","Tooltip","Off"],"type":"string"},"DashboardLink":{"additionalProperties":false,"description":"Links with references to other dashboards or external resources","properties":{"asDropdown":{"default":false,"description":"If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards","type":"boolean"},"icon":{"description":"Icon name to be displayed with the link","type":"string"},"includeVars":{"default":false,"description":"If true, includes current template variables values in the link as query params","type":"boolean"},"keepTime":{"default":false,"description":"If true, includes current time range in the link as query params","type":"boolean"},"placement":{"$ref":"#/components/schemas/DashboardLinkPlacement","description":"Placement can be used to display the link somewhere else on the dashboard other than above the visualisations."},"tags":{"description":"List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards","items":{"type":"string"},"type":"array"},"targetBlank":{"default":false,"description":"If true, the link will be opened in a new tab","type":"boolean"},"title":{"description":"Title to display with the link","type":"string"},"tooltip":{"description":"Tooltip to display when the user hovers their mouse over it","type":"string"},"type":{"$ref":"#/components/schemas/DashboardLinkType","description":"Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)\nFIXME: The type is generated as ` + "`" + `type: DashboardLinkType | dashboardLinkType.Link;` + "`" + ` but it should be ` + "`" + `type: DashboardLinkType` + "`" + `"},"url":{"description":"Link URL. Only required/valid if the type is link","type":"string"}},"required":["title","type","icon","tooltip","tags","asDropdown","targetBlank","includeVars","keepTime"],"type":"object"},"DashboardLinkPlacement":{"const":"inControlsMenu","description":"Dashboard Link placement. Defines where the link should be displayed.\n- \"inControlsMenu\" renders the link in bottom part of the dashboard controls dropdown menu","type":"string"},"DashboardLinkType":{"description":"Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)","enum":["link","dashboards"],"type":"string"},"DataLink":{"additionalProperties":false,"properties":{"targetBlank":{"type":"boolean"},"title":{"type":"string"},"url":{"type":"string"}},"required":["title","url"],"type":"object"},"DataQueryKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a DataQueryKind is the datasource type","type":"string"},"spec":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"}},"required":["kind","spec"],"type":"object"},"DataSourceRef":{"additionalProperties":false,"properties":{"type":{"description":"The plugin type-id","type":"string"},"uid":{"description":"Specific datasource instance","type":"string"}},"type":"object"},"DataTopic":{"description":"A topic is attached to DataFrame metadata in query results.\nThis specifies where the data should be used.","enum":["series","annotations","alertStates"],"type":"string"},"DataTransformerConfig":{"additionalProperties":false,"description":"Transformations allow to manipulate data returned by a query before the system applies a visualization.\nUsing transformations you can: rename fields, join time series data, perform mathematical operations across queries,\nuse the output of one transformation as the input to another transformation, etc.","properties":{"disabled":{"description":"Disabled transformations are skipped","type":"boolean"},"filter":{"$ref":"#/components/schemas/MatcherConfig","description":"Optional frame matcher. When missing it will be applied to all results"},"id":{"description":"Unique identifier of transformer","type":"string"},"options":{"additionalProperties":{},"description":"Options to be passed to the transformer\nValid options depend on the transformer id","type":"object"},"topic":{"$ref":"#/components/schemas/DataTopic","description":"Where to pull DataFrames from as input to transformation"}},"required":["id","options"],"type":"object"},"DatasourceVariableKind":{"additionalProperties":false,"description":"Datasource variable kind","properties":{"kind":{"const":"DatasourceVariable","type":"string"},"spec":{"$ref":"#/components/schemas/DatasourceVariableSpec"}},"required":["kind","spec"],"type":"object"},"DatasourceVariableSpec":{"additionalProperties":false,"description":"Datasource variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"pluginId":{"default":"","type":"string"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","pluginId","refresh","regex","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"DynamicConfigValue":{"additionalProperties":false,"properties":{"id":{"default":"","type":"string"},"value":{"additionalProperties":{},"type":"object"}},"required":["id"],"type":"object"},"Element":{"description":"Supported dashboard elements\n|* more element types in the future","oneOf":[{"$ref":"#/components/schemas/PanelKind"},{"$ref":"#/components/schemas/LibraryPanelKind"}]},"ElementReference":{"additionalProperties":false,"properties":{"kind":{"const":"ElementReference","type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"},"FetchOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url"],"type":"object"},"FieldColor":{"additionalProperties":false,"description":"Map a field to a color.","properties":{"fixedColor":{"description":"The fixed color value for fixed or shades color modes.","type":"string"},"mode":{"$ref":"#/components/schemas/FieldColorModeId","description":"The main color scheme mode."},"seriesBy":{"$ref":"#/components/schemas/FieldColorSeriesByMode","description":"Some visualizations need to know how to assign a series color from by value color schemes."}},"required":["mode"],"type":"object"},"FieldColorModeId":{"description":"Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value.\nContinuous color interpolates a color using the percentage of a value relative to min and max.\nAccepted values are:\n` + "`" + `thresholds` + "`" + `: From thresholds. Informs Grafana to take the color from the matching threshold\n` + "`" + `palette-classic` + "`" + `: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `palette-classic-by-name` + "`" + `: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `continuous-viridis` + "`" + `: Continuous Viridis palette mode\n` + "`" + `continuous-magma` + "`" + `: Continuous Magma palette mode\n` + "`" + `continuous-plasma` + "`" + `: Continuous Plasma palette mode\n` + "`" + `continuous-inferno` + "`" + `: Continuous Inferno palette mode\n` + "`" + `continuous-cividis` + "`" + `: Continuous Cividis palette mode\n` + "`" + `continuous-GrYlRd` + "`" + `: Continuous Green-Yellow-Red palette mode\n` + "`" + `continuous-RdYlGr` + "`" + `: Continuous Red-Yellow-Green palette mode\n` + "`" + `continuous-BlYlRd` + "`" + `: Continuous Blue-Yellow-Red palette mode\n` + "`" + `continuous-YlRd` + "`" + `: Continuous Yellow-Red palette mode\n` + "`" + `continuous-BlPu` + "`" + `: Continuous Blue-Purple palette mode\n` + "`" + `continuous-YlBl` + "`" + `: Continuous Yellow-Blue palette mode\n` + "`" + `continuous-blues` + "`" + `: Continuous Blue palette mode\n` + "`" + `continuous-reds` + "`" + `: Continuous Red palette mode\n` + "`" + `continuous-greens` + "`" + `: Continuous Green palette mode\n` + "`" + `continuous-purples` + "`" + `: Continuous Purple palette mode\n` + "`" + `shades` + "`" + `: Shades of a single color. Specify a single color, useful in an override rule.\n` + "`" + `fixed` + "`" + `: Fixed color mode. Specify a single color, useful in an override rule.","enum":["thresholds","palette-classic","palette-classic-by-name","continuous-viridis","continuous-magma","continuous-plasma","continuous-inferno","continuous-cividis","continuous-GrYlRd","continuous-RdYlGr","continuous-BlYlRd","continuous-YlRd","continuous-BlPu","continuous-YlBl","continuous-blues","continuous-reds","continuous-greens","continuous-purples","fixed","shades"],"type":"string"},"FieldColorSeriesByMode":{"description":"Defines how to assign a series color from \"by value\" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.","enum":["min","max","last"],"type":"string"},"FieldConfig":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"actions":{"description":"Define interactive HTTP requests that can be triggered from data visualizations.","items":{"$ref":"#/components/schemas/Action"},"type":"array"},"color":{"$ref":"#/components/schemas/FieldColor","description":"Panel color configuration"},"custom":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"custom is specified by the FieldConfig field\nin panel plugin schemas.","type":"object"},"decimals":{"description":"Specify the number of decimals Grafana includes in the rendered value.\nIf you leave this field blank, Grafana automatically truncates the number of decimals based on the value.\nFor example 1.1234 will display as 1.12 and 100.456 will display as 100.\nTo display all decimals, set the unit to ` + "`" + `String` + "`" + `.","type":"number"},"description":{"description":"Human readable field metadata","type":"string"},"displayName":{"description":"The display value for this field. This supports template variables blank is auto","type":"string"},"displayNameFromDS":{"description":"This can be used by data sources that return and explicit naming structure for values and labels\nWhen this property is configured, this value is used rather than the default naming strategy.","type":"string"},"filterable":{"description":"True if data source field supports ad-hoc filters","type":"boolean"},"links":{"description":"The behavior when clicking on a result","items":{"additionalProperties":{},"type":"object"},"type":"array"},"mappings":{"description":"Convert input values into a display string","items":{"$ref":"#/components/schemas/ValueMapping"},"type":"array"},"max":{"description":"The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"min":{"description":"The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"noValue":{"description":"Alternative to empty string","type":"string"},"path":{"description":"An explicit path to the field in the datasource. When the frame meta includes a path,\nThis will default to ` + "`" + `${frame.meta.path}/${field.name}\n\nWhen defined, this value can be used as an identifier within the datasource scope, and\nmay be used to update the results","type":"string"},"thresholds":{"$ref":"#/components/schemas/ThresholdsConfig","description":"Map numeric values to states"},"unit":{"description":"Unit a field should use. The unit you select is applied to all fields except time.\nYou can use the units ID availables in Grafana or a custom unit.\nAvailable units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts\nAs custom unit, you can use the following formats:\n` + "`" + `suffix:\u003csuffix\u003e` + "`" + ` for custom unit that should go after value.\n` + "`" + `prefix:\u003cprefix\u003e` + "`" + ` for custom unit that should go before value.\n` + "`" + `time:\u003cformat\u003e` + "`" + ` For custom date time formats type for example ` + "`" + `time:YYYY-MM-DD` + "`" + `.\n` + "`" + `si:\u003cbase scale\u003e\u003cunit characters\u003e` + "`" + ` for custom SI units. For example: ` + "`" + `si: mF` + "`" + `. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character.\n` + "`" + `count:\u003cunit\u003e` + "`" + ` for a custom count unit.\n` + "`" + `currency:\u003cunit\u003e` + "`" + ` for custom a currency unit.","type":"string"},"writeable":{"description":"True if data source can write a value to the path. Auth/authz are supported separately","type":"boolean"}},"type":"object"},"FieldConfigSource":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"defaults":{"$ref":"#/components/schemas/FieldConfig","description":"Defaults are the options applied to all fields."},"overrides":{"description":"Overrides are the options applied to specific fields overriding the defaults.","items":{"additionalProperties":false,"properties":{"__systemRef":{"description":"Describes config override rules created when interacting with Grafana.","type":"string"},"matcher":{"$ref":"#/components/schemas/MatcherConfig"},"properties":{"items":{"$ref":"#/components/schemas/DynamicConfigValue"},"type":"array"}},"required":["matcher","properties"],"type":"object"},"type":"array"}},"required":["defaults","overrides"],"type":"object"},"FilterOrigin":{"const":"dashboard","description":"Determine the origin of the adhoc variable filter","type":"string"},"GridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutItemSpec":{"additionalProperties":false,"properties":{"element":{"$ref":"#/components/schemas/ElementReference","description":"reference to a PanelKind from dashboard.spec.elements Expressed as JSON Schema reference"},"height":{"type":"integer"},"repeat":{"$ref":"#/components/schemas/RepeatOptions"},"width":{"type":"integer"},"x":{"type":"integer"},"y":{"type":"integer"}},"required":["x","y","width","height","element"],"type":"object"},"GridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutSpec":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/GridLayoutItemKind"},"type":"array"}},"required":["items"],"type":"object"},"GroupByVariableKind":{"additionalProperties":false,"description":"Group variable kind","properties":{"kind":{"const":"GroupByVariable","type":"string"},"spec":{"$ref":"#/components/schemas/GroupByVariableSpec"}},"required":["kind","spec"],"type":"object"},"GroupByVariableSpec":{"additionalProperties":false,"description":"GroupBy variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"defaultValue":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","options","multi","hide","skipUrlSync"],"type":"object"},"HttpRequestMethod":{"enum":["GET","PUT","POST","DELETE","PATCH"],"type":"string"},"InfinityOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"datasourceUid":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url","datasourceUid"],"type":"object"},"IntervalVariableKind":{"additionalProperties":false,"description":"Interval variable kind","properties":{"kind":{"const":"IntervalVariable","type":"string"},"spec":{"$ref":"#/components/schemas/IntervalVariableSpec"}},"required":["kind","spec"],"type":"object"},"IntervalVariableSpec":{"additionalProperties":false,"description":"Interval variable specification","properties":{"auto":{"default":false,"type":"boolean"},"auto_count":{"default":0,"type":"integer"},"auto_min":{"default":"","type":"string"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","options","auto","auto_min","auto_count","refresh","hide","skipUrlSync"],"type":"object"},"LibraryPanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"LibraryPanel","type":"string"},"spec":{"$ref":"#/components/schemas/LibraryPanelKindSpec"}},"required":["kind","spec"],"type":"object"},"LibraryPanelKindSpec":{"additionalProperties":false,"properties":{"id":{"description":"Panel ID for the library panel in the dashboard","type":"number"},"libraryPanel":{"$ref":"#/components/schemas/LibraryPanelRef"},"title":{"description":"Title for the library panel in the dashboard","type":"string"}},"required":["id","title","libraryPanel"],"type":"object"},"LibraryPanelRef":{"additionalProperties":false,"description":"A library panel is a reusable panel that you can use in any dashboard.\nWhen you make a change to a library panel, that change propagates to all instances of where the panel is used.\nLibrary panels streamline reuse of panels across multiple dashboards.","properties":{"name":{"description":"Library panel name","type":"string"},"uid":{"description":"Library panel uid","type":"string"}},"required":["name","uid"],"type":"object"},"MappingType":{"description":"Supported value mapping types\n` + "`" + `value` + "`" + `: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.\n` + "`" + `range` + "`" + `: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.\n` + "`" + `regex` + "`" + `: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.\n` + "`" + `special` + "`" + `: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A.","enum":["value","range","regex","special"],"type":"string"},"MatcherConfig":{"additionalProperties":false,"description":"Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation.\nIt comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type.","properties":{"id":{"default":"","description":"The matcher id. This is used to find the matcher implementation from registry.","type":"string"},"options":{"additionalProperties":{},"description":"The matcher options. This is specific to the matcher implementation.","type":"object"}},"required":["id"],"type":"object"},"MetricFindValue":{"additionalProperties":false,"description":"Define the MetricFindValue type","properties":{"expandable":{"type":"boolean"},"group":{"type":"string"},"text":{"type":"string"},"value":{"oneOf":[{"type":"string"},{"type":"number"}]}},"required":["text"],"type":"object"},"PanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"Panel","type":"string"},"spec":{"$ref":"#/components/schemas/PanelSpec"}},"required":["kind","spec"],"type":"object"},"PanelQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"PanelQuery","type":"string"},"spec":{"$ref":"#/components/schemas/PanelQuerySpec"}},"required":["kind","spec"],"type":"object"},"PanelQuerySpec":{"additionalProperties":false,"properties":{"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"hidden":{"type":"boolean"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refId":{"type":"string"}},"required":["query","refId","hidden"],"type":"object"},"PanelSpec":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/QueryGroupKind"},"description":{"type":"string"},"id":{"type":"number"},"links":{"items":{"$ref":"#/components/schemas/DataLink"},"type":"array"},"title":{"type":"string"},"transparent":{"type":"boolean"},"vizConfig":{"$ref":"#/components/schemas/VizConfigKind"}},"required":["id","title","description","links","data","vizConfig"],"type":"object"},"QueryGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"QueryGroup","type":"string"},"spec":{"$ref":"#/components/schemas/QueryGroupSpec"}},"required":["kind","spec"],"type":"object"},"QueryGroupSpec":{"additionalProperties":false,"properties":{"queries":{"items":{"$ref":"#/components/schemas/PanelQueryKind"},"type":"array"},"queryOptions":{"$ref":"#/components/schemas/QueryOptionsSpec"},"transformations":{"items":{"$ref":"#/components/schemas/TransformationKind"},"type":"array"}},"required":["queries","transformations","queryOptions"],"type":"object"},"QueryOptionsSpec":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"string"},"hideTimeOverride":{"type":"boolean"},"interval":{"type":"string"},"maxDataPoints":{"type":"integer"},"queryCachingTTL":{"type":"integer"},"timeFrom":{"type":"string"},"timeShift":{"type":"string"}},"type":"object"},"QueryVariableKind":{"additionalProperties":false,"description":"Query variable kind","properties":{"kind":{"const":"QueryVariable","type":"string"},"spec":{"$ref":"#/components/schemas/QueryVariableSpec"}},"required":["kind","spec"],"type":"object"},"QueryVariableSpec":{"additionalProperties":false,"description":"Query variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"definition":{"type":"string"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"placeholder":{"type":"string"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"regexApplyTo":{"$ref":"#/components/schemas/VariableRegexApplyTo","default":"value"},"skipUrlSync":{"default":false,"type":"boolean"},"sort":{"$ref":"#/components/schemas/VariableSort"},"staticOptions":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"staticOptionsOrder":{"enum":["before","after","sorted"],"type":"string"}},"required":["name","current","hide","refresh","skipUrlSync","query","regex","sort","options","multi","includeAll","allowCustomValue"],"type":"object"},"RangeMap":{"additionalProperties":false,"description":"Maps numerical ranges to a display text and color.\nFor example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.","properties":{"options":{"additionalProperties":false,"description":"Range to match against and the result to apply when the value is within the range","properties":{"from":{"description":"Min value of the range. It can be null which means -Infinity","type":"number"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value is within the range"},"to":{"description":"Max value of the range. It can be null which means +Infinity","type":"number"}},"required":["from","to","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RegexMap":{"additionalProperties":false,"description":"Maps regular expressions to replacement text and a color.\nFor example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.","properties":{"options":{"additionalProperties":false,"description":"Regular expression to match against and the result to apply when the value matches the regex","properties":{"pattern":{"description":"Regular expression to match against","type":"string"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the regex"}},"required":["pattern","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RepeatMode":{"const":"variable","description":"other repeat modes will be added in the future: label, frame","type":"string"},"RepeatOptions":{"additionalProperties":false,"properties":{"direction":{"enum":["h","v"],"type":"string"},"maxPerRow":{"type":"integer"},"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayoutRow","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutRowSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowSpec":{"additionalProperties":false,"properties":{"collapse":{"type":"boolean"},"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"fillScreen":{"type":"boolean"},"hideHeader":{"type":"boolean"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/RowRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"RowsLayoutSpec":{"additionalProperties":false,"properties":{"rows":{"items":{"$ref":"#/components/schemas/RowsLayoutRowKind"},"type":"array"}},"required":["rows"],"type":"object"},"SpecialValueMap":{"additionalProperties":false,"description":"Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color.\nSee SpecialValueMatch to see the list of special values.\nFor example, you can configure a special value mapping so that null values appear as N/A.","properties":{"options":{"additionalProperties":false,"properties":{"match":{"$ref":"#/components/schemas/SpecialValueMatch","description":"Special value to match against"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the special value"}},"required":["match","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"SpecialValueMatch":{"description":"Special value types supported by the ` + "`" + `SpecialValueMap` + "`" + `","enum":["true","false","null","nan","null+nan","empty"],"type":"string"},"SwitchVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"SwitchVariable","type":"string"},"spec":{"$ref":"#/components/schemas/SwitchVariableSpec"}},"required":["kind","spec"],"type":"object"},"SwitchVariableSpec":{"additionalProperties":false,"description":"Switch variable specification","properties":{"current":{"default":"false","type":"string"},"description":{"type":"string"},"disabledValue":{"default":"false","type":"string"},"enabledValue":{"default":"true","type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","enabledValue","disabledValue","hide","skipUrlSync"],"type":"object"},"TabRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"TabsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutSpec":{"additionalProperties":false,"properties":{"tabs":{"items":{"$ref":"#/components/schemas/TabsLayoutTabKind"},"type":"array"}},"required":["tabs"],"type":"object"},"TabsLayoutTabKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayoutTab","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutTabSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutTabSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/TabRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"TextVariableKind":{"additionalProperties":false,"description":"Text variable kind","properties":{"kind":{"const":"TextVariable","type":"string"},"spec":{"$ref":"#/components/schemas/TextVariableSpec"}},"required":["kind","spec"],"type":"object"},"TextVariableSpec":{"additionalProperties":false,"description":"Text variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","query","hide","skipUrlSync"],"type":"object"},"Threshold":{"additionalProperties":false,"properties":{"color":{"type":"string"},"value":{"description":"Value null means -Infinity","type":"number"}},"required":["value","color"],"type":"object"},"ThresholdsConfig":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/ThresholdsMode"},"steps":{"items":{"$ref":"#/components/schemas/Threshold"},"type":"array"}},"required":["mode","steps"],"type":"object"},"ThresholdsMode":{"enum":["absolute","percentage"],"type":"string"},"TimeRangeOption":{"additionalProperties":false,"properties":{"display":{"default":"Last 6 hours","type":"string"},"from":{"default":"now-6h","type":"string"},"to":{"default":"now","type":"string"}},"required":["display","from","to"],"type":"object"},"TimeSettingsSpec":{"additionalProperties":false,"description":"Time configuration\nIt defines the default time config for the time picker, the refresh picker for the specific dashboard.","properties":{"autoRefresh":{"default":"","description":"Refresh rate of dashboard. Represented via interval string, e.g. \"5s\", \"1m\", \"1h\", \"1d\".\nv1: refresh","type":"string"},"autoRefreshIntervals":{"default":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"description":"Interval options available in the refresh picker dropdown.\nv1: timepicker.refresh_intervals","items":{"type":"string"},"type":"array"},"fiscalYearStartMonth":{"default":0,"description":"The month that the fiscal year starts on. 0 = January, 11 = December","type":"integer"},"from":{"default":"now-6h","description":"Start time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"hideTimepicker":{"default":false,"description":"Whether timepicker is visible or not.\nv1: timepicker.hidden","type":"boolean"},"nowDelay":{"description":"Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.\nv1: timepicker.nowDelay","type":"string"},"quickRanges":{"description":"Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.\nv1: timepicker.quick_ranges , not exposed in the UI","items":{"$ref":"#/components/schemas/TimeRangeOption"},"type":"array"},"timezone":{"default":"browser","description":"Timezone of dashboard. Accepted values are IANA TZDB zone ID or \"browser\" or \"utc\".","type":"string"},"to":{"default":"now","description":"End time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"weekStart":{"description":"Day when the week starts. Expressed by the name of the day in lowercase, e.g. \"monday\".","enum":["saturday","monday","sunday"],"type":"string"}},"required":["from","to","autoRefresh","autoRefreshIntervals","hideTimepicker","fiscalYearStartMonth"],"type":"object"},"TransformationKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a TransformationKind is the transformation ID","type":"string"},"spec":{"$ref":"#/components/schemas/DataTransformerConfig"}},"required":["kind","spec"],"type":"object"},"ValueMap":{"additionalProperties":false,"description":"Maps text values to a color or different display text and color.\nFor example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.","properties":{"options":{"additionalProperties":{"$ref":"#/components/schemas/ValueMappingResult"},"description":"Map with \u003cvalue_to_match\u003e: ValueMappingResult. For example: { \"10\": { text: \"Perfection!\", color: \"green\" } }","type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"ValueMapping":{"oneOf":[{"$ref":"#/components/schemas/ValueMap"},{"$ref":"#/components/schemas/RangeMap"},{"$ref":"#/components/schemas/RegexMap"},{"$ref":"#/components/schemas/SpecialValueMap"}]},"ValueMappingResult":{"additionalProperties":false,"description":"Result used as replacement with text and color when the value matches","properties":{"color":{"description":"Text to use when the value matches","type":"string"},"icon":{"description":"Icon to display when the value matches. Only specific visualizations.","type":"string"},"index":{"description":"Position in the mapping array. Only used internally.","type":"integer"},"text":{"description":"Text to display when the value matches","type":"string"}},"type":"object"},"VariableHide":{"description":"Determine if the variable shows on dashboard\nAccepted values are ` + "`" + `dontHide` + "`" + ` (show label and value), ` + "`" + `hideLabel` + "`" + ` (show value only), ` + "`" + `hideVariable` + "`" + ` (show nothing).","enum":["dontHide","hideLabel","hideVariable"],"type":"string"},"VariableKind":{"oneOf":[{"$ref":"#/components/schemas/QueryVariableKind"},{"$ref":"#/components/schemas/TextVariableKind"},{"$ref":"#/components/schemas/ConstantVariableKind"},{"$ref":"#/components/schemas/DatasourceVariableKind"},{"$ref":"#/components/schemas/IntervalVariableKind"},{"$ref":"#/components/schemas/CustomVariableKind"},{"$ref":"#/components/schemas/GroupByVariableKind"},{"$ref":"#/components/schemas/AdhocVariableKind"},{"$ref":"#/components/schemas/SwitchVariableKind"}]},"VariableOption":{"additionalProperties":false,"description":"Variable option specification","properties":{"selected":{"description":"Whether the option is selected or not","type":"boolean"},"text":{"description":"Text to be displayed for the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]},"value":{"description":"Value of the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]}},"required":["text","value"],"type":"object"},"VariableRefresh":{"description":"Options to config when to refresh a variable\n` + "`" + `never` + "`" + `: Never refresh the variable\n` + "`" + `onDashboardLoad` + "`" + `: Queries the data source every time the dashboard loads.\n` + "`" + `onTimeRangeChanged` + "`" + `: Queries the data source when the dashboard time range changes.","enum":["never","onDashboardLoad","onTimeRangeChanged"],"type":"string"},"VariableRegexApplyTo":{"description":"Determine whether regex applies to variable value or display text\nAccepted values are ` + "`" + `value` + "`" + ` (apply to value used in queries) or ` + "`" + `text` + "`" + ` (apply to display text shown to users)","enum":["value","text"],"type":"string"},"VariableSort":{"description":"Sort variable options\nAccepted values are:\n` + "`" + `disabled` + "`" + `: No sorting\n` + "`" + `alphabeticalAsc` + "`" + `: Alphabetical ASC\n` + "`" + `alphabeticalDesc` + "`" + `: Alphabetical DESC\n` + "`" + `numericalAsc` + "`" + `: Numerical ASC\n` + "`" + `numericalDesc` + "`" + `: Numerical DESC\n` + "`" + `alphabeticalCaseInsensitiveAsc` + "`" + `: Alphabetical Case Insensitive ASC\n` + "`" + `alphabeticalCaseInsensitiveDesc` + "`" + `: Alphabetical Case Insensitive DESC\n` + "`" + `naturalAsc` + "`" + `: Natural ASC\n` + "`" + `naturalDesc` + "`" + `: Natural DESC\nVariableSort enum with default value","enum":["disabled","alphabeticalAsc","alphabeticalDesc","numericalAsc","numericalDesc","alphabeticalCaseInsensitiveAsc","alphabeticalCaseInsensitiveDesc","naturalAsc","naturalDesc"],"type":"string"},"VizConfigKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a VizConfigKind is the plugin ID","type":"string"},"spec":{"$ref":"#/components/schemas/VizConfigSpec"}},"required":["kind","spec"],"type":"object"},"VizConfigSpec":{"additionalProperties":false,"description":"--- Kinds ---","properties":{"fieldConfig":{"$ref":"#/components/schemas/FieldConfigSource"},"options":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"pluginVersion":{"type":"string"}},"required":["pluginVersion","options","fieldConfig"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"annotations":{"items":{"$ref":"#/components/schemas/AnnotationQueryKind"},"type":"array"},"cursorSync":{"$ref":"#/components/schemas/DashboardCursorSync","default":"Off","description":"Configuration of dashboard cursor sync behavior.\n\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip."},"description":{"description":"Description of dashboard.","type":"string"},"editable":{"default":true,"description":"Whether a dashboard is editable or not.","type":"boolean"},"elements":{"additionalProperties":{"$ref":"#/components/schemas/Element"},"type":"object"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"links":{"description":"Links with references to other dashboards or external websites.","items":{"$ref":"#/components/schemas/DashboardLink"},"type":"array"},"liveNow":{"description":"When set to true, the dashboard will redraw panels at an interval matching the pixel width.\nThis will keep data \"moving left\" regardless of the query refresh rate. This setting helps\navoid dashboards presenting stale live data.","type":"boolean"},"preload":{"default":false,"description":"When set to true, the dashboard will load all panels in the dashboard when it's loaded.","type":"boolean"},"revision":{"description":"Plugins only. The version of the dashboard installed together with the plugin.\nThis is used to determine if the dashboard should be updated when the plugin is updated.","type":"integer"},"tags":{"description":"Tags associated with dashboard.","items":{"type":"string"},"type":"array"},"timeSettings":{"$ref":"#/components/schemas/TimeSettingsSpec"},"title":{"description":"Title of dashboard.","type":"string"},"variables":{"description":"Configured template variables.","items":{"$ref":"#/components/schemas/VariableKind"},"type":"array"}},"required":["annotations","cursorSync","elements","layout","links","preload","tags","timeSettings","title","variables"],"type":"object"},"status":{"additionalProperties":false,"properties":{"conversion":{"$ref":"#/components/schemas/ConversionStatus","description":"Optional conversion status."}},"type":"object"}}`) + rawSchemaDashboardv2alpha1 = []byte(`{"Action":{"additionalProperties":false,"properties":{"confirmation":{"type":"string"},"fetch":{"$ref":"#/components/schemas/FetchOptions"},"infinity":{"$ref":"#/components/schemas/InfinityOptions"},"oneClick":{"type":"boolean"},"style":{"additionalProperties":false,"properties":{"backgroundColor":{"type":"string"}},"type":"object"},"title":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionType"},"variables":{"items":{"$ref":"#/components/schemas/ActionVariable"},"type":"array"}},"required":["type","title"],"type":"object"},"ActionType":{"enum":["fetch","infinity"],"type":"string"},"ActionVariable":{"additionalProperties":false,"properties":{"key":{"type":"string"},"name":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionVariableType"}},"required":["key","name","type"],"type":"object"},"ActionVariableType":{"const":"string","description":"Action variable type","type":"string"},"AdHocFilterWithLabels":{"additionalProperties":false,"description":"Define the AdHocFilterWithLabels type","properties":{"condition":{"description":"@deprecated","type":"string"},"forceEdit":{"type":"boolean"},"key":{"type":"string"},"keyLabel":{"type":"string"},"operator":{"type":"string"},"origin":{"$ref":"#/components/schemas/FilterOrigin"},"value":{"type":"string"},"valueLabels":{"items":{"type":"string"},"type":"array"},"values":{"items":{"type":"string"},"type":"array"}},"required":["key","operator","value"],"type":"object"},"AdhocVariableKind":{"additionalProperties":false,"description":"Adhoc variable kind","properties":{"kind":{"const":"AdhocVariable","type":"string"},"spec":{"$ref":"#/components/schemas/AdhocVariableSpec"}},"required":["kind","spec"],"type":"object"},"AdhocVariableSpec":{"additionalProperties":false,"description":"Adhoc variable specification","properties":{"allowCustomValue":{"default":true,"type":"boolean"},"baseFilters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"defaultKeys":{"items":{"$ref":"#/components/schemas/MetricFindValue"},"type":"array"},"description":{"type":"string"},"filters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","baseFilters","filters","defaultKeys","hide","skipUrlSync","allowCustomValue"],"type":"object"},"AnnotationEventFieldMapping":{"additionalProperties":false,"description":"Annotation event field mapping. Defines how to map a data frame field to an annotation event field.","properties":{"regex":{"description":"Regular expression to apply to the field value","type":"string"},"source":{"default":"field","description":"Source type for the field value","type":"string"},"value":{"description":"Constant value to use when source is \"text\"","type":"string"}},"type":"object"},"AnnotationPanelFilter":{"additionalProperties":false,"properties":{"exclude":{"default":false,"description":"Should the specified panels be included or excluded","type":"boolean"},"ids":{"description":"Panel IDs that should be included or excluded","items":{"type":"integer"},"type":"array"}},"required":["ids"],"type":"object"},"AnnotationQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"AnnotationQuery","type":"string"},"spec":{"$ref":"#/components/schemas/AnnotationQuerySpec"}},"required":["kind","spec"],"type":"object"},"AnnotationQuerySpec":{"additionalProperties":false,"properties":{"builtIn":{"default":false,"type":"boolean"},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"enable":{"type":"boolean"},"filter":{"$ref":"#/components/schemas/AnnotationPanelFilter"},"hide":{"type":"boolean"},"iconColor":{"type":"string"},"legacyOptions":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"Catch-all field for datasource-specific properties","type":"object"},"mappings":{"additionalProperties":{"$ref":"#/components/schemas/AnnotationEventFieldMapping"},"description":"Mappings define how to convert data frame fields to annotation event fields.","type":"object"},"name":{"type":"string"},"query":{"$ref":"#/components/schemas/DataQueryKind"}},"required":["enable","hide","iconColor","name"],"type":"object"},"AutoGridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutItemSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"element":{"$ref":"#/components/schemas/ElementReference"},"repeat":{"$ref":"#/components/schemas/AutoGridRepeatOptions"}},"required":["element"],"type":"object"},"AutoGridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutSpec":{"additionalProperties":false,"properties":{"columnWidth":{"type":"number"},"columnWidthMode":{"default":"standard","enum":["narrow","standard","wide","custom"],"type":"string"},"fillScreen":{"default":false,"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/AutoGridLayoutItemKind"},"type":"array"},"maxColumnCount":{"default":3,"type":"number"},"rowHeight":{"type":"number"},"rowHeightMode":{"default":"standard","enum":["short","standard","tall","custom"],"type":"string"}},"required":["columnWidthMode","rowHeightMode","items"],"type":"object"},"AutoGridRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"ConditionalRenderingDataKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingData","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingDataSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingDataSpec":{"additionalProperties":false,"properties":{"value":{"type":"boolean"}},"required":["value"],"type":"object"},"ConditionalRenderingGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingGroup","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingGroupSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingGroupSpec":{"additionalProperties":false,"properties":{"condition":{"enum":["and","or"],"type":"string"},"items":{"items":{"oneOf":[{"$ref":"#/components/schemas/ConditionalRenderingVariableKind"},{"$ref":"#/components/schemas/ConditionalRenderingDataKind"},{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeKind"}]},"type":"array"},"visibility":{"enum":["show","hide"],"type":"string"}},"required":["visibility","condition","items"],"type":"object"},"ConditionalRenderingTimeRangeSizeKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingTimeRangeSize","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingTimeRangeSizeSpec":{"additionalProperties":false,"properties":{"value":{"type":"string"}},"required":["value"],"type":"object"},"ConditionalRenderingVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingVariableSpec":{"additionalProperties":false,"properties":{"operator":{"enum":["equals","notEquals","matches","notMatches"],"type":"string"},"value":{"type":"string"},"variable":{"type":"string"}},"required":["variable","operator","value"],"type":"object"},"ConstantVariableKind":{"additionalProperties":false,"description":"Constant variable kind","properties":{"kind":{"const":"ConstantVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConstantVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConstantVariableSpec":{"additionalProperties":false,"description":"Constant variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","hide","skipUrlSync"],"type":"object"},"ConversionStatus":{"additionalProperties":false,"description":"ConversionStatus is the status of the conversion of the dashboard.","properties":{"error":{"description":"The error message from the conversion.\nEmpty if the conversion has not failed.","type":"string"},"failed":{"description":"Whether from another version has failed.\nIf true, means that the dashboard is not valid,\nand the caller should instead fetch the stored version.","type":"boolean"},"source":{"additionalProperties":{},"description":"The original value map[string]any","type":"object"},"storedVersion":{"description":"The version which was stored when the dashboard was created / updated.\nFetching this version should always succeed.","type":"string"}},"required":["failed"],"type":"object"},"CustomVariableKind":{"additionalProperties":false,"description":"Custom variable kind","properties":{"kind":{"const":"CustomVariable","type":"string"},"spec":{"$ref":"#/components/schemas/CustomVariableSpec"}},"required":["kind","spec"],"type":"object"},"CustomVariableSpec":{"additionalProperties":false,"description":"Custom variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"},"valuesFormat":{"enum":["csv","json"],"type":"string"}},"required":["name","query","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"Dashboard":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"DashboardCursorSync":{"description":"\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip.","enum":["Crosshair","Tooltip","Off"],"type":"string"},"DashboardLink":{"additionalProperties":false,"description":"Links with references to other dashboards or external resources","properties":{"asDropdown":{"default":false,"description":"If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards","type":"boolean"},"icon":{"description":"Icon name to be displayed with the link","type":"string"},"includeVars":{"default":false,"description":"If true, includes current template variables values in the link as query params","type":"boolean"},"keepTime":{"default":false,"description":"If true, includes current time range in the link as query params","type":"boolean"},"placement":{"$ref":"#/components/schemas/DashboardLinkPlacement","description":"Placement can be used to display the link somewhere else on the dashboard other than above the visualisations."},"tags":{"description":"List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards","items":{"type":"string"},"type":"array"},"targetBlank":{"default":false,"description":"If true, the link will be opened in a new tab","type":"boolean"},"title":{"description":"Title to display with the link","type":"string"},"tooltip":{"description":"Tooltip to display when the user hovers their mouse over it","type":"string"},"type":{"$ref":"#/components/schemas/DashboardLinkType","description":"Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)\nFIXME: The type is generated as ` + "`" + `type: DashboardLinkType | dashboardLinkType.Link;` + "`" + ` but it should be ` + "`" + `type: DashboardLinkType` + "`" + `"},"url":{"description":"Link URL. Only required/valid if the type is link","type":"string"}},"required":["title","type","icon","tooltip","tags","asDropdown","targetBlank","includeVars","keepTime"],"type":"object"},"DashboardLinkPlacement":{"const":"inControlsMenu","description":"Dashboard Link placement. Defines where the link should be displayed.\n- \"inControlsMenu\" renders the link in bottom part of the dashboard controls dropdown menu","type":"string"},"DashboardLinkType":{"description":"Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)","enum":["link","dashboards"],"type":"string"},"DataLink":{"additionalProperties":false,"properties":{"targetBlank":{"type":"boolean"},"title":{"type":"string"},"url":{"type":"string"}},"required":["title","url"],"type":"object"},"DataQueryKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a DataQueryKind is the datasource type","type":"string"},"spec":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"}},"required":["kind","spec"],"type":"object"},"DataSourceRef":{"additionalProperties":false,"properties":{"type":{"description":"The plugin type-id","type":"string"},"uid":{"description":"Specific datasource instance","type":"string"}},"type":"object"},"DataTopic":{"description":"A topic is attached to DataFrame metadata in query results.\nThis specifies where the data should be used.","enum":["series","annotations","alertStates"],"type":"string"},"DataTransformerConfig":{"additionalProperties":false,"description":"Transformations allow to manipulate data returned by a query before the system applies a visualization.\nUsing transformations you can: rename fields, join time series data, perform mathematical operations across queries,\nuse the output of one transformation as the input to another transformation, etc.","properties":{"disabled":{"description":"Disabled transformations are skipped","type":"boolean"},"filter":{"$ref":"#/components/schemas/MatcherConfig","description":"Optional frame matcher. When missing it will be applied to all results"},"id":{"description":"Unique identifier of transformer","type":"string"},"options":{"additionalProperties":{},"description":"Options to be passed to the transformer\nValid options depend on the transformer id","type":"object"},"topic":{"$ref":"#/components/schemas/DataTopic","description":"Where to pull DataFrames from as input to transformation"}},"required":["id","options"],"type":"object"},"DatasourceVariableKind":{"additionalProperties":false,"description":"Datasource variable kind","properties":{"kind":{"const":"DatasourceVariable","type":"string"},"spec":{"$ref":"#/components/schemas/DatasourceVariableSpec"}},"required":["kind","spec"],"type":"object"},"DatasourceVariableSpec":{"additionalProperties":false,"description":"Datasource variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"pluginId":{"default":"","type":"string"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","pluginId","refresh","regex","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"DynamicConfigValue":{"additionalProperties":false,"properties":{"id":{"default":"","type":"string"},"value":{"additionalProperties":{},"type":"object"}},"required":["id"],"type":"object"},"Element":{"description":"Supported dashboard elements\n|* more element types in the future","oneOf":[{"$ref":"#/components/schemas/PanelKind"},{"$ref":"#/components/schemas/LibraryPanelKind"}]},"ElementReference":{"additionalProperties":false,"properties":{"kind":{"const":"ElementReference","type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"},"FetchOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url"],"type":"object"},"FieldColor":{"additionalProperties":false,"description":"Map a field to a color.","properties":{"fixedColor":{"description":"The fixed color value for fixed or shades color modes.","type":"string"},"mode":{"$ref":"#/components/schemas/FieldColorModeId","description":"The main color scheme mode."},"seriesBy":{"$ref":"#/components/schemas/FieldColorSeriesByMode","description":"Some visualizations need to know how to assign a series color from by value color schemes."}},"required":["mode"],"type":"object"},"FieldColorModeId":{"description":"Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value.\nContinuous color interpolates a color using the percentage of a value relative to min and max.\nAccepted values are:\n` + "`" + `thresholds` + "`" + `: From thresholds. Informs Grafana to take the color from the matching threshold\n` + "`" + `palette-classic` + "`" + `: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `palette-classic-by-name` + "`" + `: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `continuous-viridis` + "`" + `: Continuous Viridis palette mode\n` + "`" + `continuous-magma` + "`" + `: Continuous Magma palette mode\n` + "`" + `continuous-plasma` + "`" + `: Continuous Plasma palette mode\n` + "`" + `continuous-inferno` + "`" + `: Continuous Inferno palette mode\n` + "`" + `continuous-cividis` + "`" + `: Continuous Cividis palette mode\n` + "`" + `continuous-GrYlRd` + "`" + `: Continuous Green-Yellow-Red palette mode\n` + "`" + `continuous-RdYlGr` + "`" + `: Continuous Red-Yellow-Green palette mode\n` + "`" + `continuous-BlYlRd` + "`" + `: Continuous Blue-Yellow-Red palette mode\n` + "`" + `continuous-YlRd` + "`" + `: Continuous Yellow-Red palette mode\n` + "`" + `continuous-BlPu` + "`" + `: Continuous Blue-Purple palette mode\n` + "`" + `continuous-YlBl` + "`" + `: Continuous Yellow-Blue palette mode\n` + "`" + `continuous-blues` + "`" + `: Continuous Blue palette mode\n` + "`" + `continuous-reds` + "`" + `: Continuous Red palette mode\n` + "`" + `continuous-greens` + "`" + `: Continuous Green palette mode\n` + "`" + `continuous-purples` + "`" + `: Continuous Purple palette mode\n` + "`" + `shades` + "`" + `: Shades of a single color. Specify a single color, useful in an override rule.\n` + "`" + `fixed` + "`" + `: Fixed color mode. Specify a single color, useful in an override rule.","enum":["thresholds","palette-classic","palette-classic-by-name","continuous-viridis","continuous-magma","continuous-plasma","continuous-inferno","continuous-cividis","continuous-GrYlRd","continuous-RdYlGr","continuous-BlYlRd","continuous-YlRd","continuous-BlPu","continuous-YlBl","continuous-blues","continuous-reds","continuous-greens","continuous-purples","fixed","shades"],"type":"string"},"FieldColorSeriesByMode":{"description":"Defines how to assign a series color from \"by value\" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.","enum":["min","max","last"],"type":"string"},"FieldConfig":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"actions":{"description":"Define interactive HTTP requests that can be triggered from data visualizations.","items":{"$ref":"#/components/schemas/Action"},"type":"array"},"color":{"$ref":"#/components/schemas/FieldColor","description":"Panel color configuration"},"custom":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"custom is specified by the FieldConfig field\nin panel plugin schemas.","type":"object"},"decimals":{"description":"Specify the number of decimals Grafana includes in the rendered value.\nIf you leave this field blank, Grafana automatically truncates the number of decimals based on the value.\nFor example 1.1234 will display as 1.12 and 100.456 will display as 100.\nTo display all decimals, set the unit to ` + "`" + `String` + "`" + `.","type":"number"},"description":{"description":"Human readable field metadata","type":"string"},"displayName":{"description":"The display value for this field. This supports template variables blank is auto","type":"string"},"displayNameFromDS":{"description":"This can be used by data sources that return and explicit naming structure for values and labels\nWhen this property is configured, this value is used rather than the default naming strategy.","type":"string"},"fieldMinMax":{"description":"Calculate min max per field","type":"boolean"},"filterable":{"description":"True if data source field supports ad-hoc filters","type":"boolean"},"links":{"description":"The behavior when clicking on a result","items":{"additionalProperties":{},"type":"object"},"type":"array"},"mappings":{"description":"Convert input values into a display string","items":{"$ref":"#/components/schemas/ValueMapping"},"type":"array"},"max":{"description":"The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"min":{"description":"The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"noValue":{"description":"Alternative to empty string","type":"string"},"nullValueMode":{"$ref":"#/components/schemas/NullValueMode","description":"How null values should be handled when calculating field stats\n\"null\" - Include null values, \"connected\" - Ignore nulls, \"null as zero\" - Treat nulls as zero"},"path":{"description":"An explicit path to the field in the datasource. When the frame meta includes a path,\nThis will default to ` + "`" + `${frame.meta.path}/${field.name}\n\nWhen defined, this value can be used as an identifier within the datasource scope, and\nmay be used to update the results","type":"string"},"thresholds":{"$ref":"#/components/schemas/ThresholdsConfig","description":"Map numeric values to states"},"unit":{"description":"Unit a field should use. The unit you select is applied to all fields except time.\nYou can use the units ID availables in Grafana or a custom unit.\nAvailable units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts\nAs custom unit, you can use the following formats:\n` + "`" + `suffix:\u003csuffix\u003e` + "`" + ` for custom unit that should go after value.\n` + "`" + `prefix:\u003cprefix\u003e` + "`" + ` for custom unit that should go before value.\n` + "`" + `time:\u003cformat\u003e` + "`" + ` For custom date time formats type for example ` + "`" + `time:YYYY-MM-DD` + "`" + `.\n` + "`" + `si:\u003cbase scale\u003e\u003cunit characters\u003e` + "`" + ` for custom SI units. For example: ` + "`" + `si: mF` + "`" + `. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character.\n` + "`" + `count:\u003cunit\u003e` + "`" + ` for a custom count unit.\n` + "`" + `currency:\u003cunit\u003e` + "`" + ` for custom a currency unit.","type":"string"},"writeable":{"description":"True if data source can write a value to the path. Auth/authz are supported separately","type":"boolean"}},"type":"object"},"FieldConfigSource":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"defaults":{"$ref":"#/components/schemas/FieldConfig","description":"Defaults are the options applied to all fields."},"overrides":{"description":"Overrides are the options applied to specific fields overriding the defaults.","items":{"additionalProperties":false,"properties":{"__systemRef":{"description":"Describes config override rules created when interacting with Grafana.","type":"string"},"matcher":{"$ref":"#/components/schemas/MatcherConfig"},"properties":{"items":{"$ref":"#/components/schemas/DynamicConfigValue"},"type":"array"}},"required":["matcher","properties"],"type":"object"},"type":"array"}},"required":["defaults","overrides"],"type":"object"},"FilterOrigin":{"const":"dashboard","description":"Determine the origin of the adhoc variable filter","type":"string"},"GridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutItemSpec":{"additionalProperties":false,"properties":{"element":{"$ref":"#/components/schemas/ElementReference","description":"reference to a PanelKind from dashboard.spec.elements Expressed as JSON Schema reference"},"height":{"type":"integer"},"repeat":{"$ref":"#/components/schemas/RepeatOptions"},"width":{"type":"integer"},"x":{"type":"integer"},"y":{"type":"integer"}},"required":["x","y","width","height","element"],"type":"object"},"GridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutSpec":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/GridLayoutItemKind"},"type":"array"}},"required":["items"],"type":"object"},"GroupByVariableKind":{"additionalProperties":false,"description":"Group variable kind","properties":{"kind":{"const":"GroupByVariable","type":"string"},"spec":{"$ref":"#/components/schemas/GroupByVariableSpec"}},"required":["kind","spec"],"type":"object"},"GroupByVariableSpec":{"additionalProperties":false,"description":"GroupBy variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"defaultValue":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","options","multi","hide","skipUrlSync"],"type":"object"},"HttpRequestMethod":{"enum":["GET","PUT","POST","DELETE","PATCH"],"type":"string"},"InfinityOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"datasourceUid":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url","datasourceUid"],"type":"object"},"IntervalVariableKind":{"additionalProperties":false,"description":"Interval variable kind","properties":{"kind":{"const":"IntervalVariable","type":"string"},"spec":{"$ref":"#/components/schemas/IntervalVariableSpec"}},"required":["kind","spec"],"type":"object"},"IntervalVariableSpec":{"additionalProperties":false,"description":"Interval variable specification","properties":{"auto":{"default":false,"type":"boolean"},"auto_count":{"default":0,"type":"integer"},"auto_min":{"default":"","type":"string"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","options","auto","auto_min","auto_count","refresh","hide","skipUrlSync"],"type":"object"},"LibraryPanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"LibraryPanel","type":"string"},"spec":{"$ref":"#/components/schemas/LibraryPanelKindSpec"}},"required":["kind","spec"],"type":"object"},"LibraryPanelKindSpec":{"additionalProperties":false,"properties":{"id":{"description":"Panel ID for the library panel in the dashboard","type":"number"},"libraryPanel":{"$ref":"#/components/schemas/LibraryPanelRef"},"title":{"description":"Title for the library panel in the dashboard","type":"string"}},"required":["id","title","libraryPanel"],"type":"object"},"LibraryPanelRef":{"additionalProperties":false,"description":"A library panel is a reusable panel that you can use in any dashboard.\nWhen you make a change to a library panel, that change propagates to all instances of where the panel is used.\nLibrary panels streamline reuse of panels across multiple dashboards.","properties":{"name":{"description":"Library panel name","type":"string"},"uid":{"description":"Library panel uid","type":"string"}},"required":["name","uid"],"type":"object"},"MappingType":{"description":"Supported value mapping types\n` + "`" + `value` + "`" + `: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.\n` + "`" + `range` + "`" + `: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.\n` + "`" + `regex` + "`" + `: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.\n` + "`" + `special` + "`" + `: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A.","enum":["value","range","regex","special"],"type":"string"},"MatcherConfig":{"additionalProperties":false,"description":"Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation.\nIt comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type.","properties":{"id":{"default":"","description":"The matcher id. This is used to find the matcher implementation from registry.","type":"string"},"options":{"additionalProperties":{},"description":"The matcher options. This is specific to the matcher implementation.","type":"object"}},"required":["id"],"type":"object"},"MetricFindValue":{"additionalProperties":false,"description":"Define the MetricFindValue type","properties":{"expandable":{"type":"boolean"},"group":{"type":"string"},"text":{"type":"string"},"value":{"oneOf":[{"type":"string"},{"type":"number"}]}},"required":["text"],"type":"object"},"NullValueMode":{"description":"How null values should be handled","enum":["null","connected","null as zero"],"type":"string"},"PanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"Panel","type":"string"},"spec":{"$ref":"#/components/schemas/PanelSpec"}},"required":["kind","spec"],"type":"object"},"PanelQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"PanelQuery","type":"string"},"spec":{"$ref":"#/components/schemas/PanelQuerySpec"}},"required":["kind","spec"],"type":"object"},"PanelQuerySpec":{"additionalProperties":false,"properties":{"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"hidden":{"type":"boolean"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refId":{"type":"string"}},"required":["query","refId","hidden"],"type":"object"},"PanelSpec":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/QueryGroupKind"},"description":{"type":"string"},"id":{"type":"number"},"links":{"items":{"$ref":"#/components/schemas/DataLink"},"type":"array"},"title":{"type":"string"},"transparent":{"type":"boolean"},"vizConfig":{"$ref":"#/components/schemas/VizConfigKind"}},"required":["id","title","description","links","data","vizConfig"],"type":"object"},"QueryGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"QueryGroup","type":"string"},"spec":{"$ref":"#/components/schemas/QueryGroupSpec"}},"required":["kind","spec"],"type":"object"},"QueryGroupSpec":{"additionalProperties":false,"properties":{"queries":{"items":{"$ref":"#/components/schemas/PanelQueryKind"},"type":"array"},"queryOptions":{"$ref":"#/components/schemas/QueryOptionsSpec"},"transformations":{"items":{"$ref":"#/components/schemas/TransformationKind"},"type":"array"}},"required":["queries","transformations","queryOptions"],"type":"object"},"QueryOptionsSpec":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"string"},"hideTimeOverride":{"type":"boolean"},"interval":{"type":"string"},"maxDataPoints":{"type":"integer"},"queryCachingTTL":{"type":"integer"},"timeFrom":{"type":"string"},"timeShift":{"type":"string"}},"type":"object"},"QueryVariableKind":{"additionalProperties":false,"description":"Query variable kind","properties":{"kind":{"const":"QueryVariable","type":"string"},"spec":{"$ref":"#/components/schemas/QueryVariableSpec"}},"required":["kind","spec"],"type":"object"},"QueryVariableSpec":{"additionalProperties":false,"description":"Query variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"definition":{"type":"string"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"placeholder":{"type":"string"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"regexApplyTo":{"$ref":"#/components/schemas/VariableRegexApplyTo","default":"value"},"skipUrlSync":{"default":false,"type":"boolean"},"sort":{"$ref":"#/components/schemas/VariableSort"},"staticOptions":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"staticOptionsOrder":{"enum":["before","after","sorted"],"type":"string"}},"required":["name","current","hide","refresh","skipUrlSync","query","regex","sort","options","multi","includeAll","allowCustomValue"],"type":"object"},"RangeMap":{"additionalProperties":false,"description":"Maps numerical ranges to a display text and color.\nFor example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.","properties":{"options":{"additionalProperties":false,"description":"Range to match against and the result to apply when the value is within the range","properties":{"from":{"description":"Min value of the range. It can be null which means -Infinity","type":"number"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value is within the range"},"to":{"description":"Max value of the range. It can be null which means +Infinity","type":"number"}},"required":["from","to","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RegexMap":{"additionalProperties":false,"description":"Maps regular expressions to replacement text and a color.\nFor example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.","properties":{"options":{"additionalProperties":false,"description":"Regular expression to match against and the result to apply when the value matches the regex","properties":{"pattern":{"description":"Regular expression to match against","type":"string"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the regex"}},"required":["pattern","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RepeatMode":{"const":"variable","description":"other repeat modes will be added in the future: label, frame","type":"string"},"RepeatOptions":{"additionalProperties":false,"properties":{"direction":{"enum":["h","v"],"type":"string"},"maxPerRow":{"type":"integer"},"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayoutRow","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutRowSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowSpec":{"additionalProperties":false,"properties":{"collapse":{"type":"boolean"},"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"fillScreen":{"type":"boolean"},"hideHeader":{"type":"boolean"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/RowRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"RowsLayoutSpec":{"additionalProperties":false,"properties":{"rows":{"items":{"$ref":"#/components/schemas/RowsLayoutRowKind"},"type":"array"}},"required":["rows"],"type":"object"},"SpecialValueMap":{"additionalProperties":false,"description":"Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color.\nSee SpecialValueMatch to see the list of special values.\nFor example, you can configure a special value mapping so that null values appear as N/A.","properties":{"options":{"additionalProperties":false,"properties":{"match":{"$ref":"#/components/schemas/SpecialValueMatch","description":"Special value to match against"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the special value"}},"required":["match","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"SpecialValueMatch":{"description":"Special value types supported by the ` + "`" + `SpecialValueMap` + "`" + `","enum":["true","false","null","nan","null+nan","empty"],"type":"string"},"SwitchVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"SwitchVariable","type":"string"},"spec":{"$ref":"#/components/schemas/SwitchVariableSpec"}},"required":["kind","spec"],"type":"object"},"SwitchVariableSpec":{"additionalProperties":false,"description":"Switch variable specification","properties":{"current":{"default":"false","type":"string"},"description":{"type":"string"},"disabledValue":{"default":"false","type":"string"},"enabledValue":{"default":"true","type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","enabledValue","disabledValue","hide","skipUrlSync"],"type":"object"},"TabRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"TabsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutSpec":{"additionalProperties":false,"properties":{"tabs":{"items":{"$ref":"#/components/schemas/TabsLayoutTabKind"},"type":"array"}},"required":["tabs"],"type":"object"},"TabsLayoutTabKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayoutTab","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutTabSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutTabSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/TabRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"TextVariableKind":{"additionalProperties":false,"description":"Text variable kind","properties":{"kind":{"const":"TextVariable","type":"string"},"spec":{"$ref":"#/components/schemas/TextVariableSpec"}},"required":["kind","spec"],"type":"object"},"TextVariableSpec":{"additionalProperties":false,"description":"Text variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","query","hide","skipUrlSync"],"type":"object"},"Threshold":{"additionalProperties":false,"properties":{"color":{"type":"string"},"value":{"description":"Value null means -Infinity","type":"number"}},"required":["value","color"],"type":"object"},"ThresholdsConfig":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/ThresholdsMode"},"steps":{"items":{"$ref":"#/components/schemas/Threshold"},"type":"array"}},"required":["mode","steps"],"type":"object"},"ThresholdsMode":{"enum":["absolute","percentage"],"type":"string"},"TimeRangeOption":{"additionalProperties":false,"properties":{"display":{"default":"Last 6 hours","type":"string"},"from":{"default":"now-6h","type":"string"},"to":{"default":"now","type":"string"}},"required":["display","from","to"],"type":"object"},"TimeSettingsSpec":{"additionalProperties":false,"description":"Time configuration\nIt defines the default time config for the time picker, the refresh picker for the specific dashboard.","properties":{"autoRefresh":{"default":"","description":"Refresh rate of dashboard. Represented via interval string, e.g. \"5s\", \"1m\", \"1h\", \"1d\".\nv1: refresh","type":"string"},"autoRefreshIntervals":{"default":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"description":"Interval options available in the refresh picker dropdown.\nv1: timepicker.refresh_intervals","items":{"type":"string"},"type":"array"},"fiscalYearStartMonth":{"default":0,"description":"The month that the fiscal year starts on. 0 = January, 11 = December","type":"integer"},"from":{"default":"now-6h","description":"Start time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"hideTimepicker":{"default":false,"description":"Whether timepicker is visible or not.\nv1: timepicker.hidden","type":"boolean"},"nowDelay":{"description":"Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.\nv1: timepicker.nowDelay","type":"string"},"quickRanges":{"description":"Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.\nv1: timepicker.quick_ranges , not exposed in the UI","items":{"$ref":"#/components/schemas/TimeRangeOption"},"type":"array"},"timezone":{"default":"browser","description":"Timezone of dashboard. Accepted values are IANA TZDB zone ID or \"browser\" or \"utc\".","type":"string"},"to":{"default":"now","description":"End time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"weekStart":{"description":"Day when the week starts. Expressed by the name of the day in lowercase, e.g. \"monday\".","enum":["saturday","monday","sunday"],"type":"string"}},"required":["from","to","autoRefresh","autoRefreshIntervals","hideTimepicker","fiscalYearStartMonth"],"type":"object"},"TransformationKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a TransformationKind is the transformation ID","type":"string"},"spec":{"$ref":"#/components/schemas/DataTransformerConfig"}},"required":["kind","spec"],"type":"object"},"ValueMap":{"additionalProperties":false,"description":"Maps text values to a color or different display text and color.\nFor example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.","properties":{"options":{"additionalProperties":{"$ref":"#/components/schemas/ValueMappingResult"},"description":"Map with \u003cvalue_to_match\u003e: ValueMappingResult. For example: { \"10\": { text: \"Perfection!\", color: \"green\" } }","type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"ValueMapping":{"oneOf":[{"$ref":"#/components/schemas/ValueMap"},{"$ref":"#/components/schemas/RangeMap"},{"$ref":"#/components/schemas/RegexMap"},{"$ref":"#/components/schemas/SpecialValueMap"}]},"ValueMappingResult":{"additionalProperties":false,"description":"Result used as replacement with text and color when the value matches","properties":{"color":{"description":"Text to use when the value matches","type":"string"},"icon":{"description":"Icon to display when the value matches. Only specific visualizations.","type":"string"},"index":{"description":"Position in the mapping array. Only used internally.","type":"integer"},"text":{"description":"Text to display when the value matches","type":"string"}},"type":"object"},"VariableHide":{"description":"Determine if the variable shows on dashboard\nAccepted values are ` + "`" + `dontHide` + "`" + ` (show label and value), ` + "`" + `hideLabel` + "`" + ` (show value only), ` + "`" + `hideVariable` + "`" + ` (show nothing).","enum":["dontHide","hideLabel","hideVariable"],"type":"string"},"VariableKind":{"oneOf":[{"$ref":"#/components/schemas/QueryVariableKind"},{"$ref":"#/components/schemas/TextVariableKind"},{"$ref":"#/components/schemas/ConstantVariableKind"},{"$ref":"#/components/schemas/DatasourceVariableKind"},{"$ref":"#/components/schemas/IntervalVariableKind"},{"$ref":"#/components/schemas/CustomVariableKind"},{"$ref":"#/components/schemas/GroupByVariableKind"},{"$ref":"#/components/schemas/AdhocVariableKind"},{"$ref":"#/components/schemas/SwitchVariableKind"}]},"VariableOption":{"additionalProperties":false,"description":"Variable option specification","properties":{"selected":{"description":"Whether the option is selected or not","type":"boolean"},"text":{"description":"Text to be displayed for the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]},"value":{"description":"Value of the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]}},"required":["text","value"],"type":"object"},"VariableRefresh":{"description":"Options to config when to refresh a variable\n` + "`" + `never` + "`" + `: Never refresh the variable\n` + "`" + `onDashboardLoad` + "`" + `: Queries the data source every time the dashboard loads.\n` + "`" + `onTimeRangeChanged` + "`" + `: Queries the data source when the dashboard time range changes.","enum":["never","onDashboardLoad","onTimeRangeChanged"],"type":"string"},"VariableRegexApplyTo":{"description":"Determine whether regex applies to variable value or display text\nAccepted values are ` + "`" + `value` + "`" + ` (apply to value used in queries) or ` + "`" + `text` + "`" + ` (apply to display text shown to users)","enum":["value","text"],"type":"string"},"VariableSort":{"description":"Sort variable options\nAccepted values are:\n` + "`" + `disabled` + "`" + `: No sorting\n` + "`" + `alphabeticalAsc` + "`" + `: Alphabetical ASC\n` + "`" + `alphabeticalDesc` + "`" + `: Alphabetical DESC\n` + "`" + `numericalAsc` + "`" + `: Numerical ASC\n` + "`" + `numericalDesc` + "`" + `: Numerical DESC\n` + "`" + `alphabeticalCaseInsensitiveAsc` + "`" + `: Alphabetical Case Insensitive ASC\n` + "`" + `alphabeticalCaseInsensitiveDesc` + "`" + `: Alphabetical Case Insensitive DESC\n` + "`" + `naturalAsc` + "`" + `: Natural ASC\n` + "`" + `naturalDesc` + "`" + `: Natural DESC\nVariableSort enum with default value","enum":["disabled","alphabeticalAsc","alphabeticalDesc","numericalAsc","numericalDesc","alphabeticalCaseInsensitiveAsc","alphabeticalCaseInsensitiveDesc","naturalAsc","naturalDesc"],"type":"string"},"VizConfigKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a VizConfigKind is the plugin ID","type":"string"},"spec":{"$ref":"#/components/schemas/VizConfigSpec"}},"required":["kind","spec"],"type":"object"},"VizConfigSpec":{"additionalProperties":false,"description":"--- Kinds ---","properties":{"fieldConfig":{"$ref":"#/components/schemas/FieldConfigSource"},"options":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"pluginVersion":{"type":"string"}},"required":["pluginVersion","options","fieldConfig"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"annotations":{"items":{"$ref":"#/components/schemas/AnnotationQueryKind"},"type":"array"},"cursorSync":{"$ref":"#/components/schemas/DashboardCursorSync","default":"Off","description":"Configuration of dashboard cursor sync behavior.\n\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip."},"description":{"description":"Description of dashboard.","type":"string"},"editable":{"default":true,"description":"Whether a dashboard is editable or not.","type":"boolean"},"elements":{"additionalProperties":{"$ref":"#/components/schemas/Element"},"type":"object"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"links":{"description":"Links with references to other dashboards or external websites.","items":{"$ref":"#/components/schemas/DashboardLink"},"type":"array"},"liveNow":{"description":"When set to true, the dashboard will redraw panels at an interval matching the pixel width.\nThis will keep data \"moving left\" regardless of the query refresh rate. This setting helps\navoid dashboards presenting stale live data.","type":"boolean"},"preload":{"default":false,"description":"When set to true, the dashboard will load all panels in the dashboard when it's loaded.","type":"boolean"},"revision":{"description":"Plugins only. The version of the dashboard installed together with the plugin.\nThis is used to determine if the dashboard should be updated when the plugin is updated.","type":"integer"},"tags":{"description":"Tags associated with dashboard.","items":{"type":"string"},"type":"array"},"timeSettings":{"$ref":"#/components/schemas/TimeSettingsSpec"},"title":{"description":"Title of dashboard.","type":"string"},"variables":{"description":"Configured template variables.","items":{"$ref":"#/components/schemas/VariableKind"},"type":"array"}},"required":["annotations","cursorSync","elements","layout","links","preload","tags","timeSettings","title","variables"],"type":"object"},"status":{"additionalProperties":false,"properties":{"conversion":{"$ref":"#/components/schemas/ConversionStatus","description":"Optional conversion status."}},"type":"object"}}`) versionSchemaDashboardv2alpha1 app.VersionSchema _ = json.Unmarshal(rawSchemaDashboardv2alpha1, &versionSchemaDashboardv2alpha1) - rawSchemaDashboardv2beta1 = []byte(`{"Action":{"additionalProperties":false,"properties":{"confirmation":{"type":"string"},"fetch":{"$ref":"#/components/schemas/FetchOptions"},"infinity":{"$ref":"#/components/schemas/InfinityOptions"},"oneClick":{"type":"boolean"},"style":{"additionalProperties":false,"properties":{"backgroundColor":{"type":"string"}},"type":"object"},"title":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionType"},"variables":{"items":{"$ref":"#/components/schemas/ActionVariable"},"type":"array"}},"required":["type","title"],"type":"object"},"ActionType":{"enum":["fetch","infinity"],"type":"string"},"ActionVariable":{"additionalProperties":false,"properties":{"key":{"type":"string"},"name":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionVariableType"}},"required":["key","name","type"],"type":"object"},"ActionVariableType":{"const":"string","description":"Action variable type","type":"string"},"AdHocFilterWithLabels":{"additionalProperties":false,"description":"Define the AdHocFilterWithLabels type","properties":{"condition":{"description":"@deprecated","type":"string"},"forceEdit":{"type":"boolean"},"key":{"type":"string"},"keyLabel":{"type":"string"},"operator":{"type":"string"},"origin":{"$ref":"#/components/schemas/FilterOrigin"},"value":{"type":"string"},"valueLabels":{"items":{"type":"string"},"type":"array"},"values":{"items":{"type":"string"},"type":"array"}},"required":["key","operator","value"],"type":"object"},"AdhocVariableKind":{"additionalProperties":false,"description":"Adhoc variable kind","properties":{"datasource":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"type":"object"},"group":{"type":"string"},"kind":{"const":"AdhocVariable","type":"string"},"spec":{"$ref":"#/components/schemas/AdhocVariableSpec"}},"required":["kind","group","spec"],"type":"object"},"AdhocVariableSpec":{"additionalProperties":false,"description":"Adhoc variable specification","properties":{"allowCustomValue":{"default":true,"type":"boolean"},"baseFilters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"defaultKeys":{"items":{"$ref":"#/components/schemas/MetricFindValue"},"type":"array"},"description":{"type":"string"},"filters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","baseFilters","filters","defaultKeys","hide","skipUrlSync","allowCustomValue"],"type":"object"},"AnnotationEventFieldMapping":{"additionalProperties":false,"description":"Annotation event field mapping. Defines how to map a data frame field to an annotation event field.","properties":{"regex":{"description":"Regular expression to apply to the field value","type":"string"},"source":{"default":"field","description":"Source type for the field value","type":"string"},"value":{"description":"Constant value to use when source is \"text\"","type":"string"}},"type":"object"},"AnnotationPanelFilter":{"additionalProperties":false,"properties":{"exclude":{"default":false,"description":"Should the specified panels be included or excluded","type":"boolean"},"ids":{"description":"Panel IDs that should be included or excluded","items":{"type":"integer"},"type":"array"}},"required":["ids"],"type":"object"},"AnnotationQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"AnnotationQuery","type":"string"},"spec":{"$ref":"#/components/schemas/AnnotationQuerySpec"}},"required":["kind","spec"],"type":"object"},"AnnotationQueryPlacement":{"const":"inControlsMenu","description":"Annotation Query placement. Defines where the annotation query should be displayed.\n- \"inControlsMenu\" renders the annotation query in the dashboard controls dropdown menu","type":"string"},"AnnotationQuerySpec":{"additionalProperties":false,"properties":{"builtIn":{"default":false,"type":"boolean"},"enable":{"type":"boolean"},"filter":{"$ref":"#/components/schemas/AnnotationPanelFilter"},"hide":{"type":"boolean"},"iconColor":{"type":"string"},"legacyOptions":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"Catch-all field for datasource-specific properties. Should not be available in as code tooling.","type":"object"},"mappings":{"additionalProperties":{"$ref":"#/components/schemas/AnnotationEventFieldMapping"},"description":"Mappings define how to convert data frame fields to annotation event fields.","type":"object"},"name":{"type":"string"},"placement":{"$ref":"#/components/schemas/AnnotationQueryPlacement","description":"Placement can be used to display the annotation query somewhere else on the dashboard other than the default location."},"query":{"$ref":"#/components/schemas/DataQueryKind"}},"required":["query","enable","hide","iconColor","name"],"type":"object"},"AutoGridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutItemSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"element":{"$ref":"#/components/schemas/ElementReference"},"repeat":{"$ref":"#/components/schemas/AutoGridRepeatOptions"}},"required":["element"],"type":"object"},"AutoGridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutSpec":{"additionalProperties":false,"properties":{"columnWidth":{"type":"number"},"columnWidthMode":{"default":"standard","enum":["narrow","standard","wide","custom"],"type":"string"},"fillScreen":{"default":false,"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/AutoGridLayoutItemKind"},"type":"array"},"maxColumnCount":{"default":3,"type":"number"},"rowHeight":{"type":"number"},"rowHeightMode":{"default":"standard","enum":["short","standard","tall","custom"],"type":"string"}},"required":["columnWidthMode","rowHeightMode","items"],"type":"object"},"AutoGridRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"ConditionalRenderingDataKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingData","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingDataSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingDataSpec":{"additionalProperties":false,"properties":{"value":{"type":"boolean"}},"required":["value"],"type":"object"},"ConditionalRenderingGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingGroup","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingGroupSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingGroupSpec":{"additionalProperties":false,"properties":{"condition":{"enum":["and","or"],"type":"string"},"items":{"items":{"oneOf":[{"$ref":"#/components/schemas/ConditionalRenderingVariableKind"},{"$ref":"#/components/schemas/ConditionalRenderingDataKind"},{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeKind"}]},"type":"array"},"visibility":{"enum":["show","hide"],"type":"string"}},"required":["visibility","condition","items"],"type":"object"},"ConditionalRenderingTimeRangeSizeKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingTimeRangeSize","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingTimeRangeSizeSpec":{"additionalProperties":false,"properties":{"value":{"type":"string"}},"required":["value"],"type":"object"},"ConditionalRenderingVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingVariableSpec":{"additionalProperties":false,"properties":{"operator":{"enum":["equals","notEquals","matches","notMatches"],"type":"string"},"value":{"type":"string"},"variable":{"type":"string"}},"required":["variable","operator","value"],"type":"object"},"ConstantVariableKind":{"additionalProperties":false,"description":"Constant variable kind","properties":{"kind":{"const":"ConstantVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConstantVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConstantVariableSpec":{"additionalProperties":false,"description":"Constant variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","hide","skipUrlSync"],"type":"object"},"ConversionStatus":{"additionalProperties":false,"description":"ConversionStatus is the status of the conversion of the dashboard.","properties":{"error":{"description":"The error message from the conversion.\nEmpty if the conversion has not failed.","type":"string"},"failed":{"description":"Whether from another version has failed.\nIf true, means that the dashboard is not valid,\nand the caller should instead fetch the stored version.","type":"boolean"},"source":{"additionalProperties":{},"description":"The original value map[string]any","type":"object"},"storedVersion":{"description":"The version which was stored when the dashboard was created / updated.\nFetching this version should always succeed.","type":"string"}},"required":["failed"],"type":"object"},"CustomVariableKind":{"additionalProperties":false,"description":"Custom variable kind","properties":{"kind":{"const":"CustomVariable","type":"string"},"spec":{"$ref":"#/components/schemas/CustomVariableSpec"}},"required":["kind","spec"],"type":"object"},"CustomVariableSpec":{"additionalProperties":false,"description":"Custom variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"},"valuesFormat":{"enum":["csv","json"],"type":"string"}},"required":["name","query","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"Dashboard":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"DashboardCursorSync":{"description":"\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip.","enum":["Crosshair","Tooltip","Off"],"type":"string"},"DashboardLink":{"additionalProperties":false,"description":"Links with references to other dashboards or external resources","properties":{"asDropdown":{"default":false,"description":"If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards","type":"boolean"},"icon":{"description":"Icon name to be displayed with the link","type":"string"},"includeVars":{"default":false,"description":"If true, includes current template variables values in the link as query params","type":"boolean"},"keepTime":{"default":false,"description":"If true, includes current time range in the link as query params","type":"boolean"},"placement":{"$ref":"#/components/schemas/DashboardLinkPlacement","description":"Placement can be used to display the link somewhere else on the dashboard other than above the visualisations."},"tags":{"description":"List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards","items":{"type":"string"},"type":"array"},"targetBlank":{"default":false,"description":"If true, the link will be opened in a new tab","type":"boolean"},"title":{"description":"Title to display with the link","type":"string"},"tooltip":{"description":"Tooltip to display when the user hovers their mouse over it","type":"string"},"type":{"$ref":"#/components/schemas/DashboardLinkType","description":"Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)\nFIXME: The type is generated as ` + "`" + `type: DashboardLinkType | dashboardLinkType.Link;` + "`" + ` but it should be ` + "`" + `type: DashboardLinkType` + "`" + `"},"url":{"description":"Link URL. Only required/valid if the type is link","type":"string"}},"required":["title","type","icon","tooltip","tags","asDropdown","targetBlank","includeVars","keepTime"],"type":"object"},"DashboardLinkPlacement":{"const":"inControlsMenu","description":"Dashboard Link placement. Defines where the link should be displayed.\n- \"inControlsMenu\" renders the link in bottom part of the dashboard controls dropdown menu","type":"string"},"DashboardLinkType":{"description":"Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)","enum":["link","dashboards"],"type":"string"},"DataLink":{"additionalProperties":false,"properties":{"targetBlank":{"type":"boolean"},"title":{"type":"string"},"url":{"type":"string"}},"required":["title","url"],"type":"object"},"DataQueryKind":{"additionalProperties":false,"properties":{"datasource":{"additionalProperties":false,"description":"New type for datasource reference\nNot 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.","properties":{"name":{"type":"string"}},"type":"object"},"group":{"type":"string"},"kind":{"const":"DataQuery","type":"string"},"spec":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"version":{"default":"v0","type":"string"}},"required":["kind","group","version","spec"],"type":"object"},"DataTopic":{"description":"A topic is attached to DataFrame metadata in query results.\nThis specifies where the data should be used.","enum":["series","annotations","alertStates"],"type":"string"},"DataTransformerConfig":{"additionalProperties":false,"description":"Transformations allow to manipulate data returned by a query before the system applies a visualization.\nUsing transformations you can: rename fields, join time series data, perform mathematical operations across queries,\nuse the output of one transformation as the input to another transformation, etc.","properties":{"disabled":{"description":"Disabled transformations are skipped","type":"boolean"},"filter":{"$ref":"#/components/schemas/MatcherConfig","description":"Optional frame matcher. When missing it will be applied to all results"},"id":{"description":"Unique identifier of transformer","type":"string"},"options":{"additionalProperties":{},"description":"Options to be passed to the transformer\nValid options depend on the transformer id","type":"object"},"topic":{"$ref":"#/components/schemas/DataTopic","description":"Where to pull DataFrames from as input to transformation"}},"required":["id","options"],"type":"object"},"DatasourceVariableKind":{"additionalProperties":false,"description":"Datasource variable kind","properties":{"kind":{"const":"DatasourceVariable","type":"string"},"spec":{"$ref":"#/components/schemas/DatasourceVariableSpec"}},"required":["kind","spec"],"type":"object"},"DatasourceVariableSpec":{"additionalProperties":false,"description":"Datasource variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"pluginId":{"default":"","type":"string"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","pluginId","refresh","regex","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"DynamicConfigValue":{"additionalProperties":false,"properties":{"id":{"default":"","type":"string"},"value":{"additionalProperties":{},"type":"object"}},"required":["id"],"type":"object"},"Element":{"description":"Supported dashboard elements\n|* more element types in the future","oneOf":[{"$ref":"#/components/schemas/PanelKind"},{"$ref":"#/components/schemas/LibraryPanelKind"}]},"ElementReference":{"additionalProperties":false,"properties":{"kind":{"const":"ElementReference","type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"},"FetchOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url"],"type":"object"},"FieldColor":{"additionalProperties":false,"description":"Map a field to a color.","properties":{"fixedColor":{"description":"The fixed color value for fixed or shades color modes.","type":"string"},"mode":{"$ref":"#/components/schemas/FieldColorModeId","description":"The main color scheme mode."},"seriesBy":{"$ref":"#/components/schemas/FieldColorSeriesByMode","description":"Some visualizations need to know how to assign a series color from by value color schemes."}},"required":["mode"],"type":"object"},"FieldColorModeId":{"description":"Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value.\nContinuous color interpolates a color using the percentage of a value relative to min and max.\nAccepted values are:\n` + "`" + `thresholds` + "`" + `: From thresholds. Informs Grafana to take the color from the matching threshold\n` + "`" + `palette-classic` + "`" + `: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `palette-classic-by-name` + "`" + `: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `continuous-viridis` + "`" + `: Continuous Viridis palette mode\n` + "`" + `continuous-magma` + "`" + `: Continuous Magma palette mode\n` + "`" + `continuous-plasma` + "`" + `: Continuous Plasma palette mode\n` + "`" + `continuous-inferno` + "`" + `: Continuous Inferno palette mode\n` + "`" + `continuous-cividis` + "`" + `: Continuous Cividis palette mode\n` + "`" + `continuous-GrYlRd` + "`" + `: Continuous Green-Yellow-Red palette mode\n` + "`" + `continuous-RdYlGr` + "`" + `: Continuous Red-Yellow-Green palette mode\n` + "`" + `continuous-BlYlRd` + "`" + `: Continuous Blue-Yellow-Red palette mode\n` + "`" + `continuous-YlRd` + "`" + `: Continuous Yellow-Red palette mode\n` + "`" + `continuous-BlPu` + "`" + `: Continuous Blue-Purple palette mode\n` + "`" + `continuous-YlBl` + "`" + `: Continuous Yellow-Blue palette mode\n` + "`" + `continuous-blues` + "`" + `: Continuous Blue palette mode\n` + "`" + `continuous-reds` + "`" + `: Continuous Red palette mode\n` + "`" + `continuous-greens` + "`" + `: Continuous Green palette mode\n` + "`" + `continuous-purples` + "`" + `: Continuous Purple palette mode\n` + "`" + `shades` + "`" + `: Shades of a single color. Specify a single color, useful in an override rule.\n` + "`" + `fixed` + "`" + `: Fixed color mode. Specify a single color, useful in an override rule.","enum":["thresholds","palette-classic","palette-classic-by-name","continuous-viridis","continuous-magma","continuous-plasma","continuous-inferno","continuous-cividis","continuous-GrYlRd","continuous-RdYlGr","continuous-BlYlRd","continuous-YlRd","continuous-BlPu","continuous-YlBl","continuous-blues","continuous-reds","continuous-greens","continuous-purples","fixed","shades"],"type":"string"},"FieldColorSeriesByMode":{"description":"Defines how to assign a series color from \"by value\" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.","enum":["min","max","last"],"type":"string"},"FieldConfig":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"actions":{"description":"Define interactive HTTP requests that can be triggered from data visualizations.","items":{"$ref":"#/components/schemas/Action"},"type":"array"},"color":{"$ref":"#/components/schemas/FieldColor","description":"Panel color configuration"},"custom":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"custom is specified by the FieldConfig field\nin panel plugin schemas.","type":"object"},"decimals":{"description":"Specify the number of decimals Grafana includes in the rendered value.\nIf you leave this field blank, Grafana automatically truncates the number of decimals based on the value.\nFor example 1.1234 will display as 1.12 and 100.456 will display as 100.\nTo display all decimals, set the unit to ` + "`" + `String` + "`" + `.","type":"number"},"description":{"description":"Human readable field metadata","type":"string"},"displayName":{"description":"The display value for this field. This supports template variables blank is auto","type":"string"},"displayNameFromDS":{"description":"This can be used by data sources that return and explicit naming structure for values and labels\nWhen this property is configured, this value is used rather than the default naming strategy.","type":"string"},"filterable":{"description":"True if data source field supports ad-hoc filters","type":"boolean"},"links":{"description":"The behavior when clicking on a result","items":{"additionalProperties":{},"type":"object"},"type":"array"},"mappings":{"description":"Convert input values into a display string","items":{"$ref":"#/components/schemas/ValueMapping"},"type":"array"},"max":{"description":"The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"min":{"description":"The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"noValue":{"description":"Alternative to empty string","type":"string"},"path":{"description":"An explicit path to the field in the datasource. When the frame meta includes a path,\nThis will default to ` + "`" + `${frame.meta.path}/${field.name}\n\nWhen defined, this value can be used as an identifier within the datasource scope, and\nmay be used to update the results","type":"string"},"thresholds":{"$ref":"#/components/schemas/ThresholdsConfig","description":"Map numeric values to states"},"unit":{"description":"Unit a field should use. The unit you select is applied to all fields except time.\nYou can use the units ID availables in Grafana or a custom unit.\nAvailable units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts\nAs custom unit, you can use the following formats:\n` + "`" + `suffix:\u003csuffix\u003e` + "`" + ` for custom unit that should go after value.\n` + "`" + `prefix:\u003cprefix\u003e` + "`" + ` for custom unit that should go before value.\n` + "`" + `time:\u003cformat\u003e` + "`" + ` For custom date time formats type for example ` + "`" + `time:YYYY-MM-DD` + "`" + `.\n` + "`" + `si:\u003cbase scale\u003e\u003cunit characters\u003e` + "`" + ` for custom SI units. For example: ` + "`" + `si: mF` + "`" + `. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character.\n` + "`" + `count:\u003cunit\u003e` + "`" + ` for a custom count unit.\n` + "`" + `currency:\u003cunit\u003e` + "`" + ` for custom a currency unit.","type":"string"},"writeable":{"description":"True if data source can write a value to the path. Auth/authz are supported separately","type":"boolean"}},"type":"object"},"FieldConfigSource":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"defaults":{"$ref":"#/components/schemas/FieldConfig","description":"Defaults are the options applied to all fields."},"overrides":{"description":"Overrides are the options applied to specific fields overriding the defaults.","items":{"additionalProperties":false,"properties":{"__systemRef":{"description":"Describes config override rules created when interacting with Grafana.","type":"string"},"matcher":{"$ref":"#/components/schemas/MatcherConfig"},"properties":{"items":{"$ref":"#/components/schemas/DynamicConfigValue"},"type":"array"}},"required":["matcher","properties"],"type":"object"},"type":"array"}},"required":["defaults","overrides"],"type":"object"},"FilterOrigin":{"const":"dashboard","description":"Determine the origin of the adhoc variable filter","type":"string"},"GridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutItemSpec":{"additionalProperties":false,"properties":{"element":{"$ref":"#/components/schemas/ElementReference","description":"reference to a PanelKind from dashboard.spec.elements Expressed as JSON Schema reference"},"height":{"type":"integer"},"repeat":{"$ref":"#/components/schemas/RepeatOptions"},"width":{"type":"integer"},"x":{"type":"integer"},"y":{"type":"integer"}},"required":["x","y","width","height","element"],"type":"object"},"GridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutSpec":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/GridLayoutItemKind"},"type":"array"}},"required":["items"],"type":"object"},"GroupByVariableKind":{"additionalProperties":false,"description":"Group variable kind","properties":{"datasource":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"type":"object"},"group":{"type":"string"},"kind":{"const":"GroupByVariable","type":"string"},"spec":{"$ref":"#/components/schemas/GroupByVariableSpec"}},"required":["kind","group","spec"],"type":"object"},"GroupByVariableSpec":{"additionalProperties":false,"description":"GroupBy variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"defaultValue":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","options","multi","hide","skipUrlSync"],"type":"object"},"HttpRequestMethod":{"enum":["GET","PUT","POST","DELETE","PATCH"],"type":"string"},"InfinityOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"datasourceUid":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url","datasourceUid"],"type":"object"},"IntervalVariableKind":{"additionalProperties":false,"description":"Interval variable kind","properties":{"kind":{"const":"IntervalVariable","type":"string"},"spec":{"$ref":"#/components/schemas/IntervalVariableSpec"}},"required":["kind","spec"],"type":"object"},"IntervalVariableSpec":{"additionalProperties":false,"description":"Interval variable specification","properties":{"auto":{"default":false,"type":"boolean"},"auto_count":{"default":0,"type":"integer"},"auto_min":{"default":"","type":"string"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"refresh":{"const":"onTimeRangeChanged","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","options","auto","auto_min","auto_count","refresh","hide","skipUrlSync"],"type":"object"},"LibraryPanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"LibraryPanel","type":"string"},"spec":{"$ref":"#/components/schemas/LibraryPanelKindSpec"}},"required":["kind","spec"],"type":"object"},"LibraryPanelKindSpec":{"additionalProperties":false,"properties":{"id":{"description":"Panel ID for the library panel in the dashboard","type":"number"},"libraryPanel":{"$ref":"#/components/schemas/LibraryPanelRef"},"title":{"description":"Title for the library panel in the dashboard","type":"string"}},"required":["id","title","libraryPanel"],"type":"object"},"LibraryPanelRef":{"additionalProperties":false,"description":"A library panel is a reusable panel that you can use in any dashboard.\nWhen you make a change to a library panel, that change propagates to all instances of where the panel is used.\nLibrary panels streamline reuse of panels across multiple dashboards.","properties":{"name":{"description":"Library panel name","type":"string"},"uid":{"description":"Library panel uid","type":"string"}},"required":["name","uid"],"type":"object"},"MappingType":{"description":"Supported value mapping types\n` + "`" + `value` + "`" + `: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.\n` + "`" + `range` + "`" + `: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.\n` + "`" + `regex` + "`" + `: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.\n` + "`" + `special` + "`" + `: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A.","enum":["value","range","regex","special"],"type":"string"},"MatcherConfig":{"additionalProperties":false,"description":"Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation.\nIt comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type.","properties":{"id":{"default":"","description":"The matcher id. This is used to find the matcher implementation from registry.","type":"string"},"options":{"additionalProperties":{},"description":"The matcher options. This is specific to the matcher implementation.","type":"object"}},"required":["id"],"type":"object"},"MetricFindValue":{"additionalProperties":false,"description":"Define the MetricFindValue type","properties":{"expandable":{"type":"boolean"},"group":{"type":"string"},"text":{"type":"string"},"value":{"oneOf":[{"type":"string"},{"type":"number"}]}},"required":["text"],"type":"object"},"PanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"Panel","type":"string"},"spec":{"$ref":"#/components/schemas/PanelSpec"}},"required":["kind","spec"],"type":"object"},"PanelQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"PanelQuery","type":"string"},"spec":{"$ref":"#/components/schemas/PanelQuerySpec"}},"required":["kind","spec"],"type":"object"},"PanelQuerySpec":{"additionalProperties":false,"properties":{"hidden":{"type":"boolean"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refId":{"default":"A","type":"string"}},"required":["query","refId","hidden"],"type":"object"},"PanelSpec":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/QueryGroupKind"},"description":{"type":"string"},"id":{"type":"number"},"links":{"items":{"$ref":"#/components/schemas/DataLink"},"type":"array"},"title":{"type":"string"},"transparent":{"type":"boolean"},"vizConfig":{"$ref":"#/components/schemas/VizConfigKind"}},"required":["id","title","description","links","data","vizConfig"],"type":"object"},"QueryGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"QueryGroup","type":"string"},"spec":{"$ref":"#/components/schemas/QueryGroupSpec"}},"required":["kind","spec"],"type":"object"},"QueryGroupSpec":{"additionalProperties":false,"properties":{"queries":{"items":{"$ref":"#/components/schemas/PanelQueryKind"},"type":"array"},"queryOptions":{"$ref":"#/components/schemas/QueryOptionsSpec"},"transformations":{"items":{"$ref":"#/components/schemas/TransformationKind"},"type":"array"}},"required":["queries","transformations","queryOptions"],"type":"object"},"QueryOptionsSpec":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"string"},"hideTimeOverride":{"type":"boolean"},"interval":{"type":"string"},"maxDataPoints":{"type":"integer"},"queryCachingTTL":{"type":"integer"},"timeCompare":{"type":"string"},"timeFrom":{"type":"string"},"timeShift":{"type":"string"}},"type":"object"},"QueryVariableKind":{"additionalProperties":false,"description":"Query variable kind","properties":{"kind":{"const":"QueryVariable","type":"string"},"spec":{"$ref":"#/components/schemas/QueryVariableSpec"}},"required":["kind","spec"],"type":"object"},"QueryVariableSpec":{"additionalProperties":false,"description":"Query variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"definition":{"type":"string"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"placeholder":{"type":"string"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"regexApplyTo":{"$ref":"#/components/schemas/VariableRegexApplyTo","default":"value"},"skipUrlSync":{"default":false,"type":"boolean"},"sort":{"$ref":"#/components/schemas/VariableSort"},"staticOptions":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"staticOptionsOrder":{"enum":["before","after","sorted"],"type":"string"}},"required":["name","current","hide","refresh","skipUrlSync","query","regex","sort","options","multi","includeAll","allowCustomValue"],"type":"object"},"RangeMap":{"additionalProperties":false,"description":"Maps numerical ranges to a display text and color.\nFor example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.","properties":{"options":{"additionalProperties":false,"description":"Range to match against and the result to apply when the value is within the range","properties":{"from":{"description":"Min value of the range. It can be null which means -Infinity","type":"number"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value is within the range"},"to":{"description":"Max value of the range. It can be null which means +Infinity","type":"number"}},"required":["from","to","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RegexMap":{"additionalProperties":false,"description":"Maps regular expressions to replacement text and a color.\nFor example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.","properties":{"options":{"additionalProperties":false,"description":"Regular expression to match against and the result to apply when the value matches the regex","properties":{"pattern":{"description":"Regular expression to match against","type":"string"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the regex"}},"required":["pattern","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RepeatMode":{"const":"variable","description":"other repeat modes will be added in the future: label, frame","type":"string"},"RepeatOptions":{"additionalProperties":false,"properties":{"direction":{"enum":["h","v"],"type":"string"},"maxPerRow":{"type":"integer"},"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayoutRow","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutRowSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowSpec":{"additionalProperties":false,"properties":{"collapse":{"type":"boolean"},"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"fillScreen":{"type":"boolean"},"hideHeader":{"type":"boolean"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/RowRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"RowsLayoutSpec":{"additionalProperties":false,"properties":{"rows":{"items":{"$ref":"#/components/schemas/RowsLayoutRowKind"},"type":"array"}},"required":["rows"],"type":"object"},"SpecialValueMap":{"additionalProperties":false,"description":"Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color.\nSee SpecialValueMatch to see the list of special values.\nFor example, you can configure a special value mapping so that null values appear as N/A.","properties":{"options":{"additionalProperties":false,"properties":{"match":{"$ref":"#/components/schemas/SpecialValueMatch","description":"Special value to match against"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the special value"}},"required":["match","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"SpecialValueMatch":{"description":"Special value types supported by the ` + "`" + `SpecialValueMap` + "`" + `","enum":["true","false","null","nan","null+nan","empty"],"type":"string"},"SwitchVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"SwitchVariable","type":"string"},"spec":{"$ref":"#/components/schemas/SwitchVariableSpec"}},"required":["kind","spec"],"type":"object"},"SwitchVariableSpec":{"additionalProperties":false,"properties":{"current":{"default":"false","type":"string"},"description":{"type":"string"},"disabledValue":{"default":"false","type":"string"},"enabledValue":{"default":"true","type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","enabledValue","disabledValue","hide","skipUrlSync"],"type":"object"},"TabRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"TabsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutSpec":{"additionalProperties":false,"properties":{"tabs":{"items":{"$ref":"#/components/schemas/TabsLayoutTabKind"},"type":"array"}},"required":["tabs"],"type":"object"},"TabsLayoutTabKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayoutTab","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutTabSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutTabSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/TabRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"TextVariableKind":{"additionalProperties":false,"description":"Text variable kind","properties":{"kind":{"const":"TextVariable","type":"string"},"spec":{"$ref":"#/components/schemas/TextVariableSpec"}},"required":["kind","spec"],"type":"object"},"TextVariableSpec":{"additionalProperties":false,"description":"Text variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","query","hide","skipUrlSync"],"type":"object"},"Threshold":{"additionalProperties":false,"properties":{"color":{"type":"string"},"value":{"description":"Value null means -Infinity","type":"number"}},"required":["value","color"],"type":"object"},"ThresholdsConfig":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/ThresholdsMode"},"steps":{"items":{"$ref":"#/components/schemas/Threshold"},"type":"array"}},"required":["mode","steps"],"type":"object"},"ThresholdsMode":{"enum":["absolute","percentage"],"type":"string"},"TimeRangeOption":{"additionalProperties":false,"properties":{"display":{"default":"Last 6 hours","type":"string"},"from":{"default":"now-6h","type":"string"},"to":{"default":"now","type":"string"}},"required":["display","from","to"],"type":"object"},"TimeSettingsSpec":{"additionalProperties":false,"description":"Time configuration\nIt defines the default time config for the time picker, the refresh picker for the specific dashboard.","properties":{"autoRefresh":{"default":"","description":"Refresh rate of dashboard. Represented via interval string, e.g. \"5s\", \"1m\", \"1h\", \"1d\".\nv1: refresh","type":"string"},"autoRefreshIntervals":{"default":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"description":"Interval options available in the refresh picker dropdown.\nv1: timepicker.refresh_intervals","items":{"type":"string"},"type":"array"},"fiscalYearStartMonth":{"default":0,"description":"The month that the fiscal year starts on. 0 = January, 11 = December","type":"integer"},"from":{"default":"now-6h","description":"Start time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"hideTimepicker":{"default":false,"description":"Whether timepicker is visible or not.\nv1: timepicker.hidden","type":"boolean"},"nowDelay":{"description":"Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.\nv1: timepicker.nowDelay","type":"string"},"quickRanges":{"description":"Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.\nv1: timepicker.quick_ranges , not exposed in the UI","items":{"$ref":"#/components/schemas/TimeRangeOption"},"type":"array"},"timezone":{"default":"browser","description":"Timezone of dashboard. Accepted values are IANA TZDB zone ID or \"browser\" or \"utc\".","type":"string"},"to":{"default":"now","description":"End time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"weekStart":{"description":"Day when the week starts. Expressed by the name of the day in lowercase, e.g. \"monday\".","enum":["saturday","monday","sunday"],"type":"string"}},"required":["from","to","autoRefresh","autoRefreshIntervals","hideTimepicker","fiscalYearStartMonth"],"type":"object"},"TransformationKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a TransformationKind is the transformation ID","type":"string"},"spec":{"$ref":"#/components/schemas/DataTransformerConfig"}},"required":["kind","spec"],"type":"object"},"ValueMap":{"additionalProperties":false,"description":"Maps text values to a color or different display text and color.\nFor example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.","properties":{"options":{"additionalProperties":{"$ref":"#/components/schemas/ValueMappingResult"},"description":"Map with \u003cvalue_to_match\u003e: ValueMappingResult. For example: { \"10\": { text: \"Perfection!\", color: \"green\" } }","type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"ValueMapping":{"oneOf":[{"$ref":"#/components/schemas/ValueMap"},{"$ref":"#/components/schemas/RangeMap"},{"$ref":"#/components/schemas/RegexMap"},{"$ref":"#/components/schemas/SpecialValueMap"}]},"ValueMappingResult":{"additionalProperties":false,"description":"Result used as replacement with text and color when the value matches","properties":{"color":{"description":"Text to use when the value matches","type":"string"},"icon":{"description":"Icon to display when the value matches. Only specific visualizations.","type":"string"},"index":{"description":"Position in the mapping array. Only used internally.","type":"integer"},"text":{"description":"Text to display when the value matches","type":"string"}},"type":"object"},"VariableHide":{"description":"Determine if the variable shows on dashboard\nAccepted values are ` + "`" + `dontHide` + "`" + ` (show label and value), ` + "`" + `hideLabel` + "`" + ` (show value only), ` + "`" + `hideVariable` + "`" + ` (show nothing), ` + "`" + `inControlsMenu` + "`" + ` (show in a drop-down menu).","enum":["dontHide","hideLabel","hideVariable","inControlsMenu"],"type":"string"},"VariableKind":{"oneOf":[{"$ref":"#/components/schemas/QueryVariableKind"},{"$ref":"#/components/schemas/TextVariableKind"},{"$ref":"#/components/schemas/ConstantVariableKind"},{"$ref":"#/components/schemas/DatasourceVariableKind"},{"$ref":"#/components/schemas/IntervalVariableKind"},{"$ref":"#/components/schemas/CustomVariableKind"},{"$ref":"#/components/schemas/GroupByVariableKind"},{"$ref":"#/components/schemas/AdhocVariableKind"},{"$ref":"#/components/schemas/SwitchVariableKind"}]},"VariableOption":{"additionalProperties":false,"description":"Variable option specification","properties":{"selected":{"description":"Whether the option is selected or not","type":"boolean"},"text":{"description":"Text to be displayed for the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]},"value":{"description":"Value of the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]}},"required":["text","value"],"type":"object"},"VariableRefresh":{"description":"Options to config when to refresh a variable\n` + "`" + `never` + "`" + `: Never refresh the variable\n` + "`" + `onDashboardLoad` + "`" + `: Queries the data source every time the dashboard loads.\n` + "`" + `onTimeRangeChanged` + "`" + `: Queries the data source when the dashboard time range changes.","enum":["never","onDashboardLoad","onTimeRangeChanged"],"type":"string"},"VariableRegexApplyTo":{"description":"Determine whether regex applies to variable value or display text\nAccepted values are ` + "`" + `value` + "`" + ` (apply to value used in queries) or ` + "`" + `text` + "`" + ` (apply to display text shown to users)","enum":["value","text"],"type":"string"},"VariableSort":{"description":"Sort variable options\nAccepted values are:\n` + "`" + `disabled` + "`" + `: No sorting\n` + "`" + `alphabeticalAsc` + "`" + `: Alphabetical ASC\n` + "`" + `alphabeticalDesc` + "`" + `: Alphabetical DESC\n` + "`" + `numericalAsc` + "`" + `: Numerical ASC\n` + "`" + `numericalDesc` + "`" + `: Numerical DESC\n` + "`" + `alphabeticalCaseInsensitiveAsc` + "`" + `: Alphabetical Case Insensitive ASC\n` + "`" + `alphabeticalCaseInsensitiveDesc` + "`" + `: Alphabetical Case Insensitive DESC\n` + "`" + `naturalAsc` + "`" + `: Natural ASC\n` + "`" + `naturalDesc` + "`" + `: Natural DESC\nVariableSort enum with default value","enum":["disabled","alphabeticalAsc","alphabeticalDesc","numericalAsc","numericalDesc","alphabeticalCaseInsensitiveAsc","alphabeticalCaseInsensitiveDesc","naturalAsc","naturalDesc"],"type":"string"},"VizConfigKind":{"additionalProperties":false,"properties":{"group":{"description":"The group is the plugin ID","type":"string"},"kind":{"const":"VizConfig","type":"string"},"spec":{"$ref":"#/components/schemas/VizConfigSpec"},"version":{"type":"string"}},"required":["kind","group","version","spec"],"type":"object"},"VizConfigSpec":{"additionalProperties":false,"description":"--- Kinds ---","properties":{"fieldConfig":{"$ref":"#/components/schemas/FieldConfigSource"},"options":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"}},"required":["options","fieldConfig"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"annotations":{"items":{"$ref":"#/components/schemas/AnnotationQueryKind"},"type":"array"},"cursorSync":{"$ref":"#/components/schemas/DashboardCursorSync","default":"Off","description":"Configuration of dashboard cursor sync behavior.\n\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip."},"description":{"description":"Description of dashboard.","type":"string"},"editable":{"default":true,"description":"Whether a dashboard is editable or not.","type":"boolean"},"elements":{"additionalProperties":{"$ref":"#/components/schemas/Element"},"type":"object"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"links":{"description":"Links with references to other dashboards or external websites.","items":{"$ref":"#/components/schemas/DashboardLink"},"type":"array"},"liveNow":{"description":"When set to true, the dashboard will redraw panels at an interval matching the pixel width.\nThis will keep data \"moving left\" regardless of the query refresh rate. This setting helps\navoid dashboards presenting stale live data.","type":"boolean"},"preload":{"default":false,"description":"When set to true, the dashboard will load all panels in the dashboard when it's loaded.","type":"boolean"},"revision":{"description":"Plugins only. The version of the dashboard installed together with the plugin.\nThis is used to determine if the dashboard should be updated when the plugin is updated.","type":"integer"},"tags":{"description":"Tags associated with dashboard.","items":{"type":"string"},"type":"array"},"timeSettings":{"$ref":"#/components/schemas/TimeSettingsSpec"},"title":{"description":"Title of dashboard.","type":"string"},"variables":{"description":"Configured template variables.","items":{"$ref":"#/components/schemas/VariableKind"},"type":"array"}},"required":["annotations","cursorSync","elements","layout","links","preload","tags","timeSettings","title","variables"],"type":"object"},"status":{"additionalProperties":false,"properties":{"conversion":{"$ref":"#/components/schemas/ConversionStatus","description":"Optional conversion status."}},"type":"object"}}`) + rawSchemaDashboardv2beta1 = []byte(`{"Action":{"additionalProperties":false,"properties":{"confirmation":{"type":"string"},"fetch":{"$ref":"#/components/schemas/FetchOptions"},"infinity":{"$ref":"#/components/schemas/InfinityOptions"},"oneClick":{"type":"boolean"},"style":{"additionalProperties":false,"properties":{"backgroundColor":{"type":"string"}},"type":"object"},"title":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionType"},"variables":{"items":{"$ref":"#/components/schemas/ActionVariable"},"type":"array"}},"required":["type","title"],"type":"object"},"ActionType":{"enum":["fetch","infinity"],"type":"string"},"ActionVariable":{"additionalProperties":false,"properties":{"key":{"type":"string"},"name":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionVariableType"}},"required":["key","name","type"],"type":"object"},"ActionVariableType":{"const":"string","description":"Action variable type","type":"string"},"AdHocFilterWithLabels":{"additionalProperties":false,"description":"Define the AdHocFilterWithLabels type","properties":{"condition":{"description":"@deprecated","type":"string"},"forceEdit":{"type":"boolean"},"key":{"type":"string"},"keyLabel":{"type":"string"},"operator":{"type":"string"},"origin":{"$ref":"#/components/schemas/FilterOrigin"},"value":{"type":"string"},"valueLabels":{"items":{"type":"string"},"type":"array"},"values":{"items":{"type":"string"},"type":"array"}},"required":["key","operator","value"],"type":"object"},"AdhocVariableKind":{"additionalProperties":false,"description":"Adhoc variable kind","properties":{"datasource":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"type":"object"},"group":{"type":"string"},"kind":{"const":"AdhocVariable","type":"string"},"spec":{"$ref":"#/components/schemas/AdhocVariableSpec"}},"required":["kind","group","spec"],"type":"object"},"AdhocVariableSpec":{"additionalProperties":false,"description":"Adhoc variable specification","properties":{"allowCustomValue":{"default":true,"type":"boolean"},"baseFilters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"defaultKeys":{"items":{"$ref":"#/components/schemas/MetricFindValue"},"type":"array"},"description":{"type":"string"},"filters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","baseFilters","filters","defaultKeys","hide","skipUrlSync","allowCustomValue"],"type":"object"},"AnnotationEventFieldMapping":{"additionalProperties":false,"description":"Annotation event field mapping. Defines how to map a data frame field to an annotation event field.","properties":{"regex":{"description":"Regular expression to apply to the field value","type":"string"},"source":{"default":"field","description":"Source type for the field value","type":"string"},"value":{"description":"Constant value to use when source is \"text\"","type":"string"}},"type":"object"},"AnnotationPanelFilter":{"additionalProperties":false,"properties":{"exclude":{"default":false,"description":"Should the specified panels be included or excluded","type":"boolean"},"ids":{"description":"Panel IDs that should be included or excluded","items":{"type":"integer"},"type":"array"}},"required":["ids"],"type":"object"},"AnnotationQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"AnnotationQuery","type":"string"},"spec":{"$ref":"#/components/schemas/AnnotationQuerySpec"}},"required":["kind","spec"],"type":"object"},"AnnotationQueryPlacement":{"const":"inControlsMenu","description":"Annotation Query placement. Defines where the annotation query should be displayed.\n- \"inControlsMenu\" renders the annotation query in the dashboard controls dropdown menu","type":"string"},"AnnotationQuerySpec":{"additionalProperties":false,"properties":{"builtIn":{"default":false,"type":"boolean"},"enable":{"type":"boolean"},"filter":{"$ref":"#/components/schemas/AnnotationPanelFilter"},"hide":{"type":"boolean"},"iconColor":{"type":"string"},"legacyOptions":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"Catch-all field for datasource-specific properties. Should not be available in as code tooling.","type":"object"},"mappings":{"additionalProperties":{"$ref":"#/components/schemas/AnnotationEventFieldMapping"},"description":"Mappings define how to convert data frame fields to annotation event fields.","type":"object"},"name":{"type":"string"},"placement":{"$ref":"#/components/schemas/AnnotationQueryPlacement","description":"Placement can be used to display the annotation query somewhere else on the dashboard other than the default location."},"query":{"$ref":"#/components/schemas/DataQueryKind"}},"required":["query","enable","hide","iconColor","name"],"type":"object"},"AutoGridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutItemSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"element":{"$ref":"#/components/schemas/ElementReference"},"repeat":{"$ref":"#/components/schemas/AutoGridRepeatOptions"}},"required":["element"],"type":"object"},"AutoGridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutSpec":{"additionalProperties":false,"properties":{"columnWidth":{"type":"number"},"columnWidthMode":{"default":"standard","enum":["narrow","standard","wide","custom"],"type":"string"},"fillScreen":{"default":false,"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/AutoGridLayoutItemKind"},"type":"array"},"maxColumnCount":{"default":3,"type":"number"},"rowHeight":{"type":"number"},"rowHeightMode":{"default":"standard","enum":["short","standard","tall","custom"],"type":"string"}},"required":["columnWidthMode","rowHeightMode","items"],"type":"object"},"AutoGridRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"ConditionalRenderingDataKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingData","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingDataSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingDataSpec":{"additionalProperties":false,"properties":{"value":{"type":"boolean"}},"required":["value"],"type":"object"},"ConditionalRenderingGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingGroup","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingGroupSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingGroupSpec":{"additionalProperties":false,"properties":{"condition":{"enum":["and","or"],"type":"string"},"items":{"items":{"oneOf":[{"$ref":"#/components/schemas/ConditionalRenderingVariableKind"},{"$ref":"#/components/schemas/ConditionalRenderingDataKind"},{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeKind"}]},"type":"array"},"visibility":{"enum":["show","hide"],"type":"string"}},"required":["visibility","condition","items"],"type":"object"},"ConditionalRenderingTimeRangeSizeKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingTimeRangeSize","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingTimeRangeSizeSpec":{"additionalProperties":false,"properties":{"value":{"type":"string"}},"required":["value"],"type":"object"},"ConditionalRenderingVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingVariableSpec":{"additionalProperties":false,"properties":{"operator":{"enum":["equals","notEquals","matches","notMatches"],"type":"string"},"value":{"type":"string"},"variable":{"type":"string"}},"required":["variable","operator","value"],"type":"object"},"ConstantVariableKind":{"additionalProperties":false,"description":"Constant variable kind","properties":{"kind":{"const":"ConstantVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConstantVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConstantVariableSpec":{"additionalProperties":false,"description":"Constant variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","hide","skipUrlSync"],"type":"object"},"ConversionStatus":{"additionalProperties":false,"description":"ConversionStatus is the status of the conversion of the dashboard.","properties":{"error":{"description":"The error message from the conversion.\nEmpty if the conversion has not failed.","type":"string"},"failed":{"description":"Whether from another version has failed.\nIf true, means that the dashboard is not valid,\nand the caller should instead fetch the stored version.","type":"boolean"},"source":{"additionalProperties":{},"description":"The original value map[string]any","type":"object"},"storedVersion":{"description":"The version which was stored when the dashboard was created / updated.\nFetching this version should always succeed.","type":"string"}},"required":["failed"],"type":"object"},"CustomVariableKind":{"additionalProperties":false,"description":"Custom variable kind","properties":{"kind":{"const":"CustomVariable","type":"string"},"spec":{"$ref":"#/components/schemas/CustomVariableSpec"}},"required":["kind","spec"],"type":"object"},"CustomVariableSpec":{"additionalProperties":false,"description":"Custom variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"},"valuesFormat":{"enum":["csv","json"],"type":"string"}},"required":["name","query","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"Dashboard":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"DashboardCursorSync":{"description":"\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip.","enum":["Crosshair","Tooltip","Off"],"type":"string"},"DashboardLink":{"additionalProperties":false,"description":"Links with references to other dashboards or external resources","properties":{"asDropdown":{"default":false,"description":"If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards","type":"boolean"},"icon":{"description":"Icon name to be displayed with the link","type":"string"},"includeVars":{"default":false,"description":"If true, includes current template variables values in the link as query params","type":"boolean"},"keepTime":{"default":false,"description":"If true, includes current time range in the link as query params","type":"boolean"},"placement":{"$ref":"#/components/schemas/DashboardLinkPlacement","description":"Placement can be used to display the link somewhere else on the dashboard other than above the visualisations."},"tags":{"description":"List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards","items":{"type":"string"},"type":"array"},"targetBlank":{"default":false,"description":"If true, the link will be opened in a new tab","type":"boolean"},"title":{"description":"Title to display with the link","type":"string"},"tooltip":{"description":"Tooltip to display when the user hovers their mouse over it","type":"string"},"type":{"$ref":"#/components/schemas/DashboardLinkType","description":"Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)\nFIXME: The type is generated as ` + "`" + `type: DashboardLinkType | dashboardLinkType.Link;` + "`" + ` but it should be ` + "`" + `type: DashboardLinkType` + "`" + `"},"url":{"description":"Link URL. Only required/valid if the type is link","type":"string"}},"required":["title","type","icon","tooltip","tags","asDropdown","targetBlank","includeVars","keepTime"],"type":"object"},"DashboardLinkPlacement":{"const":"inControlsMenu","description":"Dashboard Link placement. Defines where the link should be displayed.\n- \"inControlsMenu\" renders the link in bottom part of the dashboard controls dropdown menu","type":"string"},"DashboardLinkType":{"description":"Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)","enum":["link","dashboards"],"type":"string"},"DataLink":{"additionalProperties":false,"properties":{"targetBlank":{"type":"boolean"},"title":{"type":"string"},"url":{"type":"string"}},"required":["title","url"],"type":"object"},"DataQueryKind":{"additionalProperties":false,"properties":{"datasource":{"additionalProperties":false,"description":"New type for datasource reference\nNot 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.","properties":{"name":{"type":"string"}},"type":"object"},"group":{"type":"string"},"kind":{"const":"DataQuery","type":"string"},"spec":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"version":{"default":"v0","type":"string"}},"required":["kind","group","version","spec"],"type":"object"},"DataTopic":{"description":"A topic is attached to DataFrame metadata in query results.\nThis specifies where the data should be used.","enum":["series","annotations","alertStates"],"type":"string"},"DataTransformerConfig":{"additionalProperties":false,"description":"Transformations allow to manipulate data returned by a query before the system applies a visualization.\nUsing transformations you can: rename fields, join time series data, perform mathematical operations across queries,\nuse the output of one transformation as the input to another transformation, etc.","properties":{"disabled":{"description":"Disabled transformations are skipped","type":"boolean"},"filter":{"$ref":"#/components/schemas/MatcherConfig","description":"Optional frame matcher. When missing it will be applied to all results"},"id":{"description":"Unique identifier of transformer","type":"string"},"options":{"additionalProperties":{},"description":"Options to be passed to the transformer\nValid options depend on the transformer id","type":"object"},"topic":{"$ref":"#/components/schemas/DataTopic","description":"Where to pull DataFrames from as input to transformation"}},"required":["id","options"],"type":"object"},"DatasourceVariableKind":{"additionalProperties":false,"description":"Datasource variable kind","properties":{"kind":{"const":"DatasourceVariable","type":"string"},"spec":{"$ref":"#/components/schemas/DatasourceVariableSpec"}},"required":["kind","spec"],"type":"object"},"DatasourceVariableSpec":{"additionalProperties":false,"description":"Datasource variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"pluginId":{"default":"","type":"string"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","pluginId","refresh","regex","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"DynamicConfigValue":{"additionalProperties":false,"properties":{"id":{"default":"","type":"string"},"value":{"additionalProperties":{},"type":"object"}},"required":["id"],"type":"object"},"Element":{"description":"Supported dashboard elements\n|* more element types in the future","oneOf":[{"$ref":"#/components/schemas/PanelKind"},{"$ref":"#/components/schemas/LibraryPanelKind"}]},"ElementReference":{"additionalProperties":false,"properties":{"kind":{"const":"ElementReference","type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"},"FetchOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url"],"type":"object"},"FieldColor":{"additionalProperties":false,"description":"Map a field to a color.","properties":{"fixedColor":{"description":"The fixed color value for fixed or shades color modes.","type":"string"},"mode":{"$ref":"#/components/schemas/FieldColorModeId","description":"The main color scheme mode."},"seriesBy":{"$ref":"#/components/schemas/FieldColorSeriesByMode","description":"Some visualizations need to know how to assign a series color from by value color schemes."}},"required":["mode"],"type":"object"},"FieldColorModeId":{"description":"Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value.\nContinuous color interpolates a color using the percentage of a value relative to min and max.\nAccepted values are:\n` + "`" + `thresholds` + "`" + `: From thresholds. Informs Grafana to take the color from the matching threshold\n` + "`" + `palette-classic` + "`" + `: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `palette-classic-by-name` + "`" + `: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `continuous-viridis` + "`" + `: Continuous Viridis palette mode\n` + "`" + `continuous-magma` + "`" + `: Continuous Magma palette mode\n` + "`" + `continuous-plasma` + "`" + `: Continuous Plasma palette mode\n` + "`" + `continuous-inferno` + "`" + `: Continuous Inferno palette mode\n` + "`" + `continuous-cividis` + "`" + `: Continuous Cividis palette mode\n` + "`" + `continuous-GrYlRd` + "`" + `: Continuous Green-Yellow-Red palette mode\n` + "`" + `continuous-RdYlGr` + "`" + `: Continuous Red-Yellow-Green palette mode\n` + "`" + `continuous-BlYlRd` + "`" + `: Continuous Blue-Yellow-Red palette mode\n` + "`" + `continuous-YlRd` + "`" + `: Continuous Yellow-Red palette mode\n` + "`" + `continuous-BlPu` + "`" + `: Continuous Blue-Purple palette mode\n` + "`" + `continuous-YlBl` + "`" + `: Continuous Yellow-Blue palette mode\n` + "`" + `continuous-blues` + "`" + `: Continuous Blue palette mode\n` + "`" + `continuous-reds` + "`" + `: Continuous Red palette mode\n` + "`" + `continuous-greens` + "`" + `: Continuous Green palette mode\n` + "`" + `continuous-purples` + "`" + `: Continuous Purple palette mode\n` + "`" + `shades` + "`" + `: Shades of a single color. Specify a single color, useful in an override rule.\n` + "`" + `fixed` + "`" + `: Fixed color mode. Specify a single color, useful in an override rule.","enum":["thresholds","palette-classic","palette-classic-by-name","continuous-viridis","continuous-magma","continuous-plasma","continuous-inferno","continuous-cividis","continuous-GrYlRd","continuous-RdYlGr","continuous-BlYlRd","continuous-YlRd","continuous-BlPu","continuous-YlBl","continuous-blues","continuous-reds","continuous-greens","continuous-purples","fixed","shades"],"type":"string"},"FieldColorSeriesByMode":{"description":"Defines how to assign a series color from \"by value\" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.","enum":["min","max","last"],"type":"string"},"FieldConfig":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"actions":{"description":"Define interactive HTTP requests that can be triggered from data visualizations.","items":{"$ref":"#/components/schemas/Action"},"type":"array"},"color":{"$ref":"#/components/schemas/FieldColor","description":"Panel color configuration"},"custom":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"custom is specified by the FieldConfig field\nin panel plugin schemas.","type":"object"},"decimals":{"description":"Specify the number of decimals Grafana includes in the rendered value.\nIf you leave this field blank, Grafana automatically truncates the number of decimals based on the value.\nFor example 1.1234 will display as 1.12 and 100.456 will display as 100.\nTo display all decimals, set the unit to ` + "`" + `String` + "`" + `.","type":"number"},"description":{"description":"Human readable field metadata","type":"string"},"displayName":{"description":"The display value for this field. This supports template variables blank is auto","type":"string"},"displayNameFromDS":{"description":"This can be used by data sources that return and explicit naming structure for values and labels\nWhen this property is configured, this value is used rather than the default naming strategy.","type":"string"},"fieldMinMax":{"description":"Calculate min max per field","type":"boolean"},"filterable":{"description":"True if data source field supports ad-hoc filters","type":"boolean"},"links":{"description":"The behavior when clicking on a result","items":{"additionalProperties":{},"type":"object"},"type":"array"},"mappings":{"description":"Convert input values into a display string","items":{"$ref":"#/components/schemas/ValueMapping"},"type":"array"},"max":{"description":"The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"min":{"description":"The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"noValue":{"description":"Alternative to empty string","type":"string"},"nullValueMode":{"$ref":"#/components/schemas/NullValueMode","description":"How null values should be handled when calculating field stats\n\"null\" - Include null values, \"connected\" - Ignore nulls, \"null as zero\" - Treat nulls as zero"},"path":{"description":"An explicit path to the field in the datasource. When the frame meta includes a path,\nThis will default to ` + "`" + `${frame.meta.path}/${field.name}\n\nWhen defined, this value can be used as an identifier within the datasource scope, and\nmay be used to update the results","type":"string"},"thresholds":{"$ref":"#/components/schemas/ThresholdsConfig","description":"Map numeric values to states"},"unit":{"description":"Unit a field should use. The unit you select is applied to all fields except time.\nYou can use the units ID availables in Grafana or a custom unit.\nAvailable units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts\nAs custom unit, you can use the following formats:\n` + "`" + `suffix:\u003csuffix\u003e` + "`" + ` for custom unit that should go after value.\n` + "`" + `prefix:\u003cprefix\u003e` + "`" + ` for custom unit that should go before value.\n` + "`" + `time:\u003cformat\u003e` + "`" + ` For custom date time formats type for example ` + "`" + `time:YYYY-MM-DD` + "`" + `.\n` + "`" + `si:\u003cbase scale\u003e\u003cunit characters\u003e` + "`" + ` for custom SI units. For example: ` + "`" + `si: mF` + "`" + `. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character.\n` + "`" + `count:\u003cunit\u003e` + "`" + ` for a custom count unit.\n` + "`" + `currency:\u003cunit\u003e` + "`" + ` for custom a currency unit.","type":"string"},"writeable":{"description":"True if data source can write a value to the path. Auth/authz are supported separately","type":"boolean"}},"type":"object"},"FieldConfigSource":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"defaults":{"$ref":"#/components/schemas/FieldConfig","description":"Defaults are the options applied to all fields."},"overrides":{"description":"Overrides are the options applied to specific fields overriding the defaults.","items":{"additionalProperties":false,"properties":{"__systemRef":{"description":"Describes config override rules created when interacting with Grafana.","type":"string"},"matcher":{"$ref":"#/components/schemas/MatcherConfig"},"properties":{"items":{"$ref":"#/components/schemas/DynamicConfigValue"},"type":"array"}},"required":["matcher","properties"],"type":"object"},"type":"array"}},"required":["defaults","overrides"],"type":"object"},"FilterOrigin":{"const":"dashboard","description":"Determine the origin of the adhoc variable filter","type":"string"},"GridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutItemSpec":{"additionalProperties":false,"properties":{"element":{"$ref":"#/components/schemas/ElementReference","description":"reference to a PanelKind from dashboard.spec.elements Expressed as JSON Schema reference"},"height":{"type":"integer"},"repeat":{"$ref":"#/components/schemas/RepeatOptions"},"width":{"type":"integer"},"x":{"type":"integer"},"y":{"type":"integer"}},"required":["x","y","width","height","element"],"type":"object"},"GridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutSpec":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/GridLayoutItemKind"},"type":"array"}},"required":["items"],"type":"object"},"GroupByVariableKind":{"additionalProperties":false,"description":"Group variable kind","properties":{"datasource":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"type":"object"},"group":{"type":"string"},"kind":{"const":"GroupByVariable","type":"string"},"spec":{"$ref":"#/components/schemas/GroupByVariableSpec"}},"required":["kind","group","spec"],"type":"object"},"GroupByVariableSpec":{"additionalProperties":false,"description":"GroupBy variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"defaultValue":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","options","multi","hide","skipUrlSync"],"type":"object"},"HttpRequestMethod":{"enum":["GET","PUT","POST","DELETE","PATCH"],"type":"string"},"InfinityOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"datasourceUid":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url","datasourceUid"],"type":"object"},"IntervalVariableKind":{"additionalProperties":false,"description":"Interval variable kind","properties":{"kind":{"const":"IntervalVariable","type":"string"},"spec":{"$ref":"#/components/schemas/IntervalVariableSpec"}},"required":["kind","spec"],"type":"object"},"IntervalVariableSpec":{"additionalProperties":false,"description":"Interval variable specification","properties":{"auto":{"default":false,"type":"boolean"},"auto_count":{"default":0,"type":"integer"},"auto_min":{"default":"","type":"string"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"refresh":{"const":"onTimeRangeChanged","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","options","auto","auto_min","auto_count","refresh","hide","skipUrlSync"],"type":"object"},"LibraryPanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"LibraryPanel","type":"string"},"spec":{"$ref":"#/components/schemas/LibraryPanelKindSpec"}},"required":["kind","spec"],"type":"object"},"LibraryPanelKindSpec":{"additionalProperties":false,"properties":{"id":{"description":"Panel ID for the library panel in the dashboard","type":"number"},"libraryPanel":{"$ref":"#/components/schemas/LibraryPanelRef"},"title":{"description":"Title for the library panel in the dashboard","type":"string"}},"required":["id","title","libraryPanel"],"type":"object"},"LibraryPanelRef":{"additionalProperties":false,"description":"A library panel is a reusable panel that you can use in any dashboard.\nWhen you make a change to a library panel, that change propagates to all instances of where the panel is used.\nLibrary panels streamline reuse of panels across multiple dashboards.","properties":{"name":{"description":"Library panel name","type":"string"},"uid":{"description":"Library panel uid","type":"string"}},"required":["name","uid"],"type":"object"},"MappingType":{"description":"Supported value mapping types\n` + "`" + `value` + "`" + `: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.\n` + "`" + `range` + "`" + `: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.\n` + "`" + `regex` + "`" + `: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.\n` + "`" + `special` + "`" + `: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A.","enum":["value","range","regex","special"],"type":"string"},"MatcherConfig":{"additionalProperties":false,"description":"Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation.\nIt comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type.","properties":{"id":{"default":"","description":"The matcher id. This is used to find the matcher implementation from registry.","type":"string"},"options":{"additionalProperties":{},"description":"The matcher options. This is specific to the matcher implementation.","type":"object"}},"required":["id"],"type":"object"},"MetricFindValue":{"additionalProperties":false,"description":"Define the MetricFindValue type","properties":{"expandable":{"type":"boolean"},"group":{"type":"string"},"text":{"type":"string"},"value":{"oneOf":[{"type":"string"},{"type":"number"}]}},"required":["text"],"type":"object"},"NullValueMode":{"description":"How null values should be handled","enum":["null","connected","null as zero"],"type":"string"},"PanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"Panel","type":"string"},"spec":{"$ref":"#/components/schemas/PanelSpec"}},"required":["kind","spec"],"type":"object"},"PanelQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"PanelQuery","type":"string"},"spec":{"$ref":"#/components/schemas/PanelQuerySpec"}},"required":["kind","spec"],"type":"object"},"PanelQuerySpec":{"additionalProperties":false,"properties":{"hidden":{"type":"boolean"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refId":{"default":"A","type":"string"}},"required":["query","refId","hidden"],"type":"object"},"PanelSpec":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/QueryGroupKind"},"description":{"type":"string"},"id":{"type":"number"},"links":{"items":{"$ref":"#/components/schemas/DataLink"},"type":"array"},"title":{"type":"string"},"transparent":{"type":"boolean"},"vizConfig":{"$ref":"#/components/schemas/VizConfigKind"}},"required":["id","title","description","links","data","vizConfig"],"type":"object"},"QueryGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"QueryGroup","type":"string"},"spec":{"$ref":"#/components/schemas/QueryGroupSpec"}},"required":["kind","spec"],"type":"object"},"QueryGroupSpec":{"additionalProperties":false,"properties":{"queries":{"items":{"$ref":"#/components/schemas/PanelQueryKind"},"type":"array"},"queryOptions":{"$ref":"#/components/schemas/QueryOptionsSpec"},"transformations":{"items":{"$ref":"#/components/schemas/TransformationKind"},"type":"array"}},"required":["queries","transformations","queryOptions"],"type":"object"},"QueryOptionsSpec":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"string"},"hideTimeOverride":{"type":"boolean"},"interval":{"type":"string"},"maxDataPoints":{"type":"integer"},"queryCachingTTL":{"type":"integer"},"timeCompare":{"type":"string"},"timeFrom":{"type":"string"},"timeShift":{"type":"string"}},"type":"object"},"QueryVariableKind":{"additionalProperties":false,"description":"Query variable kind","properties":{"kind":{"const":"QueryVariable","type":"string"},"spec":{"$ref":"#/components/schemas/QueryVariableSpec"}},"required":["kind","spec"],"type":"object"},"QueryVariableSpec":{"additionalProperties":false,"description":"Query variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"definition":{"type":"string"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"placeholder":{"type":"string"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"regexApplyTo":{"$ref":"#/components/schemas/VariableRegexApplyTo","default":"value"},"skipUrlSync":{"default":false,"type":"boolean"},"sort":{"$ref":"#/components/schemas/VariableSort"},"staticOptions":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"staticOptionsOrder":{"enum":["before","after","sorted"],"type":"string"}},"required":["name","current","hide","refresh","skipUrlSync","query","regex","sort","options","multi","includeAll","allowCustomValue"],"type":"object"},"RangeMap":{"additionalProperties":false,"description":"Maps numerical ranges to a display text and color.\nFor example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.","properties":{"options":{"additionalProperties":false,"description":"Range to match against and the result to apply when the value is within the range","properties":{"from":{"description":"Min value of the range. It can be null which means -Infinity","type":"number"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value is within the range"},"to":{"description":"Max value of the range. It can be null which means +Infinity","type":"number"}},"required":["from","to","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RegexMap":{"additionalProperties":false,"description":"Maps regular expressions to replacement text and a color.\nFor example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.","properties":{"options":{"additionalProperties":false,"description":"Regular expression to match against and the result to apply when the value matches the regex","properties":{"pattern":{"description":"Regular expression to match against","type":"string"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the regex"}},"required":["pattern","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RepeatMode":{"const":"variable","description":"other repeat modes will be added in the future: label, frame","type":"string"},"RepeatOptions":{"additionalProperties":false,"properties":{"direction":{"enum":["h","v"],"type":"string"},"maxPerRow":{"type":"integer"},"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayoutRow","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutRowSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowSpec":{"additionalProperties":false,"properties":{"collapse":{"type":"boolean"},"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"fillScreen":{"type":"boolean"},"hideHeader":{"type":"boolean"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/RowRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"RowsLayoutSpec":{"additionalProperties":false,"properties":{"rows":{"items":{"$ref":"#/components/schemas/RowsLayoutRowKind"},"type":"array"}},"required":["rows"],"type":"object"},"SpecialValueMap":{"additionalProperties":false,"description":"Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color.\nSee SpecialValueMatch to see the list of special values.\nFor example, you can configure a special value mapping so that null values appear as N/A.","properties":{"options":{"additionalProperties":false,"properties":{"match":{"$ref":"#/components/schemas/SpecialValueMatch","description":"Special value to match against"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the special value"}},"required":["match","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"SpecialValueMatch":{"description":"Special value types supported by the ` + "`" + `SpecialValueMap` + "`" + `","enum":["true","false","null","nan","null+nan","empty"],"type":"string"},"SwitchVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"SwitchVariable","type":"string"},"spec":{"$ref":"#/components/schemas/SwitchVariableSpec"}},"required":["kind","spec"],"type":"object"},"SwitchVariableSpec":{"additionalProperties":false,"properties":{"current":{"default":"false","type":"string"},"description":{"type":"string"},"disabledValue":{"default":"false","type":"string"},"enabledValue":{"default":"true","type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","enabledValue","disabledValue","hide","skipUrlSync"],"type":"object"},"TabRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"TabsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutSpec":{"additionalProperties":false,"properties":{"tabs":{"items":{"$ref":"#/components/schemas/TabsLayoutTabKind"},"type":"array"}},"required":["tabs"],"type":"object"},"TabsLayoutTabKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayoutTab","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutTabSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutTabSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/TabRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"TextVariableKind":{"additionalProperties":false,"description":"Text variable kind","properties":{"kind":{"const":"TextVariable","type":"string"},"spec":{"$ref":"#/components/schemas/TextVariableSpec"}},"required":["kind","spec"],"type":"object"},"TextVariableSpec":{"additionalProperties":false,"description":"Text variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","query","hide","skipUrlSync"],"type":"object"},"Threshold":{"additionalProperties":false,"properties":{"color":{"type":"string"},"value":{"description":"Value null means -Infinity","type":"number"}},"required":["value","color"],"type":"object"},"ThresholdsConfig":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/ThresholdsMode"},"steps":{"items":{"$ref":"#/components/schemas/Threshold"},"type":"array"}},"required":["mode","steps"],"type":"object"},"ThresholdsMode":{"enum":["absolute","percentage"],"type":"string"},"TimeRangeOption":{"additionalProperties":false,"properties":{"display":{"default":"Last 6 hours","type":"string"},"from":{"default":"now-6h","type":"string"},"to":{"default":"now","type":"string"}},"required":["display","from","to"],"type":"object"},"TimeSettingsSpec":{"additionalProperties":false,"description":"Time configuration\nIt defines the default time config for the time picker, the refresh picker for the specific dashboard.","properties":{"autoRefresh":{"default":"","description":"Refresh rate of dashboard. Represented via interval string, e.g. \"5s\", \"1m\", \"1h\", \"1d\".\nv1: refresh","type":"string"},"autoRefreshIntervals":{"default":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"description":"Interval options available in the refresh picker dropdown.\nv1: timepicker.refresh_intervals","items":{"type":"string"},"type":"array"},"fiscalYearStartMonth":{"default":0,"description":"The month that the fiscal year starts on. 0 = January, 11 = December","type":"integer"},"from":{"default":"now-6h","description":"Start time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"hideTimepicker":{"default":false,"description":"Whether timepicker is visible or not.\nv1: timepicker.hidden","type":"boolean"},"nowDelay":{"description":"Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.\nv1: timepicker.nowDelay","type":"string"},"quickRanges":{"description":"Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.\nv1: timepicker.quick_ranges , not exposed in the UI","items":{"$ref":"#/components/schemas/TimeRangeOption"},"type":"array"},"timezone":{"default":"browser","description":"Timezone of dashboard. Accepted values are IANA TZDB zone ID or \"browser\" or \"utc\".","type":"string"},"to":{"default":"now","description":"End time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"weekStart":{"description":"Day when the week starts. Expressed by the name of the day in lowercase, e.g. \"monday\".","enum":["saturday","monday","sunday"],"type":"string"}},"required":["from","to","autoRefresh","autoRefreshIntervals","hideTimepicker","fiscalYearStartMonth"],"type":"object"},"TransformationKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a TransformationKind is the transformation ID","type":"string"},"spec":{"$ref":"#/components/schemas/DataTransformerConfig"}},"required":["kind","spec"],"type":"object"},"ValueMap":{"additionalProperties":false,"description":"Maps text values to a color or different display text and color.\nFor example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.","properties":{"options":{"additionalProperties":{"$ref":"#/components/schemas/ValueMappingResult"},"description":"Map with \u003cvalue_to_match\u003e: ValueMappingResult. For example: { \"10\": { text: \"Perfection!\", color: \"green\" } }","type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"ValueMapping":{"oneOf":[{"$ref":"#/components/schemas/ValueMap"},{"$ref":"#/components/schemas/RangeMap"},{"$ref":"#/components/schemas/RegexMap"},{"$ref":"#/components/schemas/SpecialValueMap"}]},"ValueMappingResult":{"additionalProperties":false,"description":"Result used as replacement with text and color when the value matches","properties":{"color":{"description":"Text to use when the value matches","type":"string"},"icon":{"description":"Icon to display when the value matches. Only specific visualizations.","type":"string"},"index":{"description":"Position in the mapping array. Only used internally.","type":"integer"},"text":{"description":"Text to display when the value matches","type":"string"}},"type":"object"},"VariableHide":{"description":"Determine if the variable shows on dashboard\nAccepted values are ` + "`" + `dontHide` + "`" + ` (show label and value), ` + "`" + `hideLabel` + "`" + ` (show value only), ` + "`" + `hideVariable` + "`" + ` (show nothing), ` + "`" + `inControlsMenu` + "`" + ` (show in a drop-down menu).","enum":["dontHide","hideLabel","hideVariable","inControlsMenu"],"type":"string"},"VariableKind":{"oneOf":[{"$ref":"#/components/schemas/QueryVariableKind"},{"$ref":"#/components/schemas/TextVariableKind"},{"$ref":"#/components/schemas/ConstantVariableKind"},{"$ref":"#/components/schemas/DatasourceVariableKind"},{"$ref":"#/components/schemas/IntervalVariableKind"},{"$ref":"#/components/schemas/CustomVariableKind"},{"$ref":"#/components/schemas/GroupByVariableKind"},{"$ref":"#/components/schemas/AdhocVariableKind"},{"$ref":"#/components/schemas/SwitchVariableKind"}]},"VariableOption":{"additionalProperties":false,"description":"Variable option specification","properties":{"selected":{"description":"Whether the option is selected or not","type":"boolean"},"text":{"description":"Text to be displayed for the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]},"value":{"description":"Value of the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]}},"required":["text","value"],"type":"object"},"VariableRefresh":{"description":"Options to config when to refresh a variable\n` + "`" + `never` + "`" + `: Never refresh the variable\n` + "`" + `onDashboardLoad` + "`" + `: Queries the data source every time the dashboard loads.\n` + "`" + `onTimeRangeChanged` + "`" + `: Queries the data source when the dashboard time range changes.","enum":["never","onDashboardLoad","onTimeRangeChanged"],"type":"string"},"VariableRegexApplyTo":{"description":"Determine whether regex applies to variable value or display text\nAccepted values are ` + "`" + `value` + "`" + ` (apply to value used in queries) or ` + "`" + `text` + "`" + ` (apply to display text shown to users)","enum":["value","text"],"type":"string"},"VariableSort":{"description":"Sort variable options\nAccepted values are:\n` + "`" + `disabled` + "`" + `: No sorting\n` + "`" + `alphabeticalAsc` + "`" + `: Alphabetical ASC\n` + "`" + `alphabeticalDesc` + "`" + `: Alphabetical DESC\n` + "`" + `numericalAsc` + "`" + `: Numerical ASC\n` + "`" + `numericalDesc` + "`" + `: Numerical DESC\n` + "`" + `alphabeticalCaseInsensitiveAsc` + "`" + `: Alphabetical Case Insensitive ASC\n` + "`" + `alphabeticalCaseInsensitiveDesc` + "`" + `: Alphabetical Case Insensitive DESC\n` + "`" + `naturalAsc` + "`" + `: Natural ASC\n` + "`" + `naturalDesc` + "`" + `: Natural DESC\nVariableSort enum with default value","enum":["disabled","alphabeticalAsc","alphabeticalDesc","numericalAsc","numericalDesc","alphabeticalCaseInsensitiveAsc","alphabeticalCaseInsensitiveDesc","naturalAsc","naturalDesc"],"type":"string"},"VizConfigKind":{"additionalProperties":false,"properties":{"group":{"description":"The group is the plugin ID","type":"string"},"kind":{"const":"VizConfig","type":"string"},"spec":{"$ref":"#/components/schemas/VizConfigSpec"},"version":{"type":"string"}},"required":["kind","group","version","spec"],"type":"object"},"VizConfigSpec":{"additionalProperties":false,"description":"--- Kinds ---","properties":{"fieldConfig":{"$ref":"#/components/schemas/FieldConfigSource"},"options":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"}},"required":["options","fieldConfig"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"annotations":{"items":{"$ref":"#/components/schemas/AnnotationQueryKind"},"type":"array"},"cursorSync":{"$ref":"#/components/schemas/DashboardCursorSync","default":"Off","description":"Configuration of dashboard cursor sync behavior.\n\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip."},"description":{"description":"Description of dashboard.","type":"string"},"editable":{"default":true,"description":"Whether a dashboard is editable or not.","type":"boolean"},"elements":{"additionalProperties":{"$ref":"#/components/schemas/Element"},"type":"object"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"links":{"description":"Links with references to other dashboards or external websites.","items":{"$ref":"#/components/schemas/DashboardLink"},"type":"array"},"liveNow":{"description":"When set to true, the dashboard will redraw panels at an interval matching the pixel width.\nThis will keep data \"moving left\" regardless of the query refresh rate. This setting helps\navoid dashboards presenting stale live data.","type":"boolean"},"preload":{"default":false,"description":"When set to true, the dashboard will load all panels in the dashboard when it's loaded.","type":"boolean"},"revision":{"description":"Plugins only. The version of the dashboard installed together with the plugin.\nThis is used to determine if the dashboard should be updated when the plugin is updated.","type":"integer"},"tags":{"description":"Tags associated with dashboard.","items":{"type":"string"},"type":"array"},"timeSettings":{"$ref":"#/components/schemas/TimeSettingsSpec"},"title":{"description":"Title of dashboard.","type":"string"},"variables":{"description":"Configured template variables.","items":{"$ref":"#/components/schemas/VariableKind"},"type":"array"}},"required":["annotations","cursorSync","elements","layout","links","preload","tags","timeSettings","title","variables"],"type":"object"},"status":{"additionalProperties":false,"properties":{"conversion":{"$ref":"#/components/schemas/ConversionStatus","description":"Optional conversion status."}},"type":"object"}}`) versionSchemaDashboardv2beta1 app.VersionSchema _ = json.Unmarshal(rawSchemaDashboardv2beta1, &versionSchemaDashboardv2beta1) ) diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/annotations/v1beta1.annotation-filtering.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/annotations/v1beta1.annotation-filtering.v42.json new file mode 100644 index 00000000000..5e3d12546e1 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/annotations/v1beta1.annotation-filtering.v42.json @@ -0,0 +1,427 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + }, + { + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "enable": true, + "filter": { + "exclude": false, + "ids": [ + 1 + ] + }, + "iconColor": "red", + "name": "Red, only panel 1", + "target": { + "lines": 4, + "refId": "Anno", + "scenarioId": "annotations" + } + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "enable": true, + "filter": { + "exclude": true, + "ids": [ + 1 + ] + }, + "iconColor": "yellow", + "name": "Yellow - all except 1", + "target": { + "lines": 5, + "refId": "Anno", + "scenarioId": "annotations" + } + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "enable": true, + "filter": { + "exclude": false, + "ids": [ + 3, + 4 + ] + }, + "iconColor": "dark-purple", + "name": "Purple only panel 3+4", + "target": { + "lines": 6, + "refId": "Anno", + "scenarioId": "annotations" + } + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 119, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "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": { + "mode": "single", + "sort": "none" + } + }, + "title": "Panel one", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "title": "Panel two", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "title": "Panel three", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "title": "Panel four", + "type": "timeseries" + } + ], + "refresh": "", + "schemaVersion": 42, + "tags": [ + "gdev", + "annotations" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-30m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Annotation filtering", + "uid": "ed155665", + "weekStart": "" +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/annotations/v0alpha1.multi-lane-annotations.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/annotations/v1beta1.multi-lane-annotations.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/annotations/v0alpha1.multi-lane-annotations.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/annotations/v1beta1.multi-lane-annotations.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-elasticsearch/v0alpha1.elasticsearch_complex.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-elasticsearch/v1beta1.elasticsearch_complex.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-elasticsearch/v0alpha1.elasticsearch_complex.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-elasticsearch/v1beta1.elasticsearch_complex.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-elasticsearch/v0alpha1.elasticsearch_migration.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-elasticsearch/v1beta1.elasticsearch_migration.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-elasticsearch/v0alpha1.elasticsearch_migration.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-elasticsearch/v1beta1.elasticsearch_migration.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-elasticsearch/v0alpha1.elasticsearch_simple.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-elasticsearch/v1beta1.elasticsearch_simple.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-elasticsearch/v0alpha1.elasticsearch_simple.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-elasticsearch/v1beta1.elasticsearch_simple.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-influxdb/v0alpha1.influxdb-logs.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-influxdb/v1beta1.influxdb-logs.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-influxdb/v0alpha1.influxdb-logs.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-influxdb/v1beta1.influxdb-logs.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-influxdb/v0alpha1.influxdb-templated.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-influxdb/v1beta1.influxdb-templated.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-influxdb/v0alpha1.influxdb-templated.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-influxdb/v1beta1.influxdb-templated.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-loki/v0alpha1.loki_fakedata.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-loki/v1beta1.loki_fakedata.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-loki/v0alpha1.loki_fakedata.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-loki/v1beta1.loki_fakedata.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-loki/v0alpha1.loki_query_splitting.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-loki/v1beta1.loki_query_splitting.v42.json similarity index 98% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-loki/v0alpha1.loki_query_splitting.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-loki/v1beta1.loki_query_splitting.v42.json index a7beffa4cdc..8af239195cb 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-loki/v0alpha1.loki_query_splitting.v42.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-loki/v1beta1.loki_query_splitting.v42.json @@ -219,8 +219,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -312,8 +311,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -492,8 +490,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -584,8 +581,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -676,8 +672,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -791,8 +786,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -906,8 +900,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -1022,8 +1015,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-mssql/v0alpha1.mssql_fakedata.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-mssql/v1beta1.mssql_fakedata.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-mssql/v0alpha1.mssql_fakedata.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-mssql/v1beta1.mssql_fakedata.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-mssql/v0alpha1.mssql_unittest.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-mssql/v1beta1.mssql_unittest.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-mssql/v0alpha1.mssql_unittest.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-mssql/v1beta1.mssql_unittest.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-mysql/v0alpha1.mysql_fakedata.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-mysql/v1beta1.mysql_fakedata.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-mysql/v0alpha1.mysql_fakedata.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-mysql/v1beta1.mysql_fakedata.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-mysql/v0alpha1.mysql_unittest.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-mysql/v1beta1.mysql_unittest.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-mysql/v0alpha1.mysql_unittest.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-mysql/v1beta1.mysql_unittest.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-opentsdb/v0alpha1.opentsdb.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-opentsdb/v1beta1.opentsdb.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-opentsdb/v0alpha1.opentsdb.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-opentsdb/v1beta1.opentsdb.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-opentsdb/v0alpha1.opentsdb_v23.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-opentsdb/v1beta1.opentsdb_v23.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-opentsdb/v0alpha1.opentsdb_v23.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-opentsdb/v1beta1.opentsdb_v23.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-postgres/v0alpha1.postgres_fakedata.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-postgres/v1beta1.postgres_fakedata.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-postgres/v0alpha1.postgres_fakedata.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-postgres/v1beta1.postgres_fakedata.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-postgres/v0alpha1.postgres_unittest.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-postgres/v1beta1.postgres_unittest.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-postgres/v0alpha1.postgres_unittest.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-postgres/v1beta1.postgres_unittest.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-testdata/v0alpha1.bar-gauge-demo2.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-testdata/v1beta1.bar-gauge-demo2.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-testdata/v0alpha1.bar-gauge-demo2.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-testdata/v1beta1.bar-gauge-demo2.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-testdata/v0alpha1.demo1.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-testdata/v1beta1.demo1.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-testdata/v0alpha1.demo1.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-testdata/v1beta1.demo1.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-testdata/v0alpha1.new_features_in_v74.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-testdata/v1beta1.new_features_in_v74.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-testdata/v0alpha1.new_features_in_v74.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-testdata/v1beta1.new_features_in_v74.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-testdata/v0alpha1.new_features_in_v8.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-testdata/v1beta1.new_features_in_v8.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-testdata/v0alpha1.new_features_in_v8.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-testdata/v1beta1.new_features_in_v8.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v0alpha1.Repeating-Kitchen-Sink.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v1beta1.Repeating-Kitchen-Sink.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v0alpha1.Repeating-Kitchen-Sink.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v1beta1.Repeating-Kitchen-Sink.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v0alpha1.Repeating-a-panel-horizontally.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v1beta1.Repeating-a-panel-horizontally.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v0alpha1.Repeating-a-panel-horizontally.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v1beta1.Repeating-a-panel-horizontally.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v0alpha1.Repeating-a-panel-vertically.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v1beta1.Repeating-a-panel-vertically.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v0alpha1.Repeating-a-panel-vertically.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v1beta1.Repeating-a-panel-vertically.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v0alpha1.Repeating-a-row-with-a-repeating-horizontal-panel.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v1beta1.Repeating-a-row-with-a-repeating-horizontal-panel.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v0alpha1.Repeating-a-row-with-a-repeating-horizontal-panel.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v1beta1.Repeating-a-row-with-a-repeating-horizontal-panel.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v0alpha1.Repeating-a-row-with-a-repeating-vertical-panel.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v1beta1.Repeating-a-row-with-a-repeating-vertical-panel.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v0alpha1.Repeating-a-row-with-a-repeating-vertical-panel.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v1beta1.Repeating-a-row-with-a-repeating-vertical-panel.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v0alpha1.Repeating-an-empty-row.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v1beta1.Repeating-an-empty-row.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v0alpha1.Repeating-an-empty-row.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/v1beta1.Repeating-an-empty-row.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/extensions/v0alpha1.link-onclick-extensions.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/extensions/v1beta1.link-onclick-extensions.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/extensions/v0alpha1.link-onclick-extensions.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/extensions/v1beta1.link-onclick-extensions.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/extensions/v0alpha1.link-path-extensions.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/extensions/v1beta1.link-path-extensions.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/extensions/v0alpha1.link-path-extensions.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/extensions/v1beta1.link-path-extensions.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.datadata-macros.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.datadata-macros.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.datadata-macros.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.datadata-macros.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.global-variables-and-interpolation.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.global-variables-and-interpolation.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.global-variables-and-interpolation.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.global-variables-and-interpolation.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.templating-dashboard-links-and-variables.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.templating-dashboard-links-and-variables.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.templating-dashboard-links-and-variables.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.templating-dashboard-links-and-variables.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.templating-repeating-panels.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.templating-repeating-panels.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.templating-repeating-panels.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.templating-repeating-panels.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.templating-repeating-rows.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.templating-repeating-rows.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.templating-repeating-rows.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.templating-repeating-rows.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.templating-textbox-e2e-scenarios.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.templating-textbox-e2e-scenarios.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.templating-textbox-e2e-scenarios.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.templating-textbox-e2e-scenarios.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-datalinks.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.testdata-datalinks.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-datalinks.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.testdata-datalinks.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables-drilldown.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.testdata-nested-variables-drilldown.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables-drilldown.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.testdata-nested-variables-drilldown.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.testdata-nested-variables.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.testdata-nested-variables.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-test-variable-output.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.testdata-test-variable-output.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-test-variable-output.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.testdata-test-variable-output.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-variables-textbox.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.testdata-variables-textbox.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-variables-textbox.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.testdata-variables-textbox.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-variables-that-update-on-time-change.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.testdata-variables-that-update-on-time-change.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-variables-that-update-on-time-change.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/v1beta1.testdata-variables-that-update-on-time-change.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/live/v0alpha1.live-flakey-refresh.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/live/v1beta1.live-flakey-refresh.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/live/v0alpha1.live-flakey-refresh.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/live/v1beta1.live-flakey-refresh.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/live/v0alpha1.live-flakey.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/live/v1beta1.live-flakey.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/live/v0alpha1.live-flakey.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/live/v1beta1.live-flakey.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/live/v0alpha1.live-publish.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/live/v1beta1.live-publish.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/live/v0alpha1.live-publish.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/live/v1beta1.live-publish.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/live/v0alpha1.live-streams.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/live/v1beta1.live-streams.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/live/v0alpha1.live-streams.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/live/v1beta1.live-streams.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/migrations/v0alpha1.migrations.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/migrations/v1beta1.migrations.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/migrations/v0alpha1.migrations.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/migrations/v1beta1.migrations.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/v0alpha1.barchart-autosizing.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/v1beta1.barchart-autosizing.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/v0alpha1.barchart-autosizing.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/v1beta1.barchart-autosizing.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/v0alpha1.barchart-label-rotation-skipping.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/v1beta1.barchart-label-rotation-skipping.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/v0alpha1.barchart-label-rotation-skipping.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/v1beta1.barchart-label-rotation-skipping.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/v0alpha1.barchart-series-toggle.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/v1beta1.barchart-series-toggle.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/v0alpha1.barchart-series-toggle.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/v1beta1.barchart-series-toggle.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/v0alpha1.barchart-thresholds-mappings.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/v1beta1.barchart-thresholds-mappings.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/v0alpha1.barchart-thresholds-mappings.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/v1beta1.barchart-thresholds-mappings.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/v0alpha1.barchart-tooltips-legends.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/v1beta1.barchart-tooltips-legends.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/v0alpha1.barchart-tooltips-legends.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/v1beta1.barchart-tooltips-legends.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-bargauge/v0alpha1.bar_gauge_demo.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-bargauge/v1beta1.bar_gauge_demo.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-bargauge/v0alpha1.bar_gauge_demo.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-bargauge/v1beta1.bar_gauge_demo.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-bargauge/v0alpha1.panel_tests_bar_gauge.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-bargauge/v1beta1.panel_tests_bar_gauge.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-bargauge/v0alpha1.panel_tests_bar_gauge.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-bargauge/v1beta1.panel_tests_bar_gauge.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-bargauge/v0alpha1.panel_tests_bar_gauge2.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-bargauge/v1beta1.panel_tests_bar_gauge2.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-bargauge/v0alpha1.panel_tests_bar_gauge2.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-bargauge/v1beta1.panel_tests_bar_gauge2.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-candlestick/v0alpha1.candlestick.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-candlestick/v1beta1.candlestick.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-candlestick/v0alpha1.candlestick.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-candlestick/v1beta1.candlestick.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-canvas/v0alpha1.canvas-connection-examples.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-canvas/v1beta1.canvas-connection-examples.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-canvas/v0alpha1.canvas-connection-examples.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-canvas/v1beta1.canvas-connection-examples.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-canvas/v0alpha1.canvas-datalinks.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-canvas/v1beta1.canvas-datalinks.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-canvas/v0alpha1.canvas-datalinks.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-canvas/v1beta1.canvas-datalinks.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-canvas/v0alpha1.canvas-examples.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-canvas/v1beta1.canvas-examples.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-canvas/v0alpha1.canvas-examples.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-canvas/v1beta1.canvas-examples.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v0alpha1.auto_decimals.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v1beta1.auto_decimals.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v0alpha1.auto_decimals.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v1beta1.auto_decimals.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v0alpha1.color_modes.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v1beta1.color_modes.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v0alpha1.color_modes.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v1beta1.color_modes.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v0alpha1.lazy_loading.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v1beta1.lazy_loading.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v0alpha1.lazy_loading.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v1beta1.lazy_loading.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v0alpha1.linked-viz.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v1beta1.linked-viz.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v0alpha1.linked-viz.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v1beta1.linked-viz.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v0alpha1.panels_without_title.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v1beta1.panels_without_title.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v0alpha1.panels_without_title.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v1beta1.panels_without_title.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v0alpha1.shared_queries.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v1beta1.shared_queries.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v0alpha1.shared_queries.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/v1beta1.shared_queries.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-dashlist/v0alpha1.dashlist.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-dashlist/v1beta1.dashlist.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-dashlist/v0alpha1.dashlist.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-dashlist/v1beta1.dashlist.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-datagrid/v0alpha1.datagrid_metric_values.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-datagrid/v1beta1.datagrid_metric_values.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-datagrid/v0alpha1.datagrid_metric_values.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-datagrid/v1beta1.datagrid_metric_values.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-flamegraph/v0alpha1.panel_tests_flame_graph.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-flamegraph/v1beta1.panel_tests_flame_graph.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-flamegraph/v0alpha1.panel_tests_flame_graph.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-flamegraph/v1beta1.panel_tests_flame_graph.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge-multi-series.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v1beta1.gauge-multi-series.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge-multi-series.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v1beta1.gauge-multi-series.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v1beta1.gauge_tests.v42.json similarity index 93% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v1beta1.gauge_tests.v42.json index 92a865b0b10..b00b08dd2ab 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests.v42.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v1beta1.gauge_tests.v42.json @@ -65,17 +65,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -136,17 +133,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -207,17 +201,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -271,7 +262,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [], "max": 100, "min": 0, @@ -279,17 +269,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -342,7 +329,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [], "max": 100, "min": 0, @@ -350,17 +336,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -414,7 +397,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [], "max": 100, "min": 0, @@ -422,17 +404,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -485,7 +464,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [], "max": 100, "min": 0, @@ -493,17 +471,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -668,7 +643,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [ { "options": { @@ -685,17 +659,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { - "color": "#e24d42", - "index": 2, + "color": "#e24d42", "value": 90 } ] @@ -750,7 +721,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [ { "options": { @@ -768,17 +738,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -833,7 +800,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [ { "options": { @@ -852,17 +818,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -917,7 +880,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [ { "options": { @@ -946,17 +908,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -1038,7 +997,7 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "2", + "decimals": 2, "mappings": [], "max": 100, "min": 0, @@ -1046,17 +1005,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v1beta1.gauge_tests_new.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v1beta1.gauge_tests_new.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v1beta1.gauge_tests_old_to_new.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v1beta1.gauge_tests_old_to_new.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v0alpha1.geomap-color-field.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v1beta1.geomap-color-field.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v0alpha1.geomap-color-field.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v1beta1.geomap-color-field.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v0alpha1.geomap-photo-layer.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v1beta1.geomap-photo-layer.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v0alpha1.geomap-photo-layer.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v1beta1.geomap-photo-layer.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v0alpha1.geomap-route-layer.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v1beta1.geomap-route-layer.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v0alpha1.geomap-route-layer.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v1beta1.geomap-route-layer.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v0alpha1.geomap-spatial-operations-transformer.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v1beta1.geomap-spatial-operations-transformer.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v0alpha1.geomap-spatial-operations-transformer.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v1beta1.geomap-spatial-operations-transformer.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v0alpha1.geomap-v91.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v1beta1.geomap-v91.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v0alpha1.geomap-v91.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v1beta1.geomap-v91.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v0alpha1.geomap_multi-layers.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v1beta1.geomap_multi-layers.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v0alpha1.geomap_multi-layers.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v1beta1.geomap_multi-layers.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v0alpha1.panel-geomap.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v1beta1.panel-geomap.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v0alpha1.panel-geomap.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/v1beta1.panel-geomap.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/v0alpha1.graph-gradient-area-fills.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/v1beta1.graph-gradient-area-fills.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/v0alpha1.graph-gradient-area-fills.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/v1beta1.graph-gradient-area-fills.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/v0alpha1.graph-shared-tooltips.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/v1beta1.graph-shared-tooltips.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/v0alpha1.graph-shared-tooltips.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/v1beta1.graph-shared-tooltips.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/v0alpha1.graph-time-regions.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/v1beta1.graph-time-regions.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/v0alpha1.graph-time-regions.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/v1beta1.graph-time-regions.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/v0alpha1.graph_tests.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/v1beta1.graph_tests.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/v0alpha1.graph_tests.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/v1beta1.graph_tests.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/v0alpha1.graph_y_axis.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/v1beta1.graph_y_axis.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/v0alpha1.graph_y_axis.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/v1beta1.graph_y_axis.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-calculate-log.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-heatmap/v1beta1.heatmap-calculate-log.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-calculate-log.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-heatmap/v1beta1.heatmap-calculate-log.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-legacy.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-heatmap/v1beta1.heatmap-legacy.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-legacy.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-heatmap/v1beta1.heatmap-legacy.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-x.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-heatmap/v1beta1.heatmap-x.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-x.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-heatmap/v1beta1.heatmap-x.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-histogram/v0alpha1.histogram_tests.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-histogram/v1beta1.histogram_tests.v42.json similarity index 99% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-histogram/v0alpha1.histogram_tests.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-histogram/v1beta1.histogram_tests.v42.json index 7e392bd55d0..5b3e0ed0b72 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-histogram/v0alpha1.histogram_tests.v42.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-histogram/v1beta1.histogram_tests.v42.json @@ -58,8 +58,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -127,8 +126,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -196,8 +194,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -277,8 +274,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -355,8 +351,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -448,8 +443,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -536,8 +530,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [ { @@ -619,8 +612,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [ { @@ -702,8 +694,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [ { @@ -785,8 +776,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -850,8 +840,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [ { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-library/v0alpha1.panel-library.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-library/v1beta1.panel-library.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-library/v0alpha1.panel-library.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-library/v1beta1.panel-library.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-piechart/v0alpha1.panel_test_piechart.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-piechart/v1beta1.panel_test_piechart.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-piechart/v0alpha1.panel_test_piechart.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-piechart/v1beta1.panel_test_piechart.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-polystat/v0alpha1.polystat_test.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-polystat/v1beta1.polystat_test.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-polystat/v0alpha1.polystat_test.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-polystat/v1beta1.polystat_test.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-stat/v0alpha1.panel-stat-tests.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-stat/v1beta1.panel-stat-tests.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-stat/v0alpha1.panel-stat-tests.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-stat/v1beta1.panel-stat-tests.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-status-history/v0alpha1.status-history-thresholds-mappings.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-status-history/v1beta1.status-history-thresholds-mappings.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-status-history/v0alpha1.status-history-thresholds-mappings.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-status-history/v1beta1.status-history-thresholds-mappings.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v0alpha1.table_footer.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v1beta1.table_footer.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v0alpha1.table_footer.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v1beta1.table_footer.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v0alpha1.table_kitchen_sink.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v1beta1.table_kitchen_sink.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v0alpha1.table_kitchen_sink.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v1beta1.table_kitchen_sink.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v0alpha1.table_markdown.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v1beta1.table_markdown.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v0alpha1.table_markdown.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v1beta1.table_markdown.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v0alpha1.table_pagination.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v1beta1.table_pagination.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v0alpha1.table_pagination.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v1beta1.table_pagination.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v0alpha1.table_sparkline_cell.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v1beta1.table_sparkline_cell.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v0alpha1.table_sparkline_cell.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v1beta1.table_sparkline_cell.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v0alpha1.table_tests.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v1beta1.table_tests.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v0alpha1.table_tests.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v1beta1.table_tests.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v0alpha1.table_tests_new.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v1beta1.table_tests_new.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v0alpha1.table_tests_new.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v1beta1.table_tests_new.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v0alpha1.table_v12_2_migrations.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v1beta1.table_v12_2_migrations.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v0alpha1.table_v12_2_migrations.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/v1beta1.table_v12_2_migrations.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-text/v0alpha1.text-options.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-text/v1beta1.text-options.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-text/v0alpha1.text-options.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-text/v1beta1.text-options.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/v0alpha1.timeline-align-endtime.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/v1beta1.timeline-align-endtime.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/v0alpha1.timeline-align-endtime.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/v1beta1.timeline-align-endtime.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/v0alpha1.timeline-align-nulls-retain.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/v1beta1.timeline-align-nulls-retain.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/v0alpha1.timeline-align-nulls-retain.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/v1beta1.timeline-align-nulls-retain.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/v0alpha1.timeline-demo.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/v1beta1.timeline-demo.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/v0alpha1.timeline-demo.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/v1beta1.timeline-demo.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/v0alpha1.timeline-modes.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/v1beta1.timeline-modes.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/v0alpha1.timeline-modes.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/v1beta1.timeline-modes.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/v0alpha1.timeline-thresholds-mappings.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/v1beta1.timeline-thresholds-mappings.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/v0alpha1.timeline-thresholds-mappings.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/v1beta1.timeline-thresholds-mappings.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-bars-high-density.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-bars-high-density.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-bars-high-density.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-bars-high-density.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-by-value-color-schemes.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-by-value-color-schemes.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-by-value-color-schemes.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-by-value-color-schemes.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-formats.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-formats.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-formats.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-formats.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-gradient-area.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-gradient-area.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-gradient-area.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-gradient-area.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-hue-gradients.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-hue-gradients.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-hue-gradients.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-hue-gradients.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-nulls.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-nulls.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-nulls.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-nulls.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-out-of-rage.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-out-of-rage.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-out-of-rage.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-out-of-rage.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-shared-tooltip-cursor-position.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-shared-tooltip-cursor-position.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-shared-tooltip-cursor-position.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-shared-tooltip-cursor-position.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-soft-limits.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-soft-limits.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-soft-limits.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-soft-limits.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-stacking.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-stacking.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-stacking.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-stacking.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-stacking2.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-stacking2.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-stacking2.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-stacking2.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-thresholds.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-thresholds.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-thresholds.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-thresholds.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-time.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-time.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-time.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-time.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-y-ticks-zero-decimals.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-y-ticks-zero-decimals.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-y-ticks-zero-decimals.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-y-ticks-zero-decimals.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-yaxis-ticks.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-yaxis-ticks.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-yaxis-ticks.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries-yaxis-ticks.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/v1beta1.timeseries.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-trend/v0alpha1.trend_example.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-trend/v1beta1.trend_example.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-trend/v0alpha1.trend_example.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-trend/v1beta1.trend_example.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-xychart/v0alpha1.xychart-demo.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-xychart/v1beta1.xychart-demo.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-xychart/v0alpha1.xychart-demo.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-xychart/v1beta1.xychart-demo.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-xychart/v0alpha1.xychart-migrations.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-xychart/v1beta1.xychart-migrations.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-xychart/v0alpha1.xychart-migrations.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-xychart/v1beta1.xychart-migrations.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-xychart/v0alpha1.xychart-tooltip-color-test.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-xychart/v1beta1.xychart-tooltip-color-test.v42.json similarity index 98% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-xychart/v0alpha1.xychart-tooltip-color-test.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-xychart/v1beta1.xychart-tooltip-color-test.v42.json index 417ea1661e1..f28fee864e5 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-xychart/v0alpha1.xychart-tooltip-color-test.v42.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-xychart/v1beta1.xychart-tooltip-color-test.v42.json @@ -61,8 +61,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -148,8 +147,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -235,8 +233,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -322,8 +319,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -416,8 +412,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -510,8 +505,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -604,8 +598,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/v0alpha1.mostly-blank-dashboard.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/v1beta1.mostly-blank-dashboard.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/v0alpha1.mostly-blank-dashboard.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/v1beta1.mostly-blank-dashboard.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/v0alpha1.relative_time_zone_support.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/v1beta1.relative_time_zone_support.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/v0alpha1.relative_time_zone_support.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/v1beta1.relative_time_zone_support.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/v0alpha1.slow_queries_and_annotations.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/v1beta1.slow_queries_and_annotations.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/v0alpha1.slow_queries_and_annotations.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/v1beta1.slow_queries_and_annotations.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/v0alpha1.tall_dashboard.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/v1beta1.tall_dashboard.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/v0alpha1.tall_dashboard.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/v1beta1.tall_dashboard.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/v0alpha1.time_zone_support.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/v1beta1.time_zone_support.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/v0alpha1.time_zone_support.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/v1beta1.time_zone_support.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v0alpha1.config-from-query.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v1beta1.config-from-query.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v0alpha1.config-from-query.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v1beta1.config-from-query.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v0alpha1.extract-json-paths.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v1beta1.extract-json-paths.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v0alpha1.extract-json-paths.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v1beta1.extract-json-paths.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v0alpha1.filter.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v1beta1.filter.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v0alpha1.filter.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v1beta1.filter.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v0alpha1.join-by-field.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v1beta1.join-by-field.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v0alpha1.join-by-field.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v1beta1.join-by-field.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v0alpha1.join-by-labels.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v1beta1.join-by-labels.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v0alpha1.join-by-labels.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v1beta1.join-by-labels.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v0alpha1.regression-analysis.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v1beta1.regression-analysis.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v0alpha1.regression-analysis.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v1beta1.regression-analysis.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v0alpha1.reuse.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v1beta1.reuse.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v0alpha1.reuse.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v1beta1.reuse.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v0alpha1.rows-to-fields.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v1beta1.rows-to-fields.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v0alpha1.rows-to-fields.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/v1beta1.rows-to-fields.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/v0alpha1.v1beta1.v1beta1.all-panels.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/v1beta1.v1beta1.v1beta1.all-panels.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/v0alpha1.v1beta1.v1beta1.all-panels.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/v1beta1.v1beta1.v1beta1.all-panels.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/v0alpha1.v1beta1.v1beta1.home.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/v1beta1.v1beta1.v1beta1.home.v42.json similarity index 100% rename from apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/v0alpha1.v1beta1.v1beta1.home.v42.json rename to apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/v1beta1.v1beta1.v1beta1.home.v42.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.legacy-ds-ref.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.legacy-ds-ref.json new file mode 100644 index 00000000000..7bb2bc70f81 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.legacy-ds-ref.json @@ -0,0 +1,287 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v1beta1", + "metadata": { + "name": "legacy-ds-ref" + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "panels": [ + { + "datasource": "${datasource}", + "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", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Minimum cluster size" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "custom.lineStyle", + "value": { + "dash": [10, 10], + "fill": "dash" + } + }, + { + "id": "custom.lineWidth", + "value": 1 + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 0 + }, + "id": 16, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "timeCompare": false, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": "${datasource}", + "editorMode": "code", + "expr": "count by (version) (alloy_build_info{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\"})", + "instant": false, + "legendFormat": "{{version}}", + "range": true, + "refId": "B" + } + ], + "title": "Number of Alloy Instances", + "type": "timeseries" + }, + { + "datasource": "${datasource}", + "description": "CPU usage of the Alloy process relative to 1 CPU core.\n\nFor example, 100% means using one entire CPU core.\n", + "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", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [ + { + "__systemRef": "hideSeriesFrom", + "matcher": { + "id": "byNames", + "options": { + "mode": "exclude", + "names": [ + "Total" + ], + "prefix": "All except:", + "readOnly": true + } + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": false, + "tooltip": true, + "viz": true + } + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 0 + }, + "id": 17, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "timeCompare": false, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": "${datasource}", + "expr": "rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])\n", + "hide": true, + "instant": false, + "legendFormat": "{{instance}}", + "range": true, + "refId": "A" + }, + { + "datasource": "${datasource}", + "editorMode": "code", + "expr": "sum(rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "Total", + "range": true, + "refId": "B" + } + ], + "title": "CPU usage", + "type": "timeseries" + } + ], + "time": { + "from": "now-90m", + "to": "now" + }, + "timezone": "utc", + "title": "Legacy DS Panel Query Ref", + "weekStart": "" + } +} + + \ 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 index a27c33c3239..ac88b83370f 100644 --- 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 @@ -852,6 +852,194 @@ } } } + }, + "panel-7": { + "kind": "Panel", + "spec": { + "id": 7, + "title": "Single Dashboard DS Query", + "description": "Panel with a single -- Dashboard -- datasource query", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "stat", + "spec": { + "pluginVersion": "12.1.0-pre", + "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" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } + }, + "panel-8": { + "kind": "Panel", + "spec": { + "id": 8, + "title": "Multiple Dashboard DS Queries", + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "A", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 2, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "B", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 3, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "C", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "stat", + "spec": { + "pluginVersion": "12.1.0-pre", + "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" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } } }, "layout": { @@ -914,6 +1102,24 @@ "name": "panel-6" } } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-7" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-8" + } + } } ] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v2beta1.ds-data-query.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v2beta1.ds-data-query.json index 0c787609714..fad72787d19 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/input/v2beta1.ds-data-query.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v2beta1.ds-data-query.json @@ -879,6 +879,200 @@ } } } + }, + "panel-7": { + "kind": "Panel", + "spec": { + "id": 7, + "title": "Single Dashboard DS Query", + "description": "Panel with a single -- Dashboard -- datasource query", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "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" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } + }, + "panel-8": { + "kind": "Panel", + "spec": { + "id": 8, + "title": "Multiple Dashboard DS Queries", + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "refId": "A", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 2, + "withTransforms": true + } + }, + "refId": "B", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 3, + "withTransforms": true + } + }, + "refId": "C", + "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" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } } }, "layout": { @@ -973,6 +1167,32 @@ "name": "panel-6" } } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 6, + "width": 8, + "height": 3, + "element": { + "kind": "ElementReference", + "name": "panel-7" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 8, + "y": 6, + "width": 8, + "height": 3, + "element": { + "kind": "ElementReference", + "name": "panel-8" + } + } } ] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/annotations/v0alpha1.multi-lane-annotations.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/annotations/v0alpha1.multi-lane-annotations.v42.v2alpha1.json index b6addcc81ed..d6f207c6fdd 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/annotations/v0alpha1.multi-lane-annotations.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/annotations/v0alpha1.multi-lane-annotations.v42.v2alpha1.json @@ -970,8 +970,7 @@ "le": 1e-9 }, "legend": { - "show": true, - "showLegend": true + "show": true }, "rowsFrame": { "layout": "auto" @@ -1064,8 +1063,7 @@ "le": 1e-9 }, "legend": { - "show": true, - "showLegend": true + "show": true }, "rowsFrame": { "layout": "auto" diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/annotations/v0alpha1.multi-lane-annotations.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/annotations/v0alpha1.multi-lane-annotations.v42.v2beta1.json index f806e27a98f..c7ef28fa2b8 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/annotations/v0alpha1.multi-lane-annotations.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/annotations/v0alpha1.multi-lane-annotations.v42.v2beta1.json @@ -991,8 +991,7 @@ "le": 1e-9 }, "legend": { - "show": true, - "showLegend": true + "show": true }, "rowsFrame": { "layout": "auto" @@ -1087,8 +1086,7 @@ "le": 1e-9 }, "legend": { - "show": true, - "showLegend": true + "show": true }, "rowsFrame": { "layout": "auto" diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-loki/v0alpha1.loki_query_splitting.v42.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-loki/v0alpha1.loki_query_splitting.v42.v1beta1.json index aed8292522f..3cb966ba891 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-loki/v0alpha1.loki_query_splitting.v42.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-loki/v0alpha1.loki_query_splitting.v42.v1beta1.json @@ -225,8 +225,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -318,8 +317,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -498,8 +496,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -590,8 +587,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -682,8 +678,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -797,8 +792,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -912,8 +906,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -1028,8 +1021,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2alpha1.json index b1dbd3de041..4d208a1d8dc 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2alpha1.json @@ -467,7 +467,8 @@ "title": "Go to drilldown", "url": "/d/O6GmNPvWk/dashboard-tests-nested-template-variables-drilldown?orgId=1\u0026${__all_variables}\u0026${__url_time_range}" } - ] + ], + "nullValueMode": "connected" }, "overrides": [] } @@ -550,7 +551,8 @@ "title": "Go to drilldown", "url": "/d/O6GmNPvWk/dashboard-tests-nested-template-variables-drilldown?orgId=1\u0026${__all_variables}\u0026${__url_time_range}" } - ] + ], + "nullValueMode": "connected" }, "overrides": [] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2beta1.json index 9089dd1d1fb..5165f97554d 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2beta1.json @@ -481,7 +481,8 @@ "title": "Go to drilldown", "url": "/d/O6GmNPvWk/dashboard-tests-nested-template-variables-drilldown?orgId=1\u0026${__all_variables}\u0026${__url_time_range}" } - ] + ], + "nullValueMode": "connected" }, "overrides": [] } @@ -566,7 +567,8 @@ "title": "Go to drilldown", "url": "/d/O6GmNPvWk/dashboard-tests-nested-template-variables-drilldown?orgId=1\u0026${__all_variables}\u0026${__url_time_range}" } - ] + ], + "nullValueMode": "connected" }, "overrides": [] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-x.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-x.v42.v2alpha1.json index 2eb67e36f2f..a1b9c4b230a 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-x.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-x.v42.v2alpha1.json @@ -169,8 +169,7 @@ "le": 1e-9 }, "legend": { - "show": true, - "showLegend": true + "show": true }, "rowsFrame": { "layout": "auto" @@ -336,8 +335,7 @@ "le": 1e-9 }, "legend": { - "show": true, - "showLegend": true + "show": true }, "rowsFrame": { "layout": "auto" @@ -408,8 +406,7 @@ "le": 1e-9 }, "legend": { - "show": true, - "showLegend": true + "show": true }, "rowsFrame": { "layout": "auto" diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-x.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-x.v42.v2beta1.json index acba4cedbc2..2428d6fd107 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-x.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-x.v42.v2beta1.json @@ -175,8 +175,7 @@ "le": 1e-9 }, "legend": { - "show": true, - "showLegend": true + "show": true }, "rowsFrame": { "layout": "auto" @@ -347,8 +346,7 @@ "le": 1e-9 }, "legend": { - "show": true, - "showLegend": true + "show": true }, "rowsFrame": { "layout": "auto" @@ -420,8 +418,7 @@ "le": 1e-9 }, "legend": { - "show": true, - "showLegend": true + "show": true }, "rowsFrame": { "layout": "auto" diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-histogram/v0alpha1.histogram_tests.v42.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-histogram/v0alpha1.histogram_tests.v42.v1beta1.json index b65608bc758..c3435f0f17d 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-histogram/v0alpha1.histogram_tests.v42.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-histogram/v0alpha1.histogram_tests.v42.v1beta1.json @@ -64,8 +64,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -133,8 +132,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -202,8 +200,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -283,8 +280,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -361,8 +357,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -454,8 +449,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -542,8 +536,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [ { @@ -625,8 +618,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [ { @@ -708,8 +700,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [ { @@ -791,8 +782,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -856,8 +846,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [ { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-histogram/v0alpha1.histogram_tests.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-histogram/v0alpha1.histogram_tests.v42.v2alpha1.json index 57fbcad9d99..ec5c52a7119 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-histogram/v0alpha1.histogram_tests.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-histogram/v0alpha1.histogram_tests.v42.v2alpha1.json @@ -882,6 +882,7 @@ "kind": "filterFieldsByName", "spec": { "id": "filterFieldsByName", + "disabled": true, "options": { "include": { "names": [ @@ -895,6 +896,7 @@ "kind": "histogram", "spec": { "id": "histogram", + "disabled": true, "options": { "combine": true, "fields": {} diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-histogram/v0alpha1.histogram_tests.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-histogram/v0alpha1.histogram_tests.v42.v2beta1.json index 5b2ee8d8df2..3ff41469dbc 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-histogram/v0alpha1.histogram_tests.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-histogram/v0alpha1.histogram_tests.v42.v2beta1.json @@ -911,6 +911,7 @@ "kind": "filterFieldsByName", "spec": { "id": "filterFieldsByName", + "disabled": true, "options": { "include": { "names": [ @@ -924,6 +925,7 @@ "kind": "histogram", "spec": { "id": "histogram", + "disabled": true, "options": { "combine": true, "fields": {} diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-status-history/v0alpha1.status-history-thresholds-mappings.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-status-history/v0alpha1.status-history-thresholds-mappings.v42.v2alpha1.json index d90c8dc52cd..b9ba0b13da4 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-status-history/v0alpha1.status-history-thresholds-mappings.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-status-history/v0alpha1.status-history-thresholds-mappings.v42.v2alpha1.json @@ -222,7 +222,8 @@ "insertNulls": false, "lineWidth": 0, "spanNulls": false - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -318,7 +319,8 @@ "insertNulls": false, "lineWidth": 0, "spanNulls": false - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -424,7 +426,8 @@ "insertNulls": false, "lineWidth": 0, "spanNulls": false - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -507,7 +510,8 @@ "insertNulls": false, "lineWidth": 0, "spanNulls": false - } + }, + "fieldMinMax": false }, "overrides": [] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-status-history/v0alpha1.status-history-thresholds-mappings.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-status-history/v0alpha1.status-history-thresholds-mappings.v42.v2beta1.json index 7aaa0fff33a..e130fc7e172 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-status-history/v0alpha1.status-history-thresholds-mappings.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-status-history/v0alpha1.status-history-thresholds-mappings.v42.v2beta1.json @@ -229,7 +229,8 @@ "insertNulls": false, "lineWidth": 0, "spanNulls": false - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -328,7 +329,8 @@ "insertNulls": false, "lineWidth": 0, "spanNulls": false - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -437,7 +439,8 @@ "insertNulls": false, "lineWidth": 0, "spanNulls": false - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -523,7 +526,8 @@ "insertNulls": false, "lineWidth": 0, "spanNulls": false - } + }, + "fieldMinMax": false }, "overrides": [] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_footer.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_footer.v42.v2alpha1.json index 540f0d9e54d..9d15475c82d 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_footer.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_footer.v42.v2alpha1.json @@ -167,7 +167,8 @@ }, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -361,7 +362,8 @@ }, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -596,7 +598,8 @@ }, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -787,7 +790,8 @@ }, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -974,7 +978,8 @@ }, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1181,7 +1186,8 @@ }, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1384,7 +1390,8 @@ }, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1573,7 +1580,8 @@ }, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_footer.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_footer.v42.v2beta1.json index 6c9aa023163..3965312c00a 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_footer.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_footer.v42.v2beta1.json @@ -173,7 +173,8 @@ }, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -372,7 +373,8 @@ }, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -612,7 +614,8 @@ }, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -808,7 +811,8 @@ }, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1000,7 +1004,8 @@ }, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1212,7 +1217,8 @@ }, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1420,7 +1426,8 @@ }, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1614,7 +1621,8 @@ }, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_kitchen_sink.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_kitchen_sink.v42.v2alpha1.json index bccce10d162..f342cab8373 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_kitchen_sink.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_kitchen_sink.v42.v2alpha1.json @@ -194,7 +194,8 @@ "filterable": true, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1190,7 +1191,8 @@ "reducer": [] }, "inspect": true - } + }, + "fieldMinMax": true }, "overrides": [] } @@ -1262,7 +1264,8 @@ "type": "auto" }, "inspect": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1418,7 +1421,8 @@ "type": "auto" }, "inspect": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1575,7 +1579,8 @@ "type": "auto" }, "inspect": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1737,7 +1742,8 @@ "type": "auto" }, "inspect": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1888,7 +1894,8 @@ "type": "auto" }, "inspect": false - } + }, + "fieldMinMax": true }, "overrides": [ { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_kitchen_sink.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_kitchen_sink.v42.v2beta1.json index 5e186ef1443..59f9b3d7942 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_kitchen_sink.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_kitchen_sink.v42.v2beta1.json @@ -200,7 +200,8 @@ "filterable": true, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1208,7 +1209,8 @@ "reducer": [] }, "inspect": true - } + }, + "fieldMinMax": true }, "overrides": [] } @@ -1283,7 +1285,8 @@ "type": "auto" }, "inspect": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1442,7 +1445,8 @@ "type": "auto" }, "inspect": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1602,7 +1606,8 @@ "type": "auto" }, "inspect": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1767,7 +1772,8 @@ "type": "auto" }, "inspect": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1921,7 +1927,8 @@ "type": "auto" }, "inspect": false - } + }, + "fieldMinMax": true }, "overrides": [ { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_sparkline_cell.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_sparkline_cell.v42.v2alpha1.json index e5e260fd150..92729fdddcb 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_sparkline_cell.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_sparkline_cell.v42.v2alpha1.json @@ -302,6 +302,23 @@ "url": "https://google.com/search?q=grafana" } ], + "actions": [ + { + "type": "fetch", + "title": "Get instance health", + "fetch": { + "method": "GET", + "url": "/api/health", + "body": "{}", + "headers": [ + [ + "Content-Type", + "application/json" + ] + ] + } + } + ], "custom": { "align": "auto", "cellOptions": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_sparkline_cell.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_sparkline_cell.v42.v2beta1.json index ac15a298939..5246af0a06b 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_sparkline_cell.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_sparkline_cell.v42.v2beta1.json @@ -312,6 +312,23 @@ "url": "https://google.com/search?q=grafana" } ], + "actions": [ + { + "type": "fetch", + "title": "Get instance health", + "fetch": { + "method": "GET", + "url": "/api/health", + "body": "{}", + "headers": [ + [ + "Content-Type", + "application/json" + ] + ] + } + } + ], "custom": { "align": "auto", "cellOptions": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_v12_2_migrations.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_v12_2_migrations.v42.v2alpha1.json index 1b6348c35d5..d6451b5d80f 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_v12_2_migrations.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_v12_2_migrations.v42.v2alpha1.json @@ -206,7 +206,8 @@ }, "filterable": true, "inspect": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -753,7 +754,8 @@ }, "filterable": true, "inspect": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1303,7 +1305,8 @@ "filterable": true, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1497,7 +1500,8 @@ "filterable": true, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1692,7 +1696,8 @@ "filterable": true, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1886,7 +1891,8 @@ "filterable": true, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -2081,7 +2087,8 @@ "filterable": true, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -2276,7 +2283,8 @@ "filterable": true, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_v12_2_migrations.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_v12_2_migrations.v42.v2beta1.json index a77140c5beb..75353a995ac 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_v12_2_migrations.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_v12_2_migrations.v42.v2beta1.json @@ -212,7 +212,8 @@ }, "filterable": true, "inspect": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -764,7 +765,8 @@ }, "filterable": true, "inspect": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1319,7 +1321,8 @@ "filterable": true, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1518,7 +1521,8 @@ "filterable": true, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1718,7 +1722,8 @@ "filterable": true, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -1917,7 +1922,8 @@ "filterable": true, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -2117,7 +2123,8 @@ "filterable": true, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { @@ -2317,7 +2324,8 @@ "filterable": true, "inspect": false, "wrapText": false - } + }, + "fieldMinMax": true }, "overrides": [ { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeline/v0alpha1.timeline-thresholds-mappings.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeline/v0alpha1.timeline-thresholds-mappings.v42.v2alpha1.json index 8dff3c34ccf..3366490c00a 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeline/v0alpha1.timeline-thresholds-mappings.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeline/v0alpha1.timeline-thresholds-mappings.v42.v2alpha1.json @@ -222,7 +222,8 @@ "insertNulls": false, "lineWidth": 0, "spanNulls": false - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -318,7 +319,8 @@ "insertNulls": false, "lineWidth": 0, "spanNulls": false - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -424,7 +426,8 @@ "insertNulls": false, "lineWidth": 0, "spanNulls": false - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -507,7 +510,8 @@ "insertNulls": false, "lineWidth": 0, "spanNulls": false - } + }, + "fieldMinMax": false }, "overrides": [] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeline/v0alpha1.timeline-thresholds-mappings.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeline/v0alpha1.timeline-thresholds-mappings.v42.v2beta1.json index 3baa3d21130..59d2e929972 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeline/v0alpha1.timeline-thresholds-mappings.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeline/v0alpha1.timeline-thresholds-mappings.v42.v2beta1.json @@ -229,7 +229,8 @@ "insertNulls": false, "lineWidth": 0, "spanNulls": false - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -328,7 +329,8 @@ "insertNulls": false, "lineWidth": 0, "spanNulls": false - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -437,7 +439,8 @@ "insertNulls": false, "lineWidth": 0, "spanNulls": false - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -523,7 +526,8 @@ "insertNulls": false, "lineWidth": 0, "spanNulls": false - } + }, + "fieldMinMax": false }, "overrides": [] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-gradient-area.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-gradient-area.v42.v2alpha1.json index 5b43876c65f..bde73320d42 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-gradient-area.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-gradient-area.v42.v2alpha1.json @@ -110,7 +110,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -217,7 +218,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -324,7 +326,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -431,7 +434,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -537,7 +541,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -643,7 +648,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-gradient-area.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-gradient-area.v42.v2beta1.json index ca96f9d5720..06331f32233 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-gradient-area.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-gradient-area.v42.v2beta1.json @@ -114,7 +114,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -223,7 +224,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -332,7 +334,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -441,7 +444,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -549,7 +553,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -657,7 +662,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-hue-gradients.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-hue-gradients.v42.v2alpha1.json index 74cba148009..c4bb5720d36 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-hue-gradients.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-hue-gradients.v42.v2alpha1.json @@ -116,7 +116,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -229,7 +230,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -342,7 +344,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -455,7 +458,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -568,7 +572,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -681,7 +686,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -794,7 +800,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -907,7 +914,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -1020,7 +1028,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-hue-gradients.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-hue-gradients.v42.v2beta1.json index 7e64bc79ef3..7c18be27f07 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-hue-gradients.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries-hue-gradients.v42.v2beta1.json @@ -120,7 +120,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -235,7 +236,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -350,7 +352,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -465,7 +468,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -580,7 +584,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -695,7 +700,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -810,7 +816,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -925,7 +932,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -1040,7 +1048,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries.v42.v2alpha1.json index e50b453076a..cb63e4f234d 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries.v42.v2alpha1.json @@ -3607,7 +3607,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -3740,7 +3741,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries.v42.v2beta1.json index 65105663c85..a57e430cc63 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-timeseries/v0alpha1.timeseries.v42.v2beta1.json @@ -3674,7 +3674,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { @@ -3809,7 +3810,8 @@ }, "showPoints": "never", "spanNulls": true - } + }, + "nullValueMode": "null" }, "overrides": [ { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-xychart/v0alpha1.xychart-tooltip-color-test.v42.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-xychart/v0alpha1.xychart-tooltip-color-test.v42.v1beta1.json index cdc93bf3cfa..1b7c9effbe8 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-xychart/v0alpha1.xychart-tooltip-color-test.v42.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-xychart/v0alpha1.xychart-tooltip-color-test.v42.v1beta1.json @@ -67,8 +67,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -154,8 +153,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -241,8 +239,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -328,8 +325,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -422,8 +418,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -516,8 +511,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -610,8 +604,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-xychart/v0alpha1.xychart-tooltip-color-test.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-xychart/v0alpha1.xychart-tooltip-color-test.v42.v2alpha1.json index 1d60f0ef9bf..861c4b41a6c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-xychart/v0alpha1.xychart-tooltip-color-test.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-xychart/v0alpha1.xychart-tooltip-color-test.v42.v2alpha1.json @@ -124,7 +124,8 @@ "type": "linear" }, "show": "points" - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -225,7 +226,8 @@ "type": "linear" }, "show": "points" - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -326,7 +328,8 @@ "type": "linear" }, "show": "points" - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -434,7 +437,8 @@ "type": "linear" }, "show": "points" - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -542,7 +546,8 @@ "type": "linear" }, "show": "points" - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -650,7 +655,8 @@ "type": "linear" }, "show": "points" - } + }, + "fieldMinMax": false }, "overrides": [] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-xychart/v0alpha1.xychart-tooltip-color-test.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-xychart/v0alpha1.xychart-tooltip-color-test.v42.v2beta1.json index 5a46646474d..0ae6dc172bd 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-xychart/v0alpha1.xychart-tooltip-color-test.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-xychart/v0alpha1.xychart-tooltip-color-test.v42.v2beta1.json @@ -128,7 +128,8 @@ "type": "linear" }, "show": "points" - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -232,7 +233,8 @@ "type": "linear" }, "show": "points" - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -336,7 +338,8 @@ "type": "linear" }, "show": "points" - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -447,7 +450,8 @@ "type": "linear" }, "show": "points" - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -558,7 +562,8 @@ "type": "linear" }, "show": "points" - } + }, + "fieldMinMax": false }, "overrides": [] } @@ -669,7 +674,8 @@ "type": "linear" }, "show": "points" - } + }, + "fieldMinMax": false }, "overrides": [] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/transforms/v0alpha1.filter.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/transforms/v0alpha1.filter.v42.v2alpha1.json index 056fdc62383..dd3ba7146e5 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/transforms/v0alpha1.filter.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/transforms/v0alpha1.filter.v42.v2alpha1.json @@ -81,6 +81,10 @@ "kind": "reduce", "spec": { "id": "reduce", + "filter": { + "id": "byRefId", + "options": "A" + }, "options": { "includeTimeField": false, "mode": "reduceFields", @@ -94,6 +98,10 @@ "kind": "reduce", "spec": { "id": "reduce", + "filter": { + "id": "byRefId", + "options": "B" + }, "options": { "includeTimeField": false, "mode": "reduceFields", diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/transforms/v0alpha1.filter.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/transforms/v0alpha1.filter.v42.v2beta1.json index 57c5559add1..0f4ab69c96a 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/transforms/v0alpha1.filter.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/transforms/v0alpha1.filter.v42.v2beta1.json @@ -86,6 +86,10 @@ "kind": "reduce", "spec": { "id": "reduce", + "filter": { + "id": "byRefId", + "options": "A" + }, "options": { "includeTimeField": false, "mode": "reduceFields", @@ -99,6 +103,10 @@ "kind": "reduce", "spec": { "id": "reduce", + "filter": { + "id": "byRefId", + "options": "B" + }, "options": { "includeTimeField": false, "mode": "reduceFields", diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v0alpha1.json new file mode 100644 index 00000000000..9c6354b2319 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v0alpha1.json @@ -0,0 +1,294 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v0alpha1", + "metadata": { + "name": "legacy-ds-ref" + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "panels": [ + { + "datasource": "${datasource}", + "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", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Minimum cluster size" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 10, + 10 + ], + "fill": "dash" + } + }, + { + "id": "custom.lineWidth", + "value": 1 + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 0 + }, + "id": 16, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "timeCompare": false, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": "${datasource}", + "editorMode": "code", + "expr": "count by (version) (alloy_build_info{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\"})", + "instant": false, + "legendFormat": "{{version}}", + "range": true, + "refId": "B" + } + ], + "title": "Number of Alloy Instances", + "type": "timeseries" + }, + { + "datasource": "${datasource}", + "description": "CPU usage of the Alloy process relative to 1 CPU core.\n\nFor example, 100% means using one entire CPU core.\n", + "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", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [ + { + "__systemRef": "hideSeriesFrom", + "matcher": { + "id": "byNames", + "options": { + "mode": "exclude", + "names": [ + "Total" + ], + "prefix": "All except:", + "readOnly": true + } + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": false, + "tooltip": true, + "viz": true + } + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 0 + }, + "id": 17, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "timeCompare": false, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": "${datasource}", + "expr": "rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])\n", + "hide": true, + "instant": false, + "legendFormat": "{{instance}}", + "range": true, + "refId": "A" + }, + { + "datasource": "${datasource}", + "editorMode": "code", + "expr": "sum(rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "Total", + "range": true, + "refId": "B" + } + ], + "title": "CPU usage", + "type": "timeseries" + } + ], + "time": { + "from": "now-90m", + "to": "now" + }, + "timezone": "utc", + "title": "Legacy DS Panel Query Ref", + "weekStart": "" + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v2alpha1.json new file mode 100644 index 00000000000..23affbf4d4a --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v2alpha1.json @@ -0,0 +1,405 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v2alpha1", + "metadata": { + "name": "legacy-ds-ref" + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "query": { + "kind": "grafana", + "spec": {} + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "builtIn": true, + "legacyOptions": { + "type": "dashboard" + } + } + } + ], + "cursorSync": "Off", + "editable": true, + "elements": { + "panel-16": { + "kind": "Panel", + "spec": { + "id": 16, + "title": "Number of Alloy Instances", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "", + "spec": { + "editorMode": "code", + "expr": "count by (version) (alloy_build_info{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\"})", + "instant": false, + "legendFormat": "{{version}}", + "range": true + } + }, + "datasource": { + "type": "", + "uid": "${datasource}" + }, + "refId": "B", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "timeseries", + "spec": { + "pluginVersion": "", + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "timeCompare": false, + "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", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Minimum cluster size" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 10, + 10 + ], + "fill": "dash" + } + }, + { + "id": "custom.lineWidth", + "value": 1 + } + ] + } + ] + } + } + } + } + }, + "panel-17": { + "kind": "Panel", + "spec": { + "id": 17, + "title": "CPU usage", + "description": "CPU usage of the Alloy process relative to 1 CPU core.\n\nFor example, 100% means using one entire CPU core.\n", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "", + "spec": { + "expr": "rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])\n", + "instant": false, + "legendFormat": "{{instance}}", + "range": true + } + }, + "datasource": { + "type": "", + "uid": "${datasource}" + }, + "refId": "A", + "hidden": true + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "", + "spec": { + "editorMode": "code", + "expr": "sum(rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "Total", + "range": true + } + }, + "datasource": { + "type": "", + "uid": "${datasource}" + }, + "refId": "B", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "timeseries", + "spec": { + "pluginVersion": "", + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "timeCompare": false, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "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", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [ + { + "__systemRef": "hideSeriesFrom", + "matcher": { + "id": "byNames", + "options": { + "mode": "exclude", + "names": [ + "Total" + ], + "prefix": "All except:", + "readOnly": true + } + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": false, + "tooltip": true, + "viz": true + } + } + ] + } + ] + } + } + } + } + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 0, + "width": 8, + "height": 9, + "element": { + "kind": "ElementReference", + "name": "panel-16" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 8, + "y": 0, + "width": 8, + "height": 9, + "element": { + "kind": "ElementReference", + "name": "panel-17" + } + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "timezone": "utc", + "from": "now-90m", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "Legacy DS Panel Query Ref", + "variables": [] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v2beta1.json new file mode 100644 index 00000000000..758ffd9c348 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v2beta1.json @@ -0,0 +1,411 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v2beta1", + "metadata": { + "name": "legacy-ds-ref" + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "grafana", + "version": "v0", + "datasource": { + "name": "-- Grafana --" + }, + "spec": {} + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "builtIn": true, + "legacyOptions": { + "type": "dashboard" + } + } + } + ], + "cursorSync": "Off", + "editable": true, + "elements": { + "panel-16": { + "kind": "Panel", + "spec": { + "id": 16, + "title": "Number of Alloy Instances", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "", + "version": "v0", + "datasource": { + "name": "${datasource}" + }, + "spec": { + "editorMode": "code", + "expr": "count by (version) (alloy_build_info{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\"})", + "instant": false, + "legendFormat": "{{version}}", + "range": true + } + }, + "refId": "B", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "", + "spec": { + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "timeCompare": false, + "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", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Minimum cluster size" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 10, + 10 + ], + "fill": "dash" + } + }, + { + "id": "custom.lineWidth", + "value": 1 + } + ] + } + ] + } + } + } + } + }, + "panel-17": { + "kind": "Panel", + "spec": { + "id": 17, + "title": "CPU usage", + "description": "CPU usage of the Alloy process relative to 1 CPU core.\n\nFor example, 100% means using one entire CPU core.\n", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "", + "version": "v0", + "datasource": { + "name": "${datasource}" + }, + "spec": { + "expr": "rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])\n", + "instant": false, + "legendFormat": "{{instance}}", + "range": true + } + }, + "refId": "A", + "hidden": true + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "", + "version": "v0", + "datasource": { + "name": "${datasource}" + }, + "spec": { + "editorMode": "code", + "expr": "sum(rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "Total", + "range": true + } + }, + "refId": "B", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "", + "spec": { + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "timeCompare": false, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "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", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [ + { + "__systemRef": "hideSeriesFrom", + "matcher": { + "id": "byNames", + "options": { + "mode": "exclude", + "names": [ + "Total" + ], + "prefix": "All except:", + "readOnly": true + } + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": false, + "tooltip": true, + "viz": true + } + } + ] + } + ] + } + } + } + } + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 0, + "width": 8, + "height": 9, + "element": { + "kind": "ElementReference", + "name": "panel-16" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 8, + "y": 0, + "width": 8, + "height": 9, + "element": { + "kind": "ElementReference", + "name": "panel-17" + } + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "timezone": "utc", + "from": "now-90m", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "Legacy DS Panel Query Ref", + "variables": [] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ 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 index 20f70a0a647..b38cf688949 100644 --- 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 @@ -711,6 +711,146 @@ ], "title": "Mixed DS WITHOUT REFS", "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "Panel with a single -- Dashboard -- datasource query", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 18 + }, + "id": 7, + "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", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + } + ], + "title": "Single Dashboard DS Query", + "type": "stat" + }, + { + "datasource": { + "type": "mixed", + "uid": "-- Mixed --" + }, + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 18 + }, + "id": 8, + "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", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 2, + "refId": "B", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 3, + "refId": "C", + "withTransforms": true + } + ], + "title": "Multiple Dashboard DS Queries", + "type": "stat" } ], "preload": false, 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 index 9956ad6962f..0b7f512e6f1 100644 --- 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 @@ -711,6 +711,146 @@ ], "title": "Mixed DS WITHOUT REFS", "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "Panel with a single -- Dashboard -- datasource query", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 18 + }, + "id": 7, + "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", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + } + ], + "title": "Single Dashboard DS Query", + "type": "stat" + }, + { + "datasource": { + "type": "mixed", + "uid": "-- Mixed --" + }, + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 18 + }, + "id": 8, + "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", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 2, + "refId": "B", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 3, + "refId": "C", + "withTransforms": true + } + ], + "title": "Multiple Dashboard DS Queries", + "type": "stat" } ], "preload": false, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v2beta1.json index 09e35c64258..aba5db6146d 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v2beta1.json @@ -879,6 +879,200 @@ } } } + }, + "panel-7": { + "kind": "Panel", + "spec": { + "id": 7, + "title": "Single Dashboard DS Query", + "description": "Panel with a single -- Dashboard -- datasource query", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "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" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } + }, + "panel-8": { + "kind": "Panel", + "spec": { + "id": 8, + "title": "Multiple Dashboard DS Queries", + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "refId": "A", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 2, + "withTransforms": true + } + }, + "refId": "B", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 3, + "withTransforms": true + } + }, + "refId": "C", + "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" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } } }, "layout": { @@ -941,6 +1135,24 @@ "name": "panel-6" } } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-7" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-8" + } + } } ] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v0alpha1.json index 4494023eb13..99bcc3e9581 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v0alpha1.json @@ -711,6 +711,146 @@ ], "title": "Mixed DS WITHOUT REFS", "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "Panel with a single -- Dashboard -- datasource query", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 3, + "w": 8, + "x": 0, + "y": 6 + }, + "id": 7, + "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", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + } + ], + "title": "Single Dashboard DS Query", + "type": "stat" + }, + { + "datasource": { + "type": "mixed", + "uid": "-- Mixed --" + }, + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 3, + "w": 8, + "x": 8, + "y": 6 + }, + "id": 8, + "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", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 2, + "refId": "B", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 3, + "refId": "C", + "withTransforms": true + } + ], + "title": "Multiple Dashboard DS Queries", + "type": "stat" } ], "preload": false, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v1beta1.json index bc8d90d796a..e2d54185ea5 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v1beta1.json @@ -711,6 +711,146 @@ ], "title": "Mixed DS WITHOUT REFS", "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "Panel with a single -- Dashboard -- datasource query", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 3, + "w": 8, + "x": 0, + "y": 6 + }, + "id": 7, + "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", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + } + ], + "title": "Single Dashboard DS Query", + "type": "stat" + }, + { + "datasource": { + "type": "mixed", + "uid": "-- Mixed --" + }, + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 3, + "w": 8, + "x": 8, + "y": 6 + }, + "id": 8, + "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", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 2, + "refId": "B", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 3, + "refId": "C", + "withTransforms": true + } + ], + "title": "Multiple Dashboard DS Queries", + "type": "stat" } ], "preload": false, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v2alpha1.json index bb70e99ec48..d3ca201e380 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v2alpha1.json @@ -852,6 +852,194 @@ } } } + }, + "panel-7": { + "kind": "Panel", + "spec": { + "id": 7, + "title": "Single Dashboard DS Query", + "description": "Panel with a single -- Dashboard -- datasource query", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "stat", + "spec": { + "pluginVersion": "12.1.0-pre", + "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" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } + }, + "panel-8": { + "kind": "Panel", + "spec": { + "id": 8, + "title": "Multiple Dashboard DS Queries", + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "A", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 2, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "B", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 3, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "C", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "stat", + "spec": { + "pluginVersion": "12.1.0-pre", + "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" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } } }, "layout": { @@ -946,6 +1134,32 @@ "name": "panel-6" } } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 6, + "width": 8, + "height": 3, + "element": { + "kind": "ElementReference", + "name": "panel-7" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 8, + "y": 6, + "width": 8, + "height": 3, + "element": { + "kind": "ElementReference", + "name": "panel-8" + } + } } ] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v0alpha1.json index 716b476f825..40b4fff030e 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v0alpha1.json @@ -586,6 +586,7 @@ }, "id": -1, "panels": [], + "repeat": "custom_var_tab", "title": "Repeated Tab by \"$custom_var_tab\"", "type": "row" }, @@ -610,8 +611,11 @@ "y": 22 }, "id": 6, + "maxPerRow": 3, "options": {}, "pluginVersion": "12.4.0-19736337744", + "repeat": "custom_var_panel", + "repeatDirection": "h", "targets": [ { "refId": "A" diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v1beta1.json index f915142fd14..ff1e2d42e20 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v1beta1.json @@ -586,6 +586,7 @@ }, "id": -1, "panels": [], + "repeat": "custom_var_tab", "title": "Repeated Tab by \"$custom_var_tab\"", "type": "row" }, @@ -610,8 +611,11 @@ "y": 22 }, "id": 6, + "maxPerRow": 3, "options": {}, "pluginVersion": "12.4.0-19736337744", + "repeat": "custom_var_panel", + "repeatDirection": "h", "targets": [ { "refId": "A" diff --git a/apps/dashboard/pkg/migration/conversion/v0alpha1_to_v1beta1.go b/apps/dashboard/pkg/migration/conversion/v0alpha1_to_v1beta1.go index d0679c76e63..df2db1ac5a1 100644 --- a/apps/dashboard/pkg/migration/conversion/v0alpha1_to_v1beta1.go +++ b/apps/dashboard/pkg/migration/conversion/v0alpha1_to_v1beta1.go @@ -88,6 +88,11 @@ func ConvertDashboard_V0_to_V1beta1(in *dashv0.Dashboard, out *dashv1.Dashboard, // Which means that we have schemaVersion: 42 dashboards where datasource variable references are still strings normalizeTemplateVariableDatasources(out.Spec.Object) + // Normalize panel and target datasources from string to object format + // This handles legacy dashboards where panels/targets have datasource: "$datasource" (string) + // instead of datasource: { uid: "$datasource" } (object) + normalizePanelDatasources(out.Spec.Object) + return nil } @@ -134,3 +139,62 @@ func isTemplateVariableRef(s string) bool { } return strings.HasPrefix(s, "$") || strings.HasPrefix(s, "${") } + +// normalizePanelDatasources converts panel and target string datasources to object format. +// Legacy dashboards may have panels/targets with datasource: "$datasource" (string). +// This normalizes them to datasource: { uid: "$datasource" } for consistent V1→V2 conversion. +func normalizePanelDatasources(dashboard map[string]interface{}) { + panels, ok := dashboard["panels"].([]interface{}) + if !ok { + return + } + + normalizePanelsDatasources(panels) +} + +// normalizePanelsDatasources normalizes datasources in a list of panels (including nested row panels) +func normalizePanelsDatasources(panels []interface{}) { + for _, panel := range panels { + panelMap, ok := panel.(map[string]interface{}) + if !ok { + continue + } + + // Handle row panels with nested panels + if panelType, _ := panelMap["type"].(string); panelType == "row" { + if nestedPanels, ok := panelMap["panels"].([]interface{}); ok { + normalizePanelsDatasources(nestedPanels) + } + } + + // Normalize panel-level datasource + if ds := panelMap["datasource"]; ds != nil { + if dsStr, ok := ds.(string); ok && isTemplateVariableRef(dsStr) { + panelMap["datasource"] = map[string]interface{}{ + "uid": dsStr, + } + } + } + + // Normalize target-level datasources + targets, ok := panelMap["targets"].([]interface{}) + if !ok { + continue + } + + for _, target := range targets { + targetMap, ok := target.(map[string]interface{}) + if !ok { + continue + } + + if ds := targetMap["datasource"]; ds != nil { + if dsStr, ok := ds.(string); ok && isTemplateVariableRef(dsStr) { + targetMap["datasource"] = map[string]interface{}{ + "uid": dsStr, + } + } + } + } + } +} diff --git a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go index 59c622b07bc..bfff3c49797 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go @@ -2059,6 +2059,12 @@ func transformPanelQueries(ctx context.Context, panelMap map[string]interface{}, Uid: &dsUID, } } + } else if dsStr, ok := ds.(string); ok && isTemplateVariable(dsStr) { + // Handle legacy panel datasource as string (template variable reference e.g., "$datasource") + // Only process template variables - other string values are not supported in V2 format + panelDatasource = &dashv2alpha1.DashboardDataSourceRef{ + Uid: &dsStr, + } } } @@ -2145,6 +2151,10 @@ func transformSingleQuery(ctx context.Context, targetMap map[string]interface{}, // Resolve Grafana datasource UID when type is "datasource" and UID is empty queryDatasourceUID = resolveGrafanaDatasourceUID(queryDatasourceType, queryDatasourceUID) } + } else if dsStr, ok := targetMap["datasource"].(string); ok && isTemplateVariable(dsStr) { + // Handle legacy target datasource as string (template variable reference e.g., "$datasource") + // Only process template variables - other string values are not supported in V2 format + queryDatasourceUID = dsStr } // Use panel datasource if target datasource is missing or empty @@ -2220,6 +2230,20 @@ func transformPanelTransformations(panelMap map[string]interface{}) []dashv2alph Options: options, }, } + + // Extract disabled if present (optional, transformations are enabled by default) + if disabled, ok := tMap["disabled"].(bool); ok && disabled { + transformationKind.Spec.Disabled = &disabled + } + + // Extract filter if present (optional frame matcher for transformations) + if filterMap, ok := tMap["filter"].(map[string]interface{}); ok { + transformationKind.Spec.Filter = &dashv2alpha1.DashboardMatcherConfig{ + Id: schemaversion.GetStringValue(filterMap, "id"), + Options: filterMap["options"], + } + } + result = append(result, transformationKind) } } @@ -2339,14 +2363,6 @@ func buildVizConfig(panelMap map[string]interface{}) dashv2alpha1.DashboardVizCo } } - // Add frontend-style default options to match frontend behavior - if legend, ok := options["legend"].(map[string]interface{}); ok { - // Add showLegend: true to match frontend behavior - showLegend := getBoolField(legend, "showLegend", true) - legend["showLegend"] = showLegend - options["legend"] = legend - } - // Handle Angular panel migrations // This replicates the v0→v1 migration logic for panels that weren't migrated yet. // We check two cases: @@ -2521,6 +2537,15 @@ func extractFieldConfigDefaults(defaults map[string]interface{}) dashv2alpha1.Da fieldConfigDefaults.Writeable = val hasDefaults = true } + if val, ok := extractBoolField(defaults, "fieldMinMax"); ok { + fieldConfigDefaults.FieldMinMax = val + hasDefaults = true + } + if val, ok := defaults["nullValueMode"].(string); ok { + nullValueMode := dashv2alpha1.DashboardNullValueMode(val) + fieldConfigDefaults.NullValueMode = &nullValueMode + hasDefaults = true + } // Extract array field - strip BOMs from link URLs if linksArray, ok := extractArrayField(defaults, "links"); ok { @@ -2533,6 +2558,12 @@ func extractFieldConfigDefaults(defaults map[string]interface{}) dashv2alpha1.Da hasDefaults = true } + // Extract actions array + if actionsArray, ok := extractArrayField(defaults, "actions"); ok { + fieldConfigDefaults.Actions = convertActionsToV2(actionsArray) + hasDefaults = true + } + // Extract mappings if mappings, exists := defaults["mappings"]; exists { resultMappings := buildValueMappings(mappings) @@ -2832,6 +2863,157 @@ func extractFieldConfigOverrides(fieldConfig map[string]interface{}) []dashv2alp return result } +// convertActionsToV2 converts an array of V1 action objects to V2 DashboardAction structs. +func convertActionsToV2(actionsArray []interface{}) []dashv2alpha1.DashboardAction { + if len(actionsArray) == 0 { + return nil + } + + result := make([]dashv2alpha1.DashboardAction, 0, len(actionsArray)) + for _, action := range actionsArray { + actionMap, ok := action.(map[string]interface{}) + if !ok { + continue + } + + dashAction := dashv2alpha1.DashboardAction{ + Type: dashv2alpha1.DashboardActionType(schemaversion.GetStringValue(actionMap, "type")), + Title: schemaversion.GetStringValue(actionMap, "title"), + } + + // Convert confirmation + if confirmation, ok := actionMap["confirmation"].(string); ok && confirmation != "" { + dashAction.Confirmation = &confirmation + } + + // Convert oneClick + if oneClick, ok := actionMap["oneClick"].(bool); ok { + dashAction.OneClick = &oneClick + } + + // Convert fetch options + if fetchMap, ok := actionMap["fetch"].(map[string]interface{}); ok { + dashAction.Fetch = convertFetchOptionsToV2(fetchMap) + } + + // Convert infinity options + if infinityMap, ok := actionMap["infinity"].(map[string]interface{}); ok { + dashAction.Infinity = convertInfinityOptionsToV2(infinityMap) + } + + // Convert variables + if variablesArray, ok := actionMap["variables"].([]interface{}); ok { + dashAction.Variables = convertActionVariablesToV2(variablesArray) + } + + // Convert style + if styleMap, ok := actionMap["style"].(map[string]interface{}); ok { + dashAction.Style = convertActionStyleToV2(styleMap) + } + + result = append(result, dashAction) + } + + return result +} + +func convertFetchOptionsToV2(fetchMap map[string]interface{}) *dashv2alpha1.DashboardFetchOptions { + fetchOptions := &dashv2alpha1.DashboardFetchOptions{ + Method: dashv2alpha1.DashboardHttpRequestMethod(schemaversion.GetStringValue(fetchMap, "method")), + Url: schemaversion.GetStringValue(fetchMap, "url"), + } + + if body, ok := fetchMap["body"].(string); ok { + fetchOptions.Body = &body + } + + // Convert queryParams (2D array of strings) - preserve empty arrays + if queryParams, ok := fetchMap["queryParams"].([]interface{}); ok { + fetchOptions.QueryParams = convert2DStringArrayPreserveEmpty(queryParams) + } + + // Convert headers (2D array of strings) - preserve empty arrays + if headers, ok := fetchMap["headers"].([]interface{}); ok { + fetchOptions.Headers = convert2DStringArrayPreserveEmpty(headers) + } + + return fetchOptions +} + +func convertInfinityOptionsToV2(infinityMap map[string]interface{}) *dashv2alpha1.DashboardInfinityOptions { + infinityOptions := &dashv2alpha1.DashboardInfinityOptions{ + Method: dashv2alpha1.DashboardHttpRequestMethod(schemaversion.GetStringValue(infinityMap, "method")), + Url: schemaversion.GetStringValue(infinityMap, "url"), + DatasourceUid: schemaversion.GetStringValue(infinityMap, "datasourceUid"), + } + + if body, ok := infinityMap["body"].(string); ok { + infinityOptions.Body = &body + } + + if queryParams, ok := infinityMap["queryParams"].([]interface{}); ok { + infinityOptions.QueryParams = convert2DStringArrayPreserveEmpty(queryParams) + } + + if headers, ok := infinityMap["headers"].([]interface{}); ok { + infinityOptions.Headers = convert2DStringArrayPreserveEmpty(headers) + } + + return infinityOptions +} + +func convertActionVariablesToV2(variablesArray []interface{}) []dashv2alpha1.DashboardActionVariable { + if len(variablesArray) == 0 { + return nil + } + + result := make([]dashv2alpha1.DashboardActionVariable, 0, len(variablesArray)) + for _, variable := range variablesArray { + variableMap, ok := variable.(map[string]interface{}) + if !ok { + continue + } + + result = append(result, dashv2alpha1.DashboardActionVariable{ + Key: schemaversion.GetStringValue(variableMap, "key"), + Name: schemaversion.GetStringValue(variableMap, "name"), + Type: schemaversion.GetStringValue(variableMap, "type"), + }) + } + + return result +} + +func convertActionStyleToV2(styleMap map[string]interface{}) *dashv2alpha1.DashboardV2alpha1ActionStyle { + style := &dashv2alpha1.DashboardV2alpha1ActionStyle{} + + if backgroundColor, ok := styleMap["backgroundColor"].(string); ok { + style.BackgroundColor = &backgroundColor + } + + return style +} + +// convert2DStringArrayPreserveEmpty is like convert2DStringArray but returns +// an empty slice (not nil) when input is empty, to ensure JSON marshals as [] +func convert2DStringArrayPreserveEmpty(arr []interface{}) [][]string { + // Return empty slice (not nil) to preserve [] in JSON output + result := make([][]string, 0, len(arr)) + for _, item := range arr { + if innerArr, ok := item.([]interface{}); ok { + stringArr := make([]string, 0, len(innerArr)) + for _, s := range innerArr { + if str, ok := s.(string); ok { + stringArr = append(stringArr, str) + } + } + result = append(result, stringArr) + } + } + + return result +} + // getAngularPanelMigration is a convenience wrapper around schemaversion.GetAngularPanelMigration. // It checks if a panel type is an Angular panel and returns the new type to migrate to. // Returns the new panel type if migration is needed, empty string otherwise. diff --git a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go index 46a2a533d41..f9f953f965d 100644 --- a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go +++ b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go @@ -71,11 +71,6 @@ func convertDashboardSpec_V2alpha1_to_V1beta1(in *dashv2alpha1.DashboardSpec) (m if err != nil { return nil, fmt.Errorf("failed to convert panels: %w", err) } - // Count total panels including those in collapsed rows - totalPanelsConverted := countTotalPanels(panels) - if totalPanelsConverted < len(in.Elements) { - return nil, fmt.Errorf("some panels were not converted from v2alpha1 to v1beta1") - } if len(panels) > 0 { dashboard["panels"] = panels @@ -198,29 +193,6 @@ func convertLinksToV1(links []dashv2alpha1.DashboardDashboardLink) []map[string] return result } -// countTotalPanels counts all panels including those nested in collapsed row panels. -func countTotalPanels(panels []interface{}) int { - count := 0 - for _, p := range panels { - panel, ok := p.(map[string]interface{}) - if !ok { - count++ - continue - } - - // Check if this is a row panel with nested panels - if panelType, ok := panel["type"].(string); ok && panelType == "row" { - if nestedPanels, ok := panel["panels"].([]interface{}); ok { - count += len(nestedPanels) - } - // Don't count the row itself as a panel element - } else { - count++ - } - } - return count -} - // convertPanelsFromElementsAndLayout converts V2 layout structures to V1 panel arrays. // V1 only supports a flat array of panels with row panels for grouping. // This function dispatches to the appropriate converter based on layout type: @@ -467,6 +439,11 @@ func processTabItem(elements map[string]dashv2alpha1.DashboardElement, tab *dash rowPanel["title"] = *tab.Spec.Title } + if tab.Spec.Repeat != nil && tab.Spec.Repeat.Value != "" { + // We only use value here as V1 doesn't support mode + rowPanel["repeat"] = tab.Spec.Repeat.Value + } + rowPanel["gridPos"] = map[string]interface{}{ "x": 0, "y": currentY, @@ -847,6 +824,21 @@ func convertAutoGridLayoutToPanelsWithOffset(elements map[string]dashv2alpha1.Da }, } + // Convert AutoGridRepeatOptions to RepeatOptions if present + // AutoGridRepeatOptions only has mode and value; infer direction and maxPerRow from AutoGrid settings: + // - direction: always "h" (AutoGrid flows horizontally, left-to-right then wraps) + // - maxPerRow: from AutoGrid's maxColumnCount + if item.Spec.Repeat != nil { + directionH := dashv2alpha1.DashboardRepeatOptionsDirectionH + maxPerRow := int64(maxColumnCount) + gridItem.Spec.Repeat = &dashv2alpha1.DashboardRepeatOptions{ + Mode: item.Spec.Repeat.Mode, + Value: item.Spec.Repeat.Value, + Direction: &directionH, + MaxPerRow: &maxPerRow, + } + } + panel, err := convertPanelFromElement(&element, &gridItem) if err != nil { return nil, fmt.Errorf("failed to convert panel %s: %w", item.Spec.Element.Name, err) @@ -1090,6 +1082,17 @@ func convertPanelKindToV1(panelKind *dashv2alpha1.DashboardPanelKind, panel map[ "id": t.Spec.Id, "options": t.Spec.Options, } + // Add disabled if set + if t.Spec.Disabled != nil { + transformation["disabled"] = *t.Spec.Disabled + } + // Add filter if set + if t.Spec.Filter != nil { + transformation["filter"] = map[string]interface{}{ + "id": t.Spec.Filter.Id, + "options": t.Spec.Filter.Options, + } + } transformations = append(transformations, transformation) } panel["transformations"] = transformations @@ -1195,16 +1198,36 @@ func getDataSourceForQuery(explicitDS *dashv2alpha1.DashboardDataSourceRef, quer // getPanelDatasource determines the panel-level datasource for V1. // Returns: // - Mixed datasource reference if queries use different datasources +// - Mixed datasource reference if multiple queries use Dashboard datasource (they fetch from different panels) +// - Dashboard datasource reference if a single query uses Dashboard datasource // - First query's datasource if all queries use the same datasource // - nil if no queries exist // Compares based on V2 input without runtime resolution: // - If query has explicit datasource.uid → use that UID and type // - Else → use query.Kind as type (empty UID) func getPanelDatasource(queries []dashv2alpha1.DashboardPanelQueryKind) map[string]interface{} { + const sharedDashboardQuery = "-- Dashboard --" + if len(queries) == 0 { return nil } + // Count how many queries use Dashboard datasource + // Multiple dashboard queries need mixed mode because they fetch from different panels + // which may have different underlying datasources + dashboardDsQueryCount := 0 + for _, query := range queries { + if query.Spec.Datasource != nil && query.Spec.Datasource.Uid != nil && *query.Spec.Datasource.Uid == sharedDashboardQuery { + dashboardDsQueryCount++ + } + } + if dashboardDsQueryCount > 1 { + return map[string]interface{}{ + "type": "mixed", + "uid": "-- Mixed --", + } + } + var firstUID, firstType string var hasFirst bool @@ -1239,6 +1262,16 @@ func getPanelDatasource(queries []dashv2alpha1.DashboardPanelQueryKind) map[stri } } + // Handle case when a single query uses Dashboard datasource. + // This is needed for the frontend to properly activate and fetch data from source panels. + // See DashboardDatasourceBehaviour.tsx for more details. + if firstUID == sharedDashboardQuery { + return map[string]interface{}{ + "type": "datasource", + "uid": sharedDashboardQuery, + } + } + // Not mixed - return the first query's datasource so the panel has a datasource set. // This is required because the frontend's legacy PanelModel.PanelQueryRunner.run uses panel.datasource // to resolve the datasource, and if undefined, it falls back to the default datasource @@ -1955,9 +1988,18 @@ func convertFieldConfigDefaultsToV1(defaults *dashv2alpha1.DashboardFieldConfig) if defaults.Writeable != nil { result["writeable"] = *defaults.Writeable } + if defaults.FieldMinMax != nil { + result["fieldMinMax"] = *defaults.FieldMinMax + } + if defaults.NullValueMode != nil { + result["nullValueMode"] = string(*defaults.NullValueMode) + } if defaults.Links != nil { result["links"] = defaults.Links } + if len(defaults.Actions) > 0 { + result["actions"] = convertActionsToV1(defaults.Actions) + } if defaults.Color != nil { result["color"] = convertFieldColorToV1(defaults.Color) } @@ -2163,3 +2205,115 @@ func convertThresholdsToV1(thresholds *dashv2alpha1.DashboardThresholdsConfig) m return thresholdsMap } + +func convertActionsToV1(actions []dashv2alpha1.DashboardAction) []map[string]interface{} { + result := make([]map[string]interface{}, 0, len(actions)) + + for _, action := range actions { + actionMap := map[string]interface{}{ + "type": string(action.Type), + "title": action.Title, + } + + if action.Confirmation != nil { + actionMap["confirmation"] = *action.Confirmation + } + + if action.OneClick != nil { + actionMap["oneClick"] = *action.OneClick + } + + if action.Fetch != nil { + actionMap["fetch"] = convertFetchOptionsToV1(action.Fetch) + } + + if action.Infinity != nil { + actionMap["infinity"] = convertInfinityOptionsToV1(action.Infinity) + } + + if len(action.Variables) > 0 { + actionMap["variables"] = convertActionVariablesToV1(action.Variables) + } + + if action.Style != nil { + styleMap := map[string]interface{}{} + if action.Style.BackgroundColor != nil { + styleMap["backgroundColor"] = *action.Style.BackgroundColor + } + if len(styleMap) > 0 { + actionMap["style"] = styleMap + } + } + + result = append(result, actionMap) + } + + return result +} + +func convertFetchOptionsToV1(fetch *dashv2alpha1.DashboardFetchOptions) map[string]interface{} { + result := map[string]interface{}{ + "method": string(fetch.Method), + "url": fetch.Url, + } + + if fetch.Body != nil { + result["body"] = *fetch.Body + } + + if len(fetch.QueryParams) > 0 { + result["queryParams"] = convert2DStringArrayToInterface(fetch.QueryParams) + } + + if len(fetch.Headers) > 0 { + result["headers"] = convert2DStringArrayToInterface(fetch.Headers) + } + + return result +} + +func convertInfinityOptionsToV1(infinity *dashv2alpha1.DashboardInfinityOptions) map[string]interface{} { + result := map[string]interface{}{ + "method": string(infinity.Method), + "url": infinity.Url, + "datasourceUid": infinity.DatasourceUid, + } + + if infinity.Body != nil { + result["body"] = *infinity.Body + } + + if len(infinity.QueryParams) > 0 { + result["queryParams"] = convert2DStringArrayToInterface(infinity.QueryParams) + } + + if len(infinity.Headers) > 0 { + result["headers"] = convert2DStringArrayToInterface(infinity.Headers) + } + + return result +} + +func convertActionVariablesToV1(variables []dashv2alpha1.DashboardActionVariable) []map[string]interface{} { + result := make([]map[string]interface{}, 0, len(variables)) + for _, v := range variables { + result = append(result, map[string]interface{}{ + "key": v.Key, + "name": v.Name, + "type": v.Type, + }) + } + return result +} + +func convert2DStringArrayToInterface(arr [][]string) []interface{} { + result := make([]interface{}, 0, len(arr)) + for _, innerArr := range arr { + interfaceArr := make([]interface{}, 0, len(innerArr)) + for _, s := range innerArr { + interfaceArr = append(interfaceArr, s) + } + result = append(result, interfaceArr) + } + return result +} diff --git a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go index 45803b6d7ec..8435c56d83f 100644 --- a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go +++ b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go @@ -310,6 +310,9 @@ func convertFieldConfig_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardFieldConfi Links: in.Links, NoValue: in.NoValue, Custom: in.Custom, + FieldMinMax: in.FieldMinMax, + NullValueMode: (*dashv2beta1.DashboardNullValueMode)(in.NullValueMode), + Actions: convertActions_V2alpha1_to_V2beta1(in.Actions), } // Convert thresholds @@ -1021,3 +1024,59 @@ func convertAnnotationMappings_V2alpha1_to_V2beta1(in map[string]dashv2alpha1.Da } return out } + +func convertActions_V2alpha1_to_V2beta1(in []dashv2alpha1.DashboardAction) []dashv2beta1.DashboardAction { + if len(in) == 0 { + return nil + } + + out := make([]dashv2beta1.DashboardAction, len(in)) + for i, action := range in { + out[i] = dashv2beta1.DashboardAction{ + Type: dashv2beta1.DashboardActionType(action.Type), + Title: action.Title, + Confirmation: action.Confirmation, + OneClick: action.OneClick, + } + + if action.Fetch != nil { + out[i].Fetch = &dashv2beta1.DashboardFetchOptions{ + Method: dashv2beta1.DashboardHttpRequestMethod(action.Fetch.Method), + Url: action.Fetch.Url, + Body: action.Fetch.Body, + QueryParams: action.Fetch.QueryParams, + Headers: action.Fetch.Headers, + } + } + + if action.Infinity != nil { + out[i].Infinity = &dashv2beta1.DashboardInfinityOptions{ + Method: dashv2beta1.DashboardHttpRequestMethod(action.Infinity.Method), + Url: action.Infinity.Url, + Body: action.Infinity.Body, + QueryParams: action.Infinity.QueryParams, + Headers: action.Infinity.Headers, + DatasourceUid: action.Infinity.DatasourceUid, + } + } + + if len(action.Variables) > 0 { + out[i].Variables = make([]dashv2beta1.DashboardActionVariable, len(action.Variables)) + for j, v := range action.Variables { + out[i].Variables[j] = dashv2beta1.DashboardActionVariable{ + Key: v.Key, + Name: v.Name, + Type: v.Type, + } + } + } + + if action.Style != nil { + out[i].Style = &dashv2beta1.DashboardV2beta1ActionStyle{ + BackgroundColor: action.Style.BackgroundColor, + } + } + } + + return out +} diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/datasource-loki/loki_query_splitting.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/datasource-loki/loki_query_splitting.v42.json index a7beffa4cdc..8af239195cb 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/datasource-loki/loki_query_splitting.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/datasource-loki/loki_query_splitting.v42.json @@ -219,8 +219,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -312,8 +311,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -492,8 +490,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -584,8 +581,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -676,8 +672,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -791,8 +786,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -906,8 +900,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -1022,8 +1015,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests.v42.json index 635103053bf..87b63411976 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests.v42.json @@ -65,17 +65,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -136,17 +133,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -207,17 +201,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -271,7 +262,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [], "max": 100, "min": 0, @@ -279,17 +269,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -342,7 +329,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [], "max": 100, "min": 0, @@ -350,17 +336,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -414,7 +397,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [], "max": 100, "min": 0, @@ -422,17 +404,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -485,7 +464,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [], "max": 100, "min": 0, @@ -493,17 +471,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -682,7 +657,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [ { "options": { @@ -699,17 +673,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -764,7 +735,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [ { "options": { @@ -782,17 +752,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -847,7 +814,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [ { "options": { @@ -866,17 +832,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -931,7 +894,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [ { "options": { @@ -960,17 +922,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -1052,7 +1011,7 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "2", + "decimals": 2, "mappings": [], "max": 100, "min": 0, @@ -1060,17 +1019,14 @@ "mode": "absolute", "steps": [ { - "color": "#7EB26D", - "index": 0 + "color": "#7EB26D" }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json index 61f092d491e..7fdd474df87 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json @@ -2117,7 +2117,7 @@ } ], "title": "Numeric, no series", - "type": "gauge" + "type": "radialbar" }, { "datasource": { @@ -2183,7 +2183,7 @@ } ], "title": "Non-numeric", - "type": "gauge" + "type": "radialbar" } ], "preload": false, @@ -2201,4 +2201,4 @@ "title": "Panel tests - Gauge (new)", "uid": "panel-tests-gauge-new", "weekStart": "" -} \ No newline at end of file +} diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-histogram/histogram_tests.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-histogram/histogram_tests.v42.json index 7e392bd55d0..6c521eaec9b 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-histogram/histogram_tests.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-histogram/histogram_tests.v42.json @@ -58,8 +58,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -127,8 +126,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -196,8 +194,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -277,8 +274,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -355,8 +351,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -448,8 +443,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -536,8 +530,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [ { @@ -619,8 +612,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [ { @@ -702,8 +694,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [ { @@ -785,8 +776,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -850,8 +840,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [ { diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-xychart/xychart-tooltip-color-test.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-xychart/xychart-tooltip-color-test.v42.json index 417ea1661e1..f28fee864e5 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-xychart/xychart-tooltip-color-test.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-xychart/xychart-tooltip-color-test.v42.json @@ -61,8 +61,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -148,8 +147,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -235,8 +233,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -322,8 +319,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -416,8 +412,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -510,8 +505,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -604,8 +598,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, diff --git a/apps/iam/go.mod b/apps/iam/go.mod index 0c91d66c945..e35a733d350 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -24,8 +24,6 @@ replace github.com/grafana/grafana/apps/alerting/historian => ../alerting/histor replace github.com/grafana/grafana/apps/correlations => ../correlations -replace github.com/grafana/grafana/apps/investigations => ../investigations - replace github.com/grafana/grafana/apps/logsdrilldown => ../logsdrilldown replace github.com/grafana/grafana/apps/playlist => ../playlist diff --git a/apps/iam/pkg/apis/iam/v0alpha1/extensions.go b/apps/iam/pkg/apis/iam/v0alpha1/extensions.go deleted file mode 100644 index c0c5a11f7e3..00000000000 --- a/apps/iam/pkg/apis/iam/v0alpha1/extensions.go +++ /dev/null @@ -1,43 +0,0 @@ -package v0alpha1 - -import ( - "fmt" - - "github.com/grafana/grafana/pkg/apimachinery/utils" -) - -func (u User) AuthID() string { - meta, err := utils.MetaAccessor(&u) - if err != nil { - return "" - } - // TODO: Workaround until we move all definitions - // After having all resource definitions here in the app, we can remove this - // and we need to change the List authorization to use the MetaAccessor and the GetDeprecatedInternalID method - //nolint:staticcheck - return fmt.Sprintf("%d", meta.GetDeprecatedInternalID()) -} - -func (s ServiceAccount) AuthID() string { - meta, err := utils.MetaAccessor(&s) - if err != nil { - return "" - } - // TODO: Workaround until we move all definitions - // After having all resource definitions here in the app, we can remove this - // and we need to change the List authorization to use the MetaAccessor and the GetDeprecatedInternalID method - //nolint:staticcheck - return fmt.Sprintf("%d", meta.GetDeprecatedInternalID()) -} - -func (t Team) AuthID() string { - meta, err := utils.MetaAccessor(&t) - if err != nil { - return "" - } - // TODO: Workaround until we move all definitions - // After having all resource definitions here in the app, we can remove this - // and we need to change the List authorization to use the MetaAccessor and the GetDeprecatedInternalID method - //nolint:staticcheck - return fmt.Sprintf("%d", meta.GetDeprecatedInternalID()) -} diff --git a/apps/investigations/Makefile b/apps/investigations/Makefile deleted file mode 100644 index bc8d6d30cb5..00000000000 --- a/apps/investigations/Makefile +++ /dev/null @@ -1,10 +0,0 @@ -include ../sdk.mk - -.PHONY: generate # Run Grafana App SDK code generation -generate: install-app-sdk update-app-sdk - @$(APP_SDK_BIN) generate \ - --source=./kinds/ \ - --gogenpath=./pkg/apis \ - --grouping=group \ - --genoperatorstate=false \ - --defencoding=none \ No newline at end of file diff --git a/apps/investigations/example.json b/apps/investigations/example.json deleted file mode 100644 index 0a390fb074f..00000000000 --- a/apps/investigations/example.json +++ /dev/null @@ -1,152 +0,0 @@ -[ - { - "id": "896312ce-65b0-4b50-ade1-e7f04fa22c66", - "title": "Thursday morning investigation", - "hasCustomName": false, - "isFavorite": false, - "collectables": [ - { - "origin": "Explore Logs", - "type": "timeseries", - "queries": [ - { - "refId": "LABEL_BREAKDOWN_VALUES", - "queryType": "range", - "editorMode": "code", - "supportingQueryType": "grafana-lokiexplore-app", - "legendFormat": "{{detected_level}}", - "expr": "sum(count_over_time({service_name=\"web_app_1\"} | detected_level != \"\"[$__auto])) by (detected_level)" - } - ], - "timeRange": { - "to": "2025-02-13T11:31:20.536Z", - "from": "2025-02-13T11:16:20.536Z", - "raw": { - "from": "now-15m", - "to": "now" - } - }, - "datasource": { - "uid": "fe9k7u07b1a0wc" - }, - "url": "http://localhost:3000/a/grafana-lokiexplore-app/explore/service/web_app_1/labels?patterns=%5B%5D&from=now-15m&to=now&var-ds=fe9k7u07b1a0wc&var-filters=service_name%7C%3D%7Cweb_app_1&var-fields=&var-levels=&var-metadata=&var-patterns=&var-lineFilterV2=&var-lineFilters=&urlColumns=%5B%5D&visualizationType=%22logs%22&displayedFields=%5B%5D&timezone=browser&var-all-fields=&var-labelBy=$__all", - "id": "LABEL_BREAKDOWN_VALUES_detected_level", - "title": "detected_level", - "logoPath": "public/plugins/grafana-lokiexplore-app/img/img/logo.svg", - "createdAt": "2025-02-13T11:31:23.637Z" - } - ], - "createdAt": "2025-02-13T11:31:23.636Z", - "updatedAt": "2025-02-13T11:31:23.637Z", - "viewMode": { - "mode": "compact", - "showComments": true, - "showTooltips": false - } - }, - { - "id": "e9cf1958-d0ed-46b7-b597-9052c7648656", - "title": "Thursday morning investigation", - "hasCustomName": false, - "isFavorite": false, - "collectables": [ - { - "origin": "Explore Logs", - "type": "timeseries", - "queries": [ - { - "refId": "LABEL_BREAKDOWN_VALUES", - "queryType": "range", - "editorMode": "code", - "supportingQueryType": "grafana-lokiexplore-app", - "legendFormat": "{{detected_level}}", - "expr": "sum(count_over_time({service_name=\"web_app_1\"} | detected_level != \"\"[$__auto])) by (detected_level)" - } - ], - "timeRange": { - "to": "2025-02-13T11:31:20.536Z", - "from": "2025-02-13T11:16:20.536Z", - "raw": { - "from": "now-15m", - "to": "now" - } - }, - "datasource": { - "uid": "fe9k7u07b1a0wc" - }, - "url": "http://localhost:3000/a/grafana-lokiexplore-app/explore/service/web_app_1/labels?patterns=%5B%5D&from=now-15m&to=now&var-ds=fe9k7u07b1a0wc&var-filters=service_name%7C%3D%7Cweb_app_1&var-fields=&var-levels=&var-metadata=&var-patterns=&var-lineFilterV2=&var-lineFilters=&urlColumns=%5B%5D&visualizationType=%22logs%22&displayedFields=%5B%5D&timezone=browser&var-all-fields=&var-labelBy=$__all", - "id": "LABEL_BREAKDOWN_VALUES_detected_level", - "title": "detected_level", - "logoPath": "public/plugins/grafana-lokiexplore-app/img/img/logo.svg", - "createdAt": "2025-02-13T11:31:23.638Z" - }, - { - "origin": "Explore Logs", - "type": "timeseries", - "queries": [ - { - "refId": "LABEL_BREAKDOWN_VALUES", - "queryType": "range", - "editorMode": "code", - "supportingQueryType": "grafana-lokiexplore-app", - "legendFormat": "{{service_name}}", - "expr": "sum(count_over_time({service_name=\"web_app_1\",service_name != \"\"} [$__auto])) by (service_name)" - } - ], - "timeRange": { - "to": "2025-02-13T11:31:20.536Z", - "from": "2025-02-13T11:16:20.536Z", - "raw": { - "from": "now-15m", - "to": "now" - } - }, - "datasource": { - "uid": "fe9k7u07b1a0wc" - }, - "url": "http://localhost:3000/a/grafana-lokiexplore-app/explore/service/web_app_1/labels?patterns=%5B%5D&from=now-15m&to=now&var-ds=fe9k7u07b1a0wc&var-filters=service_name%7C%3D%7Cweb_app_1&var-fields=&var-levels=&var-metadata=&var-patterns=&var-lineFilterV2=&var-lineFilters=&urlColumns=%5B%5D&visualizationType=%22logs%22&displayedFields=%5B%5D&timezone=browser&var-all-fields=&var-labelBy=$__all", - "id": "LABEL_BREAKDOWN_VALUES_service_name", - "title": "service_name", - "logoPath": "public/plugins/grafana-lokiexplore-app/img/img/logo.svg", - "createdAt": "2025-02-13T11:31:41.507Z" - }, - { - "origin": "Explore Logs", - "type": "timeseries", - "queries": [ - { - "refId": "LABEL_BREAKDOWN_VALUES", - "queryType": "range", - "editorMode": "code", - "supportingQueryType": "grafana-lokiexplore-app", - "legendFormat": "{{service}}", - "expr": "sum(count_over_time({service_name=\"web_app_1\",service != \"\"} [$__auto])) by (service)" - } - ], - "timeRange": { - "to": "2025-02-13T11:31:20.536Z", - "from": "2025-02-13T11:16:20.536Z", - "raw": { - "from": "now-15m", - "to": "now" - } - }, - "datasource": { - "uid": "fe9k7u07b1a0wc" - }, - "url": "http://localhost:3000/a/grafana-lokiexplore-app/explore/service/web_app_1/labels?patterns=%5B%5D&from=now-15m&to=now&var-ds=fe9k7u07b1a0wc&var-filters=service_name%7C%3D%7Cweb_app_1&var-fields=&var-levels=&var-metadata=&var-patterns=&var-lineFilterV2=&var-lineFilters=&urlColumns=%5B%5D&visualizationType=%22logs%22&displayedFields=%5B%5D&timezone=browser&var-all-fields=&var-labelBy=$__all", - "id": "LABEL_BREAKDOWN_VALUES_service", - "title": "service", - "logoPath": "public/plugins/grafana-lokiexplore-app/img/img/logo.svg", - "createdAt": "2025-02-13T11:31:43.698Z" - } - ], - "createdAt": "2025-02-13T11:31:23.637Z", - "updatedAt": "2025-02-13T11:31:43.698Z", - "viewMode": { - "mode": "compact", - "showComments": true, - "showTooltips": false - } - } -] diff --git a/apps/investigations/go.mod b/apps/investigations/go.mod deleted file mode 100644 index 40677b56dab..00000000000 --- a/apps/investigations/go.mod +++ /dev/null @@ -1,102 +0,0 @@ -module github.com/grafana/grafana/apps/investigations - -go 1.25.5 - -require ( - github.com/grafana/grafana-app-sdk v0.48.7 - k8s.io/apimachinery v0.34.3 - k8s.io/klog/v2 v2.130.1 - k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e -) - -require ( - github.com/beorn7/perks v1.0.1 // indirect - github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect - github.com/cenkalti/backoff/v5 v5.0.3 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/emicklei/go-restful/v3 v3.13.0 // indirect - github.com/evanphx/json-patch v5.9.11+incompatible // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/getkin/kin-openapi v0.133.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.22.4 // indirect - github.com/go-openapi/jsonreference v0.21.4 // indirect - github.com/go-openapi/swag v0.25.4 // indirect - github.com/go-openapi/swag/cmdutils v0.25.4 // indirect - github.com/go-openapi/swag/conv v0.25.4 // indirect - github.com/go-openapi/swag/fileutils v0.25.4 // indirect - github.com/go-openapi/swag/jsonname v0.25.4 // indirect - github.com/go-openapi/swag/jsonutils v0.25.4 // indirect - github.com/go-openapi/swag/loading v0.25.4 // indirect - github.com/go-openapi/swag/mangling v0.25.4 // indirect - github.com/go-openapi/swag/netutils v0.25.4 // indirect - github.com/go-openapi/swag/stringutils v0.25.4 // indirect - github.com/go-openapi/swag/typeutils v0.25.4 // indirect - github.com/go-openapi/swag/yamlutils v0.25.4 // indirect - github.com/go-test/deep v1.1.1 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/gnostic-models v0.7.1 // indirect - 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.48.7 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // 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 - github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.9.1 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect - github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect - github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect - github.com/onsi/ginkgo/v2 v2.22.2 // indirect - github.com/onsi/gomega v1.36.2 // indirect - github.com/perimeterx/marshmallow v1.1.5 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.4 // indirect - github.com/prometheus/procfs v0.19.2 // indirect - github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/woodsbury/decimal128 v1.4.0 // indirect - github.com/x448/float16 v0.8.4 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/sdk v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/term v0.38.0 // indirect - golang.org/x/text v0.32.0 // indirect - golang.org/x/time v0.14.0 // indirect - gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect - google.golang.org/grpc v1.77.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.34.3 // indirect - k8s.io/apiextensions-apiserver v0.34.3 // indirect - k8s.io/client-go v0.34.3 // indirect - k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect - sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect - sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect -) diff --git a/apps/investigations/go.sum b/apps/investigations/go.sum deleted file mode 100644 index e5233ba82c9..00000000000 --- a/apps/investigations/go.sum +++ /dev/null @@ -1,264 +0,0 @@ -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/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I= -github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= -github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= -github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= -github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= -github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= -github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= -github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= -github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= -github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= -github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= -github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= -github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= -github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= -github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= -github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= -github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= -github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= -github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= -github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= -github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= -github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= -github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= -github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= -github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= -github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= -github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= -github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= -github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= -github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= -github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= -github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= -github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= -github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= -github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= -github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= -github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= -github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= -github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= -github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= -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.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfUHB32u2ZMo= -github.com/grafana/grafana-app-sdk v0.48.7/go.mod h1:DWsaaH39ZMHwSOSoUBaeW8paMrRaYsjRYlLwCJYd78k= -github.com/grafana/grafana-app-sdk/logging v0.48.7 h1:Oa5qg473gka5+W/WQk61Xbw4YdAv+wV2Z4bJtzeCaQw= -github.com/grafana/grafana-app-sdk/logging v0.48.7/go.mod h1:5u3KalezoBAAo2Y3ytDYDAIIPvEqFLLDSxeiK99QxDU= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= -github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= -github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -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= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= -github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= -github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= -github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= -github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= -github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= -github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= -github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= -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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= -github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= -github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= -github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= -github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= -gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU= -google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= -google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= -gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= -gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= -gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4= -k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk= -k8s.io/apiextensions-apiserver v0.34.3 h1:p10fGlkDY09eWKOTeUSioxwLukJnm+KuDZdrW71y40g= -k8s.io/apiextensions-apiserver v0.34.3/go.mod h1:aujxvqGFRdb/cmXYfcRTeppN7S2XV/t7WMEc64zB5A0= -k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= -k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= -k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= -k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ= -k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= -sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= -sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= -sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= -sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= -sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= -sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/investigations/kinds/collectable.cue b/apps/investigations/kinds/collectable.cue deleted file mode 100644 index f9fc754a67c..00000000000 --- a/apps/investigations/kinds/collectable.cue +++ /dev/null @@ -1,43 +0,0 @@ -package investigations - -// Collectable represents an item collected during investigation -#Collectable: { - id: string - createdAt: string - - title: string - origin: string - type: string - queries: [...string] // +listType=atomic - timeRange: #TimeRange - datasource: #DatasourceRef - url: string - logoPath?: string - - note: string - noteUpdatedAt: string - - fieldConfig: string -} - -#CollectableSummary: { - id: string - title: string - logoPath: string - origin: string -} - -// TimeRange represents a time range with both absolute and relative values -#TimeRange: { - from: string - to: string - raw: { - from: string - to: string - } -} - -// DatasourceRef is a reference to a datasource -#DatasourceRef: { - uid: string -} diff --git a/apps/investigations/kinds/cue.mod/module.cue b/apps/investigations/kinds/cue.mod/module.cue deleted file mode 100644 index c592b1ce788..00000000000 --- a/apps/investigations/kinds/cue.mod/module.cue +++ /dev/null @@ -1,4 +0,0 @@ -module: "github.com/grafana/grafana/apps/investigations" -language: { - version: "v0.11.0" -} \ No newline at end of file diff --git a/apps/investigations/kinds/investigation.cue b/apps/investigations/kinds/investigation.cue deleted file mode 100644 index 1ade89416b9..00000000000 --- a/apps/investigations/kinds/investigation.cue +++ /dev/null @@ -1,43 +0,0 @@ -package investigations - -investigationV0alpha1: { - kind: "Investigation" - pluralName: "Investigations" - schema: { - spec: { - title: string - createdByProfile: #Person - hasCustomName: bool - isFavorite: bool - overviewNote: string - overviewNoteUpdatedAt: string - collectables: [...#Collectable] // +listType=atomic - viewMode: #ViewMode - } - } -} - -// Type definition for investigation summaries -#InvestigationSummary: { - title: string - createdByProfile: #Person - hasCustomName: bool - isFavorite: bool - overviewNote: string - overviewNoteUpdatedAt: string - viewMode: #ViewMode - collectableSummaries: [...#CollectableSummary] // +listType=atomic -} - -// Person represents a user profile with basic information -#Person: { - uid: string // Unique identifier for the user - name: string // Display name of the user - gravatarUrl: string // URL to user's Gravatar image -} - -#ViewMode: { - mode: "compact" | "full" - showComments: bool - showTooltips: bool -} diff --git a/apps/investigations/kinds/investigationindex.cue b/apps/investigations/kinds/investigationindex.cue deleted file mode 100644 index 65c5e3ae79f..00000000000 --- a/apps/investigations/kinds/investigationindex.cue +++ /dev/null @@ -1,18 +0,0 @@ -package investigations - -investigationIndexV0alpha1:{ - kind: "InvestigationIndex" - pluralName: "InvestigationIndexes" - schema: { - spec: { - // Title of the index, e.g. 'Favorites' or 'My Investigations' - title: string - - // The Person who owns this investigation index - owner: #Person - - // Array of investigation summaries - investigationSummaries: [...#InvestigationSummary] // +listType=atomic - } - } -} diff --git a/apps/investigations/kinds/manifest.cue b/apps/investigations/kinds/manifest.cue deleted file mode 100644 index 947d84be482..00000000000 --- a/apps/investigations/kinds/manifest.cue +++ /dev/null @@ -1,18 +0,0 @@ -package investigations - -manifest: { - appName: "investigations" - groupOverride: "investigations.grafana.app" - versions: { - "v0alpha1": { - codegen: { - ts: {enabled: false} - go: {enabled: true} - } - kinds: [ - investigationV0alpha1, - investigationIndexV0alpha1, - ] - } - } -} \ No newline at end of file diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/constants.go b/apps/investigations/pkg/apis/investigations/v0alpha1/constants.go deleted file mode 100644 index bd538eb9c30..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/constants.go +++ /dev/null @@ -1,18 +0,0 @@ -package v0alpha1 - -import "k8s.io/apimachinery/pkg/runtime/schema" - -const ( - // APIGroup is the API group used by all kinds in this package - APIGroup = "investigations.grafana.app" - // APIVersion is the API version used by all kinds in this package - APIVersion = "v0alpha1" -) - -var ( - // GroupVersion is a schema.GroupVersion consisting of the Group and Version constants for this package - GroupVersion = schema.GroupVersion{ - Group: APIGroup, - Version: APIVersion, - } -) diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_client_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_client_gen.go deleted file mode 100644 index dea4d71802d..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_client_gen.go +++ /dev/null @@ -1,80 +0,0 @@ -package v0alpha1 - -import ( - "context" - - "github.com/grafana/grafana-app-sdk/resource" -) - -type InvestigationClient struct { - client *resource.TypedClient[*Investigation, *InvestigationList] -} - -func NewInvestigationClient(client resource.Client) *InvestigationClient { - return &InvestigationClient{ - client: resource.NewTypedClient[*Investigation, *InvestigationList](client, InvestigationKind()), - } -} - -func NewInvestigationClientFromGenerator(generator resource.ClientGenerator) (*InvestigationClient, error) { - c, err := generator.ClientFor(InvestigationKind()) - if err != nil { - return nil, err - } - return NewInvestigationClient(c), nil -} - -func (c *InvestigationClient) Get(ctx context.Context, identifier resource.Identifier) (*Investigation, error) { - return c.client.Get(ctx, identifier) -} - -func (c *InvestigationClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*InvestigationList, error) { - return c.client.List(ctx, namespace, opts) -} - -func (c *InvestigationClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*InvestigationList, error) { - resp, err := c.client.List(ctx, namespace, resource.ListOptions{ - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - for resp.GetContinue() != "" { - page, err := c.client.List(ctx, namespace, resource.ListOptions{ - Continue: resp.GetContinue(), - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - resp.SetContinue(page.GetContinue()) - resp.SetResourceVersion(page.GetResourceVersion()) - resp.SetItems(append(resp.GetItems(), page.GetItems()...)) - } - return resp, nil -} - -func (c *InvestigationClient) Create(ctx context.Context, obj *Investigation, opts resource.CreateOptions) (*Investigation, error) { - // Make sure apiVersion and kind are set - obj.APIVersion = GroupVersion.Identifier() - obj.Kind = InvestigationKind().Kind() - return c.client.Create(ctx, obj, opts) -} - -func (c *InvestigationClient) Update(ctx context.Context, obj *Investigation, opts resource.UpdateOptions) (*Investigation, error) { - return c.client.Update(ctx, obj, opts) -} - -func (c *InvestigationClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*Investigation, error) { - return c.client.Patch(ctx, identifier, req, opts) -} - -func (c *InvestigationClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { - return c.client.Delete(ctx, identifier, opts) -} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_codec_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_codec_gen.go deleted file mode 100644 index 6bde81b50d3..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_codec_gen.go +++ /dev/null @@ -1,28 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v0alpha1 - -import ( - "encoding/json" - "io" - - "github.com/grafana/grafana-app-sdk/resource" -) - -// InvestigationJSONCodec is an implementation of resource.Codec for kubernetes JSON encoding -type InvestigationJSONCodec struct{} - -// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` -func (*InvestigationJSONCodec) Read(reader io.Reader, into resource.Object) error { - return json.NewDecoder(reader).Decode(into) -} - -// Write writes JSON-encoded bytes into `writer` marshaled from `from` -func (*InvestigationJSONCodec) Write(writer io.Writer, from resource.Object) error { - return json.NewEncoder(writer).Encode(from) -} - -// Interface compliance checks -var _ resource.Codec = &InvestigationJSONCodec{} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_metadata_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_metadata_gen.go deleted file mode 100644 index ffd588fec5e..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_metadata_gen.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v0alpha1 - -import ( - time "time" -) - -// metadata contains embedded CommonMetadata and can be extended with custom string fields -// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -// without external reference as using the CommonMetadata reference breaks thema codegen. -type InvestigationMetadata struct { - UpdateTimestamp time.Time `json:"updateTimestamp"` - CreatedBy string `json:"createdBy"` - Uid string `json:"uid"` - CreationTimestamp time.Time `json:"creationTimestamp"` - DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` - Finalizers []string `json:"finalizers"` - ResourceVersion string `json:"resourceVersion"` - Generation int64 `json:"generation"` - UpdatedBy string `json:"updatedBy"` - Labels map[string]string `json:"labels"` -} - -// NewInvestigationMetadata creates a new InvestigationMetadata object. -func NewInvestigationMetadata() *InvestigationMetadata { - return &InvestigationMetadata{ - Finalizers: []string{}, - Labels: map[string]string{}, - } -} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_object_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_object_gen.go deleted file mode 100644 index f64d7b6e047..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_object_gen.go +++ /dev/null @@ -1,293 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v0alpha1 - -import ( - "fmt" - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "time" -) - -// +k8s:openapi-gen=true -type Investigation struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ObjectMeta `json:"metadata" yaml:"metadata"` - - // Spec is the spec of the Investigation - Spec InvestigationSpec `json:"spec" yaml:"spec"` -} - -func (o *Investigation) GetSpec() any { - return o.Spec -} - -func (o *Investigation) SetSpec(spec any) error { - cast, ok := spec.(InvestigationSpec) - if !ok { - return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) - } - o.Spec = cast - return nil -} - -func (o *Investigation) GetSubresources() map[string]any { - return map[string]any{} -} - -func (o *Investigation) GetSubresource(name string) (any, bool) { - switch name { - default: - return nil, false - } -} - -func (o *Investigation) SetSubresource(name string, value any) error { - switch name { - default: - return fmt.Errorf("subresource '%s' does not exist", name) - } -} - -func (o *Investigation) GetStaticMetadata() resource.StaticMetadata { - gvk := o.GroupVersionKind() - return resource.StaticMetadata{ - Name: o.ObjectMeta.Name, - Namespace: o.ObjectMeta.Namespace, - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - } -} - -func (o *Investigation) SetStaticMetadata(metadata resource.StaticMetadata) { - o.Name = metadata.Name - o.Namespace = metadata.Namespace - o.SetGroupVersionKind(schema.GroupVersionKind{ - Group: metadata.Group, - Version: metadata.Version, - Kind: metadata.Kind, - }) -} - -func (o *Investigation) GetCommonMetadata() resource.CommonMetadata { - dt := o.DeletionTimestamp - var deletionTimestamp *time.Time - if dt != nil { - deletionTimestamp = &dt.Time - } - // Legacy ExtraFields support - extraFields := make(map[string]any) - if o.Annotations != nil { - extraFields["annotations"] = o.Annotations - } - if o.ManagedFields != nil { - extraFields["managedFields"] = o.ManagedFields - } - if o.OwnerReferences != nil { - extraFields["ownerReferences"] = o.OwnerReferences - } - return resource.CommonMetadata{ - UID: string(o.UID), - ResourceVersion: o.ResourceVersion, - Generation: o.Generation, - Labels: o.Labels, - CreationTimestamp: o.CreationTimestamp.Time, - DeletionTimestamp: deletionTimestamp, - Finalizers: o.Finalizers, - UpdateTimestamp: o.GetUpdateTimestamp(), - CreatedBy: o.GetCreatedBy(), - UpdatedBy: o.GetUpdatedBy(), - ExtraFields: extraFields, - } -} - -func (o *Investigation) SetCommonMetadata(metadata resource.CommonMetadata) { - o.UID = types.UID(metadata.UID) - o.ResourceVersion = metadata.ResourceVersion - o.Generation = metadata.Generation - o.Labels = metadata.Labels - o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) - if metadata.DeletionTimestamp != nil { - dt := metav1.NewTime(*metadata.DeletionTimestamp) - o.DeletionTimestamp = &dt - } else { - o.DeletionTimestamp = nil - } - o.Finalizers = metadata.Finalizers - if o.Annotations == nil { - o.Annotations = make(map[string]string) - } - if !metadata.UpdateTimestamp.IsZero() { - o.SetUpdateTimestamp(metadata.UpdateTimestamp) - } - if metadata.CreatedBy != "" { - o.SetCreatedBy(metadata.CreatedBy) - } - if metadata.UpdatedBy != "" { - o.SetUpdatedBy(metadata.UpdatedBy) - } - // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields - if metadata.ExtraFields != nil { - if annotations, ok := metadata.ExtraFields["annotations"]; ok { - if cast, ok := annotations.(map[string]string); ok { - o.Annotations = cast - } - } - if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { - if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { - o.ManagedFields = cast - } - } - if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { - if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { - o.OwnerReferences = cast - } - } - } -} - -func (o *Investigation) GetCreatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/createdBy"] -} - -func (o *Investigation) SetCreatedBy(createdBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy -} - -func (o *Investigation) GetUpdateTimestamp() time.Time { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) - return parsed -} - -func (o *Investigation) SetUpdateTimestamp(updateTimestamp time.Time) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) -} - -func (o *Investigation) GetUpdatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/updatedBy"] -} - -func (o *Investigation) SetUpdatedBy(updatedBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy -} - -func (o *Investigation) Copy() resource.Object { - return resource.CopyObject(o) -} - -func (o *Investigation) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *Investigation) DeepCopy() *Investigation { - cpy := &Investigation{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *Investigation) DeepCopyInto(dst *Investigation) { - dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion - dst.TypeMeta.Kind = o.TypeMeta.Kind - o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) - o.Spec.DeepCopyInto(&dst.Spec) -} - -// Interface compliance compile-time check -var _ resource.Object = &Investigation{} - -// +k8s:openapi-gen=true -type InvestigationList struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ListMeta `json:"metadata" yaml:"metadata"` - Items []Investigation `json:"items" yaml:"items"` -} - -func (o *InvestigationList) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *InvestigationList) Copy() resource.ListObject { - cpy := &InvestigationList{ - TypeMeta: o.TypeMeta, - Items: make([]Investigation, len(o.Items)), - } - o.ListMeta.DeepCopyInto(&cpy.ListMeta) - for i := 0; i < len(o.Items); i++ { - if item, ok := o.Items[i].Copy().(*Investigation); ok { - cpy.Items[i] = *item - } - } - return cpy -} - -func (o *InvestigationList) GetItems() []resource.Object { - items := make([]resource.Object, len(o.Items)) - for i := 0; i < len(o.Items); i++ { - items[i] = &o.Items[i] - } - return items -} - -func (o *InvestigationList) SetItems(items []resource.Object) { - o.Items = make([]Investigation, len(items)) - for i := 0; i < len(items); i++ { - o.Items[i] = *items[i].(*Investigation) - } -} - -func (o *InvestigationList) DeepCopy() *InvestigationList { - cpy := &InvestigationList{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *InvestigationList) DeepCopyInto(dst *InvestigationList) { - resource.CopyObjectInto(dst, o) -} - -// Interface compliance compile-time check -var _ resource.ListObject = &InvestigationList{} - -// Copy methods for all subresource types - -// DeepCopy creates a full deep copy of Spec -func (s *InvestigationSpec) DeepCopy() *InvestigationSpec { - cpy := &InvestigationSpec{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Spec into another Spec object -func (s *InvestigationSpec) DeepCopyInto(dst *InvestigationSpec) { - resource.CopyObjectInto(dst, s) -} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_schema_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_schema_gen.go deleted file mode 100644 index 2a1f03c884a..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_schema_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v0alpha1 - -import ( - "github.com/grafana/grafana-app-sdk/resource" -) - -// schema is unexported to prevent accidental overwrites -var ( - schemaInvestigation = resource.NewSimpleSchema("investigations.grafana.app", "v0alpha1", &Investigation{}, &InvestigationList{}, resource.WithKind("Investigation"), - resource.WithPlural("investigations"), resource.WithScope(resource.NamespacedScope)) - kindInvestigation = resource.Kind{ - Schema: schemaInvestigation, - Codecs: map[resource.KindEncoding]resource.Codec{ - resource.KindEncodingJSON: &InvestigationJSONCodec{}, - }, - } -) - -// Kind returns a resource.Kind for this Schema with a JSON codec -func InvestigationKind() resource.Kind { - return kindInvestigation -} - -// Schema returns a resource.SimpleSchema representation of Investigation -func InvestigationSchema() *resource.SimpleSchema { - return schemaInvestigation -} - -// Interface compliance checks -var _ resource.Schema = kindInvestigation diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_spec_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_spec_gen.go deleted file mode 100644 index 88d74be54eb..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_spec_gen.go +++ /dev/null @@ -1,126 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v0alpha1 - -// Person represents a user profile with basic information -// +k8s:openapi-gen=true -type InvestigationPerson struct { - // Unique identifier for the user - Uid string `json:"uid"` - // Display name of the user - Name string `json:"name"` - // URL to user's Gravatar image - GravatarUrl string `json:"gravatarUrl"` -} - -// NewInvestigationPerson creates a new InvestigationPerson object. -func NewInvestigationPerson() *InvestigationPerson { - return &InvestigationPerson{} -} - -// Collectable represents an item collected during investigation -// +k8s:openapi-gen=true -type InvestigationCollectable struct { - Id string `json:"id"` - CreatedAt string `json:"createdAt"` - Title string `json:"title"` - Origin string `json:"origin"` - Type string `json:"type"` - // +listType=atomic - Queries []string `json:"queries"` - TimeRange InvestigationTimeRange `json:"timeRange"` - Datasource InvestigationDatasourceRef `json:"datasource"` - Url string `json:"url"` - LogoPath *string `json:"logoPath,omitempty"` - Note string `json:"note"` - NoteUpdatedAt string `json:"noteUpdatedAt"` - FieldConfig string `json:"fieldConfig"` -} - -// NewInvestigationCollectable creates a new InvestigationCollectable object. -func NewInvestigationCollectable() *InvestigationCollectable { - return &InvestigationCollectable{ - Queries: []string{}, - TimeRange: *NewInvestigationTimeRange(), - Datasource: *NewInvestigationDatasourceRef(), - } -} - -// TimeRange represents a time range with both absolute and relative values -// +k8s:openapi-gen=true -type InvestigationTimeRange struct { - From string `json:"from"` - To string `json:"to"` - Raw InvestigationV0alpha1TimeRangeRaw `json:"raw"` -} - -// NewInvestigationTimeRange creates a new InvestigationTimeRange object. -func NewInvestigationTimeRange() *InvestigationTimeRange { - return &InvestigationTimeRange{ - Raw: *NewInvestigationV0alpha1TimeRangeRaw(), - } -} - -// DatasourceRef is a reference to a datasource -// +k8s:openapi-gen=true -type InvestigationDatasourceRef struct { - Uid string `json:"uid"` -} - -// NewInvestigationDatasourceRef creates a new InvestigationDatasourceRef object. -func NewInvestigationDatasourceRef() *InvestigationDatasourceRef { - return &InvestigationDatasourceRef{} -} - -// +k8s:openapi-gen=true -type InvestigationViewMode struct { - Mode InvestigationViewModeMode `json:"mode"` - ShowComments bool `json:"showComments"` - ShowTooltips bool `json:"showTooltips"` -} - -// NewInvestigationViewMode creates a new InvestigationViewMode object. -func NewInvestigationViewMode() *InvestigationViewMode { - return &InvestigationViewMode{} -} - -// +k8s:openapi-gen=true -type InvestigationSpec struct { - Title string `json:"title"` - CreatedByProfile InvestigationPerson `json:"createdByProfile"` - HasCustomName bool `json:"hasCustomName"` - IsFavorite bool `json:"isFavorite"` - OverviewNote string `json:"overviewNote"` - OverviewNoteUpdatedAt string `json:"overviewNoteUpdatedAt"` - // +listType=atomic - Collectables []InvestigationCollectable `json:"collectables"` - ViewMode InvestigationViewMode `json:"viewMode"` -} - -// NewInvestigationSpec creates a new InvestigationSpec object. -func NewInvestigationSpec() *InvestigationSpec { - return &InvestigationSpec{ - CreatedByProfile: *NewInvestigationPerson(), - Collectables: []InvestigationCollectable{}, - ViewMode: *NewInvestigationViewMode(), - } -} - -// +k8s:openapi-gen=true -type InvestigationV0alpha1TimeRangeRaw struct { - From string `json:"from"` - To string `json:"to"` -} - -// NewInvestigationV0alpha1TimeRangeRaw creates a new InvestigationV0alpha1TimeRangeRaw object. -func NewInvestigationV0alpha1TimeRangeRaw() *InvestigationV0alpha1TimeRangeRaw { - return &InvestigationV0alpha1TimeRangeRaw{} -} - -// +k8s:openapi-gen=true -type InvestigationViewModeMode string - -const ( - InvestigationViewModeModeCompact InvestigationViewModeMode = "compact" - InvestigationViewModeModeFull InvestigationViewModeMode = "full" -) diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_client_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_client_gen.go deleted file mode 100644 index dafe3e1012e..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_client_gen.go +++ /dev/null @@ -1,80 +0,0 @@ -package v0alpha1 - -import ( - "context" - - "github.com/grafana/grafana-app-sdk/resource" -) - -type InvestigationIndexClient struct { - client *resource.TypedClient[*InvestigationIndex, *InvestigationIndexList] -} - -func NewInvestigationIndexClient(client resource.Client) *InvestigationIndexClient { - return &InvestigationIndexClient{ - client: resource.NewTypedClient[*InvestigationIndex, *InvestigationIndexList](client, InvestigationIndexKind()), - } -} - -func NewInvestigationIndexClientFromGenerator(generator resource.ClientGenerator) (*InvestigationIndexClient, error) { - c, err := generator.ClientFor(InvestigationIndexKind()) - if err != nil { - return nil, err - } - return NewInvestigationIndexClient(c), nil -} - -func (c *InvestigationIndexClient) Get(ctx context.Context, identifier resource.Identifier) (*InvestigationIndex, error) { - return c.client.Get(ctx, identifier) -} - -func (c *InvestigationIndexClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*InvestigationIndexList, error) { - return c.client.List(ctx, namespace, opts) -} - -func (c *InvestigationIndexClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*InvestigationIndexList, error) { - resp, err := c.client.List(ctx, namespace, resource.ListOptions{ - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - for resp.GetContinue() != "" { - page, err := c.client.List(ctx, namespace, resource.ListOptions{ - Continue: resp.GetContinue(), - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - resp.SetContinue(page.GetContinue()) - resp.SetResourceVersion(page.GetResourceVersion()) - resp.SetItems(append(resp.GetItems(), page.GetItems()...)) - } - return resp, nil -} - -func (c *InvestigationIndexClient) Create(ctx context.Context, obj *InvestigationIndex, opts resource.CreateOptions) (*InvestigationIndex, error) { - // Make sure apiVersion and kind are set - obj.APIVersion = GroupVersion.Identifier() - obj.Kind = InvestigationIndexKind().Kind() - return c.client.Create(ctx, obj, opts) -} - -func (c *InvestigationIndexClient) Update(ctx context.Context, obj *InvestigationIndex, opts resource.UpdateOptions) (*InvestigationIndex, error) { - return c.client.Update(ctx, obj, opts) -} - -func (c *InvestigationIndexClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*InvestigationIndex, error) { - return c.client.Patch(ctx, identifier, req, opts) -} - -func (c *InvestigationIndexClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { - return c.client.Delete(ctx, identifier, opts) -} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_codec_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_codec_gen.go deleted file mode 100644 index 0496ce4af6f..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_codec_gen.go +++ /dev/null @@ -1,28 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v0alpha1 - -import ( - "encoding/json" - "io" - - "github.com/grafana/grafana-app-sdk/resource" -) - -// InvestigationIndexJSONCodec is an implementation of resource.Codec for kubernetes JSON encoding -type InvestigationIndexJSONCodec struct{} - -// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` -func (*InvestigationIndexJSONCodec) Read(reader io.Reader, into resource.Object) error { - return json.NewDecoder(reader).Decode(into) -} - -// Write writes JSON-encoded bytes into `writer` marshaled from `from` -func (*InvestigationIndexJSONCodec) Write(writer io.Writer, from resource.Object) error { - return json.NewEncoder(writer).Encode(from) -} - -// Interface compliance checks -var _ resource.Codec = &InvestigationIndexJSONCodec{} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_metadata_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_metadata_gen.go deleted file mode 100644 index e7bef02a137..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_metadata_gen.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v0alpha1 - -import ( - time "time" -) - -// metadata contains embedded CommonMetadata and can be extended with custom string fields -// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -// without external reference as using the CommonMetadata reference breaks thema codegen. -type InvestigationIndexMetadata struct { - UpdateTimestamp time.Time `json:"updateTimestamp"` - CreatedBy string `json:"createdBy"` - Uid string `json:"uid"` - CreationTimestamp time.Time `json:"creationTimestamp"` - DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` - Finalizers []string `json:"finalizers"` - ResourceVersion string `json:"resourceVersion"` - Generation int64 `json:"generation"` - UpdatedBy string `json:"updatedBy"` - Labels map[string]string `json:"labels"` -} - -// NewInvestigationIndexMetadata creates a new InvestigationIndexMetadata object. -func NewInvestigationIndexMetadata() *InvestigationIndexMetadata { - return &InvestigationIndexMetadata{ - Finalizers: []string{}, - Labels: map[string]string{}, - } -} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_object_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_object_gen.go deleted file mode 100644 index b8272b4fae7..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_object_gen.go +++ /dev/null @@ -1,293 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v0alpha1 - -import ( - "fmt" - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "time" -) - -// +k8s:openapi-gen=true -type InvestigationIndex struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ObjectMeta `json:"metadata" yaml:"metadata"` - - // Spec is the spec of the InvestigationIndex - Spec InvestigationIndexSpec `json:"spec" yaml:"spec"` -} - -func (o *InvestigationIndex) GetSpec() any { - return o.Spec -} - -func (o *InvestigationIndex) SetSpec(spec any) error { - cast, ok := spec.(InvestigationIndexSpec) - if !ok { - return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) - } - o.Spec = cast - return nil -} - -func (o *InvestigationIndex) GetSubresources() map[string]any { - return map[string]any{} -} - -func (o *InvestigationIndex) GetSubresource(name string) (any, bool) { - switch name { - default: - return nil, false - } -} - -func (o *InvestigationIndex) SetSubresource(name string, value any) error { - switch name { - default: - return fmt.Errorf("subresource '%s' does not exist", name) - } -} - -func (o *InvestigationIndex) GetStaticMetadata() resource.StaticMetadata { - gvk := o.GroupVersionKind() - return resource.StaticMetadata{ - Name: o.ObjectMeta.Name, - Namespace: o.ObjectMeta.Namespace, - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - } -} - -func (o *InvestigationIndex) SetStaticMetadata(metadata resource.StaticMetadata) { - o.Name = metadata.Name - o.Namespace = metadata.Namespace - o.SetGroupVersionKind(schema.GroupVersionKind{ - Group: metadata.Group, - Version: metadata.Version, - Kind: metadata.Kind, - }) -} - -func (o *InvestigationIndex) GetCommonMetadata() resource.CommonMetadata { - dt := o.DeletionTimestamp - var deletionTimestamp *time.Time - if dt != nil { - deletionTimestamp = &dt.Time - } - // Legacy ExtraFields support - extraFields := make(map[string]any) - if o.Annotations != nil { - extraFields["annotations"] = o.Annotations - } - if o.ManagedFields != nil { - extraFields["managedFields"] = o.ManagedFields - } - if o.OwnerReferences != nil { - extraFields["ownerReferences"] = o.OwnerReferences - } - return resource.CommonMetadata{ - UID: string(o.UID), - ResourceVersion: o.ResourceVersion, - Generation: o.Generation, - Labels: o.Labels, - CreationTimestamp: o.CreationTimestamp.Time, - DeletionTimestamp: deletionTimestamp, - Finalizers: o.Finalizers, - UpdateTimestamp: o.GetUpdateTimestamp(), - CreatedBy: o.GetCreatedBy(), - UpdatedBy: o.GetUpdatedBy(), - ExtraFields: extraFields, - } -} - -func (o *InvestigationIndex) SetCommonMetadata(metadata resource.CommonMetadata) { - o.UID = types.UID(metadata.UID) - o.ResourceVersion = metadata.ResourceVersion - o.Generation = metadata.Generation - o.Labels = metadata.Labels - o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) - if metadata.DeletionTimestamp != nil { - dt := metav1.NewTime(*metadata.DeletionTimestamp) - o.DeletionTimestamp = &dt - } else { - o.DeletionTimestamp = nil - } - o.Finalizers = metadata.Finalizers - if o.Annotations == nil { - o.Annotations = make(map[string]string) - } - if !metadata.UpdateTimestamp.IsZero() { - o.SetUpdateTimestamp(metadata.UpdateTimestamp) - } - if metadata.CreatedBy != "" { - o.SetCreatedBy(metadata.CreatedBy) - } - if metadata.UpdatedBy != "" { - o.SetUpdatedBy(metadata.UpdatedBy) - } - // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields - if metadata.ExtraFields != nil { - if annotations, ok := metadata.ExtraFields["annotations"]; ok { - if cast, ok := annotations.(map[string]string); ok { - o.Annotations = cast - } - } - if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { - if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { - o.ManagedFields = cast - } - } - if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { - if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { - o.OwnerReferences = cast - } - } - } -} - -func (o *InvestigationIndex) GetCreatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/createdBy"] -} - -func (o *InvestigationIndex) SetCreatedBy(createdBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy -} - -func (o *InvestigationIndex) GetUpdateTimestamp() time.Time { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) - return parsed -} - -func (o *InvestigationIndex) SetUpdateTimestamp(updateTimestamp time.Time) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) -} - -func (o *InvestigationIndex) GetUpdatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/updatedBy"] -} - -func (o *InvestigationIndex) SetUpdatedBy(updatedBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy -} - -func (o *InvestigationIndex) Copy() resource.Object { - return resource.CopyObject(o) -} - -func (o *InvestigationIndex) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *InvestigationIndex) DeepCopy() *InvestigationIndex { - cpy := &InvestigationIndex{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *InvestigationIndex) DeepCopyInto(dst *InvestigationIndex) { - dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion - dst.TypeMeta.Kind = o.TypeMeta.Kind - o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) - o.Spec.DeepCopyInto(&dst.Spec) -} - -// Interface compliance compile-time check -var _ resource.Object = &InvestigationIndex{} - -// +k8s:openapi-gen=true -type InvestigationIndexList struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ListMeta `json:"metadata" yaml:"metadata"` - Items []InvestigationIndex `json:"items" yaml:"items"` -} - -func (o *InvestigationIndexList) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *InvestigationIndexList) Copy() resource.ListObject { - cpy := &InvestigationIndexList{ - TypeMeta: o.TypeMeta, - Items: make([]InvestigationIndex, len(o.Items)), - } - o.ListMeta.DeepCopyInto(&cpy.ListMeta) - for i := 0; i < len(o.Items); i++ { - if item, ok := o.Items[i].Copy().(*InvestigationIndex); ok { - cpy.Items[i] = *item - } - } - return cpy -} - -func (o *InvestigationIndexList) GetItems() []resource.Object { - items := make([]resource.Object, len(o.Items)) - for i := 0; i < len(o.Items); i++ { - items[i] = &o.Items[i] - } - return items -} - -func (o *InvestigationIndexList) SetItems(items []resource.Object) { - o.Items = make([]InvestigationIndex, len(items)) - for i := 0; i < len(items); i++ { - o.Items[i] = *items[i].(*InvestigationIndex) - } -} - -func (o *InvestigationIndexList) DeepCopy() *InvestigationIndexList { - cpy := &InvestigationIndexList{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *InvestigationIndexList) DeepCopyInto(dst *InvestigationIndexList) { - resource.CopyObjectInto(dst, o) -} - -// Interface compliance compile-time check -var _ resource.ListObject = &InvestigationIndexList{} - -// Copy methods for all subresource types - -// DeepCopy creates a full deep copy of Spec -func (s *InvestigationIndexSpec) DeepCopy() *InvestigationIndexSpec { - cpy := &InvestigationIndexSpec{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Spec into another Spec object -func (s *InvestigationIndexSpec) DeepCopyInto(dst *InvestigationIndexSpec) { - resource.CopyObjectInto(dst, s) -} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_schema_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_schema_gen.go deleted file mode 100644 index 3a0041dc6b8..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_schema_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v0alpha1 - -import ( - "github.com/grafana/grafana-app-sdk/resource" -) - -// schema is unexported to prevent accidental overwrites -var ( - schemaInvestigationIndex = resource.NewSimpleSchema("investigations.grafana.app", "v0alpha1", &InvestigationIndex{}, &InvestigationIndexList{}, resource.WithKind("InvestigationIndex"), - resource.WithPlural("investigationindexes"), resource.WithScope(resource.NamespacedScope)) - kindInvestigationIndex = resource.Kind{ - Schema: schemaInvestigationIndex, - Codecs: map[resource.KindEncoding]resource.Codec{ - resource.KindEncodingJSON: &InvestigationIndexJSONCodec{}, - }, - } -) - -// Kind returns a resource.Kind for this Schema with a JSON codec -func InvestigationIndexKind() resource.Kind { - return kindInvestigationIndex -} - -// Schema returns a resource.SimpleSchema representation of InvestigationIndex -func InvestigationIndexSchema() *resource.SimpleSchema { - return schemaInvestigationIndex -} - -// Interface compliance checks -var _ resource.Schema = kindInvestigationIndex diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_spec_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_spec_gen.go deleted file mode 100644 index 44be927c850..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_spec_gen.go +++ /dev/null @@ -1,94 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v0alpha1 - -// Person represents a user profile with basic information -// +k8s:openapi-gen=true -type InvestigationIndexPerson struct { - // Unique identifier for the user - Uid string `json:"uid"` - // Display name of the user - Name string `json:"name"` - // URL to user's Gravatar image - GravatarUrl string `json:"gravatarUrl"` -} - -// NewInvestigationIndexPerson creates a new InvestigationIndexPerson object. -func NewInvestigationIndexPerson() *InvestigationIndexPerson { - return &InvestigationIndexPerson{} -} - -// Type definition for investigation summaries -// +k8s:openapi-gen=true -type InvestigationIndexInvestigationSummary struct { - Title string `json:"title"` - CreatedByProfile InvestigationIndexPerson `json:"createdByProfile"` - HasCustomName bool `json:"hasCustomName"` - IsFavorite bool `json:"isFavorite"` - OverviewNote string `json:"overviewNote"` - OverviewNoteUpdatedAt string `json:"overviewNoteUpdatedAt"` - ViewMode InvestigationIndexViewMode `json:"viewMode"` - // +listType=atomic - CollectableSummaries []InvestigationIndexCollectableSummary `json:"collectableSummaries"` -} - -// NewInvestigationIndexInvestigationSummary creates a new InvestigationIndexInvestigationSummary object. -func NewInvestigationIndexInvestigationSummary() *InvestigationIndexInvestigationSummary { - return &InvestigationIndexInvestigationSummary{ - CreatedByProfile: *NewInvestigationIndexPerson(), - ViewMode: *NewInvestigationIndexViewMode(), - CollectableSummaries: []InvestigationIndexCollectableSummary{}, - } -} - -// +k8s:openapi-gen=true -type InvestigationIndexViewMode struct { - Mode InvestigationIndexViewModeMode `json:"mode"` - ShowComments bool `json:"showComments"` - ShowTooltips bool `json:"showTooltips"` -} - -// NewInvestigationIndexViewMode creates a new InvestigationIndexViewMode object. -func NewInvestigationIndexViewMode() *InvestigationIndexViewMode { - return &InvestigationIndexViewMode{} -} - -// +k8s:openapi-gen=true -type InvestigationIndexCollectableSummary struct { - Id string `json:"id"` - Title string `json:"title"` - LogoPath string `json:"logoPath"` - Origin string `json:"origin"` -} - -// NewInvestigationIndexCollectableSummary creates a new InvestigationIndexCollectableSummary object. -func NewInvestigationIndexCollectableSummary() *InvestigationIndexCollectableSummary { - return &InvestigationIndexCollectableSummary{} -} - -// +k8s:openapi-gen=true -type InvestigationIndexSpec struct { - // Title of the index, e.g. 'Favorites' or 'My Investigations' - Title string `json:"title"` - // The Person who owns this investigation index - Owner InvestigationIndexPerson `json:"owner"` - // Array of investigation summaries - // +listType=atomic - InvestigationSummaries []InvestigationIndexInvestigationSummary `json:"investigationSummaries"` -} - -// NewInvestigationIndexSpec creates a new InvestigationIndexSpec object. -func NewInvestigationIndexSpec() *InvestigationIndexSpec { - return &InvestigationIndexSpec{ - Owner: *NewInvestigationIndexPerson(), - InvestigationSummaries: []InvestigationIndexInvestigationSummary{}, - } -} - -// +k8s:openapi-gen=true -type InvestigationIndexViewModeMode string - -const ( - InvestigationIndexViewModeModeCompact InvestigationIndexViewModeMode = "compact" - InvestigationIndexViewModeModeFull InvestigationIndexViewModeMode = "full" -) diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_status_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_status_gen.go deleted file mode 100644 index 455d39bdb48..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_status_gen.go +++ /dev/null @@ -1,44 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v0alpha1 - -// +k8s:openapi-gen=true -type InvestigationIndexstatusOperatorState struct { - // lastEvaluation is the ResourceVersion last evaluated - LastEvaluation string `json:"lastEvaluation"` - // state describes the state of the lastEvaluation. - // It is limited to three possible states for machine evaluation. - State InvestigationIndexStatusOperatorStateState `json:"state"` - // descriptiveState is an optional more descriptive state field which has no requirements on format - DescriptiveState *string `json:"descriptiveState,omitempty"` - // details contains any extra information that is operator-specific - Details map[string]interface{} `json:"details,omitempty"` -} - -// NewInvestigationIndexstatusOperatorState creates a new InvestigationIndexstatusOperatorState object. -func NewInvestigationIndexstatusOperatorState() *InvestigationIndexstatusOperatorState { - return &InvestigationIndexstatusOperatorState{} -} - -// +k8s:openapi-gen=true -type InvestigationIndexStatus struct { - // operatorStates is a map of operator ID to operator state evaluations. - // Any operator which consumes this kind SHOULD add its state evaluation information to this field. - OperatorStates map[string]InvestigationIndexstatusOperatorState `json:"operatorStates,omitempty"` - // additionalFields is reserved for future use - AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` -} - -// NewInvestigationIndexStatus creates a new InvestigationIndexStatus object. -func NewInvestigationIndexStatus() *InvestigationIndexStatus { - return &InvestigationIndexStatus{} -} - -// +k8s:openapi-gen=true -type InvestigationIndexStatusOperatorStateState string - -const ( - InvestigationIndexStatusOperatorStateStateSuccess InvestigationIndexStatusOperatorStateState = "success" - InvestigationIndexStatusOperatorStateStateInProgress InvestigationIndexStatusOperatorStateState = "in_progress" - InvestigationIndexStatusOperatorStateStateFailed InvestigationIndexStatusOperatorStateState = "failed" -) diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/zz_openapi_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/zz_openapi_gen.go deleted file mode 100644 index 4a095da63fc..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/zz_openapi_gen.go +++ /dev/null @@ -1,1014 +0,0 @@ -//go:build !ignore_autogenerated -// +build !ignore_autogenerated - -// Code generated by grafana-app-sdk. DO NOT EDIT. - -package v0alpha1 - -import ( - common "k8s.io/kube-openapi/pkg/common" - spec "k8s.io/kube-openapi/pkg/validation/spec" -) - -func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { - return map[string]common.OpenAPIDefinition{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.Investigation": schema_pkg_apis_investigations_v0alpha1_Investigation(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationCollectable": schema_pkg_apis_investigations_v0alpha1_InvestigationCollectable(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationDatasourceRef": schema_pkg_apis_investigations_v0alpha1_InvestigationDatasourceRef(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndex": schema_pkg_apis_investigations_v0alpha1_InvestigationIndex(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexCollectableSummary": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexCollectableSummary(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexInvestigationSummary": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexInvestigationSummary(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexList": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexList(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexPerson": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexPerson(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexSpec": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexSpec(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexStatus": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexStatus(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexViewMode": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexViewMode(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexstatusOperatorState": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexstatusOperatorState(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationList": schema_pkg_apis_investigations_v0alpha1_InvestigationList(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationPerson": schema_pkg_apis_investigations_v0alpha1_InvestigationPerson(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationSpec": schema_pkg_apis_investigations_v0alpha1_InvestigationSpec(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationStatus": schema_pkg_apis_investigations_v0alpha1_InvestigationStatus(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationTimeRange": schema_pkg_apis_investigations_v0alpha1_InvestigationTimeRange(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationV0alpha1TimeRangeRaw": schema_pkg_apis_investigations_v0alpha1_InvestigationV0alpha1TimeRangeRaw(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationViewMode": schema_pkg_apis_investigations_v0alpha1_InvestigationViewMode(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationstatusOperatorState": schema_pkg_apis_investigations_v0alpha1_InvestigationstatusOperatorState(ref), - } -} - -func schema_pkg_apis_investigations_v0alpha1_Investigation(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "metadata": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), - }, - }, - "spec": { - SchemaProps: spec.SchemaProps{ - Description: "Spec is the spec of the Investigation", - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationSpec"), - }, - }, - "status": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationStatus"), - }, - }, - }, - Required: []string{"metadata", "spec", "status"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationSpec", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationCollectable(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Collectable represents an item collected during investigation", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "id": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "createdAt": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "title": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "origin": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "type": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "queries": { - VendorExtensible: spec.VendorExtensible{ - Extensions: spec.Extensions{ - "x-kubernetes-list-type": "atomic", - }, - }, - SchemaProps: spec.SchemaProps{ - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - "timeRange": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationTimeRange"), - }, - }, - "datasource": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationDatasourceRef"), - }, - }, - "url": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "logoPath": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - "note": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "noteUpdatedAt": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "fieldConfig": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"id", "createdAt", "title", "origin", "type", "queries", "timeRange", "datasource", "url", "note", "noteUpdatedAt", "fieldConfig"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationDatasourceRef", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationTimeRange"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationDatasourceRef(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "DatasourceRef is a reference to a datasource", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "uid": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"uid"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndex(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "metadata": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), - }, - }, - "spec": { - SchemaProps: spec.SchemaProps{ - Description: "Spec is the spec of the InvestigationIndex", - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexSpec"), - }, - }, - "status": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexStatus"), - }, - }, - }, - Required: []string{"metadata", "spec", "status"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexSpec", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexCollectableSummary(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "id": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "title": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "logoPath": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "origin": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"id", "title", "logoPath", "origin"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexInvestigationSummary(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Type definition for investigation summaries", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "title": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "createdByProfile": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexPerson"), - }, - }, - "hasCustomName": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "isFavorite": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "overviewNote": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "overviewNoteUpdatedAt": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "viewMode": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexViewMode"), - }, - }, - "collectableSummaries": { - VendorExtensible: spec.VendorExtensible{ - Extensions: spec.Extensions{ - "x-kubernetes-list-type": "atomic", - }, - }, - SchemaProps: spec.SchemaProps{ - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexCollectableSummary"), - }, - }, - }, - }, - }, - }, - Required: []string{"title", "createdByProfile", "hasCustomName", "isFavorite", "overviewNote", "overviewNoteUpdatedAt", "viewMode", "collectableSummaries"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexCollectableSummary", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexPerson", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexViewMode"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexList(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "metadata": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), - }, - }, - "items": { - SchemaProps: spec.SchemaProps{ - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndex"), - }, - }, - }, - }, - }, - }, - Required: []string{"metadata", "items"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndex", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexPerson(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Person represents a user profile with basic information", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "uid": { - SchemaProps: spec.SchemaProps{ - Description: "Unique identifier for the user", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "name": { - SchemaProps: spec.SchemaProps{ - Description: "Display name of the user", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "gravatarUrl": { - SchemaProps: spec.SchemaProps{ - Description: "URL to user's Gravatar image", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"uid", "name", "gravatarUrl"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "title": { - SchemaProps: spec.SchemaProps{ - Description: "Title of the index, e.g. 'Favorites' or 'My Investigations'", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "owner": { - SchemaProps: spec.SchemaProps{ - Description: "The Person who owns this investigation index", - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexPerson"), - }, - }, - "investigationSummaries": { - VendorExtensible: spec.VendorExtensible{ - Extensions: spec.Extensions{ - "x-kubernetes-list-type": "atomic", - }, - }, - SchemaProps: spec.SchemaProps{ - Description: "Array of investigation summaries", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexInvestigationSummary"), - }, - }, - }, - }, - }, - }, - Required: []string{"title", "owner", "investigationSummaries"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexInvestigationSummary", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexPerson"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "operatorStates": { - SchemaProps: spec.SchemaProps{ - Description: "operatorStates is a map of operator ID to operator state evaluations. Any operator which consumes this kind SHOULD add its state evaluation information to this field.", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexstatusOperatorState"), - }, - }, - }, - }, - }, - "additionalFields": { - SchemaProps: spec.SchemaProps{ - Description: "additionalFields is reserved for future use", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Format: "", - }, - }, - }, - }, - }, - }, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexstatusOperatorState"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexViewMode(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "mode": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "showComments": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "showTooltips": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - }, - Required: []string{"mode", "showComments", "showTooltips"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexstatusOperatorState(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "lastEvaluation": { - SchemaProps: spec.SchemaProps{ - Description: "lastEvaluation is the ResourceVersion last evaluated", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "state": { - SchemaProps: spec.SchemaProps{ - Description: "state describes the state of the lastEvaluation. It is limited to three possible states for machine evaluation.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "descriptiveState": { - SchemaProps: spec.SchemaProps{ - Description: "descriptiveState is an optional more descriptive state field which has no requirements on format", - Type: []string{"string"}, - Format: "", - }, - }, - "details": { - SchemaProps: spec.SchemaProps{ - Description: "details contains any extra information that is operator-specific", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Format: "", - }, - }, - }, - }, - }, - }, - Required: []string{"lastEvaluation", "state"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationList(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "metadata": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), - }, - }, - "items": { - SchemaProps: spec.SchemaProps{ - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.Investigation"), - }, - }, - }, - }, - }, - }, - Required: []string{"metadata", "items"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.Investigation", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationPerson(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Person represents a user profile with basic information", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "uid": { - SchemaProps: spec.SchemaProps{ - Description: "Unique identifier for the user", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "name": { - SchemaProps: spec.SchemaProps{ - Description: "Display name of the user", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "gravatarUrl": { - SchemaProps: spec.SchemaProps{ - Description: "URL to user's Gravatar image", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"uid", "name", "gravatarUrl"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "spec is the schema of our resource", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "title": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "createdByProfile": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationPerson"), - }, - }, - "hasCustomName": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "isFavorite": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "overviewNote": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "overviewNoteUpdatedAt": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "collectables": { - VendorExtensible: spec.VendorExtensible{ - Extensions: spec.Extensions{ - "x-kubernetes-list-type": "atomic", - }, - }, - SchemaProps: spec.SchemaProps{ - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationCollectable"), - }, - }, - }, - }, - }, - "viewMode": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationViewMode"), - }, - }, - }, - Required: []string{"title", "createdByProfile", "hasCustomName", "isFavorite", "overviewNote", "overviewNoteUpdatedAt", "collectables", "viewMode"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationCollectable", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationPerson", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationViewMode"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "operatorStates": { - SchemaProps: spec.SchemaProps{ - Description: "operatorStates is a map of operator ID to operator state evaluations. Any operator which consumes this kind SHOULD add its state evaluation information to this field.", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationstatusOperatorState"), - }, - }, - }, - }, - }, - "additionalFields": { - SchemaProps: spec.SchemaProps{ - Description: "additionalFields is reserved for future use", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Format: "", - }, - }, - }, - }, - }, - }, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationstatusOperatorState"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationTimeRange(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "TimeRange represents a time range with both absolute and relative values", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "from": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "to": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "raw": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationV0alpha1TimeRangeRaw"), - }, - }, - }, - Required: []string{"from", "to", "raw"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationV0alpha1TimeRangeRaw"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationV0alpha1TimeRangeRaw(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "from": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "to": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"from", "to"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationViewMode(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "mode": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "showComments": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "showTooltips": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - }, - Required: []string{"mode", "showComments", "showTooltips"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationstatusOperatorState(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "lastEvaluation": { - SchemaProps: spec.SchemaProps{ - Description: "lastEvaluation is the ResourceVersion last evaluated", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "state": { - SchemaProps: spec.SchemaProps{ - Description: "state describes the state of the lastEvaluation. It is limited to three possible states for machine evaluation.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "descriptiveState": { - SchemaProps: spec.SchemaProps{ - Description: "descriptiveState is an optional more descriptive state field which has no requirements on format", - Type: []string{"string"}, - Format: "", - }, - }, - "details": { - SchemaProps: spec.SchemaProps{ - Description: "details contains any extra information that is operator-specific", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Format: "", - }, - }, - }, - }, - }, - }, - Required: []string{"lastEvaluation", "state"}, - }, - }, - } -} diff --git a/apps/investigations/pkg/apis/investigations_manifest.go b/apps/investigations/pkg/apis/investigations_manifest.go deleted file mode 100644 index 249e9b49758..00000000000 --- a/apps/investigations/pkg/apis/investigations_manifest.go +++ /dev/null @@ -1,136 +0,0 @@ -// -// This file is generated by grafana-app-sdk -// DO NOT EDIT -// - -package apis - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/grafana/grafana-app-sdk/app" - "github.com/grafana/grafana-app-sdk/resource" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/kube-openapi/pkg/spec3" - "k8s.io/kube-openapi/pkg/validation/spec" - - v0alpha1 "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1" -) - -var ( - rawSchemaInvestigationv0alpha1 = []byte(`{"Collectable":{"additionalProperties":false,"description":"Collectable represents an item collected during investigation","properties":{"createdAt":{"type":"string"},"datasource":{"$ref":"#/components/schemas/DatasourceRef"},"fieldConfig":{"type":"string"},"id":{"type":"string"},"logoPath":{"type":"string"},"note":{"type":"string"},"noteUpdatedAt":{"type":"string"},"origin":{"type":"string"},"queries":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"timeRange":{"$ref":"#/components/schemas/TimeRange"},"title":{"type":"string"},"type":{"type":"string"},"url":{"type":"string"}},"required":["id","createdAt","title","origin","type","queries","timeRange","datasource","url","note","noteUpdatedAt","fieldConfig"],"type":"object"},"DatasourceRef":{"additionalProperties":false,"description":"DatasourceRef is a reference to a datasource","properties":{"uid":{"type":"string"}},"required":["uid"],"type":"object"},"Investigation":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"Person":{"additionalProperties":false,"description":"Person represents a user profile with basic information","properties":{"gravatarUrl":{"description":"URL to user's Gravatar image","type":"string"},"name":{"description":"Display name of the user","type":"string"},"uid":{"description":"Unique identifier for the user","type":"string"}},"required":["uid","name","gravatarUrl"],"type":"object"},"TimeRange":{"additionalProperties":false,"description":"TimeRange represents a time range with both absolute and relative values","properties":{"from":{"type":"string"},"raw":{"additionalProperties":false,"properties":{"from":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"to":{"type":"string"}},"required":["from","to","raw"],"type":"object"},"ViewMode":{"additionalProperties":false,"properties":{"mode":{"enum":["compact","full"],"type":"string"},"showComments":{"type":"boolean"},"showTooltips":{"type":"boolean"}},"required":["mode","showComments","showTooltips"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"collectables":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Collectable"},"type":"array"},"createdByProfile":{"$ref":"#/components/schemas/Person"},"hasCustomName":{"type":"boolean"},"isFavorite":{"type":"boolean"},"overviewNote":{"type":"string"},"overviewNoteUpdatedAt":{"type":"string"},"title":{"type":"string"},"viewMode":{"$ref":"#/components/schemas/ViewMode"}},"required":["title","createdByProfile","hasCustomName","isFavorite","overviewNote","overviewNoteUpdatedAt","collectables","viewMode"],"type":"object"}}`) - versionSchemaInvestigationv0alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaInvestigationv0alpha1, &versionSchemaInvestigationv0alpha1) - rawSchemaInvestigationIndexv0alpha1 = []byte(`{"CollectableSummary":{"additionalProperties":false,"properties":{"id":{"type":"string"},"logoPath":{"type":"string"},"origin":{"type":"string"},"title":{"type":"string"}},"required":["id","title","logoPath","origin"],"type":"object"},"InvestigationIndex":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"InvestigationSummary":{"additionalProperties":false,"description":"Type definition for investigation summaries","properties":{"collectableSummaries":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/CollectableSummary"},"type":"array"},"createdByProfile":{"$ref":"#/components/schemas/Person"},"hasCustomName":{"type":"boolean"},"isFavorite":{"type":"boolean"},"overviewNote":{"type":"string"},"overviewNoteUpdatedAt":{"type":"string"},"title":{"type":"string"},"viewMode":{"$ref":"#/components/schemas/ViewMode"}},"required":["title","createdByProfile","hasCustomName","isFavorite","overviewNote","overviewNoteUpdatedAt","viewMode","collectableSummaries"],"type":"object"},"Person":{"additionalProperties":false,"description":"Person represents a user profile with basic information","properties":{"gravatarUrl":{"description":"URL to user's Gravatar image","type":"string"},"name":{"description":"Display name of the user","type":"string"},"uid":{"description":"Unique identifier for the user","type":"string"}},"required":["uid","name","gravatarUrl"],"type":"object"},"ViewMode":{"additionalProperties":false,"properties":{"mode":{"enum":["compact","full"],"type":"string"},"showComments":{"type":"boolean"},"showTooltips":{"type":"boolean"}},"required":["mode","showComments","showTooltips"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"investigationSummaries":{"description":"Array of investigation summaries\n+listType=atomic","items":{"$ref":"#/components/schemas/InvestigationSummary"},"type":"array"},"owner":{"$ref":"#/components/schemas/Person","description":"The Person who owns this investigation index"},"title":{"description":"Title of the index, e.g. 'Favorites' or 'My Investigations'","type":"string"}},"required":["title","owner","investigationSummaries"],"type":"object"}}`) - versionSchemaInvestigationIndexv0alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaInvestigationIndexv0alpha1, &versionSchemaInvestigationIndexv0alpha1) -) - -var appManifestData = app.ManifestData{ - AppName: "investigations", - Group: "investigations.grafana.app", - PreferredVersion: "v0alpha1", - Versions: []app.ManifestVersion{ - { - Name: "v0alpha1", - Served: true, - Kinds: []app.ManifestVersionKind{ - { - Kind: "Investigation", - Plural: "Investigations", - Scope: "Namespaced", - Conversion: false, - Schema: &versionSchemaInvestigationv0alpha1, - }, - - { - Kind: "InvestigationIndex", - Plural: "InvestigationIndexes", - Scope: "Namespaced", - Conversion: false, - Schema: &versionSchemaInvestigationIndexv0alpha1, - }, - }, - Routes: app.ManifestVersionRoutes{ - Namespaced: map[string]spec3.PathProps{}, - Cluster: map[string]spec3.PathProps{}, - Schemas: map[string]spec.Schema{}, - }, - }, - }, -} - -func LocalManifest() app.Manifest { - return app.NewEmbeddedManifest(appManifestData) -} - -func RemoteManifest() app.Manifest { - return app.NewAPIServerManifest("investigations") -} - -var kindVersionToGoType = map[string]resource.Kind{ - "Investigation/v0alpha1": v0alpha1.InvestigationKind(), - "InvestigationIndex/v0alpha1": v0alpha1.InvestigationIndexKind(), -} - -// ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists. -// If there is no association for the provided Kind and Version, exists will return false. -func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exists bool) { - goType, exists = kindVersionToGoType[fmt.Sprintf("%s/%s", kind, version)] - return goType, exists -} - -var customRouteToGoResponseType = map[string]any{} - -// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists. -// kind may be empty for custom routes which are not kind subroutes. Leading slashes are removed from subroute paths. -// If there is no association for the provided kind, version, custom route path, and method, exists will return false. -// Resource routes (those without a kind) should prefix their route with "/" if the route is namespaced (otherwise the route is assumed to be cluster-scope) -func ManifestCustomRouteResponsesAssociator(kind, version, path, verb string) (goType any, exists bool) { - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - goType, exists = customRouteToGoResponseType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] - return goType, exists -} - -var customRouteToGoParamsType = map[string]runtime.Object{} - -func ManifestCustomRouteQueryAssociator(kind, version, path, verb string) (goType runtime.Object, exists bool) { - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - goType, exists = customRouteToGoParamsType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] - return goType, exists -} - -var customRouteToGoRequestBodyType = map[string]any{} - -func ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb string) (goType any, exists bool) { - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - goType, exists = customRouteToGoRequestBodyType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] - return goType, exists -} - -type GoTypeAssociator struct{} - -func NewGoTypeAssociator() *GoTypeAssociator { - return &GoTypeAssociator{} -} - -func (g *GoTypeAssociator) KindToGoType(kind, version string) (goType resource.Kind, exists bool) { - return ManifestGoTypeAssociator(kind, version) -} -func (g *GoTypeAssociator) CustomRouteReturnGoType(kind, version, path, verb string) (goType any, exists bool) { - return ManifestCustomRouteResponsesAssociator(kind, version, path, verb) -} -func (g *GoTypeAssociator) CustomRouteQueryGoType(kind, version, path, verb string) (goType runtime.Object, exists bool) { - return ManifestCustomRouteQueryAssociator(kind, version, path, verb) -} -func (g *GoTypeAssociator) CustomRouteRequestBodyGoType(kind, version, path, verb string) (goType any, exists bool) { - return ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb) -} diff --git a/apps/investigations/pkg/app/investigations_app.go b/apps/investigations/pkg/app/investigations_app.go deleted file mode 100644 index 516d278788d..00000000000 --- a/apps/investigations/pkg/app/investigations_app.go +++ /dev/null @@ -1,62 +0,0 @@ -package app - -import ( - "context" - - "github.com/grafana/grafana-app-sdk/app" - "github.com/grafana/grafana-app-sdk/operator" - "github.com/grafana/grafana-app-sdk/resource" - "github.com/grafana/grafana-app-sdk/simple" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/klog/v2" - - investigationsv0alpha1 "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1" -) - -func New(cfg app.Config) (app.App, error) { - var err error - simpleConfig := simple.AppConfig{ - Name: "investigation", - KubeConfig: cfg.KubeConfig, - InformerConfig: simple.AppInformerConfig{ - InformerOptions: operator.InformerOptions{ - ErrorHandler: func(_ context.Context, err error) { - klog.ErrorS(err, "Informer processing error") - }, - }, - }, - ManagedKinds: []simple.AppManagedKind{ - { - Kind: investigationsv0alpha1.InvestigationKind(), - }, - { - Kind: investigationsv0alpha1.InvestigationIndexKind(), - }, - }, - } - - a, err := simple.NewApp(simpleConfig) - if err != nil { - return nil, err - } - - err = a.ValidateManifest(cfg.ManifestData) - if err != nil { - return nil, err - } - - return a, nil -} - -func GetKinds() map[schema.GroupVersion][]resource.Kind { - gv := schema.GroupVersion{ - Group: investigationsv0alpha1.InvestigationKind().Group(), - Version: investigationsv0alpha1.InvestigationKind().Version(), - } - return map[schema.GroupVersion][]resource.Kind{ - gv: { - investigationsv0alpha1.InvestigationKind(), - investigationsv0alpha1.InvestigationIndexKind(), - }, - } -} diff --git a/apps/investigations/plugin/src/generated/investigation/v0alpha1/types.spec.gen.ts b/apps/investigations/plugin/src/generated/investigation/v0alpha1/types.spec.gen.ts deleted file mode 100644 index c46622cd502..00000000000 --- a/apps/investigations/plugin/src/generated/investigation/v0alpha1/types.spec.gen.ts +++ /dev/null @@ -1,115 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -// Person represents a user profile with basic information -export interface Person { - // Unique identifier for the user - uid: string; - // Display name of the user - name: string; - // URL to user's Gravatar image - gravatarUrl: string; -} - -export const defaultPerson = (): Person => ({ - uid: "", - name: "", - gravatarUrl: "", -}); - -// Collectable represents an item collected during investigation -export interface Collectable { - id: string; - createdAt: string; - title: string; - origin: string; - type: string; - // +listType=atomic - queries: string[]; - timeRange: TimeRange; - datasource: DatasourceRef; - url: string; - logoPath?: string; - note: string; - noteUpdatedAt: string; - fieldConfig: string; -} - -export const defaultCollectable = (): Collectable => ({ - id: "", - createdAt: "", - title: "", - origin: "", - type: "", - queries: [], - timeRange: defaultTimeRange(), - datasource: defaultDatasourceRef(), - url: "", - note: "", - noteUpdatedAt: "", - fieldConfig: "", -}); - -// TimeRange represents a time range with both absolute and relative values -export interface TimeRange { - from: string; - to: string; - raw: { - from: string; - to: string; - }; -} - -export const defaultTimeRange = (): TimeRange => ({ - from: "", - to: "", - raw: { - from: "", - to: "", -}, -}); - -// DatasourceRef is a reference to a datasource -export interface DatasourceRef { - uid: string; -} - -export const defaultDatasourceRef = (): DatasourceRef => ({ - uid: "", -}); - -export interface ViewMode { - mode: "compact" | "full"; - showComments: boolean; - showTooltips: boolean; -} - -export const defaultViewMode = (): ViewMode => ({ - mode: "compact", - showComments: false, - showTooltips: false, -}); - -// spec is the schema of our resource -export interface Spec { - title: string; - createdByProfile: Person; - hasCustomName: boolean; - isFavorite: boolean; - overviewNote: string; - overviewNoteUpdatedAt: string; - // +listType=atomic - collectables: Collectable[]; - viewMode: ViewMode; -} - -export const defaultSpec = (): Spec => ({ - title: "", - createdByProfile: defaultPerson(), - hasCustomName: false, - isFavorite: false, - overviewNote: "", - overviewNoteUpdatedAt: "", - collectables: [], - viewMode: defaultViewMode(), -}); - diff --git a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.spec.gen.ts b/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.spec.gen.ts deleted file mode 100644 index f0d2f25d96b..00000000000 --- a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.spec.gen.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -// Person represents a user profile with basic information -export interface Person { - // Unique identifier for the user - uid: string; - // Display name of the user - name: string; - // URL to user's Gravatar image - gravatarUrl: string; -} - -export const defaultPerson = (): Person => ({ - uid: "", - name: "", - gravatarUrl: "", -}); - -// Type definition for investigation summaries -export interface InvestigationSummary { - title: string; - createdByProfile: Person; - hasCustomName: boolean; - isFavorite: boolean; - overviewNote: string; - overviewNoteUpdatedAt: string; - viewMode: ViewMode; - // +listType=atomic - collectableSummaries: CollectableSummary[]; -} - -export const defaultInvestigationSummary = (): InvestigationSummary => ({ - title: "", - createdByProfile: defaultPerson(), - hasCustomName: false, - isFavorite: false, - overviewNote: "", - overviewNoteUpdatedAt: "", - viewMode: defaultViewMode(), - collectableSummaries: [], -}); - -export interface ViewMode { - mode: "compact" | "full"; - showComments: boolean; - showTooltips: boolean; -} - -export const defaultViewMode = (): ViewMode => ({ - mode: "compact", - showComments: false, - showTooltips: false, -}); - -export interface CollectableSummary { - id: string; - title: string; - logoPath: string; - origin: string; -} - -export const defaultCollectableSummary = (): CollectableSummary => ({ - id: "", - title: "", - logoPath: "", - origin: "", -}); - -export interface Spec { - // Title of the index, e.g. 'Favorites' or 'My Investigations' - title: string; - // The Person who owns this investigation index - owner: Person; - // Array of investigation summaries - // +listType=atomic - investigationSummaries: InvestigationSummary[]; -} - -export const defaultSpec = (): Spec => ({ - title: "", - owner: defaultPerson(), - investigationSummaries: [], -}); - diff --git a/apps/logsdrilldown/definitions/logsdrilldown-manifest.json b/apps/logsdrilldown/definitions/logsdrilldown-manifest.json index da34c0c70f2..3ef92d3d5b8 100644 --- a/apps/logsdrilldown/definitions/logsdrilldown-manifest.json +++ b/apps/logsdrilldown/definitions/logsdrilldown-manifest.json @@ -191,7 +191,13 @@ } }, "conversion": false - }, + } + ] + }, + { + "name": "v1beta1", + "served": true, + "kinds": [ { "kind": "LogsDrilldownDefaultColumns", "plural": "LogsDrilldownDefaultColumns", @@ -314,6 +320,6 @@ ] } ], - "preferredVersion": "v1alpha1" + "preferredVersion": "v1beta1" } } diff --git a/apps/logsdrilldown/definitions/logsdrilldowndefaultcolumns.logsdrilldown.grafana.app.json b/apps/logsdrilldown/definitions/logsdrilldowndefaultcolumns.logsdrilldown.grafana.app.json index 28aa314311d..c8987741983 100644 --- a/apps/logsdrilldown/definitions/logsdrilldowndefaultcolumns.logsdrilldown.grafana.app.json +++ b/apps/logsdrilldown/definitions/logsdrilldowndefaultcolumns.logsdrilldown.grafana.app.json @@ -8,7 +8,7 @@ "group": "logsdrilldown.grafana.app", "versions": [ { - "name": "v1alpha1", + "name": "v1beta1", "served": true, "storage": true, "schema": { diff --git a/apps/logsdrilldown/kinds/logsdrilldown.cue b/apps/logsdrilldown/kinds/logsdrilldown.cue index d2752103820..4b2d815743d 100644 --- a/apps/logsdrilldown/kinds/logsdrilldown.cue +++ b/apps/logsdrilldown/kinds/logsdrilldown.cue @@ -1,7 +1,7 @@ package kinds import ( - "github.com/grafana/grafana/apps/logsdrilldown/kinds/v0alpha1" + "github.com/grafana/grafana/apps/logsdrilldown/kinds/v1beta1", ) LogsDrilldownSpecv1alpha1: { @@ -26,11 +26,11 @@ logsdrilldownDefaultsv1alpha1: { } } -// Default columns API -logsdrilldownDefaultColumnsv0alpha1: { +// Default columns API (beta) +logsdrilldownDefaultColumnsv1beta1: { kind: "LogsDrilldownDefaultColumns" pluralName: "LogsDrilldownDefaultColumns" schema: { - spec: v0alpha1.LogsDefaultColumns + spec: v1beta1.LogsDefaultColumns } } diff --git a/apps/logsdrilldown/kinds/manifest.cue b/apps/logsdrilldown/kinds/manifest.cue index ab717de6a92..cbd47ce2b65 100644 --- a/apps/logsdrilldown/kinds/manifest.cue +++ b/apps/logsdrilldown/kinds/manifest.cue @@ -15,6 +15,7 @@ manifest: { // If your app needs access to kinds managed by another app, use permissions.accessKinds to allow your app access. versions: { "v1alpha1": v1alpha1 + "v1beta1" : v1beta1 } // extraPermissions contains any additional permissions your app may require to function. // Your app will always have all permissions for each kind it manages (the items defined in 'kinds'). @@ -35,7 +36,40 @@ manifest: { // It includes kinds which the v1alpha1 API serves, and (future) custom routes served globally from the v1alpha1 version. v1alpha1: { // kinds is the list of kinds served by this version - kinds: [logsdrilldownv1alpha1, logsdrilldownDefaultsv1alpha1, logsdrilldownDefaultColumnsv0alpha1] + kinds: [logsdrilldownv1alpha1, logsdrilldownDefaultsv1alpha1] + // [OPTIONAL] + // served indicates whether this particular version is served by the API server. + // served should be set to false before a version is removed from the manifest entirely. + // served defaults to true if not present. + served: true + // [OPTIONAL] + // Codegen is a trait that tells the grafana-app-sdk, or other code generation tooling, how to process this kind. + // If not present, default values within the codegen trait are used. + // If you wish to specify codegen per-version, put this section in the version's object + // (for example, v1alpha1) instead. + codegen: { + // [OPTIONAL] + // ts contains TypeScript code generation properties for the kind + ts: { + // [OPTIONAL] + // enabled indicates whether the CLI should generate front-end TypeScript code for the kind. + // Defaults to true if not present. + enabled: true + } + // [OPTIONAL] + // go contains go code generation properties for the kind + go: { + // [OPTIONAL] + // enabled indicates whether the CLI should generate back-end go code for the kind. + // Defaults to true if not present. + enabled: true + } + } +} + +v1beta1: { + // kinds is the list of kinds served by this version + kinds: [logsdrilldownDefaultColumnsv1beta1] // [OPTIONAL] // served indicates whether this particular version is served by the API server. // served should be set to false before a version is removed from the manifest entirely. diff --git a/apps/logsdrilldown/kinds/v1beta1/defaultcolumns.cue b/apps/logsdrilldown/kinds/v1beta1/defaultcolumns.cue new file mode 100644 index 00000000000..a66e813766b --- /dev/null +++ b/apps/logsdrilldown/kinds/v1beta1/defaultcolumns.cue @@ -0,0 +1,19 @@ +package v1beta1 + +#LogsDefaultColumnsLabel: { + key: string + value: string +} + +#LogsDefaultColumnsLabels: [...#LogsDefaultColumnsLabel] + +#LogsDefaultColumnsRecord: { + columns: [...string] + labels: #LogsDefaultColumnsLabels +} + +#LogsDefaultColumnsRecords: [...#LogsDefaultColumnsRecord] + +LogsDefaultColumns: { + records: #LogsDefaultColumnsRecords +} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/constants.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/constants.go similarity index 91% rename from apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/constants.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/constants.go index 082bec7c874..ecfc11a456f 100644 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/constants.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/constants.go @@ -1,4 +1,4 @@ -package v1alpha1 +package v1beta1 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 = "logsdrilldown.grafana.app" // APIVersion is the API version used by all kinds in this package - APIVersion = "v1alpha1" + APIVersion = "v1beta1" ) var ( diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_client_gen.go similarity index 99% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_client_gen.go index b5d573bc1dc..856c3af291b 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_client_gen.go @@ -1,4 +1,4 @@ -package v1alpha1 +package v1beta1 import ( "context" diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_codec_gen.go similarity index 98% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_codec_gen.go index 311d2f02683..12622814e72 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_codec_gen.go @@ -2,7 +2,7 @@ // Code generated by grafana-app-sdk. DO NOT EDIT. // -package v1alpha1 +package v1beta1 import ( "encoding/json" diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_metadata_gen.go similarity index 98% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_metadata_gen.go index a4bb052fe25..ee2d44fde2e 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_metadata_gen.go @@ -1,6 +1,6 @@ // Code generated - EDITING IS FUTILE. DO NOT EDIT. -package v1alpha1 +package v1beta1 import ( time "time" diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_object_gen.go similarity index 99% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_object_gen.go index 4340a27714e..6822fbfb4d7 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_object_gen.go @@ -2,7 +2,7 @@ // Code generated by grafana-app-sdk. DO NOT EDIT. // -package v1alpha1 +package v1beta1 import ( "fmt" diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_schema_gen.go similarity index 85% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_schema_gen.go index cc5363e16bb..49d234f47a5 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_schema_gen.go @@ -2,7 +2,7 @@ // Code generated by grafana-app-sdk. DO NOT EDIT. // -package v1alpha1 +package v1beta1 import ( "github.com/grafana/grafana-app-sdk/resource" @@ -10,7 +10,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( - schemaLogsDrilldownDefaultColumns = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1alpha1", NewLogsDrilldownDefaultColumns(), &LogsDrilldownDefaultColumnsList{}, resource.WithKind("LogsDrilldownDefaultColumns"), + schemaLogsDrilldownDefaultColumns = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1beta1", NewLogsDrilldownDefaultColumns(), &LogsDrilldownDefaultColumnsList{}, resource.WithKind("LogsDrilldownDefaultColumns"), resource.WithPlural("logsdrilldowndefaultcolumns"), resource.WithScope(resource.NamespacedScope)) kindLogsDrilldownDefaultColumns = resource.Kind{ Schema: schemaLogsDrilldownDefaultColumns, diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_spec_gen.go similarity index 99% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_spec_gen.go index ce12ebb0761..6163f66c52c 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_spec_gen.go @@ -1,6 +1,6 @@ // Code generated - EDITING IS FUTILE. DO NOT EDIT. -package v1alpha1 +package v1beta1 // +k8s:openapi-gen=true type LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords []LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_status_gen.go similarity index 99% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_status_gen.go index c2183832095..e109592eb09 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_status_gen.go @@ -1,6 +1,6 @@ // Code generated - EDITING IS FUTILE. DO NOT EDIT. -package v1alpha1 +package v1beta1 // +k8s:openapi-gen=true type LogsDrilldownDefaultColumnsstatusOperatorState struct { diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown_manifest.go b/apps/logsdrilldown/pkg/apis/logsdrilldown_manifest.go index 2350b924dda..a242d11b2bf 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown_manifest.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown_manifest.go @@ -17,24 +17,25 @@ import ( "k8s.io/kube-openapi/pkg/validation/spec" v1alpha1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1" + v1beta1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1" ) var ( - rawSchemaLogsDrilldownv1alpha1 = []byte(`{"LogsDrilldown":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownv1alpha1, &versionSchemaLogsDrilldownv1alpha1) - rawSchemaLogsDrilldownDefaultsv1alpha1 = []byte(`{"LogsDrilldownDefaults":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownDefaultsv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultsv1alpha1, &versionSchemaLogsDrilldownDefaultsv1alpha1) - rawSchemaLogsDrilldownDefaultColumnsv1alpha1 = []byte(`{"LogsDefaultColumnsLabel":{"additionalProperties":false,"properties":{"key":{"type":"string"},"value":{"type":"string"}},"required":["key","value"],"type":"object"},"LogsDefaultColumnsLabels":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsLabel"},"type":"array"},"LogsDefaultColumnsRecord":{"additionalProperties":false,"properties":{"columns":{"items":{"type":"string"},"type":"array"},"labels":{"$ref":"#/components/schemas/LogsDefaultColumnsLabels"}},"required":["columns","labels"],"type":"object"},"LogsDefaultColumnsRecords":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsRecord"},"type":"array"},"LogsDrilldownDefaultColumns":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"records":{"$ref":"#/components/schemas/LogsDefaultColumnsRecords"}},"required":["records"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownDefaultColumnsv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultColumnsv1alpha1, &versionSchemaLogsDrilldownDefaultColumnsv1alpha1) + rawSchemaLogsDrilldownv1alpha1 = []byte(`{"LogsDrilldown":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) + versionSchemaLogsDrilldownv1alpha1 app.VersionSchema + _ = json.Unmarshal(rawSchemaLogsDrilldownv1alpha1, &versionSchemaLogsDrilldownv1alpha1) + rawSchemaLogsDrilldownDefaultsv1alpha1 = []byte(`{"LogsDrilldownDefaults":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) + versionSchemaLogsDrilldownDefaultsv1alpha1 app.VersionSchema + _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultsv1alpha1, &versionSchemaLogsDrilldownDefaultsv1alpha1) + rawSchemaLogsDrilldownDefaultColumnsv1beta1 = []byte(`{"LogsDefaultColumnsLabel":{"additionalProperties":false,"properties":{"key":{"type":"string"},"value":{"type":"string"}},"required":["key","value"],"type":"object"},"LogsDefaultColumnsLabels":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsLabel"},"type":"array"},"LogsDefaultColumnsRecord":{"additionalProperties":false,"properties":{"columns":{"items":{"type":"string"},"type":"array"},"labels":{"$ref":"#/components/schemas/LogsDefaultColumnsLabels"}},"required":["columns","labels"],"type":"object"},"LogsDefaultColumnsRecords":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsRecord"},"type":"array"},"LogsDrilldownDefaultColumns":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"records":{"$ref":"#/components/schemas/LogsDefaultColumnsRecords"}},"required":["records"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) + versionSchemaLogsDrilldownDefaultColumnsv1beta1 app.VersionSchema + _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultColumnsv1beta1, &versionSchemaLogsDrilldownDefaultColumnsv1beta1) ) var appManifestData = app.ManifestData{ AppName: "logsdrilldown", Group: "logsdrilldown.grafana.app", - PreferredVersion: "v1alpha1", + PreferredVersion: "v1beta1", Versions: []app.ManifestVersion{ { Name: "v1alpha1", @@ -55,13 +56,24 @@ var appManifestData = app.ManifestData{ Conversion: false, Schema: &versionSchemaLogsDrilldownDefaultsv1alpha1, }, + }, + Routes: app.ManifestVersionRoutes{ + Namespaced: map[string]spec3.PathProps{}, + Cluster: map[string]spec3.PathProps{}, + Schemas: map[string]spec.Schema{}, + }, + }, + { + Name: "v1beta1", + Served: true, + Kinds: []app.ManifestVersionKind{ { Kind: "LogsDrilldownDefaultColumns", Plural: "LogsDrilldownDefaultColumns", Scope: "Namespaced", Conversion: false, - Schema: &versionSchemaLogsDrilldownDefaultColumnsv1alpha1, + Schema: &versionSchemaLogsDrilldownDefaultColumnsv1beta1, }, }, Routes: app.ManifestVersionRoutes{ @@ -82,9 +94,9 @@ func RemoteManifest() app.Manifest { } var kindVersionToGoType = map[string]resource.Kind{ - "LogsDrilldown/v1alpha1": v1alpha1.LogsDrilldownKind(), - "LogsDrilldownDefaults/v1alpha1": v1alpha1.LogsDrilldownDefaultsKind(), - "LogsDrilldownDefaultColumns/v1alpha1": v1alpha1.LogsDrilldownDefaultColumnsKind(), + "LogsDrilldown/v1alpha1": v1alpha1.LogsDrilldownKind(), + "LogsDrilldownDefaults/v1alpha1": v1alpha1.LogsDrilldownDefaultsKind(), + "LogsDrilldownDefaultColumns/v1beta1": v1beta1.LogsDrilldownDefaultColumnsKind(), } // ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists. diff --git a/apps/logsdrilldown/pkg/app/app.go b/apps/logsdrilldown/pkg/app/app.go index 23260270207..1e3e37851e2 100644 --- a/apps/logsdrilldown/pkg/app/app.go +++ b/apps/logsdrilldown/pkg/app/app.go @@ -11,6 +11,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" logsdrilldownv1alpha1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1" + logsdrilldownv1beta1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1" ) func New(cfg app.Config) (app.App, error) { @@ -32,7 +33,7 @@ func New(cfg app.Config) (app.App, error) { Kind: logsdrilldownv1alpha1.LogsDrilldownDefaultsKind(), }, { - Kind: logsdrilldownv1alpha1.LogsDrilldownDefaultColumnsKind(), + Kind: logsdrilldownv1beta1.LogsDrilldownDefaultColumnsKind(), }, }, } diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_client_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_client_gen.go deleted file mode 100644 index c133b65f45b..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_client_gen.go +++ /dev/null @@ -1,99 +0,0 @@ -package v1alpha1 - -import ( - "context" - - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type LogsDrilldownClient struct { - client *resource.TypedClient[*LogsDrilldown, *LogsDrilldownList] -} - -func NewLogsDrilldownClient(client resource.Client) *LogsDrilldownClient { - return &LogsDrilldownClient{ - client: resource.NewTypedClient[*LogsDrilldown, *LogsDrilldownList](client, Kind()), - } -} - -func NewLogsDrilldownClientFromGenerator(generator resource.ClientGenerator) (*LogsDrilldownClient, error) { - c, err := generator.ClientFor(Kind()) - if err != nil { - return nil, err - } - return NewLogsDrilldownClient(c), nil -} - -func (c *LogsDrilldownClient) Get(ctx context.Context, identifier resource.Identifier) (*LogsDrilldown, error) { - return c.client.Get(ctx, identifier) -} - -func (c *LogsDrilldownClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownList, error) { - return c.client.List(ctx, namespace, opts) -} - -func (c *LogsDrilldownClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownList, error) { - resp, err := c.client.List(ctx, namespace, resource.ListOptions{ - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - for resp.GetContinue() != "" { - page, err := c.client.List(ctx, namespace, resource.ListOptions{ - Continue: resp.GetContinue(), - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - resp.SetContinue(page.GetContinue()) - resp.SetResourceVersion(page.GetResourceVersion()) - resp.SetItems(append(resp.GetItems(), page.GetItems()...)) - } - return resp, nil -} - -func (c *LogsDrilldownClient) Create(ctx context.Context, obj *LogsDrilldown, opts resource.CreateOptions) (*LogsDrilldown, error) { - // Make sure apiVersion and kind are set - obj.APIVersion = GroupVersion.Identifier() - obj.Kind = Kind().Kind() - return c.client.Create(ctx, obj, opts) -} - -func (c *LogsDrilldownClient) Update(ctx context.Context, obj *LogsDrilldown, opts resource.UpdateOptions) (*LogsDrilldown, error) { - return c.client.Update(ctx, obj, opts) -} - -func (c *LogsDrilldownClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*LogsDrilldown, error) { - return c.client.Patch(ctx, identifier, req, opts) -} - -func (c *LogsDrilldownClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus Status, opts resource.UpdateOptions) (*LogsDrilldown, error) { - return c.client.Update(ctx, &LogsDrilldown{ - TypeMeta: metav1.TypeMeta{ - Kind: Kind().Kind(), - APIVersion: GroupVersion.Identifier(), - }, - ObjectMeta: metav1.ObjectMeta{ - ResourceVersion: opts.ResourceVersion, - Namespace: identifier.Namespace, - Name: identifier.Name, - }, - Status: newStatus, - }, resource.UpdateOptions{ - Subresource: "status", - ResourceVersion: opts.ResourceVersion, - }) -} - -func (c *LogsDrilldownClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { - return c.client.Delete(ctx, identifier, opts) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_codec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_codec_gen.go deleted file mode 100644 index bb458caeb88..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_codec_gen.go +++ /dev/null @@ -1,28 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "encoding/json" - "io" - - "github.com/grafana/grafana-app-sdk/resource" -) - -// JSONCodec is an implementation of resource.Codec for kubernetes JSON encoding -type JSONCodec struct{} - -// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` -func (*JSONCodec) Read(reader io.Reader, into resource.Object) error { - return json.NewDecoder(reader).Decode(into) -} - -// Write writes JSON-encoded bytes into `writer` marshaled from `from` -func (*JSONCodec) Write(writer io.Writer, from resource.Object) error { - return json.NewEncoder(writer).Encode(from) -} - -// Interface compliance checks -var _ resource.Codec = &JSONCodec{} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_metadata_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_metadata_gen.go deleted file mode 100644 index cb7233b22ab..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_metadata_gen.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -import ( - time "time" -) - -// metadata contains embedded CommonMetadata and can be extended with custom string fields -// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -// without external reference as using the CommonMetadata reference breaks thema codegen. -type Metadata struct { - UpdateTimestamp time.Time `json:"updateTimestamp"` - CreatedBy string `json:"createdBy"` - Uid string `json:"uid"` - CreationTimestamp time.Time `json:"creationTimestamp"` - DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` - Finalizers []string `json:"finalizers"` - ResourceVersion string `json:"resourceVersion"` - Generation int64 `json:"generation"` - UpdatedBy string `json:"updatedBy"` - Labels map[string]string `json:"labels"` -} - -// NewMetadata creates a new Metadata object. -func NewMetadata() *Metadata { - return &Metadata{ - Finalizers: []string{}, - Labels: map[string]string{}, - } -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_object_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_object_gen.go deleted file mode 100644 index 5d40a873e6b..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_object_gen.go +++ /dev/null @@ -1,319 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "fmt" - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "time" -) - -// +k8s:openapi-gen=true -type LogsDrilldown struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ObjectMeta `json:"metadata" yaml:"metadata"` - - // Spec is the spec of the LogsDrilldown - Spec Spec `json:"spec" yaml:"spec"` - - Status Status `json:"status" yaml:"status"` -} - -func (o *LogsDrilldown) GetSpec() any { - return o.Spec -} - -func (o *LogsDrilldown) SetSpec(spec any) error { - cast, ok := spec.(Spec) - if !ok { - return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) - } - o.Spec = cast - return nil -} - -func (o *LogsDrilldown) GetSubresources() map[string]any { - return map[string]any{ - "status": o.Status, - } -} - -func (o *LogsDrilldown) GetSubresource(name string) (any, bool) { - switch name { - case "status": - return o.Status, true - default: - return nil, false - } -} - -func (o *LogsDrilldown) SetSubresource(name string, value any) error { - switch name { - case "status": - cast, ok := value.(Status) - if !ok { - return fmt.Errorf("cannot set status type %#v, not of type Status", value) - } - o.Status = cast - return nil - default: - return fmt.Errorf("subresource '%s' does not exist", name) - } -} - -func (o *LogsDrilldown) GetStaticMetadata() resource.StaticMetadata { - gvk := o.GroupVersionKind() - return resource.StaticMetadata{ - Name: o.ObjectMeta.Name, - Namespace: o.ObjectMeta.Namespace, - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - } -} - -func (o *LogsDrilldown) SetStaticMetadata(metadata resource.StaticMetadata) { - o.Name = metadata.Name - o.Namespace = metadata.Namespace - o.SetGroupVersionKind(schema.GroupVersionKind{ - Group: metadata.Group, - Version: metadata.Version, - Kind: metadata.Kind, - }) -} - -func (o *LogsDrilldown) GetCommonMetadata() resource.CommonMetadata { - dt := o.DeletionTimestamp - var deletionTimestamp *time.Time - if dt != nil { - deletionTimestamp = &dt.Time - } - // Legacy ExtraFields support - extraFields := make(map[string]any) - if o.Annotations != nil { - extraFields["annotations"] = o.Annotations - } - if o.ManagedFields != nil { - extraFields["managedFields"] = o.ManagedFields - } - if o.OwnerReferences != nil { - extraFields["ownerReferences"] = o.OwnerReferences - } - return resource.CommonMetadata{ - UID: string(o.UID), - ResourceVersion: o.ResourceVersion, - Generation: o.Generation, - Labels: o.Labels, - CreationTimestamp: o.CreationTimestamp.Time, - DeletionTimestamp: deletionTimestamp, - Finalizers: o.Finalizers, - UpdateTimestamp: o.GetUpdateTimestamp(), - CreatedBy: o.GetCreatedBy(), - UpdatedBy: o.GetUpdatedBy(), - ExtraFields: extraFields, - } -} - -func (o *LogsDrilldown) SetCommonMetadata(metadata resource.CommonMetadata) { - o.UID = types.UID(metadata.UID) - o.ResourceVersion = metadata.ResourceVersion - o.Generation = metadata.Generation - o.Labels = metadata.Labels - o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) - if metadata.DeletionTimestamp != nil { - dt := metav1.NewTime(*metadata.DeletionTimestamp) - o.DeletionTimestamp = &dt - } else { - o.DeletionTimestamp = nil - } - o.Finalizers = metadata.Finalizers - if o.Annotations == nil { - o.Annotations = make(map[string]string) - } - if !metadata.UpdateTimestamp.IsZero() { - o.SetUpdateTimestamp(metadata.UpdateTimestamp) - } - if metadata.CreatedBy != "" { - o.SetCreatedBy(metadata.CreatedBy) - } - if metadata.UpdatedBy != "" { - o.SetUpdatedBy(metadata.UpdatedBy) - } - // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields - if metadata.ExtraFields != nil { - if annotations, ok := metadata.ExtraFields["annotations"]; ok { - if cast, ok := annotations.(map[string]string); ok { - o.Annotations = cast - } - } - if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { - if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { - o.ManagedFields = cast - } - } - if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { - if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { - o.OwnerReferences = cast - } - } - } -} - -func (o *LogsDrilldown) GetCreatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/createdBy"] -} - -func (o *LogsDrilldown) SetCreatedBy(createdBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy -} - -func (o *LogsDrilldown) GetUpdateTimestamp() time.Time { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) - return parsed -} - -func (o *LogsDrilldown) SetUpdateTimestamp(updateTimestamp time.Time) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) -} - -func (o *LogsDrilldown) GetUpdatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/updatedBy"] -} - -func (o *LogsDrilldown) SetUpdatedBy(updatedBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy -} - -func (o *LogsDrilldown) Copy() resource.Object { - return resource.CopyObject(o) -} - -func (o *LogsDrilldown) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldown) DeepCopy() *LogsDrilldown { - cpy := &LogsDrilldown{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldown) DeepCopyInto(dst *LogsDrilldown) { - dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion - dst.TypeMeta.Kind = o.TypeMeta.Kind - o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) - o.Spec.DeepCopyInto(&dst.Spec) - o.Status.DeepCopyInto(&dst.Status) -} - -// Interface compliance compile-time check -var _ resource.Object = &LogsDrilldown{} - -// +k8s:openapi-gen=true -type LogsDrilldownList struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ListMeta `json:"metadata" yaml:"metadata"` - Items []LogsDrilldown `json:"items" yaml:"items"` -} - -func (o *LogsDrilldownList) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldownList) Copy() resource.ListObject { - cpy := &LogsDrilldownList{ - TypeMeta: o.TypeMeta, - Items: make([]LogsDrilldown, len(o.Items)), - } - o.ListMeta.DeepCopyInto(&cpy.ListMeta) - for i := 0; i < len(o.Items); i++ { - if item, ok := o.Items[i].Copy().(*LogsDrilldown); ok { - cpy.Items[i] = *item - } - } - return cpy -} - -func (o *LogsDrilldownList) GetItems() []resource.Object { - items := make([]resource.Object, len(o.Items)) - for i := 0; i < len(o.Items); i++ { - items[i] = &o.Items[i] - } - return items -} - -func (o *LogsDrilldownList) SetItems(items []resource.Object) { - o.Items = make([]LogsDrilldown, len(items)) - for i := 0; i < len(items); i++ { - o.Items[i] = *items[i].(*LogsDrilldown) - } -} - -func (o *LogsDrilldownList) DeepCopy() *LogsDrilldownList { - cpy := &LogsDrilldownList{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldownList) DeepCopyInto(dst *LogsDrilldownList) { - resource.CopyObjectInto(dst, o) -} - -// Interface compliance compile-time check -var _ resource.ListObject = &LogsDrilldownList{} - -// Copy methods for all subresource types - -// DeepCopy creates a full deep copy of Spec -func (s *Spec) DeepCopy() *Spec { - cpy := &Spec{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Spec into another Spec object -func (s *Spec) DeepCopyInto(dst *Spec) { - resource.CopyObjectInto(dst, s) -} - -// DeepCopy creates a full deep copy of Status -func (s *Status) DeepCopy() *Status { - cpy := &Status{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Status into another Status object -func (s *Status) DeepCopyInto(dst *Status) { - resource.CopyObjectInto(dst, s) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_schema_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_schema_gen.go deleted file mode 100644 index 942794416e8..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_schema_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "github.com/grafana/grafana-app-sdk/resource" -) - -// schema is unexported to prevent accidental overwrites -var ( - schemaLogsDrilldown = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1alpha1", &LogsDrilldown{}, &LogsDrilldownList{}, resource.WithKind("LogsDrilldown"), - resource.WithPlural("logsdrilldowns"), resource.WithScope(resource.NamespacedScope)) - kindLogsDrilldown = resource.Kind{ - Schema: schemaLogsDrilldown, - Codecs: map[resource.KindEncoding]resource.Codec{ - resource.KindEncodingJSON: &JSONCodec{}, - }, - } -) - -// Kind returns a resource.Kind for this Schema with a JSON codec -func Kind() resource.Kind { - return kindLogsDrilldown -} - -// Schema returns a resource.SimpleSchema representation of LogsDrilldown -func Schema() *resource.SimpleSchema { - return schemaLogsDrilldown -} - -// Interface compliance checks -var _ resource.Schema = kindLogsDrilldown diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_spec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_spec_gen.go deleted file mode 100644 index faff5c108dd..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_spec_gen.go +++ /dev/null @@ -1,18 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type Spec struct { - DefaultFields []string `json:"defaultFields"` - PrettifyJSON bool `json:"prettifyJSON"` - WrapLogMessage bool `json:"wrapLogMessage"` - InterceptDismissed bool `json:"interceptDismissed"` -} - -// NewSpec creates a new Spec object. -func NewSpec() *Spec { - return &Spec{ - DefaultFields: []string{}, - } -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_status_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_status_gen.go deleted file mode 100644 index 9b227b00f44..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_status_gen.go +++ /dev/null @@ -1,44 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type StatusOperatorState struct { - // lastEvaluation is the ResourceVersion last evaluated - LastEvaluation string `json:"lastEvaluation"` - // state describes the state of the lastEvaluation. - // It is limited to three possible states for machine evaluation. - State StatusOperatorStateState `json:"state"` - // descriptiveState is an optional more descriptive state field which has no requirements on format - DescriptiveState *string `json:"descriptiveState,omitempty"` - // details contains any extra information that is operator-specific - Details map[string]interface{} `json:"details,omitempty"` -} - -// NewStatusOperatorState creates a new StatusOperatorState object. -func NewStatusOperatorState() *StatusOperatorState { - return &StatusOperatorState{} -} - -// +k8s:openapi-gen=true -type Status struct { - // operatorStates is a map of operator ID to operator state evaluations. - // Any operator which consumes this kind SHOULD add its state evaluation information to this field. - OperatorStates map[string]StatusOperatorState `json:"operatorStates,omitempty"` - // additionalFields is reserved for future use - AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` -} - -// NewStatus creates a new Status object. -func NewStatus() *Status { - return &Status{} -} - -// +k8s:openapi-gen=true -type StatusOperatorStateState string - -const ( - StatusOperatorStateStateSuccess StatusOperatorStateState = "success" - StatusOperatorStateStateInProgress StatusOperatorStateState = "in_progress" - StatusOperatorStateStateFailed StatusOperatorStateState = "failed" -) diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/constants.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/constants.go deleted file mode 100644 index 082bec7c874..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/constants.go +++ /dev/null @@ -1,18 +0,0 @@ -package v1alpha1 - -import "k8s.io/apimachinery/pkg/runtime/schema" - -const ( - // APIGroup is the API group used by all kinds in this package - APIGroup = "logsdrilldown.grafana.app" - // APIVersion is the API version used by all kinds in this package - APIVersion = "v1alpha1" -) - -var ( - // GroupVersion is a schema.GroupVersion consisting of the Group and Version constants for this package - GroupVersion = schema.GroupVersion{ - Group: APIGroup, - Version: APIVersion, - } -) diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go deleted file mode 100644 index b66471eb4ba..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go +++ /dev/null @@ -1,99 +0,0 @@ -package v1alpha1 - -import ( - "context" - - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type LogsDrilldownDefaultColumnsClient struct { - client *resource.TypedClient[*LogsDrilldownDefaultColumns, *LogsDrilldownDefaultColumnsList] -} - -func NewLogsDrilldownDefaultColumnsClient(client resource.Client) *LogsDrilldownDefaultColumnsClient { - return &LogsDrilldownDefaultColumnsClient{ - client: resource.NewTypedClient[*LogsDrilldownDefaultColumns, *LogsDrilldownDefaultColumnsList](client, Kind()), - } -} - -func NewLogsDrilldownDefaultColumnsClientFromGenerator(generator resource.ClientGenerator) (*LogsDrilldownDefaultColumnsClient, error) { - c, err := generator.ClientFor(Kind()) - if err != nil { - return nil, err - } - return NewLogsDrilldownDefaultColumnsClient(c), nil -} - -func (c *LogsDrilldownDefaultColumnsClient) Get(ctx context.Context, identifier resource.Identifier) (*LogsDrilldownDefaultColumns, error) { - return c.client.Get(ctx, identifier) -} - -func (c *LogsDrilldownDefaultColumnsClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownDefaultColumnsList, error) { - return c.client.List(ctx, namespace, opts) -} - -func (c *LogsDrilldownDefaultColumnsClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownDefaultColumnsList, error) { - resp, err := c.client.List(ctx, namespace, resource.ListOptions{ - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - for resp.GetContinue() != "" { - page, err := c.client.List(ctx, namespace, resource.ListOptions{ - Continue: resp.GetContinue(), - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - resp.SetContinue(page.GetContinue()) - resp.SetResourceVersion(page.GetResourceVersion()) - resp.SetItems(append(resp.GetItems(), page.GetItems()...)) - } - return resp, nil -} - -func (c *LogsDrilldownDefaultColumnsClient) Create(ctx context.Context, obj *LogsDrilldownDefaultColumns, opts resource.CreateOptions) (*LogsDrilldownDefaultColumns, error) { - // Make sure apiVersion and kind are set - obj.APIVersion = GroupVersion.Identifier() - obj.Kind = Kind().Kind() - return c.client.Create(ctx, obj, opts) -} - -func (c *LogsDrilldownDefaultColumnsClient) Update(ctx context.Context, obj *LogsDrilldownDefaultColumns, opts resource.UpdateOptions) (*LogsDrilldownDefaultColumns, error) { - return c.client.Update(ctx, obj, opts) -} - -func (c *LogsDrilldownDefaultColumnsClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*LogsDrilldownDefaultColumns, error) { - return c.client.Patch(ctx, identifier, req, opts) -} - -func (c *LogsDrilldownDefaultColumnsClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus Status, opts resource.UpdateOptions) (*LogsDrilldownDefaultColumns, error) { - return c.client.Update(ctx, &LogsDrilldownDefaultColumns{ - TypeMeta: metav1.TypeMeta{ - Kind: Kind().Kind(), - APIVersion: GroupVersion.Identifier(), - }, - ObjectMeta: metav1.ObjectMeta{ - ResourceVersion: opts.ResourceVersion, - Namespace: identifier.Namespace, - Name: identifier.Name, - }, - Status: newStatus, - }, resource.UpdateOptions{ - Subresource: "status", - ResourceVersion: opts.ResourceVersion, - }) -} - -func (c *LogsDrilldownDefaultColumnsClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { - return c.client.Delete(ctx, identifier, opts) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go deleted file mode 100644 index bb458caeb88..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go +++ /dev/null @@ -1,28 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "encoding/json" - "io" - - "github.com/grafana/grafana-app-sdk/resource" -) - -// JSONCodec is an implementation of resource.Codec for kubernetes JSON encoding -type JSONCodec struct{} - -// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` -func (*JSONCodec) Read(reader io.Reader, into resource.Object) error { - return json.NewDecoder(reader).Decode(into) -} - -// Write writes JSON-encoded bytes into `writer` marshaled from `from` -func (*JSONCodec) Write(writer io.Writer, from resource.Object) error { - return json.NewEncoder(writer).Encode(from) -} - -// Interface compliance checks -var _ resource.Codec = &JSONCodec{} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go deleted file mode 100644 index cb7233b22ab..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -import ( - time "time" -) - -// metadata contains embedded CommonMetadata and can be extended with custom string fields -// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -// without external reference as using the CommonMetadata reference breaks thema codegen. -type Metadata struct { - UpdateTimestamp time.Time `json:"updateTimestamp"` - CreatedBy string `json:"createdBy"` - Uid string `json:"uid"` - CreationTimestamp time.Time `json:"creationTimestamp"` - DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` - Finalizers []string `json:"finalizers"` - ResourceVersion string `json:"resourceVersion"` - Generation int64 `json:"generation"` - UpdatedBy string `json:"updatedBy"` - Labels map[string]string `json:"labels"` -} - -// NewMetadata creates a new Metadata object. -func NewMetadata() *Metadata { - return &Metadata{ - Finalizers: []string{}, - Labels: map[string]string{}, - } -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go deleted file mode 100644 index 3173c28330e..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go +++ /dev/null @@ -1,319 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "fmt" - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "time" -) - -// +k8s:openapi-gen=true -type LogsDrilldownDefaultColumns struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ObjectMeta `json:"metadata" yaml:"metadata"` - - // Spec is the spec of the LogsDrilldownDefaultColumns - Spec Spec `json:"spec" yaml:"spec"` - - Status Status `json:"status" yaml:"status"` -} - -func (o *LogsDrilldownDefaultColumns) GetSpec() any { - return o.Spec -} - -func (o *LogsDrilldownDefaultColumns) SetSpec(spec any) error { - cast, ok := spec.(Spec) - if !ok { - return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) - } - o.Spec = cast - return nil -} - -func (o *LogsDrilldownDefaultColumns) GetSubresources() map[string]any { - return map[string]any{ - "status": o.Status, - } -} - -func (o *LogsDrilldownDefaultColumns) GetSubresource(name string) (any, bool) { - switch name { - case "status": - return o.Status, true - default: - return nil, false - } -} - -func (o *LogsDrilldownDefaultColumns) SetSubresource(name string, value any) error { - switch name { - case "status": - cast, ok := value.(Status) - if !ok { - return fmt.Errorf("cannot set status type %#v, not of type Status", value) - } - o.Status = cast - return nil - default: - return fmt.Errorf("subresource '%s' does not exist", name) - } -} - -func (o *LogsDrilldownDefaultColumns) GetStaticMetadata() resource.StaticMetadata { - gvk := o.GroupVersionKind() - return resource.StaticMetadata{ - Name: o.ObjectMeta.Name, - Namespace: o.ObjectMeta.Namespace, - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - } -} - -func (o *LogsDrilldownDefaultColumns) SetStaticMetadata(metadata resource.StaticMetadata) { - o.Name = metadata.Name - o.Namespace = metadata.Namespace - o.SetGroupVersionKind(schema.GroupVersionKind{ - Group: metadata.Group, - Version: metadata.Version, - Kind: metadata.Kind, - }) -} - -func (o *LogsDrilldownDefaultColumns) GetCommonMetadata() resource.CommonMetadata { - dt := o.DeletionTimestamp - var deletionTimestamp *time.Time - if dt != nil { - deletionTimestamp = &dt.Time - } - // Legacy ExtraFields support - extraFields := make(map[string]any) - if o.Annotations != nil { - extraFields["annotations"] = o.Annotations - } - if o.ManagedFields != nil { - extraFields["managedFields"] = o.ManagedFields - } - if o.OwnerReferences != nil { - extraFields["ownerReferences"] = o.OwnerReferences - } - return resource.CommonMetadata{ - UID: string(o.UID), - ResourceVersion: o.ResourceVersion, - Generation: o.Generation, - Labels: o.Labels, - CreationTimestamp: o.CreationTimestamp.Time, - DeletionTimestamp: deletionTimestamp, - Finalizers: o.Finalizers, - UpdateTimestamp: o.GetUpdateTimestamp(), - CreatedBy: o.GetCreatedBy(), - UpdatedBy: o.GetUpdatedBy(), - ExtraFields: extraFields, - } -} - -func (o *LogsDrilldownDefaultColumns) SetCommonMetadata(metadata resource.CommonMetadata) { - o.UID = types.UID(metadata.UID) - o.ResourceVersion = metadata.ResourceVersion - o.Generation = metadata.Generation - o.Labels = metadata.Labels - o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) - if metadata.DeletionTimestamp != nil { - dt := metav1.NewTime(*metadata.DeletionTimestamp) - o.DeletionTimestamp = &dt - } else { - o.DeletionTimestamp = nil - } - o.Finalizers = metadata.Finalizers - if o.Annotations == nil { - o.Annotations = make(map[string]string) - } - if !metadata.UpdateTimestamp.IsZero() { - o.SetUpdateTimestamp(metadata.UpdateTimestamp) - } - if metadata.CreatedBy != "" { - o.SetCreatedBy(metadata.CreatedBy) - } - if metadata.UpdatedBy != "" { - o.SetUpdatedBy(metadata.UpdatedBy) - } - // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields - if metadata.ExtraFields != nil { - if annotations, ok := metadata.ExtraFields["annotations"]; ok { - if cast, ok := annotations.(map[string]string); ok { - o.Annotations = cast - } - } - if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { - if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { - o.ManagedFields = cast - } - } - if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { - if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { - o.OwnerReferences = cast - } - } - } -} - -func (o *LogsDrilldownDefaultColumns) GetCreatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/createdBy"] -} - -func (o *LogsDrilldownDefaultColumns) SetCreatedBy(createdBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy -} - -func (o *LogsDrilldownDefaultColumns) GetUpdateTimestamp() time.Time { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) - return parsed -} - -func (o *LogsDrilldownDefaultColumns) SetUpdateTimestamp(updateTimestamp time.Time) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) -} - -func (o *LogsDrilldownDefaultColumns) GetUpdatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/updatedBy"] -} - -func (o *LogsDrilldownDefaultColumns) SetUpdatedBy(updatedBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy -} - -func (o *LogsDrilldownDefaultColumns) Copy() resource.Object { - return resource.CopyObject(o) -} - -func (o *LogsDrilldownDefaultColumns) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldownDefaultColumns) DeepCopy() *LogsDrilldownDefaultColumns { - cpy := &LogsDrilldownDefaultColumns{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldownDefaultColumns) DeepCopyInto(dst *LogsDrilldownDefaultColumns) { - dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion - dst.TypeMeta.Kind = o.TypeMeta.Kind - o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) - o.Spec.DeepCopyInto(&dst.Spec) - o.Status.DeepCopyInto(&dst.Status) -} - -// Interface compliance compile-time check -var _ resource.Object = &LogsDrilldownDefaultColumns{} - -// +k8s:openapi-gen=true -type LogsDrilldownDefaultColumnsList struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ListMeta `json:"metadata" yaml:"metadata"` - Items []LogsDrilldownDefaultColumns `json:"items" yaml:"items"` -} - -func (o *LogsDrilldownDefaultColumnsList) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldownDefaultColumnsList) Copy() resource.ListObject { - cpy := &LogsDrilldownDefaultColumnsList{ - TypeMeta: o.TypeMeta, - Items: make([]LogsDrilldownDefaultColumns, len(o.Items)), - } - o.ListMeta.DeepCopyInto(&cpy.ListMeta) - for i := 0; i < len(o.Items); i++ { - if item, ok := o.Items[i].Copy().(*LogsDrilldownDefaultColumns); ok { - cpy.Items[i] = *item - } - } - return cpy -} - -func (o *LogsDrilldownDefaultColumnsList) GetItems() []resource.Object { - items := make([]resource.Object, len(o.Items)) - for i := 0; i < len(o.Items); i++ { - items[i] = &o.Items[i] - } - return items -} - -func (o *LogsDrilldownDefaultColumnsList) SetItems(items []resource.Object) { - o.Items = make([]LogsDrilldownDefaultColumns, len(items)) - for i := 0; i < len(items); i++ { - o.Items[i] = *items[i].(*LogsDrilldownDefaultColumns) - } -} - -func (o *LogsDrilldownDefaultColumnsList) DeepCopy() *LogsDrilldownDefaultColumnsList { - cpy := &LogsDrilldownDefaultColumnsList{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldownDefaultColumnsList) DeepCopyInto(dst *LogsDrilldownDefaultColumnsList) { - resource.CopyObjectInto(dst, o) -} - -// Interface compliance compile-time check -var _ resource.ListObject = &LogsDrilldownDefaultColumnsList{} - -// Copy methods for all subresource types - -// DeepCopy creates a full deep copy of Spec -func (s *Spec) DeepCopy() *Spec { - cpy := &Spec{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Spec into another Spec object -func (s *Spec) DeepCopyInto(dst *Spec) { - resource.CopyObjectInto(dst, s) -} - -// DeepCopy creates a full deep copy of Status -func (s *Status) DeepCopy() *Status { - cpy := &Status{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Status into another Status object -func (s *Status) DeepCopyInto(dst *Status) { - resource.CopyObjectInto(dst, s) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go deleted file mode 100644 index b50be391fc7..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "github.com/grafana/grafana-app-sdk/resource" -) - -// schema is unexported to prevent accidental overwrites -var ( - schemaLogsDrilldownDefaultColumns = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1alpha1", &LogsDrilldownDefaultColumns{}, &LogsDrilldownDefaultColumnsList{}, resource.WithKind("LogsDrilldownDefaultColumns"), - resource.WithPlural("logsdrilldowndefaultcolumns"), resource.WithScope(resource.NamespacedScope)) - kindLogsDrilldownDefaultColumns = resource.Kind{ - Schema: schemaLogsDrilldownDefaultColumns, - Codecs: map[resource.KindEncoding]resource.Codec{ - resource.KindEncodingJSON: &JSONCodec{}, - }, - } -) - -// Kind returns a resource.Kind for this Schema with a JSON codec -func Kind() resource.Kind { - return kindLogsDrilldownDefaultColumns -} - -// Schema returns a resource.SimpleSchema representation of LogsDrilldownDefaultColumns -func Schema() *resource.SimpleSchema { - return schemaLogsDrilldownDefaultColumns -} - -// Interface compliance checks -var _ resource.Schema = kindLogsDrilldownDefaultColumns diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go deleted file mode 100644 index d9cd977aeb9..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go +++ /dev/null @@ -1,43 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type LogsDefaultColumnsRecords []LogsDefaultColumnsRecord - -// +k8s:openapi-gen=true -type LogsDefaultColumnsRecord struct { - Columns []string `json:"columns"` - Labels LogsDefaultColumnsLabels `json:"labels"` -} - -// NewLogsDefaultColumnsRecord creates a new LogsDefaultColumnsRecord object. -func NewLogsDefaultColumnsRecord() *LogsDefaultColumnsRecord { - return &LogsDefaultColumnsRecord{ - Columns: []string{}, - } -} - -// +k8s:openapi-gen=true -type LogsDefaultColumnsLabels []LogsDefaultColumnsLabel - -// +k8s:openapi-gen=true -type LogsDefaultColumnsLabel struct { - Key string `json:"key"` - Value string `json:"value"` -} - -// NewLogsDefaultColumnsLabel creates a new LogsDefaultColumnsLabel object. -func NewLogsDefaultColumnsLabel() *LogsDefaultColumnsLabel { - return &LogsDefaultColumnsLabel{} -} - -// +k8s:openapi-gen=true -type Spec struct { - Records LogsDefaultColumnsRecords `json:"records"` -} - -// NewSpec creates a new Spec object. -func NewSpec() *Spec { - return &Spec{} -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go deleted file mode 100644 index 9b227b00f44..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go +++ /dev/null @@ -1,44 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type StatusOperatorState struct { - // lastEvaluation is the ResourceVersion last evaluated - LastEvaluation string `json:"lastEvaluation"` - // state describes the state of the lastEvaluation. - // It is limited to three possible states for machine evaluation. - State StatusOperatorStateState `json:"state"` - // descriptiveState is an optional more descriptive state field which has no requirements on format - DescriptiveState *string `json:"descriptiveState,omitempty"` - // details contains any extra information that is operator-specific - Details map[string]interface{} `json:"details,omitempty"` -} - -// NewStatusOperatorState creates a new StatusOperatorState object. -func NewStatusOperatorState() *StatusOperatorState { - return &StatusOperatorState{} -} - -// +k8s:openapi-gen=true -type Status struct { - // operatorStates is a map of operator ID to operator state evaluations. - // Any operator which consumes this kind SHOULD add its state evaluation information to this field. - OperatorStates map[string]StatusOperatorState `json:"operatorStates,omitempty"` - // additionalFields is reserved for future use - AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` -} - -// NewStatus creates a new Status object. -func NewStatus() *Status { - return &Status{} -} - -// +k8s:openapi-gen=true -type StatusOperatorStateState string - -const ( - StatusOperatorStateStateSuccess StatusOperatorStateState = "success" - StatusOperatorStateStateInProgress StatusOperatorStateState = "in_progress" - StatusOperatorStateStateFailed StatusOperatorStateState = "failed" -) diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/constants.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/constants.go deleted file mode 100644 index 082bec7c874..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/constants.go +++ /dev/null @@ -1,18 +0,0 @@ -package v1alpha1 - -import "k8s.io/apimachinery/pkg/runtime/schema" - -const ( - // APIGroup is the API group used by all kinds in this package - APIGroup = "logsdrilldown.grafana.app" - // APIVersion is the API version used by all kinds in this package - APIVersion = "v1alpha1" -) - -var ( - // GroupVersion is a schema.GroupVersion consisting of the Group and Version constants for this package - GroupVersion = schema.GroupVersion{ - Group: APIGroup, - Version: APIVersion, - } -) diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_client_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_client_gen.go deleted file mode 100644 index cc06a10b1e7..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_client_gen.go +++ /dev/null @@ -1,99 +0,0 @@ -package v1alpha1 - -import ( - "context" - - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type LogsDrilldownDefaultsClient struct { - client *resource.TypedClient[*LogsDrilldownDefaults, *LogsDrilldownDefaultsList] -} - -func NewLogsDrilldownDefaultsClient(client resource.Client) *LogsDrilldownDefaultsClient { - return &LogsDrilldownDefaultsClient{ - client: resource.NewTypedClient[*LogsDrilldownDefaults, *LogsDrilldownDefaultsList](client, Kind()), - } -} - -func NewLogsDrilldownDefaultsClientFromGenerator(generator resource.ClientGenerator) (*LogsDrilldownDefaultsClient, error) { - c, err := generator.ClientFor(Kind()) - if err != nil { - return nil, err - } - return NewLogsDrilldownDefaultsClient(c), nil -} - -func (c *LogsDrilldownDefaultsClient) Get(ctx context.Context, identifier resource.Identifier) (*LogsDrilldownDefaults, error) { - return c.client.Get(ctx, identifier) -} - -func (c *LogsDrilldownDefaultsClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownDefaultsList, error) { - return c.client.List(ctx, namespace, opts) -} - -func (c *LogsDrilldownDefaultsClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownDefaultsList, error) { - resp, err := c.client.List(ctx, namespace, resource.ListOptions{ - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - for resp.GetContinue() != "" { - page, err := c.client.List(ctx, namespace, resource.ListOptions{ - Continue: resp.GetContinue(), - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - resp.SetContinue(page.GetContinue()) - resp.SetResourceVersion(page.GetResourceVersion()) - resp.SetItems(append(resp.GetItems(), page.GetItems()...)) - } - return resp, nil -} - -func (c *LogsDrilldownDefaultsClient) Create(ctx context.Context, obj *LogsDrilldownDefaults, opts resource.CreateOptions) (*LogsDrilldownDefaults, error) { - // Make sure apiVersion and kind are set - obj.APIVersion = GroupVersion.Identifier() - obj.Kind = Kind().Kind() - return c.client.Create(ctx, obj, opts) -} - -func (c *LogsDrilldownDefaultsClient) Update(ctx context.Context, obj *LogsDrilldownDefaults, opts resource.UpdateOptions) (*LogsDrilldownDefaults, error) { - return c.client.Update(ctx, obj, opts) -} - -func (c *LogsDrilldownDefaultsClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*LogsDrilldownDefaults, error) { - return c.client.Patch(ctx, identifier, req, opts) -} - -func (c *LogsDrilldownDefaultsClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus Status, opts resource.UpdateOptions) (*LogsDrilldownDefaults, error) { - return c.client.Update(ctx, &LogsDrilldownDefaults{ - TypeMeta: metav1.TypeMeta{ - Kind: Kind().Kind(), - APIVersion: GroupVersion.Identifier(), - }, - ObjectMeta: metav1.ObjectMeta{ - ResourceVersion: opts.ResourceVersion, - Namespace: identifier.Namespace, - Name: identifier.Name, - }, - Status: newStatus, - }, resource.UpdateOptions{ - Subresource: "status", - ResourceVersion: opts.ResourceVersion, - }) -} - -func (c *LogsDrilldownDefaultsClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { - return c.client.Delete(ctx, identifier, opts) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_codec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_codec_gen.go deleted file mode 100644 index bb458caeb88..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_codec_gen.go +++ /dev/null @@ -1,28 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "encoding/json" - "io" - - "github.com/grafana/grafana-app-sdk/resource" -) - -// JSONCodec is an implementation of resource.Codec for kubernetes JSON encoding -type JSONCodec struct{} - -// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` -func (*JSONCodec) Read(reader io.Reader, into resource.Object) error { - return json.NewDecoder(reader).Decode(into) -} - -// Write writes JSON-encoded bytes into `writer` marshaled from `from` -func (*JSONCodec) Write(writer io.Writer, from resource.Object) error { - return json.NewEncoder(writer).Encode(from) -} - -// Interface compliance checks -var _ resource.Codec = &JSONCodec{} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_metadata_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_metadata_gen.go deleted file mode 100644 index cb7233b22ab..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_metadata_gen.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -import ( - time "time" -) - -// metadata contains embedded CommonMetadata and can be extended with custom string fields -// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -// without external reference as using the CommonMetadata reference breaks thema codegen. -type Metadata struct { - UpdateTimestamp time.Time `json:"updateTimestamp"` - CreatedBy string `json:"createdBy"` - Uid string `json:"uid"` - CreationTimestamp time.Time `json:"creationTimestamp"` - DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` - Finalizers []string `json:"finalizers"` - ResourceVersion string `json:"resourceVersion"` - Generation int64 `json:"generation"` - UpdatedBy string `json:"updatedBy"` - Labels map[string]string `json:"labels"` -} - -// NewMetadata creates a new Metadata object. -func NewMetadata() *Metadata { - return &Metadata{ - Finalizers: []string{}, - Labels: map[string]string{}, - } -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_object_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_object_gen.go deleted file mode 100644 index d9354522dd7..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_object_gen.go +++ /dev/null @@ -1,319 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "fmt" - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "time" -) - -// +k8s:openapi-gen=true -type LogsDrilldownDefaults struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ObjectMeta `json:"metadata" yaml:"metadata"` - - // Spec is the spec of the LogsDrilldownDefaults - Spec Spec `json:"spec" yaml:"spec"` - - Status Status `json:"status" yaml:"status"` -} - -func (o *LogsDrilldownDefaults) GetSpec() any { - return o.Spec -} - -func (o *LogsDrilldownDefaults) SetSpec(spec any) error { - cast, ok := spec.(Spec) - if !ok { - return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) - } - o.Spec = cast - return nil -} - -func (o *LogsDrilldownDefaults) GetSubresources() map[string]any { - return map[string]any{ - "status": o.Status, - } -} - -func (o *LogsDrilldownDefaults) GetSubresource(name string) (any, bool) { - switch name { - case "status": - return o.Status, true - default: - return nil, false - } -} - -func (o *LogsDrilldownDefaults) SetSubresource(name string, value any) error { - switch name { - case "status": - cast, ok := value.(Status) - if !ok { - return fmt.Errorf("cannot set status type %#v, not of type Status", value) - } - o.Status = cast - return nil - default: - return fmt.Errorf("subresource '%s' does not exist", name) - } -} - -func (o *LogsDrilldownDefaults) GetStaticMetadata() resource.StaticMetadata { - gvk := o.GroupVersionKind() - return resource.StaticMetadata{ - Name: o.ObjectMeta.Name, - Namespace: o.ObjectMeta.Namespace, - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - } -} - -func (o *LogsDrilldownDefaults) SetStaticMetadata(metadata resource.StaticMetadata) { - o.Name = metadata.Name - o.Namespace = metadata.Namespace - o.SetGroupVersionKind(schema.GroupVersionKind{ - Group: metadata.Group, - Version: metadata.Version, - Kind: metadata.Kind, - }) -} - -func (o *LogsDrilldownDefaults) GetCommonMetadata() resource.CommonMetadata { - dt := o.DeletionTimestamp - var deletionTimestamp *time.Time - if dt != nil { - deletionTimestamp = &dt.Time - } - // Legacy ExtraFields support - extraFields := make(map[string]any) - if o.Annotations != nil { - extraFields["annotations"] = o.Annotations - } - if o.ManagedFields != nil { - extraFields["managedFields"] = o.ManagedFields - } - if o.OwnerReferences != nil { - extraFields["ownerReferences"] = o.OwnerReferences - } - return resource.CommonMetadata{ - UID: string(o.UID), - ResourceVersion: o.ResourceVersion, - Generation: o.Generation, - Labels: o.Labels, - CreationTimestamp: o.CreationTimestamp.Time, - DeletionTimestamp: deletionTimestamp, - Finalizers: o.Finalizers, - UpdateTimestamp: o.GetUpdateTimestamp(), - CreatedBy: o.GetCreatedBy(), - UpdatedBy: o.GetUpdatedBy(), - ExtraFields: extraFields, - } -} - -func (o *LogsDrilldownDefaults) SetCommonMetadata(metadata resource.CommonMetadata) { - o.UID = types.UID(metadata.UID) - o.ResourceVersion = metadata.ResourceVersion - o.Generation = metadata.Generation - o.Labels = metadata.Labels - o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) - if metadata.DeletionTimestamp != nil { - dt := metav1.NewTime(*metadata.DeletionTimestamp) - o.DeletionTimestamp = &dt - } else { - o.DeletionTimestamp = nil - } - o.Finalizers = metadata.Finalizers - if o.Annotations == nil { - o.Annotations = make(map[string]string) - } - if !metadata.UpdateTimestamp.IsZero() { - o.SetUpdateTimestamp(metadata.UpdateTimestamp) - } - if metadata.CreatedBy != "" { - o.SetCreatedBy(metadata.CreatedBy) - } - if metadata.UpdatedBy != "" { - o.SetUpdatedBy(metadata.UpdatedBy) - } - // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields - if metadata.ExtraFields != nil { - if annotations, ok := metadata.ExtraFields["annotations"]; ok { - if cast, ok := annotations.(map[string]string); ok { - o.Annotations = cast - } - } - if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { - if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { - o.ManagedFields = cast - } - } - if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { - if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { - o.OwnerReferences = cast - } - } - } -} - -func (o *LogsDrilldownDefaults) GetCreatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/createdBy"] -} - -func (o *LogsDrilldownDefaults) SetCreatedBy(createdBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy -} - -func (o *LogsDrilldownDefaults) GetUpdateTimestamp() time.Time { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) - return parsed -} - -func (o *LogsDrilldownDefaults) SetUpdateTimestamp(updateTimestamp time.Time) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) -} - -func (o *LogsDrilldownDefaults) GetUpdatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/updatedBy"] -} - -func (o *LogsDrilldownDefaults) SetUpdatedBy(updatedBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy -} - -func (o *LogsDrilldownDefaults) Copy() resource.Object { - return resource.CopyObject(o) -} - -func (o *LogsDrilldownDefaults) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldownDefaults) DeepCopy() *LogsDrilldownDefaults { - cpy := &LogsDrilldownDefaults{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldownDefaults) DeepCopyInto(dst *LogsDrilldownDefaults) { - dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion - dst.TypeMeta.Kind = o.TypeMeta.Kind - o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) - o.Spec.DeepCopyInto(&dst.Spec) - o.Status.DeepCopyInto(&dst.Status) -} - -// Interface compliance compile-time check -var _ resource.Object = &LogsDrilldownDefaults{} - -// +k8s:openapi-gen=true -type LogsDrilldownDefaultsList struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ListMeta `json:"metadata" yaml:"metadata"` - Items []LogsDrilldownDefaults `json:"items" yaml:"items"` -} - -func (o *LogsDrilldownDefaultsList) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldownDefaultsList) Copy() resource.ListObject { - cpy := &LogsDrilldownDefaultsList{ - TypeMeta: o.TypeMeta, - Items: make([]LogsDrilldownDefaults, len(o.Items)), - } - o.ListMeta.DeepCopyInto(&cpy.ListMeta) - for i := 0; i < len(o.Items); i++ { - if item, ok := o.Items[i].Copy().(*LogsDrilldownDefaults); ok { - cpy.Items[i] = *item - } - } - return cpy -} - -func (o *LogsDrilldownDefaultsList) GetItems() []resource.Object { - items := make([]resource.Object, len(o.Items)) - for i := 0; i < len(o.Items); i++ { - items[i] = &o.Items[i] - } - return items -} - -func (o *LogsDrilldownDefaultsList) SetItems(items []resource.Object) { - o.Items = make([]LogsDrilldownDefaults, len(items)) - for i := 0; i < len(items); i++ { - o.Items[i] = *items[i].(*LogsDrilldownDefaults) - } -} - -func (o *LogsDrilldownDefaultsList) DeepCopy() *LogsDrilldownDefaultsList { - cpy := &LogsDrilldownDefaultsList{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldownDefaultsList) DeepCopyInto(dst *LogsDrilldownDefaultsList) { - resource.CopyObjectInto(dst, o) -} - -// Interface compliance compile-time check -var _ resource.ListObject = &LogsDrilldownDefaultsList{} - -// Copy methods for all subresource types - -// DeepCopy creates a full deep copy of Spec -func (s *Spec) DeepCopy() *Spec { - cpy := &Spec{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Spec into another Spec object -func (s *Spec) DeepCopyInto(dst *Spec) { - resource.CopyObjectInto(dst, s) -} - -// DeepCopy creates a full deep copy of Status -func (s *Status) DeepCopy() *Status { - cpy := &Status{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Status into another Status object -func (s *Status) DeepCopyInto(dst *Status) { - resource.CopyObjectInto(dst, s) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_schema_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_schema_gen.go deleted file mode 100644 index bda3e49377d..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_schema_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "github.com/grafana/grafana-app-sdk/resource" -) - -// schema is unexported to prevent accidental overwrites -var ( - schemaLogsDrilldownDefaults = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1alpha1", &LogsDrilldownDefaults{}, &LogsDrilldownDefaultsList{}, resource.WithKind("LogsDrilldownDefaults"), - resource.WithPlural("logsdrilldowndefaults"), resource.WithScope(resource.NamespacedScope)) - kindLogsDrilldownDefaults = resource.Kind{ - Schema: schemaLogsDrilldownDefaults, - Codecs: map[resource.KindEncoding]resource.Codec{ - resource.KindEncodingJSON: &JSONCodec{}, - }, - } -) - -// Kind returns a resource.Kind for this Schema with a JSON codec -func Kind() resource.Kind { - return kindLogsDrilldownDefaults -} - -// Schema returns a resource.SimpleSchema representation of LogsDrilldownDefaults -func Schema() *resource.SimpleSchema { - return schemaLogsDrilldownDefaults -} - -// Interface compliance checks -var _ resource.Schema = kindLogsDrilldownDefaults diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_spec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_spec_gen.go deleted file mode 100644 index faff5c108dd..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_spec_gen.go +++ /dev/null @@ -1,18 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type Spec struct { - DefaultFields []string `json:"defaultFields"` - PrettifyJSON bool `json:"prettifyJSON"` - WrapLogMessage bool `json:"wrapLogMessage"` - InterceptDismissed bool `json:"interceptDismissed"` -} - -// NewSpec creates a new Spec object. -func NewSpec() *Spec { - return &Spec{ - DefaultFields: []string{}, - } -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_status_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_status_gen.go deleted file mode 100644 index 9b227b00f44..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_status_gen.go +++ /dev/null @@ -1,44 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type StatusOperatorState struct { - // lastEvaluation is the ResourceVersion last evaluated - LastEvaluation string `json:"lastEvaluation"` - // state describes the state of the lastEvaluation. - // It is limited to three possible states for machine evaluation. - State StatusOperatorStateState `json:"state"` - // descriptiveState is an optional more descriptive state field which has no requirements on format - DescriptiveState *string `json:"descriptiveState,omitempty"` - // details contains any extra information that is operator-specific - Details map[string]interface{} `json:"details,omitempty"` -} - -// NewStatusOperatorState creates a new StatusOperatorState object. -func NewStatusOperatorState() *StatusOperatorState { - return &StatusOperatorState{} -} - -// +k8s:openapi-gen=true -type Status struct { - // operatorStates is a map of operator ID to operator state evaluations. - // Any operator which consumes this kind SHOULD add its state evaluation information to this field. - OperatorStates map[string]StatusOperatorState `json:"operatorStates,omitempty"` - // additionalFields is reserved for future use - AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` -} - -// NewStatus creates a new Status object. -func NewStatus() *Status { - return &Status{} -} - -// +k8s:openapi-gen=true -type StatusOperatorStateState string - -const ( - StatusOperatorStateStateSuccess StatusOperatorStateState = "success" - StatusOperatorStateStateInProgress StatusOperatorStateState = "in_progress" - StatusOperatorStateStateFailed StatusOperatorStateState = "failed" -) diff --git a/apps/logsdrilldown/pkg/generated/manifestdata/logsdrilldown_manifest.go b/apps/logsdrilldown/pkg/generated/manifestdata/logsdrilldown_manifest.go deleted file mode 100644 index 9deb5d5d3a1..00000000000 --- a/apps/logsdrilldown/pkg/generated/manifestdata/logsdrilldown_manifest.go +++ /dev/null @@ -1,150 +0,0 @@ -// -// This file is generated by grafana-app-sdk -// DO NOT EDIT -// - -package manifestdata - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/grafana/grafana-app-sdk/app" - "github.com/grafana/grafana-app-sdk/resource" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/kube-openapi/pkg/spec3" - "k8s.io/kube-openapi/pkg/validation/spec" - - logsdrilldownv1alpha1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1" - logsdrilldowndefaultcolumnsv1alpha1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1" - logsdrilldowndefaultsv1alpha1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1" -) - -var ( - rawSchemaLogsDrilldownv1alpha1 = []byte(`{"LogsDrilldown":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownv1alpha1, &versionSchemaLogsDrilldownv1alpha1) - rawSchemaLogsDrilldownDefaultsv1alpha1 = []byte(`{"LogsDrilldownDefaults":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownDefaultsv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultsv1alpha1, &versionSchemaLogsDrilldownDefaultsv1alpha1) - rawSchemaLogsDrilldownDefaultColumnsv1alpha1 = []byte(`{"LogsDefaultColumnsLabel":{"additionalProperties":false,"properties":{"key":{"type":"string"},"value":{"type":"string"}},"required":["key","value"],"type":"object"},"LogsDefaultColumnsLabels":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsLabel"},"type":"array"},"LogsDefaultColumnsRecord":{"additionalProperties":false,"properties":{"columns":{"items":{"type":"string"},"type":"array"},"labels":{"$ref":"#/components/schemas/LogsDefaultColumnsLabels"}},"required":["columns","labels"],"type":"object"},"LogsDefaultColumnsRecords":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsRecord"},"type":"array"},"LogsDrilldownDefaultColumns":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"records":{"$ref":"#/components/schemas/LogsDefaultColumnsRecords"}},"required":["records"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownDefaultColumnsv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultColumnsv1alpha1, &versionSchemaLogsDrilldownDefaultColumnsv1alpha1) -) - -var appManifestData = app.ManifestData{ - AppName: "logsdrilldown", - Group: "logsdrilldown.grafana.app", - PreferredVersion: "v1alpha1", - Versions: []app.ManifestVersion{ - { - Name: "v1alpha1", - Served: true, - Kinds: []app.ManifestVersionKind{ - { - Kind: "LogsDrilldown", - Plural: "LogsDrilldowns", - Scope: "Namespaced", - Conversion: false, - Schema: &versionSchemaLogsDrilldownv1alpha1, - }, - - { - Kind: "LogsDrilldownDefaults", - Plural: "LogsDrilldownDefaults", - Scope: "Namespaced", - Conversion: false, - Schema: &versionSchemaLogsDrilldownDefaultsv1alpha1, - }, - - { - Kind: "LogsDrilldownDefaultColumns", - Plural: "LogsDrilldownDefaultColumns", - Scope: "Namespaced", - Conversion: false, - Schema: &versionSchemaLogsDrilldownDefaultColumnsv1alpha1, - }, - }, - Routes: app.ManifestVersionRoutes{ - Namespaced: map[string]spec3.PathProps{}, - Cluster: map[string]spec3.PathProps{}, - Schemas: map[string]spec.Schema{}, - }, - }, - }, -} - -func LocalManifest() app.Manifest { - return app.NewEmbeddedManifest(appManifestData) -} - -func RemoteManifest() app.Manifest { - return app.NewAPIServerManifest("logsdrilldown") -} - -var kindVersionToGoType = map[string]resource.Kind{ - "LogsDrilldown/v1alpha1": logsdrilldownv1alpha1.Kind(), - "LogsDrilldownDefaults/v1alpha1": logsdrilldowndefaultsv1alpha1.Kind(), - "LogsDrilldownDefaultColumns/v1alpha1": logsdrilldowndefaultcolumnsv1alpha1.Kind(), -} - -// ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists. -// If there is no association for the provided Kind and Version, exists will return false. -func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exists bool) { - goType, exists = kindVersionToGoType[fmt.Sprintf("%s/%s", kind, version)] - return goType, exists -} - -var customRouteToGoResponseType = map[string]any{} - -// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists. -// kind may be empty for custom routes which are not kind subroutes. Leading slashes are removed from subroute paths. -// If there is no association for the provided kind, version, custom route path, and method, exists will return false. -// Resource routes (those without a kind) should prefix their route with "/" if the route is namespaced (otherwise the route is assumed to be cluster-scope) -func ManifestCustomRouteResponsesAssociator(kind, version, path, verb string) (goType any, exists bool) { - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - goType, exists = customRouteToGoResponseType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] - return goType, exists -} - -var customRouteToGoParamsType = map[string]runtime.Object{} - -func ManifestCustomRouteQueryAssociator(kind, version, path, verb string) (goType runtime.Object, exists bool) { - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - goType, exists = customRouteToGoParamsType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] - return goType, exists -} - -var customRouteToGoRequestBodyType = map[string]any{} - -func ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb string) (goType any, exists bool) { - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - goType, exists = customRouteToGoRequestBodyType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] - return goType, exists -} - -type GoTypeAssociator struct{} - -func NewGoTypeAssociator() *GoTypeAssociator { - return &GoTypeAssociator{} -} - -func (g *GoTypeAssociator) KindToGoType(kind, version string) (goType resource.Kind, exists bool) { - return ManifestGoTypeAssociator(kind, version) -} -func (g *GoTypeAssociator) CustomRouteReturnGoType(kind, version, path, verb string) (goType any, exists bool) { - return ManifestCustomRouteResponsesAssociator(kind, version, path, verb) -} -func (g *GoTypeAssociator) CustomRouteQueryGoType(kind, version, path, verb string) (goType runtime.Object, exists bool) { - return ManifestCustomRouteQueryAssociator(kind, version, path, verb) -} -func (g *GoTypeAssociator) CustomRouteRequestBodyGoType(kind, version, path, verb string) (goType any, exists bool) { - return ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb) -} diff --git a/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/logsdrilldowndefaultcolumns_object_gen.ts b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/logsdrilldowndefaultcolumns_object_gen.ts new file mode 100644 index 00000000000..f7ba7b0f223 --- /dev/null +++ b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/logsdrilldowndefaultcolumns_object_gen.ts @@ -0,0 +1,49 @@ +/* + * This file was generated by grafana-app-sdk. DO NOT EDIT. + */ +import { Spec } from './types.spec.gen'; +import { Status } from './types.status.gen'; + +export interface Metadata { + name: string; + namespace: string; + generateName?: string; + selfLink?: string; + uid?: string; + resourceVersion?: string; + generation?: number; + creationTimestamp?: string; + deletionTimestamp?: string; + deletionGracePeriodSeconds?: number; + labels?: Record; + annotations?: Record; + ownerReferences?: OwnerReference[]; + finalizers?: string[]; + managedFields?: ManagedFieldsEntry[]; +} + +export interface OwnerReference { + apiVersion: string; + kind: string; + name: string; + uid: string; + controller?: boolean; + blockOwnerDeletion?: boolean; +} + +export interface ManagedFieldsEntry { + manager?: string; + operation?: string; + apiVersion?: string; + time?: string; + fieldsType?: string; + subresource?: string; +} + +export interface LogsDrilldownDefaultColumns { + kind: string; + apiVersion: string; + metadata: Metadata; + spec: Spec; + status: Status; +} diff --git a/apps/investigations/plugin/src/generated/investigation/v0alpha1/types.metadata.gen.ts b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.metadata.gen.ts similarity index 100% rename from apps/investigations/plugin/src/generated/investigation/v0alpha1/types.metadata.gen.ts rename to apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.metadata.gen.ts diff --git a/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.spec.gen.ts b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.spec.gen.ts new file mode 100644 index 00000000000..fde99894776 --- /dev/null +++ b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.spec.gen.ts @@ -0,0 +1,38 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +export type LogsDefaultColumnsRecords = LogsDefaultColumnsRecord[]; + +export const defaultLogsDefaultColumnsRecords = (): LogsDefaultColumnsRecords => ([]); + +export interface LogsDefaultColumnsRecord { + columns: string[]; + labels: LogsDefaultColumnsLabels; +} + +export const defaultLogsDefaultColumnsRecord = (): LogsDefaultColumnsRecord => ({ + columns: [], + labels: defaultLogsDefaultColumnsLabels(), +}); + +export type LogsDefaultColumnsLabels = LogsDefaultColumnsLabel[]; + +export const defaultLogsDefaultColumnsLabels = (): LogsDefaultColumnsLabels => ([]); + +export interface LogsDefaultColumnsLabel { + key: string; + value: string; +} + +export const defaultLogsDefaultColumnsLabel = (): LogsDefaultColumnsLabel => ({ + key: "", + value: "", +}); + +export interface Spec { + records: LogsDefaultColumnsRecords; +} + +export const defaultSpec = (): Spec => ({ + records: defaultLogsDefaultColumnsRecords(), +}); + diff --git a/apps/investigations/plugin/src/generated/investigation/v0alpha1/types.status.gen.ts b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.status.gen.ts similarity index 100% rename from apps/investigations/plugin/src/generated/investigation/v0alpha1/types.status.gen.ts rename to apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.status.gen.ts diff --git a/apps/plugins/Makefile b/apps/plugins/Makefile index 230bfd4149a..2db266ef19b 100644 --- a/apps/plugins/Makefile +++ b/apps/plugins/Makefile @@ -1,9 +1,16 @@ include ../sdk.mk -.PHONY: generate # Run Grafana App SDK code generation -generate: install-app-sdk update-app-sdk +.PHONY: internal-generate # Run Grafana App SDK code generation +internal-generate: install-app-sdk update-app-sdk @$(APP_SDK_BIN) generate \ --source=./kinds/ \ --gogenpath=./pkg/apis \ --grouping=group \ - --defencoding=none \ No newline at end of file + --defencoding=none + +.PHONY: generate +generate: internal-generate # copy files to packages/grafana-runtime/src/services/pluginMeta/types + rm -f ./packages/grafana-runtime/src/services/pluginMeta/types/*.ts + cp plugin/src/generated/meta/v0alpha1/meta_object_gen.ts ../../packages/grafana-runtime/src/services/pluginMeta/types/meta_object_gen.ts + cp plugin/src/generated/meta/v0alpha1/types.spec.gen.ts ../../packages/grafana-runtime/src/services/pluginMeta/types/types.spec.gen.ts + cp plugin/src/generated/meta/v0alpha1/types.status.gen.ts ../../packages/grafana-runtime/src/services/pluginMeta/types/types.status.gen.ts \ No newline at end of file diff --git a/apps/plugins/README.md b/apps/plugins/README.md index 7f91dd6ea12..f21fa6701b5 100644 --- a/apps/plugins/README.md +++ b/apps/plugins/README.md @@ -4,8 +4,7 @@ API documentation is available at http://localhost:3000/swagger?api=plugins.graf ## Codegen -- Go: `make generate` -- Frontend: Follow instructions in this [README](../..//packages/grafana-api-clients/README.md) +- Go and TypeScript: `make generate` ## Plugin sync diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index 69006b41792..7e9e5f47876 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -8,12 +8,17 @@ replace github.com/grafana/grafana/pkg/apimachinery => ../../pkg/apimachinery replace github.com/grafana/grafana/pkg/apiserver => ../../pkg/apiserver +replace github.com/grafana/grafana/pkg/plugins => ../../pkg/plugins + +replace github.com/grafana/grafana/pkg/semconv => ../../pkg/semconv + require ( github.com/emicklei/go-restful/v3 v3.13.0 github.com/grafana/grafana v0.0.0-00010101000000-000000000000 github.com/grafana/grafana-app-sdk v0.48.7 github.com/grafana/grafana-app-sdk/logging v0.48.7 github.com/grafana/grafana/pkg/apimachinery v0.0.0 + github.com/grafana/grafana/pkg/plugins v0.0.0 github.com/stretchr/testify v1.11.1 k8s.io/apimachinery v0.34.3 k8s.io/apiserver v0.34.3 @@ -26,7 +31,7 @@ require ( cel.dev/expr v0.25.1 // indirect github.com/Machiel/slugify v1.0.1 // indirect github.com/NYTimes/gziphandler v1.1.1 // indirect - github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apache/arrow-go/v18 v18.4.1 // indirect github.com/armon/go-metrics v0.4.1 // indirect @@ -92,7 +97,7 @@ require ( github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // indirect + github.com/grafana/alerting v0.0.0-20260112172717-98a49ed9557f // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect @@ -101,7 +106,7 @@ require ( github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect github.com/grafana/grafana-plugin-sdk-go v0.284.0 // indirect github.com/grafana/grafana/pkg/apiserver v0.0.0 // indirect - github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 // indirect + github.com/grafana/grafana/pkg/semconv v0.0.0 // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/grafana/sqlds/v5 v5.0.3 // indirect diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index d1429e1a498..c991111f90e 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -11,8 +11,8 @@ github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= -github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= -github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -215,8 +215,8 @@ 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/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts= -github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20260112172717-98a49ed9557f h1:3bXOyht68qkfvD6Y8z8XoenFbytSSOIkr/s+AqRzj0o= +github.com/grafana/alerting v0.0.0-20260112172717-98a49ed9557f/go.mod h1:Ji0SfJChcwjgq8ljy6Y5CcYfHfAYKXjKYeysOoDS/6s= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= @@ -235,8 +235,6 @@ github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1/go.mod h1:Oi4anANlCuTCc66jCyqIzfVbgLXFll8Wja+Y4vfANlc= github.com/grafana/grafana-plugin-sdk-go v0.284.0 h1:1bK7eWsnPBLUWDcWJWe218Ik5ad0a5JpEL4mH9ry7Ws= github.com/grafana/grafana-plugin-sdk-go v0.284.0/go.mod h1:lHPniaSxq3SL5MxDIPy04TYB1jnTp/ivkYO+xn5Rz3E= -github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 h1:A65jWgLk4Re28gIuZcpC0aTh71JZ0ey89hKGE9h543s= -github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2/go.mod h1:2HRzUK/xQEYc+8d5If/XSusMcaYq9IptnBSHACiQcOQ= github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 h1:aXfUhVN/Ewfpbko2CCtL65cIiGgwStOo4lWH2b6gw2U= diff --git a/apps/plugins/kinds/manifest.cue b/apps/plugins/kinds/manifest.cue index f624dc117bc..680a0f7565d 100644 --- a/apps/plugins/kinds/manifest.cue +++ b/apps/plugins/kinds/manifest.cue @@ -11,7 +11,7 @@ manifest: { v0alpha1Version: { served: true codegen: { - ts: {enabled: false} + ts: {enabled: true} go: {enabled: true} } kinds: [ diff --git a/apps/plugins/kinds/meta.cue b/apps/plugins/kinds/meta.cue index 01dc45adf77..479a9111d24 100644 --- a/apps/plugins/kinds/meta.cue +++ b/apps/plugins/kinds/meta.cue @@ -18,9 +18,6 @@ metaV0Alpha1: { type?: "grafana" | "commercial" | "community" | "private" | "private-glob" org?: string } - angular?: { - detected: bool - } translations?: [string]: string // +listType=atomic children?: [...string] diff --git a/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go index 631febbe2fa..141e9e5ad82 100644 --- a/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go +++ b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go @@ -215,7 +215,6 @@ type MetaSpec struct { Module *MetaV0alpha1SpecModule `json:"module,omitempty"` BaseURL *string `json:"baseURL,omitempty"` Signature *MetaV0alpha1SpecSignature `json:"signature,omitempty"` - Angular *MetaV0alpha1SpecAngular `json:"angular,omitempty"` Translations map[string]string `json:"translations,omitempty"` // +listType=atomic Children []string `json:"children,omitempty"` @@ -461,16 +460,6 @@ func NewMetaV0alpha1SpecSignature() *MetaV0alpha1SpecSignature { return &MetaV0alpha1SpecSignature{} } -// +k8s:openapi-gen=true -type MetaV0alpha1SpecAngular struct { - Detected bool `json:"detected"` -} - -// NewMetaV0alpha1SpecAngular creates a new MetaV0alpha1SpecAngular object. -func NewMetaV0alpha1SpecAngular() *MetaV0alpha1SpecAngular { - return &MetaV0alpha1SpecAngular{} -} - // +k8s:openapi-gen=true type MetaJSONDataType string diff --git a/apps/plugins/pkg/apis/plugins_manifest.go b/apps/plugins/pkg/apis/plugins_manifest.go index 0c52665e75d..f37c14ed0cf 100644 --- a/apps/plugins/pkg/apis/plugins_manifest.go +++ b/apps/plugins/pkg/apis/plugins_manifest.go @@ -23,7 +23,7 @@ var ( rawSchemaPluginv0alpha1 = []byte(`{"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"Plugin":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"id":{"type":"string"},"url":{"type":"string"},"version":{"type":"string"}},"required":["id","version"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) versionSchemaPluginv0alpha1 app.VersionSchema _ = json.Unmarshal(rawSchemaPluginv0alpha1, &versionSchemaPluginv0alpha1) - rawSchemaMetav0alpha1 = []byte(`{"Dependencies":{"additionalProperties":false,"properties":{"extensions":{"additionalProperties":false,"properties":{"exposedComponents":{"description":"+listType=set","items":{"type":"string"},"type":"array"}},"type":"object"},"grafanaDependency":{"description":"Required field","type":"string"},"grafanaVersion":{"description":"Optional fields","type":"string"},"plugins":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"enum":["app","datasource","panel"],"type":"string"}},"required":["id","type","name"],"type":"object"},"type":"array"}},"required":["grafanaDependency"],"type":"object"},"EnterpriseFeatures":{"additionalProperties":false,"properties":{"healthDiagnosticsErrors":{"default":false,"description":"Allow additional properties","type":"boolean"}},"type":"object"},"Extensions":{"additionalProperties":false,"properties":{"addedComponents":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"addedFunctions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"addedLinks":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"exposedComponents":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"},"extensionPoints":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"}},"type":"object"},"IAM":{"additionalProperties":false,"properties":{"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"Include":{"additionalProperties":false,"properties":{"action":{"type":"string"},"addToNav":{"type":"boolean"},"component":{"type":"string"},"defaultNav":{"type":"boolean"},"icon":{"type":"string"},"name":{"type":"string"},"path":{"type":"string"},"role":{"enum":["Admin","Editor","Viewer","None"],"type":"string"},"type":{"enum":["dashboard","page","panel","datasource"],"type":"string"},"uid":{"type":"string"}},"type":"object"},"Info":{"additionalProperties":false,"properties":{"author":{"additionalProperties":false,"description":"Optional fields","properties":{"email":{"type":"string"},"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"description":{"type":"string"},"keywords":{"description":"Required fields\n+listType=set","items":{"type":"string"},"type":"array"},"links":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"type":"array"},"logos":{"additionalProperties":false,"properties":{"large":{"type":"string"},"small":{"type":"string"}},"required":["small","large"],"type":"object"},"screenshots":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"path":{"type":"string"}},"type":"object"},"type":"array"},"updated":{"type":"string"},"version":{"type":"string"}},"required":["keywords","logos","updated","version"],"type":"object"},"JSONData":{"additionalProperties":false,"description":"JSON configuration schema for Grafana plugins\nConverted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json","properties":{"alerting":{"description":"Optional fields","type":"boolean"},"annotations":{"type":"boolean"},"autoEnabled":{"type":"boolean"},"backend":{"type":"boolean"},"buildMode":{"type":"string"},"builtIn":{"type":"boolean"},"category":{"enum":["tsdb","logging","cloud","tracing","profiling","sql","enterprise","iot","other"],"type":"string"},"dependencies":{"$ref":"#/components/schemas/Dependencies","description":"Dependency information"},"enterpriseFeatures":{"$ref":"#/components/schemas/EnterpriseFeatures"},"executable":{"type":"string"},"extensions":{"$ref":"#/components/schemas/Extensions"},"hideFromList":{"type":"boolean"},"iam":{"$ref":"#/components/schemas/IAM"},"id":{"description":"Unique name of the plugin","type":"string"},"includes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Include"},"type":"array"},"info":{"$ref":"#/components/schemas/Info","description":"Metadata for the plugin"},"logs":{"type":"boolean"},"metrics":{"type":"boolean"},"multiValueFilterOperators":{"type":"boolean"},"name":{"description":"Human-readable name of the plugin","type":"string"},"pascalName":{"type":"string"},"preload":{"type":"boolean"},"queryOptions":{"$ref":"#/components/schemas/QueryOptions"},"roles":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Role"},"type":"array"},"routes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Route"},"type":"array"},"skipDataQuery":{"type":"boolean"},"state":{"enum":["alpha","beta"],"type":"string"},"streaming":{"type":"boolean"},"suggestions":{"type":"boolean"},"tracing":{"type":"boolean"},"type":{"description":"Plugin type","enum":["app","datasource","panel","renderer"],"type":"string"}},"required":["id","type","name","info","dependencies"],"type":"object"},"Meta":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"QueryOptions":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"boolean"},"maxDataPoints":{"type":"boolean"},"minInterval":{"type":"boolean"}},"type":"object"},"Role":{"additionalProperties":false,"properties":{"grants":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"role":{"additionalProperties":false,"properties":{"description":{"type":"string"},"name":{"type":"string"},"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"}},"type":"object"},"Route":{"additionalProperties":false,"properties":{"body":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"headers":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"jwtTokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"method":{"type":"string"},"path":{"type":"string"},"reqAction":{"type":"string"},"reqRole":{"type":"string"},"reqSignedIn":{"type":"boolean"},"tokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"url":{"type":"string"},"urlParams":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"content":{"type":"string"},"name":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"spec":{"additionalProperties":false,"properties":{"angular":{"additionalProperties":false,"properties":{"detected":{"type":"boolean"}},"required":["detected"],"type":"object"},"baseURL":{"type":"string"},"children":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"class":{"enum":["core","external"],"type":"string"},"module":{"additionalProperties":false,"properties":{"hash":{"type":"string"},"loadingStrategy":{"enum":["fetch","script"],"type":"string"},"path":{"type":"string"}},"required":["path"],"type":"object"},"pluginJson":{"$ref":"#/components/schemas/JSONData"},"signature":{"additionalProperties":false,"properties":{"org":{"type":"string"},"status":{"enum":["internal","valid","invalid","modified","unsigned"],"type":"string"},"type":{"enum":["grafana","commercial","community","private","private-glob"],"type":"string"}},"required":["status"],"type":"object"},"translations":{"additionalProperties":{"type":"string"},"type":"object"}},"required":["pluginJson","class"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) + rawSchemaMetav0alpha1 = []byte(`{"Dependencies":{"additionalProperties":false,"properties":{"extensions":{"additionalProperties":false,"properties":{"exposedComponents":{"description":"+listType=set","items":{"type":"string"},"type":"array"}},"type":"object"},"grafanaDependency":{"description":"Required field","type":"string"},"grafanaVersion":{"description":"Optional fields","type":"string"},"plugins":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"enum":["app","datasource","panel"],"type":"string"}},"required":["id","type","name"],"type":"object"},"type":"array"}},"required":["grafanaDependency"],"type":"object"},"EnterpriseFeatures":{"additionalProperties":false,"properties":{"healthDiagnosticsErrors":{"default":false,"description":"Allow additional properties","type":"boolean"}},"type":"object"},"Extensions":{"additionalProperties":false,"properties":{"addedComponents":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"addedFunctions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"addedLinks":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"exposedComponents":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"},"extensionPoints":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"}},"type":"object"},"IAM":{"additionalProperties":false,"properties":{"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"Include":{"additionalProperties":false,"properties":{"action":{"type":"string"},"addToNav":{"type":"boolean"},"component":{"type":"string"},"defaultNav":{"type":"boolean"},"icon":{"type":"string"},"name":{"type":"string"},"path":{"type":"string"},"role":{"enum":["Admin","Editor","Viewer","None"],"type":"string"},"type":{"enum":["dashboard","page","panel","datasource"],"type":"string"},"uid":{"type":"string"}},"type":"object"},"Info":{"additionalProperties":false,"properties":{"author":{"additionalProperties":false,"description":"Optional fields","properties":{"email":{"type":"string"},"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"description":{"type":"string"},"keywords":{"description":"Required fields\n+listType=set","items":{"type":"string"},"type":"array"},"links":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"type":"array"},"logos":{"additionalProperties":false,"properties":{"large":{"type":"string"},"small":{"type":"string"}},"required":["small","large"],"type":"object"},"screenshots":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"path":{"type":"string"}},"type":"object"},"type":"array"},"updated":{"type":"string"},"version":{"type":"string"}},"required":["keywords","logos","updated","version"],"type":"object"},"JSONData":{"additionalProperties":false,"description":"JSON configuration schema for Grafana plugins\nConverted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json","properties":{"alerting":{"description":"Optional fields","type":"boolean"},"annotations":{"type":"boolean"},"autoEnabled":{"type":"boolean"},"backend":{"type":"boolean"},"buildMode":{"type":"string"},"builtIn":{"type":"boolean"},"category":{"enum":["tsdb","logging","cloud","tracing","profiling","sql","enterprise","iot","other"],"type":"string"},"dependencies":{"$ref":"#/components/schemas/Dependencies","description":"Dependency information"},"enterpriseFeatures":{"$ref":"#/components/schemas/EnterpriseFeatures"},"executable":{"type":"string"},"extensions":{"$ref":"#/components/schemas/Extensions"},"hideFromList":{"type":"boolean"},"iam":{"$ref":"#/components/schemas/IAM"},"id":{"description":"Unique name of the plugin","type":"string"},"includes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Include"},"type":"array"},"info":{"$ref":"#/components/schemas/Info","description":"Metadata for the plugin"},"logs":{"type":"boolean"},"metrics":{"type":"boolean"},"multiValueFilterOperators":{"type":"boolean"},"name":{"description":"Human-readable name of the plugin","type":"string"},"pascalName":{"type":"string"},"preload":{"type":"boolean"},"queryOptions":{"$ref":"#/components/schemas/QueryOptions"},"roles":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Role"},"type":"array"},"routes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Route"},"type":"array"},"skipDataQuery":{"type":"boolean"},"state":{"enum":["alpha","beta"],"type":"string"},"streaming":{"type":"boolean"},"suggestions":{"type":"boolean"},"tracing":{"type":"boolean"},"type":{"description":"Plugin type","enum":["app","datasource","panel","renderer"],"type":"string"}},"required":["id","type","name","info","dependencies"],"type":"object"},"Meta":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"QueryOptions":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"boolean"},"maxDataPoints":{"type":"boolean"},"minInterval":{"type":"boolean"}},"type":"object"},"Role":{"additionalProperties":false,"properties":{"grants":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"role":{"additionalProperties":false,"properties":{"description":{"type":"string"},"name":{"type":"string"},"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"}},"type":"object"},"Route":{"additionalProperties":false,"properties":{"body":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"headers":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"jwtTokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"method":{"type":"string"},"path":{"type":"string"},"reqAction":{"type":"string"},"reqRole":{"type":"string"},"reqSignedIn":{"type":"boolean"},"tokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"url":{"type":"string"},"urlParams":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"content":{"type":"string"},"name":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"spec":{"additionalProperties":false,"properties":{"baseURL":{"type":"string"},"children":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"class":{"enum":["core","external"],"type":"string"},"module":{"additionalProperties":false,"properties":{"hash":{"type":"string"},"loadingStrategy":{"enum":["fetch","script"],"type":"string"},"path":{"type":"string"}},"required":["path"],"type":"object"},"pluginJson":{"$ref":"#/components/schemas/JSONData"},"signature":{"additionalProperties":false,"properties":{"org":{"type":"string"},"status":{"enum":["internal","valid","invalid","modified","unsigned"],"type":"string"},"type":{"enum":["grafana","commercial","community","private","private-glob"],"type":"string"}},"required":["status"],"type":"object"},"translations":{"additionalProperties":{"type":"string"},"type":"object"}},"required":["pluginJson","class"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) versionSchemaMetav0alpha1 app.VersionSchema _ = json.Unmarshal(rawSchemaMetav0alpha1, &versionSchemaMetav0alpha1) ) diff --git a/apps/plugins/pkg/app/meta/converter.go b/apps/plugins/pkg/app/meta/converter.go index b8c0c4371d7..70a1bc78b0c 100644 --- a/apps/plugins/pkg/app/meta/converter.go +++ b/apps/plugins/pkg/app/meta/converter.go @@ -565,10 +565,6 @@ func pluginStorePluginToMeta(plugin pluginstore.Plugin, loadingStrategy plugins. metaSpec.Children = plugin.Children } - metaSpec.Angular = &pluginsv0alpha1.MetaV0alpha1SpecAngular{ - Detected: plugin.Angular.Detected, - } - if len(plugin.Translations) > 0 { metaSpec.Translations = plugin.Translations } @@ -668,10 +664,6 @@ func pluginToMetaSpec(plugin *plugins.Plugin) pluginsv0alpha1.MetaSpec { metaSpec.Children = children } - metaSpec.Angular = &pluginsv0alpha1.MetaV0alpha1SpecAngular{ - Detected: plugin.Angular.Detected, - } - if len(plugin.Translations) > 0 { metaSpec.Translations = plugin.Translations } @@ -712,8 +704,7 @@ type grafanaComPluginVersionMeta struct { Rel string `json:"rel"` Href string `json:"href"` } `json:"links"` - AngularDetected bool `json:"angularDetected"` - Scopes []string `json:"scopes"` + Scopes []string `json:"scopes"` } // grafanaComPluginVersionMetaToMetaSpec converts a grafanaComPluginVersionMeta to a pluginsv0alpha1.MetaSpec. @@ -753,10 +744,5 @@ func grafanaComPluginVersionMetaToMetaSpec(gcomMeta grafanaComPluginVersionMeta) metaSpec.Signature = signature } - // Set angular info - metaSpec.Angular = &pluginsv0alpha1.MetaV0alpha1SpecAngular{ - Detected: gcomMeta.AngularDetected, - } - return metaSpec } diff --git a/apps/investigations/plugin/src/generated/investigation/v0alpha1/investigation_object_gen.ts b/apps/plugins/plugin/src/generated/meta/v0alpha1/meta_object_gen.ts similarity index 97% rename from apps/investigations/plugin/src/generated/investigation/v0alpha1/investigation_object_gen.ts rename to apps/plugins/plugin/src/generated/meta/v0alpha1/meta_object_gen.ts index 3c7cf0874f8..044ec1f4cd8 100644 --- a/apps/investigations/plugin/src/generated/investigation/v0alpha1/investigation_object_gen.ts +++ b/apps/plugins/plugin/src/generated/meta/v0alpha1/meta_object_gen.ts @@ -40,7 +40,7 @@ export interface ManagedFieldsEntry { subresource?: string; } -export interface Investigation { +export interface Meta { kind: string; apiVersion: string; metadata: Metadata; diff --git a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.metadata.gen.ts b/apps/plugins/plugin/src/generated/meta/v0alpha1/types.metadata.gen.ts similarity index 100% rename from apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.metadata.gen.ts rename to apps/plugins/plugin/src/generated/meta/v0alpha1/types.metadata.gen.ts diff --git a/apps/plugins/plugin/src/generated/meta/v0alpha1/types.spec.gen.ts b/apps/plugins/plugin/src/generated/meta/v0alpha1/types.spec.gen.ts new file mode 100644 index 00000000000..51845e98454 --- /dev/null +++ b/apps/plugins/plugin/src/generated/meta/v0alpha1/types.spec.gen.ts @@ -0,0 +1,278 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +// JSON configuration schema for Grafana plugins +// Converted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json +export interface JSONData { + // Unique name of the plugin + id: string; + // Plugin type + type: "app" | "datasource" | "panel" | "renderer"; + // Human-readable name of the plugin + name: string; + // Metadata for the plugin + info: Info; + // Dependency information + dependencies: Dependencies; + // Optional fields + alerting?: boolean; + annotations?: boolean; + autoEnabled?: boolean; + backend?: boolean; + buildMode?: string; + builtIn?: boolean; + category?: "tsdb" | "logging" | "cloud" | "tracing" | "profiling" | "sql" | "enterprise" | "iot" | "other"; + enterpriseFeatures?: EnterpriseFeatures; + executable?: string; + hideFromList?: boolean; + // +listType=atomic + includes?: Include[]; + logs?: boolean; + metrics?: boolean; + multiValueFilterOperators?: boolean; + pascalName?: string; + preload?: boolean; + queryOptions?: QueryOptions; + // +listType=atomic + routes?: Route[]; + skipDataQuery?: boolean; + state?: "alpha" | "beta"; + streaming?: boolean; + suggestions?: boolean; + tracing?: boolean; + iam?: IAM; + // +listType=atomic + roles?: Role[]; + extensions?: Extensions; +} + +export const defaultJSONData = (): JSONData => ({ + id: "", + type: "app", + name: "", + info: defaultInfo(), + dependencies: defaultDependencies(), +}); + +export interface Info { + // Required fields + // +listType=set + keywords: string[]; + logos: { + small: string; + large: string; + }; + updated: string; + version: string; + // Optional fields + author?: { + name?: string; + email?: string; + url?: string; + }; + description?: string; + // +listType=atomic + links?: { + name?: string; + url?: string; + }[]; + // +listType=atomic + screenshots?: { + name?: string; + path?: string; + }[]; +} + +export const defaultInfo = (): Info => ({ + keywords: [], + logos: { + small: "", + large: "", +}, + updated: "", + version: "", +}); + +export interface Dependencies { + // Required field + grafanaDependency: string; + // Optional fields + grafanaVersion?: string; + // +listType=set + // +listMapKey=id + plugins?: { + id: string; + type: "app" | "datasource" | "panel"; + name: string; + }[]; + extensions?: { + // +listType=set + exposedComponents?: string[]; + }; +} + +export const defaultDependencies = (): Dependencies => ({ + grafanaDependency: "", +}); + +export interface EnterpriseFeatures { + // Allow additional properties + healthDiagnosticsErrors?: boolean; +} + +export const defaultEnterpriseFeatures = (): EnterpriseFeatures => ({ + healthDiagnosticsErrors: false, +}); + +export interface Include { + uid?: string; + type?: "dashboard" | "page" | "panel" | "datasource"; + name?: string; + component?: string; + role?: "Admin" | "Editor" | "Viewer" | "None"; + action?: string; + path?: string; + addToNav?: boolean; + defaultNav?: boolean; + icon?: string; +} + +export const defaultInclude = (): Include => ({ +}); + +export interface QueryOptions { + maxDataPoints?: boolean; + minInterval?: boolean; + cacheTimeout?: boolean; +} + +export const defaultQueryOptions = (): QueryOptions => ({ +}); + +export interface Route { + path?: string; + method?: string; + url?: string; + reqSignedIn?: boolean; + reqRole?: string; + reqAction?: string; + // +listType=atomic + headers?: string[]; + body?: Record; + tokenAuth?: { + url?: string; + // +listType=set + scopes?: string[]; + params?: Record; + }; + jwtTokenAuth?: { + url?: string; + // +listType=set + scopes?: string[]; + params?: Record; + }; + // +listType=atomic + urlParams?: { + name?: string; + content?: string; + }[]; +} + +export const defaultRoute = (): Route => ({ +}); + +export interface IAM { + // +listType=atomic + permissions?: { + action?: string; + scope?: string; + }[]; +} + +export const defaultIAM = (): IAM => ({ +}); + +export interface Role { + role?: { + name?: string; + description?: string; + // +listType=atomic + permissions?: { + action?: string; + scope?: string; + }[]; + }; + // +listType=set + grants?: string[]; +} + +export const defaultRole = (): Role => ({ +}); + +export interface Extensions { + // +listType=atomic + addedComponents?: { + // +listType=set + targets: string[]; + title: string; + description?: string; + }[]; + // +listType=atomic + addedLinks?: { + // +listType=set + targets: string[]; + title: string; + description?: string; + }[]; + // +listType=atomic + addedFunctions?: { + // +listType=set + targets: string[]; + title: string; + description?: string; + }[]; + // +listType=set + // +listMapKey=id + exposedComponents?: { + id: string; + title?: string; + description?: string; + }[]; + // +listType=set + // +listMapKey=id + extensionPoints?: { + id: string; + title?: string; + description?: string; + }[]; +} + +export const defaultExtensions = (): Extensions => ({ +}); + +export interface Spec { + pluginJson: JSONData; + class: "core" | "external"; + module?: { + path: string; + hash?: string; + loadingStrategy?: "fetch" | "script"; + }; + baseURL?: string; + signature?: { + status: "internal" | "valid" | "invalid" | "modified" | "unsigned"; + type?: "grafana" | "commercial" | "community" | "private" | "private-glob"; + org?: string; + }; + angular?: { + detected: boolean; + }; + translations?: Record; + // +listType=atomic + children?: string[]; +} + +export const defaultSpec = (): Spec => ({ + pluginJson: defaultJSONData(), + class: "core", +}); + diff --git a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.status.gen.ts b/apps/plugins/plugin/src/generated/meta/v0alpha1/types.status.gen.ts similarity index 100% rename from apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.status.gen.ts rename to apps/plugins/plugin/src/generated/meta/v0alpha1/types.status.gen.ts diff --git a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/investigationindex_object_gen.ts b/apps/plugins/plugin/src/generated/plugin/v0alpha1/plugin_object_gen.ts similarity index 96% rename from apps/investigations/plugin/src/generated/investigationindex/v0alpha1/investigationindex_object_gen.ts rename to apps/plugins/plugin/src/generated/plugin/v0alpha1/plugin_object_gen.ts index 7d88430d451..c4e625fc418 100644 --- a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/investigationindex_object_gen.ts +++ b/apps/plugins/plugin/src/generated/plugin/v0alpha1/plugin_object_gen.ts @@ -40,7 +40,7 @@ export interface ManagedFieldsEntry { subresource?: string; } -export interface InvestigationIndex { +export interface Plugin { kind: string; apiVersion: string; metadata: Metadata; diff --git a/apps/plugins/plugin/src/generated/plugin/v0alpha1/types.metadata.gen.ts b/apps/plugins/plugin/src/generated/plugin/v0alpha1/types.metadata.gen.ts new file mode 100644 index 00000000000..4377f3c1d08 --- /dev/null +++ b/apps/plugins/plugin/src/generated/plugin/v0alpha1/types.metadata.gen.ts @@ -0,0 +1,30 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +// metadata contains embedded CommonMetadata and can be extended with custom string fields +// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here +// without external reference as using the CommonMetadata reference breaks thema codegen. +export interface Metadata { + updateTimestamp: string; + createdBy: string; + uid: string; + creationTimestamp: string; + deletionTimestamp?: string; + finalizers: string[]; + resourceVersion: string; + generation: number; + updatedBy: string; + labels: Record; +} + +export const defaultMetadata = (): Metadata => ({ + updateTimestamp: "", + createdBy: "", + uid: "", + creationTimestamp: "", + finalizers: [], + resourceVersion: "", + generation: 0, + updatedBy: "", + labels: {}, +}); + diff --git a/apps/plugins/plugin/src/generated/plugin/v0alpha1/types.spec.gen.ts b/apps/plugins/plugin/src/generated/plugin/v0alpha1/types.spec.gen.ts new file mode 100644 index 00000000000..6b7824b8941 --- /dev/null +++ b/apps/plugins/plugin/src/generated/plugin/v0alpha1/types.spec.gen.ts @@ -0,0 +1,13 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +export interface Spec { + id: string; + version: string; + url?: string; +} + +export const defaultSpec = (): Spec => ({ + id: "", + version: "", +}); + diff --git a/apps/plugins/plugin/src/generated/plugin/v0alpha1/types.status.gen.ts b/apps/plugins/plugin/src/generated/plugin/v0alpha1/types.status.gen.ts new file mode 100644 index 00000000000..01be8df7961 --- /dev/null +++ b/apps/plugins/plugin/src/generated/plugin/v0alpha1/types.status.gen.ts @@ -0,0 +1,30 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +export interface OperatorState { + // lastEvaluation is the ResourceVersion last evaluated + lastEvaluation: string; + // state describes the state of the lastEvaluation. + // It is limited to three possible states for machine evaluation. + state: "success" | "in_progress" | "failed"; + // descriptiveState is an optional more descriptive state field which has no requirements on format + descriptiveState?: string; + // details contains any extra information that is operator-specific + details?: Record; +} + +export const defaultOperatorState = (): OperatorState => ({ + lastEvaluation: "", + state: "success", +}); + +export interface Status { + // operatorStates is a map of operator ID to operator state evaluations. + // Any operator which consumes this kind SHOULD add its state evaluation information to this field. + operatorStates?: Record; + // additionalFields is reserved for future use + additionalFields?: Record; +} + +export const defaultStatus = (): Status => ({ +}); + diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go index 228523f598e..2738af49db1 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go @@ -32,7 +32,7 @@ type ConnectionSecure struct { // Token is the reference of the token used to act as the Connection. // This value is stored securely and cannot be read back - Token common.InlineSecureValue `json:"webhook,omitzero,omitempty"` + Token common.InlineSecureValue `json:"token,omitzero,omitempty"` } func (v ConnectionSecure) IsZero() bool { @@ -116,3 +116,26 @@ type ConnectionList struct { // +listType=atomic Items []Connection `json:"items"` } + +// ExternalRepositoryList lists repositories from an external git provider +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ExternalRepositoryList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + // +listType=atomic + Items []ExternalRepository `json:"items"` +} + +type ExternalRepository struct { + // Name of the repository + Name string `json:"name"` + // Owner is the user, organization, or workspace that owns the repository + // For GitHub: organization or user + // For GitLab: namespace (user or group) + // For Bitbucket: workspace + // For pure Git: empty + Owner string `json:"owner,omitempty"` + // URL of the repository + URL string `json:"url"` +} diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go index f06798c0ddd..c1785a048d1 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go @@ -197,6 +197,7 @@ func AddKnownTypes(gv schema.GroupVersion, scheme *runtime.Scheme) error { &HistoricJobList{}, &Connection{}, &ConnectionList{}, + &ExternalRepositoryList{}, ) return nil } diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go index 48d03d23f99..97b1e752f68 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go @@ -262,6 +262,53 @@ func (in *ExportJobOptions) DeepCopy() *ExportJobOptions { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalRepository) DeepCopyInto(out *ExternalRepository) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalRepository. +func (in *ExternalRepository) DeepCopy() *ExternalRepository { + if in == nil { + return nil + } + out := new(ExternalRepository) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalRepositoryList) DeepCopyInto(out *ExternalRepositoryList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ExternalRepository, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalRepositoryList. +func (in *ExternalRepositoryList) DeepCopy() *ExternalRepositoryList { + if in == nil { + return nil + } + out := new(ExternalRepositoryList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ExternalRepositoryList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FileItem) DeepCopyInto(out *FileItem) { *out = *in diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index a96fa2d8a6b..4db11489c98 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -26,6 +26,8 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.DeleteJobOptions": schema_pkg_apis_provisioning_v0alpha1_DeleteJobOptions(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ErrorDetails": schema_pkg_apis_provisioning_v0alpha1_ErrorDetails(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ExportJobOptions": schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref), + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ExternalRepository": schema_pkg_apis_provisioning_v0alpha1_ExternalRepository(ref), + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ExternalRepositoryList": schema_pkg_apis_provisioning_v0alpha1_ExternalRepositoryList(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.FileItem": schema_pkg_apis_provisioning_v0alpha1_FileItem(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.FileList": schema_pkg_apis_provisioning_v0alpha1_FileList(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitHubConnectionConfig": schema_pkg_apis_provisioning_v0alpha1_GitHubConnectionConfig(ref), @@ -318,7 +320,7 @@ func schema_pkg_apis_provisioning_v0alpha1_ConnectionSecure(ref common.Reference Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue"), }, }, - "webhook": { + "token": { SchemaProps: spec.SchemaProps{ Description: "Token is the reference of the token used to act as the Connection. This value is stored securely and cannot be read back", Default: map[string]interface{}{}, @@ -544,6 +546,96 @@ func schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref common.Reference } } +func schema_pkg_apis_provisioning_v0alpha1_ExternalRepository(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 of the repository", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "owner": { + SchemaProps: spec.SchemaProps{ + Description: "Owner is the user, organization, or workspace that owns the repository For GitHub: organization or user For GitLab: namespace (user or group) For Bitbucket: workspace For pure Git: empty", + Type: []string{"string"}, + Format: "", + }, + }, + "url": { + SchemaProps: spec.SchemaProps{ + Description: "URL of the repository", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"name", "url"}, + }, + }, + } +} + +func schema_pkg_apis_provisioning_v0alpha1_ExternalRepositoryList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "ExternalRepositoryList lists repositories from an external git provider", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "atomic", + }, + }, + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ExternalRepository"), + }, + }, + }, + }, + }, + }, + Required: []string{"items"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ExternalRepository", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + func schema_pkg_apis_provisioning_v0alpha1_FileItem(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list index b9504855b80..72567e04b90 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -1,6 +1,7 @@ API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ConnectionList,Items API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,DeleteJobOptions,Paths API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,DeleteJobOptions,Resources +API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ExternalRepositoryList,Items API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,FileList,Items API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,HistoryList,Items API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobResourceSummary,Errors @@ -21,7 +22,6 @@ API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioni API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ResourceList,Items API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,TestResults,Errors API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,WebhookStatus,SubscribedEvents -API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ConnectionSecure,Token API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ConnectionSpec,GitHub API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobSpec,PullRequest API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobStatus,URLs diff --git a/apps/provisioning/pkg/connection/connection.go b/apps/provisioning/pkg/connection/connection.go new file mode 100644 index 00000000000..d2043b5af4a --- /dev/null +++ b/apps/provisioning/pkg/connection/connection.go @@ -0,0 +1,16 @@ +package connection + +import ( + "context" +) + +//go:generate mockery --name Connection --structname MockConnection --inpackage --filename connection_mock.go --with-expecter +type Connection interface { + // Validate ensures the resource _looks_ correct. + // It should be called before trying to upsert a resource into the Kubernetes API server. + // This is not an indication that the connection information works, just that they are reasonably configured. + Validate(ctx context.Context) error + + // Mutate performs in place mutation of the underneath resource. + Mutate(context.Context) error +} diff --git a/apps/provisioning/pkg/connection/connection_mock.go b/apps/provisioning/pkg/connection/connection_mock.go new file mode 100644 index 00000000000..3867059d432 --- /dev/null +++ b/apps/provisioning/pkg/connection/connection_mock.go @@ -0,0 +1,128 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package connection + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// MockConnection is an autogenerated mock type for the Connection type +type MockConnection struct { + mock.Mock +} + +type MockConnection_Expecter struct { + mock *mock.Mock +} + +func (_m *MockConnection) EXPECT() *MockConnection_Expecter { + return &MockConnection_Expecter{mock: &_m.Mock} +} + +// Mutate provides a mock function with given fields: _a0 +func (_m *MockConnection) Mutate(_a0 context.Context) error { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for Mutate") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockConnection_Mutate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Mutate' +type MockConnection_Mutate_Call struct { + *mock.Call +} + +// Mutate is a helper method to define mock.On call +// - _a0 context.Context +func (_e *MockConnection_Expecter) Mutate(_a0 interface{}) *MockConnection_Mutate_Call { + return &MockConnection_Mutate_Call{Call: _e.mock.On("Mutate", _a0)} +} + +func (_c *MockConnection_Mutate_Call) Run(run func(_a0 context.Context)) *MockConnection_Mutate_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockConnection_Mutate_Call) Return(_a0 error) *MockConnection_Mutate_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConnection_Mutate_Call) RunAndReturn(run func(context.Context) error) *MockConnection_Mutate_Call { + _c.Call.Return(run) + return _c +} + +// Validate provides a mock function with given fields: ctx +func (_m *MockConnection) Validate(ctx context.Context) error { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Validate") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = rf(ctx) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockConnection_Validate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Validate' +type MockConnection_Validate_Call struct { + *mock.Call +} + +// Validate is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockConnection_Expecter) Validate(ctx interface{}) *MockConnection_Validate_Call { + return &MockConnection_Validate_Call{Call: _e.mock.On("Validate", ctx)} +} + +func (_c *MockConnection_Validate_Call) Run(run func(ctx context.Context)) *MockConnection_Validate_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockConnection_Validate_Call) Return(_a0 error) *MockConnection_Validate_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConnection_Validate_Call) RunAndReturn(run func(context.Context) error) *MockConnection_Validate_Call { + _c.Call.Return(run) + return _c +} + +// NewMockConnection creates a new instance of MockConnection. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockConnection(t interface { + mock.TestingT + Cleanup(func()) +}) *MockConnection { + mock := &MockConnection{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/apps/provisioning/pkg/connection/extra_mock.go b/apps/provisioning/pkg/connection/extra_mock.go new file mode 100644 index 00000000000..cc2a1f3d5e2 --- /dev/null +++ b/apps/provisioning/pkg/connection/extra_mock.go @@ -0,0 +1,141 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package connection + +import ( + context "context" + + v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + mock "github.com/stretchr/testify/mock" +) + +// MockExtra is an autogenerated mock type for the Extra type +type MockExtra struct { + mock.Mock +} + +type MockExtra_Expecter struct { + mock *mock.Mock +} + +func (_m *MockExtra) EXPECT() *MockExtra_Expecter { + return &MockExtra_Expecter{mock: &_m.Mock} +} + +// Build provides a mock function with given fields: ctx, r +func (_m *MockExtra) Build(ctx context.Context, r *v0alpha1.Connection) (Connection, error) { + ret := _m.Called(ctx, r) + + if len(ret) == 0 { + panic("no return value specified for Build") + } + + var r0 Connection + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) (Connection, error)); ok { + return rf(ctx, r) + } + if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) Connection); ok { + r0 = rf(ctx, r) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(Connection) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *v0alpha1.Connection) error); ok { + r1 = rf(ctx, r) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockExtra_Build_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Build' +type MockExtra_Build_Call struct { + *mock.Call +} + +// Build is a helper method to define mock.On call +// - ctx context.Context +// - r *v0alpha1.Connection +func (_e *MockExtra_Expecter) Build(ctx interface{}, r interface{}) *MockExtra_Build_Call { + return &MockExtra_Build_Call{Call: _e.mock.On("Build", ctx, r)} +} + +func (_c *MockExtra_Build_Call) Run(run func(ctx context.Context, r *v0alpha1.Connection)) *MockExtra_Build_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*v0alpha1.Connection)) + }) + return _c +} + +func (_c *MockExtra_Build_Call) Return(_a0 Connection, _a1 error) *MockExtra_Build_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockExtra_Build_Call) RunAndReturn(run func(context.Context, *v0alpha1.Connection) (Connection, error)) *MockExtra_Build_Call { + _c.Call.Return(run) + return _c +} + +// Type provides a mock function with no fields +func (_m *MockExtra) Type() v0alpha1.ConnectionType { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Type") + } + + var r0 v0alpha1.ConnectionType + if rf, ok := ret.Get(0).(func() v0alpha1.ConnectionType); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(v0alpha1.ConnectionType) + } + + return r0 +} + +// MockExtra_Type_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Type' +type MockExtra_Type_Call struct { + *mock.Call +} + +// Type is a helper method to define mock.On call +func (_e *MockExtra_Expecter) Type() *MockExtra_Type_Call { + return &MockExtra_Type_Call{Call: _e.mock.On("Type")} +} + +func (_c *MockExtra_Type_Call) Run(run func()) *MockExtra_Type_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockExtra_Type_Call) Return(_a0 v0alpha1.ConnectionType) *MockExtra_Type_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockExtra_Type_Call) RunAndReturn(run func() v0alpha1.ConnectionType) *MockExtra_Type_Call { + _c.Call.Return(run) + return _c +} + +// NewMockExtra creates a new instance of MockExtra. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockExtra(t interface { + mock.TestingT + Cleanup(func()) +}) *MockExtra { + mock := &MockExtra{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/apps/provisioning/pkg/connection/factory.go b/apps/provisioning/pkg/connection/factory.go new file mode 100644 index 00000000000..4a0e46d84d2 --- /dev/null +++ b/apps/provisioning/pkg/connection/factory.go @@ -0,0 +1,75 @@ +package connection + +import ( + "context" + "fmt" + "sort" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +//go:generate mockery --name=Extra --structname=MockExtra --inpackage --filename=extra_mock.go --with-expecter +type Extra interface { + Type() provisioning.ConnectionType + Build(ctx context.Context, r *provisioning.Connection) (Connection, error) +} + +//go:generate mockery --name=Factory --structname=MockFactory --inpackage --filename=factory_mock.go --with-expecter +type Factory interface { + Types() []provisioning.ConnectionType + Build(ctx context.Context, r *provisioning.Connection) (Connection, error) +} + +type factory struct { + extras map[provisioning.ConnectionType]Extra + enabled map[provisioning.ConnectionType]struct{} +} + +func ProvideFactory(enabled map[provisioning.ConnectionType]struct{}, extras []Extra) (Factory, error) { + f := &factory{ + enabled: enabled, + extras: make(map[provisioning.ConnectionType]Extra, len(extras)), + } + + for _, e := range extras { + if _, exists := f.extras[e.Type()]; exists { + return nil, fmt.Errorf("connection type %q is already registered", e.Type()) + } + f.extras[e.Type()] = e + } + + return f, nil +} + +func (f *factory) Types() []provisioning.ConnectionType { + var types []provisioning.ConnectionType + for t := range f.enabled { + if _, exists := f.extras[t]; exists { + types = append(types, t) + } + } + + sort.Slice(types, func(i, j int) bool { + return string(types[i]) < string(types[j]) + }) + + return types +} + +func (f *factory) Build(ctx context.Context, c *provisioning.Connection) (Connection, error) { + for _, e := range f.extras { + if e.Type() == c.Spec.Type { + if _, enabled := f.enabled[e.Type()]; !enabled { + return nil, fmt.Errorf("connection type %q is not enabled", e.Type()) + } + + return e.Build(ctx, c) + } + } + + return nil, fmt.Errorf("connection type %q is not supported", c.Spec.Type) +} + +var ( + _ Factory = (*factory)(nil) +) diff --git a/apps/provisioning/pkg/connection/factory_mock.go b/apps/provisioning/pkg/connection/factory_mock.go new file mode 100644 index 00000000000..8fd7023920f --- /dev/null +++ b/apps/provisioning/pkg/connection/factory_mock.go @@ -0,0 +1,143 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package connection + +import ( + context "context" + + v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + mock "github.com/stretchr/testify/mock" +) + +// MockFactory is an autogenerated mock type for the Factory type +type MockFactory struct { + mock.Mock +} + +type MockFactory_Expecter struct { + mock *mock.Mock +} + +func (_m *MockFactory) EXPECT() *MockFactory_Expecter { + return &MockFactory_Expecter{mock: &_m.Mock} +} + +// Build provides a mock function with given fields: ctx, r +func (_m *MockFactory) Build(ctx context.Context, r *v0alpha1.Connection) (Connection, error) { + ret := _m.Called(ctx, r) + + if len(ret) == 0 { + panic("no return value specified for Build") + } + + var r0 Connection + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) (Connection, error)); ok { + return rf(ctx, r) + } + if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) Connection); ok { + r0 = rf(ctx, r) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(Connection) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *v0alpha1.Connection) error); ok { + r1 = rf(ctx, r) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockFactory_Build_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Build' +type MockFactory_Build_Call struct { + *mock.Call +} + +// Build is a helper method to define mock.On call +// - ctx context.Context +// - r *v0alpha1.Connection +func (_e *MockFactory_Expecter) Build(ctx interface{}, r interface{}) *MockFactory_Build_Call { + return &MockFactory_Build_Call{Call: _e.mock.On("Build", ctx, r)} +} + +func (_c *MockFactory_Build_Call) Run(run func(ctx context.Context, r *v0alpha1.Connection)) *MockFactory_Build_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*v0alpha1.Connection)) + }) + return _c +} + +func (_c *MockFactory_Build_Call) Return(_a0 Connection, _a1 error) *MockFactory_Build_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockFactory_Build_Call) RunAndReturn(run func(context.Context, *v0alpha1.Connection) (Connection, error)) *MockFactory_Build_Call { + _c.Call.Return(run) + return _c +} + +// Types provides a mock function with no fields +func (_m *MockFactory) Types() []v0alpha1.ConnectionType { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Types") + } + + var r0 []v0alpha1.ConnectionType + if rf, ok := ret.Get(0).(func() []v0alpha1.ConnectionType); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]v0alpha1.ConnectionType) + } + } + + return r0 +} + +// MockFactory_Types_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Types' +type MockFactory_Types_Call struct { + *mock.Call +} + +// Types is a helper method to define mock.On call +func (_e *MockFactory_Expecter) Types() *MockFactory_Types_Call { + return &MockFactory_Types_Call{Call: _e.mock.On("Types")} +} + +func (_c *MockFactory_Types_Call) Run(run func()) *MockFactory_Types_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockFactory_Types_Call) Return(_a0 []v0alpha1.ConnectionType) *MockFactory_Types_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockFactory_Types_Call) RunAndReturn(run func() []v0alpha1.ConnectionType) *MockFactory_Types_Call { + _c.Call.Return(run) + return _c +} + +// NewMockFactory creates a new instance of MockFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockFactory(t interface { + mock.TestingT + Cleanup(func()) +}) *MockFactory { + mock := &MockFactory{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/apps/provisioning/pkg/connection/factory_test.go b/apps/provisioning/pkg/connection/factory_test.go new file mode 100644 index 00000000000..4ce6bc96e0e --- /dev/null +++ b/apps/provisioning/pkg/connection/factory_test.go @@ -0,0 +1,309 @@ +package connection + +import ( + "context" + "errors" + "testing" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestProvideFactory(t *testing.T) { + t.Run("should create factory with valid extras", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + provisioning.GitlabConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.NoError(t, err) + require.NotNil(t, factory) + }) + + t.Run("should create factory with empty extras", func(t *testing.T) { + enabled := map[provisioning.ConnectionType]struct{}{} + + factory, err := ProvideFactory(enabled, []Extra{}) + require.NoError(t, err) + require.NotNil(t, factory) + }) + + t.Run("should create factory with nil enabled map", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + factory, err := ProvideFactory(nil, []Extra{extra1}) + require.NoError(t, err) + require.NotNil(t, factory) + }) + + t.Run("should return error when duplicate repository types", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GithubConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.Error(t, err) + assert.Nil(t, factory) + assert.Contains(t, err.Error(), "connection type \"github\" is already registered") + }) +} + +func TestFactory_Types(t *testing.T) { + t.Run("should return only enabled types that have extras", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + provisioning.GitlabConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.NoError(t, err) + + types := factory.Types() + assert.Len(t, types, 2) + assert.Contains(t, types, provisioning.GithubConnectionType) + assert.Contains(t, types, provisioning.GitlabConnectionType) + }) + + t.Run("should return sorted list of types", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GitlabConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GithubConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + provisioning.GitlabConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.NoError(t, err) + + types := factory.Types() + assert.Len(t, types, 2) + // github should come before gitlab alphabetically + assert.Equal(t, provisioning.GithubConnectionType, types[0]) + assert.Equal(t, provisioning.GitlabConnectionType, types[1]) + }) + + t.Run("should return empty list when no types are enabled", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{} + + factory, err := ProvideFactory(enabled, []Extra{extra1}) + require.NoError(t, err) + + types := factory.Types() + assert.Empty(t, types) + }) + + t.Run("should not return types that are enabled but have no extras", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + provisioning.GitlabConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1}) + require.NoError(t, err) + + types := factory.Types() + assert.Len(t, types, 1) + assert.Contains(t, types, provisioning.GithubConnectionType) + assert.NotContains(t, types, provisioning.GitlabConnectionType) + }) + + t.Run("should not return types that have extras but are not enabled", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.NoError(t, err) + + types := factory.Types() + assert.Len(t, types, 1) + assert.Contains(t, types, provisioning.GithubConnectionType) + assert.NotContains(t, types, provisioning.GitlabConnectionType) + }) + + t.Run("should return empty list when no extras are provided", func(t *testing.T) { + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{}) + require.NoError(t, err) + + types := factory.Types() + assert.Empty(t, types) + }) +} + +func TestFactory_Build(t *testing.T) { + t.Run("should successfully build connection when type is enabled and has extra", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + }, + } + + mockConnection := NewMockConnection(t) + extra := NewMockExtra(t) + extra.EXPECT().Type().Return(provisioning.GithubConnectionType) + extra.EXPECT().Build(ctx, conn).Return(mockConnection, nil) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra}) + require.NoError(t, err) + + result, err := factory.Build(ctx, conn) + require.NoError(t, err) + assert.Equal(t, mockConnection, result) + }) + + t.Run("should return error when type is not enabled", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GitlabConnectionType, + }, + } + + extra := NewMockExtra(t) + extra.EXPECT().Type().Return(provisioning.GitlabConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra}) + require.NoError(t, err) + + result, err := factory.Build(ctx, conn) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "connection type \"gitlab\" is not enabled") + }) + + t.Run("should return error when type is not supported", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GitlabConnectionType, + }, + } + + extra := NewMockExtra(t) + extra.EXPECT().Type().Return(provisioning.GithubConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra}) + require.NoError(t, err) + + result, err := factory.Build(ctx, conn) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "connection type \"gitlab\" is not supported") + }) + + t.Run("should pass through errors from extra.Build()", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + }, + } + + expectedErr := errors.New("build error") + extra := NewMockExtra(t) + extra.EXPECT().Type().Return(provisioning.GithubConnectionType) + extra.EXPECT().Build(ctx, conn).Return(nil, expectedErr) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra}) + require.NoError(t, err) + + result, err := factory.Build(ctx, conn) + require.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, expectedErr, err) + }) + + t.Run("should build with multiple extras registered", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GitlabConnectionType, + }, + } + + mockConnection := NewMockConnection(t) + + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType) + extra2.EXPECT().Build(ctx, conn).Return(mockConnection, nil) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + provisioning.GitlabConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.NoError(t, err) + + result, err := factory.Build(ctx, conn) + require.NoError(t, err) + assert.Equal(t, mockConnection, result) + }) +} diff --git a/apps/provisioning/pkg/connection/github/client.go b/apps/provisioning/pkg/connection/github/client.go new file mode 100644 index 00000000000..7ddb9a4665e --- /dev/null +++ b/apps/provisioning/pkg/connection/github/client.go @@ -0,0 +1,93 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/google/go-github/v70/github" + apierrors "k8s.io/apimachinery/pkg/api/errors" +) + +// API errors that we need to convey after parsing real GH errors (or faking them). +var ( + //lint:ignore ST1005 this is not punctuation + ErrServiceUnavailable = apierrors.NewServiceUnavailable("github is unavailable") +) + +//go:generate mockery --name Client --structname MockClient --inpackage --filename client_mock.go --with-expecter +type Client interface { + // Apps and installations + GetApp(ctx context.Context) (App, error) + GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error) +} + +// App represents a Github App. +type App struct { + // ID represents the GH app ID. + ID int64 + // Slug represents the GH app slug. + Slug string + // Owner represents the GH account/org owning the app + Owner string +} + +// AppInstallation represents a Github App Installation. +type AppInstallation struct { + // ID represents the GH installation ID. + ID int64 + // Whether the installation is enabled or not. + Enabled bool +} + +type githubClient struct { + gh *github.Client +} + +func NewClient(client *github.Client) Client { + return &githubClient{client} +} + +// GetApp gets the app by using the given token. +func (r *githubClient) GetApp(ctx context.Context) (App, error) { + app, _, err := r.gh.Apps.Get(ctx, "") + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable { + return App{}, ErrServiceUnavailable + } + return App{}, err + } + + // TODO(ferruvich): do we need any other info? + return App{ + ID: app.GetID(), + Slug: app.GetSlug(), + Owner: app.GetOwner().GetLogin(), + }, nil +} + +// GetAppInstallation gets the installation of the app related to the given token. +func (r *githubClient) GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error) { + id, err := strconv.Atoi(installationID) + if err != nil { + return AppInstallation{}, fmt.Errorf("invalid installation ID: %s", installationID) + } + + installation, _, err := r.gh.Apps.GetInstallation(ctx, int64(id)) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable { + return AppInstallation{}, ErrServiceUnavailable + } + return AppInstallation{}, err + } + + // TODO(ferruvich): do we need any other info? + return AppInstallation{ + ID: installation.GetID(), + Enabled: installation.GetSuspendedAt().IsZero(), + }, nil +} diff --git a/apps/provisioning/pkg/connection/github/client_mock.go b/apps/provisioning/pkg/connection/github/client_mock.go new file mode 100644 index 00000000000..c9f009f5021 --- /dev/null +++ b/apps/provisioning/pkg/connection/github/client_mock.go @@ -0,0 +1,149 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package github + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// MockClient is an autogenerated mock type for the Client type +type MockClient struct { + mock.Mock +} + +type MockClient_Expecter struct { + mock *mock.Mock +} + +func (_m *MockClient) EXPECT() *MockClient_Expecter { + return &MockClient_Expecter{mock: &_m.Mock} +} + +// GetApp provides a mock function with given fields: ctx +func (_m *MockClient) GetApp(ctx context.Context) (App, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetApp") + } + + var r0 App + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (App, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) App); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(App) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetApp' +type MockClient_GetApp_Call struct { + *mock.Call +} + +// GetApp is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockClient_Expecter) GetApp(ctx interface{}) *MockClient_GetApp_Call { + return &MockClient_GetApp_Call{Call: _e.mock.On("GetApp", ctx)} +} + +func (_c *MockClient_GetApp_Call) Run(run func(ctx context.Context)) *MockClient_GetApp_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockClient_GetApp_Call) Return(_a0 App, _a1 error) *MockClient_GetApp_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetApp_Call) RunAndReturn(run func(context.Context) (App, error)) *MockClient_GetApp_Call { + _c.Call.Return(run) + return _c +} + +// GetAppInstallation provides a mock function with given fields: ctx, installationID +func (_m *MockClient) GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error) { + ret := _m.Called(ctx, installationID) + + if len(ret) == 0 { + panic("no return value specified for GetAppInstallation") + } + + var r0 AppInstallation + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (AppInstallation, error)); ok { + return rf(ctx, installationID) + } + if rf, ok := ret.Get(0).(func(context.Context, string) AppInstallation); ok { + r0 = rf(ctx, installationID) + } else { + r0 = ret.Get(0).(AppInstallation) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, installationID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetAppInstallation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAppInstallation' +type MockClient_GetAppInstallation_Call struct { + *mock.Call +} + +// GetAppInstallation is a helper method to define mock.On call +// - ctx context.Context +// - installationID string +func (_e *MockClient_Expecter) GetAppInstallation(ctx interface{}, installationID interface{}) *MockClient_GetAppInstallation_Call { + return &MockClient_GetAppInstallation_Call{Call: _e.mock.On("GetAppInstallation", ctx, installationID)} +} + +func (_c *MockClient_GetAppInstallation_Call) Run(run func(ctx context.Context, installationID string)) *MockClient_GetAppInstallation_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *MockClient_GetAppInstallation_Call) Return(_a0 AppInstallation, _a1 error) *MockClient_GetAppInstallation_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetAppInstallation_Call) RunAndReturn(run func(context.Context, string) (AppInstallation, error)) *MockClient_GetAppInstallation_Call { + _c.Call.Return(run) + return _c +} + +// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockClient(t interface { + mock.TestingT + Cleanup(func()) +}) *MockClient { + mock := &MockClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/apps/provisioning/pkg/connection/github/client_test.go b/apps/provisioning/pkg/connection/github/client_test.go new file mode 100644 index 00000000000..bae6d6ac1e9 --- /dev/null +++ b/apps/provisioning/pkg/connection/github/client_test.go @@ -0,0 +1,297 @@ +package github_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/google/go-github/v70/github" + conngh "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" + mockhub "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGithubClient_GetApp(t *testing.T) { + tests := []struct { + name string + mockHandler *http.Client + token string + wantApp conngh.App + wantErr error + }{ + { + name: "get app successfully", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + app := &github.App{ + ID: github.Ptr(int64(12345)), + Slug: github.Ptr("my-test-app"), + Owner: &github.User{ + Login: github.Ptr("grafana"), + }, + } + w.WriteHeader(http.StatusOK) + require.NoError(t, json.NewEncoder(w).Encode(app)) + }), + ), + ), + token: "test-token", + wantApp: conngh.App{ + ID: 12345, + Slug: "my-test-app", + Owner: "grafana", + }, + wantErr: nil, + }, + { + name: "service unavailable", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusServiceUnavailable, + }, + Message: "Service unavailable", + })) + }), + ), + ), + token: "test-token", + wantApp: conngh.App{}, + wantErr: conngh.ErrServiceUnavailable, + }, + { + name: "other error", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + }, + Message: "Internal server error", + })) + }), + ), + ), + token: "test-token", + wantApp: conngh.App{}, + wantErr: &github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + }, + Message: "Internal server error", + }, + }, + { + name: "unauthorized error", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusUnauthorized, + }, + Message: "Bad credentials", + })) + }), + ), + ), + token: "invalid-token", + wantApp: conngh.App{}, + wantErr: &github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusUnauthorized, + }, + Message: "Bad credentials", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock client + ghClient := github.NewClient(tt.mockHandler) + client := conngh.NewClient(ghClient) + + // Call the method being tested + app, err := client.GetApp(context.Background()) + + // Check the error + if tt.wantErr != nil { + assert.Error(t, err) + assert.Equal(t, tt.wantApp, app) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantApp, app) + } + }) + } +} + +func TestGithubClient_GetAppInstallation(t *testing.T) { + tests := []struct { + name string + mockHandler *http.Client + appToken string + installationID string + wantInstallation conngh.AppInstallation + wantErr bool + errContains string + }{ + { + name: "get disabled app installation successfully", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + installation := &github.Installation{ + ID: github.Ptr(int64(67890)), + SuspendedAt: github.Ptr(github.Timestamp{Time: time.Now()}), + } + w.WriteHeader(http.StatusOK) + require.NoError(t, json.NewEncoder(w).Encode(installation)) + }), + ), + ), + appToken: "test-app-token", + installationID: "67890", + wantInstallation: conngh.AppInstallation{ + ID: 67890, + Enabled: false, + }, + wantErr: false, + }, + { + name: "get enabled app installation successfully", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + installation := &github.Installation{ + ID: github.Ptr(int64(67890)), + SuspendedAt: nil, + } + w.WriteHeader(http.StatusOK) + require.NoError(t, json.NewEncoder(w).Encode(installation)) + }), + ), + ), + appToken: "test-app-token", + installationID: "67890", + wantInstallation: conngh.AppInstallation{ + ID: 67890, + Enabled: true, + }, + wantErr: false, + }, + { + name: "invalid installation ID", + mockHandler: mockhub.NewMockedHTTPClient(), + appToken: "test-app-token", + installationID: "not-a-number", + wantInstallation: conngh.AppInstallation{}, + wantErr: true, + errContains: "invalid installation ID", + }, + { + name: "service unavailable", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusServiceUnavailable, + }, + Message: "Service unavailable", + })) + }), + ), + ), + appToken: "test-app-token", + installationID: "67890", + wantInstallation: conngh.AppInstallation{}, + wantErr: true, + }, + { + name: "installation not found", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + }, + Message: "Not Found", + })) + }), + ), + ), + appToken: "test-app-token", + installationID: "99999", + wantInstallation: conngh.AppInstallation{}, + wantErr: true, + }, + { + name: "other error", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + }, + Message: "Internal server error", + })) + }), + ), + ), + appToken: "test-app-token", + installationID: "67890", + wantInstallation: conngh.AppInstallation{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock client + ghClient := github.NewClient(tt.mockHandler) + client := conngh.NewClient(ghClient) + + // Call the method being tested + installation, err := client.GetAppInstallation(context.Background(), tt.installationID) + + // Check the error + if tt.wantErr { + assert.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + } else { + assert.NoError(t, err) + } + + // Check the result + assert.Equal(t, tt.wantInstallation, installation) + }) + } +} diff --git a/apps/provisioning/pkg/connection/github/connection.go b/apps/provisioning/pkg/connection/github/connection.go new file mode 100644 index 00000000000..6a2da98ac8d --- /dev/null +++ b/apps/provisioning/pkg/connection/github/connection.go @@ -0,0 +1,192 @@ +package github + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v4" + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/connection" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +//go:generate mockery --name GithubFactory --structname MockGithubFactory --inpackage --filename factory_mock.go --with-expecter +type GithubFactory interface { + New(ctx context.Context, ghToken common.RawSecureValue) Client +} + +type Connection struct { + obj *provisioning.Connection + ghFactory GithubFactory +} + +func NewConnection( + obj *provisioning.Connection, + factory GithubFactory, +) Connection { + return Connection{ + obj: obj, + ghFactory: factory, + } +} + +const ( + //TODO(ferruvich): these probably need to be setup in API configuration. + githubInstallationURL = "https://github.com/settings/installations" + jwtExpirationMinutes = 10 // GitHub Apps JWT tokens expire in 10 minutes maximum +) + +// Mutate performs in place mutation of the underneath resource. +func (c *Connection) Mutate(_ context.Context) error { + // Do nothing in case spec.Github is nil. + // If this field is required, we should fail at validation time. + if c.obj.Spec.GitHub == nil { + return nil + } + + c.obj.Spec.URL = fmt.Sprintf("%s/%s", githubInstallationURL, c.obj.Spec.GitHub.InstallationID) + + // Generate JWT token if private key is being provided. + // Same as for the spec.Github, if such a field is required, Validation will take care of that. + if !c.obj.Secure.PrivateKey.Create.IsZero() { + token, err := generateToken(c.obj.Spec.GitHub.AppID, c.obj.Secure.PrivateKey.Create) + if err != nil { + return fmt.Errorf("failed to generate JWT token: %w", err) + } + + // Store the generated token + c.obj.Secure.Token = common.InlineSecureValue{Create: token} + } + + return nil +} + +// Token generates and returns the Connection token. +func generateToken(appID string, privateKey common.RawSecureValue) (common.RawSecureValue, error) { + // Decode base64-encoded private key + privateKeyPEM, err := base64.StdEncoding.DecodeString(string(privateKey)) + if err != nil { + return "", fmt.Errorf("failed to decode base64 private key: %w", err) + } + + // Parse the private key + key, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyPEM) + if err != nil { + return "", fmt.Errorf("failed to parse private key: %w", err) + } + + // Create the JWT token + now := time.Now() + claims := jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(jwtExpirationMinutes) * time.Minute)), + Issuer: appID, + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + signedToken, err := token.SignedString(key) + if err != nil { + return "", fmt.Errorf("failed to sign JWT token: %w", err) + } + + return common.RawSecureValue(signedToken), nil +} + +// Validate ensures the resource _looks_ correct. +func (c *Connection) Validate(ctx context.Context) error { + list := field.ErrorList{} + + if c.obj.Spec.Type != provisioning.GithubConnectionType { + list = append(list, field.Invalid(field.NewPath("spec", "type"), c.obj.Spec.Type, "invalid connection type")) + + // Doesn't make much sense to continue validating a connection which is not a Github one. + return toError(c.obj.GetName(), list) + } + + if c.obj.Spec.GitHub == nil { + list = append( + list, field.Required(field.NewPath("spec", "github"), "github info must be specified for GitHub connection"), + ) + + // Doesn't make much sense to continue validating a connection with no information. + return toError(c.obj.GetName(), list) + } + + if c.obj.Secure.PrivateKey.IsZero() { + list = append(list, field.Required(field.NewPath("secure", "privateKey"), "privateKey must be specified for GitHub connection")) + } + if c.obj.Secure.Token.IsZero() { + list = append(list, field.Required(field.NewPath("secure", "token"), "token must be specified for GitHub connection")) + } + if !c.obj.Secure.ClientSecret.IsZero() { + list = append(list, field.Forbidden(field.NewPath("secure", "clientSecret"), "clientSecret is forbidden in GitHub connection")) + } + + // Validate GitHub configuration fields + if c.obj.Spec.GitHub.AppID == "" { + list = append(list, field.Required(field.NewPath("spec", "github", "appID"), "appID must be specified for GitHub connection")) + } + if c.obj.Spec.GitHub.InstallationID == "" { + list = append(list, field.Required(field.NewPath("spec", "github", "installationID"), "installationID must be specified for GitHub connection")) + } + + // In case we have any error above, we don't go forward with the validation, and return the errors. + if len(list) > 0 { + return toError(c.obj.GetName(), list) + } + + // Validating app content via GH API + if err := c.validateAppAndInstallation(ctx); err != nil { + list = append(list, err) + } + + return toError(c.obj.GetName(), list) +} + +// validateAppAndInstallation validates the appID and installationID against the given github token. +func (c *Connection) validateAppAndInstallation(ctx context.Context) *field.Error { + ghClient := c.ghFactory.New(ctx, c.obj.Secure.Token.Create) + + app, err := ghClient.GetApp(ctx) + if err != nil { + if errors.Is(err, ErrServiceUnavailable) { + return field.InternalError(field.NewPath("spec", "token"), ErrServiceUnavailable) + } + return field.Invalid(field.NewPath("spec", "token"), "[REDACTED]", "invalid token") + } + + if fmt.Sprintf("%d", app.ID) != c.obj.Spec.GitHub.AppID { + return field.Invalid(field.NewPath("spec", "appID"), c.obj.Spec.GitHub.AppID, "appID mismatch") + } + + _, err = ghClient.GetAppInstallation(ctx, c.obj.Spec.GitHub.InstallationID) + if err != nil { + if errors.Is(err, ErrServiceUnavailable) { + return field.InternalError(field.NewPath("spec", "token"), ErrServiceUnavailable) + } + return field.Invalid(field.NewPath("spec", "installationID"), c.obj.Spec.GitHub.InstallationID, "invalid installation ID") + } + + return nil +} + +// toError converts a field.ErrorList to an error, returning nil if the list is empty +func toError(name string, list field.ErrorList) error { + if len(list) == 0 { + return nil + } + return apierrors.NewInvalid( + provisioning.ConnectionResourceInfo.GroupVersionKind().GroupKind(), + name, + list, + ) +} + +var ( + _ connection.Connection = (*Connection)(nil) +) diff --git a/apps/provisioning/pkg/connection/github/connection_test.go b/apps/provisioning/pkg/connection/github/connection_test.go new file mode 100644 index 00000000000..6a916db730e --- /dev/null +++ b/apps/provisioning/pkg/connection/github/connection_test.go @@ -0,0 +1,434 @@ +package github + +import ( + "context" + "encoding/base64" + "testing" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +//nolint:gosec // Test RSA private key (generated for testing purposes only) +const testPrivateKeyPEM = `-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAoInVbLY9io2Q/wHvUIXlEHg2Qyvd8eRzBAVEJ92DS6fx9H10 +06V0VRm78S0MXyo6i+n8ZAbZ0/R+GWpP2Ephxm0Gs2zo+iO2mpB19xQFI4o6ZTOw +b2WyjSaa2Vr4oyDkqti6AvfjW4VUAu932e08GkgwmmQSHXj7FX2CMWjgUwTTcuaX +65SHNKLNYLUP0HTumLzoZeqDTdoMMpKNdgH9Avr4/8vkVJ0mD6rqvxnw3JHsseNO +WdQTxf2aApBNHIIKxWZ2i/ZmjLNey7kltgjEquGiBdJvip3fHhH5XHdkrXcjRtnw +OJDnDmi5lQwv5yUBOSkbvbXRv/L/m0YLoD/fbwIDAQABAoIBAFfl//hM8/cnuesV ++R1Con/ZAgTXQOdPqPXbmEyniVrkMqMmCdBUOBTcST4s5yg36+RtkeaGpb/ajyyF +PAB2AYDucwvMpudGpJWOYTiOOp4R8hU1LvZfXVrRd1lo6NgQi4NLtNUpOtACeVQ+ +H4Yv0YemXQ47mnuOoRNMK/u3q5NoIdSahWptXBgUno8KklNpUrH3IYWaUxfBzDN3 +2xsVRTn2SfTSyoDmTDdTgptJONmoK1/sV7UsgWksdFc6XyYhsFAZgOGEJrBABRvF +546dyQ0cWxuPyVXpM7CN3tqC5ssvLjElg3LicK1V6gnjpdRnnvX88d1Eh3Uc/9IM +OZInT2ECgYEA6W8sQXTWinyEwl8SDKKMbB2ApIghAcFgdRxprZE4WFxjsYNCNL70 +dnSB7MRuzmxf5W77cV0N7JhH66N8HvY6Xq9olrpQ5dNttR4w8Pyv3wavDe8x7seL +5L2Xtbu7ihDr8Dk27MjiBSin3IxhBP5CJS910+pR6LrAWtEuU+FzFfECgYEAsA6y +qxHhCMXlTnauXhsnmPd1g61q7chW8kLQFYtHMLlQlgjHTW7irDZ9cPbPYDNjwRLO +7KLorcpv2NKe7rqq2ZyCm6hf1b9WnlQjo3dLpNWMu6fhy/smK8MgbRqcWpX+oTKF +79mK6hbY7o6eBzsQHBl7Z+LBNuwYmp9qOodPa18CgYEArv6ipKdcNhFGzRfMRiCN +OHederp6VACNuP2F05IsNUF9kxOdTEFirnKE++P+VU01TqA2azOhPp6iO+ohIGzi +MR06QNSH1OL9OWvasK4dggpWrRGF00VQgDgJRTnpS4WH+lxJ6pRlrAxgWpv6F24s +VAgSQr1Ejj2B+hMasdMvHWECgYBJ4uE4yhgXBnZlp4kmFV9Y4wF+cZkekaVrpn6N +jBYkbKFVVfnOlWqru3KJpgsB5I9IyAvvY68iwIKQDFSG+/AXw4dMrC0MF3DSoZ0T +TU2Br92QI7SvVod+djV1lGVp3ukt3XY4YqPZ+hywgUnw3uiz4j3YK2HLGup4ec6r +IX5DIQKBgHRLzvT3zqtlR1Oh0vv098clLwt+pGzXOxzJpxioOa5UqK13xIpFXbcg +iWUVh5YXCcuqaICUv4RLIEac5xQitk9Is/9IhP0NJ/81rHniosvdSpCeFXzxTImS +B8Uc0WUgheB4+yVKGnYpYaSOgFFI5+1BYUva/wDHLy2pWHz39Usb +-----END RSA PRIVATE KEY-----` + +func TestConnection_Mutate(t *testing.T) { + t.Run("should add URL to Github connection", func(t *testing.T) { + c := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Name: "test-private-key", + }, + }, + } + + mockFactory := NewMockGithubFactory(t) + conn := NewConnection(c, mockFactory) + + require.NoError(t, conn.Mutate(context.Background())) + assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL) + }) + + t.Run("should generate JWT token when private key is provided", func(t *testing.T) { + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) + + c := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue(privateKeyBase64), + }, + }, + } + + mockFactory := NewMockGithubFactory(t) + conn := NewConnection(c, mockFactory) + + require.NoError(t, conn.Mutate(context.Background())) + assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL) + assert.False(t, c.Secure.Token.Create.IsZero(), "JWT token should be generated") + }) + + t.Run("should do nothing when GitHub config is nil", func(t *testing.T) { + c := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GitlabConnectionType, + Gitlab: &provisioning.GitlabConnectionConfig{ + ClientID: "clientID", + }, + }, + } + + mockFactory := NewMockGithubFactory(t) + conn := NewConnection(c, mockFactory) + + require.NoError(t, conn.Mutate(context.Background())) + }) + + t.Run("should fail when private key is not base64", func(t *testing.T) { + c := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("invalid-key"), + }, + }, + } + + mockFactory := NewMockGithubFactory(t) + conn := NewConnection(c, mockFactory) + + err := conn.Mutate(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to generate JWT token") + assert.Contains(t, err.Error(), "failed to decode base64 private key") + }) + + t.Run("should fail when private key is invalid", func(t *testing.T) { + c := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue(base64.StdEncoding.EncodeToString([]byte("invalid-key"))), + }, + }, + } + + mockFactory := NewMockGithubFactory(t) + conn := NewConnection(c, mockFactory) + + err := conn.Mutate(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to generate JWT token") + assert.Contains(t, err.Error(), "failed to parse private key") + }) +} + +func TestConnection_Validate(t *testing.T) { + tests := []struct { + name string + connection *provisioning.Connection + setupMock func(*MockGithubFactory) + wantErr bool + errMsgContains []string + }{ + { + name: "invalid type returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: "invalid", + }, + }, + wantErr: true, + errMsgContains: []string{"spec.type"}, + }, + { + name: "github type without github config returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.github"}, + }, + { + name: "github type without private key returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + }, + wantErr: true, + errMsgContains: []string{"secure.privateKey"}, + }, + { + name: "github type without token returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"secure.token"}, + }, + { + name: "github type with client secret returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + ClientSecret: common.InlineSecureValue{ + Create: common.NewSecretValue("test-client-secret"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"secure.clientSecret"}, + }, + { + name: "github type without appID returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("test-token"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.github.appID"}, + }, + { + name: "github type without installationID returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Name: "test-private-key", + }, + Token: common.InlineSecureValue{ + Name: "test-token", + }, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.github.installationID"}, + }, + { + name: "github type with valid config is valid", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("test-token"), + }, + }, + }, + wantErr: false, + setupMock: func(mockFactory *MockGithubFactory) { + mockClient := NewMockClient(t) + + mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient) + mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 123, Slug: "test-app"}, nil) + mockClient.EXPECT().GetAppInstallation(mock.Anything, "456").Return(AppInstallation{ID: 456}, nil) + }, + }, + { + name: "problem getting app returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("test-token"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.token", "[REDACTED]"}, + setupMock: func(mockFactory *MockGithubFactory) { + mockClient := NewMockClient(t) + + mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient) + mockClient.EXPECT().GetApp(mock.Anything).Return(App{}, assert.AnError) + }, + }, + { + name: "mismatched app ID returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("test-token"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.appID"}, + setupMock: func(mockFactory *MockGithubFactory) { + mockClient := NewMockClient(t) + + mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient) + mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 444, Slug: "test-app"}, nil) + }, + }, + { + name: "problem when getting installation returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("test-token"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.installationID", "456"}, + setupMock: func(mockFactory *MockGithubFactory) { + mockClient := NewMockClient(t) + + mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient) + mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 123, Slug: "test-app"}, nil) + mockClient.EXPECT().GetAppInstallation(mock.Anything, "456").Return(AppInstallation{}, assert.AnError) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockFactory := NewMockGithubFactory(t) + if tt.setupMock != nil { + tt.setupMock(mockFactory) + } + + conn := NewConnection(tt.connection, mockFactory) + err := conn.Validate(context.Background()) + if tt.wantErr { + assert.Error(t, err) + for _, msg := range tt.errMsgContains { + assert.Contains(t, err.Error(), msg) + } + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/apps/provisioning/pkg/connection/github/extra.go b/apps/provisioning/pkg/connection/github/extra.go new file mode 100644 index 00000000000..2c207637c61 --- /dev/null +++ b/apps/provisioning/pkg/connection/github/extra.go @@ -0,0 +1,36 @@ +package github + +import ( + "context" + "fmt" + + "github.com/grafana/grafana-app-sdk/logging" + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/connection" +) + +type extra struct { + factory GithubFactory +} + +func (e *extra) Type() provisioning.ConnectionType { + return provisioning.GithubConnectionType +} + +func (e *extra) Build(ctx context.Context, connection *provisioning.Connection) (connection.Connection, error) { + logger := logging.FromContext(ctx) + if connection == nil || connection.Spec.GitHub == nil { + logger.Error("connection is nil or github info is nil") + + return nil, fmt.Errorf("invalid github connection") + } + + c := NewConnection(connection, e.factory) + return &c, nil +} + +func Extra(factory GithubFactory) connection.Extra { + return &extra{ + factory: factory, + } +} diff --git a/apps/provisioning/pkg/connection/github/extra_test.go b/apps/provisioning/pkg/connection/github/extra_test.go new file mode 100644 index 00000000000..c5bcc8279d9 --- /dev/null +++ b/apps/provisioning/pkg/connection/github/extra_test.go @@ -0,0 +1,126 @@ +package github_test + +import ( + "context" + "testing" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestExtra_Type(t *testing.T) { + t.Run("should return GithubConnectionType", func(t *testing.T) { + mockFactory := github.NewMockGithubFactory(t) + e := github.Extra(mockFactory) + result := e.Type() + assert.Equal(t, provisioning.GithubConnectionType, result) + }) +} + +func TestExtra_Build(t *testing.T) { + t.Run("should successfully build connection", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + }, + } + + mockFactory := github.NewMockGithubFactory(t) + + e := github.Extra(mockFactory) + + result, err := e.Build(ctx, conn) + require.NoError(t, err) + require.NotNil(t, result) + }) + + t.Run("should handle different connection configurations", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "another-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "789", + InstallationID: "101112", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Name: "existing-private-key", + }, + Token: common.InlineSecureValue{ + Name: "existing-token", + }, + }, + } + + mockFactory := github.NewMockGithubFactory(t) + + e := github.Extra(mockFactory) + + result, err := e.Build(ctx, conn) + require.NoError(t, err) + require.NotNil(t, result) + }) + + t.Run("should build connection with background context", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + } + + mockFactory := github.NewMockGithubFactory(t) + e := github.Extra(mockFactory) + result, err := e.Build(ctx, conn) + require.NoError(t, err) + require.NotNil(t, result) + }) + + t.Run("should always pass empty token to factory.New", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("some-token"), + }, + }, + } + + mockFactory := github.NewMockGithubFactory(t) + e := github.Extra(mockFactory) + result, err := e.Build(ctx, conn) + require.NoError(t, err) + require.NotNil(t, result) + }) +} diff --git a/apps/provisioning/pkg/connection/github/factory.go b/apps/provisioning/pkg/connection/github/factory.go new file mode 100644 index 00000000000..2399f3c9f69 --- /dev/null +++ b/apps/provisioning/pkg/connection/github/factory.go @@ -0,0 +1,39 @@ +package github + +import ( + "context" + "net/http" + + "github.com/google/go-github/v70/github" + "golang.org/x/oauth2" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +// Factory creates new GitHub clients. +// It exists only for the ability to test the code easily. +type Factory struct { + // Client allows overriding the client to use in the GH client returned. It exists primarily for testing. + // FIXME: we should replace in this way. We should add some options pattern for the factory. + Client *http.Client +} + +func ProvideFactory() GithubFactory { + return &Factory{} +} + +func (r *Factory) New(ctx context.Context, ghToken common.RawSecureValue) Client { + if r.Client != nil { + return NewClient(github.NewClient(r.Client)) + } + + if !ghToken.IsZero() { + tokenSrc := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: string(ghToken)}, + ) + tokenClient := oauth2.NewClient(ctx, tokenSrc) + return NewClient(github.NewClient(tokenClient)) + } + + return NewClient(github.NewClient(&http.Client{})) +} diff --git a/apps/provisioning/pkg/connection/github/factory_mock.go b/apps/provisioning/pkg/connection/github/factory_mock.go new file mode 100644 index 00000000000..a9e1424b62d --- /dev/null +++ b/apps/provisioning/pkg/connection/github/factory_mock.go @@ -0,0 +1,86 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package github + +import ( + context "context" + + v0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + mock "github.com/stretchr/testify/mock" +) + +// MockGithubFactory is an autogenerated mock type for the GithubFactory type +type MockGithubFactory struct { + mock.Mock +} + +type MockGithubFactory_Expecter struct { + mock *mock.Mock +} + +func (_m *MockGithubFactory) EXPECT() *MockGithubFactory_Expecter { + return &MockGithubFactory_Expecter{mock: &_m.Mock} +} + +// New provides a mock function with given fields: ctx, ghToken +func (_m *MockGithubFactory) New(ctx context.Context, ghToken v0alpha1.RawSecureValue) Client { + ret := _m.Called(ctx, ghToken) + + if len(ret) == 0 { + panic("no return value specified for New") + } + + var r0 Client + if rf, ok := ret.Get(0).(func(context.Context, v0alpha1.RawSecureValue) Client); ok { + r0 = rf(ctx, ghToken) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(Client) + } + } + + return r0 +} + +// MockGithubFactory_New_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'New' +type MockGithubFactory_New_Call struct { + *mock.Call +} + +// New is a helper method to define mock.On call +// - ctx context.Context +// - ghToken v0alpha1.RawSecureValue +func (_e *MockGithubFactory_Expecter) New(ctx interface{}, ghToken interface{}) *MockGithubFactory_New_Call { + return &MockGithubFactory_New_Call{Call: _e.mock.On("New", ctx, ghToken)} +} + +func (_c *MockGithubFactory_New_Call) Run(run func(ctx context.Context, ghToken v0alpha1.RawSecureValue)) *MockGithubFactory_New_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(v0alpha1.RawSecureValue)) + }) + return _c +} + +func (_c *MockGithubFactory_New_Call) Return(_a0 Client) *MockGithubFactory_New_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGithubFactory_New_Call) RunAndReturn(run func(context.Context, v0alpha1.RawSecureValue) Client) *MockGithubFactory_New_Call { + _c.Call.Return(run) + return _c +} + +// NewMockGithubFactory creates a new instance of MockGithubFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockGithubFactory(t interface { + mock.TestingT + Cleanup(func()) +}) *MockGithubFactory { + mock := &MockGithubFactory{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/apps/provisioning/pkg/connection/mutator.go b/apps/provisioning/pkg/connection/mutator.go deleted file mode 100644 index 30291669905..00000000000 --- a/apps/provisioning/pkg/connection/mutator.go +++ /dev/null @@ -1,28 +0,0 @@ -package connection - -import ( - "fmt" - - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" -) - -const ( - githubInstallationURL = "https://github.com/settings/installations" -) - -func MutateConnection(connection *provisioning.Connection) error { - switch connection.Spec.Type { - case provisioning.GithubConnectionType: - // Do nothing in case spec.Github is nil. - // If this field is required, we should fail at validation time. - if connection.Spec.GitHub == nil { - return nil - } - - connection.Spec.URL = fmt.Sprintf("%s/%s", githubInstallationURL, connection.Spec.GitHub.InstallationID) - return nil - default: - // TODO: we need to setup the URL for bitbucket and gitlab. - return nil - } -} diff --git a/apps/provisioning/pkg/connection/mutator_test.go b/apps/provisioning/pkg/connection/mutator_test.go deleted file mode 100644 index a25aabd10a1..00000000000 --- a/apps/provisioning/pkg/connection/mutator_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package connection_test - -import ( - "testing" - - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - "github.com/grafana/grafana/apps/provisioning/pkg/connection" - common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestMutateConnection(t *testing.T) { - t.Run("should add URL to Github connection", func(t *testing.T) { - c := &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GithubConnectionType, - GitHub: &provisioning.GitHubConnectionConfig{ - AppID: "123", - InstallationID: "456", - }, - }, - Secure: provisioning.ConnectionSecure{ - PrivateKey: common.InlineSecureValue{ - Name: "test-private-key", - }, - }, - } - - require.NoError(t, connection.MutateConnection(c)) - assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL) - }) -} diff --git a/apps/provisioning/pkg/connection/validator.go b/apps/provisioning/pkg/connection/validator.go deleted file mode 100644 index c2537e3af2f..00000000000 --- a/apps/provisioning/pkg/connection/validator.go +++ /dev/null @@ -1,104 +0,0 @@ -package connection - -import ( - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -func ValidateConnection(connection *provisioning.Connection) error { - list := field.ErrorList{} - - if connection.Spec.Type == "" { - list = append(list, field.Required(field.NewPath("spec", "type"), "type must be specified")) - } - - switch connection.Spec.Type { - case provisioning.GithubConnectionType: - list = append(list, validateGithubConnection(connection)...) - case provisioning.BitbucketConnectionType: - list = append(list, validateBitbucketConnection(connection)...) - case provisioning.GitlabConnectionType: - list = append(list, validateGitlabConnection(connection)...) - default: - list = append( - list, field.NotSupported( - field.NewPath("spec", "type"), - connection.Spec.Type, - []provisioning.ConnectionType{ - provisioning.GithubConnectionType, - provisioning.BitbucketConnectionType, - provisioning.GitlabConnectionType, - }), - ) - } - - return toError(connection.GetName(), list) -} - -func validateGithubConnection(connection *provisioning.Connection) field.ErrorList { - list := field.ErrorList{} - - if connection.Spec.GitHub == nil { - list = append( - list, field.Required(field.NewPath("spec", "github"), "github info must be specified for GitHub connection"), - ) - } - - if connection.Secure.PrivateKey.IsZero() { - list = append(list, field.Required(field.NewPath("secure", "privateKey"), "privateKey must be specified for GitHub connection")) - } - if !connection.Secure.ClientSecret.IsZero() { - list = append(list, field.Forbidden(field.NewPath("secure", "clientSecret"), "clientSecret is forbidden in GitHub connection")) - } - - return list -} - -func validateBitbucketConnection(connection *provisioning.Connection) field.ErrorList { - list := field.ErrorList{} - - if connection.Spec.Bitbucket == nil { - list = append( - list, field.Required(field.NewPath("spec", "bitbucket"), "bitbucket info must be specified in Bitbucket connection"), - ) - } - if connection.Secure.ClientSecret.IsZero() { - list = append(list, field.Required(field.NewPath("secure", "clientSecret"), "clientSecret must be specified for Bitbucket connection")) - } - if !connection.Secure.PrivateKey.IsZero() { - list = append(list, field.Forbidden(field.NewPath("secure", "privateKey"), "privateKey is forbidden in Bitbucket connection")) - } - - return list -} - -func validateGitlabConnection(connection *provisioning.Connection) field.ErrorList { - list := field.ErrorList{} - - if connection.Spec.Gitlab == nil { - list = append( - list, field.Required(field.NewPath("spec", "gitlab"), "gitlab info must be specified in Gitlab connection"), - ) - } - if connection.Secure.ClientSecret.IsZero() { - list = append(list, field.Required(field.NewPath("secure", "clientSecret"), "clientSecret must be specified for Gitlab connection")) - } - if !connection.Secure.PrivateKey.IsZero() { - list = append(list, field.Forbidden(field.NewPath("secure", "privateKey"), "privateKey is forbidden in Gitlab connection")) - } - - return list -} - -// toError converts a field.ErrorList to an error, returning nil if the list is empty -func toError(name string, list field.ErrorList) error { - if len(list) == 0 { - return nil - } - return apierrors.NewInvalid( - provisioning.ConnectionResourceInfo.GroupVersionKind().GroupKind(), - name, - list, - ) -} diff --git a/apps/provisioning/pkg/connection/validator_test.go b/apps/provisioning/pkg/connection/validator_test.go deleted file mode 100644 index 23d4b01b800..00000000000 --- a/apps/provisioning/pkg/connection/validator_test.go +++ /dev/null @@ -1,253 +0,0 @@ -package connection_test - -import ( - "testing" - - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - "github.com/grafana/grafana/apps/provisioning/pkg/connection" - common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" - "github.com/stretchr/testify/assert" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestValidateConnection(t *testing.T) { - tests := []struct { - name string - connection *provisioning.Connection - wantErr bool - errMsg string - }{ - { - name: "empty type returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{}, - }, - wantErr: true, - errMsg: "spec.type", - }, - { - name: "invalid type returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: "invalid", - }, - }, - wantErr: true, - errMsg: "spec.type", - }, - { - name: "github type without github config returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GithubConnectionType, - }, - }, - wantErr: true, - errMsg: "spec.github", - }, - { - name: "github type without private key returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GithubConnectionType, - GitHub: &provisioning.GitHubConnectionConfig{ - AppID: "123", - InstallationID: "456", - }, - }, - }, - wantErr: true, - errMsg: "secure.privateKey", - }, - { - name: "github type with client secret returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GithubConnectionType, - GitHub: &provisioning.GitHubConnectionConfig{ - AppID: "123", - InstallationID: "456", - }, - }, - Secure: provisioning.ConnectionSecure{ - PrivateKey: common.InlineSecureValue{ - Name: "test-private-key", - }, - ClientSecret: common.InlineSecureValue{ - Name: "test-client-secret", - }, - }, - }, - wantErr: true, - errMsg: "secure.clientSecret", - }, - { - name: "github type with github config is valid", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GithubConnectionType, - GitHub: &provisioning.GitHubConnectionConfig{ - AppID: "123", - InstallationID: "456", - }, - }, - Secure: provisioning.ConnectionSecure{ - PrivateKey: common.InlineSecureValue{ - Name: "test-private-key", - }, - }, - }, - wantErr: false, - }, - { - name: "bitbucket type without bitbucket config returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.BitbucketConnectionType, - }, - }, - wantErr: true, - errMsg: "spec.bitbucket", - }, - { - name: "bitbucket type without client secret returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.BitbucketConnectionType, - Bitbucket: &provisioning.BitbucketConnectionConfig{ - ClientID: "client-123", - }, - }, - }, - wantErr: true, - errMsg: "secure.clientSecret", - }, - { - name: "bitbucket type with private key returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.BitbucketConnectionType, - Bitbucket: &provisioning.BitbucketConnectionConfig{ - ClientID: "client-123", - }, - }, - Secure: provisioning.ConnectionSecure{ - PrivateKey: common.InlineSecureValue{ - Name: "test-private-key", - }, - ClientSecret: common.InlineSecureValue{ - Name: "test-client-secret", - }, - }, - }, - wantErr: true, - errMsg: "secure.privateKey", - }, - { - name: "bitbucket type with bitbucket config is valid", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.BitbucketConnectionType, - Bitbucket: &provisioning.BitbucketConnectionConfig{ - ClientID: "client-123", - }, - }, - Secure: provisioning.ConnectionSecure{ - ClientSecret: common.InlineSecureValue{ - Name: "test-client-secret", - }, - }, - }, - wantErr: false, - }, - { - name: "gitlab type without gitlab config returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GitlabConnectionType, - }, - }, - wantErr: true, - errMsg: "spec.gitlab", - }, - { - name: "gitlab type without client secret returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GitlabConnectionType, - Gitlab: &provisioning.GitlabConnectionConfig{ - ClientID: "client-456", - }, - }, - }, - wantErr: true, - errMsg: "secure.clientSecret", - }, - { - name: "gitlab type with private key returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GitlabConnectionType, - Gitlab: &provisioning.GitlabConnectionConfig{ - ClientID: "client-456", - }, - }, - Secure: provisioning.ConnectionSecure{ - PrivateKey: common.InlineSecureValue{ - Name: "test-private-key", - }, - ClientSecret: common.InlineSecureValue{ - Name: "test-client-secret", - }, - }, - }, - wantErr: true, - errMsg: "secure.privateKey", - }, - { - name: "gitlab type with gitlab config is valid", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GitlabConnectionType, - Gitlab: &provisioning.GitlabConnectionConfig{ - ClientID: "client-456", - }, - }, - Secure: provisioning.ConnectionSecure{ - ClientSecret: common.InlineSecureValue{ - Name: "test-client-secret", - }, - }, - }, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := connection.ValidateConnection(tt.connection) - if tt.wantErr { - assert.Error(t, err) - if tt.errMsg != "" { - assert.Contains(t, err.Error(), tt.errMsg) - } - } else { - assert.NoError(t, err) - } - }) - } -} diff --git a/apps/provisioning/pkg/controller/connection_status.go b/apps/provisioning/pkg/controller/connection_status.go new file mode 100644 index 00000000000..0d8a0002f41 --- /dev/null +++ b/apps/provisioning/pkg/controller/connection_status.go @@ -0,0 +1,40 @@ +package controller + +import ( + "context" + "encoding/json" + "fmt" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// ConnectionStatusPatcher provides methods to patch Connection status subresources. +type ConnectionStatusPatcher struct { + client client.ProvisioningV0alpha1Interface +} + +// NewConnectionStatusPatcher creates a new ConnectionStatusPatcher. +func NewConnectionStatusPatcher(client client.ProvisioningV0alpha1Interface) *ConnectionStatusPatcher { + return &ConnectionStatusPatcher{ + client: client, + } +} + +// Patch applies JSON patch operations to a Connection's status subresource. +func (p *ConnectionStatusPatcher) Patch(ctx context.Context, conn *provisioning.Connection, patchOperations ...map[string]interface{}) error { + patch, err := json.Marshal(patchOperations) + if err != nil { + return fmt.Errorf("unable to marshal patch data: %w", err) + } + + _, err = p.client.Connections(conn.Namespace). + Patch(ctx, conn.Name, types.JSONPatchType, patch, metav1.PatchOptions{}, "status") + if err != nil { + return fmt.Errorf("unable to update connection status: %w", err) + } + + return nil +} diff --git a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionsecure.go b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionsecure.go index 8ac26b192c9..f5be635560d 100644 --- a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionsecure.go +++ b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionsecure.go @@ -13,7 +13,7 @@ import ( type ConnectionSecureApplyConfiguration struct { PrivateKey *commonv0alpha1.InlineSecureValue `json:"privateKey,omitempty"` ClientSecret *commonv0alpha1.InlineSecureValue `json:"clientSecret,omitempty"` - Token *commonv0alpha1.InlineSecureValue `json:"webhook,omitempty"` + Token *commonv0alpha1.InlineSecureValue `json:"token,omitempty"` } // ConnectionSecureApplyConfiguration constructs a declarative configuration of the ConnectionSecure type for use with diff --git a/conf/defaults.ini b/conf/defaults.ini index 363ca39d0c4..080d4e62fe0 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -336,7 +336,7 @@ rudderstack_data_plane_url = rudderstack_sdk_url = # Rudderstack v3 SDK, optional, defaults to false. If set, Rudderstack v3 SDK will be used instead of v1 -rudderstack_v3_sdk_url = +rudderstack_v3_sdk_url = # Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config rudderstack_config_url = @@ -2079,8 +2079,14 @@ enable = # To enable features by default, set `Expression: "true"` in: # https://github.com/grafana/grafana/blob/main/pkg/services/featuremgmt/registry.go +# The feature_toggles section supports feature flags of a number of types, +# including boolean, string, integer, float, and structured values, following the OpenFeature specification. +# # feature1 = true # feature2 = false +# feature3 = "foobar" +# feature4 = 1.5 +# feature5 = { "foo": "bar" } [feature_toggles.openfeature] # This is EXPERIMENTAL. Please, do not use this section @@ -2234,6 +2240,8 @@ encryption_provider = secret_key.v1 # These flags are required in on-prem installations for GitSync to work # +# Whether to register the MT CRUD API +register_api_server = true # Whether to create the MT secrets management database run_secrets_db_migrations = true # Whether to run the data key id migration. Requires that RunSecretsDBMigrations is also true. @@ -2279,3 +2287,10 @@ allow_image_rendering = true # will check if there has been any changes to the repository not propagated by a webhook. # The minimum value is 10 seconds. min_sync_interval = 10s + +#################################### Unified Storage #################################### +[unified_storage] +# index_path is the path where unified storage can store its index files for search. +# If empty, defaults to "/unified-search/bleve" (see [paths] section). +# Please note that sharing the same index_path between multiple running Grafana instances is not supported. +index_path = diff --git a/conf/sample.ini b/conf/sample.ini index 530b14c87ac..b4bc6027abf 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -323,7 +323,7 @@ ;rudderstack_sdk_url = # Rudderstack v3 SDK, optional, defaults to false. If set, Rudderstack v3 SDK will be used instead of v1 -;rudderstack_v3_sdk_url = +;rudderstack_v3_sdk_url = # Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config ;rudderstack_config_url = @@ -1913,7 +1913,7 @@ default_datasource_uid = # client_queue_max_size is the maximum size in bytes of the client queue # for Live connections. Defaults to 4MB. -;client_queue_max_size = +;client_queue_max_size = #################################### Grafana Image Renderer Plugin ########################## [plugin.grafana-image-renderer] @@ -1996,9 +1996,14 @@ default_datasource_uid = ;enable = feature1,feature2 +# The feature_toggles section supports feature flags of a number of types, +# including boolean, string, integer, float, and structured values, following the OpenFeature specification. + ;feature1 = true ;feature2 = false - +;feature3 = "foobar" +;feature4 = 1.5 +;feature5 = { "foo": "bar" } [date_formats] # For information on what formatting patterns that are supported https://momentjs.com/docs/#/displaying/ @@ -2123,6 +2128,8 @@ default_datasource_uid = # These flags are required in on-prem installations for GitSync to work # +# Whether to register the MT CRUD API +;register_api_server = true # Whether to create the MT secrets management database ;run_secrets_db_migrations = true # Whether to run the data key id migration. Requires that RunSecretsDBMigrations is also true. diff --git a/devenv/dev-dashboards/datasource-loki/loki_query_splitting.json b/devenv/dev-dashboards/datasource-loki/loki_query_splitting.json index 0cc5135a0f4..44577d49ac4 100644 --- a/devenv/dev-dashboards/datasource-loki/loki_query_splitting.json +++ b/devenv/dev-dashboards/datasource-loki/loki_query_splitting.json @@ -216,8 +216,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -311,8 +310,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -493,8 +491,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -584,8 +581,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -675,8 +671,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -789,8 +784,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -903,8 +897,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -1018,8 +1011,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, diff --git a/devenv/dev-dashboards/panel-gauge/gauge_tests.json b/devenv/dev-dashboards/panel-gauge/gauge_tests.json index f32ace420b4..f76f5d8809a 100644 --- a/devenv/dev-dashboards/panel-gauge/gauge_tests.json +++ b/devenv/dev-dashboards/panel-gauge/gauge_tests.json @@ -51,17 +51,14 @@ "steps": [ { "color": "#7EB26D", - "index": 0, "value": null }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -122,17 +119,14 @@ "steps": [ { "color": "#7EB26D", - "index": 0, "value": null }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -193,17 +187,14 @@ "steps": [ { "color": "#7EB26D", - "index": 0, "value": null }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -255,7 +246,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [], "max": 100, "min": 0, @@ -264,17 +254,14 @@ "steps": [ { "color": "#7EB26D", - "index": 0, "value": null }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -326,7 +313,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [], "max": 100, "min": 0, @@ -335,17 +321,14 @@ "steps": [ { "color": "#7EB26D", - "index": 0, "value": null }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -397,7 +380,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [], "max": 100, "min": 0, @@ -406,17 +388,14 @@ "steps": [ { "color": "#7EB26D", - "index": 0, "value": null }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -468,7 +447,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [], "max": 100, "min": 0, @@ -477,17 +455,14 @@ "steps": [ { "color": "#7EB26D", - "index": 0, "value": null }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -641,7 +616,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [ { "from": "", @@ -660,17 +634,14 @@ "steps": [ { "color": "#7EB26D", - "index": 0, "value": null }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -723,7 +694,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [ { "from": "", @@ -742,17 +712,14 @@ "steps": [ { "color": "#7EB26D", - "index": 0, "value": null }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -805,7 +772,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [ { "from": "0", @@ -824,17 +790,14 @@ "steps": [ { "color": "#7EB26D", - "index": 0, "value": null }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -887,7 +850,6 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "", "mappings": [ { "from": "0", @@ -915,17 +877,14 @@ "steps": [ { "color": "#7EB26D", - "index": 0, "value": null }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -991,7 +950,7 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "2", + "decimals": 2, "mappings": [], "max": 100, "min": 0, @@ -1000,17 +959,14 @@ "steps": [ { "color": "#7EB26D", - "index": 0, "value": null }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -1071,7 +1027,7 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "2", + "decimals": 2, "mappings": [], "max": 100, "min": 0, @@ -1080,17 +1036,14 @@ "steps": [ { "color": "#7EB26D", - "index": 0, "value": null }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -1152,7 +1105,7 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "2", + "decimals": 2, "mappings": [], "max": 100, "min": 0, @@ -1161,17 +1114,14 @@ "steps": [ { "color": "#7EB26D", - "index": 0, "value": null }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] @@ -1233,7 +1183,7 @@ "mode": "thresholds" }, "custom": {}, - "decimals": "2", + "decimals": 2, "mappings": [], "max": 100, "min": 0, @@ -1242,17 +1192,14 @@ "steps": [ { "color": "#7EB26D", - "index": 0, "value": null }, { "color": "#ef843c", - "index": 1, "value": 75 }, { "color": "#e24d42", - "index": 2, "value": 90 } ] diff --git a/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json b/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json index ff69226fdf5..c44353b7df2 100644 --- a/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json +++ b/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json @@ -2067,7 +2067,7 @@ } ], "title": "Numeric, no series", - "type": "gauge" + "type": "radialbar" }, { "datasource": { @@ -2131,7 +2131,7 @@ } ], "title": "Non-numeric", - "type": "gauge" + "type": "radialbar" } ], "preload": false, diff --git a/devenv/dev-dashboards/panel-histogram/histogram_tests.json b/devenv/dev-dashboards/panel-histogram/histogram_tests.json index af6127cb447..7d6a684417e 100644 --- a/devenv/dev-dashboards/panel-histogram/histogram_tests.json +++ b/devenv/dev-dashboards/panel-histogram/histogram_tests.json @@ -58,8 +58,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -125,8 +124,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -192,8 +190,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -269,8 +266,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -343,8 +339,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -432,8 +427,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -516,8 +510,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [ { @@ -597,8 +590,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [ { @@ -678,8 +670,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [ { @@ -759,8 +750,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -824,8 +814,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [ { diff --git a/devenv/dev-dashboards/panel-xychart/xychart-tooltip-color-test.json b/devenv/dev-dashboards/panel-xychart/xychart-tooltip-color-test.json index b0ae2a9d76b..15fda5d6316 100644 --- a/devenv/dev-dashboards/panel-xychart/xychart-tooltip-color-test.json +++ b/devenv/dev-dashboards/panel-xychart/xychart-tooltip-color-test.json @@ -62,8 +62,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -150,8 +149,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -238,8 +236,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -326,8 +323,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -421,8 +417,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -516,8 +511,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, @@ -611,8 +605,7 @@ "value": 80 } ] - }, - "unitScale": true + } }, "overrides": [] }, diff --git a/devenv/scopes/scopes-config.yaml b/devenv/scopes/scopes-config.yaml index ef800415547..66ba7b6988c 100644 --- a/devenv/scopes/scopes-config.yaml +++ b/devenv/scopes/scopes-config.yaml @@ -48,6 +48,23 @@ scopes: operator: equals value: kids + # This scope appears in multiple places in the tree. + # The defaultPath determines which path is shown when this scope is selected + # (e.g., from a URL or programmatically), even if another path also links to it. + shared-service: + title: Shared Service + # Path from the root node down to the direct scopeNode. + # Node names are hierarchical (parent-child), so use the full names. + # This points to: gdev-scopes > production > shared-service-prod + defaultPath: + - gdev-scopes + - gdev-scopes-production + - gdev-scopes-production-shared-service-prod + filters: + - key: service + operator: equals + value: shared + tree: gdev-scopes: title: gdev-scopes @@ -68,6 +85,13 @@ tree: nodeType: leaf linkId: app2 linkType: scope + # This node links to 'shared-service' scope. + # The scope's defaultPath points here (production > gdev-scopes). + shared-service-prod: + title: Shared Service + nodeType: leaf + linkId: shared-service + linkType: scope test-cases: title: Test cases nodeType: container @@ -83,6 +107,15 @@ tree: nodeType: leaf linkId: test-case-2 linkType: scope + # This node also links to the same 'shared-service' scope. + # However, the scope's defaultPath points to the production path, + # so selecting this scope will expand the tree to production > shared-service-prod. + shared-service-test: + title: Shared Service (also in Production) + subTitle: defaultPath points to Production + nodeType: leaf + linkId: shared-service + linkType: scope test-case-redirect: title: Test case with redirect nodeType: leaf diff --git a/devenv/scopes/scopes.go b/devenv/scopes/scopes.go index e3f4de80d21..4487c5b69ef 100644 --- a/devenv/scopes/scopes.go +++ b/devenv/scopes/scopes.go @@ -51,8 +51,9 @@ type Config struct { // ScopeConfig is used for YAML parsing - converts to v0alpha1.ScopeSpec type ScopeConfig struct { - Title string `yaml:"title"` - Filters []ScopeFilterConfig `yaml:"filters"` + Title string `yaml:"title"` + DefaultPath []string `yaml:"defaultPath,omitempty"` + Filters []ScopeFilterConfig `yaml:"filters"` } // ScopeFilterConfig is used for YAML parsing - converts to v0alpha1.ScopeFilter @@ -116,9 +117,20 @@ func convertScopeSpec(cfg ScopeConfig) v0alpha1.ScopeSpec { for i, f := range cfg.Filters { filters[i] = convertFilter(f) } + + // Prefix defaultPath elements with the gdev prefix + var defaultPath []string + if len(cfg.DefaultPath) > 0 { + defaultPath = make([]string, len(cfg.DefaultPath)) + for i, p := range cfg.DefaultPath { + defaultPath[i] = prefix + "-" + p + } + } + return v0alpha1.ScopeSpec{ - Title: cfg.Title, - Filters: filters, + Title: cfg.Title, + DefaultPath: defaultPath, + Filters: filters, } } diff --git a/docs/sources/administration/plugin-management/plugin-sign.md b/docs/sources/administration/plugin-management/plugin-sign.md index 7850996d0f6..54d65baacde 100644 --- a/docs/sources/administration/plugin-management/plugin-sign.md +++ b/docs/sources/administration/plugin-management/plugin-sign.md @@ -25,7 +25,7 @@ Plugin signature verification, also known as _signing_, is a security measure to Learn more at [plugin policies](https://grafana.com/legal/plugins/). -## How does verifiction work? +## How does verification work? At startup, Grafana verifies the signatures of every plugin in the plugin directory. diff --git a/docs/sources/administration/roles-and-permissions/_index.md b/docs/sources/administration/roles-and-permissions/_index.md index c8135836fa1..7a33d940a15 100644 --- a/docs/sources/administration/roles-and-permissions/_index.md +++ b/docs/sources/administration/roles-and-permissions/_index.md @@ -35,10 +35,10 @@ For Grafana Cloud users, Grafana Support is not authorised to make org role chan ## Grafana server administrators -A Grafana server administrator manages server-wide settings and access to resources such as organizations, users, and licenses. Grafana includes a default server administrator that you can use to manage all of Grafana, or you can divide that responsibility among other server administrators that you create. +A Grafana server administrator (sometimes referred to as a **Grafana Admin**) manages server-wide settings and access to resources such as organizations, users, and licenses. Grafana includes a default server administrator that you can use to manage all of Grafana, or you can divide that responsibility among other server administrators that you create. -{{< admonition type="note" >}} -The server administrator role does not mean that the user is also a Grafana [organization administrator](#organization-roles). +{{< admonition type="caution" >}} +The server administrator role is distinct from the [organization administrator](#organization-roles) role. {{< /admonition >}} A server administrator can perform the following tasks: @@ -50,7 +50,7 @@ A server administrator can perform the following tasks: - Upgrade the server to Grafana Enterprise. {{< admonition type="note" >}} -The server administrator role does not exist in Grafana Cloud. +The server administrator (Grafana Admin) role does not exist in Grafana Cloud. {{< /admonition >}} To assign or remove server administrator privileges, see [Server user management](../user-management/server-user-management/assign-remove-server-admin-privileges/). diff --git a/docs/sources/administration/roles-and-permissions/access-control/manage-rbac-roles/index.md b/docs/sources/administration/roles-and-permissions/access-control/manage-rbac-roles/index.md index 40a6d3645af..b0f35087efc 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/manage-rbac-roles/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/manage-rbac-roles/index.md @@ -53,6 +53,11 @@ refs: destination: /docs/grafana//administration/roles-and-permissions/access-control/custom-role-actions-scopes/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana-cloud/account-management/authentication-and-permissions/access-control/custom-role-actions-scopes/ + rbac-terraform-provisioning: + - pattern: /docs/grafana/ + destination: /docs/grafana//administration/roles-and-permissions/access-control/rbac-terraform-provisioning/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/account-management/authentication-and-permissions/access-control/rbac-terraform-provisioning/ rbac-grafana-provisioning: - pattern: /docs/grafana/ destination: /docs/grafana//administration/roles-and-permissions/access-control/rbac-grafana-provisioning/ @@ -145,7 +150,13 @@ Refer to the [RBAC HTTP API](ref:api-rbac-get-a-role) for more details. ## Create custom roles -This section shows you how to create a custom RBAC role using Grafana provisioning and the HTTP API. +This section shows you how to create a custom RBAC role using Grafana provisioning or the HTTP API. + +Creating and editing custom roles is not currently possible in the Grafana UI. To manage custom roles, use one of the following methods: + +- [Provisioning](ref:rbac-grafana-provisioning) (for self-managed instances) +- [HTTP API](ref:api-rbac-create-a-new-custom-role) +- [Terraform](ref:rbac-terraform-provisioning) Create a custom role when basic roles and fixed roles do not meet your permissions requirements. @@ -153,14 +164,101 @@ Create a custom role when basic roles and fixed roles do not meet your permissio - [Plan your RBAC rollout strategy](ref:plan-rbac-rollout-strategy). - Determine which permissions you want to add to the custom role. To see a list of actions and scope, refer to [RBAC permissions, actions, and scopes](ref:custom-role-actions-scopes). -- [Enable role provisioning](ref:rbac-grafana-provisioning). - Ensure that you have permissions to create a custom role. - By default, the Grafana Admin role has permission to create custom roles. - A Grafana Admin can delegate the custom role privilege to another user by creating a custom role with the relevant permissions and adding the `permissions:type:delegate` scope. -### Create custom roles using provisioning +### Create custom roles using the HTTP API -[File-based provisioning](ref:rbac-grafana-provisioning) is one method you can use to create custom roles. +The following examples show you how to create a custom role using the Grafana HTTP API. For more information about the HTTP API, refer to [Create a new custom role](ref:api-rbac-create-a-new-custom-role). + +{{< admonition type="note" >}} +When you create a custom role you can only give it the same permissions you already have. For example, if you only have `users:create` permissions, then you can't create a role that includes other permissions. +{{< /admonition >}} + +The following example creates a `custom:users:admin` role and assigns the `users:create` action to it. + +**Example request** + +``` +curl --location --request POST '/api/access-control/roles/' \ +--header 'Authorization: Basic YWRtaW46cGFzc3dvcmQ=' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "version": 1, + "uid": "jZrmlLCkGksdka", + "name": "custom:users:admin", + "displayName": "custom users admin", + "description": "My custom role which gives users permissions to create users", + "global": true, + "permissions": [ + { + "action": "users:create" + } + ] +}' +``` + +**Example response** + +``` +{ + "version": 1, + "uid": "jZrmlLCkGksdka", + "name": "custom:users:admin", + "displayName": "custom users admin", + "description": "My custom role which gives users permissions to create users", + "global": true, + "permissions": [ + { + "action": "users:create" + "updated": "2021-05-17T22:07:31.569936+02:00", + "created": "2021-05-17T22:07:31.569935+02:00" + } + ], + "updated": "2021-05-17T22:07:31.564403+02:00", + "created": "2021-05-17T22:07:31.564403+02:00" +} +``` + +Refer to the [RBAC HTTP API](ref:api-rbac-create-a-new-custom-role) for more details. + +### Create custom roles using Terraform + +You can use the [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs) to manage custom roles and their assignments. This is the recommended method for Grafana Cloud users who want to manage RBAC as code. For more information, refer to [Provisioning RBAC with Terraform](ref:rbac-terraform-provisioning). + +The following example creates a custom role and assigns it to a team: + +```terraform +resource "grafana_role" "custom_folder_manager" { + name = "custom:folders:manager" + description = "Custom role for reading and creating folders" + uid = "custom-folders-manager" + version = 1 + global = true + + permissions { + action = "folders:read" + scope = "folders:*" + } + + permissions { + action = "folders:create" + scope = "folders:uid:general" # Allows creating folders at the root level + } +} + +resource "grafana_role_assignment" "custom_folder_manager_assignment" { + role_uid = grafana_role.custom_folder_manager.uid + teams = [""] +} +``` + +For more information, refer to the [`grafana_role`](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/role) and [`grafana_role_assignment`](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/role_assignment) documentation in the Terraform Registry. + +### Create custom roles using file-based provisioning + +You can use [file-based provisioning](ref:rbac-grafana-provisioning) to create custom roles for self-managed instances. 1. Open the YAML configuration file and locate the `roles` section. @@ -251,61 +349,6 @@ roles: state: 'absent' ``` -### Create custom roles using the HTTP API - -The following examples show you how to create a custom role using the Grafana HTTP API. For more information about the HTTP API, refer to [Create a new custom role](ref:api-rbac-create-a-new-custom-role). - -{{< admonition type="note" >}} -You cannot create a custom role with permissions that you do not have. For example, if you only have `users:create` permissions, then you cannot create a role that includes other permissions. -{{< /admonition >}} - -The following example creates a `custom:users:admin` role and assigns the `users:create` action to it. - -**Example request** - -``` -curl --location --request POST '/api/access-control/roles/' \ ---header 'Authorization: Basic YWRtaW46cGFzc3dvcmQ=' \ ---header 'Content-Type: application/json' \ ---data-raw '{ - "version": 1, - "uid": "jZrmlLCkGksdka", - "name": "custom:users:admin", - "displayName": "custom users admin", - "description": "My custom role which gives users permissions to create users", - "global": true, - "permissions": [ - { - "action": "users:create" - } - ] -}' -``` - -**Example response** - -``` -{ - "version": 1, - "uid": "jZrmlLCkGksdka", - "name": "custom:users:admin", - "displayName": "custom users admin", - "description": "My custom role which gives users permissions to create users", - "global": true, - "permissions": [ - { - "action": "users:create" - "updated": "2021-05-17T22:07:31.569936+02:00", - "created": "2021-05-17T22:07:31.569935+02:00" - } - ], - "updated": "2021-05-17T22:07:31.564403+02:00", - "created": "2021-05-17T22:07:31.564403+02:00" -} -``` - -Refer to the [RBAC HTTP API](ref:api-rbac-create-a-new-custom-role) for more details. - ## Update basic role permissions If the default basic role definitions do not meet your requirements, you can change their permissions. diff --git a/docs/sources/administration/roles-and-permissions/access-control/rbac-for-app-plugins/index.md b/docs/sources/administration/roles-and-permissions/access-control/rbac-for-app-plugins/index.md index 15cf895a5e8..465880919ca 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/rbac-for-app-plugins/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/rbac-for-app-plugins/index.md @@ -66,17 +66,18 @@ Please refer to plugin documentation to see what RBAC permissions the plugin has The following list contains app plugins that have fine-grained RBAC support. -| App plugin | App plugin ID | App plugin permission documentation | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| [Access policies](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/) | `grafana-auth-app` | [RBAC actions for Access Policies](ref:cloud-access-policies-action-definitions) | -| [Adaptive Metrics](https://grafana.com/docs/grafana-cloud/cost-management-and-billing/reduce-costs/metrics-costs/control-metrics-usage-via-adaptive-metrics/adaptive-metrics-plugin/) | `grafana-adaptive-metrics-app` | [RBAC actions for Adaptive Metrics](ref:adaptive-metrics-permissions) | -| [Cloud Provider](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/monitor-cloud-provider/) | `grafana-csp-app` | [Cloud Provider Observability role-based access control](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/monitor-cloud-provider/rbac/) | -| [Incident](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/incident/) | `grafana-incident-app` | n/a | -| [Kubernetes Monitoring](/docs/grafana-cloud/monitor-infrastructure/kubernetes-monitoring/) | `grafana-k8s-app` | [Kubernetes Monitoring role-based access control](/docs/grafana-cloud/monitor-infrastructure/kubernetes-monitoring/configuration/control-access/#precision-access-with-rbac-custom-plugin-roles) | -| [OnCall](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/oncall/) | `grafana-oncall-app` | [Configure RBAC for OnCall](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/oncall/manage/user-and-team-management/#manage-users-and-teams-for-grafana-oncall) | -| [Performance Testing (K6)](https://grafana.com/docs/grafana-cloud/testing/k6/) | `k6-app` | [Configure RBAC for K6](https://grafana.com/docs/grafana-cloud/testing/k6/projects-and-users/configure-rbac/) | -| [Private data source connect (PDC)](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) | `grafana-pdc-app` | n/a | -| [Service Level Objective (SLO)](https://grafana.com/docs/grafana-cloud/alerting-and-irm/slo/) | `grafana-slo-app` | [Configure RBAC for SLO](https://grafana.com/docs/grafana-cloud/alerting-and-irm/slo/set-up/rbac/) | +| App plugin | App plugin ID | App plugin permission documentation | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [Access policies](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/) | `grafana-auth-app` | [RBAC actions for Access Policies](ref:cloud-access-policies-action-definitions) | +| [Adaptive Metrics](https://grafana.com/docs/grafana-cloud/cost-management-and-billing/reduce-costs/metrics-costs/control-metrics-usage-via-adaptive-metrics/adaptive-metrics-plugin/) | `grafana-adaptive-metrics-app` | [RBAC actions for Adaptive Metrics](ref:adaptive-metrics-permissions) | +| [Cloud Provider](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/monitor-cloud-provider/) | `grafana-csp-app` | [Cloud Provider Observability role-based access control](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/monitor-cloud-provider/rbac/) | +| [Incident](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/incident/) | `grafana-incident-app` | n/a | +| [Kubernetes Monitoring](/docs/grafana-cloud/monitor-infrastructure/kubernetes-monitoring/) | `grafana-k8s-app` | [Kubernetes Monitoring role-based access control](/docs/grafana-cloud/monitor-infrastructure/kubernetes-monitoring/configuration/control-access/#precision-access-with-rbac-custom-plugin-roles) | +| [OnCall](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/oncall/) | `grafana-oncall-app` | [Configure RBAC for OnCall](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/oncall/manage/user-and-team-management/#manage-users-and-teams-for-grafana-oncall) | +| [Performance Testing (K6)](https://grafana.com/docs/grafana-cloud/testing/k6/) | `k6-app` | [Configure RBAC for K6](https://grafana.com/docs/grafana-cloud/testing/k6/projects-and-users/configure-rbac/) | +| [Private data source connect (PDC)](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) | `grafana-pdc-app` | n/a | +| [Service Level Objective (SLO)](https://grafana.com/docs/grafana-cloud/alerting-and-irm/slo/) | `grafana-slo-app` | [Configure RBAC for SLO](https://grafana.com/docs/grafana-cloud/alerting-and-irm/slo/set-up/rbac/) | +| [Synthetic Monitoring](https://grafana.com/docs/grafana-cloud/testing/synthetic-monitoring/) | `grafana-synthetic-monitoring-app` | [Configure RBAC for Synthetic Monitoring](https://grafana.com/docs/grafana-cloud/testing/synthetic-monitoring/user-and-team-management/) | ### Revoke fine-grained access from app plugins diff --git a/docs/sources/administration/roles-and-permissions/access-control/rbac-grafana-provisioning/index.md b/docs/sources/administration/roles-and-permissions/access-control/rbac-grafana-provisioning/index.md index 06f9699533b..fe45a620bc5 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/rbac-grafana-provisioning/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/rbac-grafana-provisioning/index.md @@ -6,7 +6,6 @@ description: Learn about RBAC Grafana provisioning and view an example YAML prov file that configures Grafana role assignments. labels: products: - - cloud - enterprise menuTitle: Provisioning RBAC with Grafana title: Provisioning RBAC with Grafana @@ -52,11 +51,13 @@ refs: # Provisioning RBAC with Grafana {{< admonition type="note" >}} -Available in [Grafana Enterprise](/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud](/docs/grafana-cloud). +Available in [Grafana Enterprise](/docs/grafana//introduction/grafana-enterprise/) for self-managed instances. This feature is not available in Grafana Cloud. {{< /admonition >}} You can create, change or remove [Custom roles](ref:manage-rbac-roles-create-custom-roles-using-provisioning) and create or remove [basic role assignments](ref:assign-rbac-roles-assign-a-fixed-role-to-a-basic-role-using-provisioning), by adding one or more YAML configuration files in the `provisioning/access-control/` directory. +Because this method requires access to the file system where Grafana is running, it's only available for self-managed Grafana instances. To provision RBAC in Grafana Cloud, use [Terraform](ref:rbac-terraform-provisioning) or the [HTTP API](ref:api-rbac-create-and-manage-custom-roles). + Grafana performs provisioning during startup. After you make a change to the configuration file, you can reload it during runtime. You do not need to restart the Grafana server for your changes to take effect. **Before you begin:** diff --git a/docs/sources/as-code/observability-as-code/_index.md b/docs/sources/as-code/observability-as-code/_index.md index 338d9235255..92441bd5110 100644 --- a/docs/sources/as-code/observability-as-code/_index.md +++ b/docs/sources/as-code/observability-as-code/_index.md @@ -25,10 +25,6 @@ cards: height: 24 href: ./foundation-sdk/ description: The Grafana Foundation SDK is a set of tools, types, and libraries that let you define Grafana dashboards and resources using familiar programming languages like Go, TypeScript, Python, Java, and PHP. Use it in conjunction with `grafanactl` to push your programmatically generated resources. - - title: JSON schema v2 - height: 24 - href: ./schema-v2/ - description: Grafana dashboards are represented as JSON objects that store metadata, panels, variables, and settings. Observability as Code works with all versions of the JSON model, and it's fully compatible with version 2. - title: Git Sync (private preview) height: 24 href: ./provision-resources/intro-git-sync/ @@ -68,7 +64,7 @@ Historically, managing Grafana as code involved various community and Grafana La - This approach requires handling HTTP requests and responses but provides complete control over resource management. - `grafanactl`, Git Sync, and the Foundation SDK are all built on top of these APIs. -- To understand Dashboard Schemas accepted by the APIs, refer to the [JSON models documentation](https://grafana.com/docs/grafana//observability-as-code/schema-v2/). +- To understand Dashboard Schemas accepted by the APIs, refer to the [JSON models documentation](https://grafana.com/docs/grafana//visualizations/dashboards/build-dashboards/view-dashboard-json-model/index.md). ## Explore diff --git a/docs/sources/as-code/observability-as-code/schema-v2/_index.md b/docs/sources/as-code/observability-as-code/schema-v2/_index.md deleted file mode 100644 index 9be869a8391..00000000000 --- a/docs/sources/as-code/observability-as-code/schema-v2/_index.md +++ /dev/null @@ -1,243 +0,0 @@ ---- -description: A reference for the JSON dashboard schemas used with Observability as Code, including the experimental V2 schema. -keywords: - - configuration - - as code - - dashboards - - git integration - - git sync - - github -labels: - products: - - cloud - - enterprise - - oss -title: JSON schema v2 -weight: 500 -canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/ -aliases: - - ../../observability-as-code/schema-v2/ # /docs/grafana/next/observability-as-code/schema-v2/ ---- - -# Dashboard JSON schema v2 - -{{< admonition type="caution" >}} - -Dashboard JSON schema v2 is an [experimental](https://grafana.com/docs/release-life-cycle/) feature. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. To get early access to this feature, request it through [this form](https://docs.google.com/forms/d/e/1FAIpQLSd73nQzuhzcHJOrLFK4ef_uMxHAQiPQh1-rsQUT2MRqbeMLpg/viewform?usp=dialog). - -**Do not enable this feature in production environments as it may result in the irreversible loss of data.** - -{{< /admonition >}} - -Grafana dashboards are represented as JSON objects that store metadata, panels, variables, and settings. - -Observability as Code works with all versions of the JSON model, and it's fully compatible with version 2. - -## Before you begin - -Schema v2 is automatically enabled with the Dynamic Dashboards feature toggle. -To get early access to this feature, request it through [this form](https://docs.google.com/forms/d/e/1FAIpQLSd73nQzuhzcHJOrLFK4ef_uMxHAQiPQh1-rsQUT2MRqbeMLpg/viewform?usp=dialog). -It also requires the new dashboards API feature toggle, `kubernetesDashboards`, to be enabled as well. - -For more information on how dashboards behave depending on your feature flag configuration, refer to [Notes and limitations](#notes-and-limitations). - -## Accessing the JSON Model - -To view the JSON representation of a dashboard: - -1. Toggle on the edit mode switch in the top-right corner of the dashboard. -1. Click the gear icon in the top navigation bar to go to **Settings**. -1. Select the **JSON Model** tab. -1. Copy or edit the JSON structure as needed. - -## JSON fields - -```json -{ - "annotations": [], - "cursorSync": "Off", - "editable": true, - "elements": {}, - "layout": { - "kind": GridLayout, // Can also be AutoGridLayout, RowsLayout, or TabsLayout - "spec": { - "items": [] - } - }, - "links": [], - "liveNow": false, - "preload": false, - "tags": [], // Tags associated with the dashboard. - "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": "", - "variables": [] -}, -``` - -The dashboard JSON sample shown uses the default `GridLayoutKind`. -The JSON in a new dashboard for the other three layout options, `AutoGridLayout`, `RowsLayout`, and `TabsLayout`, are as follows: - -**`AutoGridLayout`** - -```json - "layout": { - "kind": "AutoGridLayout", - "spec": { - "columnWidthMode": "standard", - "items": [], - "fillScreen": false, - "maxColumnCount": 3, - "rowHeightMode": "standard" - } - }, -``` - -**`RowsLayout`** - -```json - "layout": { - "kind": "RowsLayout", - "spec": { - "rows": [] - }, -``` - -**`TabsLayout`** - -```json - "layout": { - "kind": "TabsLayout", - "spec": { - "tabs": [] - }, -``` - -### `DashboardSpec` - -The following table explains the usage of the dashboard JSON fields. -The table includes default and other fields: - - - -| Name | Usage | -| ------------ | ------------------------------------------------------------------------- | -| annotations | Contains the list of annotations that are associated with the dashboard. | -| cursorSync | Dashboard cursor sync behavior.
  • `Off` - No shared crosshair or tooltip (default)
  • `Crosshair` - Shared crosshair
  • `Tooltip` - Shared crosshair and shared tooltip
| -| editable | bool. Whether or not a dashboard is editable. | -| elements | Contains the list of elements included in the dashboard. Supported dashboard elements are: PanelKind and LibraryPanelKind. | -| layout | The dashboard layout. Supported layouts are:
  • GridLayoutKind
  • AutoGridLayoutKind
  • RowsLayoutKind
  • TabsLayoutKind
| -| links | Links with references to other dashboards or external websites. | -| liveNow | bool. When set to `true`, the dashboard redraws panels at an interval matching the pixel width. This keeps data "moving left" regardless of the query refresh rate. This setting helps avoid dashboards presenting stale live data. | -| preload | bool. When set to `true`, the dashboard loads all panels when the dashboard is loaded. | -| tags | Contains the list of tags associated with dashboard. | -| timeSettings | All time settings for the dashboard. | -| title | Title of the dashboard. | -| variables | Contains the list of configured template variables. | - - - -### `annotations` - -The configuration for the list of annotations that are associated with the dashboard. -For the JSON and field usage notes, refer to the [annotations schema documentation](https://grafana.com/docs/grafana//observability-as-code/schema-v2/annotations-schema/). - -### `elements` - -Dashboards can contain the following elements: - -- [PanelKind](https://grafana.com/docs/grafana//observability-as-code/schema-v2/panel-schema/) -- [LibraryPanelKind](https://grafana.com/docs/grafana//observability-as-code/schema-v2/librarypanel-schema/) - -### `layout` - -Dashboards can have four layout options: - -- [GridLayoutKind](https://grafana.com/docs/grafana//observability-as-code/schema-v2/layout-schema/#gridlayoutkind) -- [AutoGridLayoutKind](https://grafana.com/docs/grafana//observability-as-code/schema-v2/layout-schema/#autogridlayoutkind) -- [RowsLayoutKind](https://grafana.com/docs/grafana//observability-as-code/schema-v2/layout-schema/#rowslayoutkind) -- [TabsLayoutKind](https://grafana.com/docs/grafana//observability-as-code/schema-v2/layout-schema/#tabslayoutkind) - -For the JSON and field usage notes about each of these, refer to the [layout schema documentation](https://grafana.com/docs/grafana//observability-as-code/schema-v2/layout-schema/). - -### `links` - -The configuration for links with references to other dashboards or external websites. - -For the JSON and field usage notes, refer to the [links schema documentation](https://grafana.com/docs/grafana//observability-as-code/schema-v2/links-schema/). - -### `tags` - -The tags associated with the dashboard: - -` [...string]` - -### `timesettings` - -The `TimeSettingsSpec` defines the default time configuration for the time picker and the refresh picker for the specific dashboard. -For the JSON and field usage notes about the `TimeSettingsSpec`, refer to the [timesettings schema documentation](https://grafana.com/docs/grafana//observability-as-code/schema-v2/timesettings-schema/). - -### `variables` - -The `variables` schema defines which variables are used in the dashboard. - -There are eight variables types: - -- QueryVariableKind -- TextVariableKind -- ConstantVariableKind -- DatasourceVariableKind -- IntervalVariableKind -- CustomVariableKind -- GroupByVariableKind -- AdhocVariableKind - -For the JSON and field usage notes about the `variables` spec, refer to the [variables schema documentation](https://grafana.com/docs/grafana//observability-as-code/schema-v2/variables-schema/). - -## Notes and limitations - -### Existing dashboards - -With schema v2 enabled, you can still open and view your pre-existing dashboards. -Upon saving, they’ll be updated to the new schema where you can take advantage of the new features and functionalities. - -### Dashboard behavior with disabled feature flags - -If you disable the Dynamic dashboards or `kubernetesDashboards` feature flags, you should be aware of how dashboards will behave. - -#### Disable Dynamic dashboards - -If the Dynamic dashboards feature toggle is disabled, depending on how the dashboard was built, it will behave differently: - -- Dashboards built on the new schema through the UI - View only -- Dashboards built on Schema v1 - View and edit -- Dashboards built on the new schema by way of Terraform or the CLI - View and edit -- Provisioned dashboards built on the new schema - View and edit, but the edit experience will be the old experience - -#### Disable Dynamic dashboards and `kubernetesDashboards` - -You’ll be unable to view or edit dashboards created or updated in the new schema. - -### Import and export - -From the UI, dashboards created on schema v2 can be exported and imported like other dashboards. -When you export them to use in another instance, references of data sources are not persisted but data source types are. -You’ll have the option to select the data source of your choice in the import UI. diff --git a/docs/sources/as-code/observability-as-code/schema-v2/annotations-schema.md b/docs/sources/as-code/observability-as-code/schema-v2/annotations-schema.md deleted file mode 100644 index e99e7c2cce6..00000000000 --- a/docs/sources/as-code/observability-as-code/schema-v2/annotations-schema.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -description: A reference for the JSON annotations schema used with Observability as Code. -keywords: - - configuration - - as code - - as-code - - dashboards - - git integration - - git sync - - github - - annotations -labels: - products: - - cloud - - enterprise - - oss -menuTitle: annotations schema -title: annotations -weight: 100 -canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/annotations-schema/ -aliases: - - ../../../observability-as-code/schema-v2/annotations-schema/ # /docs/grafana/next/observability-as-code/schema-v2/annotations-schema/ ---- - -# `annotations` - -The configuration for the list of annotations that are associated with the dashboard. - -```json - "annotations": [ - { - "kind": "AnnotationQuery", - "spec": { - "builtIn": false, - "datasource": { - "type": "", - "uid": "" - }, - "enable": false, - "hide": false, - "iconColor": "", - "name": "" - } - } - ], -``` - -`AnnotationsQueryKind` consists of: - -- kind: "AnnotationQuery" -- spec: [AnnotationQuerySpec](#annotationqueryspec) - -## `AnnotationQuerySpec` - -| Name | Type/Definition | -| ---------- | ----------------------------------------------------------------- | -| datasource | [`DataSourceRef`](#datasourceref) | -| query | [`DataQueryKind`](#dataquerykind) | -| enable | bool | -| hide | bool | -| iconColor | string | -| name | string | -| builtIn | bool. Default is `false`. | -| filter | [`AnnotationPanelFilter`](#annotationpanelfilter) | -| options | `[string]`: A catch-all field for datasource-specific properties. | - -### `DataSourceRef` - -| Name | Usage | -| ----- | ---------------------------------- | -| type? | string. The plugin type-id. | -| uid? | The specific data source instance. | - -### `DataQueryKind` - -| Name | Type | -| ---- | ------ | -| kind | string | -| spec | string | - -### `AnnotationPanelFilter` - -| Name | Type/Definition | -| -------- | ------------------------------------------------------------------------------ | -| exclude? | bool. Should the specified panels be included or excluded. Default is `false`. | -| ids | `[...uint8]`. Panel IDs that should be included or excluded. | diff --git a/docs/sources/as-code/observability-as-code/schema-v2/layout-schema.md b/docs/sources/as-code/observability-as-code/schema-v2/layout-schema.md deleted file mode 100644 index ca31417bbcf..00000000000 --- a/docs/sources/as-code/observability-as-code/schema-v2/layout-schema.md +++ /dev/null @@ -1,339 +0,0 @@ ---- -description: A reference for the JSON layout schema used with Observability as Code. -keywords: - - configuration - - as code - - as-code - - dashboards - - git integration - - git sync - - github - - layout -labels: - products: - - cloud - - enterprise - - oss -menuTitle: layout schema -title: layout -weight: 400 -canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/layout-schema/ -aliases: - - ../../../observability-as-code/schema-v2/layout-schema/ # /docs/grafana/next/observability-as-code/schema-v2/layout-schema/ ---- - -# `layout` - -There are four layout options offering two types of panel control: - -**Panel layout options** - -These options control the size and position of panels: - -- [GridLayoutKind](#gridlayoutkind) - Corresponds to the **Custom** option in the UI. You define panel size and panel positions using x- and y- settings. -- [AutoGridLayoutKind](#autogridlayoutkind) - Corresponds to the **Auto grid** option in the UI. Panel size and position are automatically set based on column and row parameters. - -**Panel grouping options** - -These options control the grouping of panels: - -- [RowsLayoutKind](#rowslayoutkind) - Groups panels into rows. -- [TabsLayoutKind](#tabslayoutkind) - Groups panels into tabs. - -## `GridLayoutKind` - -The grid layout allows you to manually size and position grid items by setting the height, width, x, and y of each item. -This layout corresponds to the **Custom** option in the UI. - -Following is the JSON for a default grid layout, a grid layout item, and a grid layout row: - -```json - "kind": "GridLayout", - "spec": { - "items": [ - { - "kind": "GridLayoutItem", - "spec": { - "element": {...}, - "height": 0, - "width": 0, - "x": 0, - "y": 0 - } - }, - { - "kind": "GridLayoutRow", - "spec": { - "collapsed": false, - "elements": [], - "title": "", - "y": 0 - } - }, - ] - } -``` - -`GridLayoutKind` consists of: - -- kind: "GridLayout" -- spec: GridLayoutSpec - - items: GridLayoutItemKind` or GridLayoutRowKind` - - GridLayoutItemKind - - kind: "GridLayoutItem" - - spec: [GridLayoutItemSpec](#gridlayoutitemspec) - - GridLayoutRowKind - - kind: "GridLayoutRow" - - spec: [GridLayoutRowSpec](#gridlayoutrowspec) - -### `GridLayoutItemSpec` - -The following table explains the usage of the grid layout item JSON fields: - -| Name | Usage | -| ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| x | integer. Position of the item x-axis. | -| y | integer. Position of the item y-axis. | -| width | Width of the item in pixels. | -| height | Height of the item in pixels. | -| element | `ElementReference`. Reference to a [`PanelKind`](https://grafana.com/docs/grafana//observability-as-code/schema-v2/panel-schema/) from `dashboard.spec.elements` expressed as JSON Schema reference. | -| repeat? | [RepeatOptions](#repeatoptions). Configured repeat options, if any | - -#### `RepeatOptions` - -The following table explains the usage of the repeat option JSON fields: - -| Name | Usage | -| ---------- | ---------------------------------------------------- | -| mode | `RepeatMode` - "variable" | -| value | string | -| direction? | Options are `h` for horizontal and `v` for vertical. | -| maxPerRow? | integer | - -### `GridLayoutRowSpec` - -The following table explains the usage of the grid layout row JSON fields: - - - -| Name | Usage | -| ---- | ----- | -| y | integer. Position of the row y-axis | -| collapsed | bool. Whether or not the row is collapsed | -| title | Row title | -| elements | [`[...GridLayoutItemKind]`](#gridlayoutitemspec). Grid items in the row will have their y value be relative to the row's y value. This means a panel positioned at `y: 0` in a row with `y: 10` will be positioned at `y: 11` (row header has a height of 1) in the dashboard. | -| repeat? | [RowRepeatOptions](#rowrepeatoptions) Configured row repeat options, if any

| - - - -#### `RowRepeatOptions` - -| Name | Usage | -| ----- | ------------------------- | -| mode | `RepeatMode` - "variable" | -| value | string | - -## `AutoGridLayoutKind` - -With an auto grid, Grafana sizes and positions your panels for the best fit based on the column and row constraints that you set. -This layout corresponds to the **Auto grid** option in the UI. - -Following is the JSON for a default auto grid layout and a grid layout item: - - - -```json - "kind": "AutoGridLayout", - "spec": { - "columnWidthMode": "standard", - "fillScreen": false, - "items": [ - { - "kind": "AutoGridLayoutItem", - "spec": { - "element": {...}, - } - } - ], - "maxColumnCount": 3, - "rowHeightMode": "standard" - } -``` - -`AutoGridLayoutKind` consists of: - -- kind: "AutoGridLayout" -- spec: [AutoGridLayoutSpec](#autogridlayoutspec) - -### `AutoGridLayoutSpec` - -The following table explains the usage of the auto grid layout JSON fields: - - - -| Name | Usage | -| ---- | ----- | -| maxColumnCount? | number. Default is `3`. | -| columnWidthMode | Options are: `narrow`, `standard`, `wide`, and `custom`. Default is `standard`. | -| columnWidth? | number | -| rowHeightMode | Options are: `short`, `standard`, `tall`, and `custom`. Default is `standard`. | -| rowHeight? | number | -| fillScreen? | bool. Default is `false`. | -| items | `AutoGridLayoutItemKind`. Consists of:
  • kind: "AutoGridLayoutItem"
  • spec: [AutoGridLayoutItemSpec](#autogridlayoutitemspec)
| - - - -#### `AutoGridLayoutItemSpec` - -The following table explains the usage of the auto grid layout item JSON fields: - - - -| Name | Usage | -| ---- | ----- | -| element | `ElementReference`. Reference to a [`PanelKind`](https://grafana.com/docs/grafana//observability-as-code/schema-v2/panel-schema/) from `dashboard.spec.elements` expressed as JSON Schema reference. | -| repeat? | [AutoGridRepeatOptions](#autogridrepeatoptions). Configured repeat options, if any. | -| conditionalRendering? | `ConditionalRenderingGroupKind`. Rules for hiding or showing panels, if any. Consists of:
  • kind: "ConditionalRenderingGroup"
  • spec: [ConditionalRenderingGroupSpec](#conditionalrenderinggroupspec)
| - - - -##### `AutoGridRepeatOptions` - -The following table explains the usage of the auto grid repeat option JSON fields: - -| Name | Usage | -| ----- | ------------------------- | -| mode | `RepeatMode` - "variable" | -| value | String | - -##### `ConditionalRenderingGroupSpec` - - - -| Name | Usage | -| ---- | ----- | -| visibility | Options are `show` and `hide` | -| condition | Options are `and` and `or` | -| items | Options are:
  • ConditionalRenderingVariableKind
    • kind: "ConditionalRenderingVariable"
    • spec: [ConditionalRenderingVariableSpec](#conditionalrenderingvariablespec)
  • ConditionalRenderingDataKind
    • kind: "ConditionalRenderingData"
    • spec: [ConditionalRenderingDataSpec](#conditionalrenderingdataspec)
  • ConditionalRenderingTimeRangeSizeKind
    • kind: "ConditionalRenderingTimeRangeSize"
    • spec: [ConditionalRenderingTimeRangeSizeSpec](#conditionalrenderingtimerangesizespec)
| - - - -###### `ConditionalRenderingVariableSpec` - -| Name | Usage | -| -------- | ------------------------------------ | -| variable | string | -| operator | Options are `equals` and `notEquals` | -| value | string | - -###### `ConditionalRenderingDataSpec` - -| Name | Type | -| ----- | ---- | -| value | bool | - -###### `ConditionalRenderingTimeRangeSizeSpec` - -| Name | Type | -| ----- | ------ | -| value | string | - -## `RowsLayoutKind` - -The `RowsLayoutKind` is one of two options that you can use to group panels. -You can nest any other kind of layout inside a layout row. -Rows can also be nested in auto grids or tabs. - -Following is the JSON for a default rows layout row: - -```json - "kind": "RowsLayout", - "spec": { - "rows": [ - { - "kind": "RowsLayoutRow", - "spec": { - "layout": { - "kind": "GridLayout", // Can also be AutoGridLayout or TabsLayout - "spec": {...} - }, - "title": "" - } - } - ] - } -``` - -`RowsLayoutKind` consists of: - -- kind: RowsLayout -- spec: RowsLayoutSpec - - rows: RowsLayoutRowKind - - kind: RowsLayoutRow - - spec: [RowsLayoutRowSpec](#rowslayoutrowspec) - -### `RowsLayoutRowSpec` - -The following table explains the usage of the rows layout row JSON fields: - - - -| Name | Usage | -| ---- | ----- | -| title? | Title of the row. | -| collapse | bool. Whether or not the row is collapsed. | -| hideHeader? | bool. Whether the row header is hidden or shown. | -| fullScreen? | bool. Whether or not the row takes up the full screen. | -| conditionalRendering? | `ConditionalRenderingGroupKind`. Rules for hiding or showing rows, if any. Consists of:
  • kind: "ConditionalRenderingGroup"
  • spec: [ConditionalRenderingGroupSpec](#conditionalrenderinggroupspec)
| -| repeat? | [RowRepeatOptions](#rowrepeatoptions). Configured repeat options, if any. | -| layout | Supported layouts are:
  • [GridLayoutKind](#gridlayoutkind)
  • [RowsLayoutKind](#rowslayoutkind)
  • [AutoGridLayoutKind](#autogridlayoutkind)
  • [TabsLayoutKind](#tabslayoutkind)
| - - - -## `TabsLayoutKind` - -The `TabsLayoutKind` is one of two options that you can use to group panels. -You can nest any other kind of layout inside a tab. -Tabs can also be nested in auto grids or rows. - -Following is the JSON for a default tabs layout tab and a tab: - -```json - "kind": "TabsLayout", - "spec": { - "tabs": [ - { - "kind": "TabsLayoutTab", - "spec": { - "layout": { - "kind": "GridLayout", // Can also be AutoGridLayout or RowsLayout - "spec": {...} - }, - "title": "New tab" - } - } - ] - } -``` - -`TabsLayoutKind` consists of: - -- kind: TabsLayout - - spec: TabsLayoutSpec - - tabs: TabsLayoutTabKind - - kind: TabsLayoutTab - - spec: [TabsLayoutTabSpec](#tabslayouttabspec) - -### `TabsLayoutTabSpec` - -The following table explains the usage of the tabs layout tab JSON fields: - - - -| Name | Usage | -| ---- | ----- | -| title? | The title of the tab. | -| layout | Supported layouts are:
  • [GridLayoutKind](#gridlayoutkind)
  • [RowsLayoutKind](#rowslayoutkind)
  • [AutoGridLayoutKind](#autogridlayoutkind)
  • [TabsLayoutKind](#tabslayoutkind)
| -| conditionalRendering? | `ConditionalRenderingGroupKind`. Rules for hiding or showing panels, if any. Consists of:
  • kind: "ConditionalRenderingGroup"
  • spec: [ConditionalRenderingGroupSpec](#conditionalrenderinggroupspec)
| - - diff --git a/docs/sources/as-code/observability-as-code/schema-v2/librarypanel-schema.md b/docs/sources/as-code/observability-as-code/schema-v2/librarypanel-schema.md deleted file mode 100644 index 45715e15b15..00000000000 --- a/docs/sources/as-code/observability-as-code/schema-v2/librarypanel-schema.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -description: A reference for the JSON library panel schema used with Observability as Code. -keywords: - - configuration - - as code - - as-code - - dashboards - - git integration - - git sync - - github - - library panel -labels: - products: - - cloud - - enterprise - - oss -menuTitle: LibraryPanelKind schema -title: LibraryPanelKind -weight: 300 -canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/librarypanel-schema/ -aliases: - - ../../../observability-as-code/schema-v2/librarypanel-schema/ # /docs/grafana/next/observability-as-code/schema-v2/librarypanel-schema/ ---- - -# `LibraryPanelKind` - -A library panel is a reusable panel that you can use in any dashboard. -When you make a change to a library panel, that change propagates to all instances of where the panel is used. -Library panels streamline reuse of panels across multiple dashboards. - -Following is the default library panel element JSON: - -```json - "kind": "LibraryPanel", - "spec": { - "id": 0, - "libraryPanel": { - name: "", - uid: "", - } - "title": "" - } -``` - -The `LibraryPanelKind` consists of: - -- kind: "LibraryPanel" -- spec: [LibraryPanelKindSpec](#librarypanelkindspec) - - libraryPanel: [LibraryPanelRef](#librarypanelref) - -## `LibraryPanelKindSpec` - -The following table explains the usage of the library panel element JSON fields: - -| Name | Usage | -| ------------ | ------------------------------------------------ | -| id | Panel ID for the library panel in the dashboard. | -| libraryPanel | [`LibraryPanelRef`](#librarypanelref) | -| title | Title for the library panel in the dashboard. | - -### `LibraryPanelRef` - -The following table explains the usage of the library panel reference JSON fields: - -| Name | Usage | -| ---- | ------------------ | -| name | Library panel name | -| uid | Library panel uid | diff --git a/docs/sources/as-code/observability-as-code/schema-v2/links-schema.md b/docs/sources/as-code/observability-as-code/schema-v2/links-schema.md deleted file mode 100644 index 0ddc50376de..00000000000 --- a/docs/sources/as-code/observability-as-code/schema-v2/links-schema.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -description: A reference for the JSON links schema used with Observability as Code. -keywords: - - configuration - - as code - - as-code - - dashboards - - git integration - - git sync - - github - - links -labels: - products: - - cloud - - enterprise - - oss -menuTitle: links schema -title: links -weight: 500 -canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/links-schema/ -aliases: - - ../../../observability-as-code/schema-v2/links-schema/ # /docs/grafana/next/observability-as-code/schema-v2/links-schema/ ---- - -# `links` - -The `links` schema is the configuration for links with references to other dashboards or external websites. -Following are the default JSON fields: - -```json - "links": [ - { - "asDropdown": false, - "icon": "", - "includeVars": false, - "keepTime": false, - "tags": [], - "targetBlank": false, - "title": "", - "tooltip": "", - "type": "link", - }, - ], -``` - -## `DashboardLink` - -The following table explains the usage of the dashboard link JSON fields. -The table includes default and other fields: - - - -| Name | Usage | -| ----------- | --------------------------------------- | -| title | string. Title to display with the link. | -| type | `DashboardLinkType`. Link type. Accepted values are:
  • dashboards - To refer to another dashboard
  • link - To refer to an external resource
| -| icon | string. Icon name to be displayed with the link. | -| tooltip | string. Tooltip to display when the user hovers their mouse over it. | -| url? | string. Link URL. Only required/valid if the type is link. | -| tags | string. List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards. | -| asDropdown | bool. If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards. Default is `false`. | -| targetBlank | bool. If true, the link will be opened in a new tab. Default is `false`. | -| includeVars | bool. If true, includes current template variables values in the link as query params. Default is `false`. | -| keepTime | bool. If true, includes current time range in the link as query params. Default is `false`. | -| placement? | string. Use placement to display the link somewhere else on the dashboard other than above the visualizations. Use the `inControlsMenu` parameter to render the link in the dashboard controls dropdown menu. | - - diff --git a/docs/sources/as-code/observability-as-code/schema-v2/panel-schema.md b/docs/sources/as-code/observability-as-code/schema-v2/panel-schema.md deleted file mode 100644 index 088ab8eebf4..00000000000 --- a/docs/sources/as-code/observability-as-code/schema-v2/panel-schema.md +++ /dev/null @@ -1,305 +0,0 @@ ---- -description: A reference for the JSON panel schema used with Observability as Code. -keywords: - - configuration - - as code - - as-code - - dashboards - - git integration - - git sync - - github - - panels -labels: - products: - - cloud - - enterprise - - oss -menuTitle: PanelKind schema -title: PanelKind -weight: 200 -canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/panel-schema/ -aliases: - - ../../../observability-as-code/schema-v2/panel-schema/ # /docs/grafana/next/observability-as-code/schema-v2/panel-schema/ ---- - -# `PanelKind` - -The panel element contains all the information about the panel including the visualization type, panel and visualization configuration, queries, and transformations. -There's a panel element for each panel contained in the dashboard. - -Following is the default panel element JSON: - -```json - "kind": "Panel", - "spec": { - "data": { - "kind": "QueryGroup", - "spec": {...}, - "description": "", - "id": 0, - "links": [], - "title": "", - "vizConfig": { - "kind": "", - "spec": {...}, - } - } -``` - -The `PanelKind` consists of: - -- kind: "Panel" -- spec: [PanelSpec](#panelspec) - -## `PanelSpec` - -The following table explains the usage of the panel element JSON fields: - - - -| Name | Usage | -| ------------ | --------------------------------------------------------------------- | -| data | `QueryGroupKind`, which includes queries and transformations. Consists of:
  • kind: "QueryGroup"
  • spec: [QueryGroupSpec](#querygroupspec)
| -| description | The panel description. | -| id | The panel ID. | -| links | Links with references to other dashboards or external websites. | -| title | The panel title. | -| vizConfig | `VizConfigKind`. Includes visualization type, field configuration options, and all other visualization options. Consists of:
  • kind: string. Plugin ID.
  • spec: [VizConfigSpec](#vizconfigspec)
| -| transparent? | bool. Controls whether or not the panel background is transparent. | - - - -### `QueryGroupSpec` - - - -| Name | Usage | -| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| queries | `PanelQueryKind`. Consists of:
  • kind: PanelQuery
  • spec: [PanelQuerySpec](#panelqueryspec)
| -| transformations | `TransformationKind`. Consists of:
  • kind: string. The transformation ID.
  • spec: [DataTransformerConfig](#datatransformerconfig)
| -| queryOptions | [`QueryOptionsSpec`](#queryoptionsspec) | - - - -#### `PanelQuerySpec` - -| Name | Usage | -| ----------- | --------------------------------- | -| query | [`DataQueryKind`](#dataquerykind) | -| datasource? | [`DataSourceRef`](#datasourceref) | - -##### `DataQueryKind` - -| Name | Type | -| ---- | ------ | -| kind | string | -| spec | string | - -##### `DataSourceRef` - -| Name | Usage | -| ----- | ---------------------------------- | -| type? | string. The plugin type-id. | -| uid? | The specific data source instance. | - -#### `DataTransformerConfig` - -Transformations allow you to manipulate data returned by a query before the system applies a visualization. -Using transformations you can: rename fields, join time series data, perform mathematical operations across queries, or use the output of one transformation as the input to another transformation. - - - -| Name | Usage | -| --------- | ------------------------------------------- | -| id | string. Unique identifier of transformer. | -| disabled? | bool. Disabled transformations are skipped. | -| filter? | [`MatcherConfig`](#matcherconfig). Optional frame matcher. When missing it will be applied to all results. | -| topic? | `DataTopic`. Where to pull `DataFrames` from as input to transformation. Options are: `series`, `annotations`, and `alertStates`. | -| options | Options to be passed to the transformer. Valid options depend on the transformer id. | - - - -##### `MatcherConfig` - -Matcher is a predicate configuration. -Based on the configuration a set of field or values, it's filtered to apply an override or transformation. -It comes with in id (to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. - -| Name | Usage | -| -------- | -------------------------------------------------------------------------------------- | -| id | string. The matcher id. This is used to find the matcher implementation from registry. | -| options? | The matcher options. This is specific to the matcher implementation. | - -#### `QueryOptionsSpec` - -| Name | Type | -| ----------------- | ------- | -| timeFrom? | string | -| maxDataPoints? | integer | -| timeShift? | string | -| queryCachingTTL? | integer | -| interval? | string | -| cacheTimeout? | string | -| hideTimeOverride? | bool | - -### `VizConfigSpec` - -| Name | Type/Definition | -| ------------- | --------------------------------------- | -| pluginVersion | string | -| options | string | -| fieldConfig | [FieldConfigSource](#fieldconfigsource) | - -#### `FieldConfigSource` - -The data model used in Grafana, namely the _data frame_, is a columnar-oriented table structure that unifies both time series and table query results. -Each column within this structure is called a field. -A field can represent a single time series or table column. -Field options allow you to change how the data is displayed in your visualizations. - - - -| Name | Type/Definition | -| ---------- | ------------------------------------- | -| defaults | [`FieldConfig`](#fieldconfig). Defaults are the options applied to all fields. | -| overrides | The options applied to specific fields overriding the defaults. | -| matcher | [`MatcherConfig`](#matcherconfig). Optional frame matcher. When missing it will be applied to all results. | -| properties | `DynamicConfigValue`. Consists of:
  • `id` - string
  • value?
| - - - -##### `FieldConfig` - - - -| Name | Type/Definition | -| ------------------ | --------------------------------------- | -| displayName? | string. The display value for this field. This supports template variables where empty is auto. | -| displayNameFromDS? | string. This can be used by data sources that return an explicit naming structure for values and labels. When this property is configured, this value is used rather than the default naming strategy. | -| description? | string. Human readable field metadata. | -| path? | string. An explicit path to the field in the data source. When the frame meta includes a path, this will default to `${frame.meta.path}/${field.name}`. When defined, this value can be used as an identifier within the data source scope, and may be used to update the results. | -| writeable? | bool. True if the data source can write a value to the path. Auth/authz are supported separately. | -| filterable? | bool. True if the data source field supports ad-hoc filters. | -| unit? | string. Unit a field should use. The unit you select is applied to all fields except time. You can use the unit's ID available in Grafana or a custom unit. [Available units in Grafana](https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts). As custom units, you can use the following formats:
  • `suffix:` for custom unit that should go after value.
  • `prefix:` for custom unit that should go before value.
  • `time:` for custom date time formats type for example
  • `time:YYYY-MM-DD`
  • `si:` for custom SI units. For example: `si: mF`. You can specify both a unit and the source data scale, so if your source data is represented as milli (thousands of) something, prefix the unit with that SI scale character.
  • `count:` for a custom count unit.
  • `currency:` for custom a currency unit.
| -| decimals? | number. Specify the number of decimals Grafana includes in the rendered value. If you leave this field blank, Grafana automatically truncates the number of decimals based on the value. For example 1.1234 will display as 1.12 and 100.456 will display as 100. To display all decimals, set the unit to `string`. | -| min? | number. The minimum value used in percentage threshold calculations. Leave empty for auto calculation based on all series and fields. | -| max? | number. The maximum value used in percentage threshold calculations. Leave empty for auto calculation based on all series and fields. | -| mappings? | `[...ValueMapping]`. Convert input values into a display string. Options are: [`ValueMap`](#valuemap), [`RangeMap`](#rangemap), [`RegexMap`](#rangemap), [`SpecialValueMap`](#specialvaluemap). | -| thresholds? | `ThresholdsConfig`. Map numeric values to states. Consists of:
  • `mode` - `ThresholdsMode`. Options are: `absolute` and `percentage`.
  • `steps` - `[...Threshold]`
| -| color? | [`FieldColor`](#fieldcolor). Panel color configuration. | -| links? | `[...]`. The behavior when clicking a result. | -| noValue? | string. Alternative to an empty string. | -| custom? | `{...}`. Specified by the `FieldConfig` field in panel plugin schemas. | - - - -###### `ValueMap` - -Maps text values to a color or different display text and color. -For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number. - - - -| Name | Usage | -| ------- | -------- | -| type | `MappingType` & "value". `MappingType` options are: `value`, `range`, `regex`, and `special`. | -| options | string. [`ValueMappingResult`](#valuemappingresult). Map with ``: `ValueMappingResult`. For example: `{ "10": { text: "Perfection!", color: "green" } }`. | - - - -###### `RangeMap` - -Maps numerical ranges to a display text and color. -For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. - - - -| Name | Usage | -| ------- | ---------------------------------------------------------------------------------------------------- | -| type | `MappingType` & "range". `MappingType` options are: `value`, `range`, `regex`, and `special`. | -| options | Range to match against and the result to apply when the value is within the range. Spec:
  • `from` - `float64` or `null`. Min value of the range. It can be null which means `-Infinity`.
  • `to` - `float64` or `null`. Max value of the range. It can be null which means `+Infinity`.
  • `result` - [`ValueMappingResult`](#valuemappingresult) | - - - -###### `RegexMap` - -Maps regular expressions to replacement text and a color. -For example, if a value is `www.example.com`, you can configure a regex value mapping so that Grafana displays www and truncates the domain. - - - -| Name | Usage | -| ------- | --------------------------------------------------------------------------------------------- | -| type | `MappingType` & "regex". `MappingType` options are: `value`, `range`, `regex`, and `special`. | -| options | Regular expression to match against and the result to apply when the value matches the regex. Spec:
    • `pattern` - string. Regular expression to match against.
    • `result` - [`ValueMappingResult`](#valuemappingresult) | - - - -###### `SpecialValueMap` - -Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. -See `SpecialValueMatch` in the following table to see the list of special values. -For example, you can configure a special value mapping so that null values appear as N/A. - - - -| Name | Usage | -| ------- | ----------------------------------------------------------------------------------------------- | -| type | `MappingType` & "special". `MappingType` options are: `value`, `range`, `regex`, and `special`. | -| options | Spec:
      • `match` - `SpecialValueMatch`. Special value to match against. Types are:
        • true
        • false
        • null
        • nan
        • empty
      • `result` - [`ValueMappingResult`](#valuemappingresult) | - - - -###### `ValueMappingResult` - -Result used as replacement with text and color when the value matches. - -| Name | Usage | -| ----- | ----------------------------------------------------------------------------- | -| text | string. Text to display when the value matches. | -| color | string. Color to use when the value matches. | -| icon | string. Icon to display when the value matches. Only specific visualizations. | -| index | int32. Position in the mapping array. Only used internally. | - -###### `FieldColor` - -Map a field to a color. - - - -| Name | Usage | -| ----------- | -------------------------------------------------------------------- | -| mode | [`FieldColorModeId`](#fieldcolormodeid). The main color scheme mode. | -| FixedColor? | string. The fixed color value for fixed or shades color modes. | -| seriesBy? | `FieldColorSeriesByMode`. Some visualizations need to know how to assign a series color from by value color schemes. Defines how to assign a series color from "by value" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value. Options are: `min`, `max`, and `last`. | - - - -###### `FieldColorModeId` - -Color mode for a field. -You can specify a single color, or select a continuous (gradient) color schemes, based on a value. -Continuous color interpolates a color using the percentage of a value relative to min and max. -Accepted values are: - - - -| Name | Description | -| --- | ---- | -| thresholds | From thresholds. Informs Grafana to take the color from the matching threshold. | -| palette-classic | Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for graphs and pie charts and other categorical data visualizations. | -| palette-classic-by-name | Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations | -| continuous-GrYlRd | Continuous Green-Yellow-Red palette mode | -| continuous-RdYlGr | Continuous Red-Yellow-Green palette mode | -| continuous-BlYlRd | Continuous Blue-Yellow-Red palette mode | -| continuous-YlRd | Continuous Yellow-Red palette mode | -| continuous-BlPu | Continuous Blue-Purple palette mode | -| continuous-YlBl | Continuous Yellow-Blue palette mode | -| continuous-blues | Continuous Blue palette mode | -| continuous-reds | Continuous Red palette mode | -| continuous-greens | Continuous Green palette mode | -| continuous-purples | Continuous Purple palette mode | -| shades | Shades of a single color. Specify a single color, useful in an override rule. | -| fixed | Fixed color mode. Specify a single color, useful in an override rule. | - - diff --git a/docs/sources/as-code/observability-as-code/schema-v2/timesettings-schema.md b/docs/sources/as-code/observability-as-code/schema-v2/timesettings-schema.md deleted file mode 100644 index 8db14212740..00000000000 --- a/docs/sources/as-code/observability-as-code/schema-v2/timesettings-schema.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -description: A reference for the JSON timesettings schema used with Observability as Code. -keywords: - - configuration - - as code - - as-code - - dashboards - - git integration - - git sync - - github - - time settings -labels: - products: - - cloud - - enterprise - - oss -menuTitle: timesettings schema -title: timesettings -weight: 600 -canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/timesettings-schema/ -aliases: - - ../../../observability-as-code/schema-v2/timesettings-schema/ # /docs/grafana/next/observability-as-code/schema-v2/timesettings-schema/ ---- - -# `timeSettings` - -The `TimeSettingsSpec` defines the default time configuration for the time picker and the refresh picker for the specific dashboard. - -Following is the JSON for default time settings: - -```json - "timeSettings": { - "autoRefresh": "", - "autoRefreshIntervals": [ - "5s", - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ], - "fiscalYearStartMonth": 0, - "from": "now-6h", - "hideTimepicker": false, - "timezone": "browser", - "to": "now" - }, -``` - -`timeSettings` consists of: - -- [TimeSettingsSpec](#timesettingsspec) - -## `TimeSettingsSpec` - -The following table explains the usage of the time settings JSON fields: - - - -| Name | Usage | -| ---- | ----- | -| timezone? | string. Timezone of dashboard. Accepted values are IANA TZDB zone ID, `browser`, or `utc`. Default is `browser`. | -| from | string. Start time range for dashboard. Accepted values are relative time strings like `now-6h` or absolute time strings like `2020-07-10T08:00:00.000Z`. Default is `now-6h`. | -| to | string. End time range for dashboard. Accepted values are relative time strings like `now-6h` or absolute time strings like `2020-07-10T08:00:00.000Z`. Default is `now`. | -| autoRefresh | string. Refresh rate of dashboard. Represented by interval string. For example: `5s`, `1m`, `1h`, `1d`. No default. In schema v1: `refresh`. | -| autoRefreshIntervals | string. Interval options available in the refresh picker drop-down menu. The default array is `["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]`. | -|quickRanges? | Selectable options available in the time picker drop-down menu. Has no effect on provisioned dashboard. Defined in the [`TimeRangeOption`](#timerangeoption) spec. In schema v1: `timepicker.quick_ranges`, not exposed in the UI. | -| hideTimepicker | bool. Whether or not the time picker is visible. Default is `false`. In schema v1: `timepicker.hidden`. | -| weekStart? | Day when the week starts. Expressed by the name of the day in lowercase. For example: `monday`. Options are `saturday`, `monday`, and `sunday`. | -| fiscalYearStartMonth | The month that the fiscal year starts on. `0` = January, `11` = December | -| nowDelay? | string. Override the "now" time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. In schema v1: `timepicker.nowDelay`. | - - - -### `TimeRangeOption` - -The following table explains the usage of the time range option JSON fields: - -| Name | Usage | -| ------- | ---------------------------------- | -| display | string. Default is `Last 6 hours`. | -| from | string. Default is `now-6h`. | -| to | string. Default is `now`. | diff --git a/docs/sources/as-code/observability-as-code/schema-v2/variables-schema.md b/docs/sources/as-code/observability-as-code/schema-v2/variables-schema.md deleted file mode 100644 index 549478692f1..00000000000 --- a/docs/sources/as-code/observability-as-code/schema-v2/variables-schema.md +++ /dev/null @@ -1,501 +0,0 @@ ---- -description: A reference for the JSON variables schema used with Observability as Code. -keywords: - - configuration - - as code - - as-code - - dashboards - - git integration - - git sync - - github - - variables -labels: - products: - - cloud - - enterprise - - oss -menuTitle: variables schema -title: variables -weight: 700 -canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/variables-schema/ -aliases: - - ../../../observability-as-code/schema-v2/variables-schema/ # /docs/grafana/next/observability-as-code/schema-v2/variables-schema/ ---- - -# `variables` - -The available variable types described in the following sections: - -- [QueryVariableKind](#queryvariablekind) -- [TextVariableKind](#textvariablekind) -- [ConstantVariableKind](#constantvariablekind) -- [DatasourceVariableKind](#datasourcevariablekind) -- [IntervalVariableKind](#intervalvariablekind) -- [CustomVariableKind](#customvariablekind) -- [SwitchVariableKind](#switchvariablekind) -- [GroupByVariableKind](#groupbyvariablekind) -- [AdhocVariableKind](#adhocvariablekind) - -## `QueryVariableKind` - -Following is the JSON for a default query variable: - -```json - "variables": [ - { - "kind": "QueryVariable", - "spec": { - "current": { - "text": "", - "value": "" - }, - "hide": "dontHide", - "includeAll": false, - "multi": false, - "name": "", - "options": [], - "query": defaultDataQueryKind(), - "refresh": "never", - "regex": "", - "skipUrlSync": false, - "sort": "disabled" - } - } - ] -``` - -`QueryVariableKind` consists of: - -- kind: "QueryVariable" -- spec: [QueryVariableSpec](#queryvariablespec) - -### `QueryVariableSpec` - -The following table explains the usage of the query variable JSON fields: - - - -| Name | Usage | -| ------------ | ---------------------------------------------- | -| name | string. Name of the variable. | -| current | "Text" and a "value" or [`VariableOption`](#variableoption) | -| label? | string | -| hide | `VariableHide`. Options are: `dontHide`, `hideLabel`, and `hideVariable`. | -| refresh | `VariableRefresh`. Options are `never`, `onDashboardLoad`, and `onTimeChanged`. | -| skipUrlSync | bool. Default is `false`. | -| description? | string | -| datasource? | [`DataSourceRef`](#datasourceref) | -| query | `DataQueryKind`. Consists of:
        • kind: string
        • spec: string
        | -| regex | string | -| sort | `VariableSort`. Options are:
        • disabled
        • alphabeticalAsc
        • alphabeticalDesc
        • numericalAsc
        • numericalDesc
        • alphabeticalCaseInsensitiveAsc
        • alphabeticalCaseInsensitiveDesc
        • naturalAsc
        • naturalDesc
        | -| definition? | string | -| options | [`VariableOption`](#variableoption) | -| multi | bool. Default is `false`. | -| includeAll | bool. Default is `false`. | -| allValue? | string | -| placeholder? | string | - - - -#### `VariableOption` - -| Name | Usage | -| -------- | -------------------------------------------- | -| selected | bool. Whether or not the option is selected. | -| text | string. Text to be displayed for the option. | -| value | string. Value of the option. | - -#### `DataSourceRef` - -| Name | Usage | -| ----- | ---------------------------------- | -| type? | string. The plugin type-id. | -| uid? | The specific data source instance. | - -## `TextVariableKind` - -Following is the JSON for a default text variable: - -```json - "variables": [ - { - "kind": "TextVariable", - "spec": { - "current": { - "text": "", - "value": "" - }, - "hide": "dontHide", - "name": "", - "query": "", - "skipUrlSync": false - } - } - ] -``` - -`TextVariableKind` consists of: - -- kind: TextVariableKind -- spec: [TextVariableSpec](#textvariablespec) - -### `TextVariableSpec` - -The following table explains the usage of the query variable JSON fields: - -| Name | Usage | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| name | string. Name of the variable. | -| current | "Text" and a "value" or `VariableOption`. Refer to the [`VariableOption` definition](#variableoption) under `QueryVariableKind`. | -| query | string | -| label? | string | -| hide | `VariableHide`. Options are: `dontHide`, `hideLabel`, and `hideVariable`. | -| skipUrlSync | bool. Default is `false`. | -| description? | string | - -## `ConstantVariableKind` - -Following is the JSON for a default constant variable: - -```json - "variables": [ - { - "kind": "ConstantVariable", - "spec": { - "current": { - "text": "", - "value": "" - }, - "hide": "hideVariable", - "name": "", - "query": "", - "skipUrlSync": true - } - } - ] -``` - -`ConstantVariableKind` consists of: - -- kind: "ConstantVariable" -- spec: [ConstantVariableSpec](#constantvariablespec) - -### `ConstantVariableSpec` - -The following table explains the usage of the constant variable JSON fields: - -| Name | Usage | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| name | string. Name of the variable. | -| query | string | -| current | "Text" and a "value" or `VariableOption`. Refer to the [`VariableOption` definition](#variableoption) under `QueryVariableKind`. | -| label? | string | -| hide | `VariableHide`. Options are: `dontHide`, `hideLabel`, and `hideVariable`. | -| skipUrlSync | bool. Default is `false`. | -| description? | string | - -## `DatasourceVariableKind` - -Following is the JSON for a default data source variable: - -```json - "variables": [ - { - "kind": "DatasourceVariable", - "spec": { - "current": { - "text": "", - "value": "" - }, - "hide": "dontHide", - "includeAll": false, - "multi": false, - "name": "", - "options": [], - "pluginId": "", - "refresh": "never", - "regex": "", - "skipUrlSync": false - } - } - ] -``` - -`DatasourceVariableKind` consists of: - -- kind: "DatasourceVariable" -- spec: [DatasourceVariableSpec](#datasourcevariablespec) - -### `DatasourceVariableSpec` - -The following table explains the usage of the data source variable JSON fields: - -| Name | Usage | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| name | string. Name of the variable. | -| pluginId | string | -| refresh | `VariableRefresh`. Options are `never`, `onDashboardLoad`, and `onTimeChanged`. | -| regex | string | -| current | `Text` and a `value` or `VariableOption`. Refer to the [`VariableOption` definition](#variableoption) under `QueryVariableKind`. | -| options | `VariableOption`. Refer to the [`VariableOption` definition](#variableoption) under `QueryVariableKind`. | -| multi | bool. Default is `false`. | -| includeAll | bool. Default is `false`. | -| allValue? | string | -| label? | string | -| hide | `VariableHide`. Options are: `dontHide`, `hideLabel`, and `hideVariable`. | -| skipUrlSync | bool. Default is `false`. | -| description? | string | - -## `IntervalVariableKind` - -Following is the JSON for a default interval variable: - -```json - "variables": [ - { - "kind": "IntervalVariable", - "spec": { - "auto": false, - "auto_count": 0, - "auto_min": "", - "current": { - "text": "", - "value": "" - }, - "hide": "dontHide", - "name": "", - "options": [], - "query": "", - "refresh": "never", - "skipUrlSync": false - } - } - ] -``` - -`IntervalVariableKind` consists of: - -- kind: "IntervalVariable" -- spec: [IntervalVariableSpec](#intervalvariablespec) - -### `IntervalVariableSpec` - -The following table explains the usage of the interval variable JSON fields: - -| Name | Usage | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| name | string. Name of the variable. | -| query | string | -| current | `Text` and a `value` or `VariableOption`. Refer to the [`VariableOption` definition](#variableoption) under `QueryVariableKind`. | -| options | `VariableOption`. Refer to the [`VariableOption` definition](#variableoption) under `QueryVariableKind`. | -| auto | bool. Default is `false`. | -| auto_count | integer. Default is `0`. | -| refresh | `VariableRefresh`. Options are `never`, `onDashboardLoad`, and `onTimeChanged`. | -| label? | string | -| hide | `VariableHide`. Options are: `dontHide`, `hideLabel`, and `hideVariable`. | -| skipUrlSync | bool. Default is `false` | -| description? | string | - -## `CustomVariableKind` - -Following is the JSON for a default custom variable: - -```json - "variables": [ - { - "kind": "CustomVariable", - "spec": { - "current": defaultVariableOption(), - "hide": "dontHide", - "includeAll": false, - "multi": false, - "name": "", - "options": [], - "query": "", - "skipUrlSync": false - } - } - ] -``` - -`CustomVariableKind` consists of: - -- kind: "CustomVariable" -- spec: [CustomVariableSpec](#customvariablespec) - -### `CustomVariableSpec` - -The following table explains the usage of the custom variable JSON fields: - -| Name | Usage | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| name | string. Name of the variable. | -| query | string | -| current | `Text` and a `value` or `VariableOption`. Refer to the [`VariableOption` definition](#variableoption) under `QueryVariableKind`. | -| options | `VariableOption`. Refer to the [`VariableOption` definition](#variableoption) under `QueryVariableKind`. | -| multi | bool. Default is `false`. | -| includeAll | bool. Default is `false`. | -| allValue? | string | -| label? | string | -| hide | `VariableHide`. Options are: `dontHide`, `hideLabel`, and `hideVariable`. | -| skipUrlSync | bool. Default is `false`. | -| description? | string | - -## `SwitchVariableKind` - -Following is the JSON for a default switch variable: - -```json - "variables": [ - { - "kind": "SwitchVariable", - "spec": { - "current": "false", - "enabledValue": "true", - "disabledValue": "false", - "hide": "dontHide", - "name": "", - "skipUrlSync": false - } - } - ] -``` - -`SwitchVariableKind` consists of: - -- kind: "SwitchVariable" -- spec: [SwitchVariableSpec](#switchvariablespec) - -### `SwitchVariableSpec` - -The following table explains the usage of the switch variable JSON fields: - - - -| Name | Usage | -| -------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| name | string. Name of the variable. | -| current | string. Current value of the switch variable (either `enabledValue` or `disabledValue`). | -| enabledValue | string. Value when the switch is in the enabled state. | -| disabledValue | string. Value when the switch is in the disabled state. | -| label? | string | -| hide | `VariableHide`. Options are: `dontHide`, `hideLabel`, and `hideVariable`. | -| skipUrlSync | bool. Default is `false`. | -| description? | string | - - - -## `GroupByVariableKind` - -Following is the JSON for a default group by variable: - -```json - "variables": [ - { - "kind": "GroupByVariable", - "spec": { - "current": { - "text": [ - "" - ], - "value": [ - "" - ] - }, - "datasource": {}, - "hide": "dontHide", - "multi": false, - "name": "", - "options": [], - "skipUrlSync": false - } - } - ] -``` - -`GroupByVariableKind` consists of: - -- kind: "GroupByVariable" -- spec: [GroupByVariableSpec](#groupbyvariablespec) - -### `GroupByVariableSpec` - -The following table explains the usage of the group by variable JSON fields: - -| Name | Usage | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| name | string. Name of the variable | -| datasource? | `DataSourceRef`. Refer to the [`DataSourceRef` definition](#datasourceref) under `QueryVariableKind`. | -| current | `Text` and a `value` or `VariableOption`. Refer to the [`VariableOption` definition](#variableoption) under `QueryVariableKind`. | -| options | `VariableOption`. Refer to the [`VariableOption` definition](#variableoption) under `QueryVariableKind`. | -| multi | bool. Default is `false`. | -| label? | string | -| hide | `VariableHide`. Options are: `dontHide`, `hideLabel`, and `hideVariable`. | -| skipUrlSync | bool. Default is `false`. | -| description? | string. | - -## `AdhocVariableKind` - -Following is the JSON for a default ad hoc variable: - -```json - "variables": [ - { - "kind": "AdhocVariable", - "spec": { - "baseFilters": [], - "defaultKeys": [], - "filters": [], - "hide": "dontHide", - "name": "", - "skipUrlSync": false - } - } - ] -``` - -`AdhocVariableKind` consists of: - -- kind: "AdhocVariable" -- spec: [AdhocVariableSpec](#adhocvariablespec) - -### `AdhocVariableSpec` - -The following table explains the usage of the ad hoc variable JSON fields: - -| Name | Usage | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | -| name | string. Name of the variable. | -| datasource? | `DataSourceRef`. Consists of:
        • type? - string. The plugin type-id.
        • uid? - string. The specific data source instance.
        | -| baseFilters | [AdHocFilterWithLabels](#adhocfilterswithlabels) | -| filters | [AdHocFilterWithLabels](#adhocfilterswithlabels) | -| defaultKeys | [MetricFindValue](#metricfindvalue) | -| label? | string | -| hide | `VariableHide`. Options are: `dontHide`, `hideLabel`, and `hideVariable`. | -| skipUrlSync | bool. Default is `false`. | -| description? | string | - -#### `AdHocFiltersWithLabels` - -The following table explains the usage of the ad hoc variable with labels JSON fields: - -| Name | Type | -| ------------ | ------------- | -| key | string | -| operator | string | -| value | string | -| values? | `[...string]` | -| keyLabel | string | -| valueLabels? | `[...string]` | -| forceEdit? | bool | - -#### `MetricFindValue` - -The following table explains the usage of the metric find value JSON fields: - -| Name | Type | -| ----------- | ---------------- | -| text | string | -| value? | string or number | -| group? | string | -| expandable? | bool | diff --git a/docs/sources/datasources/graphite/_index.md b/docs/sources/datasources/graphite/_index.md index fba9d616f38..78972af5781 100644 --- a/docs/sources/datasources/graphite/_index.md +++ b/docs/sources/datasources/graphite/_index.md @@ -111,3 +111,4 @@ After installing and configuring the Graphite data source you can: - Add [transformations](ref:transformations) - Add [annotations](ref:annotate-visualizations) - Set up [alerting](ref:alerting) +- [Troubleshoot](troubleshooting/) common issues with the Graphite data source diff --git a/docs/sources/datasources/graphite/troubleshooting/index.md b/docs/sources/datasources/graphite/troubleshooting/index.md new file mode 100644 index 00000000000..41702c6b975 --- /dev/null +++ b/docs/sources/datasources/graphite/troubleshooting/index.md @@ -0,0 +1,174 @@ +--- +description: Troubleshoot common issues with the Graphite data source. +keywords: + - grafana + - graphite + - troubleshooting + - guide +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Troubleshooting +title: Troubleshoot Graphite data source issues +weight: 400 +refs: + configure-graphite: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/graphite/configure/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/graphite/configure/ + query-editor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/graphite/query-editor/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/graphite/query-editor/ +--- + +# Troubleshoot Graphite data source issues + +This document provides solutions for common issues you might encounter when using the Graphite data source. + +## Connection issues + +Use the following troubleshooting steps to resolve connection problems between Grafana and your Graphite server. + +**Data source test fails with "Unable to connect":** + +If the data source test fails, verify the following: + +- The URL in your data source configuration is correct and accessible from the Grafana server. +- The Graphite server is running and accepting connections. +- Any firewall rules or network policies allow traffic between Grafana and the Graphite server. +- If using TLS, ensure your certificates are valid and properly configured. + +To test connectivity, run the following command from the Grafana server: + +```sh +curl -v /render +``` + +Replace _``_ with your Graphite server URL. A successful connection returns a response from the Graphite server. + +**Authentication errors:** + +If you receive 401 or 403 errors: + +- Verify your Basic Auth username and password are correct. +- Ensure the **With Credentials** toggle is enabled if your Graphite server requires cookies for authentication. +- Check that your TLS client certificates are valid and match what the server expects. + +For detailed authentication configuration, refer to [Configure the Graphite data source](ref:configure-graphite). + +## Query issues + +Use the following troubleshooting steps to resolve problems with Graphite queries. + +**No data returned:** + +If your query returns no data: + +- Verify the metric path exists in your Graphite server by testing directly in the Graphite web interface. +- Check that the time range in Grafana matches when data was collected. +- Ensure wildcards in your query match existing metrics. +- Confirm your query syntax is correct for your Graphite version. + +**HTTP 500 errors with HTML content:** + +Graphite-web versions before 1.6 return HTTP 500 errors with full HTML stack traces when a query fails. If you see error messages containing HTML tags: + +- Check the Graphite server logs for the full error details. +- Verify your query syntax is valid. +- Ensure the requested time range doesn't exceed your Graphite server's capabilities. +- Check that all functions used in your query are supported by your Graphite version. + +**Parser errors in the query editor:** + +If the query editor displays parser errors: + +- Check for unbalanced parentheses in function calls. +- Verify that function arguments are in the correct format. +- Ensure metric paths don't contain unsupported characters. + +For query syntax help, refer to [Graphite query editor](ref:query-editor). + +## Version and feature issues + +Use the following troubleshooting steps to resolve problems related to Graphite versions and features. + +**Functions missing from the query editor:** + +If expected functions don't appear in the query editor: + +- Verify the correct Graphite version is selected in the data source configuration. +- The available functions depend on the configured version. For example, tag-based functions require Graphite 1.1 or later. +- If using a custom Graphite installation with additional functions, ensure the version setting matches your server. + +**Tag-based queries not working:** + +If `seriesByTag()` or other tag functions fail: + +- Confirm your Graphite server is version 1.1 or later. +- Verify the Graphite version setting in your data source configuration matches your actual server version. +- Check that tags are properly configured in your Graphite server. + +## Performance issues + +Use the following troubleshooting steps to address slow queries or timeouts. + +**Queries timing out:** + +If queries consistently time out: + +- Increase the **Timeout** setting in the data source configuration. +- Reduce the time range of your query. +- Use more specific metric paths instead of broad wildcards. +- Consider using `summarize()` or `consolidateBy()` functions to reduce the amount of data returned. +- Check your Graphite server's performance and resource utilization. + +**Slow autocomplete in the query editor:** + +If metric path autocomplete is slow: + +- This often indicates a large number of metrics in your Graphite server. +- Use more specific path prefixes to narrow the search scope. +- Check your Graphite server's index performance. + +## MetricTank-specific issues + +If you're using MetricTank as your Graphite backend, use the following troubleshooting steps. + +**Rollup indicator not appearing:** + +If the rollup indicator doesn't display when expected: + +- Verify **Metrictank** is selected as the Graphite backend type in the data source configuration. +- Ensure the **Rollup indicator** toggle is enabled. +- The indicator only appears when data aggregation actually occurs. + +**Unexpected data aggregation:** + +If you see unexpected aggregation in your data: + +- Check the rollup configuration in your MetricTank instance. +- Adjust the time range or use `consolidateBy()` to control aggregation behavior. +- Review the query processing metadata in the panel inspector for details on how data was processed. + +## Get additional help + +If you continue to experience issues: + +- Check the [Grafana community forums](https://community.grafana.com/) for similar issues and solutions. +- Review the [Graphite documentation](https://graphite.readthedocs.io/) for additional configuration options. +- Contact [Grafana Support](https://grafana.com/support/) if you're an Enterprise, Cloud Pro, or Cloud Advanced customer. + +When reporting issues, include the following information: + +- Grafana version +- Graphite version (for example, 1.1.x) and backend type (Default or MetricTank) +- Authentication method (Basic Auth, TLS, or none) +- Error messages (redact sensitive information) +- Steps to reproduce the issue +- Relevant configuration such as data source settings, timeout values, and Graphite version setting (redact passwords and other credentials) +- Sample query (if applicable, with sensitive data redacted) diff --git a/docs/sources/datasources/mssql/_index.md b/docs/sources/datasources/mssql/_index.md index a5e00da6dcb..b7ea159e972 100644 --- a/docs/sources/datasources/mssql/_index.md +++ b/docs/sources/datasources/mssql/_index.md @@ -99,12 +99,27 @@ refs: 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 + mssql-troubleshoot: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/mssql/troubleshooting/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/mssql/troubleshooting/ + postgres: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/postgres/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/postgres/ + mysql: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/mysql/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/mysql/ --- # Microsoft SQL Server (MSSQL) data source 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. +You can query and visualize data from any Microsoft SQL Server 2005 or newer, including Microsoft Azure SQL Database. Use this data source to create dashboards, explore SQL data, and monitor MSSQL-based workloads in real time. @@ -113,10 +128,33 @@ The following documentation helps you get started working with the Microsoft SQL - [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) +- [Troubleshoot Microsoft SQL Server data source issues](ref:mssql-troubleshoot) -## Get the most out of the data source +## Supported versions -After installing and configuring the Microsoft SQL Server data source, you can: +This data source supports the following Microsoft SQL Server versions: + +- Microsoft SQL Server 2005 and newer +- Microsoft Azure SQL Database +- Azure SQL Managed Instance + +Grafana recommends using the latest available service pack for your SQL Server version for optimal compatibility. + +## Key capabilities + +The Microsoft SQL Server data source supports: + +- **Time series queries:** Visualize metrics over time using the built-in time grouping macros. +- **Table queries:** Display query results in table format for any valid SQL query. +- **Template variables:** Create dynamic dashboards with variable-driven queries. +- **Annotations:** Overlay events from SQL Server on your dashboard graphs. +- **Alerting:** Create alerts based on SQL Server query results. +- **Stored procedures:** Execute stored procedures and visualize results. +- **Macros:** Simplify queries with built-in macros for time filtering and grouping. + +## Additional resources + +After configuring the Microsoft SQL Server data source, you can: - Create a wide variety of [visualizations](ref:visualizations) - Configure and use [templates and variables](ref:variables) @@ -124,3 +162,8 @@ After installing and configuring the Microsoft SQL Server data source, you can: - Add [annotations](ref:annotate-visualizations) - Set up [alerting](ref:alerting) - Optimize performance with [query caching](ref:query-caching) + +## Related data sources + +- [PostgreSQL](ref:postgres) - For PostgreSQL databases. +- [MySQL](ref:mysql) - For MySQL and MariaDB databases. diff --git a/docs/sources/datasources/mssql/configure/index.md b/docs/sources/datasources/mssql/configure/index.md index 7ce6398f1cc..f41deeb51dd 100644 --- a/docs/sources/datasources/mssql/configure/index.md +++ b/docs/sources/datasources/mssql/configure/index.md @@ -89,6 +89,26 @@ refs: destination: /docs/grafana//setup-grafana/configure-access/configure-authentication/azuread/#enable-azure-ad-oauth-in-grafana - pattern: /docs/grafana-cloud/ destination: /docs/grafana//setup-grafana/configure-access/configure-authentication/azuread/#enable-azure-ad-oauth-in-grafana + 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/ + alerting: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/ + mssql-troubleshoot: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/mssql/troubleshooting/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/mssql/troubleshooting/ --- # Configure the Microsoft SQL Server data source @@ -97,13 +117,28 @@ This document provides instructions for configuring the Microsoft SQL Server dat ## Before you begin -- Grafana comes with a built-in MSSQL data source plugin, eliminating the need to install a plugin. +Before configuring the Microsoft SQL Server data source, ensure you have the following: -- 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. +- **Grafana permissions:** You must have the `Organization administrator` role to configure data sources. 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. +- **A running SQL Server instance:** Microsoft SQL Server 2005 or newer, Azure SQL Database, or Azure SQL Managed Instance. -- Verify that data from MSSQL is being written to your Grafana instance. +- **Network access:** Grafana must be able to reach your SQL Server. The default port is `1433`. + +- **Authentication credentials:** Depending on your authentication method, you need one of: + - SQL Server login credentials (username and password). + - Windows/Kerberos credentials and configuration (not supported in Grafana Cloud). + - Azure Entra ID app registration or managed identity. + +- **Security certificates:** If using encrypted connections, gather any necessary TLS/SSL certificates. + +{{< admonition type="note" >}} +Grafana ships with a built-in Microsoft SQL Server data source plugin. No additional installation is required. +{{< /admonition >}} + +{{< admonition type="tip" >}} +**Grafana Cloud users:** If your SQL Server is in a private network, you can configure [Private data source connect](ref:private-data-source-connect) to establish connectivity. +{{< /admonition >}} ## Add the MSSQL data source @@ -382,3 +417,48 @@ datasources: secureJsonData: password: 'Password!' ``` + +### Configure with Terraform + +You can configure the Microsoft SQL Server data source using [Terraform](https://www.terraform.io/) with the [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs). + +For more information about provisioning resources with Terraform, refer to the [Grafana as code using Terraform](https://grafana.com/docs/grafana-cloud/developer-resources/infrastructure-as-code/terraform/) documentation. + +#### Terraform example + +The following example creates a basic Microsoft SQL Server data source: + +```hcl +resource "grafana_data_source" "mssql" { + name = "MSSQL" + type = "mssql" + url = "localhost:1433" + user = "grafana" + + json_data_encoded = jsonencode({ + database = "grafana" + maxOpenConns = 100 + maxIdleConns = 100 + maxIdleConnsAuto = true + connMaxLifetime = 14400 + connectionTimeout = 0 + encrypt = "false" + }) + + secure_json_data_encoded = jsonencode({ + password = "Password!" + }) +} +``` + +For all available configuration options, refer to the [Grafana provider data source resource documentation](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/data_source). + +## Next steps + +After configuring your Microsoft SQL Server data source, you can: + +- [Write queries](ref:mssql-query-editor) using the query editor to explore and visualize your data +- [Create template variables](ref:mssql-template-variables) to build dynamic, reusable dashboards +- [Add annotations](ref:annotate-visualizations) to overlay SQL Server events on your graphs +- [Set up alerting](ref:alerting) to create alert rules based on your SQL Server data +- [Troubleshoot issues](ref:mssql-troubleshoot) if you encounter problems with your data source diff --git a/docs/sources/datasources/mssql/troubleshooting/index.md b/docs/sources/datasources/mssql/troubleshooting/index.md new file mode 100644 index 00000000000..a62f3eb59e1 --- /dev/null +++ b/docs/sources/datasources/mssql/troubleshooting/index.md @@ -0,0 +1,333 @@ +--- +description: Troubleshoot common problems with the Microsoft SQL Server data source in Grafana +keywords: + - grafana + - MSSQL + - Microsoft + - SQL + - troubleshooting + - errors +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Troubleshooting +title: Troubleshoot Microsoft SQL Server data source issues +weight: 400 +refs: + 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/ + 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/ +--- + +# Troubleshoot Microsoft SQL Server data source issues + +This document provides solutions to common issues you may encounter when configuring or using the Microsoft SQL Server (MSSQL) data source in Grafana. + +## Connection errors + +These errors occur when Grafana cannot establish or maintain a connection to the Microsoft SQL Server. + +### Unable to connect to the server + +**Error message:** "Unable to open tcp connection" or "dial tcp: connection refused" + +**Cause:** Grafana cannot establish a network connection to the SQL Server. + +**Solution:** + +1. Verify that the SQL Server is running and accessible. +1. Check that the host and port are correct in the data source configuration. The default SQL Server port is `1433`. +1. Ensure there are no firewall rules blocking the connection between Grafana and SQL Server. +1. Verify that SQL Server is configured to allow remote connections. +1. For Grafana Cloud, ensure you have configured [Private data source connect](ref:private-data-source-connect) if your SQL Server instance is not publicly accessible. + +### Connection timeout + +**Error message:** "Connection timed out" or "I/O timeout" + +**Cause:** The connection to SQL Server timed out before receiving a response. + +**Solution:** + +1. Check the network latency between Grafana and SQL Server. +1. Verify that SQL Server is not overloaded or experiencing performance issues. +1. Increase the **Connection timeout** setting in the data source configuration under **Additional settings**. +1. Check if any network devices (load balancers, proxies) are timing out the connection. + +### Encryption-related connection failures + +**Error message:** "TLS handshake failed" or "certificate verify failed" + +**Cause:** There is a mismatch between the encryption settings in Grafana and what the SQL Server supports or requires. + +**Solution:** + +1. For older versions of SQL Server (2008, 2008R2), set the **Encrypt** option to **Disable** or **False** in the data source configuration. +1. Verify that the SQL Server has a valid SSL certificate if encryption is enabled. +1. Check that the certificate is trusted by the Grafana server. +1. Ensure you're using the latest available service pack for your SQL Server version for optimal compatibility. + +### Named instance connection issues + +**Error message:** "Cannot connect to named instance" or connection fails when using instance name + +**Cause:** Grafana cannot resolve the SQL Server named instance. + +**Solution:** + +1. Use the format `hostname\instancename` or `hostname\instancename,port` in the **Host** field. +1. Verify that the SQL Server Browser service is running on the SQL Server machine. +1. If the Browser service is unavailable, specify the port number directly: `hostname,port`. +1. Check that UDP port 1434 is open if using the SQL Server Browser service. + +## Authentication errors + +These errors occur when there are issues with authentication credentials or permissions. + +### Login failed for user + +**Error message:** "Login failed for user 'username'" or "Authentication failed" + +**Cause:** The authentication credentials are invalid or the user doesn't have permission to access the database. + +**Solution:** + +1. Verify that the username and password are correct. +1. Check that the user exists in SQL Server and is enabled. +1. Ensure the user has access to the specified database. +1. For Windows Authentication, verify that the credentials are in the correct format (`DOMAIN\User`). +1. Check that the SQL Server authentication mode allows the type of login you're using (SQL Server Authentication, Windows Authentication, or Mixed Mode). + +### Access denied to database + +**Error message:** "Cannot open database 'dbname' requested by the login" + +**Cause:** The authenticated user doesn't have permission to access the specified database. + +**Solution:** + +1. Verify that the database name is correct in the data source configuration. +1. Ensure the user is mapped to the database with appropriate permissions. +1. Grant at least `SELECT` permission on the required tables: + + ```sql + USE [your_database] + GRANT SELECT ON dbo.YourTable TO [your_user] + ``` + +1. Check that the user doesn't have any conflicting permissions from the public role. + +### Windows Authentication (Kerberos) issues + +**Error message:** "Kerberos authentication failed" or "Cannot initialize Kerberos" + +**Cause:** Kerberos configuration is incorrect or incomplete. + +**Solution:** + +1. Verify that the Kerberos configuration file (`krb5.conf`) path is correct in the data source settings. +1. For keytab authentication, ensure the keytab file exists and is readable by Grafana. +1. Check that the realm and KDC settings are correct in the Kerberos configuration. +1. Verify DNS is correctly resolving the KDC servers. +1. Ensure the service principal name (SPN) is registered for the SQL Server instance. + +{{< admonition type="note" >}} +Kerberos authentication is not supported in Grafana Cloud. +{{< /admonition >}} + +### Azure Entra ID authentication errors + +**Error message:** "AADSTS error codes" or "Azure AD authentication failed" + +**Cause:** Azure Entra ID (formerly Azure AD) authentication is misconfigured. + +**Solution:** + +1. For **App Registration** authentication: + - Verify the tenant ID, client ID, and client secret are correct. + - Ensure the app registration has been added as a user in the Azure SQL database. + - Check that the client secret hasn't expired. + +1. For **Managed Identity** authentication: + - Verify `managed_identity_enabled = true` is set in the Grafana server configuration. + - Ensure the managed identity has been added to the Azure SQL database. + - Confirm the Azure resource hosting Grafana has managed identity enabled. + +1. For **Current User** authentication: + - Ensure `user_identity_enabled = true` is set in the Grafana server configuration. + - Verify the app registration is configured to issue both Access Tokens and ID Tokens. + - Check that the required API permissions are configured (`user_impersonation` for Azure SQL). + +For detailed Azure authentication configuration, refer to [Configure the Microsoft SQL Server data source](ref:configure-mssql-data-source). + +## Query errors + +These errors occur when there are issues with query syntax or configuration. + +### Time column not found or invalid + +**Error message:** "Could not find time column" or time series visualization shows no data + +**Cause:** The query doesn't return a properly formatted `time` column for time series visualization. + +**Solution:** + +1. Ensure your query includes a column named `time` when using the **Time series** format. +1. Use the `$__time()` macro to rename your date column: `$__time(your_date_column)`. +1. Verify the time column is of a valid SQL date/time type (`datetime`, `datetime2`, `date`) or contains Unix epoch values. +1. Ensure the result set is sorted by the time column using `ORDER BY`. + +### Macro expansion errors + +**Error message:** "Error parsing query" or macros appear unexpanded in the query + +**Cause:** Grafana macros are being used incorrectly. + +**Solution:** + +1. Verify macro syntax: use `$__timeFilter(column)` not `$_timeFilter(column)`. +1. Macros don't work inside stored procedures—use explicit date parameters instead. +1. Check that the column name passed to macros exists in your table. +1. View the expanded query by clicking **Generated SQL** after running the query to debug macro expansion. + +### Timezone and time shift issues + +**Cause:** Time series data appears shifted or doesn't align with expected times. + +**Solution:** + +1. Store timestamps in UTC in your database to avoid timezone issues. +1. Time macros (`$__time`, `$__timeFilter`, etc.) always expand to UTC values. +1. If your timestamps are stored in local time, convert them to UTC in your query: + + ```sql + SELECT + your_datetime_column AT TIME ZONE 'Your Local Timezone' AT TIME ZONE 'UTC' AS time, + value + FROM your_table + ``` + +1. Don't pass timezone parameters to time macros—they're not supported. + +### Query returns too many rows + +**Error message:** "Result set too large" or browser becomes unresponsive + +**Cause:** The query returns more data than can be efficiently processed. + +**Solution:** + +1. Add time filters using `$__timeFilter(column)` to limit data to the dashboard time range. +1. Use aggregations (`AVG`, `SUM`, `COUNT`) with `GROUP BY` instead of returning raw rows. +1. Add a `TOP` clause to limit results: `SELECT TOP 1000 ...`. +1. Use the `$__timeGroup()` macro to aggregate data into time intervals. + +### Stored procedure returns no data + +**Cause:** Stored procedure output isn't being captured correctly. + +**Solution:** + +1. Ensure the stored procedure uses `SELECT` statements, not just variable assignments. +1. Remove `SET NOCOUNT ON` if present, or ensure it's followed by a `SELECT` statement. +1. Verify the stored procedure parameters are being passed correctly. +1. Test the stored procedure directly in SQL Server Management Studio with the same parameters. + +For more information on using stored procedures, refer to the [query editor documentation](ref:mssql-query-editor). + +## Performance issues + +These issues relate to slow queries or high resource usage. + +### Slow query execution + +**Cause:** Queries take a long time to execute. + +**Solution:** + +1. Reduce the dashboard time range to limit data volume. +1. Add indexes to columns used in `WHERE` clauses and time filters. +1. Use aggregations instead of returning individual rows. +1. Increase the **Min time interval** setting to reduce the number of data points. +1. Review the query execution plan in SQL Server Management Studio to identify bottlenecks. + +### Connection pool exhaustion + +**Error message:** "Too many connections" or "Connection pool exhausted" + +**Cause:** Too many concurrent connections to the database. + +**Solution:** + +1. Increase the **Max open** connection limit in the data source configuration. +1. Enable **Auto max idle** to automatically manage idle connections. +1. Reduce the number of panels querying the same data source simultaneously. +1. Check for long-running queries that might be holding connections. + +## Other common issues + +The following issues don't produce specific error messages but are commonly encountered. + +### System databases appear in queries + +**Cause:** Queries accidentally access system databases. + +**Solution:** + +1. The query editor automatically excludes `tempdb`, `model`, `msdb`, and `master` from the database dropdown. +1. Always specify the database in your data source configuration to restrict access. +1. Ensure the database user only has permissions on the intended database. + +### Template variable queries fail + +**Cause:** Variable queries return unexpected results or errors. + +**Solution:** + +1. Verify the variable query syntax is valid SQL that returns a single column. +1. Check that the data source connection is working. +1. Ensure the user has permission to access the tables referenced in the variable query. +1. Test the query in the query editor before using it as a variable query. + +### Data appears incorrect or misaligned + +**Cause:** Data formatting or type conversion issues. + +**Solution:** + +1. Use explicit column aliases to ensure consistent naming: `SELECT value AS metric`. +1. Verify numeric columns are actually numeric types, not strings. +1. Check for `NULL` values that might affect aggregations. +1. Use the `FILL` option in `$__timeGroup()` macro to handle missing data points. + +## Get additional help + +If you continue to experience issues after following this troubleshooting guide: + +1. Check the [Grafana community forums](https://community.grafana.com/) for similar issues. +1. Review the [Grafana GitHub issues](https://github.com/grafana/grafana/issues) for known bugs. +1. Enable debug logging in Grafana to capture detailed error information. +1. Check SQL Server logs for additional error details. +1. Contact Grafana Support if you're an Enterprise or Cloud customer. + +When reporting issues, include: + +- Grafana version +- SQL Server version +- Error messages (redact sensitive information) +- Steps to reproduce +- Relevant query examples (redact sensitive data) diff --git a/docs/sources/developer-resources/api-reference/http-api/dashboard.md b/docs/sources/developer-resources/api-reference/http-api/dashboard.md index abef6a8f5e6..1b5d32bb9f4 100644 --- a/docs/sources/developer-resources/api-reference/http-api/dashboard.md +++ b/docs/sources/developer-resources/api-reference/http-api/dashboard.md @@ -231,6 +231,10 @@ JSON Body schema: - **metadata.annotations.grafana.app/folder** - Optional field, the unique identifier of the folder under which the dashboard should be created. - **spec** – The dashboard json. +{{< admonition type="note" >}} +Custom labels and annotations in the metadata field are supported on some instances, with full support planned for all instances when these APIs reach general availability. If they are not yet supported on your instance, they will be ignored. +{{< /admonition >}} + **Example Response**: ```http @@ -521,6 +525,10 @@ JSON Body schema: - **metadata.annotations.grafana.app/message** - Optional field, to set a commit message for the version history. - **spec** – The dashboard json. +{{< admonition type="note" >}} +Custom labels and annotations in the metadata field are supported on some instances, with full support planned for all instances when these APIs reach general availability. If they are not yet supported on your instance, they will be ignored. +{{< /admonition >}} + **Example Response**: ```http diff --git a/docs/sources/developer-resources/api-reference/http-api/dashboard_versions.md b/docs/sources/developer-resources/api-reference/http-api/dashboard_versions.md index f4b46adad3f..370b1fed7e4 100644 --- a/docs/sources/developer-resources/api-reference/http-api/dashboard_versions.md +++ b/docs/sources/developer-resources/api-reference/http-api/dashboard_versions.md @@ -171,146 +171,3 @@ Status Codes: - **200** - Ok - **401** - Unauthorized - **404** - Dashboard version not found - -## Restore dashboard by dashboard UID - -`POST /api/dashboards/uid/:uid/restore` - -Restores a dashboard to a given dashboard version using `uid`. - -**Example request for restoring a dashboard version**: - -```http -POST /api/dashboards/uid/QA7wKklGz/restore -Accept: application/json -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - -{ - "version": 1 -} -``` - -JSON body schema: - -- **version** - The dashboard version to restore to - -**Example response**: - -```http -HTTP/1.1 200 OK -Content-Type: application/json; charset=UTF-8 -Content-Length: 67 - -{ - "id": 70, - "slug": "my-dashboard", - "status": "success", - "uid": "QA7wKklGz", - "url": "/d/QA7wKklGz/my-dashboard", - "version": 3 -} -``` - -JSON response body schema: - -- **slug** - the URL friendly slug of the dashboard's title -- **status** - whether the restoration was successful or not -- **version** - the new dashboard version, following the restoration - -Status codes: - -- **200** - OK -- **400** - Bad request (specified version has the same content as the current dashboard) -- **401** - Unauthorized -- **404** - Not found (dashboard not found or dashboard version not found) -- **500** - Internal server error (indicates issue retrieving dashboard tags from database) - -**Example error response** - -```http -HTTP/1.1 404 Not Found -Content-Type: application/json; charset=UTF-8 -Content-Length: 46 - -{ - "message": "Dashboard version not found" -} -``` - -JSON response body schema: - -- **message** - Message explaining the reason for the request failure. - -## Compare dashboard versions - -`POST /api/dashboards/calculate-diff` - -Compares two dashboard versions by calculating the JSON diff of them. - -**Example request**: - -```http -POST /api/dashboards/calculate-diff HTTP/1.1 -Accept: text/html -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - -{ - "base": { - "dashboardId": 1, - "version": 1 - }, - "new": { - "dashboardId": 1, - "version": 2 - }, - "diffType": "json" -} -``` - -JSON body schema: - -- **base** - an object representing the base dashboard version -- **new** - an object representing the new dashboard version -- **diffType** - the type of diff to return. Can be "json" or "basic". - -**Example response (JSON diff)**: - -```http -HTTP/1.1 200 OK -Content-Type: text/html; charset=UTF-8 - -

        - -

        -``` - -The response is a textual representation of the diff, with the dashboard values being in JSON, similar to the diffs seen on sites like GitHub or GitLab. - -Status Codes: - -- **200** - Ok -- **400** - Bad request (invalid JSON sent) -- **401** - Unauthorized -- **404** - Not found - -**Example response (basic diff)**: - -```http -HTTP/1.1 200 OK -Content-Type: text/html; charset=UTF-8 - -
        - -
        -``` - -The response here is a summary of the changes, derived from the diff between the two JSON objects. - -Status Codes: - -- **200** - OK -- **400** - Bad request (invalid JSON sent) -- **401** - Unauthorized -- **404** - Not found diff --git a/docs/sources/developer-resources/api-reference/http-api/folder.md b/docs/sources/developer-resources/api-reference/http-api/folder.md index f042a3a5a3e..6a6b18abeba 100644 --- a/docs/sources/developer-resources/api-reference/http-api/folder.md +++ b/docs/sources/developer-resources/api-reference/http-api/folder.md @@ -259,6 +259,10 @@ JSON Body schema: - **metadata.annotations.grafana.app/folder** - Optional field, the unique identifier of the parent folder under which the folder should be created. Requires nested folders to be enabled. - **spec.title** – The title of the folder. +{{< admonition type="note" >}} +Custom labels and annotations in the metadata field are supported on some instances, with full support planned for all instances when these APIs reach general availability. If they are not yet supported on your instance, they will be ignored. +{{< /admonition >}} + **Example Response**: ```http @@ -337,6 +341,10 @@ JSON Body schema: - **metadata.annotations.grafana.app/folder** - Optional field, the unique identifier of the parent folder under which the folder should be - update this to move the folder under a different parent folder. Requires nested folders to be enabled. - **spec.title** – The title of the folder. +{{< admonition type="note" >}} +Custom labels and annotations in the metadata field are supported on some instances, with full support planned for all instances when these APIs reach general availability. If they are not yet supported on your instance, they will be ignored. +{{< /admonition >}} + **Example Response**: ```http diff --git a/docs/sources/developer-resources/api-reference/http-api/library_element.md b/docs/sources/developer-resources/api-reference/http-api/library_element.md index ea241c220e1..ce367c485ef 100644 --- a/docs/sources/developer-resources/api-reference/http-api/library_element.md +++ b/docs/sources/developer-resources/api-reference/http-api/library_element.md @@ -41,7 +41,8 @@ Query parameters: - `sortDirection`: Sort order of elements. Use `alpha-asc` for ascending and `alpha-desc` for descending sort order. - `typeFilter`: A comma separated list of types to filter the elements by. - `excludeUid`: Element UID to exclude from search results. -- `folderFilter`: A comma separated list of folder IDs to filter the elements by. +- `folderFilter`: **Deprecated.** A comma separated list of folder IDs to filter the elements by. Use `folderFilterUIDs` instead. +- `folderFilterUIDs`: A comma separated list of folder UIDs to filter the elements by. - `perPage`: The number of results per page; default is 100. - `page`: The page for a set of records, given that only `perPage` records are returned at a time. Numbering starts at `1`. diff --git a/docs/sources/developer-resources/api-reference/http-api/preferences.md b/docs/sources/developer-resources/api-reference/http-api/preferences.md index 1cd350ee059..079fa1ebce9 100644 --- a/docs/sources/developer-resources/api-reference/http-api/preferences.md +++ b/docs/sources/developer-resources/api-reference/http-api/preferences.md @@ -25,7 +25,7 @@ Keys: - **theme** - One of: `light`, `dark`, or an empty string for the default theme - **homeDashboardId** - Deprecated. Use `homeDashboardUID` instead. - **homeDashboardUID**: The `:uid` of a dashboard -- **timezone** - One of: `utc`, `browser`, or an empty string for the default +- **timezone** - Any valid IANA timezone string (e.g., `America/New_York`, `Europe/London`), `utc`, `browser`, or an empty string for the default. Omitting a key will cause the current value to be replaced with the system default value. diff --git a/docs/sources/introduction/grafana-enterprise.md b/docs/sources/introduction/grafana-enterprise.md index 5500601829d..0fa0e059472 100644 --- a/docs/sources/introduction/grafana-enterprise.md +++ b/docs/sources/introduction/grafana-enterprise.md @@ -87,6 +87,7 @@ With a Grafana Enterprise license, you also get access to premium data sources, - [CockroachDB](/grafana/plugins/grafana-cockroachdb-datasource) - [Databricks](/grafana/plugins/grafana-databricks-datasource) - [DataDog](/grafana/plugins/grafana-datadog-datasource) +- [IBM Db2](/grafana/plugins/grafana-ibmdb2-datasource) - [Drone](/grafana/plugins/grafana-drone-datasource) - [DynamoDB](/grafana/plugins/grafana-dynamodb-datasource/) - [Dynatrace](/grafana/plugins/grafana-dynatrace-datasource) diff --git a/docs/sources/setup-grafana/configure-access/configure-authentication/anonymous-auth/index.md b/docs/sources/setup-grafana/configure-access/configure-authentication/anonymous-auth/index.md index 77df611f904..27c65e7bd02 100644 --- a/docs/sources/setup-grafana/configure-access/configure-authentication/anonymous-auth/index.md +++ b/docs/sources/setup-grafana/configure-access/configure-authentication/anonymous-auth/index.md @@ -38,13 +38,6 @@ Users can now view anonymous usage statistics, including the count of devices an The number of anonymous devices is not limited by default. The configuration option `device_limit` allows you to enforce a limit on the number of anonymous devices. This enables you to have greater control over the usage within your Grafana instance and keep the usage within the limits of your environment. Once the limit is reached, any new devices that try to access Grafana will be denied access. -To display anonymous users and devices for versions 10.2, 10.3, 10.4, you need to enable the feature toggle `displayAnonymousStats` - -```bash -[feature_toggles] -enable = displayAnonymousStats -``` - ## Configuration Example: @@ -67,3 +60,15 @@ device_limit = ``` If you change your organization name in the Grafana UI this setting needs to be updated to match the new name. + +## Licensing for anonymous access + +Grafana Enterprise (self-managed) licenses anonymous access as active users. + +Anonymous access lets people use Grafana without login credentials. It was an early way to share dashboards, but Public dashboards gives you a more secure way to share dashboards. + +### How anonymous usage is counted + +Grafana estimates anonymous active users from anonymous devices: + +- **Counting rule**: Grafana counts 1 anonymous user for every 3 anonymous devices detected. diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 67c361b2bdc..d9c5f524bd3 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -2030,6 +2030,44 @@ For example: `disabled_labels=grafana_folder`
        +### `[unified_alerting.state_history]` + +This section configures where Grafana Alerting writes alert state history. Refer to [Configure alert state history](/docs/grafana//alerting/set-up/configure-alert-state-history/) for end-to-end setup and examples. + +#### `enabled ` + +Enables recording alert state history. Default is `false`. + +#### `backend ` + +Select the backend used to store alert state history. Supported values: `loki`, `prometheus`, `multiple`. + +#### `loki_remote_url ` + +The URL of the Loki server used when `backend = loki` (or when `backend = multiple` and Loki is a primary/secondary). + +#### `prometheus_target_datasource_uid ` + +Target Prometheus data source UID used for writing alert state changes when `backend = prometheus` (or when `backend = multiple` and Prometheus is a secondary). + +#### `prometheus_metric_name ` + +Optional. Metric name for the alert state metric. Default is `GRAFANA_ALERTS`. + +#### `prometheus_write_timeout ` + +Optional. Timeout for writing alert state data to the target data source. Default is `10s`. + +#### `primary ` + +Used only when `backend = multiple`. Selects the primary backend (for example `loki`). + +#### `secondaries ` + +Used only when `backend = multiple`. Comma-separated list of secondary backends (for example `prometheus`). + +
        + ### `[unified_alerting.state_history.annotations]` This section controls retention of annotations automatically created while evaluating alert rules when alerting state history backend is configured to be annotations (see setting [unified_alerting.state_history].backend) @@ -2836,9 +2874,11 @@ For more information about Grafana Enterprise, refer to [Grafana Enterprise](../ Keys of features to enable, separated by space. -#### `FEATURE_TOGGLE_NAME = false` +#### `FEATURE_NAME = ` -Some feature toggles for stable features are on by default. Use this setting to disable an on-by-default feature toggle with the name FEATURE_TOGGLE_NAME, for example, `exploreMixedDatasource = false`. +Use a key-value pair to set feature flag values explicitly, overriding any default values. A few different types are supported, following the OpenFeature specification. See the defaults.ini file for more details. + +For example, to disable an on-by-default feature toggle named `exploreMixedDatasource`, specify `exploreMixedDatasource = false`.
        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 16c97f6d10a..813efb29eba 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -66,7 +66,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `sharingDashboardImage` | Enables image sharing functionality for dashboards | Yes | | `tabularNumbers` | Use fixed-width numbers globally in the UI | | | `azureResourcePickerUpdates` | Enables the updated Azure Monitor resource picker | Yes | -| `tempoSearchBackendMigration` | Run search queries through the tempo backend | | | `opentsdbBackendMigration` | Run queries through the data source backend | | ## Public preview feature toggles @@ -84,6 +83,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `reportingRetries` | Enables rendering retries for the reporting feature | | `externalServiceAccounts` | Automatic service account and token setup for plugins | | `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches | +| `dashboardNewLayouts` | Enables new dashboard layouts | | `pdfTables` | Enables generating table data as PDF in reporting | | `canvasPanelPanZoom` | Allow pan and zoom in canvas panel | | `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage. Default is enabled. | diff --git a/docs/sources/shared/datasources/tempo-editor-traceql.md b/docs/sources/shared/datasources/tempo-editor-traceql.md index 54496c339e3..003a449fd96 100644 --- a/docs/sources/shared/datasources/tempo-editor-traceql.md +++ b/docs/sources/shared/datasources/tempo-editor-traceql.md @@ -135,9 +135,12 @@ You can use the **Span Limit** field in **Options** section of the TraceQL query This field sets the maximum number of spans to return for each span set. By default, the maximum value that you can set for the **Span Limit** value (or the spss query) is 100. In Tempo configuration, this value is controlled by the `max_spans_per_span_set` parameter and can be modified by your Tempo administrator. -Grafana Cloud users can contact Grafana Support to request a change. Entering a value higher than the default results in an error. +{{< admonition type="note" >}} +Changing the value of `max_spans_per_span_set` isn't supported in Grafana Cloud. +{{< /admonition >}} + ### Focus on traces or spans Under **Options**, you can choose to display the table as **Traces** or **Spans** focused. diff --git a/docs/sources/shared/visualizations/panel-zoom.md b/docs/sources/shared/visualizations/panel-pan-zoom.md similarity index 65% rename from docs/sources/shared/visualizations/panel-zoom.md rename to docs/sources/shared/visualizations/panel-pan-zoom.md index 281f138fc63..72b089916ae 100644 --- a/docs/sources/shared/visualizations/panel-zoom.md +++ b/docs/sources/shared/visualizations/panel-pan-zoom.md @@ -4,7 +4,8 @@ comments: | This file is used in the following visualizations: candlestick, heatmap, state timeline, status history, time series. --- -You can zoom the panel time range in and out, which in turn, changes the dashboard time range. +You can pan the panel time range left and right, and zoom it and in and out. +This, in turn, changes the dashboard time range. **Zoom in** - Click and drag on the panel to zoom in on a particular time range. @@ -16,4 +17,9 @@ For example, if the original time range is from 9:00 to 9:59, the time range cha - Next range: 8:30 - 10:29 - Next range: 7:30 - 11:29 -For screen recordings showing these interactions, refer to the [Panel overview documentation](https://grafana.com/docs/grafana//visualizations/panels-visualizations/panel-overview/#zoom-panel-time-range). +**Pan** - Click and drag the x-axis area of the panel to pan the time range. + +The time range shifts by the distance you drag. +For example, if the original time range is from 9:00 to 9:59 and you drag 30 minutes to the right, the time range changes to 9:30 to 10:29. + +For screen recordings showing these interactions, refer to the [Panel overview documentation](https://grafana.com/docs/grafana//visualizations/panels-visualizations/panel-overview/#pan-and-zoom-panel-time-range). diff --git a/docs/sources/visualizations/dashboards/assess-dashboard-usage/index.md b/docs/sources/visualizations/dashboards/assess-dashboard-usage/index.md index 2030cd1b2d8..a220ba21b53 100644 --- a/docs/sources/visualizations/dashboards/assess-dashboard-usage/index.md +++ b/docs/sources/visualizations/dashboards/assess-dashboard-usage/index.md @@ -78,9 +78,9 @@ For every dashboard and data source, you can access usage information. ### Dashboard insights -To see dashboard usage information, click the dashboard insights icon in the header. +To see dashboard usage information, click the dashboard insights icon in the sidebar. -![Dashboard insights icon](/media/docs/grafana/dashboards/screenshot-dashboard-insights-icon-11.2.png) +{{< figure src="/media/docs/grafana/dashboards/screenshot-dashboard-insights-v12.4.png" max-width="500px" alt="Dashboard insights icon" >}} Dashboard insights show the following information: diff --git a/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md b/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md index 10196a40811..fd2deafa469 100644 --- a/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md +++ b/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md @@ -2,238 +2,423 @@ aliases: - ../../../dashboards/build-dashboards/add-organize-panels/ # /docs/grafana/next/dashboards/build-dashboards/add-organize-panels/ - ../../../dashboards/build-dashboards/create-dashboard/ # /docs/grafana/next/dashboards/build-dashboards/create-dashboard/ + - ../../../dashboards/build-dashboards/create-dynamic-dashboard/ # /docs/grafana/latest/dashboards/build-dashboards/create-dynamic-dashboard/ + - ./create-dynamic-dashboard/ # /docs/grafana/latest/visualizations/dashboards/build-dashboards/create-dynamic-dashboard/ keywords: - panel - dashboard - create + - dynamic dashboard labels: products: - cloud - enterprise - oss -menuTitle: Create a dashboard -title: Create a dashboard +title: Create dashboards description: Create and edit a dashboard weight: 1 -refs: - built-in-special-data-sources: - - pattern: /docs/grafana/ - destination: /docs/grafana//datasources/#special-data-sources - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/#special-data-sources - visualization-specific-options: - - pattern: /docs/grafana/ - destination: /docs/grafana//visualizations/panels-visualizations/visualizations/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/ - configure-standard-options: - - pattern: /docs/grafana/ - destination: /docs/grafana//visualizations/panels-visualizations/configure-standard-options/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-standard-options/ - configure-value-mappings: - - pattern: /docs/grafana/ - destination: /docs/grafana//visualizations/panels-visualizations/configure-value-mappings/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-value-mappings/ - generative-ai-features: - - pattern: /docs/grafana/ - destination: /docs/grafana//visualizations/dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards - configure-thresholds: - - pattern: /docs/grafana/ - destination: /docs/grafana//visualizations/panels-visualizations/configure-thresholds/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-thresholds/ - data-sources: - - pattern: /docs/grafana/ - destination: /docs/grafana//datasources/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/ - add-a-data-source: - - pattern: /docs/grafana/ - destination: /docs/grafana//datasources/#add-a-data-source - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//datasources/#add-a-data-source - about-users-and-permissions: - - pattern: /docs/grafana/ - destination: /docs/grafana//administration/roles-and-permissions/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//administration/roles-and-permissions/ - visualizations-options: - - pattern: /docs/grafana/ - destination: /docs/grafana//visualizations/panels-visualizations/visualizations/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//panels-visualizations/visualizations/ - configure-repeating-panels: - - pattern: /docs/grafana/ - destination: /docs/grafana//visualizations/panels-visualizations/configure-panel-options/#configure-repeating-panels - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-panel-options/#configure-repeating-panels - override-field-values: - - pattern: /docs/grafana/ - destination: /docs/grafana//visualizations/panels-visualizations/configure-overrides/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-overrides/ - saved-queries: - - pattern: /docs/grafana/ - destination: /docs/grafana//visualizations/panels-visualizations/query-transform-data/#saved-queries - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/#saved-queries - save-query: - - pattern: /docs/grafana/ - destination: /docs/grafana//visualizations/panels-visualizations/query-transform-data/#save-a-query - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/#save-a-query +image_maps: + - key: editpane-sidebar + src: /media/docs/grafana/dashboards/screenshot-edit-sidebar-v12.4.png + alt: An annotated image of the edit pane and sidebar + points: + - x_coord: 96 + y_coord: 17 + content: | + **Dashboard options** + + Click the icon to open the edit pane. Edit mode only. + - x_coord: 96 + y_coord: 25 + content: | + **Feedback** + + Submit feedback on the new editing experience. Edit mode only. + - x_coord: 96 + y_coord: 33 + content: | + **Export** + + Click to display [export](https://grafana.com/docs/grafana//visualizations/dashboards/share-dashboards-panels/#export-dashboards) options. + - x_coord: 96 + y_coord: 41 + content: | + **Content outline** + + Navigate a dashboard using the [Content outline](#navigate-using-the-content-outline). + - x_coord: 96 + y_coord: 49 + content: | + **Dashboard insights** + + View [dashboard analytics](https://grafana.com/docs/grafana//visualizations/dashboards/assess-dashboard-usage/) including information about users, activity, and query counts. --- -## Create a dashboard +# Create dashboards -Dashboards and panels allow you to show your data in visual form. Each panel needs at least one query to display a visualization. +{{< admonition type="note">}} +Dynamic dashboards is currently in public preview. Grafana Labs offers limited support, and breaking changes might occur prior to the feature being made generally available. + +For information on the generally available dashboard creation experience, refer to the [documentation for the latest self-managed version of Grafana](https://grafana.com/docs/grafana/latest/visualizations/dashboards/build-dashboards/create-dashboard/). +{{< /admonition >}} + +Dashboards and panels allow you to show your data in visual form. +Each panel needs at least one query to display a visualization. **Before you begin:** -- Ensure that you have the proper permissions. For more information about permissions, refer to [About users and permissions](ref:about-users-and-permissions). -- Identify the dashboard to which you want to add the panel. +- Ensure that you have the proper permissions. For more information about permissions, refer to [About users and permissions](https://grafana.com/docs/grafana//administration/roles-and-permissions/). - Understand the query language of the target data source. -- Ensure that data source for which you are writing a query has been added. For more information about adding a data source, refer to [Add a data source](ref:add-a-data-source) if you need instructions. + +## Create a dashboard To create a dashboard, follow these steps: -{{< shared id="create-dashboard" >}} - 1. Click **Dashboards** in the main menu. 1. Click **New** and select **New Dashboard**. -1. On the empty dashboard, click **+ Add visualization**. - - ![Empty dashboard state](/media/docs/grafana/dashboards/empty-dashboard-10.2.png) - -{{< /shared >}} - +1. Click **+ Add visualization**. 1. In the dialog box that opens, do one of the following: - Select one of your existing data sources. - - Select one of the Grafana [built-in special data sources](ref:built-in-special-data-sources). + - Select one of the Grafana [built-in special data sources](https://grafana.com/docs/grafana//datasources/#special-data-sources). - Click **Configure a new data source** to set up a new one (Admins only). {{< figure class="float-right" src="/media/docs/grafana/dashboards/screenshot-data-source-selector-10.0.png" max-width="800px" alt="Select data source modal" >}} The **Edit panel** view opens with your data source selected. - You can change the panel data source later using the drop-down in the **Queries** tab of the panel editor if needed. + You can change the panel data source later using the drop-down in the **Query** tab of the panel editor if needed. - For more information about data sources, refer to [Data sources](ref:data-sources) for specific guidelines. + For more information about data sources, refer to [Data sources](https://grafana.com/docs/grafana//datasources/) for specific guidelines. 1. To create a query, do one of the following: - Write or construct a query in the query language of your data source. - - Open the **Saved queries** drop-down menu and click **Replace query** to reuse a [saved query](ref:saved-queries). + - Open the **Saved queries** drop-down menu and click **Replace query** to reuse a [saved query](https://grafana.com/docs/grafana//visualizations/panels-visualizations/query-transform-data/#saved-queries). -1. (Optional) To [save the query](ref:save-query) for reuse, open the **Saved queries** drop-down menu and click the **Save query** option. -1. Click **Refresh** to query the data source. -1. (Optional) To add subsequent queries, click **+ Add query** or **+ Add from saved queries**, and refresh the data source as many times as needed. +1. (Optional) To [save the query](https://grafana.com/docs/grafana//visualizations/panels-visualizations/query-transform-data/#save-a-query) for reuse, open the **Saved queries** drop-down menu and click the **Save query** option. {{< admonition type="note" >}} - [Saved queries](ref:saved-queries) is currently in [public preview](https://grafana.com/docs/release-life-cycle/) in Grafana Enterprise and Grafana Cloud only. + [Saved queries](https://grafana.com/docs/grafana//visualizations/panels-visualizations/query-transform-data/#saved-queries) is currently in [public preview](https://grafana.com/docs/release-life-cycle/) in Grafana Enterprise and Grafana Cloud only. {{< /admonition >}} +1. Click **Refresh** to query the data source. 1. In the visualization list, select a visualization type. - ![Visualization selector](/media/docs/grafana/dashboards/screenshot-select-visualization-11-2.png) + {{< figure src="/media/docs/grafana/dashboards/screenshot-select-visualization-v12.png" max-width="350px" alt="Visualization selector" >}} Grafana displays a preview of your query results with the visualization applied. - For more information about individual visualizations, refer to [Visualizations options](ref:visualizations-options). + For more information about configuring individual visualizations, refer to [Visualizations options](https://grafana.com/docs/grafana//visualizations/panels-visualizations/visualizations/). -1. Under **Panel options**, enter a title and description for your panel or have Grafana create them using [generative AI features](ref:generative-ai-features). +1. Under **Panel options**, enter a title and description for the panel or have Grafana create them using [generative AI features](https://grafana.com/docs/grafana//visualizations/dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards). 1. Refer to the following documentation for ways you can adjust panel settings. While not required, most visualizations need some adjustment before they properly display the information that you need. - - [Configure value mappings](ref:configure-value-mappings) - - [Visualization-specific options](ref:visualization-specific-options) - - [Override field values](ref:override-field-values) - - [Configure thresholds](ref:configure-thresholds) - - [Configure standard options](ref:configure-standard-options) + - [Configure value mappings](https://grafana.com/docs/grafana//visualizations/panels-visualizations/configure-value-mappings/) + - [Visualization-specific options](https://grafana.com/docs/grafana//visualizations/panels-visualizations/visualizations/) + - [Override field values](https://grafana.com/docs/grafana//visualizations/panels-visualizations/configure-overrides/) + - [Configure thresholds](https://grafana.com/docs/grafana//visualizations/panels-visualizations/configure-thresholds/) + - [Configure standard options](https://grafana.com/docs/grafana//visualizations/panels-visualizations/configure-standard-options/) -1. When you've finished editing your panel, click **Save dashboard**. - - Alternatively, click **Back to dashboard** if you want to see your changes applied to the dashboard first. Then click **Save dashboard** when you're ready. - -1. Enter a title and description for your dashboard or have Grafana create them using [generative AI features](ref:generative-ai-features). +1. When you've finished editing the panel, click **Save**. +1. Enter a title and description for the dashboard if you haven't already or have Grafana create them using [generative AI features](https://grafana.com/docs/grafana//visualizations/dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards). 1. Select a folder, if applicable. +1. Click **Save** +1. Click **Back to dashboard**. +1. (Optional) Continue building the dashboard by clicking one or more of the following options: + - **+ Add panel**: Set panel options in the edit pane or click **Configure** to complete panel setup. + - **+ Add variable**: Follow the steps to [add a variable to the dashboard](#add-variables). + - **Group panels**: Choose from **Group into row** or **Group into tab**. For more information on groupings, refer to [Panel groupings](#panel-groupings). + - **Dashboard options** icon: Open the edit pane to access [panel layout options](#panel-layouts). + +1. When you've finished making changes, click **Save**. +1. (Optional) Enter a description of the changes you've made. 1. Click **Save**. -1. To add more panels to the dashboard, click **Back to dashboard**. - Then click **Add** in the dashboard header and select **Visualization** in the drop-down. +1. Click **Exit edit**. - ![Add drop-down](/media/docs/grafana/dashboards/screenshot-add-dropdown-11.2.png) +## Dashboard edit - When you add additional panels to the dashboard, you're taken straight to the **Edit panel** view. +Now that you've created a basic dashboard, you can augment it with more options. +You can make several updates without leaving the dashboard by using the edit pane, which is explained in the next section. -1. When you've saved all the changes you want to make to the dashboard, click **Exit edit**. +### The edit pane and sidebar - Now, when you want to make more changes to the saved dashboard, click **Edit** in the top-right corner. +The _edit pane_ allows you to make changes without leaving the dashboard, by displaying options associated with the part of the dashboard that's in focus. +The _sidebar_ is on the next to the edit pane, and it includes options that are useful to have available all the time. -### Begin dashboard creation from data source configuration +The following image shows the parts of the edit pane and the sidebar. +Hover your cursor over the numbers to display descriptions of the sidebar options (descriptions also follow the image): -You can start the process of creating a dashboard directly from a data source rather than from the **Dashboards** page. +{{< image-map key="editpane-sidebar" >}} -To begin building a dashboard directly from a data source, follow these steps: +{{< admonition type="note" >}} +The sidebar is displayed in both edit and view mode, but the **Dashboard options** and **Feedback** icons aren't available in view mode. +{{< /admonition >}} -1. Navigate to **Connections > Data sources**. -1. On the row of the data source for which you want to build a dashboard, click **Build a dashboard**. +You can dock, undock, and resize the edit pane. +When the edit pane is closed, you can resize the sidebar so the icon names are visible. - The empty dashboard page opens. +{{< video-embed src="/media/docs/grafana/dashboards/screenrecord-edit-side-v12.4.mp4" >}} +The available configuration options in the edit pane differ depending on the selected dashboard element: + +- Dashboards: High-level options are in the edit pane and further configuration options are in the **Settings** page. +- Groupings (rows and tabs): All configuration options are available in the edit pane. +- Panels: High-level options are in the edit pane and further configuration options are in the **Edit panel** view. + +### Navigate using the content outline + +The **Content outline** provides a tree-like structure that shows you all the parts of the dashboard and their relationships to each other, including panels, rows, tabs, and variables. +The outline also lets you quickly navigate the dashboard and is available in both view and edit modes (note that variables are only included in edit mode). + +{{< figure src="/media/docs/grafana/dashboards/screenshot-content-outline-v12.4.png" max-width="750px" alt="Dashboard with outline open" >}} + +To navigate the dashboard using the outline, follow these steps: + +1. Navigate to the dashboard you want to view or update. +1. In the right sidebar, click the **Content outline** icon to open it. +1. Expand the outline to find the part of the dashboard you want to view or update. +1. Click the tree item to navigate that part of the dashboard. + +### Edit a dashboard + +To edit a dashboard, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Click the part of the dashboard you want to update to open the edit pane, or click the **Dashboard options** icon to open it. + + If the dashboard is large, open the **Content outline** and use it to navigate to the part of the dashboard you want to update. + +1. Update the dashboard as needed. +1. When you've finished making changes, click **Save**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Back to dashboard**, if needed. +1. Click **Exit edit** + +## Panel layouts + +Panel layouts control the size and arrangement of panels in the dashboard. +There are two panel layout options: + +- **Custom**: You can position and size panels individually. This is the default selection for a new dashboard. **Show/hide rules** are not supported. +- **Auto grid**: Panels resize and fit automatically to create a uniform grid. You can't make manual changes to this layout. **Show/hide rules** are supported. + +You can use both layouts in row or tab groupings. + +### Auto grid layout + +In the auto grid layout, panels are automatically sized and positioned as you add them. +There are default parameters to constrain the layout, and you can update these to have more control over the display: + +- **Min column width**: Choose from **Standard**, **Narrow**, **Wide**, or **Custom**, for which you can enter the minimum width in pixels. +- **Max columns**: Set a number up to 10. +- **Row height**: Choose from **Standard**, **Short**, **Tall**, and **Custom**, for which you can enter the row height in pixels. +- **Fill screen**: Toggle the switch on to have the panel fill the entire height of the screen. If the panel is in a row, the **Fill screen** toggle for the row must also be enabled (refer to [grouping configuration options](#grouping-configuration-options)). + +### Update panel layout + +To update the panel layout, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Click the dashboard or the grouping that contains the panel layout you want to update. +1. Click the **Dashboard options** icon to open the edit pane, if needed. +1. Under **Layout**, select **Custom** or **Auto grid**. +1. Click **Save**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit** + +## Panel groupings + +To help create meaningful sections in your dashboard, you can group panels into rows or tabs. +Rows and tabs let you break up big dashboards or make one dashboard out of several smaller ones. + +You can think of the dashboard as a series of nested containers: the dashboard is the largest container and it contains panels, rows, or tabs. +Rows and tabs are the next largest containers, and they contain panels. + +You can also nest: + +- Rows in a row +- Rows in a tab +- Tabs in a row + +You can nest up to two levels deep, which means a dashboard can have a maximum of four configuration levels: + +- Dashboard +- Grouping 1 - Row or tab +- Grouping 2 - Row or tab +- Panels + +You can only have one type of grouping at each level. +Inside of those groupings however, you have to freedom to add different elements. +Also, custom and auto grid panel layouts are supported for rows and tabs, so each grouping can have a different panel layout. + + + +The following sections describe: + +- [Grouping configuration options](#grouping-configuration-options) +- [Grouping layouts](#grouping-layouts) +- [How to group panels](#group-panels) +- [How to ungroup panels](#ungroup-panels) + +### Grouping configuration options + +The following table describes the options you can set for a row or tab: + + + +| Option | Description | +| ----------------| --------------------------------------------------------------------------- | +| Title | Title of the row or tab. | +| Fill screen | Toggle the switch on to make the row fill the screen. Rows only. | +| Hide row header | Toggle the switch on to hide row headers in view mode. In edit mode, the row header is visible, but crossed out with the hidden icon next to it. Rows only. | +| Layout | Select the layout. If the grouping contains another grouping, choose from **Rows** or **Tabs**. If the grouping contains panels, choose from **Custom** or **Auto grid**. For more information, refer to [Panel layouts](#panel-layouts) or [Grouping layouts](#grouping-layouts). | +| Repeat options > [Repeat by variable](#configure-repeat-options) | Configure the dashboard to dynamically add panels, rows, or tabs based on the value of a variable. | +| Show / hide rules > [Panel/Row/Tab visibility](#configure-showhide-rules) | Control whether or not panels, rows, or tabs are displayed based on variable values, a time range, or query results (panels only). | + + + +### Grouping layouts + +When you have panels grouped into rows or tabs, the **Layout** options available depend on which dashboard element is selected and the nesting level of that element. + +You can nest up to two levels deep, which means a dashboard can have a maximum of four configuration levels, with the following layout options: + +- **Dashboard**: Layout options allow you to choose between rows or tabs. +- **Grouping 1 (outer)**: Layout options allow you to choose between rows or tabs. +- **Grouping 2 (inner)**: Layout options allow you to choose between custom and auto grid (refer to [Panel layouts](#panel-layouts)). +- **Panels**: No layout options + +You can switch between rows and tabs or update the panel layout by clicking the parent container and changing the layout selection. + +### Group panels + +To group panels, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Under a panel, click **Group panels**. + + While grouping is typically used for multiple panels, you can start a grouping with just one panel. + +1. Select **Group into row** or **Group into tab**. + + All the panels are moved into the grouping, and a dotted blue line surrounds the row or tab. + The edit pane opens, displaying the relevant options. + +1. Set the [grouping configuration options](#grouping-configuration-options) in the edit pane. +1. (Optional) Add one or both of the following: + - A [nested grouping](#add-nested-groupings) + - Other [groupings at the same level](#add-more-groupings-at-the-same-level). + +1. Click **Save**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit**. + +#### Add nested groupings + +To add a second-level (or nested) grouping, follow these steps: + +1. In the existing grouping, under the panels, click **Group panels**. + + {{< figure src="/media/docs/grafana/dashboards/screenshot-nest-group-v12.4.png" alt="Adding a nested grouping" max-width="500px" >}} + +1. Click **Group into row** or **Group into tab** (**Group into tab** is only available if the parent grouping is a row). + + The new grouping is added inside the first grouping, and the panels are moved into the nested grouping. + The edit pane opens displaying the relevant options. + +1. Set the configuration options for the nested grouping. +1. Click **Save**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit**. + +#### Add more groupings at the same level + +To add more first-level groupings, follow these steps: + +1. On the dashboard, outside the existing first-level grouping, click **New row** or **New tab** (only one option will be available). + + {{< figure src="/media/docs/grafana/dashboards/screenshot-add-group-v12.4.png" alt="Adding a nested grouping" max-width="500px" >}} + +1. Set the configuration options for the new grouping. +1. Click **+ Add panel** to begin adding panels. +1. Click **Save**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit**. + +### Ungroup panels + +You can ungroup some or all of the dashboard groupings without losing your panels. +Ungrouping behavior depends on whether you're working with first-level or nested groupings: + +| Grouping | Action and outcome | +| ---------- | -------------------------------------------------------------------------------------------------- | +| Rows | **Ungroup rows** ungroups all first-level rows in the dashboard and all of their nested groupings. | +| Tabs | **Ungroup tabs** ungroups all first-level tabs in the dashboard and all of their nested groupings. | +| Row > row | **Ungroup rows** ungroups the nested row. | +| Row > tabs | **Ungroup tabs** ungroups all the nested tabs in that row. Tabs in other rows are not affected. | +| Tab > rows | **Ungroup rows** ungroups all the nested rows in that tab. Rows in other tabs are not affected. | + +{{< figure src="/media/docs/grafana/dashboards/screenshot-ungrouping-v12.4.png" alt="Dashboard with ungrouping behavior annotated" max-width="750px" >}} + +{{< admonition type="caution" >}} +If you delete a grouping, rather than ungrouping it, its panels are deleted as well. +{{< /admonition >}} + +To remove groupings, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. (Optional) Click the **Content outline** icon to quickly navigate to the grouping you want to remove. 1. Do one of the following: - - Click **+Add visualization** to configure all the elements of the new dashboard. - - Select one of the suggested dashboards by clicking its **Use dashboard** button. This can be helpful when you're not sure how to most effectively visualize your data. - The suggested dashboards are specific to your data source type (for example, Prometheus, Loki, or Elasticsearch). If there are more than three dashboard suggestions, you can click **View all** to see the rest of them. - - ![Empty dashboard with add visualization and suggested dashboard options](/media/docs/grafana/dashboards/screenshot-suggested-dashboards-v12.3.png) - - {{< docs/public-preview product="Suggested dashboards" >}} - -1. Complete the rest of the dashboard configuration. For more detailed steps, refer to [Create a dashboard](#create-a-dashboard), beginning at step five. - -## Copy a dashboard - -To copy a dashboard, follow these steps: - -1. Click **Dashboards** in the main menu. -1. Open the dashboard you want to copy. -1. Click **Edit** in top-right corner. -1. Click the **Save dashboard** drop-down and select **Save as copy**. -1. (Optional) Specify the name, folder, description, and whether or not to copy the original dashboard tags for the copied dashboard. - - By default, the copied dashboard has the same name as the original dashboard with the word "Copy" appended and is in the same folder. + - Click **Ungroup rows** or **Ungroup tabs** at the bottom of the dashboard to ungroup all rows or tabs, including any nested groupings. + - Click in a grouping and click **Ungroup rows** or **Ungroup tabs** to ungroup only the tabs or rows nested in that grouping. +1. If you've ungrouped panels that were previously in different panel layouts, you'll be prompted to select a common layout type for all the panels; click **Convert to Auto grid** or **Convert to Custom**. 1. Click **Save**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit**. -## Configure repeating rows +## Configure repeat options -You can configure Grafana to dynamically add panels or rows to a dashboard based on the value of a variable. Variables dynamically change your queries across all rows in a dashboard. For more information about repeating panels, refer to [Configure repeating panels](ref:configure-repeating-panels). +You can configure Grafana to dynamically add panels, rows, or tabs to a dashboard based on the value of a variable. +Variables dynamically change your queries across all panels, rows, or tabs in a dashboard. -To see an example of repeating rows, refer to [Dashboard with repeating rows](https://play.grafana.org/d/000000153/repeat-rows). The example shows that you can also repeat rows if you have variables set with `Multi-value` or `Include all values` selected. +This only applies to queries that include a multi-value variable. -**Before you begin:** +To configure repeats, follow these steps: -- Ensure that the query includes a multi-value variable. +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Click the panel, row, or tab you want to update to open the edit pane, or click the **Dashboard options** icon to open it. -**To configure repeating rows:** + If the dashboard is large, open the **Content outline** and use it to navigate to the part of the dashboard you want to update. -1. Click **Dashboards** in the main menu. -1. Navigate to the dashboard you want to work on. -1. At the top of the dashboard, click **Add** and select **Row** in the drop-down. +1. Expand the **Repeat options** section. +1. Select the **Repeat by variable**. +1. For panels in a custom layout, set the following options: + 1. Under **Repeat direction**, choose one of the following: + - **Horizontal** - Arrange panels side-by-side. Grafana adjusts the width of a repeated panel. You can’t mix other panels on a row with a repeated panel. + - **Vertical** - Arrange panels in a column. The width of repeated panels is the same as the original, repeated panel. + 1. If you selected **Horizontal**, select a value in the **Max per row** drop-down list to control the maximum number of panels that can be in a row. - If the dashboard is empty, you can click the **+ Add row** button in the middle of the dashboard. +1. (Optional) To provide context to dashboard users, add the variable name to the panel, row, or tab title. +1. When you've finished setting the repeat option, click **Save**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit**. -1. Hover over the row title and click the cog icon. -1. In the **Row Options** dialog box, add a title and select the variable for which you want to add repeating rows. -1. Click **Update**. +### Repeating rows and tabs and the Dashboard special data source -To provide context to dashboard users, add the variable to the row title. - -### Repeating rows and the Dashboard special data source - -If a row includes panels using the special [Dashboard data source](ref:built-in-special-data-sources)—the data source that uses a result set from another panel in the same dashboard—then corresponding panels in repeated rows will reference the panel in the original row, not the ones in the repeated rows. +If a row includes panels using the special [Dashboard data source](https://grafana.com/docs/grafana//datasources/#special-data-sources)—the data source that uses a result set from another panel in the same dashboard—then corresponding panels in repeated rows will reference the panel in the original row, not the ones in the repeated rows. +The same behavior applies to tabs. For example, in a dashboard: @@ -242,28 +427,196 @@ For example, in a dashboard: - Repeating row, `Row 2`, includes `Panel 2A` and `Panel 2B` - `Panel 2B` references `Panel 1A`, not `Panel 2A` -## Move a panel +## Show/hide rules -You can place a panel on a dashboard in any location. +You can configure panels, rows, and tabs to be shown or hidden based on rules. +For example, you can set a panel to be hidden if there's no data returned by a query or a tab to only be shown if a specific variable value is present. -1. Click **Dashboards** in the main menu. -1. Navigate to the dashboard you want to work on. -1. Click **Edit** in the top-right corner. -1. Click the panel title and drag the panel to the new location. -1. Click **Save dashboard**. +There are three types of show/hide rules to choose from: + +- [Query result](#query-result-rule) +- [Template variable](#template-variable-rule) +- [Time range less than](#time-range-less-than-rule) + +For steps on how to create show/hide rules, refer to [Configure show/hide rules](#configure-showhide-rules). + +{{< admonition type="note" >}} +You can only configure show/hide rules for panels in the **Auto grid** layout. Set the panel layout at the dashboard, row, or tab-level. +{{< /admonition >}} + +### Query result rule + +Show or hide a panel based on whether or not the query returns any results. +The rule provides **Has data** and **No data** options, so you can choose to show or hide the panel based on the presence or absence of data. + +For example, if you have a dashboard with several panels and only want panels that return data to appear, set the rule as follows: + +- Panel visibility > Show +- Query result > Has data + +Alternatively, you might also want to troubleshoot a dashboard with several panels to see which ones contain broken queries that aren't returning any results. +In this case, you'd set the rule as follows: + +- Panel visibility > Show +- Query result > No data + +### Template variable rule + +Show or hide a panel, row, or tab dynamically based on the variable value. +You can select any variable that's configured for the dashboard and choose from the following operators for maximum flexibility: + +- Equals +- Not equals +- Matches (regular expression values) +- Not matches (regular expression values) + +You can [add more variables](#add-variables) if you need to without leaving the dashboard. + +### Time range less than rule + +Show or hide a panel, row, or tab if the dashboard time range is shorter than the selected time range. +This ensures that as you change the time range of the dashboard, you only see data relevant to that time period. + +For example, a dashboard is tracking adoption of a feature over time has the following setup: + +- Dashboard time range is **Last 7 days** +- One panel tracks weekly stats +- One panel tracks daily stats + +For the panel tracking weekly stats, a rule is set up to hide it if the dashboard time range is less than 7 days. +For the panel tracking daily stats, a rule is set up to hide it if the dashboard time range is less 24 hours. +This configuration ensures that these time-based panels are only displayed when enough time has passed to make them relevant. + +For this rule type, you can select time ranges from **5 minutes** to **5 years**. + +### Configure show/hide rules + +To configure show/hide rules, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Click the panel, row, or tab you want to update to open the edit pane, or click the **Dashboard options** icon to open it. + + If the dashboard is large, open the **Content outline** and use it to navigate to the part of the dashboard you want to update. + +1. Expand the **Show / hide rules** section. +1. Select **Show** or **Hide** to set whether the panel, row, or tab is shown or hidden based on the rules outcome. +1. Click **+ Add rule**. +1. Select a rule type: + - **Query result**: Show or hide a panel based on query results. Choose from **Has data** and **No data**. + - **Template variable**: Show or hide the panel, row, or tab dynamically based on the variable value. Select a variable and operator and enter a value. + - **Time range less than**: Show or hide the panel, row, or tab if the dashboard time range is shorter than the selected time range. Select a time range from **5 minutes** to **5 years**. + +1. If you've configured more than rule, under **Match rules**, select one of the following: + - **Match all**: The panel, row, or tab is shown or hidden only if _all_ the rules are matched. + - **Match any**: The panel, row, or tab is shown or hidden if _any_ of the rules are matched. + + This option is only displayed if you add multiple rules. + +1. When you've finished setting rules, click **Save**. 1. (Optional) Enter a description of the changes you've made. 1. Click **Save**. -1. Click **Exit edit**. +1. Click **Exit edit** + +Hidden panels, rows, or tabs aren't visible when the dashboard is in view mode. +In edit mode, hidden dashboard elements are displayed with an icon or overlay indicating this. + +## Move a panel + +To move a panel, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Navigate to the panel you want to move. + + If the dashboard is large, open the **Content outline** and use it to navigate to the panel. + +1. Click the panel title and drag the panel to another row or tab, or to a new position on the dashboard. + + If the dashboard has groupings, you can only move the panel to another grouping. + +1. Click **Save**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit** ## Resize a panel -You can size a dashboard panel to suits your needs. +When your dashboard or grouping has a **Custom** layout, you can manually resize a panel. -1. Click **Dashboards** in the main menu. -1. Navigate to the dashboard you want to work on. -1. Click **Edit** in the top-right corner. -1. To adjust the size of the panel, click and drag the lower-right corner of the panel. -1. Click **Save dashboard**. +To resize a panel, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Navigate to the panel you want to resize. + + If the dashboard is large, open the **Content outline** and use it to navigate to the panel. + +1. Click and drag the lower-right corner of the panel to change the size of the panel. +1. Click **Save**. 1. (Optional) Enter a description of the changes you've made. 1. Click **Save**. -1. Click **Exit edit**. +1. Click **Exit edit** + +## Add variables + +To add variables without leaving the dashboard, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Click **+ Add variable** at the top of the dashboard. +1. Choose a variable type from the list. +1. Set the options for the variable. +1. Click **Save**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit** + +### Add variables using the content outline + +You can also add variables without leaving the dashboard using the content outline. + +To access the variables creation flow this way, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Click the **Content outline** icon. +1. Click **Variables** in the outline. +1. Click **+ Add variable**. +1. Complete the rest of the steps to [add a variable without leaving the dashboard](#add-variables). + +## Copy or duplicate dashboard elements + +You can copy and paste or duplicate panels, rows, and tabs. + +To copy or duplicate dashboard elements, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Click the panel, row, or tab you want to update to open the edit pane, or click the **Dashboard options** icon to open it. + + If the dashboard is large, open the **Content outline** and use it to navigate to the part of the dashboard you want to update. + +1. In the top-corner of the edit pane, click the **Copy or Duplicate** icon and do one of the following: + - Click **Copy**. + - Click **Duplicate**. The duplicated element is added next to the original one. Proceed to step 6. + +1. If you selected **Copy**, navigate to the part of the dashboard where you want to add the copied element, and click **Paste panel**, **Paste row**, or **Paste tab**. +1. Update the copied or duplicated element if needed. +1. Click **Save**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit** + +## Copy a dashboard + +To make a copy of a dashboard, follow these steps: + +1. Navigate to the dashboard you want to update. +1. Click **Edit**. +1. Click the **Save** drop-down list and select **Save as copy**. +1. (Optional) Specify the name, folder, description, and whether or not to copy the original dashboard tags for the copied dashboard. + + By default, the copied dashboard has the same name as the original dashboard with the word "Copy" appended and is in the same folder. + +1. Click **Save**. diff --git a/docs/sources/visualizations/dashboards/build-dashboards/create-dynamic-dashboard/index.md b/docs/sources/visualizations/dashboards/build-dashboards/create-dynamic-dashboard/index.md deleted file mode 100644 index 0167ff147e5..00000000000 --- a/docs/sources/visualizations/dashboards/build-dashboards/create-dynamic-dashboard/index.md +++ /dev/null @@ -1,416 +0,0 @@ ---- -labels: - products: - - cloud - - oss - stage: - - experimental -_build: - list: false -noindex: true -title: Create a dynamic dashboard -description: Create and edit a dynamic dashboard -weight: 900 -refs: - built-in-special-data-sources: - - pattern: /docs/grafana/ - destination: /docs/grafana//datasources/#special-data-sources - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/#special-data-sources - visualization-specific-options: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/visualizations/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/ - configure-standard-options: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-standard-options/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-standard-options/ - configure-value-mappings: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-value-mappings/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-value-mappings/ - generative-ai-features: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards - configure-thresholds: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-thresholds/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-thresholds/ - data-sources: - - pattern: /docs/grafana/ - destination: /docs/grafana//datasources/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/ - add-a-data-source: - - pattern: /docs/grafana/ - destination: /docs/grafana//datasources/#add-a-data-source - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//datasources/#add-a-data-source - about-users-and-permissions: - - pattern: /docs/grafana/ - destination: /docs/grafana//administration/roles-and-permissions/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//administration/roles-and-permissions/ - visualizations-options: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/visualizations/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//panels-visualizations/visualizations/ - configure-repeating-panels: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-panel-options/#configure-repeating-panels - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-panel-options/#configure-repeating-panels - override-field-values: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-overrides/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-overrides/ -aliases: - - ../../../dashboards/build-dashboards/create-dynamic-dashboard/ # /docs/grafana/next/dashboards/build-dashboards/create-dynamic-dashboard/ ---- - -# Create and edit dynamic dashboards - -{{< admonition type="caution" >}} - -Dynamic dashboards is an [experimental](https://grafana.com/docs/release-life-cycle/) feature. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. To get early access to this feature, request it through [this form](https://docs.google.com/forms/d/e/1FAIpQLSd73nQzuhzcHJOrLFK4ef_uMxHAQiPQh1-rsQUT2MRqbeMLpg/viewform?usp=dialog). - -**Do not enable this feature in production environments as it may result in the irreversible loss of data.** - -{{< /admonition >}} - -Dashboards and panels allow you to show your data in visual form. Each panel needs at least one query to display a visualization. - -## Before you begin - -- Ensure that you have the proper permissions. For more information about permissions, refer to [About users and permissions](ref:about-users-and-permissions). -- Identify the dashboard to which you want to add the panel. -- Understand the query language of the target data source. -- Ensure that data source for which you are writing a query has been added. For more information about adding a data source, refer to [Add a data source](ref:add-a-data-source) if you need instructions. - -## Create a dashboard - -To create a dashboard, follow these steps: - -1. Click **Dashboards** in the main menu. -1. Click **New** and select **New Dashboard**. -1. In the edit pane, enter the dashboard title and description. - - {{< figure src="/media/docs/grafana/dashboards/screenshot-new-dashboard-v12.png" max-width="750px" alt="New dashboard" >}} - -1. Under **Panel layout**, choose one of the following options: - - **Custom** - Position and size panels manually. The default selection. - - **Auto grid** - Panels are automatically resized to create a uniform grid based on the column and row settings. - -1. Click **+ Add visualization**. -1. In the dialog box that opens, do one of the following: - - Select one of your existing data sources. - - Select one of the Grafana [built-in special data sources](ref:built-in-special-data-sources). - - Click **Configure a new data source** to set up a new one (Admins only). - - {{< figure class="float-right" src="/media/docs/grafana/dashboards/screenshot-data-source-selector-10.0.png" max-width="800px" alt="Select data source modal" >}} - - The **Edit panel** view opens with your data source selected. - You can change the panel data source later using the drop-down in the **Query** tab of the panel editor if needed. - - For more information about data sources, refer to [Data sources](ref:data-sources) for specific guidelines. - -1. Write or construct a query in the query language of your data source. -1. Click **Refresh** to query the data source. -1. In the visualization list, select a visualization type. - - {{< figure src="/media/docs/grafana/dashboards/screenshot-select-visualization-v12.png" max-width="350px" alt="Visualization selector" >}} - - Grafana displays a preview of your query results with the visualization applied. - - For more information about configuring individual visualizations, refer to [Visualizations options](ref:visualizations-options). - -1. Under **Panel options**, enter a title and description for your panel or have Grafana create them using [generative AI features](ref:generative-ai-features). -1. Refer to the following documentation for ways you can adjust panel settings. - - While not required, most visualizations need some adjustment before they properly display the information that you need. - - [Configure value mappings](ref:configure-value-mappings) - - [Visualization-specific options](ref:visualization-specific-options) - - [Override field values](ref:override-field-values) - - [Configure thresholds](ref:configure-thresholds) - - [Configure standard options](ref:configure-standard-options) - -1. When you've finished editing your panel, click **Save**. - - Alternatively, click **Back to dashboard** if you want to see your changes applied to the dashboard first. Then click **Save** when you're ready. - -1. Enter a title and description for your dashboard if you haven't already or have Grafana create them using [generative AI features](ref:generative-ai-features). -1. Select a folder, if applicable. -1. (Optional) Enter a description of the changes you've made. -1. Click **Save**. -1. To add more panels to the dashboard, click **Back to dashboard** and at the bottom-left corner of the dashboard, click **+ Add panel**. - - {{< figure src="/media/docs/grafana/dashboards/screenshot-add-panel-v12.png" max-width="500px" alt="Add panel button" >}} - -1. (Optional) In the edit pane, enter a title and description for the panel and set the panel transparency and repeat options, if applicable. -1. Click **Configure** in either the edit pane or on the panel to the configuration process. -1. When you've saved all the changes you want to make to the dashboard, click **Back to dashboard**. -1. Toggle off the edit mode switch. - -{{< admonition type="caution" >}} - -Dynamic dashboards is an [experimental](https://grafana.com/docs/release-life-cycle/) feature. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. To get early access to this feature, request it through [this form](https://docs.google.com/forms/d/e/1FAIpQLSd73nQzuhzcHJOrLFK4ef_uMxHAQiPQh1-rsQUT2MRqbeMLpg/viewform?usp=dialog). - -**Do not enable this feature in production environments as it may result in the irreversible loss of data.** - -{{< /admonition >}} - -## Group panels - -To help create meaningful sections in your dashboard, you can group panels into rows or tabs. -Rows and tabs let you break up big dashboards or make one dashboard out of several smaller ones. -You can nest tabs and rows within each other or themselves. -Also, tabs are included in the dashboard URL. - -The following sections describe the configuration options for adding tabs and rows. -While grouping is meant for multiple panels, you can start a grouping with just one panel. - -1. Click **Dashboards** in the main menu. -1. Navigate to the dashboard you want to update. -1. Toggle on the edit mode switch. -1. At the bottom-left corner of the dashboard, click **Group panels**. -1. Select **Group into row** or **Group into tab**. - - A dotted line surrounds the panels and the **Row** or **Tab** edit pane is displayed on the right side of the dashboard. - -1. Set the [grouping configuration options](#grouping-configuration-options). -1. When you're finished, click **Save** at the top-right corner of the dashboard. -1. (Optional) Enter a description of the changes you've made. -1. Click **Save**. - -### Grouping configuration options - -The following table describes the options you can set for a row. - - - -| Option | Description | -| ------ | ----------- | -| Title | Title of the row or tab. | -| Fill screen | Toggle the switch on to make the row fill the screen. Only applies to rows. | -| Hide row header | Toggle the switch on to hide the header. In edit mode, the row header is visible, but crossed out with the hidden icon next to it. Only applies to rows. | -| Group layout | Select the grouping option, between **Rows** and **Tabs**. Only available when there's a nested grouping and applies to the nested grouping. | -| Panel layout | Select whether panels are sized and positioned manually, **Custom**, or automatically, **Auto grid**. Only available when a grouping contains panels. | -| Repeat options > [Repeat by variable](#configure-repeat-options) | Configure the dashboard to dynamically add rows or tabs based on the value of a variable. | -| Show / hide rules > [Row/Tab visibility](#configure-showhide-rules) | Control whether or not rows or tabs are displayed based on variables or a time range. | - - - -## Configure repeat options - - - -You can configure Grafana to dynamically add panels, rows, or tabs to a dashboard based on the value of that variable. -Variables dynamically change your queries across all rows in a dashboard. - -This only applies to queries that include a multi-value variable. - - - -To configure repeats, follow these steps: - -1. Click **Dashboards** in the main menu. -1. Navigate to the dashboard you want to update. -1. Toggle on the edit mode switch. - - The **Dashboard** edit pane opens on the right side of the dashboard. - -1. Click in the panel, row, or tab you want to work with to bring it into focus and display the associated options in the edit pane. -1. Expand the **Repeat options** section. -1. Select the **Repeat by variable**. -1. For panels only, set the following options: - - Under **Repeat direction**, choose one of the following: - - **Horizontal** - Arrange panels side-by-side. Grafana adjusts the width of a repeated panel. You can’t mix other panels on a row with a repeated panel. - - **Vertical** - Arrange panels in a column. The width of repeated panels is the same as the original, repeated panel. - - - If you selected **Horizontal**, select a value in the **Max per row** drop-down list to control the maximum number of panels that can be in a row. - -1. (Optional) To provide context to dashboard users, add the variable name to the panel, row, or tab title. -1. When you've finished setting the repeat option, click **Save**. -1. (Optional) Enter a description of the changes you've made. -1. Click **Save**. -1. Toggle off the edit mode switch. - -### Repeating rows and tabs and the Dashboard special data source - - - -If a row includes panels using the special [Dashboard data source](ref:built-in-special-data-sources)—the data source that uses a result set from another panel in the same dashboard—then corresponding panels in repeated rows will reference the panel in the original row, not the ones in the repeated rows. -The same behavior applies to tabs. - -For example, in a dashboard: - -- `Row 1` includes `Panel 1A` and `Panel 1B` -- `Panel 1B` uses the results from `Panel 1A` by way of the `-- Dashboard --` data source -- Repeating row, `Row 2`, includes `Panel 2A` and `Panel 2B` -- `Panel 2B` references `Panel 1A`, not `Panel 2A` - -## Configure show/hide rules - -You can configure panels, rows, and tabs to be shown or hidden based on rules. -For example, you might want to set a panel to be hidden if there's no data returned by a query or a tab to only be shown based on a variable being present. - -{{< admonition type="note" >}} -You can only configure show/hide rules for panels when the dashboard is using the **Auto grid** panel layout. -{{< /admonition >}} - -To configure show/hide rules, follow these steps: - -1. Click **Dashboards** in the main menu. -1. Navigate to the dashboard you want to update. -1. Toggle on the edit mode switch. - - The **Dashboard** edit pane opens on the right side of the dashboard. - -1. Click in the panel, row, or tab you want to work with to bring it into focus and display the associated options in the edit pane. -1. Expand the **Show / hide rules** section. -1. Select **Show** or **Hide** to set whether the panel, row, or tab is shown or hidden based on the rules outcome. -1. Click **+ Add rule**. -1. Select a rule type: - - **Query result** - Show or hide a panel based on query results. Choose from **Has data** and **No data**. For panels only. - - **Template variable** - Show or hide the panel, row, or tab dynamically based on the variable value. Select a variable and operator and enter a value. - - **Time range less than** - Show or hide the panel, row, or tab if the dashboard time range is shorter than the selected time frame. Select or enter a time range. - -1. Configure the rule. -1. Under **Match rules**, select one of the following: - - **Match all** - The panel, row, or tab is shown or hidden only if _all_ the rules are matched. - - **Match any** - The panel, row, or tab is shown or hidden if _any_ of the rules are matched. - - This option is only displayed if you add multiple rules. - -1. When you've finished setting rules, click **Save**. -1. (Optional) Enter a description of the changes you've made. -1. Click **Save**. -1. Toggle off the edit mode switch. - -{{< admonition type="caution" >}} - -Dynamic dashboards is an [experimental](https://grafana.com/docs/release-life-cycle/) feature. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. To get early access to this feature, request it through [this form](https://docs.google.com/forms/d/e/1FAIpQLSd73nQzuhzcHJOrLFK4ef_uMxHAQiPQh1-rsQUT2MRqbeMLpg/viewform?usp=dialog). - -**Do not enable this feature in production environments as it may result in the irreversible loss of data.** - -{{< /admonition >}} - -## Edit dashboards - -When the dashboard is in edit mode, the edit pane that opens displays options associated with the part of the dashboard that it's in focus. -For example, if you click in the area of a panel, row, or tab, that area comes into focus and the edit pane shows the options for that area: - -{{< figure src="/media/docs/grafana/dashboards/screenshot-edit-pane-focus-v12.png" max-width="750px" alt="Dashboard with a panel in focus" >}} - -- For rows and tabs, all of the available options are in the edit pane. -- For panels, high-level options are in the edit pane and further configuration options are in the **Edit panel** view. -- For dashboards, high-level options are in the edit pane and further configuration options are in the **Settings** page. - -To edit dashboards, follow these steps: - -1. Click **Dashboards** in the main menu. -1. Navigate to the dashboard you want to update. -1. Toggle on the edit mode switch. - - The **Dashboard** edit pane opens on the right side of the dashboard. - -1. Click in the area you want to work with to bring it into focus and display the associated options in the edit pane. -1. Do one of the following: - - For rows or tabs, make the required changes using the edit pane. - - For panels, update the panel title, description, repeat options or show/hide rules in the edit pane. For more changes, click **Configure** and continue in **Edit panel** view. - - For dashboards, update the dashboard title, description, grouping or panel layout. For more changes, click the settings (gear) icon in the top-right corner. - -1. When you've finished making changes, click **Save**. -1. (Optional) Enter a description of the changes you've made. -1. Click **Save**. -1. Toggle off the edit mode switch. - -### Undo and redo - -When a dashboard is in edit mode, you can undo and redo changes you've made using the buttons on the toolbar: - -{{< figure src="/media/docs/grafana/dashboards/screenshot-undo-redo-icons-v12.0.png" max-width="500px" alt="Undo and redo buttons" >}} - -When you've made a change and hover the cursor over the buttons, the tooltip displays the change you're about to undo or redo. -Also, you can continue undoing or redoing as many changes as you need: - -{{< video-embed src="/media/docs/grafana/dashboards/screen-record-undo-redo-v12.0.mp4" >}} - -The undo and redo buttons are only available at the dashboard level and only apply to changes made there, such as dashboard layout and grouping and high-level dashboard or panel updates. -They aren't visible and don't apply when you're configuring a panel or making changes in the dashboard settings. - -{{< admonition type="note" >}} -Not all dashboard edit actions can be undone or redone yet. -{{< /admonition >}} - -## Move or resize a panel - - - -When you're dashboard has a **Custom** layout, you can resize or move a panel to any location on the dashboard. - -To move or resize, follow these steps: - -1. Click **Dashboards** in the main menu. -1. Navigate to the dashboard you want to update. -1. Toggle on the edit mode switch. -1. Do one of the following: - - Click the panel title and drag the panel to the new location. - - Click and drag the lower-right corner of the panel to change the size of the panel. - -1. Click **Save**. -1. (Optional) Enter a description of the changes you've made. -1. Click **Save**. -1. Toggle off the edit mode switch. - -## Navigate using the dashboard outline - -The dashboard **Outline** provides a tree-like structure that shows you all of the parts of your dashboard and their relationships to each other including panels, rows, tabs, and variables. -The outline also lets you quickly navigate the dashboard so that you don't have to spend time finding a particular element to work with it. -By default, the outline is collapsed except for the part that's currently in focus. - -{{< figure src="/media/docs/grafana/dashboards/screenshot-dashboard-outline-v12.png" max-width="750px" alt="Dashboard with outline open showing panel in focus" >}} - -To navigate the dashboard using the outline, follow these steps: - -1. Click **Dashboards** in the main menu. -1. Navigate to the dashboard you want to update. -1. Toggle on the edit mode switch. - - The **Dashboard** edit pane opens on the right side of the dashboard. - -1. In the edit pane, expand the **Outline** section. -1. Expand the outline to find the dashboard part to which you want to navigate. -1. Click the tree item to navigate that part of the dashboard. - -## Copy a dashboard - -To make a copy of a dashboard, follow these steps: - -1. Click **Dashboards** in the main menu. -1. Navigate to the dashboard you want to update. -1. Toggle on the edit mode switch. -1. Click the **Save** drop-down and select **Save as copy**. -1. (Optional) Specify the name, folder, description, and whether or not to copy the original dashboard tags for the copied dashboard. - - By default, the copied dashboard has the same name as the original dashboard with the word "Copy" appended and is in the same folder. - -1. Click **Save**. - -{{< admonition type="caution" >}} - -Dynamic dashboards is an [experimental](https://grafana.com/docs/release-life-cycle/) feature. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. To get early access to this feature, request it through [this form](https://docs.google.com/forms/d/e/1FAIpQLSd73nQzuhzcHJOrLFK4ef_uMxHAQiPQh1-rsQUT2MRqbeMLpg/viewform?usp=dialog). - -**Do not enable this feature in production environments as it may result in the irreversible loss of data.** - -{{< /admonition >}} diff --git a/docs/sources/visualizations/dashboards/build-dashboards/create-template-dashboards/_index.md b/docs/sources/visualizations/dashboards/build-dashboards/create-template-dashboards/_index.md index e737b240189..d741b3b53da 100644 --- a/docs/sources/visualizations/dashboards/build-dashboards/create-template-dashboards/_index.md +++ b/docs/sources/visualizations/dashboards/build-dashboards/create-template-dashboards/_index.md @@ -3,20 +3,25 @@ keywords: - grafana - dashboard - template + - suggestions labels: products: - cloud - enterprise - oss -menuTitle: Create template dashboards -title: Create dashboards from templates -description: Learn how to create dashboards from templates +menuTitle: Create template and suggested dashboards +title: Create dashboards from templates and suggestions +description: Learn how to create dashboards from templates and suggestions weight: 3 --- -{{< docs/public-preview product="Dashboard templates" >}} +# Create dashboards from templates and suggestions -# Create dashboards from templates +Grafana provides alternative ways to start building a dashboard. + +## Create dashboards from templates + +{{< docs/public-preview product="Dashboard templates" >}} Grafana provides a variety of pre-built dashboard templates that you can use to quickly set up visualizations for your data. These dashboards use sample data, which you can replace with your own data, making it easier to get started with monitoring and analysis. @@ -48,3 +53,23 @@ To create a dashboard from a template, follow these steps: {{< figure src="/media/docs/grafana/dashboards/screenshot-remove-banner-v12.3.png" max-width="750px" alt="Removing the sample data banner panel" >}} 1. Click **Save dashboard**. + +## Create dashboards from suggestions + +{{< docs/public-preview product="Suggested dashboards" >}} + +You can start the process of creating a dashboard directly from a data source rather than from the **Dashboards** page, which gives you access to suggestions based on the data source. + +To begin building a dashboard directly from a data source, follow these steps: + +1. Navigate to **Connections > Data sources**. +1. On the row of the data source for which you want to build a dashboard, click **Build a dashboard**. + + The empty dashboard page opens. + +1. Select one of the suggested dashboards by clicking its **Use dashboard** button. This can be helpful when you're not sure how to most effectively visualize your data. + The suggested dashboards are specific to your data source type (for example, Prometheus, Loki, or Elasticsearch). If there are more than three dashboard suggestions, you can click **View all** to see the rest of them. + + ![Empty dashboard with add visualization and suggested dashboard options](/media/docs/grafana/dashboards/screenshot-suggested-dashboards-v12.3.png) + +1. Complete the rest of the dashboard configuration. For more detailed steps, refer to [Create a dashboard](https://grafana.com/docs/grafana//visualizations/dashboards/build-dashboards/create-dashboard/), beginning at step five. diff --git a/docs/sources/visualizations/dashboards/build-dashboards/manage-dashboard-links/index.md b/docs/sources/visualizations/dashboards/build-dashboards/manage-dashboard-links/index.md index 3e94ec5aa22..104f3e69b07 100644 --- a/docs/sources/visualizations/dashboards/build-dashboards/manage-dashboard-links/index.md +++ b/docs/sources/visualizations/dashboards/build-dashboards/manage-dashboard-links/index.md @@ -85,7 +85,8 @@ Once you've added a dashboard link, it appears in the upper right corner of your Add links to other dashboards at the top of your current dashboard. 1. In the dashboard you want to link, click **Edit**. -1. Click **Settings**. +1. In the sidebar, click the **Dashboard options** icon. +1. In the edit pane, click **Settings**. 1. Go to the **Links** tab and then click **Add dashboard link**. The default link type is **Dashboards**. @@ -109,7 +110,8 @@ Add links to other dashboards at the top of your current dashboard. Add a link to a URL at the top of your current dashboard. You can link to any available URL, including dashboards, panels, or external sites. You can even control the time range to ensure the user is zoomed in on the right data in Grafana. 1. In the dashboard you want to link, click **Edit**. -1. Click **Settings**. +1. In the sidebar, click the **Dashboard options** icon. +1. In the edit pane, click **Settings**. 1. Go to the **Links** tab and then click **Add dashboard link**. 1. In the **Type** drop-down, select **Link**. 1. In the **URL** field, enter the URL to which you want to link. @@ -132,7 +134,8 @@ Add a link to a URL at the top of your current dashboard. You can link to any av To edit, duplicate, or delete dashboard link, follow these steps: 1. In the dashboard you want to link, click **Edit**. -1. Click **Settings**. +1. In the sidebar, click the **Dashboard options** icon. +1. In the edit pane, click **Settings**. 1. Go to the **Links** tab. 1. Do one of the following: - **Edit** - Click the name of the link and update the link settings. diff --git a/docs/sources/visualizations/dashboards/build-dashboards/manage-version-history/index.md b/docs/sources/visualizations/dashboards/build-dashboards/manage-version-history/index.md index 712cb4c4205..2f08fbed199 100644 --- a/docs/sources/visualizations/dashboards/build-dashboards/manage-version-history/index.md +++ b/docs/sources/visualizations/dashboards/build-dashboards/manage-version-history/index.md @@ -14,7 +14,7 @@ labels: - cloud - enterprise - oss -menutitle: Manage version history +menuTitle: Manage version history title: Manage dashboard version history description: View and compare previous versions of your dashboard weight: 400 @@ -32,8 +32,9 @@ The dashboard version history feature lets you compare and restore to previously To compare two dashboard versions, follow these steps: -1. Click **Edit** in the top-right corner of the dashboard. -1. Click **Settings**. +1. Click **Edit**. +1. In the sidebar, click the **Dashboard options** icon. +1. In the edit pane, click **Settings**. 1. Go to the **Versions** tab. 1. Select the two dashboard versions that you want to compare. 1. Click **Compare versions** to view the diff between the two versions. @@ -49,8 +50,9 @@ When you're comparing versions, if one of the versions you've selected is the la To restore to a previously saved dashboard version, follow these steps: -1. Click **Edit** in the top-right corner of the dashboard. -1. Click **Settings**. +1. Click **Edit**. +1. Click the **Dashboard options** icon. +1. In the edit pane, click **Settings**. 1. Go to the **Versions** tab. 1. Click the **Restore** button next to the version. diff --git a/docs/sources/visualizations/dashboards/build-dashboards/modify-dashboard-settings/index.md b/docs/sources/visualizations/dashboards/build-dashboards/modify-dashboard-settings/index.md index dbc49c40bde..e9f1f408c0d 100644 --- a/docs/sources/visualizations/dashboards/build-dashboards/modify-dashboard-settings/index.md +++ b/docs/sources/visualizations/dashboards/build-dashboards/modify-dashboard-settings/index.md @@ -50,8 +50,9 @@ The dashboard settings page allows you to: To access the dashboard setting page: -1. Click **Edit** in the top-right corner of the dashboard. -1. Click **Settings**. +1. Click **Edit**. +1. In the sidebar, click the **Dashboard options** icon. +1. In the edit pane, click **Settings**. ## Modify dashboard time settings diff --git a/docs/sources/visualizations/dashboards/build-dashboards/view-dashboard-json-model/index.md b/docs/sources/visualizations/dashboards/build-dashboards/view-dashboard-json-model/index.md index 7720a4c946a..b09283bc190 100644 --- a/docs/sources/visualizations/dashboards/build-dashboards/view-dashboard-json-model/index.md +++ b/docs/sources/visualizations/dashboards/build-dashboards/view-dashboard-json-model/index.md @@ -3,45 +3,75 @@ aliases: - ../../../reference/dashboard/ # /docs/grafana/next/reference/dashboard/ - ../../../dashboards/json-model/ # /docs/grafana/next/dashboards/json-model/ - ../../../dashboards/build-dashboards/view-dashboard-json-model/ # /docs/grafana/next/dashboards/build-dashboards/view-dashboard-json-model/ + - ../../../as-code/observability-as-code/schema-v2/ # /docs/grafana/latest/as-code/observability-as-code/schema-v2/ + - ../../../as-code/observability-as-code/schema-v2/annotations-schema/ # /docs/grafana/latest/as-code/observability-as-code/schema-v2/annotations-schema/ + - ../../../as-code/observability-as-code/schema-v2/panel-schema/ # /docs/grafana/latest/as-code/observability-as-code/schema-v2/panel-schema/ + - ../../../as-code/observability-as-code/schema-v2/librarypanel-schema/ # /docs/grafana/latest/as-code/observability-as-code/schema-v2/librarypanel-schema/ + - ../../../as-code/observability-as-code/schema-v2/layout-schema/ # /docs/grafana/latest/as-code/observability-as-code/schema-v2/layout-schema/ + - ../../../as-code/observability-as-code/schema-v2/links-schema/ # /docs/grafana/latest/as-code/observability-as-code/schema-v2/links-schema/ + - ../../../as-code/observability-as-code/schema-v2/timesettings-schema/ # /docs/grafana/latest/as-code/observability-as-code/schema-v2/timesettings-schema/ + - ../../../as-code/observability-as-code/schema-v2/variables-schema/ # /docs/grafana/latest/as-code/observability-as-code/schema-v2/variables-schema/ + - ../../../observability-as-code/schema-v2/ # /docs/grafana/latest/observability-as-code/schema-v2/ + - ../../../../next/observability-as-code/schema-v2/annotations-schema/ # /docs/grafana/next/observability-as-code/schema-v2/annotations-schema/ + - ../../../../next/observability-as-code/schema-v2/panel-schema/ # /docs/grafana/next/observability-as-code/schema-v2/panel-schema/ + - ../../../../next/observability-as-code/schema-v2/librarypanel-schema/ # /docs/grafana/next/observability-as-code/schema-v2/librarypanel-schema/ + - ../../../../next/observability-as-code/schema-v2/layout-schema/ # /docs/grafana/next/observability-as-code/schema-v2/layout-schema/ + - ../../../../next/observability-as-code/schema-v2/links-schema/ # /docs/grafana/next/observability-as-code/schema-v2/links-schema/ + - ../../../../next/observability-as-code/schema-v2/timesettings-schema/ # /docs/grafana/next/observability-as-code/schema-v2/timesettings-schema/ + - ../../../../next/observability-as-code/schema-v2/variables-schema/ # /docs/grafana/next/observability-as-code/schema-v2/variables-schema/ keywords: - grafana - dashboard - documentation - json - model + - schema v2 + - v1 resource + - v2 resource + - classic labels: products: - cloud - enterprise - oss title: JSON model -description: View your Grafana dashboard JSON object +description: View and update your Grafana dashboard JSON object weight: 700 -refs: - annotations: - - 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/ --- # Dashboard JSON model -A dashboard in Grafana is represented by a JSON object, which stores metadata of its dashboard. Dashboard metadata includes dashboard properties, metadata from panels, template variables, panel queries, etc. +Grafana dashboards are represented as JSON objects that store metadata, panels, variables, and settings. -To view the JSON of a dashboard: +## Different dashboard schema models -1. Click **Edit** in the top-right corner of the dashboard. -1. Click **Settings**. +There are currently three dashboard JSON schema models: + +- [Classic](#classic-model) - A non-Kubernetes resource used before the adoption of the Kubernetes API by Grafana in v12.2.0. It's been widely used for exporting, importing, and sharing dashboards in the Grafana dashboards collection at [grafana.com/dashboards](https://grafana.com/grafana/dashboards/). +- [V1 Resource](#v1-resource-model) - The Classic dashboard schema formatted as a Kubernetes-style resource. Its `spec` property contains the Classic model of the schema. This is the default format for API communication after Grafana v12.2.0, which enabled the Kubernetes Platform API as default backend for Grafana dashboards. Dashboards created using the Classic model can be exported using either the Classic or the V1 Resource format. +- [V2 Resource](#v2-resource-model) - The latest format, supporting new features such as advanced layouts and conditional rendering. It models all dashboard elements as Kubernetes kinds, following Kubernetes conventions for declaring dashboard components. This format is future-proof and represents the evolving standard for dashboards. + +{{< admonition type="note" >}} +[Observability as Code](https://grafana.com/docs/grafana/latest/as-code/observability-as-code/) works with all versions of the JSON model, and it's fully compatible with version 2. +{{< /admonition >}} + +## Access and update the JSON model (#view-json) + +To access the JSON representation of a dashboard: + +1. Click **Edit**. +1. In the sidebar, click the **Dashboard options** icon. +1. In the edit pane, click **Settings**. 1. Go to the **JSON Model** tab. 1. When you've finished viewing the JSON, click **Back to dashboard** and **Exit edit**. -## JSON fields +## Classic model -When a user creates a new dashboard, a new dashboard JSON object is initialized with the following fields: +When you create a new dashboard in self-managed Grafana, a new dashboard JSON object was initialized with the following fields: {{< admonition type="note" >}} -In the following JSON, id is shown as null which is the default value assigned to it until a dashboard is saved. Once a dashboard is saved, an integer value is assigned to the `id` field. +In the following JSON, id is shown as null which is the default value assigned to it until a dashboard is saved. +After a dashboard is saved, an integer value is assigned to the `id` field. {{< /admonition >}} ```json @@ -76,26 +106,30 @@ In the following JSON, id is shown as null which is the default value assigned t Each field in the dashboard JSON is explained below with its usage: -| Name | Usage | -| ----------------- | ----------------------------------------------------------------------------------------------------------------- | -| **id** | unique numeric identifier for the dashboard. (generated by the db) | -| **uid** | unique dashboard identifier that can be generated by anyone. string (8-40) | -| **title** | current title of dashboard | -| **tags** | tags associated with dashboard, an array of strings | -| **style** | theme of dashboard, i.e. `dark` or `light` | -| **timezone** | timezone of dashboard, i.e. `utc` or `browser` | -| **editable** | whether a dashboard is editable or not | -| **graphTooltip** | 0 for no shared crosshair or tooltip (default), 1 for shared crosshair, 2 for shared crosshair AND shared tooltip | -| **time** | time range for dashboard, i.e. last 6 hours, last 7 days, etc | -| **timepicker** | timepicker metadata, see [timepicker section](#timepicker) for details | -| **templating** | templating metadata, see [templating section](#templating) for details | -| **annotations** | annotations metadata, see [annotations](ref:annotations) for how to add them | -| **refresh** | auto-refresh interval | -| **schemaVersion** | version of the JSON schema (integer), incremented each time a Grafana update brings changes to said schema | -| **version** | version of the dashboard (integer), incremented each time the dashboard is updated | -| **panels** | panels array, see below for detail. | + -## Panels +| Name | Usage | +| ----------------- | ------------------------------------------------------------------------------------------ | +| **id** | unique numeric identifier for the dashboard. (generated by the db) | +| **uid** | unique dashboard identifier that can be generated by anyone. string (8-40) | +| **title** | current title of dashboard | +| **tags** | tags associated with dashboard, an array of strings | +| **style** | theme of dashboard, i.e. `dark` or `light` | +| **timezone** | timezone of dashboard, i.e. `utc` or `browser` | +| **editable** | whether a dashboard is editable or not | +| **graphTooltip** | 0 for no shared crosshair or tooltip (default), 1 for shared crosshair, 2 for shared crosshair AND shared tooltip | +| **time** | time range for dashboard, i.e. last 6 hours, last 7 days, etc | +| **timepicker** | timepicker metadata, see [timepicker section](#timepicker) for details | +| **templating** | templating metadata, see [templating section](#templating) for details | +| **annotations** | annotations metadata, see [annotations](https://grafana.com/docs/grafana//dashboards/build-dashboards/annotate-visualizations/) for how to add them | +| **refresh** | auto-refresh interval| +| **schemaVersion** | version of the JSON schema (integer), incremented each time a Grafana update brings changes to said schema | +| **version** | version of the dashboard (integer), incremented each time the dashboard is updated | +| **panels** | panels array, see below for detail. | + + + +### Panels Panels are the building blocks of a dashboard. It consists of data source queries, type of graphs, aliases, etc. Panel JSON consists of an array of JSON objects, each representing a different panel. Most of the fields are common for all panels but some fields depend on the panel type. Following is an example of panel JSON of a text panel. @@ -168,18 +202,22 @@ The grid has a negative gravity that moves panels up if there is empty space abo Usage of the fields is explained below: -| Name | Usage | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| **collapse** | whether timepicker is collapsed or not | -| **enable** | whether timepicker is enabled or not | -| **notice** | | -| **now** | | -| **hidden** | whether timepicker is hidden or not | -| **nowDelay** | override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. | -| **quick_ranges** | custom quick ranges | -| **refresh_intervals** | interval options available in the refresh picker dropdown | -| **status** | | -| **type** | | + + +| Name | Usage | +| --------------------- | --------------------------------------------------------- | +| **collapse** | whether timepicker is collapsed or not | +| **enable** | whether timepicker is enabled or not | +| **notice** | | +| **now** | | +| **hidden** | whether timepicker is hidden or not | +| **nowDelay** | override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. | +| **quick_ranges** | custom quick ranges | +| **refresh_intervals** | interval options available in the refresh picker dropdown | +| **status** | | +| **type** | | + + ### templating @@ -270,3 +308,82 @@ Usage of the above mentioned fields in the templating section is explained below | **refresh** | configures when to refresh a variable | | **regex** | extracts part of a series name or metric node segment | | **type** | type of variable, i.e. `custom`, `query` or `interval` | + +## V1 Resource model + +The V1 Resource schema model formats the [Classic JSON model](#classic-model) schema as a Kubernetes-style resource. +The `spec` property of the schema contains the Classic-style model of the schema. + +Dashboards created using the Classic model can be exported using either this model or the Classic one. + +The following code snippet shows the fields included in the V1 Resource model. + +```json +{ + "apiVersion": "dashboard.grafana.app/v1beta1", + "kind": "Dashboard", + "metadata": { + "name": "isnt5ss", + "namespace": "stacks-521104", + "uid": "92674c0e-0360-4bb4-99ab-fb150581376d", + "resourceVersion": "1764705030717045", + "generation": 1, + "creationTimestamp": "2025-12-02T19:50:30Z", + "labels": { + "grafana.app/deprecatedInternalID": "1329" + }, + "annotations": { + "grafana.app/createdBy": "user:u000000002", + "grafana.app/folder": "", + "grafana.app/saved-from-ui": "Grafana Cloud (instant)" + } + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 1329, + "links": [], + "panels": [], + "preload": false, + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "Africa/Abidjan", + "title": "Graphite suggestions", + "uid": "isnt5ss", + "version": 1, + "weekStart": "" + }, + "status": {} +} +``` + +## V2 Resource model + +{{< docs/public-preview product="Dashboard JSON schema v2" >}} + +For the detailed V2 Resource model schema, refer to the [Swagger documentation](https://play.grafana.org/swagger?api=dashboard.grafana.app-v2beta1). diff --git a/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md b/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md index e7749ba5b88..7087b3ac692 100644 --- a/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md +++ b/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md @@ -213,7 +213,7 @@ To export a dashboard in its current state as a PDF, follow these steps: 1. Click **Dashboards** in the main menu. 1. Open the dashboard you want to export. -1. Click the **Export** drop-down in the top-right corner and select **Export as PDF**. +1. Click the **Export** drop-down in the sidebar and select **Export as PDF**. 1. In the **Export dashboard PDF** drawer that opens, select either **Landscape** or **Portrait** for the PDF orientation. 1. Select either **Grid** or **Simple** for the PDF layout. 1. Set the **Zoom** level; zoom in to enlarge text, or zoom out to see more data (like table columns) per panel. @@ -229,7 +229,7 @@ Export a Grafana JSON file that contains everything you need, including layout, 1. Click **Dashboards** in the main menu. 1. Open the dashboard you want to export. -1. Click the **Export** drop-down list in the top-right corner and select **Export as code**. +1. Click the **Export** drop-down list in the sidebar and select **Export as code**. The **Export dashboard** drawer opens. @@ -255,7 +255,7 @@ To export a dashboard in its current state as a PNG image file, follow these ste 1. Click **Dashboards** in the main menu. 1. Open the dashboard you want to export. -1. Click the **Export** drop-down list in the top-right corner and select **Export as image**. +1. Click the **Export** drop-down list in the sidebar and select **Export as image**. The **Export as image** drawer opens. diff --git a/docs/sources/visualizations/dashboards/use-dashboards/index.md b/docs/sources/visualizations/dashboards/use-dashboards/index.md index 108d6bc8136..5c7141a22bc 100644 --- a/docs/sources/visualizations/dashboards/use-dashboards/index.md +++ b/docs/sources/visualizations/dashboards/use-dashboards/index.md @@ -21,67 +21,144 @@ menuTitle: Use dashboards title: Use dashboards description: Learn about the features of a Grafana dashboard weight: 100 -refs: - dashboard-analytics: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/assess-dashboard-usage/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/assess-dashboard-usage/ - generative-ai-features: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards - dashboard-settings: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/build-dashboards/modify-dashboard-settings/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/modify-dashboard-settings/ - repeating-rows: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/build-dashboards/create-dashboard/#configure-repeating-rows - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows - variables: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/variables/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/variables/ - dashboard-folders: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/manage-dashboards/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/manage-dashboards/ - sharing: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/share-dashboards-panels/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/share-dashboards-panels/ - dashboard-links: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/build-dashboards/manage-dashboard-links/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/manage-dashboard-links/ - panel-overview: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/panel-overview/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/panel-overview/ - export-dashboards: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/share-dashboards-panels/#export-dashboards - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/share-dashboards-panels/#export-dashboards - add-ad-hoc-filters: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/variables/add-template-variables/#add-ad-hoc-filters - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/variables/add-template-variables/#add-ad-hoc-filters - shared-dashboards: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/share-dashboards-panels/shared-dashboards/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/share-dashboards-panels/shared-dashboards/ +image_maps: + - key: annotated-dashboard + src: /media/docs/grafana/dashboards/screenshot-ann-dashboards-v12.4.png + alt: An annotated image of a Grafana dashboard + points: + - x_coord: 8 + y_coord: 5 + content: | + **Dashboard folder** + + Click the dashboard folder name to access the folder and perform other [folder management tasks](https://grafana.com/docs/grafana//visualizations/dashboards/manage-dashboards/). + - x_coord: 17 + y_coord: 5 + content: | + **Dashboard title** + + Create your own dashboard titles or have Grafana create them for you using [generative AI features](https://grafana.com/docs/grafana//visualizations/dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards). + - x_coord: 23 + y_coord: 5 + content: | + **Mark as favorite** + + Mark the dashboard as one of your favorites to include it in your list of **Starred** dashboards in the main menu. + - x_coord: 27 + y_coord: 5 + content: | + **Public label** + + [Externally shared dashboards](https://grafana.com/docs/grafana//visualizations/dashboards/share-dashboards-panels/shared-dashboards/), it's marked with the **Public** label. + - x_coord: 84 + y_coord: 5 + content: | + **Grafana Assistant** + + [Grafana Assistant](https://grafana.com/docs/grafana-cloud/machine-learning/assistant/introduction/) combines large language models with Grafana-integrated tools. + - x_coord: 89 + y_coord: 5 + content: | + **Invite new users** + + Invite new users to join your Grafana organization. + - x_coord: 32 + y_coord: 23 + content: | + **Variables** + + Use [variables](https://grafana.com/docs/grafana//visualizations/dashboards/variables/), including ad hoc filters, to create more interactive and dynamic dashboards. + - x_coord: 45 + y_coord: 23 + content: | + **Dashboard links** + + Link to other dashboards, panels, and external websites. Learn more about [dashboard links](https://grafana.com/docs/grafana//visualizations/dashboards/build-dashboards/manage-dashboard-links/). + - x_coord: 59 + y_coord: 29 + content: | + **Current dashboard time range and time picker** + + Select [relative time range](#relative-time-range) options or set custom [absolute time ranges](#absolute-time-range). + You can also change the **Timezone** and **Fiscal year** settings by clicking the **Change time settings** button. + - x_coord: 67 + y_coord: 29 + content: | + **Time range zoom out** + + Click to zoom out the time range. Learn more about [common time range controls](#common-time-range-controls). + - x_coord: 73 + y_coord: 29 + content: | + **Refresh dashboard** + + Trigger queries and refresh dashboard data. + - x_coord: 78 + y_coord: 29 + content: | + **Auto refresh control** + + Select a dashboard auto refresh time interval. + - x_coord: 85 + y_coord: 29 + content: | + **Share dashboard** + + Access [dashboard sharing](https://grafana.com/docs/grafana//visualizations/dashboards/share-dashboards-panels/) options. + - x_coord: 98 + y_coord: 22.5 + content: | + **Edit** + + Enter edit mode, so you can make changes and access dashboard settings. + - x_coord: 98 + y_coord: 31 + content: | + **Export** + + Access [dashboard exporting](https://grafana.com/docs/grafana//visualizations/dashboards/share-dashboards-panels/#export-dashboards) options. + - x_coord: 98 + y_coord: 39 + content: | + **Content outline** + + The outline provides a tree-like structure that lets you quickly navigate the dashboard. + - x_coord: 98 + y_coord: 47 + content: | + **Dashboard insights** + + View [dashboard analytics](https://grafana.com/docs/grafana//visualizations/dashboards/assess-dashboard-usage/) including information about users, activity, query counts. + - x_coord: 11.5 + y_coord: 30 + content: | + **Row title** + + A row is one way you can [group panels](https://grafana.com/docs/grafana//visualizations/dashboards/build-dashboards/create-dashboard/#panel-groupings) in a dashboard. + - x_coord: 20 + y_coord: 36 + content: | + **Tab title** + + A tab is one way you can [group panels](https://grafana.com/docs/grafana//visualizations/dashboards/build-dashboards/create-dashboard/#panel-groupings) in a dashboard. + - x_coord: 21 + y_coord: 45 + content: | + **Panel title** + + Create your own panel titles or have Grafana create them for you using [generative AI features](https://grafana.com/docs/grafana//visualizations/dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards). + - x_coord: 27 + y_coord: 63 + content: | + **Dashboard panel** + + The [panel](https://grafana.com/docs/grafana//panels-visualizations/panel-overview/) is the primary building block of a dashboard. + - x_coord: 19.5 + y_coord: 91 + content: | + **Panel legend** + + Change series colors as well as y-axis and series visibility directly from the legend. --- # Use dashboards @@ -95,32 +172,9 @@ This topic provides an overview of dashboard features and shortcuts, and describ The dashboard user interface provides a number of features that you can use to customize the presentation of your data. The following image and descriptions highlight all dashboard features. +Hover your cursor over a number to display information about the dashboard element. -![An annotated image of a dashboard](/media/docs/grafana/dashboards/screenshot-dashboard-annotated-v11.3-2.png) - -1. **Dashboard folder** - When you click the dashboard folder name, you can search for other dashboards contained in the folder and perform other [folder management tasks](ref:dashboard-folders). -1. **Dashboard title** - You can create your own dashboard titles or have Grafana create them for you using [generative AI features](ref:generative-ai-features). -1. **Kiosk mode** - Click to display the dashboard on a large screen such as a TV or a kiosk. Kiosk mode hides the main menu, navbar, and dashboard controls. Learn more about kiosk mode in our [How to Create Kiosks to Display Dashboards on a TV blog post](https://grafana.com/blog/2019/05/02/grafana-tutorial-how-to-create-kiosks-to-display-dashboards-on-a-tv/). Press `Esc` to leave kiosk mode. -1. **Mark as favorite** - Mark the dashboard as one of your favorites so it's included in your list of **Starred** dashboards in the main menu. -1. **Public label** - When you [share a dashboard externally](ref:shared-dashboards), it's marked with the **Public** label. -1. **Dashboard insights** - Click to view analytics about your dashboard including information about users, activity, query counts. Learn more about [dashboard analytics](ref:dashboard-analytics). -1. **Edit** - Click to leave view-only mode and enter edit mode, where you can make changes directly to the dashboard and access dashboard settings, as well as several panel editing functions. -1. **Export** - Access [dashboard exporting](ref:export-dashboards) options. -1. **Share dashboard** - Access several [dashboard sharing](ref:sharing) options. -1. **Variables** - Use [variables](ref:variables), including ad hoc filters, to create more interactive and dynamic dashboards. -1. **Dashboard links** - Link to other dashboards, panels, and external websites. Learn more about [dashboard links](ref:dashboard-links). -1. **Current dashboard time range and time picker** - Click to select [relative time range](#relative-time-range) options and set custom [absolute time ranges](#absolute-time-range). - - You can change the **Timezone** and **Fiscal year** settings from the time range controls by clicking the **Change time settings** button. - - Time settings are saved on a per-dashboard basis. -1. **Time range zoom out** - Click to zoom out the time range. Learn more about how to use [common time range controls](#common-time-range-controls). -1. **Refresh dashboard** - Click to immediately trigger queries and refresh dashboard data. -1. **Auto refresh control** - Click to select a dashboard auto refresh time interval. -1. **Dashboard row** - A dashboard row is a logical divider within a dashboard that groups panels together. - - Rows can be collapsed or expanded allowing you to hide parts of the dashboard. - - Panels inside a collapsed row do not issue queries. - - Use [repeating rows](ref:repeating-rows) to dynamically create rows based on a template variable. -1. **Dashboard panel** - The [panel](ref:panel-overview) is the primary building block of a dashboard. -1. **Panel legend** - Change series colors as well as y-axis and series visibility directly from the legend. +{{< image-map key="annotated-dashboard" >}} ## Keyboard shortcuts @@ -134,7 +188,7 @@ Grafana has a number of keyboard shortcuts available. Press `?` on your keyboard - `Ctrl+K`: Opens the command palette. - `Esc`: Exits panel when in full screen view or edit mode. Also returns you to the dashboard from dashboard settings. -**Focused panel** +### Focused panel By hovering over a panel with the mouse you can use some shortcuts that will target that panel. @@ -263,13 +317,16 @@ Click the **Copy time range to clipboard** icon to copy the current time range t You can also copy and paste a time range using the keyboard shortcuts `t+c` and `t+v` respectively. -#### Zoom out (Cmd+Z or Ctrl+Z) +#### Zoom out -Click the **Zoom out** icon to view a larger time range in the dashboard or panel visualization. +- Click the **Zoom out** icon to view a larger time range in the dashboard or panel visualizations +- Double click on the panel graph area (time series family visualizations only) +- Type the `t-` keyboard shortcut -#### Zoom in (only applicable to graph visualizations) +#### Zoom in -Click and drag to select the time range in the visualization that you want to view. +- Click and drag horizontally in the panel graph area to select a time range (time series family visualizations only) +- Type the `t+` keyboard shortcut #### Refresh dashboard @@ -285,7 +342,7 @@ Selecting the **Auto** interval schedules a refresh based on the query time rang ## Filter dashboard data -Once you've [added an ad hoc filter](ref:add-ad-hoc-filters) in the dashboard settings, you can create label/value filter pairs on the dashboard. +Once you've [added an ad hoc filter](https://grafana.com/docs/grafana//visualizations/dashboards/variables/add-template-variables/#add-ad-hoc-filters) in the dashboard settings, you can create label/value filter pairs on the dashboard. These filters are applied to all metric queries that use the specified data source and to all panels on the dashboard. To filter dashboard data, follow these steps: diff --git a/docs/sources/visualizations/dashboards/variables/add-template-variables/index.md b/docs/sources/visualizations/dashboards/variables/add-template-variables/index.md index 5ee61394a15..5bf1c4d99c6 100644 --- a/docs/sources/visualizations/dashboards/variables/add-template-variables/index.md +++ b/docs/sources/visualizations/dashboards/variables/add-template-variables/index.md @@ -146,7 +146,7 @@ To create a variable, follow these steps: - Variable drop-down lists are displayed in the order in which they're listed in the **Variables** in dashboard settings, so put the variables that you will change often at the top, so they will be shown first (far left on the dashboard). - By default, variables don't have a default value. This means that the topmost value in the drop-down list is always preselected. If you want to pre-populate a variable with an empty value, you can use the following workaround in the variable settings: 1. Select the **Include All Option** checkbox. - 2. In the **Custom all value** field, enter a value like `+`. + 2. In the **Custom all value** field, enter a value like `.+`. ## Add a query variable diff --git a/docs/sources/visualizations/panels-visualizations/configure-panel-options/index.md b/docs/sources/visualizations/panels-visualizations/configure-panel-options/index.md index 3a2185d699a..68d41969eda 100644 --- a/docs/sources/visualizations/panels-visualizations/configure-panel-options/index.md +++ b/docs/sources/visualizations/panels-visualizations/configure-panel-options/index.md @@ -35,9 +35,9 @@ refs: destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/manage-dashboard-links/#panel-links configure-repeating-rows: - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/build-dashboards/create-dashboard/#configure-repeating-rows + destination: /docs/grafana//dashboards/build-dashboards/create-dashboard/#configure-repeat-options - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows + destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/create-dashboard/#configure-repeat-options set-up-generative-ai-features-for-dashboards: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards diff --git a/docs/sources/visualizations/panels-visualizations/panel-overview/index.md b/docs/sources/visualizations/panels-visualizations/panel-overview/index.md index 8f6708492a3..9f0d07367a1 100644 --- a/docs/sources/visualizations/panels-visualizations/panel-overview/index.md +++ b/docs/sources/visualizations/panels-visualizations/panel-overview/index.md @@ -175,9 +175,10 @@ By hovering over a panel with the mouse you can use some shortcuts that will tar - `pl`: Hide or show legend - `pr`: Remove Panel -## Zoom panel time range +## Pan and zoom panel time range -You can zoom the panel time range in and out, which in turn, changes the dashboard time range. +You can pan the panel time range left and right, and zoom it and in and out. +This, in turn, changes the dashboard time range. This feature is supported for the following visualizations: @@ -191,7 +192,7 @@ This feature is supported for the following visualizations: Click and drag on the panel to zoom in on a particular time range. -The following screen recordings show this interaction in the time series and x visualizations: +The following screen recordings show this interaction in the time series and candlestick visualizations: Time series @@ -211,7 +212,7 @@ For example, if the original time range is from 9:00 to 9:59, the time range cha - Next range: 8:30 - 10:29 - Next range: 7:30 - 11:29 -The following screen recordings demonstrate the preceding example in the time series and x visualizations: +The following screen recordings demonstrate the preceding example in the time series and heatmap visualizations: Time series @@ -221,6 +222,19 @@ Heatmap {{< video-embed src="/media/docs/grafana/panels-visualizations/recording-heatmap-panel-time-zoom-out-mouse.mp4" >}} +### Pan + +Click and drag the x-axis area of the panel to pan the time range. + +The time range shifts by the distance you drag. +For example, if the original time range is from 9:00 to 9:59 and you drag 30 minutes to the right, the time range changes to 9:30 to 10:29. + +The following screen recordings show this interaction in the time series visualization: + +Time series + +{{< video-embed src="/media/docs/grafana/panels-visualizations/recording-ts-time-pan-mouse.mp4" >}} + ## Add a panel To add a panel in a new dashboard click **+ Add visualization** in the middle of the dashboard: diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/candlestick/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/candlestick/index.md index e5760e78199..7748dd749f1 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/candlestick/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/candlestick/index.md @@ -92,9 +92,9 @@ The data is converted as follows: {{< figure src="/media/docs/grafana/panels-visualizations/screenshot-candles-volume-v11.6.png" max-width="750px" alt="A candlestick visualization showing the price movements of specific asset." >}} -## Zoom panel time range +## Pan and zoom panel time range -{{< docs/shared lookup="visualizations/panel-zoom.md" source="grafana" version="" >}} +{{< docs/shared lookup="visualizations/panel-pan-zoom.md" source="grafana" version="" >}} ## Configuration options diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/datagrid/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/datagrid/index.md index b54326968a6..3a4a448ae85 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/datagrid/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/datagrid/index.md @@ -30,7 +30,9 @@ refs: # Datagrid -{{< docs/experimental product="The datagrid visualization" featureFlag="`enableDatagridEditing`" >}} +{{< admonition type="caution" >}} +Starting with Grafana 12.4, Datagrid is deprecated. It will be removed in version 13.0. +{{< /admonition >}} Datagrids offer you the ability to create, edit, and fine-tune data within Grafana. As such, this panel can act as a data source for other panels inside a dashboard. diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/heatmap/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/heatmap/index.md index 56c91db0e0d..b6005c1761e 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/heatmap/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/heatmap/index.md @@ -79,9 +79,9 @@ The data is converted as follows: {{< figure src="/static/img/docs/heatmap-panel/heatmap.png" max-width="1025px" alt="A heatmap visualization showing the random walk distribution over time" >}} -## Zoom panel time range +## Pan and zoom panel time range -{{< docs/shared lookup="visualizations/panel-zoom.md" source="grafana" version="" >}} +{{< docs/shared lookup="visualizations/panel-pan-zoom.md" source="grafana" version="" >}} ## Configuration options diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/state-timeline/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/state-timeline/index.md index ef4066c2b6c..0a25b3153a1 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/state-timeline/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/state-timeline/index.md @@ -93,9 +93,9 @@ You can also create a state timeline visualization using time series data. To do ![State timeline with time series](/media/docs/grafana/panels-visualizations/screenshot-state-timeline-time-series-v11.4.png) -## Zoom panel time range +## Pan and zoom panel time range -{{< docs/shared lookup="visualizations/panel-zoom.md" source="grafana" version="" >}} +{{< docs/shared lookup="visualizations/panel-pan-zoom.md" source="grafana" version="" >}} ## Configuration options diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/status-history/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/status-history/index.md index 834f50ca733..c3ed5504ac5 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/status-history/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/status-history/index.md @@ -85,9 +85,9 @@ The data is converted as follows: {{< figure src="/static/img/docs/status-history-panel/status_history.png" max-width="1025px" alt="A status history panel with two time columns showing the status of two servers" >}} -## Zoom panel time range +## Pan and zoom panel time range -{{< docs/shared lookup="visualizations/panel-zoom.md" source="grafana" version="" >}} +{{< docs/shared lookup="visualizations/panel-pan-zoom.md" source="grafana" version="" >}} ## Configuration options diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/time-series/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/time-series/index.md index a171c88259a..86ff2290b48 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/time-series/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/time-series/index.md @@ -167,9 +167,9 @@ The following example shows three series: Min, Max, and Value. The Min and Max s {{< docs/shared lookup="visualizations/multiple-y-axes.md" source="grafana" version="" leveloffset="+2" >}} -## Zoom panel time range +## Pan and zoom panel time range -{{< docs/shared lookup="visualizations/panel-zoom.md" source="grafana" version="" >}} +{{< docs/shared lookup="visualizations/panel-pan-zoom.md" source="grafana" version="" >}} ## Configuration options diff --git a/e2e-playwright/alerting-suite/saved-searches.spec.ts b/e2e-playwright/alerting-suite/saved-searches.spec.ts index 28de805a5a1..b4f6431a56b 100644 --- a/e2e-playwright/alerting-suite/saved-searches.spec.ts +++ b/e2e-playwright/alerting-suite/saved-searches.spec.ts @@ -2,6 +2,15 @@ import { Page } from '@playwright/test'; import { test, expect } from '@grafana/plugin-e2e'; +// Enable required feature toggles for Saved Searches (part of RuleList.v2) +test.use({ + featureToggles: { + alertingListViewV2: true, + alertingFilterV2: true, + alertingSavedSearches: true, + }, +}); + /** * UI selectors for Saved Searches e2e tests. * Each selector is a function that takes the page and returns a locator. @@ -26,26 +35,50 @@ const ui = { // Indicators emptyState: (page: Page) => page.getByText(/no saved searches/i), - defaultIcon: (page: Page) => page.locator('[title="Default search"]'), + defaultIcon: (page: Page) => page.getByRole('img', { name: /default search/i }), duplicateError: (page: Page) => page.getByText(/already exists/i), }; /** - * Helper to clear saved searches storage. - * UserStorage uses localStorage as fallback, so we clear both potential keys. + * Helper to clear saved searches from UserStorage. + * UserStorage persists data server-side via k8s API, so we need to delete via API. */ async function clearSavedSearches(page: Page) { - await page.evaluate(() => { - // Clear localStorage keys that might contain saved searches - // UserStorage stores under 'grafana.userstorage.alerting' pattern - const keysToRemove = Object.keys(localStorage).filter( - (key) => key.includes('alerting') && (key.includes('savedSearches') || key.includes('userstorage')) - ); - keysToRemove.forEach((key) => localStorage.removeItem(key)); + // Get namespace and user info from Grafana config + const storageInfo = await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bootData = (window as any).grafanaBootData; + const user = bootData?.user; + const userUID = user?.uid === '' || !user?.uid ? String(user?.id ?? 'anonymous') : user.uid; + const resourceName = `alerting:${userUID}`; + const namespace = bootData?.settings?.namespace || 'default'; - // Also clear session storage visited flag - const sessionKeysToRemove = Object.keys(sessionStorage).filter((key) => key.includes('alerting')); - sessionKeysToRemove.forEach((key) => sessionStorage.removeItem(key)); + return { namespace, resourceName }; + }); + + // Delete the UserStorage resource + try { + await page.request.delete( + `/apis/userstorage.grafana.app/v0alpha1/namespaces/${storageInfo.namespace}/user-storage/${storageInfo.resourceName}` + ); + } catch (error) { + // Ignore 404 errors (resource doesn't exist) + if (!(error && typeof error === 'object' && 'status' in error && error.status === 404)) { + console.warn('Failed to clear saved searches:', error); + } + } + + // Also clear localStorage as fallback storage + await page.evaluate(({ resourceName }) => { + // The UserStorage key pattern is always `{resourceName}:{key}` + // For saved searches, the key is 'savedSearches' + const key = `${resourceName}:savedSearches`; + window.localStorage.removeItem(key); + }, storageInfo); + + // Clear session storage visited flag + await page.evaluate(() => { + window.sessionStorage.removeItem('grafana.alerting.ruleList.visited'); }); } @@ -150,7 +183,7 @@ test.describe( await ui.saveButton(page).click(); - await ui.saveNameInput(page).fill('Apply Test'); + await ui.saveNameInput(page).fill('Firing Rules'); await ui.saveConfirmButton(page).click(); // Clear the search @@ -159,7 +192,7 @@ test.describe( // Apply the saved search await ui.savedSearchesButton(page).click(); - await page.getByRole('button', { name: /apply search.*apply test/i }).click(); + await page.getByRole('button', { name: /apply.*search.*firing rules/i }).click(); // Verify the search input is updated await expect(ui.searchInput(page)).toHaveValue('state:firing'); @@ -182,7 +215,7 @@ test.describe( await ui.renameMenuItem(page).click(); // Enter new name - const renameInput = page.getByDisplayValue('Original Name'); + const renameInput = page.getByRole('textbox', { name: /enter a name/i }); await renameInput.clear(); await renameInput.fill('Renamed Search'); await page.keyboard.press('Enter'); @@ -260,12 +293,12 @@ test.describe( await expect(ui.saveNameInput(page)).toBeVisible(); - // Press Escape to cancel + // Press Escape to cancel - this closes the entire dropdown await page.keyboard.press('Escape'); - // Verify we're back to list mode - await expect(ui.saveNameInput(page)).not.toBeVisible(); - await expect(ui.saveButton(page)).toBeVisible(); + // Verify the entire dialog is closed + await expect(ui.dropdown(page)).not.toBeVisible(); + await expect(ui.saveButton(page)).not.toBeVisible(); }); } ); diff --git a/e2e-playwright/dashboard-cujs/adhoc-filters-cujs.spec.ts b/e2e-playwright/dashboard-cujs/adhoc-filters-cujs.spec.ts index 038fe099eeb..2a0f6932141 100644 --- a/e2e-playwright/dashboard-cujs/adhoc-filters-cujs.spec.ts +++ b/e2e-playwright/dashboard-cujs/adhoc-filters-cujs.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '@grafana/plugin-e2e'; -import { setScopes } from '../utils/scope-helpers'; +import { setScopes, setupScopeRoutes } from '../utils/scope-helpers'; +import { testScopes } from '../utils/scopes'; import { getAdHocFilterOptionValues, @@ -13,6 +14,7 @@ import { } from './cuj-selectors'; import { prepareAPIMocks } from './utils'; +const USE_LIVE_DATA = Boolean(process.env.API_CONFIG_PATH); const DASHBOARD_UNDER_TEST = 'cuj-dashboard-1'; test.use({ @@ -34,6 +36,11 @@ test.describe( const adHocFilterPills = getAdHocFilterPills(page); const scopesSelectorInput = getScopesSelectorInput(page); + // Set up routes before any navigation (only for mocked mode) + if (!USE_LIVE_DATA) { + await setupScopeRoutes(page, testScopes()); + } + await test.step('1.Apply filtering to a whole dashboard', async () => { const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST }); diff --git a/e2e-playwright/dashboard-cujs/cuj-selectors.ts b/e2e-playwright/dashboard-cujs/cuj-selectors.ts index f2366fd2ef1..548c183ef3f 100644 --- a/e2e-playwright/dashboard-cujs/cuj-selectors.ts +++ b/e2e-playwright/dashboard-cujs/cuj-selectors.ts @@ -66,6 +66,17 @@ export function getScopesDashboards(page: Page) { return page.locator('[data-testid^="scopes-dashboards-"][role="treeitem"]'); } +/** + * Clicks the first available dashboard in the scopes dashboard list. + */ +export async function clickFirstScopesDashboard(page: Page) { + const dashboards = getScopesDashboards(page); + // Wait for at least one dashboard to be visible + await expect(dashboards.first()).toBeVisible({ timeout: 10000 }); + // Click - Playwright will automatically wait for the element to be actionable + await dashboards.first().click(); +} + export function getScopesDashboardsSearchInput(page: Page) { return page.getByTestId('scopes-dashboards-search'); } diff --git a/e2e-playwright/dashboard-cujs/dashboard-navigation.spec.ts b/e2e-playwright/dashboard-cujs/dashboard-navigation.spec.ts index 008e1e5538c..c941c40f6ef 100644 --- a/e2e-playwright/dashboard-cujs/dashboard-navigation.spec.ts +++ b/e2e-playwright/dashboard-cujs/dashboard-navigation.spec.ts @@ -1,8 +1,10 @@ import { test, expect } from '@grafana/plugin-e2e'; -import { setScopes } from '../utils/scope-helpers'; +import { setScopes, setupScopeRoutes } from '../utils/scope-helpers'; +import { testScopes } from '../utils/scopes'; import { + clickFirstScopesDashboard, getAdHocFilterPills, getGroupByInput, getGroupByValues, @@ -21,6 +23,7 @@ test.use({ }, }); +const USE_LIVE_DATA = Boolean(process.env.API_CONFIG_PATH); const DASHBOARD_UNDER_TEST = 'cuj-dashboard-1'; const DASHBOARD_UNDER_TEST_2 = 'cuj-dashboard-2'; const NAVIGATE_TO = 'cuj-dashboard-3'; @@ -38,6 +41,11 @@ test.describe( const adhocFilterPills = getAdHocFilterPills(page); const groupByValues = getGroupByValues(page); + // Set up routes before any navigation (only for mocked mode) + if (!USE_LIVE_DATA) { + await setupScopeRoutes(page, testScopes()); + } + await test.step('1.Search dashboard', async () => { await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST }); @@ -74,7 +82,7 @@ test.describe( await expect(markdownContent).toContainText(`now-12h`); - await scopesDashboards.first().click(); + await clickFirstScopesDashboard(page); await page.waitForURL('**/d/**'); await expect(markdownContent).toBeVisible(); @@ -117,10 +125,10 @@ test.describe( await groupByVariable.press('Enter'); await groupByVariable.press('Escape'); - await expect(scopesDashboards.first()).toBeVisible(); - const { getRequests, waitForExpectedRequests } = await trackDashboardReloadRequests(page); - await scopesDashboards.first().click(); + + await clickFirstScopesDashboard(page); + await page.waitForURL('**/d/**'); await waitForExpectedRequests(); await page.waitForLoadState('networkidle'); @@ -158,8 +166,7 @@ test.describe( const oldFilters = `GroupByVar: ${selectedValues}\n\nAdHocVar: ${processedPills}`; await expect(markdownContent).toContainText(oldFilters); - await expect(scopesDashboards.first()).toBeVisible(); - await scopesDashboards.first().click(); + await clickFirstScopesDashboard(page); await page.waitForURL('**/d/**'); const newPillCount = await adhocFilterPills.count(); diff --git a/e2e-playwright/dashboard-cujs/dashboard-view.spec.ts b/e2e-playwright/dashboard-cujs/dashboard-view.spec.ts index 53dd02a0314..e9c1370bbc6 100644 --- a/e2e-playwright/dashboard-cujs/dashboard-view.spec.ts +++ b/e2e-playwright/dashboard-cujs/dashboard-view.spec.ts @@ -165,9 +165,8 @@ test.describe( await refreshBtn.click(); - await page.waitForLoadState('networkidle'); - - expect(await panelContent.textContent()).not.toBe(panelContents); + // Wait for the panel content to change (not just for network to complete) + await expect(panelContent).not.toHaveText(panelContents!, { timeout: 10000 }); }); await test.step('6.Turn off refresh', async () => { diff --git a/e2e-playwright/dashboard-cujs/scope-cujs.spec.ts b/e2e-playwright/dashboard-cujs/scope-cujs.spec.ts index 54ec3ca7a8b..dd1cd50c35f 100644 --- a/e2e-playwright/dashboard-cujs/scope-cujs.spec.ts +++ b/e2e-playwright/dashboard-cujs/scope-cujs.spec.ts @@ -9,6 +9,7 @@ import { openScopesSelector, searchScopes, selectScope, + setupScopeRoutes, } from '../utils/scope-helpers'; import { testScopes } from '../utils/scopes'; @@ -36,32 +37,37 @@ test.describe( const scopesSelector = getScopesSelectorInput(page); const recentScopesSelector = getRecentScopesSelector(page); const scopeTreeCheckboxes = getScopeTreeCheckboxes(page); + const scopes = testScopes(); + + // Set up routes once before any navigation (only for mocked mode) + if (!USE_LIVE_DATA) { + await setupScopeRoutes(page, scopes); + } await test.step('1.View and select any scope', async () => { await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST }); expect.soft(scopesSelector).toHaveAttribute('data-value', ''); - const scopes = testScopes(); - await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); //used only in mocked scopes version + await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); let scopeName = await getScopeTreeName(page, 0); - const firstLevelScopes = scopes[0].children!; //used only in mocked scopes version + const firstLevelScopes = scopes[0].children!; await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : firstLevelScopes); scopeName = await getScopeTreeName(page, 1); - const secondLevelScopes = firstLevelScopes[0].children!; //used only in mocked scopes version + const secondLevelScopes = firstLevelScopes[0].children!; await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : secondLevelScopes); - const selectedScopes = [secondLevelScopes[0]]; //used only in mocked scopes version + const selectedScopes = [secondLevelScopes[0]]; scopeName = await getScopeLeafName(page, 0); let scopeTitle = await getScopeLeafTitle(page, 0); await selectScope(page, scopeName, USE_LIVE_DATA ? undefined : selectedScopes[0]); - await applyScopes(page, USE_LIVE_DATA ? undefined : selectedScopes); //used only in mocked scopes version + await applyScopes(page, USE_LIVE_DATA ? undefined : selectedScopes); expect.soft(scopesSelector).toHaveAttribute('data-value', scopeTitle); }); @@ -70,28 +76,27 @@ test.describe( await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST }); expect.soft(scopesSelector).toHaveAttribute('data-value', ''); - const scopes = testScopes(); - await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); //used only in mocked scopes version + await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); let scopeName = await getScopeTreeName(page, 0); - const firstLevelScopes = scopes[0].children!; //used only in mocked scopes version + const firstLevelScopes = scopes[0].children!; await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : firstLevelScopes); scopeName = await getScopeTreeName(page, 1); - const secondLevelScopes = firstLevelScopes[0].children!; //used only in mocked scopes version + const secondLevelScopes = firstLevelScopes[0].children!; await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : secondLevelScopes); const scopeTitles: string[] = []; - const selectedScopes = [secondLevelScopes[0], secondLevelScopes[1]]; //used only in mocked scopes version + const selectedScopes = [secondLevelScopes[0], secondLevelScopes[1]]; for (let i = 0; i < selectedScopes.length; i++) { scopeName = await getScopeLeafName(page, i); scopeTitles.push(await getScopeLeafTitle(page, i)); - await selectScope(page, scopeName, USE_LIVE_DATA ? undefined : selectedScopes[i]); //used only in mocked scopes version + await selectScope(page, scopeName, USE_LIVE_DATA ? undefined : selectedScopes[i]); } - await applyScopes(page, USE_LIVE_DATA ? undefined : selectedScopes); //used only in mocked scopes version + await applyScopes(page, USE_LIVE_DATA ? undefined : selectedScopes); await expect.soft(scopesSelector).toHaveAttribute('data-value', scopeTitles.join(' + ')); }); @@ -102,8 +107,7 @@ test.describe( expect.soft(scopesSelector).toHaveAttribute('data-value', ''); - const scopes = testScopes(); - await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); //used only in mocked scopes version + await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); await recentScopesSelector.click(); @@ -121,26 +125,25 @@ test.describe( expect.soft(scopesSelector).toHaveAttribute('data-value', ''); - const scopes = testScopes(); await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); let scopeName = await getScopeTreeName(page, 1); - const firstLevelScopes = scopes[2].children!; //used only in mocked scopes version + const firstLevelScopes = scopes[2].children!; await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : firstLevelScopes); scopeName = await getScopeTreeName(page, 1); - const secondLevelScopes = firstLevelScopes[0].children!; //used only in mocked scopes version + const secondLevelScopes = firstLevelScopes[0].children!; await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : secondLevelScopes); - const selectedScopes = [secondLevelScopes[0]]; //used only in mocked scopes version + const selectedScopes = [secondLevelScopes[0]]; scopeName = await getScopeLeafName(page, 0); let scopeTitle = await getScopeLeafTitle(page, 0); await selectScope(page, scopeName, USE_LIVE_DATA ? undefined : selectedScopes[0]); - await applyScopes(page, USE_LIVE_DATA ? undefined : []); //used only in mocked scopes version + await applyScopes(page, USE_LIVE_DATA ? undefined : []); expect.soft(scopesSelector).toHaveAttribute('data-value', new RegExp(`^${scopeTitle}`)); }); @@ -148,17 +151,16 @@ test.describe( await test.step('5.View pre-completed production entity values as I type', async () => { await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST }); - const scopes = testScopes(); - await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); //used only in mocked scopes version + await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); let scopeName = await getScopeTreeName(page, 0); - const firstLevelScopes = scopes[0].children!; //used only in mocked scopes version + const firstLevelScopes = scopes[0].children!; await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : firstLevelScopes); scopeName = await getScopeTreeName(page, 1); - const secondLevelScopes = firstLevelScopes[0].children!; //used only in mocked scopes version + const secondLevelScopes = firstLevelScopes[0].children!; await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : secondLevelScopes); const scopeSearchOne = await getScopeLeafTitle(page, 0); diff --git a/e2e-playwright/dashboard-cujs/scope-redirect.spec.ts b/e2e-playwright/dashboard-cujs/scope-redirect.spec.ts index 952e8a3da63..b140e7a9838 100644 --- a/e2e-playwright/dashboard-cujs/scope-redirect.spec.ts +++ b/e2e-playwright/dashboard-cujs/scope-redirect.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '@grafana/plugin-e2e'; -import { applyScopes, openScopesSelector, selectScope } from '../utils/scope-helpers'; +import { applyScopes, openScopesSelector, selectScope, setupScopeRoutes } from '../utils/scope-helpers'; import { testScopesWithRedirect } from '../utils/scopes'; test.use({ @@ -16,8 +16,13 @@ test.describe('Scope Redirect Functionality', () => { test('should redirect to custom URL when scope has redirectUrl', async ({ page, gotoDashboardPage }) => { const scopes = testScopesWithRedirect(); - await test.step('Navigate to dashboard and open scopes selector', async () => { + await test.step('Set up routes and navigate to dashboard', async () => { + // Set up routes BEFORE navigation to ensure all requests are mocked + await setupScopeRoutes(page, scopes); await gotoDashboardPage({ uid: 'cuj-dashboard-1' }); + }); + + await test.step('Open scopes selector', async () => { await openScopesSelector(page, scopes); }); @@ -40,8 +45,12 @@ test.describe('Scope Redirect Functionality', () => { test('should prioritize redirectUrl over scope navigation fallback', async ({ page, gotoDashboardPage }) => { const scopes = testScopesWithRedirect(); - await test.step('Navigate to dashboard and open scopes selector', async () => { + await test.step('Set up routes and navigate to dashboard', async () => { + await setupScopeRoutes(page, scopes); await gotoDashboardPage({ uid: 'cuj-dashboard-1' }); + }); + + await test.step('Open scopes selector', async () => { await openScopesSelector(page, scopes); }); @@ -68,8 +77,12 @@ test.describe('Scope Redirect Functionality', () => { }) => { const scopes = testScopesWithRedirect(); - await test.step('Navigate to dashboard and select scope', async () => { + await test.step('Set up routes and navigate to dashboard', async () => { + await setupScopeRoutes(page, scopes); await gotoDashboardPage({ uid: 'cuj-dashboard-1' }); + }); + + await test.step('Select and apply scope', async () => { await openScopesSelector(page, scopes); await selectScope(page, 'sn-redirect-fallback', scopes[1]); await applyScopes(page, [scopes[1]]); @@ -112,8 +125,12 @@ test.describe('Scope Redirect Functionality', () => { }) => { const scopes = testScopesWithRedirect(); - await test.step('Navigate to dashboard and select scope', async () => { + await test.step('Set up routes and navigate to dashboard', async () => { + await setupScopeRoutes(page, scopes); await gotoDashboardPage({ uid: 'cuj-dashboard-1' }); + }); + + await test.step('Select and apply scope', async () => { await openScopesSelector(page, scopes); await selectScope(page, 'sn-redirect-fallback', scopes[1]); await applyScopes(page, [scopes[1]]); @@ -151,9 +168,13 @@ test.describe('Scope Redirect Functionality', () => { test('should not redirect to redirectPath when on active scope navigation', async ({ page, gotoDashboardPage }) => { const scopes = testScopesWithRedirect(); + await test.step('Set up routes and navigate to dashboard', async () => { + await setupScopeRoutes(page, scopes); + await gotoDashboardPage({ uid: 'cuj-dashboard-1' }); + }); + await test.step('Set up scope navigation to dashboard-1', async () => { // First, apply a scope that creates scope navigation to dashboard-1 (without redirectPath) - await gotoDashboardPage({ uid: 'cuj-dashboard-1' }); await openScopesSelector(page, scopes); await selectScope(page, 'sn-redirect-setup', scopes[2]); await applyScopes(page, [scopes[2]]); diff --git a/e2e-playwright/dashboard-new-layouts/dashboard-keybindings.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboard-keybindings.spec.ts index 19ad38f16d5..867337ba088 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboard-keybindings.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboard-keybindings.spec.ts @@ -51,6 +51,8 @@ test.describe('Dashboard keybindings with new layouts', { tag: ['@dashboards'] } await expect(dashboardPage.getByGrafanaSelector(selectors.components.PanelInspector.Json.content)).toBeVisible(); + // Press Escape to close tooltip on the close button + await page.keyboard.press('Escape'); // Press Escape to close inspector await page.keyboard.press('Escape'); diff --git a/e2e-playwright/dashboard-new-layouts/dashboards-repeats-custom-grid.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboards-repeats-custom-grid.spec.ts index 8cc1f552377..e60b722c46e 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboards-repeats-custom-grid.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboards-repeats-custom-grid.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '@grafana/plugin-e2e'; import testV2DashWithRepeats from '../dashboards/V2DashWithRepeats.json'; +import testV2DashWithRowRepeats from '../dashboards/V2DashWithRowRepeats.json'; import { checkRepeatedPanelTitles, @@ -10,11 +11,14 @@ import { saveDashboard, importTestDashboard, goToEmbeddedPanel, + goToPanelSnapshot, } from './utils'; const repeatTitleBase = 'repeat - '; const newTitleBase = 'edited rep - '; const repeatOptions = [1, 2, 3, 4]; +const getTitleInRepeatRow = (rowIndex: number, panelIndex: number) => + `repeated-row-${rowIndex}-repeated-panel-${panelIndex}`; test.use({ featureToggles: { @@ -165,9 +169,7 @@ test.describe( ) ).toBeVisible(); - await dashboardPage - .getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton) - .click(); + await page.keyboard.press('Escape'); await expect( dashboardPage.getByGrafanaSelector(selectors.components.DashboardEditPaneSplitter.primaryBody) @@ -217,9 +219,7 @@ test.describe( ) ).toBeVisible(); - await dashboardPage - .getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton) - .click(); + await page.keyboard.press('Escape'); await expect( dashboardPage.getByGrafanaSelector(selectors.components.DashboardEditPaneSplitter.primaryBody) @@ -405,5 +405,143 @@ test.describe( await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerContainer).all() ).toHaveLength(3); }); + + test('can view repeated panel in a repeated row', async ({ dashboardPage, selectors, page }) => { + await importTestDashboard( + page, + selectors, + 'Custom grid repeats - view repeated panel in a repeated row', + JSON.stringify(testV2DashWithRowRepeats) + ); + + // make sure the repeated panel is present in multiple rows + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1))) + ).toBeVisible(); + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2))) + ).toBeVisible(); + + await dashboardPage + .getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1))) + .hover(); + + await page.keyboard.press('v'); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2))) + ).not.toBeVisible(); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1))) + ).toBeVisible(); + + const repeatedPanelUrl = page.url(); + + await page.keyboard.press('Escape'); + + // load view panel directly + await page.goto(repeatedPanelUrl); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1))) + ).toBeVisible(); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2))) + ).not.toBeVisible(); + }); + + test('can view embedded panel in a repeated row', async ({ dashboardPage, selectors, page }) => { + const embedPanelTitle = 'embedded-panel'; + await importTestDashboard( + page, + selectors, + 'Custom grid repeats - view embedded repeated panel in a repeated row', + JSON.stringify(testV2DashWithRowRepeats) + ); + + await dashboardPage + .getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1))) + .hover(); + await page.keyboard.press('p+e'); + + await goToEmbeddedPanel(page); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1))) + ).toBeVisible(); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2))) + ).not.toBeVisible(); + }); + + // there is a bug in the Snapshot feature that prevents the next two tests from passing + // tracking issue: https://github.com/grafana/grafana/issues/114509 + test.skip('can view repeated panel inside snapshot', async ({ dashboardPage, selectors, page }) => { + await importTestDashboard( + page, + selectors, + 'Custom grid repeats - view repeated panel inside snapshot', + JSON.stringify(testV2DashWithRowRepeats) + ); + + await dashboardPage + .getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1))) + .hover(); + await page.keyboard.press('p+s'); + + // click "Publish snapshot" + await dashboardPage + .getByGrafanaSelector(selectors.pages.ShareDashboardDrawer.ShareSnapshot.publishSnapshot) + .click(); + + // click "Copy link" button in the snapshot drawer + await dashboardPage + .getByGrafanaSelector(selectors.pages.ShareDashboardDrawer.ShareSnapshot.copyUrlButton) + .click(); + + await goToPanelSnapshot(page); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1))) + ).toBeVisible(); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2))) + ).not.toBeVisible(); + }); + test.skip('can view single panel in a repeated row inside snapshot', async ({ dashboardPage, selectors, page }) => { + await importTestDashboard( + page, + selectors, + 'Custom grid repeats - view single panel inside snapshot', + JSON.stringify(testV2DashWithRowRepeats) + ); + + await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('single panel row 1')).hover(); + // open panel snapshot + await page.keyboard.press('p+s'); + + // click "Publish snapshot" + await dashboardPage + .getByGrafanaSelector(selectors.pages.ShareDashboardDrawer.ShareSnapshot.publishSnapshot) + .click(); + + // click "Copy link" button + await dashboardPage + .getByGrafanaSelector(selectors.pages.ShareDashboardDrawer.ShareSnapshot.copyUrlButton) + .click(); + + await goToPanelSnapshot(page); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('single panel row 1')) + ).toBeVisible(); + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1))) + ).toBeHidden(); + }); } ); diff --git a/e2e-playwright/dashboard-new-layouts/utils.ts b/e2e-playwright/dashboard-new-layouts/utils.ts index ade6825b7c1..d2f196f1466 100644 --- a/e2e-playwright/dashboard-new-layouts/utils.ts +++ b/e2e-playwright/dashboard-new-layouts/utils.ts @@ -218,6 +218,15 @@ export async function goToEmbeddedPanel(page: Page) { await page.goto(soloPanelUrl!); } +export async function goToPanelSnapshot(page: Page) { + // extracting snapshot url from clipboard + const snapshotUrl = await page.evaluate(() => navigator.clipboard.readText()); + + expect(snapshotUrl).toBeDefined(); + + await page.goto(snapshotUrl); +} + export async function moveTab( dashboardPage: DashboardPage, page: Page, diff --git a/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts b/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts index f874cefa27c..dd83b3a0cfd 100644 --- a/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts @@ -58,6 +58,8 @@ test.describe( await expect(dashboardPage.getByGrafanaSelector(selectors.components.PanelInspector.Json.content)).toBeVisible(); + // Press Escape to close tooltip on the close button + await page.keyboard.press('Escape'); // Press Escape to close inspector await page.keyboard.press('Escape'); diff --git a/e2e-playwright/dashboards/V2DashWithRowRepeats.json b/e2e-playwright/dashboards/V2DashWithRowRepeats.json new file mode 100644 index 00000000000..2438908b823 --- /dev/null +++ b/e2e-playwright/dashboards/V2DashWithRowRepeats.json @@ -0,0 +1,486 @@ +{ + "apiVersion": "dashboard.grafana.app/v2beta1", + "kind": "Dashboard", + "metadata": { + "name": "ad8l8fz", + "namespace": "default", + "uid": "fLb2na54K8NZHvn8LfWGL1jhZh03Hy0xpV1KzMYgAXEX", + "resourceVersion": "1", + "generation": 2, + "creationTimestamp": "2025-11-25T15:52:42Z", + "labels": { + "grafana.app/deprecatedInternalID": "20" + }, + "annotations": { + "grafana.app/createdBy": "user:aerwo725ot62od", + "grafana.app/updatedBy": "user:aerwo725ot62od", + "grafana.app/updatedTimestamp": "2025-11-25T15:52:42Z", + "grafana.app/folder": "" + } + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "builtIn": true, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "query": { + "datasource": { + "name": "-- Grafana --" + }, + "group": "grafana", + "kind": "DataQuery", + "spec": {}, + "version": "v0" + } + } + } + ], + "cursorSync": "Off", + "description": "", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "group": "", + "kind": "DataQuery", + "spec": {}, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 4, + "links": [], + "title": "repeated-row-$c4-repeated-panel-$c3", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "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", + "showValues": false, + "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" + } + } + }, + "version": "12.4.0-pre" + } + } + }, + "panel-2": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "group": "", + "kind": "DataQuery", + "spec": {}, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 2, + "links": [], + "title": "single panel row $c4", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "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", + "showValues": false, + "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" + } + } + }, + "version": "12.4.0-pre" + } + } + } + }, + "layout": { + "kind": "RowsLayout", + "spec": { + "rows": [ + { + "kind": "RowsLayoutRow", + "spec": { + "collapse": false, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-1" + }, + "height": 10, + "repeat": { + "direction": "h", + "mode": "variable", + "value": "c3" + }, + "width": 24, + "x": 0, + "y": 0 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-2" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 10 + } + } + ] + } + }, + "repeat": { + "mode": "variable", + "value": "c4" + }, + "title": "Repeated row $c4" + } + } + ] + } + }, + "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-e2e-repeats", + "variables": [ + { + "kind": "CustomVariable", + "spec": { + "allowCustomValue": true, + "current": { + "text": ["1", "2", "3", "4"], + "value": ["1", "2", "3", "4"] + }, + "hide": "dontHide", + "includeAll": true, + "multi": true, + "name": "c1", + "options": [ + { + "selected": true, + "text": "1", + "value": "1" + }, + { + "selected": true, + "text": "2", + "value": "2" + }, + { + "selected": true, + "text": "3", + "value": "3" + }, + { + "selected": true, + "text": "4", + "value": "4" + } + ], + "query": "1,2,3,4", + "skipUrlSync": false + } + }, + { + "kind": "CustomVariable", + "spec": { + "allowCustomValue": true, + "current": { + "text": ["A", "B", "C", "D"], + "value": ["A", "B", "C", "D"] + }, + "hide": "dontHide", + "includeAll": true, + "multi": true, + "name": "c2", + "options": [ + { + "selected": true, + "text": "A", + "value": "A" + }, + { + "selected": true, + "text": "B", + "value": "B" + }, + { + "selected": true, + "text": "C", + "value": "C" + }, + { + "selected": true, + "text": "D", + "value": "D" + } + ], + "query": "A,B,C,D", + "skipUrlSync": false + } + }, + { + "kind": "CustomVariable", + "spec": { + "allowCustomValue": true, + "current": { + "text": ["1", "2", "3", "4"], + "value": ["1", "2", "3", "4"] + }, + "hide": "dontHide", + "includeAll": false, + "multi": true, + "name": "c3", + "options": [ + { + "selected": true, + "text": "1", + "value": "1" + }, + { + "selected": true, + "text": "2", + "value": "2" + }, + { + "selected": true, + "text": "3", + "value": "3" + }, + { + "selected": true, + "text": "4", + "value": "4" + } + ], + "query": "1, 2, 3, 4", + "skipUrlSync": false + } + }, + { + "kind": "CustomVariable", + "spec": { + "allowCustomValue": true, + "current": { + "text": ["1", "2", "3", "4"], + "value": ["1", "2", "3", "4"] + }, + "hide": "dontHide", + "includeAll": false, + "multi": true, + "name": "c4", + "options": [ + { + "selected": true, + "text": "1", + "value": "1" + }, + { + "selected": true, + "text": "2", + "value": "2" + }, + { + "selected": true, + "text": "3", + "value": "3" + }, + { + "selected": true, + "text": "4", + "value": "4" + } + ], + "query": "1, 2, 3, 4", + "skipUrlSync": false + } + } + ] + }, + "status": {} +} diff --git a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts index 6dddba81820..ac10cb2b735 100644 --- a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts +++ b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts @@ -82,9 +82,9 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100); // click cell inspect, check that cell inspection pops open in the side as we'd expect. - await loremIpsumCell.getByLabel('Inspect value').click(); const loremIpsumText = await loremIpsumCell.textContent(); expect(loremIpsumText).toBeDefined(); + await loremIpsumCell.getByLabel('Inspect value').click(); await expect(page.getByRole('dialog').getByText(loremIpsumText!)).toBeVisible(); }); diff --git a/e2e-playwright/utils/scope-helpers.ts b/e2e-playwright/utils/scope-helpers.ts index fc88a79d8fa..df11644a396 100644 --- a/e2e-playwright/utils/scope-helpers.ts +++ b/e2e-playwright/utils/scope-helpers.ts @@ -6,7 +6,150 @@ import { Resource } from '../../public/app/features/apiserver/types'; import { testScopes } from './scopes'; -const USE_LIVE_DATA = Boolean(process.env.API_CALLS_CONFIG_PATH); +const USE_LIVE_DATA = Boolean(process.env.API_CONFIG_PATH); + +/** + * Sets up all scope-related API routes before navigation. + * This ensures that ALL scope API requests (including those made during initial page load) + * are intercepted by the mocks, preventing RTK Query from caching real API responses. + * + * Call this BEFORE navigating to a page (e.g., before gotoDashboardPage). + */ +export async function setupScopeRoutes(page: Page, scopes: TestScope[]): Promise { + // Route for scope node children (tree structure) + await page.route(`**/apis/scope.grafana.app/v0alpha1/namespaces/*/find/scope_node_children*`, async (route) => { + const url = new URL(route.request().url()); + const parentParam = url.searchParams.get('parent'); + const queryParam = url.searchParams.get('query'); + + // Find the appropriate scopes based on parent + let scopesToReturn = scopes; + if (parentParam) { + // Find nested scopes based on parent name + const findChildren = (items: TestScope[]): TestScope[] => { + for (const item of items) { + if (item.name === parentParam && item.children) { + return item.children; + } + if (item.children) { + const found = findChildren(item.children); + if (found.length > 0) { + return found; + } + } + } + return []; + }; + scopesToReturn = findChildren(scopes); + if (scopesToReturn.length === 0) { + scopesToReturn = scopes; // Fallback to root scopes + } + } + + // Filter by search query if provided + if (queryParam) { + const query = queryParam.toLowerCase(); + const filterByQuery = (items: TestScope[]): TestScope[] => { + const results: TestScope[] = []; + for (const item of items) { + // Exact match on name or title containing the query + if (item.name.toLowerCase() === query || item.title.toLowerCase() === query) { + results.push(item); + } else if (item.name.toLowerCase().includes(query) || item.title.toLowerCase().includes(query)) { + results.push(item); + } + // Also search in children + if (item.children) { + results.push(...filterByQuery(item.children)); + } + } + return results; + }; + scopesToReturn = filterByQuery(scopesToReturn); + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + apiVersion: 'scope.grafana.app/v0alpha1', + kind: 'FindScopeNodeChildrenResults', + metadata: {}, + items: scopesToReturn.map((scope) => ({ + kind: 'ScopeNode', + apiVersion: 'scope.grafana.app/v0alpha1', + metadata: { + name: scope.name, + namespace: 'default', + }, + spec: { + title: scope.title, + description: scope.title, + disableMultiSelect: scope.disableMultiSelect ?? false, + nodeType: scope.children ? 'container' : 'leaf', + ...(parentParam && { parentName: parentParam }), + ...((scope.addLinks || scope.children) && { + linkType: 'scope', + linkId: `scope-${scope.name}`, + }), + ...(scope.redirectPath && { redirectPath: scope.redirectPath }), + }, + })), + }), + }); + }); + + // Route for individual scope fetching + await page.route(`**/apis/scope.grafana.app/v0alpha1/namespaces/*/scopes/*`, async (route) => { + const url = route.request().url(); + const scopeName = url.split('/scopes/')[1]?.split('?')[0]; + + // Find the scope in the test data + const findScope = (items: TestScope[]): TestScope | undefined => { + for (const item of items) { + if (`scope-${item.name}` === scopeName) { + return item; + } + if (item.children) { + const found = findScope(item.children); + if (found) { + return found; + } + } + } + return undefined; + }; + + const scope = findScope(scopes); + + if (scope) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + kind: 'Scope', + apiVersion: 'scope.grafana.app/v0alpha1', + metadata: { + name: `scope-${scope.name}`, + namespace: 'default', + }, + spec: { + title: scope.title, + description: '', + filters: scope.filters, + category: scope.category, + type: scope.type, + }, + }), + }); + } else { + await route.fulfill({ status: 404 }); + } + }); + + // Note: Dashboard bindings and navigations routes are set up dynamically in applyScopes() + // with scope-specific URL patterns to avoid cache issues. They are not set up here. +} export type TestScope = { name: string; @@ -24,6 +167,9 @@ export type TestScope = { type ScopeDashboardBinding = Resource; +/** + * Sets up a route for scope node children requests and waits for the response. + */ export async function scopeNodeChildrenRequest( page: Page, scopes: TestScope[], @@ -68,10 +214,13 @@ export async function scopeNodeChildrenRequest( return page.waitForResponse((response) => response.url().includes(`/find/scope_node_children`)); } +/** + * Opens the scopes selector dropdown and waits for the tree to load. + */ export async function openScopesSelector(page: Page, scopes?: TestScope[]) { const click = async () => await page.getByTestId('scopes-selector-input').click(); - if (!scopes) { + if (!scopes || USE_LIVE_DATA) { await click(); return; } @@ -82,10 +231,13 @@ export async function openScopesSelector(page: Page, scopes?: TestScope[]) { await responsePromise; } +/** + * Expands a scope tree node and waits for children to load. + */ export async function expandScopesSelection(page: Page, parentScope: string, scopes?: TestScope[]) { const click = async () => await page.getByTestId(`scopes-tree-${parentScope}-expand`).click(); - if (!scopes) { + if (!scopes || USE_LIVE_DATA) { await click(); return; } @@ -96,6 +248,9 @@ export async function expandScopesSelection(page: Page, parentScope: string, sco await responsePromise; } +/** + * Sets up a route for individual scope requests and waits for the response. + */ export async function scopeSelectRequest(page: Page, selectedScope: TestScope): Promise { await page.route( `**/apis/scope.grafana.app/v0alpha1/namespaces/*/scopes/scope-${selectedScope.name}`, @@ -125,6 +280,9 @@ export async function scopeSelectRequest(page: Page, selectedScope: TestScope): return page.waitForResponse((response) => response.url().includes(`/scopes/scope-${selectedScope.name}`)); } +/** + * Selects a scope in the tree. + */ export async function selectScope(page: Page, scopeName: string, selectedScope?: TestScope) { const click = async () => { const element = page.locator( @@ -134,7 +292,7 @@ export async function selectScope(page: Page, scopeName: string, selectedScope?: await element.click({ force: true }); }; - if (!selectedScope) { + if (!selectedScope || USE_LIVE_DATA) { await click(); return; } @@ -145,14 +303,22 @@ export async function selectScope(page: Page, scopeName: string, selectedScope?: await responsePromise; } +/** + * Applies the selected scopes and waits for the selector to close and page to settle. + * Sets up routes dynamically with scope-specific URL patterns to avoid cache issues. + */ export async function applyScopes(page: Page, scopes?: TestScope[]) { const click = async () => { await page.getByTestId('scopes-selector-apply').scrollIntoViewIfNeeded(); await page.getByTestId('scopes-selector-apply').click({ force: true }); }; - if (!scopes) { + if (!scopes || USE_LIVE_DATA) { await click(); + // Wait for the apply button to disappear (selector closed) + await page.waitForSelector('[data-testid="scopes-selector-apply"]', { state: 'hidden', timeout: 5000 }); + // Wait for any resulting API calls (dashboard bindings, etc.) to complete + await page.waitForLoadState('networkidle'); return; } @@ -166,7 +332,7 @@ export async function applyScopes(page: Page, scopes?: TestScope[]) { const groups: string[] = ['Most relevant', 'Dashboards', 'Something else', '']; - // Mock scope_dashboard_bindings endpoint + // Mock scope_dashboard_bindings endpoint with scope-specific URL pattern await page.route(dashboardBindingsUrl, async (route) => { await route.fulfill({ status: 200, @@ -220,7 +386,7 @@ export async function applyScopes(page: Page, scopes?: TestScope[]) { }); }); - // Mock scope_navigations endpoint + // Mock scope_navigations endpoint with scope-specific URL pattern await page.route(scopeNavigationsUrl, async (route) => { await route.fulfill({ status: 200, @@ -266,21 +432,23 @@ export async function applyScopes(page: Page, scopes?: TestScope[]) { (response) => response.url().includes(`/find/scope_dashboard_bindings`) || response.url().includes(`/find/scope_navigations`) ); - const scopeRequestPromises: Array> = []; - - for (const scope of scopes) { - scopeRequestPromises.push(scopeSelectRequest(page, scope)); - } await click(); await responsePromise; - await Promise.all(scopeRequestPromises); + // Wait for the apply button to disappear (selector closed) + await page.waitForSelector('[data-testid="scopes-selector-apply"]', { state: 'hidden', timeout: 5000 }); + // Wait for any resulting API calls to complete + await page.waitForLoadState('networkidle'); } -export async function searchScopes(page: Page, value: string, resultScopes: TestScope[]) { +/** + * Searches for scopes in the tree and waits for results. + * Sets up a route dynamically with filtered results to return only matching scopes. + */ +export async function searchScopes(page: Page, value: string, resultScopes?: TestScope[]) { const click = async () => await page.getByTestId('scopes-tree-search').fill(value); - if (!resultScopes) { + if (!resultScopes || USE_LIVE_DATA) { await click(); return; } diff --git a/e2e/dashboards-search-suite/mode0.ini b/e2e/dashboards-search-suite/mode0.ini index 7248a2f81a2..2b38cd62c9b 100644 --- a/e2e/dashboards-search-suite/mode0.ini +++ b/e2e/dashboards-search-suite/mode0.ini @@ -3,7 +3,6 @@ [feature_toggles] unifiedStorageSearchUI = true grafanaAPIServerWithExperimentalAPIs = true -unifiedStorageSearchSprinkles = true [unified_storage] enable_search = true diff --git a/e2e/dashboards-search-suite/mode1.ini b/e2e/dashboards-search-suite/mode1.ini index 9875afbec80..b2e9da27c3d 100644 --- a/e2e/dashboards-search-suite/mode1.ini +++ b/e2e/dashboards-search-suite/mode1.ini @@ -3,7 +3,6 @@ [feature_toggles] unifiedStorageSearchUI = true grafanaAPIServerWithExperimentalAPIs = true -unifiedStorageSearchSprinkles = true [unified_storage] enable_search = true diff --git a/e2e/dashboards-search-suite/mode2-legacy-search-api.ini b/e2e/dashboards-search-suite/mode2-legacy-search-api.ini index 18bd29127a0..9517e105306 100644 --- a/e2e/dashboards-search-suite/mode2-legacy-search-api.ini +++ b/e2e/dashboards-search-suite/mode2-legacy-search-api.ini @@ -3,7 +3,6 @@ [feature_toggles] unifiedStorageSearchUI = false grafanaAPIServerWithExperimentalAPIs = true -unifiedStorageSearchSprinkles = true [unified_storage] enable_search = true diff --git a/e2e/dashboards-search-suite/mode2.ini b/e2e/dashboards-search-suite/mode2.ini index d255663299e..138e4960cef 100644 --- a/e2e/dashboards-search-suite/mode2.ini +++ b/e2e/dashboards-search-suite/mode2.ini @@ -3,7 +3,6 @@ [feature_toggles] unifiedStorageSearchUI = true grafanaAPIServerWithExperimentalAPIs = true -unifiedStorageSearchSprinkles = true [unified_storage] enable_search = true diff --git a/e2e/dashboards-search-suite/mode3.ini b/e2e/dashboards-search-suite/mode3.ini index dfc75a310b0..0835dcb3aaa 100644 --- a/e2e/dashboards-search-suite/mode3.ini +++ b/e2e/dashboards-search-suite/mode3.ini @@ -3,7 +3,6 @@ [feature_toggles] unifiedStorageSearchUI = true grafanaAPIServerWithExperimentalAPIs = true -unifiedStorageSearchSprinkles = true [unified_storage] enable_search = true diff --git a/e2e/dashboards-search-suite/mode4.ini b/e2e/dashboards-search-suite/mode4.ini index a73a0502353..675cc237298 100644 --- a/e2e/dashboards-search-suite/mode4.ini +++ b/e2e/dashboards-search-suite/mode4.ini @@ -3,7 +3,6 @@ [feature_toggles] unifiedStorageSearchUI = true grafanaAPIServerWithExperimentalAPIs = true -unifiedStorageSearchSprinkles = true [unified_storage] enable_search = true diff --git a/e2e/dashboards-search-suite/mode5.ini b/e2e/dashboards-search-suite/mode5.ini index a79ab97e792..78b43ee681e 100644 --- a/e2e/dashboards-search-suite/mode5.ini +++ b/e2e/dashboards-search-suite/mode5.ini @@ -3,7 +3,6 @@ [feature_toggles] unifiedStorageSearchUI = true grafanaAPIServerWithExperimentalAPIs = true -unifiedStorageSearchSprinkles = true [unified_storage] enable_search = true diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 597f2b94a1a..25d3225375e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1021,11 +1021,6 @@ "count": 2 } }, - "public/app/core/actions/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 4 - } - }, "public/app/core/components/AccessControl/PermissionList.tsx": { "no-restricted-syntax": { "count": 1 @@ -1161,11 +1156,6 @@ "count": 2 } }, - "public/app/core/config.ts": { - "no-barrel-files/no-barrel-files": { - "count": 2 - } - }, "public/app/core/navigation/types.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 @@ -1347,6 +1337,11 @@ "count": 2 } }, + "public/app/features/alerting/unified/api/onCallApi.test.ts": { + "no-restricted-syntax": { + "count": 2 + } + }, "public/app/features/alerting/unified/components/AnnotationDetailsField.tsx": { "@typescript-eslint/consistent-type-assertions": { "count": 1 @@ -1387,6 +1382,11 @@ "count": 1 } }, + "public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.test.tsx": { + "no-restricted-syntax": { + "count": 1 + } + }, "public/app/features/alerting/unified/components/import-to-gma/NamespaceAndGroupFilter.tsx": { "no-restricted-syntax": { "count": 2 @@ -1627,11 +1627,31 @@ "count": 1 } }, + "public/app/features/alerting/unified/mocks/server/configure.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, + "public/app/features/alerting/unified/mocks/server/handlers/plugins.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, + "public/app/features/alerting/unified/rule-editor/clone.utils.test.tsx": { + "no-restricted-syntax": { + "count": 2 + } + }, "public/app/features/alerting/unified/rule-editor/formDefaults.ts": { "no-restricted-syntax": { "count": 6 } }, + "public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "public/app/features/alerting/unified/types/alerting.ts": { "@typescript-eslint/no-explicit-any": { "count": 5 @@ -1642,6 +1662,16 @@ "count": 1 } }, + "public/app/features/alerting/unified/utils/config.test.ts": { + "no-restricted-syntax": { + "count": 6 + } + }, + "public/app/features/alerting/unified/utils/config.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "public/app/features/alerting/unified/utils/datasource.ts": { "no-restricted-syntax": { "count": 2 @@ -1673,12 +1703,20 @@ "count": 1 } }, + "public/app/features/alerting/unified/utils/rules.test.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "public/app/features/alerting/unified/utils/rules.ts": { "@typescript-eslint/consistent-type-assertions": { "count": 3 }, "@typescript-eslint/no-explicit-any": { "count": 1 + }, + "no-restricted-syntax": { + "count": 1 } }, "public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx": { @@ -1734,6 +1772,16 @@ "count": 1 } }, + "public/app/features/connections/components/AdvisorRedirectNotice/AdvisorRedirectNotice.test.tsx": { + "no-restricted-syntax": { + "count": 2 + } + }, + "public/app/features/connections/components/AdvisorRedirectNotice/AdvisorRedirectNotice.tsx": { + "no-restricted-syntax": { + "count": 1 + } + }, "public/app/features/connections/tabs/ConnectData/ConnectData.tsx": { "@typescript-eslint/consistent-type-assertions": { "count": 1 @@ -2073,6 +2121,11 @@ "count": 1 } }, + "public/app/features/dashboard/components/GenAI/utils.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "public/app/features/dashboard/components/HelpWizard/HelpWizard.tsx": { "no-restricted-syntax": { "count": 3 @@ -2610,11 +2663,6 @@ "count": 2 } }, - "public/app/features/explore/hooks/useStateSync/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 1 - } - }, "public/app/features/explore/spec/helper/setup.tsx": { "@typescript-eslint/no-explicit-any": { "count": 1 @@ -2904,6 +2952,71 @@ "count": 1 } }, + "public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts": { + "no-restricted-syntax": { + "count": 6 + } + }, + "public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts": { + "no-restricted-syntax": { + "count": 6 + } + }, + "public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts": { + "no-restricted-syntax": { + "count": 6 + } + }, + "public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts": { + "no-restricted-syntax": { + "count": 6 + } + }, + "public/app/features/plugins/extensions/usePluginComponent.test.tsx": { + "no-restricted-syntax": { + "count": 3 + } + }, + "public/app/features/plugins/extensions/usePluginComponents.test.tsx": { + "no-restricted-syntax": { + "count": 2 + } + }, + "public/app/features/plugins/extensions/usePluginFunctions.test.tsx": { + "no-restricted-syntax": { + "count": 2 + } + }, + "public/app/features/plugins/extensions/usePluginLinks.test.tsx": { + "no-restricted-syntax": { + "count": 2 + } + }, + "public/app/features/plugins/extensions/utils.test.tsx": { + "no-restricted-syntax": { + "count": 27 + } + }, + "public/app/features/plugins/extensions/utils.tsx": { + "no-restricted-syntax": { + "count": 7 + } + }, + "public/app/features/plugins/extensions/validators.test.tsx": { + "no-restricted-syntax": { + "count": 30 + } + }, + "public/app/features/plugins/extensions/validators.ts": { + "no-restricted-syntax": { + "count": 4 + } + }, + "public/app/features/plugins/sandbox/codeLoader.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "public/app/features/plugins/sandbox/distortions.ts": { "@typescript-eslint/consistent-type-assertions": { "count": 1 @@ -3630,46 +3743,21 @@ "count": 1 } }, - "public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, "public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/aggregations.ts": { "@typescript-eslint/consistent-type-assertions": { "count": 1 } }, - "public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, "public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/MetricEditor.tsx": { "@typescript-eslint/consistent-type-assertions": { "count": 1 } }, - "public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 2 - } - }, "public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/aggregations.ts": { "@typescript-eslint/consistent-type-assertions": { "count": 1 } }, - "public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, "public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx": { "no-restricted-syntax": { "count": 1 @@ -4030,11 +4118,6 @@ "count": 1 } }, - "public/app/plugins/datasource/parca/webpack.config.ts": { - "no-barrel-files/no-barrel-files": { - "count": 1 - } - }, "public/app/plugins/datasource/prometheus/configuration/AzureAuthSettings.tsx": { "no-restricted-syntax": { "count": 1 @@ -4103,7 +4186,7 @@ "count": 1 }, "@typescript-eslint/no-explicit-any": { - "count": 2 + "count": 1 } }, "public/app/plugins/datasource/tempo/resultTransformer.ts": { diff --git a/eslint.config.js b/eslint.config.js index bd1be26465a..479f11aac66 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -117,6 +117,8 @@ module.exports = [ 'scripts/grafana-server/tmp', 'packages/grafana-ui/src/graveyard', // deprecated UI components slated for removal 'public/build-swagger', // swagger build output + 'apps/plugins/plugin/src/generated/meta/v0alpha1', + 'apps/plugins/plugin/src/generated/plugin/v0alpha1', ], }, ...grafanaConfig, @@ -575,6 +577,42 @@ module.exports = [ "Property[key.name='a11y'][value.type='ObjectExpression'] Property[key.name='test'][value.value='off']", message: 'Skipping a11y tests is not allowed. Please fix the component or story instead.', }, + { + selector: 'MemberExpression[object.name="config"][property.name="apps"]', + message: + 'Usage of config.apps is not allowed. Use the function getAppPluginMetas or useAppPluginMetas from @grafana/runtime instead', + }, + ], + }, + }, + { + files: [...commonTestIgnores], + ignores: [ + // FIXME: Remove once all enterprise issues are fixed - + // we don't have a suppressions file/approach for enterprise code yet + ...enterpriseIgnores, + ], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: 'MemberExpression[object.name="config"][property.name="apps"]', + message: + 'Usage of config.apps is not allowed. Use the function getAppPluginMetas or useAppPluginMetas from @grafana/runtime instead', + }, + ], + }, + }, + { + files: [...enterpriseIgnores], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: 'MemberExpression[object.name="config"][property.name="apps"]', + message: + 'Usage of config.apps is not allowed. Use the function getAppPluginMetas or useAppPluginMetas from @grafana/runtime instead', + }, ], }, }, @@ -585,6 +623,8 @@ module.exports = [ // FIXME: Remove once all enterprise issues are fixed - // we don't have a suppressions file/approach for enterprise code yet ...enterpriseIgnores, + // Ignore decoupled plugin webpack configs + 'public/app/**/webpack.config.ts', ], rules: { 'no-barrel-files/no-barrel-files': 'error', diff --git a/go.mod b/go.mod index 4d36534519e..ade26f2e7d1 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,6 @@ require ( github.com/Masterminds/semver v1.5.0 // @grafana/grafana-backend-group github.com/Masterminds/semver/v3 v3.4.0 // @grafana/grafana-developer-enablement-squad github.com/Masterminds/sprig/v3 v3.3.0 // @grafana/grafana-backend-group - github.com/ProtonMail/go-crypto v1.1.6 // @grafana/plugins-platform-backend github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f // @grafana/grafana-backend-group github.com/alicebob/miniredis/v2 v2.34.0 // @grafana/alerting-backend github.com/andybalholm/brotli v1.2.0 // @grafana/partner-datasources @@ -33,20 +32,20 @@ require ( github.com/armon/go-radix v1.0.0 // @grafana/grafana-app-platform-squad github.com/aws/aws-sdk-go v1.55.7 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2 v1.40.0 // @grafana/aws-datasources - github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // @grafana/grafana-operator-experience-squad + github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect; @grafana/grafana-operator-experience-squad github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.45.3 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/oam v1.18.3 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1 // @grafana/grafana-operator-experience-squad - github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // @grafana/grafana-operator-experience-squad + github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect; @grafana/grafana-operator-experience-squad github.com/aws/smithy-go v1.23.2 // @grafana/aws-datasources github.com/beevik/etree v1.4.1 // @grafana/grafana-backend-group github.com/benbjohnson/clock v1.3.5 // @grafana/alerting-backend github.com/blang/semver/v4 v4.0.0 // indirect; @grafana/grafana-developer-enablement-squad - github.com/blevesearch/bleve/v2 v2.5.0 // @grafana/grafana-search-and-storage - github.com/blevesearch/bleve_index_api v1.2.7 // @grafana/grafana-search-and-storage + github.com/blevesearch/bleve/v2 v2.5.7 // @grafana/grafana-search-and-storage + github.com/blevesearch/bleve_index_api v1.3.0 // @grafana/grafana-search-and-storage github.com/blugelabs/bluge v0.2.2 // @grafana/grafana-backend-group github.com/blugelabs/bluge_segment_api v0.2.0 // @grafana/grafana-backend-group github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // @grafana/grafana-backend-group @@ -83,14 +82,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 // indirect; @grafana/grafana-git-ui-sync-team + 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.7.0 // @grafana/grafana-backend-group github.com/googleapis/gax-go/v2 v2.15.0 // @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-20251231150637-b7821017d69f // @grafana/alerting-backend + github.com/grafana/alerting v0.0.0-20260112172717-98a49ed9557f // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @grafana/identity-access-team github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // @grafana/identity-access-team github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics @@ -114,14 +113,14 @@ require ( github.com/grafana/otel-profiling-go v0.5.1 // @grafana/grafana-backend-group github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // @grafana/observability-traces-and-profiling github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f // @grafana/observability-traces-and-profiling + github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec // @grafana/observability-traces-and-profiling github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // @grafana/grafana-search-and-storage github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // @grafana/plugins-platform-backend github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // @grafana/grafana-backend-group github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // @grafana/identity-access-team github.com/hashicorp/go-hclog v1.6.3 // @grafana/plugins-platform-backend github.com/hashicorp/go-multierror v1.1.1 // @grafana/alerting-squad - github.com/hashicorp/go-plugin v1.7.0 // @grafana/plugins-platform-backend - github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2 // @grafana/plugins-platform-backend + github.com/hashicorp/go-plugin v1.7.0 // indirect; @grafana/plugins-platform-backend github.com/hashicorp/go-version v1.7.0 // @grafana/grafana-backend-group github.com/hashicorp/golang-lru/v2 v2.0.7 // @grafana/alerting-backend github.com/hashicorp/hcl/v2 v2.24.0 // @grafana/alerting-backend @@ -250,7 +249,6 @@ require ( github.com/grafana/grafana/apps/example v0.0.0-20251027162426-edef69fdc82b // @grafana/grafana-app-platform-squad github.com/grafana/grafana/apps/folder v0.0.0 // @grafana/grafana-search-and-storage github.com/grafana/grafana/apps/iam v0.0.0 // @grafana/identity-access-team - github.com/grafana/grafana/apps/investigations v0.0.0 // @fcjack @matryer github.com/grafana/grafana/apps/logsdrilldown v0.0.0 // @grafana/observability-logs github.com/grafana/grafana/apps/playlist v0.0.0 // @grafana/grafana-app-platform-squad github.com/grafana/grafana/apps/plugins v0.0.0 // @grafana/plugins-platform-backend @@ -263,12 +261,13 @@ require ( github.com/grafana/grafana/pkg/aggregator v0.0.0 // @grafana/grafana-app-platform-squad github.com/grafana/grafana/pkg/apimachinery v0.0.0 // @grafana/grafana-app-platform-squad github.com/grafana/grafana/pkg/apiserver v0.0.0 // @grafana/grafana-app-platform-squad + github.com/grafana/grafana/pkg/plugins v0.0.0 // @grafana/plugins-platform-backend // This needs to be here for other projects that import grafana/grafana // For local development grafana/grafana will always use the local files // 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-20250804150913-990f1c69ecc2 // @grafana/grafana-app-platform-squad + github.com/grafana/grafana/pkg/semconv v0.0.0 // @grafana/grafana-app-platform-squad ) // Replace the workspace versions @@ -284,7 +283,6 @@ replace ( github.com/grafana/grafana/apps/dashboard => ./apps/dashboard github.com/grafana/grafana/apps/folder => ./apps/folder github.com/grafana/grafana/apps/iam => ./apps/iam - github.com/grafana/grafana/apps/investigations => ./apps/investigations github.com/grafana/grafana/apps/logsdrilldown => ./apps/logsdrilldown github.com/grafana/grafana/apps/playlist => ./apps/playlist github.com/grafana/grafana/apps/plugins => ./apps/plugins @@ -298,6 +296,8 @@ replace ( github.com/grafana/grafana/pkg/aggregator => ./pkg/aggregator github.com/grafana/grafana/pkg/apimachinery => ./pkg/apimachinery github.com/grafana/grafana/pkg/apiserver => ./pkg/apiserver + github.com/grafana/grafana/pkg/plugins => ./pkg/plugins + github.com/grafana/grafana/pkg/semconv => ./pkg/semconv ) require ( @@ -365,22 +365,22 @@ require ( 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/geo v0.2.4 // indirect + github.com/blevesearch/go-faiss v1.0.26 // indirect github.com/blevesearch/go-porterstemmer v1.0.3 // indirect github.com/blevesearch/gtreap v0.1.1 // indirect github.com/blevesearch/mmap-go v1.0.4 // indirect - github.com/blevesearch/scorch_segment_api/v2 v2.3.9 // indirect + github.com/blevesearch/scorch_segment_api/v2 v2.3.13 // indirect github.com/blevesearch/segment v0.9.1 // indirect github.com/blevesearch/snowballstem v0.9.0 // indirect github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect github.com/blevesearch/vellum v1.1.0 // indirect - github.com/blevesearch/zapx/v11 v11.4.1 // indirect - github.com/blevesearch/zapx/v12 v12.4.1 // indirect - github.com/blevesearch/zapx/v13 v13.4.1 // indirect - github.com/blevesearch/zapx/v14 v14.4.1 // indirect - github.com/blevesearch/zapx/v15 v15.4.1 // indirect - github.com/blevesearch/zapx/v16 v16.2.2 // indirect + github.com/blevesearch/zapx/v11 v11.4.2 // indirect + github.com/blevesearch/zapx/v12 v12.4.2 // indirect + github.com/blevesearch/zapx/v13 v13.4.2 // indirect + github.com/blevesearch/zapx/v14 v14.4.2 // indirect + github.com/blevesearch/zapx/v15 v15.4.2 // indirect + github.com/blevesearch/zapx/v16 v16.2.8 // indirect github.com/bluele/gcache v0.0.2 // indirect github.com/blugelabs/ice v1.0.0 // indirect github.com/blugelabs/ice/v2 v2.0.1 // indirect @@ -395,7 +395,6 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cheekybits/genny v1.0.0 // indirect github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d // indirect - github.com/cloudflare/circl v1.6.1 // indirect github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect github.com/cockroachdb/apd/v3 v3.2.1 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -444,7 +443,6 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.0 // 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 github.com/gomodule/redigo v1.8.9 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.26.1 // indirect @@ -492,7 +490,6 @@ require ( github.com/jhump/protoreflect v1.17.0 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6 // indirect github.com/jtolds/gls v4.20.0+incompatible // indirect @@ -658,13 +655,12 @@ require ( sigs.k8s.io/yaml v1.6.0 // indirect ) -require github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec // @grafana/observability-traces-and-profiling - -require github.com/Machiel/slugify v1.0.1 // @grafana/plugins-platform-backend - require ( - github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/IBM/pgxpoolprometheus v1.1.2 // indirect + github.com/Machiel/slugify v1.0.1 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/cloudflare/circl v1.6.1 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect @@ -684,6 +680,8 @@ require ( github.com/google/gnostic v0.7.1 // indirect github.com/gophercloud/gophercloud/v2 v2.9.0 // indirect github.com/grafana/sqlds/v5 v5.0.3 // indirect + github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2 // indirect + github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 // indirect github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/moby/go-archive v0.1.0 // indirect @@ -705,7 +703,7 @@ require ( replace github.com/crewjam/saml => github.com/grafana/saml v0.4.15-0.20240917091248-ae3bbdad8a56 // Use our fork of the upstream Alertmanager. -replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 +replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f exclude github.com/mattn/go-sqlite3 v2.0.3+incompatible diff --git a/go.sum b/go.sum index a58e4a8c177..f997af7c68e 100644 --- a/go.sum +++ b/go.sum @@ -679,8 +679,8 @@ github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1/go.mod h1:8cl44BDmi+ github.com/Azure/azure-storage-blob-go v0.15.0 h1:rXtgp8tN1p29GvpGgfJetavIG0V7OgcSXPpwp3tx6qk= github.com/Azure/azure-storage-blob-go v0.15.0/go.mod h1:vbjsVbX0dlxnRc4FFMPsS9BsJWPcne7GB7onqlPvz58= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-autorest v11.2.8+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= @@ -762,8 +762,8 @@ github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.5 h1:zl/OfRA6nftbBK9qTohYBJ5xvw6C/oNKizR7cZGl3cI= github.com/OneOfOne/xxhash v1.2.5/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= -github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= -github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= @@ -931,14 +931,14 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn 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= -github.com/blevesearch/bleve/v2 v2.5.0/go.mod h1:PcJzTPnEynO15dCf9isxOga7YFRa/cMSsbnRwnszXUk= -github.com/blevesearch/bleve_index_api v1.2.7 h1:c8r9vmbaYQroAMSGag7zq5gEVPiuXrUQDqfnj7uYZSY= -github.com/blevesearch/bleve_index_api v1.2.7/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0= -github.com/blevesearch/geo v0.1.20 h1:paaSpu2Ewh/tn5DKn/FB5SzvH0EWupxHEIwbCk/QPqM= -github.com/blevesearch/geo v0.1.20/go.mod h1:DVG2QjwHNMFmjo+ZgzrIq2sfCh6rIHzy9d9d0B59I6w= -github.com/blevesearch/go-faiss v1.0.25 h1:lel1rkOUGbT1CJ0YgzKwC7k+XH0XVBHnCVWahdCXk4U= -github.com/blevesearch/go-faiss v1.0.25/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk= +github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8= +github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA= +github.com/blevesearch/bleve_index_api v1.3.0 h1:DsMpWVjFNlBw9/6pyWf59XoqcAkhHj3H0UWiQsavb6E= +github.com/blevesearch/bleve_index_api v1.3.0/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko= +github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk= +github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8= +github.com/blevesearch/go-faiss v1.0.26 h1:4dRLolFgjPyjkaXwff4NfbZFdE/dfywbzDqporeQvXI= +github.com/blevesearch/go-faiss v1.0.26/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk= github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo= github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M= github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y= @@ -947,8 +947,8 @@ github.com/blevesearch/mmap-go v1.0.2/go.mod h1:ol2qBqYaOUsGdm7aRMRrYGgPvnwLe6Y+ github.com/blevesearch/mmap-go v1.0.3/go.mod h1:pYvKl/grLQrBxuaRYgoTssa4rVujYYeenDp++2E+yvs= github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc= github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs= -github.com/blevesearch/scorch_segment_api/v2 v2.3.9 h1:X6nJXnNHl7nasXW+U6y2Ns2Aw8F9STszkYkyBfQ+p0o= -github.com/blevesearch/scorch_segment_api/v2 v2.3.9/go.mod h1:IrzspZlVjhf4X29oJiEhBxEteTqOY9RlYlk1lCmYHr4= +github.com/blevesearch/scorch_segment_api/v2 v2.3.13 h1:ZPjv/4VwWvHJZKeMSgScCapOy8+DdmsmRyLmSB88UoY= +github.com/blevesearch/scorch_segment_api/v2 v2.3.13/go.mod h1:ENk2LClTehOuMS8XzN3UxBEErYmtwkE7MAArFTXs9Vc= github.com/blevesearch/segment v0.9.0/go.mod h1:9PfHYUdQCgHktBgvtUOF4x+pc4/l8rdH0u5spnW85UQ= github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU= github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw= @@ -960,18 +960,18 @@ github.com/blevesearch/vellum v1.0.5/go.mod h1:atE0EH3fvk43zzS7t1YNdNC7DbmcC3uz+ github.com/blevesearch/vellum v1.0.7/go.mod h1:doBZpmRhwTsASB4QdUZANlJvqVAUdUyX0ZK7QJCTeBE= github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w= github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y= -github.com/blevesearch/zapx/v11 v11.4.1 h1:qFCPlFbsEdwbbckJkysptSQOsHn4s6ZOHL5GMAIAVHA= -github.com/blevesearch/zapx/v11 v11.4.1/go.mod h1:qNOGxIqdPC1MXauJCD9HBG487PxviTUUbmChFOAosGs= -github.com/blevesearch/zapx/v12 v12.4.1 h1:K77bhypII60a4v8mwvav7r4IxWA8qxhNjgF9xGdb9eQ= -github.com/blevesearch/zapx/v12 v12.4.1/go.mod h1:QRPrlPOzAxBNMI0MkgdD+xsTqx65zbuPr3Ko4Re49II= -github.com/blevesearch/zapx/v13 v13.4.1 h1:EnkEMZFUK0lsW/jOJJF2xOcp+W8TjEsyeN5BeAZEYYE= -github.com/blevesearch/zapx/v13 v13.4.1/go.mod h1:e6duBMlCvgbH9rkzNMnUa9hRI9F7ri2BRcHfphcmGn8= -github.com/blevesearch/zapx/v14 v14.4.1 h1:G47kGCshknBZzZAtjcnIAMn3oNx8XBLxp8DMq18ogyE= -github.com/blevesearch/zapx/v14 v14.4.1/go.mod h1:O7sDxiaL2r2PnCXbhh1Bvm7b4sP+jp4unE9DDPWGoms= -github.com/blevesearch/zapx/v15 v15.4.1 h1:B5IoTMUCEzFdc9FSQbhVOxAY+BO17c05866fNruiI7g= -github.com/blevesearch/zapx/v15 v15.4.1/go.mod h1:b/MreHjYeQoLjyY2+UaM0hGZZUajEbE0xhnr1A2/Q6Y= -github.com/blevesearch/zapx/v16 v16.2.2 h1:MifKJVRTEhMTgSlle2bDRTb39BGc9jXFRLPZc6r0Rzk= -github.com/blevesearch/zapx/v16 v16.2.2/go.mod h1:B9Pk4G1CqtErgQV9DyCSA9Lb7WZe4olYfGw7fVDZ4sk= +github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs= +github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc= +github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE= +github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58= +github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks= +github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk= +github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0= +github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8= +github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k= +github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= +github.com/blevesearch/zapx/v16 v16.2.8 h1:SlnzF0YGtSlrsOE3oE7EgEX6BIepGpeqxs1IjMbHLQI= +github.com/blevesearch/zapx/v16 v16.2.8/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14= github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw= github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0= github.com/blugelabs/bluge v0.2.2 h1:gat8CqE6P6tOgeX30XGLOVNTC26cpM2RWVcreXWtYcM= @@ -1442,8 +1442,6 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2V github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= -github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 h1:gtexQ/VGyN+VVFRXSFiguSNcXmS6rkKT+X7FdIrTtfo= -github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ= @@ -1627,8 +1625,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-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts= -github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20260112172717-98a49ed9557f h1:3bXOyht68qkfvD6Y8z8XoenFbytSSOIkr/s+AqRzj0o= +github.com/grafana/alerting v0.0.0-20260112172717-98a49ed9557f/go.mod h1:Ji0SfJChcwjgq8ljy6Y5CcYfHfAYKXjKYeysOoDS/6s= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= @@ -1671,8 +1669,6 @@ github.com/grafana/grafana/apps/quotas v0.0.0-20251209183543-1013d74f13f2 h1:rDP github.com/grafana/grafana/apps/quotas v0.0.0-20251209183543-1013d74f13f2/go.mod h1:M7bV60iRB61y0ISPG1HX/oNLZtlh0ZF22rUYwNkAKjo= github.com/grafana/grafana/pkg/promlib v0.0.8 h1:VUWsqttdf0wMI4j9OX9oNrykguQpZcruudDAFpJJVw0= github.com/grafana/grafana/pkg/promlib v0.0.8/go.mod h1:U1ezG/MGaEPoThqsr3lymMPN5yIPdVTJnDZ+wcXT+ao= -github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 h1:A65jWgLk4Re28gIuZcpC0aTh71JZ0ey89hKGE9h543s= -github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2/go.mod h1:2HRzUK/xQEYc+8d5If/XSusMcaYq9IptnBSHACiQcOQ= github.com/grafana/jsonparser v0.0.0-20240425183733-ea80629e1a32 h1:NznuPwItog+rwdVg8hAuGKP29ndRSzJAwhxKldkP8oQ= github.com/grafana/jsonparser v0.0.0-20240425183733-ea80629e1a32/go.mod h1:796sq+UcONnSlzA3RtlBZ+b/hrerkZXiEmO8oMjyRwY= github.com/grafana/loki/pkg/push v0.0.0-20250823105456-332df2b20000 h1:/5LKSYgLmAhwA4m6iGUD4w1YkydEWWjazn9qxCFT8W0= @@ -1683,8 +1679,8 @@ github.com/grafana/nanogit v0.3.0 h1:XNEef+4Vi+465ZITJs/g/xgnDRJbWhhJ7iQrAnWZ0oQ github.com/grafana/nanogit v0.3.0/go.mod h1:6s6CCTpyMOHPpcUZaLGI+rgBEKdmxVbhqSGgCK13j7Y= github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= -github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 h1:aXfUhVN/Ewfpbko2CCtL65cIiGgwStOo4lWH2b6gw2U= -github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f h1:9tRhudagkQO2s61SLFLSziIdCm7XlkfypVKDxpcHokg= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f/go.mod h1:AsVdCBeDFN9QbgpJg+8voDAcgsW0RmNvBd70ecMMdC0= github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f h1:fTlIj5n4x5dU63XHItug7GLjtnaeJdPqBlqg4zlABq0= diff --git a/go.work b/go.work index 884f393673e..462bbb36a04 100644 --- a/go.work +++ b/go.work @@ -17,7 +17,6 @@ use ( ./apps/example ./apps/folder ./apps/iam - ./apps/investigations ./apps/logsdrilldown ./apps/playlist ./apps/plugins @@ -33,11 +32,12 @@ use ( ./pkg/build ./pkg/build/wire // skip:golangci-lint ./pkg/codegen + ./pkg/plugins ./pkg/plugins/codegen ./pkg/promlib ./pkg/semconv ) -replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 +replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f replace github.com/crewjam/saml => github.com/grafana/saml v0.4.15-0.20240917091248-ae3bbdad8a56 diff --git a/go.work.sum b/go.work.sum index 5ddf6932296..6388285d2bf 100644 --- a/go.work.sum +++ b/go.work.sum @@ -259,6 +259,7 @@ codeberg.org/go-latex/latex v0.1.0 h1:hoGO86rIbWVyjtlDLzCqZPjNykpWQ9YuTZqAzPcfL3 codeberg.org/go-latex/latex v0.1.0/go.mod h1:LA0q/AyWIYrqVd+A9Upkgsb+IqPcmSTKc9Dny04MHMw= codeberg.org/go-pdf/fpdf v0.10.0 h1:u+w669foDDx5Ds43mpiiayp40Ov6sZalgcPMDBcZRd4= codeberg.org/go-pdf/fpdf v0.10.0/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU= +connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= contrib.go.opencensus.io/exporter/ocagent v0.6.0 h1:Z1n6UAyr0QwM284yUuh5Zd8JlvxUGAhFZcgMJkMPrGM= contrib.go.opencensus.io/exporter/prometheus v0.4.0/go.mod h1:o7cosnyfuPVK0tB8q0QmaQNhGnptITnPQB+z1+qeFB0= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= @@ -519,14 +520,40 @@ github.com/benbjohnson/immutable v0.4.0 h1:CTqXbEerYso8YzVPxmWxh2gnoRQbbB9X1quUC github.com/benbjohnson/immutable v0.4.0/go.mod h1:iAr8OjJGLnLmVUr9MZ/rz4PWUy6Ouc2JLYuMArmvAJM= github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY= +github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8= +github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA= +github.com/blevesearch/bleve_index_api v1.2.8/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0= +github.com/blevesearch/bleve_index_api v1.2.11 h1:bXQ54kVuwP8hdrXUSOnvTQfgK0KI1+f9A0ITJT8tX1s= +github.com/blevesearch/bleve_index_api v1.2.11/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0= +github.com/blevesearch/bleve_index_api v1.3.0 h1:DsMpWVjFNlBw9/6pyWf59XoqcAkhHj3H0UWiQsavb6E= +github.com/blevesearch/bleve_index_api v1.3.0/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko= +github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk= +github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8= +github.com/blevesearch/go-faiss v1.0.26/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk= github.com/blevesearch/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:kDy+zgJFJJoJYBvdfBSiZYBbdsUL0XcjHYWezpQBGPA= github.com/blevesearch/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:9eJDeqxJ3E7WnLebQUlPD7ZjSce7AnDb9vjGmMCbD0A= github.com/blevesearch/goleveldb v1.0.1 h1:iAtV2Cu5s0GD1lwUiekkFHe2gTMCCNVj2foPclDLIFI= github.com/blevesearch/goleveldb v1.0.1/go.mod h1:WrU8ltZbIp0wAoig/MHbrPCXSOLpe79nz5lv5nqfYrQ= +github.com/blevesearch/scorch_segment_api/v2 v2.3.10/go.mod h1:Z3e6ChN3qyN35yaQpl00MfI5s8AxUJbpTR/DL8QOQ+8= +github.com/blevesearch/scorch_segment_api/v2 v2.3.13 h1:ZPjv/4VwWvHJZKeMSgScCapOy8+DdmsmRyLmSB88UoY= +github.com/blevesearch/scorch_segment_api/v2 v2.3.13/go.mod h1:ENk2LClTehOuMS8XzN3UxBEErYmtwkE7MAArFTXs9Vc= github.com/blevesearch/snowball v0.6.1 h1:cDYjn/NCH+wwt2UdehaLpr2e4BwLIjN4V/TdLsL+B5A= github.com/blevesearch/snowball v0.6.1/go.mod h1:ZF0IBg5vgpeoUhnMza2v0A/z8m1cWPlwhke08LpNusg= github.com/blevesearch/stempel v0.2.0 h1:CYzVPaScODMvgE9o+kf6D4RJ/VRomyi9uHF+PtB+Afc= github.com/blevesearch/stempel v0.2.0/go.mod h1:wjeTHqQv+nQdbPuJ/YcvOjTInA2EIc6Ks1FoSUzSLvc= +github.com/blevesearch/vellum v1.0.10/go.mod h1:ul1oT0FhSMDIExNjIxHqJoGpVrBpKCdgDQNxfqgJt7k= +github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs= +github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc= +github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE= +github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58= +github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks= +github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk= +github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0= +github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8= +github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k= +github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= +github.com/blevesearch/zapx/v16 v16.2.8 h1:SlnzF0YGtSlrsOE3oE7EgEX6BIepGpeqxs1IjMbHLQI= +github.com/blevesearch/zapx/v16 v16.2.8/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14= github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= @@ -574,6 +601,7 @@ github.com/cilium/ebpf v0.9.1/go.mod h1:+OhNOIXx/Fnu1IE8bJz2dzOA+VSfyTfdNUVdlQnx github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible h1:C29Ae4G5GtYyYMm1aztcyj/J5ckgJm2zwdDajFbx1NY= github.com/circonus-labs/circonusllhist v0.1.3 h1:TJH+oke8D16535+jHExHj4nQvzlZrj7ug5D7I/orNUA= github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= +github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe h1:QQ3GSy+MqSHxm/d8nCtnAiZdYFd45cYZPs8vOOIYKfk= 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= @@ -904,6 +932,8 @@ github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB7 github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263/go.mod h1:VKxaR93Gff0ZlO2sPcdPVob1a/UzArFEW5zx3Bpyhls= github.com/grafana/alerting v0.0.0-20251009192429-9427c24835ae/go.mod h1:VGjS5gDwWEADPP6pF/drqLxEImgeuHlEW5u8E5EfIrM= +github.com/grafana/alerting v0.0.0-20260112110054-6c6f13659ad3 h1:KVncUdAc5YwY/OQmw6HgzJmbRKn6IwrhvtcBAd1yDHo= +github.com/grafana/alerting v0.0.0-20260112110054-6c6f13659ad3/go.mod h1:Oy4MthJqfErlieO14ryZXdukDrUACy8Lg56P3zP7S1k= github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43/go.mod h1:1fWkOiL+m32NBgRHZtlZGz2ji868tPZACYbqP3nBRJI= github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= @@ -995,6 +1025,7 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975 github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36 h1:AjZ58JRw1ZieFH/SdsddF5BXtsDKt5kSrKNPWrzYz3Y= github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU= github.com/grafana/pyroscope-go/godeltaprof v0.1.8/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= +github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae/go.mod h1:6CJ1uXmLZ13ufpO9xE4pST+DyaBt0uszzrV0YnoaVLQ= github.com/grafana/sqlds/v4 v4.2.4/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU= github.com/grafana/sqlds/v4 v4.2.7/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU= github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0 h1:bjh0PVYSVVFxzINqPFYJmAmJNrWPgnVjuSdYJGHmtFU= @@ -1085,6 +1116,7 @@ github.com/jon-whit/go-grpc-prometheus v1.4.0/go.mod h1:iTPm+Iuhh3IIqR0iGZ91JJEg github.com/joncrlsn/dque v0.0.0-20211108142734-c2ef48c5192a h1:sfe532Ipn7GX0V6mHdynBk393rDmqgI0QmjLK7ct7TU= github.com/joncrlsn/dque v0.0.0-20211108142734-c2ef48c5192a/go.mod h1:dNKs71rs2VJGBAmttu7fouEsRQlRjxy0p1Sx+T5wbpY= github.com/josephspurrier/goversioninfo v1.4.0/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY= +github.com/json-iterator/go v0.0.0-20171115153421-f7279a603ede/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o= github.com/jsternberg/zap-logfmt v1.3.0 h1:z1n1AOHVVydOOVuyphbOKyR4NICDQFiJMn1IK5hVQ5Y= github.com/jsternberg/zap-logfmt v1.3.0/go.mod h1:N3DENp9WNmCZxvkBD/eReWwz1149BK6jEN9cQ4fNwZE= @@ -1349,6 +1381,7 @@ github.com/open-telemetry/opentelemetry-collector-contrib/receiver/opencensusrec github.com/open-telemetry/opentelemetry-collector-contrib/receiver/zipkinreceiver v0.121.0/go.mod h1:3axnebi8xUm9ifbs1myzehw2nODtIMrQlL566sJ4bYw= github.com/open-telemetry/opentelemetry-collector-contrib/receiver/zipkinreceiver v0.124.1 h1:XkxqUEoukMWXF+EpEWeM9itXKt62yKi13Lzd8ZEASP4= github.com/open-telemetry/opentelemetry-collector-contrib/receiver/zipkinreceiver v0.124.1/go.mod h1:CuCZVPz+yn88b5vhZPAlxaMrVuhAVexUV6f8b07lpUc= +github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/opencontainers/runc v1.1.5/go.mod h1:1J5XiS+vdZ3wCyZybsuxXZWGrgSr8fFJHLXuG2PsnNg= github.com/opencontainers/runtime-spec v1.0.3-0.20220825212826-86290f6a00fb/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/runtime-spec v1.1.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= @@ -1952,10 +1985,12 @@ gocloud.dev/secrets/hashivault v0.42.0/go.mod h1:LXprr1XLEAT7BVZ+Y66dJEHQMzDsowI golang.org/x/arch v0.4.0 h1:A8WCeEWhLwPBKNbFi5Wv5UTCBx5zzubnXDlMOFAzFMc= golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.11.1-0.20230711161743-2e82bdd1719d/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= 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= @@ -2063,6 +2098,7 @@ golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053/go.mod h1:+nZKN+XVh4LCiA9DV3ywrzN4gumyCnKjau3NGb9SGoE= +golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= @@ -2087,7 +2123,6 @@ golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= @@ -2237,7 +2272,6 @@ gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzE gopkg.in/vmihailenco/msgpack.v2 v2.9.2 h1:gjPqo9orRVlSAH/065qw3MsFCDpH7fa1KpiizXyllY4= gopkg.in/vmihailenco/msgpack.v2 v2.9.2/go.mod h1:/3Dn1Npt9+MYyLpYYXjInO/5jvMLamn+AEGwNEOatn8= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= -gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= honnef.co/go/tools v0.3.2 h1:ytYb4rOqyp1TSa2EPvNVwtPQJctSELKaMyLfqNP4+34= honnef.co/go/tools v0.3.2/go.mod h1:jzwdWgg7Jdq75wlfblQxO4neNaFFSvgc1tD5Wv8U0Yw= diff --git a/jest.config.js b/jest.config.js index 17a2ce9ca32..f9d431cf5d3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -82,6 +82,7 @@ module.exports = { // Decoupled plugins run their own tests so ignoring them here. '/public/app/plugins/datasource/azuremonitor', '/public/app/plugins/datasource/cloud-monitoring', + '/public/app/plugins/datasource/elasticsearch', '/public/app/plugins/datasource/grafana-postgresql-datasource', '/public/app/plugins/datasource/grafana-pyroscope-datasource', '/public/app/plugins/datasource/grafana-testdata-datasource', diff --git a/package.json b/package.json index 36d93996980..0a39ff67aea 100644 --- a/package.json +++ b/package.json @@ -62,8 +62,7 @@ "stats": "webpack --mode production --config scripts/webpack/webpack.prod.js --profile --json > compilation-stats.json", "storybook": "yarn workspace @grafana/ui storybook --ci", "storybook:build": "yarn workspace @grafana/ui storybook:build", - "themes-schema": "typescript-json-schema ./tsconfig.json NewThemeOptions --include 'packages/grafana-data/src/themes/createTheme.ts' --out public/app/features/theme-playground/schema.generated.json", - "themes-generate": "yarn themes-schema && esbuild --target=es6 ./scripts/cli/generateSassVariableFiles.ts --bundle --conditions=@grafana-app/source --platform=node --tsconfig=./scripts/cli/tsconfig.json | node", + "themes-generate": "yarn workspace @grafana/data themes-schema && esbuild --target=es6 ./scripts/cli/generateSassVariableFiles.ts --bundle --conditions=@grafana-app/source --platform=node --tsconfig=./scripts/cli/tsconfig.json | node", "themes:usage": "eslint . --ignore-pattern '*.test.ts*' --ignore-pattern '*.spec.ts*' --cache --plugin '@grafana' --rule '{ @grafana/theme-token-usage: \"error\" }'", "typecheck": "tsc --noEmit && yarn run packages:typecheck", "plugins:build-bundled": "echo 'bundled plugins are no longer supported'", @@ -254,7 +253,6 @@ "ts-jest": "29.4.0", "ts-node": "10.9.2", "typescript": "5.9.2", - "typescript-json-schema": "^0.65.1", "webpack": "5.101.0", "webpack-assets-manifest": "^5.1.0", "webpack-cli": "6.0.1", @@ -265,7 +263,7 @@ "webpackbar": "^7.0.0", "yaml": "^2.0.0", "yargs": "^18.0.0", - "zod": "^4.0.0" + "zod": "^4.3.0" }, "dependencies": { "@bsull/augurs": "^0.10.0", @@ -295,8 +293,8 @@ "@grafana/plugin-ui": "^0.11.1", "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "v6.52.1", - "@grafana/scenes-react": "v6.52.1", + "@grafana/scenes": "6.52.2", + "@grafana/scenes-react": "6.52.2", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", @@ -462,7 +460,7 @@ "js-yaml@npm:4.1.0": "^4.1.0", "js-yaml@npm:=4.1.0": "^4.1.0", "nodemailer": "7.0.11", - "@storybook/core@npm:8.6.2": "patch:@storybook/core@npm%3A8.6.2#~/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch" + "@storybook/core@npm:8.6.15": "patch:@storybook/core@npm%3A8.6.15#~/.yarn/patches/@storybook-core-npm-8.6.15-a468a35170.patch" }, "workspaces": { "packages": [ diff --git a/packages/grafana-alerting/package.json b/packages/grafana-alerting/package.json index 10b6e54448b..fa0ee6e5168 100644 --- a/packages/grafana-alerting/package.json +++ b/packages/grafana-alerting/package.json @@ -96,7 +96,7 @@ "@faker-js/faker": "^9.8.0", "@grafana/api-clients": "12.4.0-pre", "@grafana/i18n": "12.4.0-pre", - "@reduxjs/toolkit": "^2.9.0", + "@reduxjs/toolkit": "2.10.1", "fishery": "^2.3.1", "lodash": "^4.17.21", "tinycolor2": "^1.6.0" diff --git a/packages/grafana-api-clients/package.json b/packages/grafana-api-clients/package.json index 031b1990b04..f72eab55212 100644 --- a/packages/grafana-api-clients/package.json +++ b/packages/grafana-api-clients/package.json @@ -139,6 +139,12 @@ "types": "./dist/types/clients/rtkq/logsdrilldown/v1alpha1/index.d.ts", "import": "./dist/esm/clients/rtkq/logsdrilldown/v1alpha1/index.mjs", "require": "./dist/cjs/clients/rtkq/logsdrilldown/v1alpha1/index.cjs" + }, + "./rtkq/logsdrilldown/v1beta1": { + "@grafana-app/source": "./src/clients/rtkq/logsdrilldown/v1beta1/index.ts", + "types": "./dist/types/clients/rtkq/logsdrilldown/v1beta1/index.d.ts", + "import": "./dist/esm/clients/rtkq/logsdrilldown/v1beta1/index.mjs", + "require": "./dist/cjs/clients/rtkq/logsdrilldown/v1beta1/index.cjs" } }, "publishConfig": { @@ -170,7 +176,7 @@ }, "peerDependencies": { "@grafana/runtime": ">=11.6 <= 12.x", - "@reduxjs/toolkit": "^2.8.0", + "@reduxjs/toolkit": "^2.10.0", "rxjs": "7.8.2" } } diff --git a/packages/grafana-api-clients/src/clients/rtkq/createBaseQuery.ts b/packages/grafana-api-clients/src/clients/rtkq/createBaseQuery.ts index 477e139faac..c6216cc13b5 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/createBaseQuery.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/createBaseQuery.ts @@ -34,6 +34,8 @@ export function createBaseQuery({ baseURL }: CreateBaseQueryOptions): BaseQueryF getBackendSrv().fetch({ ...requestOptions, url: baseURL + requestOptions.url, + // Default to GET so backend_srv correctly skips success alerts for queries + method: requestOptions.method ?? 'GET', showErrorAlert: requestOptions.showErrorAlert ?? false, data: requestOptions.body, headers, diff --git a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts index 326b53ccedd..9605b8e9355 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts @@ -246,6 +246,8 @@ const injectedRtkApi = api facetLimit: queryArg.facetLimit, tags: queryArg.tags, libraryPanel: queryArg.libraryPanel, + panelType: queryArg.panelType, + dataSourceType: queryArg.dataSourceType, permission: queryArg.permission, sort: queryArg.sort, limit: queryArg.limit, @@ -674,6 +676,10 @@ export type SearchDashboardsAndFoldersApiArg = { tags?: string[]; /** find dashboards that reference a given libraryPanel */ libraryPanel?: string; + /** find dashboards using panels of a given plugin type */ + panelType?: string; + /** find dashboards using datasources of a given plugin type */ + dataSourceType?: string; /** permission needed for the resource (view, edit, admin) */ permission?: 'view' | 'edit' | 'admin'; /** sortable field */ diff --git a/packages/grafana-api-clients/src/clients/rtkq/index.ts b/packages/grafana-api-clients/src/clients/rtkq/index.ts index f7e6e3772c9..708a7b04d5b 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/index.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/index.ts @@ -9,6 +9,7 @@ import { generatedAPI as folderAPIv1beta1 } from './folder/v1beta1'; import { generatedAPI as historianAlertingAPIv0alpha1 } from './historian.alerting/v0alpha1'; import { generatedAPI as iamAPIv0alpha1 } from './iam/v0alpha1'; import { generatedAPI as logsdrilldownAPIv1alpha1 } from './logsdrilldown/v1alpha1'; +import { generatedAPI as logsdrilldownAPIv1beta1 } from './logsdrilldown/v1beta1'; import { generatedAPI as migrateToCloudAPI } from './migrate-to-cloud'; import { generatedAPI as notificationsAlertingAPIv0alpha1 } from './notifications.alerting/v0alpha1'; import { generatedAPI as playlistAPIv0alpha1 } from './playlist/v0alpha1'; @@ -38,6 +39,7 @@ export const allMiddleware = [ notificationsAlertingAPIv0alpha1.middleware, rulesAlertingAPIv0alpha1.middleware, historianAlertingAPIv0alpha1.middleware, + logsdrilldownAPIv1beta1.middleware, logsdrilldownAPIv1alpha1.middleware, // PLOP_INJECT_MIDDLEWARE ] as const; @@ -61,6 +63,7 @@ export const allReducers = { [rulesAlertingAPIv0alpha1.reducerPath]: rulesAlertingAPIv0alpha1.reducer, [historianAlertingAPIv0alpha1.reducerPath]: historianAlertingAPIv0alpha1.reducer, [logsdrilldownAPIv1alpha1.reducerPath]: logsdrilldownAPIv1alpha1.reducer, + [logsdrilldownAPIv1beta1.reducerPath]: logsdrilldownAPIv1beta1.reducer, // PLOP_INJECT_REDUCER }; diff --git a/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts index 4f17bb46ecf..0fbbb9cddc9 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts @@ -727,17 +727,6 @@ const injectedRtkApi = api }), invalidatesTags: ['dashboards', 'permissions'], }), - restoreDashboardVersionByUid: build.mutation< - RestoreDashboardVersionByUidApiResponse, - RestoreDashboardVersionByUidApiArg - >({ - query: (queryArg) => ({ - url: `/dashboards/uid/${queryArg.uid}/restore`, - method: 'POST', - body: queryArg.restoreDashboardVersionCommand, - }), - invalidatesTags: ['dashboards', 'versions'], - }), getDashboardVersionsByUid: build.query({ query: (queryArg) => ({ url: `/dashboards/uid/${queryArg.uid}/versions`, @@ -1021,6 +1010,7 @@ const injectedRtkApi = api typeFilter: queryArg.typeFilter, excludeUid: queryArg.excludeUid, folderFilter: queryArg.folderFilter, + folderFilterUIDs: queryArg.folderFilterUiDs, perPage: queryArg.perPage, page: queryArg.page, }, @@ -2627,26 +2617,6 @@ export type UpdateDashboardPermissionsByUidApiArg = { uid: string; updateDashboardAclCommand: UpdateDashboardAclCommand; }; -export type RestoreDashboardVersionByUidApiResponse = /** status 200 (empty) */ { - /** FolderUID The unique identifier (uid) of the folder the dashboard belongs to. */ - folderUid?: string; - /** ID The unique identifier (id) of the created/updated dashboard. */ - id: number; - /** Status status of the response. */ - status: string; - /** Slug The slug of the dashboard. */ - title: string; - /** UID The unique identifier (uid) of the created/updated dashboard. */ - uid: string; - /** URL The relative URL for accessing the created/updated dashboard. */ - url: string; - /** Version The version of the dashboard. */ - version: number; -}; -export type RestoreDashboardVersionByUidApiArg = { - uid: string; - restoreDashboardVersionCommand: RestoreDashboardVersionCommand; -}; export type GetDashboardVersionsByUidApiResponse = /** status 200 (empty) */ DashboardVersionResponseMeta; export type GetDashboardVersionsByUidApiArg = { uid: string; @@ -2915,8 +2885,11 @@ export type GetLibraryElementsApiArg = { typeFilter?: string; /** Element UID to exclude from search results. */ excludeUid?: string; - /** A comma separated list of folder ID(s) to filter the elements by. */ + /** A comma separated list of folder ID(s) to filter the elements by. + Deprecated: Use FolderFilterUIDs instead. */ folderFilter?: string; + /** A comma separated list of folder UID(s) to filter the elements by. */ + folderFilterUiDs?: string; /** The number of results per page. */ perPage?: number; /** The page for a set of records, given that only perPage records are returned at a time. Numbering starts at 1. */ @@ -4564,9 +4537,6 @@ export type DashboardAclUpdateItem = { export type UpdateDashboardAclCommand = { items?: DashboardAclUpdateItem[]; }; -export type RestoreDashboardVersionCommand = { - version?: number; -}; export type DashboardVersionMeta = { created?: string; createdBy?: string; @@ -5312,7 +5282,8 @@ export type PatchPrefsCmd = { queryHistory?: QueryHistoryPreference; regionalFormat?: string; theme?: 'light' | 'dark'; - timezone?: 'utc' | 'browser'; + /** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */ + timezone?: string; weekStart?: string; }; export type UpdatePrefsCmd = { @@ -5325,7 +5296,8 @@ export type UpdatePrefsCmd = { queryHistory?: QueryHistoryPreference; regionalFormat?: string; theme?: 'light' | 'dark' | 'system'; - timezone?: 'utc' | 'browser'; + /** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */ + timezone?: string; weekStart?: string; }; export type OrgUserDto = { @@ -5334,6 +5306,7 @@ export type OrgUserDto = { }; authLabels?: string[]; avatarUrl?: string; + created?: string; email?: string; isDisabled?: boolean; isExternallySynced?: boolean; @@ -5554,6 +5527,7 @@ export type ReportDashboard = { }; export type Type = string; export type ReportOptions = { + csvEncoding?: string; layout?: string; orientation?: string; pdfCombineOneFile?: boolean; @@ -6111,6 +6085,7 @@ export type ChangeUserPasswordCommand = { export type UserSearchHitDto = { authLabels?: string[]; avatarUrl?: string; + created?: string; email?: string; id?: number; isAdmin?: boolean; @@ -6624,7 +6599,6 @@ export const { useGetDashboardPermissionsListByUidQuery, useLazyGetDashboardPermissionsListByUidQuery, useUpdateDashboardPermissionsByUidMutation, - useRestoreDashboardVersionByUidMutation, useGetDashboardVersionsByUidQuery, useLazyGetDashboardVersionsByUidQuery, useGetDashboardVersionByUidQuery, diff --git a/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/baseAPI.ts b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/baseAPI.ts new file mode 100644 index 00000000000..484d5084e89 --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/baseAPI.ts @@ -0,0 +1,16 @@ +import { createApi } from '@reduxjs/toolkit/query/react'; + +import { getAPIBaseURL } from '../../../../utils/utils'; +import { createBaseQuery } from '../../createBaseQuery'; + +export const API_GROUP = 'logsdrilldown.grafana.app' as const; +export const API_VERSION = 'v1beta1' as const; +export const BASE_URL = getAPIBaseURL(API_GROUP, API_VERSION); + +export const api = createApi({ + reducerPath: 'logsdrilldownAPIv1beta1', + baseQuery: createBaseQuery({ + baseURL: BASE_URL, + }), + endpoints: () => ({}), +}); diff --git a/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/endpoints.gen.ts new file mode 100644 index 00000000000..65395d2279d --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/endpoints.gen.ts @@ -0,0 +1,652 @@ +import { api } from './baseAPI'; +export const addTagTypes = ['API Discovery', 'LogsDrilldownDefaultColumns'] as const; +const injectedRtkApi = api + .enhanceEndpoints({ + addTagTypes, + }) + .injectEndpoints({ + endpoints: (build) => ({ + getApiResources: build.query({ + query: () => ({ url: `/` }), + providesTags: ['API Discovery'], + }), + listLogsDrilldownDefaultColumns: build.query< + ListLogsDrilldownDefaultColumnsApiResponse, + ListLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns`, + params: { + pretty: queryArg.pretty, + allowWatchBookmarks: queryArg.allowWatchBookmarks, + continue: queryArg['continue'], + fieldSelector: queryArg.fieldSelector, + labelSelector: queryArg.labelSelector, + limit: queryArg.limit, + resourceVersion: queryArg.resourceVersion, + resourceVersionMatch: queryArg.resourceVersionMatch, + sendInitialEvents: queryArg.sendInitialEvents, + timeoutSeconds: queryArg.timeoutSeconds, + watch: queryArg.watch, + }, + }), + providesTags: ['LogsDrilldownDefaultColumns'], + }), + createLogsDrilldownDefaultColumns: build.mutation< + CreateLogsDrilldownDefaultColumnsApiResponse, + CreateLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns`, + method: 'POST', + body: queryArg.logsDrilldownDefaultColumns, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + deletecollectionLogsDrilldownDefaultColumns: build.mutation< + DeletecollectionLogsDrilldownDefaultColumnsApiResponse, + DeletecollectionLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + continue: queryArg['continue'], + dryRun: queryArg.dryRun, + fieldSelector: queryArg.fieldSelector, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + labelSelector: queryArg.labelSelector, + limit: queryArg.limit, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + resourceVersion: queryArg.resourceVersion, + resourceVersionMatch: queryArg.resourceVersionMatch, + sendInitialEvents: queryArg.sendInitialEvents, + timeoutSeconds: queryArg.timeoutSeconds, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + getLogsDrilldownDefaultColumns: build.query< + GetLogsDrilldownDefaultColumnsApiResponse, + GetLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}`, + params: { + pretty: queryArg.pretty, + }, + }), + providesTags: ['LogsDrilldownDefaultColumns'], + }), + replaceLogsDrilldownDefaultColumns: build.mutation< + ReplaceLogsDrilldownDefaultColumnsApiResponse, + ReplaceLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}`, + method: 'PUT', + body: queryArg.logsDrilldownDefaultColumns, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + deleteLogsDrilldownDefaultColumns: build.mutation< + DeleteLogsDrilldownDefaultColumnsApiResponse, + DeleteLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + updateLogsDrilldownDefaultColumns: build.mutation< + UpdateLogsDrilldownDefaultColumnsApiResponse, + UpdateLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}`, + method: 'PATCH', + body: queryArg.patch, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + force: queryArg.force, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + getLogsDrilldownDefaultColumnsStatus: build.query< + GetLogsDrilldownDefaultColumnsStatusApiResponse, + GetLogsDrilldownDefaultColumnsStatusApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}/status`, + params: { + pretty: queryArg.pretty, + }, + }), + providesTags: ['LogsDrilldownDefaultColumns'], + }), + replaceLogsDrilldownDefaultColumnsStatus: build.mutation< + ReplaceLogsDrilldownDefaultColumnsStatusApiResponse, + ReplaceLogsDrilldownDefaultColumnsStatusApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}/status`, + method: 'PUT', + body: queryArg.logsDrilldownDefaultColumns, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + updateLogsDrilldownDefaultColumnsStatus: build.mutation< + UpdateLogsDrilldownDefaultColumnsStatusApiResponse, + UpdateLogsDrilldownDefaultColumnsStatusApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}/status`, + method: 'PATCH', + body: queryArg.patch, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + force: queryArg.force, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + }), + overrideExisting: false, + }); +export { injectedRtkApi as generatedAPI }; +export type GetApiResourcesApiResponse = /** status 200 OK */ ApiResourceList; +export type GetApiResourcesApiArg = void; +export type ListLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ LogsDrilldownDefaultColumnsList; +export type ListLogsDrilldownDefaultColumnsApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** allowWatchBookmarks requests watch events with type "BOOKMARK". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored. */ + allowWatchBookmarks?: boolean; + /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". + + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ + continue?: string; + /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ + fieldSelector?: string; + /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ + labelSelector?: string; + /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. + + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ + limit?: number; + /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersion?: string; + /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersionMatch?: string; + /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. + + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan + is interpreted as "data at least as new as the provided `resourceVersion`" + and the bookmark event is send when the state is synced + to a `resourceVersion` at least as fresh as the one provided by the ListOptions. + If `resourceVersion` is unset, this is interpreted as "consistent read" and the + bookmark event is send when the state is synced at least to the moment + when request started being processed. + - `resourceVersionMatch` set to any other value or unset + Invalid error is returned. + + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ + sendInitialEvents?: boolean; + /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ + timeoutSeconds?: number; + /** Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion. */ + watch?: boolean; +}; +export type CreateLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ + | LogsDrilldownDefaultColumns + | /** status 201 Created */ LogsDrilldownDefaultColumns + | /** status 202 Accepted */ LogsDrilldownDefaultColumns; +export type CreateLogsDrilldownDefaultColumnsApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + logsDrilldownDefaultColumns: LogsDrilldownDefaultColumns; +}; +export type DeletecollectionLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ Status; +export type DeletecollectionLogsDrilldownDefaultColumnsApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". + + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ + continue?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ + fieldSelector?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ + labelSelector?: string; + /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. + + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ + limit?: number; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; + /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersion?: string; + /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersionMatch?: string; + /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. + + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan + is interpreted as "data at least as new as the provided `resourceVersion`" + and the bookmark event is send when the state is synced + to a `resourceVersion` at least as fresh as the one provided by the ListOptions. + If `resourceVersion` is unset, this is interpreted as "consistent read" and the + bookmark event is send when the state is synced at least to the moment + when request started being processed. + - `resourceVersionMatch` set to any other value or unset + Invalid error is returned. + + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ + sendInitialEvents?: boolean; + /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ + timeoutSeconds?: number; +}; +export type GetLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ LogsDrilldownDefaultColumns; +export type GetLogsDrilldownDefaultColumnsApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; +}; +export type ReplaceLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ + | LogsDrilldownDefaultColumns + | /** status 201 Created */ LogsDrilldownDefaultColumns; +export type ReplaceLogsDrilldownDefaultColumnsApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + logsDrilldownDefaultColumns: LogsDrilldownDefaultColumns; +}; +export type DeleteLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ + | Status + | /** status 202 Accepted */ Status; +export type DeleteLogsDrilldownDefaultColumnsApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; +}; +export type UpdateLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ + | LogsDrilldownDefaultColumns + | /** status 201 Created */ LogsDrilldownDefaultColumns; +export type UpdateLogsDrilldownDefaultColumnsApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ + force?: boolean; + patch: Patch; +}; +export type GetLogsDrilldownDefaultColumnsStatusApiResponse = /** status 200 OK */ LogsDrilldownDefaultColumns; +export type GetLogsDrilldownDefaultColumnsStatusApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; +}; +export type ReplaceLogsDrilldownDefaultColumnsStatusApiResponse = /** status 200 OK */ + | LogsDrilldownDefaultColumns + | /** status 201 Created */ LogsDrilldownDefaultColumns; +export type ReplaceLogsDrilldownDefaultColumnsStatusApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + logsDrilldownDefaultColumns: LogsDrilldownDefaultColumns; +}; +export type UpdateLogsDrilldownDefaultColumnsStatusApiResponse = /** status 200 OK */ + | LogsDrilldownDefaultColumns + | /** status 201 Created */ LogsDrilldownDefaultColumns; +export type UpdateLogsDrilldownDefaultColumnsStatusApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ + force?: boolean; + patch: Patch; +}; +export type ApiResource = { + /** categories is a list of the grouped resources this resource belongs to (e.g. 'all') */ + categories?: string[]; + /** group is the preferred group of the resource. Empty implies the group of the containing resource list. For subresources, this may have a different value, for example: Scale". */ + group?: string; + /** kind is the kind for the resource (e.g. 'Foo' is the kind for a resource 'foo') */ + kind: string; + /** name is the plural name of the resource. */ + name: string; + /** namespaced indicates if a resource is namespaced or not. */ + namespaced: boolean; + /** shortNames is a list of suggested short names of the resource. */ + shortNames?: string[]; + /** singularName is the singular name of the resource. This allows clients to handle plural and singular opaquely. The singularName is more correct for reporting status on a single item and both singular and plural are allowed from the kubectl CLI interface. */ + singularName: string; + /** The hash value of the storage version, the version this resource is converted to when written to the data store. Value must be treated as opaque by clients. Only equality comparison on the value is valid. This is an alpha feature and may change or be removed in the future. The field is populated by the apiserver only if the StorageVersionHash feature gate is enabled. This field will remain optional even if it graduates. */ + storageVersionHash?: string; + /** verbs is a list of supported kube verbs (this includes get, list, watch, create, update, patch, delete, deletecollection, and proxy) */ + verbs: string[]; + /** version is the preferred version of the resource. Empty implies the version of the containing resource list For subresources, this may have a different value, for example: v1 (while inside a v1beta1 version of the core resource's group)". */ + version?: string; +}; +export type ApiResourceList = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** groupVersion is the group and version this APIResourceList is for. */ + groupVersion: string; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** resources contains the name of the resources and if they are namespaced. */ + resources: ApiResource[]; +}; +export type Time = string; +export type FieldsV1 = object; +export type ManagedFieldsEntry = { + /** APIVersion defines the version of this resource that this field set applies to. The format is "group/version" just like the top-level APIVersion field. It is necessary to track the version of a field set because it cannot be automatically converted. */ + apiVersion?: string; + /** FieldsType is the discriminator for the different fields format and version. There is currently only one possible value: "FieldsV1" */ + fieldsType?: string; + /** FieldsV1 holds the first JSON version format as described in the "FieldsV1" type. */ + fieldsV1?: FieldsV1; + /** Manager is an identifier of the workflow managing these fields. */ + manager?: string; + /** Operation is the type of operation which lead to this ManagedFieldsEntry being created. The only valid values for this field are 'Apply' and 'Update'. */ + operation?: string; + /** Subresource is the name of the subresource used to update that object, or empty string if the object was updated through the main resource. The value of this field is used to distinguish between managers, even if they share the same name. For example, a status update will be distinct from a regular update using the same manager name. Note that the APIVersion field is not related to the Subresource field and it always corresponds to the version of the main resource. */ + subresource?: string; + /** Time is the timestamp of when the ManagedFields entry was added. The timestamp will also be updated if a field is added, the manager changes any of the owned fields value or removes a field. The timestamp does not update when a field is removed from the entry because another manager took it over. */ + time?: Time; +}; +export type OwnerReference = { + /** API version of the referent. */ + apiVersion: string; + /** If true, AND if the owner has the "foregroundDeletion" finalizer, then the owner cannot be deleted from the key-value store until this reference is removed. See https://kubernetes.io/docs/concepts/architecture/garbage-collection/#foreground-deletion for how the garbage collector interacts with this field and enforces the foreground deletion. Defaults to false. To set this field, a user needs "delete" permission of the owner, otherwise 422 (Unprocessable Entity) will be returned. */ + blockOwnerDeletion?: boolean; + /** If true, this reference points to the managing controller. */ + controller?: boolean; + /** Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind: string; + /** Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names */ + name: string; + /** UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid: string; +}; +export type ObjectMeta = { + /** Annotations is an unstructured key value map stored with a resource that may be set by external tools to store and retrieve arbitrary metadata. They are not queryable and should be preserved when modifying objects. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations */ + annotations?: { + [key: string]: string; + }; + /** CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC. + + Populated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata */ + creationTimestamp?: Time; + /** Number of seconds allowed for this object to gracefully terminate before it will be removed from the system. Only set when deletionTimestamp is also set. May only be shortened. Read-only. */ + deletionGracePeriodSeconds?: number; + /** DeletionTimestamp is RFC 3339 date and time at which this resource will be deleted. This field is set by the server when a graceful deletion is requested by the user, and is not directly settable by a client. The resource is expected to be deleted (no longer visible from resource lists, and not reachable by name) after the time in this field, once the finalizers list is empty. As long as the finalizers list contains items, deletion is blocked. Once the deletionTimestamp is set, this value may not be unset or be set further into the future, although it may be shortened or the resource may be deleted prior to this time. For example, a user may request that a pod is deleted in 30 seconds. The Kubelet will react by sending a graceful termination signal to the containers in the pod. After that 30 seconds, the Kubelet will send a hard termination signal (SIGKILL) to the container and after cleanup, remove the pod from the API. In the presence of network partitions, this object may still exist after this timestamp, until an administrator or automated process can determine the resource is fully terminated. If not set, graceful deletion of the object has not been requested. + + Populated by the system when a graceful deletion is requested. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata */ + deletionTimestamp?: Time; + /** Must be empty before the object is deleted from the registry. Each entry is an identifier for the responsible component that will remove the entry from the list. If the deletionTimestamp of the object is non-nil, entries in this list can only be removed. Finalizers may be processed and removed in any order. Order is NOT enforced because it introduces significant risk of stuck finalizers. finalizers is a shared field, any actor with permission can reorder it. If the finalizer list is processed in order, then this can lead to a situation in which the component responsible for the first finalizer in the list is waiting for a signal (field value, external system, or other) produced by a component responsible for a finalizer later in the list, resulting in a deadlock. Without enforced ordering finalizers are free to order amongst themselves and are not vulnerable to ordering changes in the list. */ + finalizers?: string[]; + /** GenerateName is an optional prefix, used by the server, to generate a unique name ONLY IF the Name field has not been provided. If this field is used, the name returned to the client will be different than the name passed. This value will also be combined with a unique suffix. The provided value has the same validation rules as the Name field, and may be truncated by the length of the suffix required to make the value unique on the server. + + If this field is specified and the generated name exists, the server will return a 409. + + Applied only if Name is not specified. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency */ + generateName?: string; + /** A sequence number representing a specific generation of the desired state. Populated by the system. Read-only. */ + generation?: number; + /** Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels */ + labels?: { + [key: string]: string; + }; + /** ManagedFields maps workflow-id and version to the set of fields that are managed by that workflow. This is mostly for internal housekeeping, and users typically shouldn't need to set or understand this field. A workflow can be the user's name, a controller's name, or the name of a specific apply path like "ci-cd". The set of fields is always in the version that the workflow used when modifying the object. */ + managedFields?: ManagedFieldsEntry[]; + /** Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names */ + name?: string; + /** Namespace defines the space within which each name must be unique. An empty namespace is equivalent to the "default" namespace, but "default" is the canonical representation. Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty. + + Must be a DNS_LABEL. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces */ + namespace?: string; + /** List of objects depended by this object. If ALL objects in the list have been deleted, this object will be garbage collected. If this object is managed by a controller, then an entry in this list will point to this controller, with the controller field set to true. There cannot be more than one managing controller. */ + ownerReferences?: OwnerReference[]; + /** An opaque value that represents the internal version of this object that can be used by clients to determine when objects have changed. May be used for optimistic concurrency, change detection, and the watch operation on a resource or set of resources. Clients must treat these values as opaque and passed unmodified back to the server. They may only be valid for a particular resource or set of resources. + + Populated by the system. Read-only. Value must be treated as opaque by clients and . More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency */ + resourceVersion?: string; + /** Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. */ + selfLink?: string; + /** UID is the unique in time and space value for this object. It is typically generated by the server on successful creation of a resource and is not allowed to change on PUT operations. + + Populated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid?: string; +}; +export type LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel = { + key: string; + value: string; +}; +export type LogsDrilldownDefaultColumnsLogsDefaultColumnsLabels = LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel[]; +export type LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord = { + columns: string[]; + labels: LogsDrilldownDefaultColumnsLogsDefaultColumnsLabels; +}; +export type LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords = + LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord[]; +export type LogsDrilldownDefaultColumnsSpec = { + records: LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords; +}; +export type LogsDrilldownDefaultColumnsOperatorState = { + /** descriptiveState is an optional more descriptive state field which has no requirements on format */ + descriptiveState?: string; + /** details contains any extra information that is operator-specific */ + details?: { + [key: string]: { + [key: string]: any; + }; + }; + /** lastEvaluation is the ResourceVersion last evaluated */ + lastEvaluation: string; + /** state describes the state of the lastEvaluation. + It is limited to three possible states for machine evaluation. */ + state: 'success' | 'in_progress' | 'failed'; +}; +export type LogsDrilldownDefaultColumnsStatus = { + /** additionalFields is reserved for future use */ + additionalFields?: { + [key: string]: { + [key: string]: any; + }; + }; + /** operatorStates is a map of operator ID to operator state evaluations. + Any operator which consumes this kind SHOULD add its state evaluation information to this field. */ + operatorStates?: { + [key: string]: LogsDrilldownDefaultColumnsOperatorState; + }; +}; +export type LogsDrilldownDefaultColumns = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion: string; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind: string; + metadata: ObjectMeta; + spec: LogsDrilldownDefaultColumnsSpec; + status?: LogsDrilldownDefaultColumnsStatus; +}; +export type ListMeta = { + /** continue may be set if the user set a limit on the number of items returned, and indicates that the server has more data available. The value is opaque and may be used to issue another request to the endpoint that served this list to retrieve the next set of available objects. Continuing a consistent list may not be possible if the server configuration has changed or more than a few minutes have passed. The resourceVersion field returned when using this continue value will be identical to the value in the first response, unless you have received this token from an error message. */ + continue?: string; + /** remainingItemCount is the number of subsequent items in the list which are not included in this list response. If the list request contained label or field selectors, then the number of remaining items is unknown and the field will be left unset and omitted during serialization. If the list is complete (either because it is not chunking or because this is the last chunk), then there are no more remaining items and this field will be left unset and omitted during serialization. Servers older than v1.15 do not set this field. The intended use of the remainingItemCount is *estimating* the size of a collection. Clients should not rely on the remainingItemCount to be set or to be exact. */ + remainingItemCount?: number; + /** String that identifies the server's internal version of this object that can be used by clients to determine when objects have changed. Value must be treated as opaque by clients and passed unmodified back to the server. Populated by the system. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency */ + resourceVersion?: string; + /** Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. */ + selfLink?: string; +}; +export type LogsDrilldownDefaultColumnsList = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + items: LogsDrilldownDefaultColumns[]; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata: ListMeta; +}; +export type StatusCause = { + /** The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional. + + Examples: + "name" - the field "name" on the current resource + "items[0].name" - the field "name" on the first array entry in "items" */ + field?: string; + /** A human-readable description of the cause of the error. This field may be presented as-is to a reader. */ + message?: string; + /** A machine-readable description of the cause of the error. If this value is empty there is no information available. */ + reason?: string; +}; +export type StatusDetails = { + /** The Causes array includes more details associated with the StatusReason failure. Not all StatusReasons may provide detailed causes. */ + causes?: StatusCause[]; + /** The group attribute of the resource associated with the status StatusReason. */ + group?: string; + /** The kind attribute of the resource associated with the status StatusReason. On some operations may differ from the requested resource Kind. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described). */ + name?: string; + /** If specified, the time in seconds before the operation should be retried. Some errors may indicate the client must take an alternate action - for those errors this field may indicate how long to wait before taking the alternate action. */ + retryAfterSeconds?: number; + /** UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid?: string; +}; +export type Status = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** Suggested HTTP return code for this status, 0 if not set. */ + code?: number; + /** Extended data associated with the reason. Each reason may define its own extended details. This field is optional and the data returned is not guaranteed to conform to any schema except that defined by the reason type. */ + details?: StatusDetails; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** A human-readable description of the status of this operation. */ + message?: string; + /** Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + metadata?: ListMeta; + /** A machine-readable description of why this operation is in the "Failure" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it. */ + reason?: string; + /** Status of the operation. One of: "Success" or "Failure". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status */ + status?: string; +}; +export type Patch = object; +export const { + useGetApiResourcesQuery, + useLazyGetApiResourcesQuery, + useListLogsDrilldownDefaultColumnsQuery, + useLazyListLogsDrilldownDefaultColumnsQuery, + useCreateLogsDrilldownDefaultColumnsMutation, + useDeletecollectionLogsDrilldownDefaultColumnsMutation, + useGetLogsDrilldownDefaultColumnsQuery, + useLazyGetLogsDrilldownDefaultColumnsQuery, + useReplaceLogsDrilldownDefaultColumnsMutation, + useDeleteLogsDrilldownDefaultColumnsMutation, + useUpdateLogsDrilldownDefaultColumnsMutation, + useGetLogsDrilldownDefaultColumnsStatusQuery, + useLazyGetLogsDrilldownDefaultColumnsStatusQuery, + useReplaceLogsDrilldownDefaultColumnsStatusMutation, + useUpdateLogsDrilldownDefaultColumnsStatusMutation, +} = injectedRtkApi; diff --git a/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/index.ts b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/index.ts new file mode 100644 index 00000000000..d80fd6d553a --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/index.ts @@ -0,0 +1,5 @@ +export { BASE_URL, API_GROUP, API_VERSION } from './baseAPI'; +import { generatedAPI as rawAPI } from './endpoints.gen'; + +export * from './endpoints.gen'; +export const generatedAPI = rawAPI.enhanceEndpoints({}); diff --git a/packages/grafana-api-clients/src/clients/rtkq/preferences/user/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/preferences/user/endpoints.gen.ts index 71107ce1072..93b5259f144 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/preferences/user/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/preferences/user/endpoints.gen.ts @@ -86,7 +86,8 @@ export type PatchPrefsCmd = { queryHistory?: QueryHistoryPreference; regionalFormat?: string; theme?: 'light' | 'dark'; - timezone?: 'utc' | 'browser'; + /** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */ + timezone?: string; weekStart?: string; }; export type UpdatePrefsCmd = { @@ -99,7 +100,8 @@ export type UpdatePrefsCmd = { queryHistory?: QueryHistoryPreference; regionalFormat?: string; theme?: 'light' | 'dark' | 'system'; - timezone?: 'utc' | 'browser'; + /** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */ + timezone?: string; weekStart?: string; }; export const { diff --git a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts index be22503f3bf..40d4299a1a0 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts @@ -122,6 +122,10 @@ const injectedRtkApi = api }), invalidatesTags: ['Connection'], }), + getConnectionRepositories: build.query({ + query: (queryArg) => ({ url: `/connections/${queryArg.name}/repositories` }), + providesTags: ['Connection'], + }), getConnectionStatus: build.query({ query: (queryArg) => ({ url: `/connections/${queryArg.name}/status`, @@ -726,6 +730,18 @@ export type UpdateConnectionApiArg = { force?: boolean; patch: Patch; }; +export type GetConnectionRepositoriesApiResponse = /** status 200 OK */ { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + items: any[]; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata?: any; +}; +export type GetConnectionRepositoriesApiArg = { + /** name of the ExternalRepositoryList */ + name: string; +}; export type GetConnectionStatusApiResponse = /** status 200 OK */ Connection; export type GetConnectionStatusApiArg = { /** name of the Connection */ @@ -1436,7 +1452,7 @@ export type ConnectionSecure = { /** PrivateKey is the reference to the private key used for GitHub App authentication. This value is stored securely and cannot be read back */ privateKey?: InlineSecureValue; /** Token is the reference of the token used to act as the Connection. This value is stored securely and cannot be read back */ - webhook?: InlineSecureValue; + token?: InlineSecureValue; }; export type BitbucketConnectionConfig = { /** App client ID */ @@ -2079,6 +2095,8 @@ export const { useReplaceConnectionMutation, useDeleteConnectionMutation, useUpdateConnectionMutation, + useGetConnectionRepositoriesQuery, + useLazyGetConnectionRepositoriesQuery, useGetConnectionStatusQuery, useLazyGetConnectionStatusQuery, useReplaceConnectionStatusMutation, diff --git a/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts b/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts index 5f529bfbf3d..8610da06920 100644 --- a/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts +++ b/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts @@ -111,6 +111,7 @@ const config: ConfigFile = { ...createAPIConfig('notifications.alerting', 'v0alpha1'), ...createAPIConfig('rules.alerting', 'v0alpha1'), ...createAPIConfig('historian.alerting', 'v0alpha1'), + ...createAPIConfig('logsdrilldown', 'v1beta1'), ...createAPIConfig('logsdrilldown', 'v1alpha1'), // PLOP_INJECT_API_CLIENT - Used by the API client generator }, diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index df595973cca..60db9295fb4 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -35,6 +35,14 @@ }, "./test": { "@grafana-app/source": "./test/index.ts" + }, + "./themes/schema.generated.json": { + "@grafana-app/source": "./src/themes/schema.generated.json", + "default": "./dist/esm/themes/schema.generated.json" + }, + "./themes/definitions/*.json": { + "@grafana-app/source": "./src/themes/themeDefinitions/*.json", + "default": "./dist/esm/themes/themeDefinitions/*.json" } }, "publishConfig": { @@ -47,11 +55,12 @@ "LICENSE_APACHE2" ], "scripts": { - "build": "tsc -p ./tsconfig.build.json && rollup -c rollup.config.ts --configPlugin esbuild", + "build": "yarn themes-schema && tsc -p ./tsconfig.build.json && rollup -c rollup.config.ts --configPlugin esbuild", "clean": "rimraf ./dist ./compiled ./unstable ./package.tgz", "typecheck": "tsc --emitDeclarationOnly false --noEmit", "prepack": "cp package.json package.json.bak && node ../../scripts/prepare-npm-package.js", - "postpack": "mv package.json.bak package.json" + "postpack": "mv package.json.bak package.json", + "themes-schema": "tsx ./scripts/generateSchema.ts" }, "dependencies": { "@braintree/sanitize-url": "7.0.1", @@ -81,10 +90,12 @@ "tinycolor2": "1.6.0", "tslib": "2.8.1", "uplot": "1.6.32", - "xss": "^1.0.14" + "xss": "^1.0.14", + "zod": "^4.3.0" }, "devDependencies": { "@grafana/scenes": "6.38.0", + "@rollup/plugin-json": "6.1.0", "@rollup/plugin-node-resolve": "16.0.1", "@testing-library/react": "16.3.0", "@types/history": "4.7.11", @@ -99,8 +110,10 @@ "react-dom": "18.3.1", "rimraf": "6.0.1", "rollup": "^4.22.4", + "rollup-plugin-copy": "3.5.0", "rollup-plugin-esbuild": "6.2.1", "rollup-plugin-node-externals": "^8.0.0", + "tsx": "^4.21.0", "typescript": "5.9.2" }, "peerDependencies": { diff --git a/packages/grafana-data/rollup.config.ts b/packages/grafana-data/rollup.config.ts index 87008ddc45f..50af331c37c 100644 --- a/packages/grafana-data/rollup.config.ts +++ b/packages/grafana-data/rollup.config.ts @@ -1,20 +1,40 @@ +import json from '@rollup/plugin-json'; import { createRequire } from 'node:module'; +import copy from 'rollup-plugin-copy'; import { entryPoint, plugins, esmOutput, cjsOutput } from '../rollup.config.parts'; const rq = createRequire(import.meta.url); const pkg = rq('./package.json'); +const grafanaDataPlugins = [ + ...plugins, + copy({ + targets: [ + { + src: 'src/themes/schema.generated.json', + dest: 'dist/esm/', + }, + { + src: 'src/themes/themeDefinitions/*.json', + dest: 'dist/esm/', + }, + ], + flatten: false, + }), + json(), +]; + export default [ { input: entryPoint, - plugins, + plugins: grafanaDataPlugins, output: [cjsOutput(pkg, 'grafana-data'), esmOutput(pkg, 'grafana-data')], treeshake: false, }, { input: 'src/unstable.ts', - plugins, + plugins: grafanaDataPlugins, output: [cjsOutput(pkg, 'grafana-data'), esmOutput(pkg, 'grafana-data')], treeshake: false, }, diff --git a/packages/grafana-data/scripts/generateSchema.ts b/packages/grafana-data/scripts/generateSchema.ts new file mode 100644 index 00000000000..f461999376e --- /dev/null +++ b/packages/grafana-data/scripts/generateSchema.ts @@ -0,0 +1,22 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { NewThemeOptionsSchema } from '../src/themes/createTheme'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const jsonOut = path.join(__dirname, '..', 'src', 'themes', 'schema.generated.json'); + +fs.writeFileSync( + jsonOut, + JSON.stringify( + NewThemeOptionsSchema.toJSONSchema({ + target: 'draft-07', + }), + undefined, + 2 + ) +); + +console.log('Successfully generated theme schema'); diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts index 767da439543..b0aa271be3f 100644 --- a/packages/grafana-data/src/field/fieldOverrides.test.ts +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -9,6 +9,7 @@ import { FieldColorModeId } from '../types/fieldColor'; import { FieldConfigPropertyItem, FieldConfigSource } from '../types/fieldOverrides'; import { InterpolateFunction } from '../types/panel'; import { ThresholdsMode } from '../types/thresholds'; +import { MappingType } from '../types/valueMapping'; import { Registry } from '../utils/Registry'; import { locationUtil } from '../utils/location'; import { mockStandardProperties } from '../utils/tests/mockStandardProperties'; @@ -999,6 +1000,45 @@ describe('setDynamicConfigValue', () => { expect(config.custom.property3).toEqual({}); expect(config.displayName).toBeUndefined(); }); + + it('works correctly with multiple value mappings in the same override', () => { + const config: FieldConfig = { + mappings: [{ type: MappingType.ValueToText, options: { existing: { text: 'existing' } } }], + }; + + setDynamicConfigValue( + config, + { + id: 'mappings', + value: [{ type: MappingType.ValueToText, options: { first: { text: 'first' } } }], + }, + { + fieldConfigRegistry: customFieldRegistry, + data: [], + field: { type: FieldType.number } as Field, + dataFrameIndex: 0, + } + ); + + setDynamicConfigValue( + config, + { + id: 'mappings', + value: [{ type: MappingType.ValueToText, options: { second: { text: 'second' } } }], + }, + { + fieldConfigRegistry: customFieldRegistry, + data: [], + field: { type: FieldType.number } as Field, + dataFrameIndex: 0, + } + ); + + expect(config.mappings).toHaveLength(3); + expect(config.mappings![0]).toEqual({ type: MappingType.ValueToText, options: { existing: { text: 'existing' } } }); + expect(config.mappings![1]).toEqual({ type: MappingType.ValueToText, options: { first: { text: 'first' } } }); + expect(config.mappings![2]).toEqual({ type: MappingType.ValueToText, options: { second: { text: 'second' } } }); + }); }); describe('getLinksSupplier', () => { diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts index 8b345036c64..bf56f811106 100644 --- a/packages/grafana-data/src/field/fieldOverrides.ts +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -341,7 +341,7 @@ export function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigV return; } - const val = item.process(value.value, context, item.settings); + let val = item.process(value.value, context, item.settings); const remove = val === undefined || val === null; @@ -352,6 +352,15 @@ export function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigV unset(config, item.path); } } else { + // Merge arrays (e.g. mappings) when multiple overrides target the same field + if (Array.isArray(val)) { + const existingValue = item.isCustom ? get(config.custom, item.path) : get(config, item.path); + + if (Array.isArray(existingValue)) { + val = [...existingValue, ...val]; + } + } + if (item.isCustom) { if (!config.custom) { config.custom = {}; diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 6027b566764..5ed081b00f0 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -844,7 +844,6 @@ export { DataLinkConfigOrigin, SupportedTransformationType, type InternalDataLink, - type LinkTarget, type LinkModel, type LinkModelSupplier, VariableOrigin, @@ -852,6 +851,7 @@ export { VariableSuggestionsScope, OneClickMode, } from './types/dataLink'; +export { type LinkTarget } from './types/linkTarget'; export { type Action, type ActionModel, diff --git a/packages/grafana-data/src/internal/index.ts b/packages/grafana-data/src/internal/index.ts index e2dab753baa..230cdd2cbf9 100644 --- a/packages/grafana-data/src/internal/index.ts +++ b/packages/grafana-data/src/internal/index.ts @@ -93,7 +93,6 @@ export { DataTransformerID } from '../transformations/transformers/ids'; export { mergeTransformer } from '../transformations/transformers/merge'; export { getThemeById } from '../themes/registry'; -export * as experimentalThemeDefinitions from '../themes/themeDefinitions'; export { GrafanaEdition } from '../types/config'; export { SIPrefix } from '../valueFormats/symbolFormatters'; @@ -106,3 +105,4 @@ export { findNumericFieldMinMax } from '../field/fieldOverrides'; export { type PanelOptionsSupplier } from '../panel/PanelPlugin'; export { sanitize, sanitizeUrl } from '../text/sanitize'; export { type NestedValueAccess, type NestedPanelOptions, isNestedPanelOptions } from '../utils/OptionsUIBuilders'; +export { NewThemeOptionsSchema } from '../themes/createTheme'; diff --git a/packages/grafana-data/src/themes/createColors.ts b/packages/grafana-data/src/themes/createColors.ts index 09b94fd3b3e..ee7beab02d6 100644 --- a/packages/grafana-data/src/themes/createColors.ts +++ b/packages/grafana-data/src/themes/createColors.ts @@ -1,83 +1,103 @@ import { merge } from 'lodash'; +import { z } from 'zod'; import { alpha, darken, emphasize, getContrastRatio, lighten } from './colorManipulator'; import { palette } from './palette'; -import { DeepPartial, ThemeRichColor } from './types'; +import { DeepRequired, ThemeRichColor, ThemeRichColorInputSchema } from './types'; +const ThemeColorsModeSchema = z.enum(['light', 'dark']); /** @internal */ -export type ThemeColorsMode = 'light' | 'dark'; +export type ThemeColorsMode = z.infer; +const createThemeColorsBaseSchema = (color: TColor) => + z + .object({ + mode: ThemeColorsModeSchema, + + primary: color, + secondary: color, + info: color, + error: color, + success: color, + warning: color, + + text: z.object({ + primary: z.string().optional(), + secondary: z.string().optional(), + disabled: z.string().optional(), + link: z.string().optional(), + /** Used for auto white or dark text on colored backgrounds */ + maxContrast: z.string().optional(), + }), + + background: z.object({ + /** Dashboard and body background */ + canvas: z.string().optional(), + /** Primary content pane background (panels etc) */ + primary: z.string().optional(), + /** Cards and elements that need to stand out on the primary background */ + secondary: z.string().optional(), + /** + * For popovers and menu backgrounds. This is the same color as primary in most light themes but in dark + * themes it has a brighter shade to help give it contrast against the primary background. + **/ + elevated: z.string().optional(), + }), + + border: z.object({ + weak: z.string().optional(), + medium: z.string().optional(), + strong: z.string().optional(), + }), + + gradients: z.object({ + brandVertical: z.string().optional(), + brandHorizontal: z.string().optional(), + }), + + action: z.object({ + /** Used for selected menu item / select option */ + selected: z.string().optional(), + /** + * @alpha (Do not use from plugins) + * Used for selected items when background only change is not enough (Currently only used for FilterPill) + **/ + selectedBorder: z.string().optional(), + /** Used for hovered menu item / select option */ + hover: z.string().optional(), + /** Used for button/colored background hover opacity */ + hoverOpacity: z.number().optional(), + /** Used focused menu item / select option */ + focus: z.string().optional(), + /** Used for disabled buttons and inputs */ + disabledBackground: z.string().optional(), + /** Disabled text */ + disabledText: z.string().optional(), + /** Disablerd opacity */ + disabledOpacity: z.number().optional(), + }), + + hoverFactor: z.number(), + contrastThreshold: z.number(), + tonalOffset: z.number(), + }) + .partial(); + +// Need to override the zod type to include the generic properly /** @internal */ -export interface ThemeColorsBase { - mode: ThemeColorsMode; - +export type ThemeColorsBase = DeepRequired< + Omit< + z.infer>, + 'primary' | 'secondary' | 'info' | 'error' | 'success' | 'warning' + > +> & { primary: TColor; secondary: TColor; info: TColor; error: TColor; success: TColor; warning: TColor; - - text: { - primary: string; - secondary: string; - disabled: string; - link: string; - /** Used for auto white or dark text on colored backgrounds */ - maxContrast: string; - }; - - background: { - /** Dashboard and body background */ - canvas: string; - /** Primary content pane background (panels etc) */ - primary: string; - /** Cards and elements that need to stand out on the primary background */ - secondary: string; - /** - * For popovers and menu backgrounds. This is the same color as primary in most light themes but in dark - * themes it has a brighter shade to help give it contrast against the primary background. - **/ - elevated: string; - }; - - border: { - weak: string; - medium: string; - strong: string; - }; - - gradients: { - brandVertical: string; - brandHorizontal: string; - }; - - action: { - /** Used for selected menu item / select option */ - selected: string; - /** - * @alpha (Do not use from plugins) - * Used for selected items when background only change is not enough (Currently only used for FilterPill) - **/ - selectedBorder: string; - /** Used for hovered menu item / select option */ - hover: string; - /** Used for button/colored background hover opacity */ - hoverOpacity: number; - /** Used focused menu item / select option */ - focus: string; - /** Used for disabled buttons and inputs */ - disabledBackground: string; - /** Disabled text */ - disabledText: string; - /** Disablerd opacity */ - disabledOpacity: number; - }; - - hoverFactor: number; - contrastThreshold: number; - tonalOffset: number; -} +}; export interface ThemeHoverStrengh {} @@ -89,8 +109,10 @@ export interface ThemeColors extends ThemeColorsBase { emphasize(color: string, amount?: number): string; } +export const ThemeColorsInputSchema = createThemeColorsBaseSchema(ThemeRichColorInputSchema); + /** @internal */ -export type ThemeColorsInput = DeepPartial>; +export type ThemeColorsInput = z.infer; class DarkColors implements ThemeColorsBase> { mode: ThemeColorsMode = 'dark'; diff --git a/packages/grafana-data/src/themes/createShape.ts b/packages/grafana-data/src/themes/createShape.ts index 42291fb78d0..f454eda6861 100644 --- a/packages/grafana-data/src/themes/createShape.ts +++ b/packages/grafana-data/src/themes/createShape.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + /** @beta */ export interface ThemeShape { /** @@ -34,9 +36,12 @@ export interface Radii { } /** @internal */ -export interface ThemeShapeInput { - borderRadius?: number; -} +export const ThemeShapeInputSchema = z.object({ + borderRadius: z.int().nonnegative().optional(), +}); + +/** @internal */ +export type ThemeShapeInput = z.infer; export function createShape(options: ThemeShapeInput): ThemeShape { const baseBorderRadius = options.borderRadius ?? 6; diff --git a/packages/grafana-data/src/themes/createSpacing.ts b/packages/grafana-data/src/themes/createSpacing.ts index 2fa047b3e68..1ba51c61917 100644 --- a/packages/grafana-data/src/themes/createSpacing.ts +++ b/packages/grafana-data/src/themes/createSpacing.ts @@ -1,11 +1,15 @@ // Code based on Material UI // The MIT License (MIT) // Copyright (c) 2014 Call-Em-All +import { z } from 'zod'; /** @internal */ -export type ThemeSpacingOptions = { - gridSize?: number; -}; +export const ThemeSpacingOptionsSchema = z.object({ + gridSize: z.int().positive().optional(), +}); + +/** @internal */ +export type ThemeSpacingOptions = z.infer; /** @internal */ export type ThemeSpacingArgument = number | string; diff --git a/packages/grafana-data/src/themes/createTheme.ts b/packages/grafana-data/src/themes/createTheme.ts index fd4d8080a4e..a4fa773cf52 100644 --- a/packages/grafana-data/src/themes/createTheme.ts +++ b/packages/grafana-data/src/themes/createTheme.ts @@ -1,28 +1,37 @@ +import * as z from 'zod'; + import { createBreakpoints } from './breakpoints'; -import { createColors, ThemeColorsInput } from './createColors'; +import { createColors, ThemeColorsInputSchema } from './createColors'; import { createComponents } from './createComponents'; import { createShadows } from './createShadows'; -import { createShape, ThemeShapeInput } from './createShape'; -import { createSpacing, ThemeSpacingOptions } from './createSpacing'; +import { createShape, ThemeShapeInputSchema } from './createShape'; +import { createSpacing, ThemeSpacingOptionsSchema } from './createSpacing'; import { createTransitions } from './createTransitions'; -import { createTypography, ThemeTypographyInput } from './createTypography'; +import { createTypography, ThemeTypographyInputSchema } from './createTypography'; import { createV1Theme } from './createV1Theme'; -import { createVisualizationColors, ThemeVisualizationColorsInput } from './createVisualizationColors'; +import { createVisualizationColors, ThemeVisualizationColorsInputSchema } from './createVisualizationColors'; import { GrafanaTheme2 } from './types'; import { zIndex } from './zIndex'; -/** @internal */ -export interface NewThemeOptions { - name?: string; - colors?: ThemeColorsInput; - spacing?: ThemeSpacingOptions; - shape?: ThemeShapeInput; - typography?: ThemeTypographyInput; - visualization?: ThemeVisualizationColorsInput; -} +export const NewThemeOptionsSchema = z.object({ + name: z.string(), + id: z.string(), + colors: ThemeColorsInputSchema.optional(), + spacing: ThemeSpacingOptionsSchema.optional(), + shape: ThemeShapeInputSchema.optional(), + typography: ThemeTypographyInputSchema.optional(), + visualization: ThemeVisualizationColorsInputSchema.optional(), +}); /** @internal */ -export function createTheme(options: NewThemeOptions = {}): GrafanaTheme2 { +export type NewThemeOptions = z.infer; + +/** @internal */ +export function createTheme( + options: Omit & { + name?: NewThemeOptions['name']; + } = {} +): GrafanaTheme2 { const { name, colors: colorsInput = {}, diff --git a/packages/grafana-data/src/themes/createTypography.ts b/packages/grafana-data/src/themes/createTypography.ts index 25c5fa7c91b..3504e52d2fa 100644 --- a/packages/grafana-data/src/themes/createTypography.ts +++ b/packages/grafana-data/src/themes/createTypography.ts @@ -1,6 +1,7 @@ // Code based on Material UI // The MIT License (MIT) // Copyright (c) 2014 Call-Em-All +import { z } from 'zod'; import { ThemeColors } from './createColors'; @@ -40,18 +41,20 @@ export interface ThemeTypographyVariant { letterSpacing?: string; } -export interface ThemeTypographyInput { - fontFamily?: string; - fontFamilyMonospace?: string; - fontSize?: number; - fontWeightLight?: number; - fontWeightRegular?: number; - fontWeightMedium?: number; - fontWeightBold?: number; - // hat's the font-size on the html element. +export const ThemeTypographyInputSchema = z.object({ + fontFamily: z.string().optional(), + fontFamilyMonospace: z.string().optional(), + fontSize: z.number().positive().optional(), + fontWeightLight: z.number().positive().optional(), + fontWeightRegular: z.number().positive().optional(), + fontWeightMedium: z.number().positive().optional(), + fontWeightBold: z.number().positive().optional(), + // what's the font-size on the html element. // 16px is the default font-size used by browsers. - htmlFontSize?: number; -} + htmlFontSize: z.number().positive().optional(), +}); + +export type ThemeTypographyInput = z.infer; const defaultFontFamily = "'Inter', 'Helvetica', 'Arial', sans-serif"; const defaultFontFamilyMonospace = "'Roboto Mono', monospace"; diff --git a/packages/grafana-data/src/themes/createVisualizationColors.ts b/packages/grafana-data/src/themes/createVisualizationColors.ts index fca963c9c07..90acbbc2144 100644 --- a/packages/grafana-data/src/themes/createVisualizationColors.ts +++ b/packages/grafana-data/src/themes/createVisualizationColors.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + import { FALLBACK_COLOR } from '../types/fieldColor'; import { ThemeColors } from './createColors'; @@ -26,29 +28,44 @@ export interface ThemeVizColor { type ThemeVizColorName = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'; -type ThemeVizColorShadeName = - | `super-light-${T}` - | `light-${T}` - | T - | `semi-dark-${T}` - | `dark-${T}`; +const createShadeSchema = (color: T extends ThemeVizColorName ? T : never) => + z.enum([`super-light-${color}`, `light-${color}`, color, `semi-dark-${color}`, `dark-${color}`]); -type ThemeVizHueGeneric = T extends ThemeVizColorName - ? { - name: T; - shades: Array>; - } - : never; +type ThemeVizColorShadeName = z.infer>>; + +const createHueSchema = (color: T extends ThemeVizColorName ? T : never) => + z.object({ + name: z.literal(color), + shades: z.array( + z.object({ + color: z.string(), + name: createShadeSchema(color), + aliases: z.array(z.string()).optional(), + primary: z.boolean().optional(), + }) + ), + }); + +const ThemeVizHueSchema = z.union([ + createHueSchema('red'), + createHueSchema('orange'), + createHueSchema('yellow'), + createHueSchema('green'), + createHueSchema('blue'), + createHueSchema('purple'), +]); /** * @alpha */ -export type ThemeVizHue = ThemeVizHueGeneric; +export type ThemeVizHue = z.infer; -export type ThemeVisualizationColorsInput = { - hues?: ThemeVizHue[]; - palette?: string[]; -}; +export const ThemeVisualizationColorsInputSchema = z.object({ + hues: z.array(ThemeVizHueSchema).optional(), + palette: z.array(z.string()).optional(), +}); + +export type ThemeVisualizationColorsInput = z.infer; /** * @internal diff --git a/packages/grafana-data/src/themes/registry.ts b/packages/grafana-data/src/themes/registry.ts index c4cf352b622..cfa4a10c6e4 100644 --- a/packages/grafana-data/src/themes/registry.ts +++ b/packages/grafana-data/src/themes/registry.ts @@ -1,7 +1,18 @@ import { Registry, RegistryItem } from '../utils/Registry'; -import { createTheme } from './createTheme'; -import * as extraThemes from './themeDefinitions'; +import { createTheme, NewThemeOptionsSchema } from './createTheme'; +import aubergine from './themeDefinitions/aubergine.json'; +import debug from './themeDefinitions/debug.json'; +import desertbloom from './themeDefinitions/desertbloom.json'; +import gildedgrove from './themeDefinitions/gildedgrove.json'; +import gloom from './themeDefinitions/gloom.json'; +import mars from './themeDefinitions/mars.json'; +import matrix from './themeDefinitions/matrix.json'; +import sapphiredusk from './themeDefinitions/sapphiredusk.json'; +import synthwave from './themeDefinitions/synthwave.json'; +import tron from './themeDefinitions/tron.json'; +import victorian from './themeDefinitions/victorian.json'; +import zen from './themeDefinitions/zen.json'; import { GrafanaTheme2 } from './types'; export interface ThemeRegistryItem extends RegistryItem { @@ -9,6 +20,21 @@ export interface ThemeRegistryItem extends RegistryItem { build: () => GrafanaTheme2; } +const extraThemes: { [key: string]: unknown } = { + aubergine, + debug, + desertbloom, + gildedgrove, + gloom, + mars, + matrix, + sapphiredusk, + synthwave, + tron, + victorian, + zen, +}; + /** * @internal * Only for internal use, never use this from a plugin @@ -42,9 +68,6 @@ export function getBuiltInThemes(allowedExtras: string[]) { return sortedThemes; } -/** - * There is also a backend list at pkg/services/preference/themes.go - */ const themeRegistry = new Registry(() => { return [ { id: 'system', name: 'System preference', build: getSystemPreferenceTheme }, @@ -53,13 +76,19 @@ const themeRegistry = new Registry(() => { ]; }); -for (const [id, theme] of Object.entries(extraThemes)) { - themeRegistry.register({ - id, - name: theme.name ?? '', - build: () => createTheme(theme), - isExtra: true, - }); +for (const [name, json] of Object.entries(extraThemes)) { + const result = NewThemeOptionsSchema.safeParse(json); + if (!result.success) { + console.error(`Invalid theme definition for theme ${name}: ${result.error.message}`); + } else { + const theme = result.data; + themeRegistry.register({ + id: theme.id, + name: theme.name, + build: () => createTheme(theme), + isExtra: true, + }); + } } function getSystemPreferenceTheme() { diff --git a/packages/grafana-data/src/themes/schema.generated.json b/packages/grafana-data/src/themes/schema.generated.json new file mode 100644 index 00000000000..366ab9c05d6 --- /dev/null +++ b/packages/grafana-data/src/themes/schema.generated.json @@ -0,0 +1,608 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "colors": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["light", "dark"] + }, + "primary": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "main": { + "type": "string" + }, + "shade": { + "type": "string" + }, + "text": { + "type": "string" + }, + "border": { + "type": "string" + }, + "transparent": { + "type": "string" + }, + "borderTransparent": { + "type": "string" + }, + "contrastText": { + "type": "string" + } + }, + "additionalProperties": false + }, + "secondary": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "main": { + "type": "string" + }, + "shade": { + "type": "string" + }, + "text": { + "type": "string" + }, + "border": { + "type": "string" + }, + "transparent": { + "type": "string" + }, + "borderTransparent": { + "type": "string" + }, + "contrastText": { + "type": "string" + } + }, + "additionalProperties": false + }, + "info": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "main": { + "type": "string" + }, + "shade": { + "type": "string" + }, + "text": { + "type": "string" + }, + "border": { + "type": "string" + }, + "transparent": { + "type": "string" + }, + "borderTransparent": { + "type": "string" + }, + "contrastText": { + "type": "string" + } + }, + "additionalProperties": false + }, + "error": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "main": { + "type": "string" + }, + "shade": { + "type": "string" + }, + "text": { + "type": "string" + }, + "border": { + "type": "string" + }, + "transparent": { + "type": "string" + }, + "borderTransparent": { + "type": "string" + }, + "contrastText": { + "type": "string" + } + }, + "additionalProperties": false + }, + "success": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "main": { + "type": "string" + }, + "shade": { + "type": "string" + }, + "text": { + "type": "string" + }, + "border": { + "type": "string" + }, + "transparent": { + "type": "string" + }, + "borderTransparent": { + "type": "string" + }, + "contrastText": { + "type": "string" + } + }, + "additionalProperties": false + }, + "warning": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "main": { + "type": "string" + }, + "shade": { + "type": "string" + }, + "text": { + "type": "string" + }, + "border": { + "type": "string" + }, + "transparent": { + "type": "string" + }, + "borderTransparent": { + "type": "string" + }, + "contrastText": { + "type": "string" + } + }, + "additionalProperties": false + }, + "text": { + "type": "object", + "properties": { + "primary": { + "type": "string" + }, + "secondary": { + "type": "string" + }, + "disabled": { + "type": "string" + }, + "link": { + "type": "string" + }, + "maxContrast": { + "type": "string" + } + }, + "additionalProperties": false + }, + "background": { + "type": "object", + "properties": { + "canvas": { + "type": "string" + }, + "primary": { + "type": "string" + }, + "secondary": { + "type": "string" + }, + "elevated": { + "type": "string" + } + }, + "additionalProperties": false + }, + "border": { + "type": "object", + "properties": { + "weak": { + "type": "string" + }, + "medium": { + "type": "string" + }, + "strong": { + "type": "string" + } + }, + "additionalProperties": false + }, + "gradients": { + "type": "object", + "properties": { + "brandVertical": { + "type": "string" + }, + "brandHorizontal": { + "type": "string" + } + }, + "additionalProperties": false + }, + "action": { + "type": "object", + "properties": { + "selected": { + "type": "string" + }, + "selectedBorder": { + "type": "string" + }, + "hover": { + "type": "string" + }, + "hoverOpacity": { + "type": "number" + }, + "focus": { + "type": "string" + }, + "disabledBackground": { + "type": "string" + }, + "disabledText": { + "type": "string" + }, + "disabledOpacity": { + "type": "number" + } + }, + "additionalProperties": false + }, + "hoverFactor": { + "type": "number" + }, + "contrastThreshold": { + "type": "number" + }, + "tonalOffset": { + "type": "number" + } + }, + "additionalProperties": false + }, + "spacing": { + "type": "object", + "properties": { + "gridSize": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, + "shape": { + "type": "object", + "properties": { + "borderRadius": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, + "typography": { + "type": "object", + "properties": { + "fontFamily": { + "type": "string" + }, + "fontFamilyMonospace": { + "type": "string" + }, + "fontSize": { + "type": "number", + "exclusiveMinimum": 0 + }, + "fontWeightLight": { + "type": "number", + "exclusiveMinimum": 0 + }, + "fontWeightRegular": { + "type": "number", + "exclusiveMinimum": 0 + }, + "fontWeightMedium": { + "type": "number", + "exclusiveMinimum": 0 + }, + "fontWeightBold": { + "type": "number", + "exclusiveMinimum": 0 + }, + "htmlFontSize": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "additionalProperties": false + }, + "visualization": { + "type": "object", + "properties": { + "hues": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "red" + }, + "shades": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "name": { + "type": "string", + "enum": ["super-light-red", "light-red", "red", "semi-dark-red", "dark-red"] + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "primary": { + "type": "boolean" + } + }, + "required": ["color", "name"], + "additionalProperties": false + } + } + }, + "required": ["name", "shades"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "orange" + }, + "shades": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "name": { + "type": "string", + "enum": ["super-light-orange", "light-orange", "orange", "semi-dark-orange", "dark-orange"] + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "primary": { + "type": "boolean" + } + }, + "required": ["color", "name"], + "additionalProperties": false + } + } + }, + "required": ["name", "shades"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "yellow" + }, + "shades": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "name": { + "type": "string", + "enum": ["super-light-yellow", "light-yellow", "yellow", "semi-dark-yellow", "dark-yellow"] + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "primary": { + "type": "boolean" + } + }, + "required": ["color", "name"], + "additionalProperties": false + } + } + }, + "required": ["name", "shades"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "green" + }, + "shades": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "name": { + "type": "string", + "enum": ["super-light-green", "light-green", "green", "semi-dark-green", "dark-green"] + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "primary": { + "type": "boolean" + } + }, + "required": ["color", "name"], + "additionalProperties": false + } + } + }, + "required": ["name", "shades"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "blue" + }, + "shades": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "name": { + "type": "string", + "enum": ["super-light-blue", "light-blue", "blue", "semi-dark-blue", "dark-blue"] + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "primary": { + "type": "boolean" + } + }, + "required": ["color", "name"], + "additionalProperties": false + } + } + }, + "required": ["name", "shades"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "purple" + }, + "shades": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "name": { + "type": "string", + "enum": ["super-light-purple", "light-purple", "purple", "semi-dark-purple", "dark-purple"] + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "primary": { + "type": "boolean" + } + }, + "required": ["color", "name"], + "additionalProperties": false + } + } + }, + "required": ["name", "shades"], + "additionalProperties": false + } + ] + } + }, + "palette": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "required": ["name", "id"], + "additionalProperties": false +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/aubergine.json b/packages/grafana-data/src/themes/themeDefinitions/aubergine.json new file mode 100644 index 00000000000..4baf4f3f439 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/aubergine.json @@ -0,0 +1,50 @@ +{ + "name": "Aubergine", + "id": "aubergine", + "colors": { + "mode": "dark", + "border": { + "weak": "#4F2A3D", + "medium": "#6A3C4B", + "strong": "#8C5A69" + }, + "text": { + "primary": "#E5D0D6", + "secondary": "#D1A8C4", + "disabled": "#B7A0A6", + "link": "#A56BB6", + "maxContrast": "#FFFFFF" + }, + "primary": { + "main": "#8C5A69" + }, + "secondary": { + "main": "#6A3C4B", + "text": "#D1A8C4", + "border": "#8C5A69" + }, + "background": { + "canvas": "#2E1F2D", + "primary": "#3C2136", + "secondary": "#4A2D47", + "elevated": "#4A2D47" + }, + "action": { + "hover": "#6A3C4B", + "selected": "#8C5A69", + "selectedBorder": "#FFB300", + "focus": "#A56BB6", + "hoverOpacity": 0.1, + "disabledText": "#B7A0A6", + "disabledBackground": "#4A2D47", + "disabledOpacity": 0.38 + }, + "gradients": { + "brandHorizontal": "linear-gradient(270deg, #6A3C4B 0%, #A56BB6 100%)", + "brandVertical": "linear-gradient(0deg, #6A3C4B 0%, #A56BB6 100%)" + }, + "contrastThreshold": 4, + "hoverFactor": 0.07, + "tonalOffset": 0.15 + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/aubergine.ts b/packages/grafana-data/src/themes/themeDefinitions/aubergine.ts deleted file mode 100644 index 967621ebc60..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/aubergine.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -const aubergineTheme: NewThemeOptions = { - name: 'Aubergine', - colors: { - mode: 'dark', - border: { - weak: '#4F2A3D', - medium: '#6A3C4B', - strong: '#8C5A69', - }, - text: { - primary: '#E5D0D6', - secondary: '#D1A8C4', - disabled: '#B7A0A6', - link: '#A56BB6', - maxContrast: '#FFFFFF', - }, - primary: { - main: '#8C5A69', - }, - secondary: { - main: '#6A3C4B', - text: '#D1A8C4', - border: '#8C5A69', - }, - background: { - canvas: '#2E1F2D', - primary: '#3C2136', - secondary: '#4A2D47', - elevated: '#4A2D47', - }, - action: { - hover: '#6A3C4B', - selected: '#8C5A69', - selectedBorder: '#FFB300', - focus: '#A56BB6', - hoverOpacity: 0.1, - disabledText: '#B7A0A6', - disabledBackground: '#4A2D47', - disabledOpacity: 0.38, - }, - gradients: { - brandHorizontal: 'linear-gradient(270deg, #6A3C4B 0%, #A56BB6 100%)', - brandVertical: 'linear-gradient(0deg, #6A3C4B 0%, #A56BB6 100%)', - }, - contrastThreshold: 4, - hoverFactor: 0.07, - tonalOffset: 0.15, - }, -}; - -export default aubergineTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/debug.json b/packages/grafana-data/src/themes/themeDefinitions/debug.json new file mode 100644 index 00000000000..a9cce4c5e21 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/debug.json @@ -0,0 +1,60 @@ +{ + "name": "Debug", + "id": "debug", + "colors": { + "mode": "dark", + "background": { + "canvas": "#000033", + "primary": "#000044", + "secondary": "#000055", + "elevated": "#000055" + }, + "text": { + "primary": "#bbbb00", + "secondary": "#888800", + "disabled": "#444400", + "link": "#dddd00", + "maxContrast": "#ffff00" + }, + "border": { + "weak": "#ff000044", + "medium": "#ff000088", + "strong": "#ff0000ff" + }, + "primary": { + "border": "#ff000088", + "text": "#cccc00", + "contrastText": "#ffff00", + "shade": "#9900dd" + }, + "secondary": { + "border": "#ff000088", + "text": "#cccc00", + "contrastText": "#ffff00", + "shade": "#9900dd" + }, + "info": { + "shade": "#9900dd" + }, + "warning": { + "shade": "#9900dd" + }, + "success": { + "shade": "#9900dd" + }, + "error": { + "shade": "#9900dd" + }, + "action": { + "hover": "#9900dd", + "focus": "#6600aa", + "selected": "#440088" + } + }, + "shape": { + "borderRadius": 8 + }, + "spacing": { + "gridSize": 10 + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/debug.ts b/packages/grafana-data/src/themes/themeDefinitions/debug.ts deleted file mode 100644 index 22e577faf2c..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/debug.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -/** - * a very ugly theme that is useful for debugging and checking if the theme is applied correctly - * borders are red, - * backgrounds are blue, - * text is yellow, - * and grafana loves you <3 - * (also corners are rounded, action states (hover, focus, selected) are purple) - */ -const debugTheme: NewThemeOptions = { - name: 'Debug', - colors: { - mode: 'dark', - background: { - canvas: '#000033', - primary: '#000044', - secondary: '#000055', - elevated: '#000055', - }, - text: { - primary: '#bbbb00', - secondary: '#888800', - disabled: '#444400', - link: '#dddd00', - maxContrast: '#ffff00', - }, - border: { - weak: '#ff000044', - medium: '#ff000088', - strong: '#ff0000ff', - }, - primary: { - border: '#ff000088', - text: '#cccc00', - contrastText: '#ffff00', - shade: '#9900dd', - }, - secondary: { - border: '#ff000088', - text: '#cccc00', - contrastText: '#ffff00', - shade: '#9900dd', - }, - info: { - shade: '#9900dd', - }, - warning: { - shade: '#9900dd', - }, - success: { - shade: '#9900dd', - }, - error: { - shade: '#9900dd', - }, - action: { - hover: '#9900dd', - focus: '#6600aa', - selected: '#440088', - }, - }, - shape: { - borderRadius: 8, - }, - spacing: { - gridSize: 10, - }, -}; - -export default debugTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/desertbloom.json b/packages/grafana-data/src/themes/themeDefinitions/desertbloom.json new file mode 100644 index 00000000000..1c2304aaff8 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/desertbloom.json @@ -0,0 +1,71 @@ +{ + "name": "Desert bloom", + "id": "desertbloom", + "colors": { + "mode": "light", + "border": { + "weak": "rgba(0, 0, 0, 0.12)", + "medium": "rgba(0, 0, 0, 0.20)", + "strong": "rgba(0, 0, 0, 0.30)" + }, + "text": { + "primary": "#333333", + "secondary": "#555555", + "disabled": "rgba(0, 0, 0, 0.5)", + "link": "#1A82E2", + "maxContrast": "#000000" + }, + "primary": { + "main": "#FF6F61", + "text": "#FE6F61", + "border": "#E55B4D", + "name": "primary", + "shade": "#E55B4D", + "transparent": "#FF6F6126", + "contrastText": "#FFFFFF", + "borderTransparent": "#FF6F6140" + }, + "secondary": { + "main": "#FFFFFF", + "text": "#695f53", + "border": "#d9cec0", + "name": "secondary", + "shade": "#d9cec0", + "transparent": "#FFFFFF26", + "contrastText": "#4c4339", + "borderTransparent": "#FFFFFF40" + }, + "info": { + "main": "#1A82E2" + }, + "success": { + "main": "#4CAF50" + }, + "warning": { + "main": "#FFC107" + }, + "background": { + "canvas": "#FFF8F0", + "primary": "#FFFFFF", + "secondary": "#f9f3e8", + "elevated": "#FFFFFF" + }, + "action": { + "hover": "rgba(168, 156, 134, 0.12)", + "selected": "rgba(168, 156, 134, 0.36)", + "selectedBorder": "#FF6F61", + "focus": "rgba(168, 156, 134, 0.50)", + "hoverOpacity": 0.08, + "disabledText": "rgba(168, 156, 134, 0.5)", + "disabledBackground": "rgba(168, 156, 134, 0.06)", + "disabledOpacity": 0.38 + }, + "gradients": { + "brandHorizontal": "linear-gradient(270deg,rgba(255, 111, 97, 1) 0%, rgba(255, 167, 58, 1) 100%)", + "brandVertical": "linear-gradient(0deg, rgba(255, 111, 97, 1) 0%, rgba(255, 167, 58, 1) 100%)" + }, + "contrastThreshold": 3, + "hoverFactor": 0.03, + "tonalOffset": 0.15 + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/desertbloom.ts b/packages/grafana-data/src/themes/themeDefinitions/desertbloom.ts deleted file mode 100644 index 8a86b73a0f7..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/desertbloom.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -const desertBloomTheme: NewThemeOptions = { - name: 'Desert bloom', - colors: { - mode: 'light', - border: { - weak: 'rgba(0, 0, 0, 0.12)', - medium: 'rgba(0, 0, 0, 0.20)', - strong: 'rgba(0, 0, 0, 0.30)', - }, - text: { - primary: '#333333', - secondary: '#555555', - disabled: 'rgba(0, 0, 0, 0.5)', - link: '#1A82E2', - maxContrast: '#000000', - }, - primary: { - main: '#FF6F61', - text: '#FE6F61', - border: '#E55B4D', - name: 'primary', - shade: '#E55B4D', - transparent: '#FF6F6126', - contrastText: '#FFFFFF', - borderTransparent: '#FF6F6140', - }, - secondary: { - main: '#FFFFFF', - text: '#695f53', - border: '#d9cec0', - name: 'secondary', - shade: '#d9cec0', - transparent: '#FFFFFF26', - contrastText: '#4c4339', - borderTransparent: '#FFFFFF40', - }, - info: { - main: '#1A82E2', - }, - success: { - main: '#4CAF50', - }, - warning: { - main: '#FFC107', - }, - background: { - canvas: '#FFF8F0', - primary: '#FFFFFF', - secondary: '#f9f3e8', - elevated: '#FFFFFF', - }, - action: { - hover: 'rgba(168, 156, 134, 0.12)', - selected: 'rgba(168, 156, 134, 0.36)', - selectedBorder: '#FF6F61', - focus: 'rgba(168, 156, 134, 0.50)', - hoverOpacity: 0.08, - disabledText: 'rgba(168, 156, 134, 0.5)', - disabledBackground: 'rgba(168, 156, 134, 0.06)', - disabledOpacity: 0.38, - }, - - gradients: { - brandHorizontal: 'linear-gradient(270deg,rgba(255, 111, 97, 1) 0%, rgba(255, 167, 58, 1) 100%)', - brandVertical: 'linear-gradient(0deg, rgba(255, 111, 97, 1) 0%, rgba(255, 167, 58, 1) 100%)', - }, - contrastThreshold: 3, - hoverFactor: 0.03, - tonalOffset: 0.15, - }, -}; - -export default desertBloomTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/gildedgrove.json b/packages/grafana-data/src/themes/themeDefinitions/gildedgrove.json new file mode 100644 index 00000000000..a147afbbe76 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/gildedgrove.json @@ -0,0 +1,62 @@ +{ + "name": "Gilded grove", + "id": "gildedgrove", + "colors": { + "mode": "dark", + "border": { + "weak": "rgba(200, 200, 180, 0.12)", + "medium": "rgba(200, 200, 180, 0.20)", + "strong": "rgba(200, 200, 180, 0.30)" + }, + "text": { + "primary": "rgb(250, 250, 239)", + "secondary": "rgba(200, 200, 180, 0.85)", + "disabled": "rgba(200, 200, 180, 0.6)", + "link": "#FEAC34", + "maxContrast": "#FFFFFF" + }, + "primary": { + "main": "#FEAC34", + "text": "#FFD783", + "border": "#FFD783", + "name": "primary", + "shade": "rgb(255, 173, 80)", + "transparent": "#FEAC3426", + "contrastText": "#111614", + "borderTransparent": "#FFD78340" + }, + "secondary": { + "main": "rgba(200, 200, 180, 0.10)", + "shade": "rgba(200, 200, 180, 0.14)", + "transparent": "rgba(200, 200, 180, 0.08)", + "text": "rgb(200, 200, 180)", + "contrastText": "rgb(200, 200, 180)", + "border": "rgba(200, 200, 180, 0.08)", + "name": "secondary", + "borderTransparent": "rgba(200, 200, 180, 0.25)" + }, + "background": { + "canvas": "#111614", + "primary": "#1d2220", + "secondary": "#27312E", + "elevated": "#27312E" + }, + "action": { + "hover": "rgba(200, 200, 180, 0.16)", + "selected": "rgba(200, 200, 180, 0.12)", + "selectedBorder": "#FEAC34", + "focus": "rgba(200, 200, 180, 0.16)", + "hoverOpacity": 0.08, + "disabledText": "rgba(200, 200, 180, 0.6)", + "disabledBackground": "rgba(200, 200, 180, 0.04)", + "disabledOpacity": 0.38 + }, + "gradients": { + "brandHorizontal": "linear-gradient(270deg, #FEAC34 0%, #FFD783 100%)", + "brandVertical": "linear-gradient(0.01deg, #FEAC34 0.01%, #FFD783 99.99%)" + }, + "contrastThreshold": 3, + "hoverFactor": 0.03, + "tonalOffset": 0.15 + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/gildedgrove.ts b/packages/grafana-data/src/themes/themeDefinitions/gildedgrove.ts deleted file mode 100644 index bfa3e121329..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/gildedgrove.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -const gildedGroveTheme: NewThemeOptions = { - name: 'Gilded grove', - colors: { - mode: 'dark', - border: { - weak: 'rgba(200, 200, 180, 0.12)', - medium: 'rgba(200, 200, 180, 0.20)', - strong: 'rgba(200, 200, 180, 0.30)', - }, - text: { - primary: 'rgb(250, 250, 239)', - secondary: 'rgba(200, 200, 180, 0.85)', - disabled: 'rgba(200, 200, 180, 0.6)', - link: '#FEAC34', - maxContrast: '#FFFFFF', - }, - primary: { - main: '#FEAC34', - text: '#FFD783', - border: '#FFD783', - name: 'primary', - shade: 'rgb(255, 173, 80)', - transparent: '#FEAC3426', - contrastText: '#111614', - borderTransparent: '#FFD78340', - }, - secondary: { - main: 'rgba(200, 200, 180, 0.10)', - shade: 'rgba(200, 200, 180, 0.14)', - transparent: 'rgba(200, 200, 180, 0.08)', - text: 'rgb(200, 200, 180)', - contrastText: 'rgb(200, 200, 180)', - border: 'rgba(200, 200, 180, 0.08)', - name: 'secondary', - borderTransparent: 'rgba(200, 200, 180, 0.25)', - }, - background: { - canvas: '#111614', - primary: '#1d2220', - secondary: '#27312E', - elevated: '#27312E', - }, - action: { - hover: 'rgba(200, 200, 180, 0.16)', - selected: 'rgba(200, 200, 180, 0.12)', - selectedBorder: '#FEAC34', - focus: 'rgba(200, 200, 180, 0.16)', - hoverOpacity: 0.08, - disabledText: 'rgba(200, 200, 180, 0.6)', - disabledBackground: 'rgba(200, 200, 180, 0.04)', - disabledOpacity: 0.38, - }, - gradients: { - brandHorizontal: 'linear-gradient(270deg, #FEAC34 0%, #FFD783 100%)', - brandVertical: 'linear-gradient(0.01deg, #FEAC34 0.01%, #FFD783 99.99%)', - }, - contrastThreshold: 3, - hoverFactor: 0.03, - tonalOffset: 0.15, - }, -}; - -export default gildedGroveTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/gloom.json b/packages/grafana-data/src/themes/themeDefinitions/gloom.json new file mode 100644 index 00000000000..8558c942511 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/gloom.json @@ -0,0 +1,52 @@ +{ + "name": "Gloom", + "id": "gloom", + "colors": { + "mode": "dark", + "border": { + "weak": "rgba(210, 210, 220, 0.12)", + "medium": "rgba(210, 210, 220, 0.20)", + "strong": "rgba(210, 210, 220, 0.30)" + }, + "text": { + "primary": "rgb(210, 210, 220)", + "secondary": "rgba(210, 210, 220, 0.65)", + "disabled": "rgba(210, 210, 220, 0.48)", + "link": "#f99a5c", + "maxContrast": "#FFF" + }, + "primary": { + "main": "#ff934d", + "text": "#f99a5c", + "border": "#ff934d", + "name": "primary" + }, + "secondary": { + "main": "rgba(195, 195, 245, 0.10)", + "shade": "rgba(195, 195, 245, 0.14)", + "transparent": "rgba(195, 195, 245, 0.08)", + "text": "rgba(195, 195, 245)", + "contrastText": "rgb(195, 195, 245)", + "border": "rgba(195, 195, 245, 0.08)" + }, + "background": { + "canvas": "#000", + "primary": "#121118", + "secondary": "#211e28", + "elevated": "#211e28" + }, + "action": { + "hover": "rgba(195, 195, 245, 0.07)", + "selected": "rgba(195, 195, 245, 0.11)", + "selectedBorder": "#ff934d", + "focus": "rgba(195, 195, 245, 0.07)", + "hoverOpacity": 0.05, + "disabledText": "rgba(210, 210, 220, 0.48)", + "disabledBackground": "rgba(210, 210, 220, 0.04)", + "disabledOpacity": 0.38 + }, + "contrastThreshold": 3, + "hoverFactor": 0.03, + "tonalOffset": 0.15 + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/gloom.ts b/packages/grafana-data/src/themes/themeDefinitions/gloom.ts deleted file mode 100644 index 49c105626fb..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/gloom.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -/** - * Torkel's GrafanaCon theme - * very WIP state - */ - -const whiteBase = `210, 210, 220`; -const secondaryBase = `195, 195, 245`; - -//const brandMain = '#3d71d9'; -//const brandText = '#6e9fff'; -const brandMain = '#ff934d'; -const brandText = '#f99a5c'; -const disabledText = `rgba(${whiteBase}, 0.48)`; - -const gloomTheme: NewThemeOptions = { - name: 'Gloom', - colors: { - mode: 'dark', - border: { - weak: `rgba(${whiteBase}, 0.12)`, - medium: `rgba(${whiteBase}, 0.20)`, - strong: `rgba(${whiteBase}, 0.30)`, - }, - - text: { - primary: `rgb(${whiteBase})`, - secondary: `rgba(${whiteBase}, 0.65)`, - disabled: disabledText, - link: brandText, - maxContrast: '#FFF', - }, - - primary: { - main: brandMain, - text: brandText, - border: brandMain, - name: 'primary', - }, - - secondary: { - main: `rgba(${secondaryBase}, 0.10)`, - shade: `rgba(${secondaryBase}, 0.14)`, - transparent: `rgba(${secondaryBase}, 0.08)`, - text: `rgba(${secondaryBase})`, - contrastText: `rgb(${secondaryBase})`, - border: `rgba(${secondaryBase}, 0.08)`, - }, - - background: { - canvas: '#000', - primary: '#121118', - secondary: '#211e28', - elevated: '#211e28', - }, - - action: { - hover: `rgba(${secondaryBase}, 0.07)`, - selected: `rgba(${secondaryBase}, 0.11)`, - selectedBorder: brandMain, - focus: `rgba(${secondaryBase}, 0.07)`, - hoverOpacity: 0.05, - disabledText: disabledText, - disabledBackground: `rgba(${whiteBase}, 0.04)`, - disabledOpacity: 0.38, - }, - - // gradients: { - // brandHorizontal: 'linear-gradient(270deg, #ff934d 0%, #FEAC34 100%)', - // brandVertical: 'linear-gradient(0.01deg, #ff934d 0.01%, #FEAC34 99.99%)', - // }, - - contrastThreshold: 3, - hoverFactor: 0.03, - tonalOffset: 0.15, - }, -}; - -export default gloomTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/index.ts b/packages/grafana-data/src/themes/themeDefinitions/index.ts deleted file mode 100644 index 151ae00593e..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { default as aubergine } from './aubergine'; -export { default as debug } from './debug'; -export { default as desertbloom } from './desertbloom'; -export { default as gildedgrove } from './gildedgrove'; -export { default as mars } from './mars'; -export { default as matrix } from './matrix'; -export { default as sapphiredusk } from './sapphiredusk'; -export { default as synthwave } from './synthwave'; -export { default as tron } from './tron'; -export { default as victorian } from './victorian'; -export { default as zen } from './zen'; -export { default as gloom } from './gloom'; diff --git a/packages/grafana-data/src/themes/themeDefinitions/mars.json b/packages/grafana-data/src/themes/themeDefinitions/mars.json new file mode 100644 index 00000000000..1aeb874f018 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/mars.json @@ -0,0 +1,50 @@ +{ + "name": "Mars", + "id": "mars", + "colors": { + "mode": "dark", + "border": { + "weak": "rgba(210, 90, 60, 0.2)", + "medium": "rgba(210, 90, 60, 0.35)", + "strong": "rgba(210, 90, 60, 0.5)" + }, + "text": { + "primary": "#DDDDDD", + "secondary": "#BBBBBB", + "disabled": "rgba(221, 221, 221, 0.5)", + "link": "#FF6F61", + "maxContrast": "#FFFFFF" + }, + "primary": { + "main": "#FF6F61" + }, + "secondary": { + "main": "#6a2f2f", + "text": "#BBBBBB", + "border": "rgba(210, 90, 60, 0.2)" + }, + "background": { + "canvas": "#3C1E1E", + "primary": "#522626", + "secondary": "#6A2F2F", + "elevated": "#6A2F2F" + }, + "action": { + "hover": "rgba(210, 90, 60, 0.16)", + "selected": "rgba(210, 90, 60, 0.12)", + "selectedBorder": "#FF6F61", + "focus": "rgba(210, 90, 60, 0.16)", + "hoverOpacity": 0.08, + "disabledText": "rgba(221, 221, 221, 0.5)", + "disabledBackground": "rgba(210, 90, 60, 0.08)", + "disabledOpacity": 0.38 + }, + "gradients": { + "brandHorizontal": "linear-gradient(270deg, #FF6F61 0%, #D25A3C 100%)", + "brandVertical": "linear-gradient(0.01deg, #FF6F61 0.01%, #D25A3C 99.99%)" + }, + "contrastThreshold": 3, + "hoverFactor": 0.05, + "tonalOffset": 0.2 + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/mars.ts b/packages/grafana-data/src/themes/themeDefinitions/mars.ts deleted file mode 100644 index f1db51e23b2..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/mars.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -const marsTheme: NewThemeOptions = { - name: 'Mars', - colors: { - mode: 'dark', - border: { - weak: 'rgba(210, 90, 60, 0.2)', - medium: 'rgba(210, 90, 60, 0.35)', - strong: 'rgba(210, 90, 60, 0.5)', - }, - text: { - primary: '#DDDDDD', - secondary: '#BBBBBB', - disabled: 'rgba(221, 221, 221, 0.5)', - link: '#FF6F61', - maxContrast: '#FFFFFF', - }, - primary: { - main: '#FF6F61', - }, - secondary: { - main: '#6a2f2f', - text: '#BBBBBB', - border: 'rgba(210, 90, 60, 0.2)', - }, - background: { - canvas: '#3C1E1E', - primary: '#522626', - secondary: '#6A2F2F', - elevated: '#6A2F2F', - }, - action: { - hover: 'rgba(210, 90, 60, 0.16)', - selected: 'rgba(210, 90, 60, 0.12)', - selectedBorder: '#FF6F61', - focus: 'rgba(210, 90, 60, 0.16)', - hoverOpacity: 0.08, - disabledText: 'rgba(221, 221, 221, 0.5)', - disabledBackground: 'rgba(210, 90, 60, 0.08)', - disabledOpacity: 0.38, - }, - gradients: { - brandHorizontal: 'linear-gradient(270deg, #FF6F61 0%, #D25A3C 100%)', - brandVertical: 'linear-gradient(0.01deg, #FF6F61 0.01%, #D25A3C 99.99%)', - }, - contrastThreshold: 3, - hoverFactor: 0.05, - tonalOffset: 0.2, - }, -}; - -export default marsTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/matrix.json b/packages/grafana-data/src/themes/themeDefinitions/matrix.json new file mode 100644 index 00000000000..a64a7ccce40 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/matrix.json @@ -0,0 +1,41 @@ +{ + "name": "Matrix", + "id": "matrix", + "colors": { + "mode": "dark", + "background": { + "canvas": "#000000", + "primary": "#020202", + "secondary": "#080808", + "elevated": "#080808" + }, + "text": { + "primary": "#00c017", + "secondary": "#008910", + "disabled": "#006a0c", + "link": "#00ff41", + "maxContrast": "#00ff41" + }, + "border": { + "weak": "#008f1144", + "medium": "#008f1188", + "strong": "#008910" + }, + "primary": { + "main": "#008910" + }, + "secondary": { + "text": "#008910" + }, + "gradients": { + "brandVertical": "linear-gradient(0deg, #008910 0%, #00ff41 100%)", + "brandHorizontal": "linear-gradient(90deg, #008910 0%, #00ff41 100%)" + } + }, + "shape": { + "borderRadius": 0 + }, + "typography": { + "fontFamily": "monospace" + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/matrix.ts b/packages/grafana-data/src/themes/themeDefinitions/matrix.ts deleted file mode 100644 index 51c58b9b394..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/matrix.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -const matrixTheme: NewThemeOptions = { - name: 'Matrix', - colors: { - mode: 'dark', - background: { - canvas: '#000000', - primary: '#020202', - secondary: '#080808', - elevated: '#080808', - }, - text: { - primary: '#00c017', - secondary: '#008910', - disabled: '#006a0c', - link: '#00ff41', - maxContrast: '#00ff41', - }, - border: { - weak: '#008f1144', - medium: '#008f1188', - strong: '#008910', - }, - primary: { - main: '#008910', - }, - secondary: { - text: '#008910', - }, - gradients: { - brandVertical: 'linear-gradient(0deg, #008910 0%, #00ff41 100%)', - brandHorizontal: 'linear-gradient(90deg, #008910 0%, #00ff41 100%)', - }, - }, - shape: { - borderRadius: 0, - }, - typography: { - fontFamily: 'monospace', - }, -}; - -export default matrixTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/sapphiredusk.json b/packages/grafana-data/src/themes/themeDefinitions/sapphiredusk.json new file mode 100644 index 00000000000..8d5f7731f05 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/sapphiredusk.json @@ -0,0 +1,76 @@ +{ + "name": "Sapphire dusk", + "id": "sapphiredusk", + "colors": { + "mode": "dark", + "border": { + "weak": "#232e47", + "medium": "#2c3853", + "strong": "#404d6b" + }, + "text": { + "primary": "#FFFFFF", + "secondary": "#bcccdd", + "disabled": "#838da5", + "link": "#93EBF0", + "maxContrast": "#FFFFFF" + }, + "primary": { + "main": "#93EBF0", + "text": "#a8e9ed", + "border": "#93ebf0", + "name": "primary", + "shade": "#c0f5d9", + "transparent": "#93EBF029", + "contrastText": "#111614", + "borderTransparent": "#93ebf040" + }, + "secondary": { + "main": "#2c364f", + "shade": "#36415e", + "transparent": "rgba(200, 200, 180, 0.08)", + "text": "#d1dfff", + "contrastText": "#acfeff", + "border": "rgba(200, 200, 180, 0.08)", + "name": "secondary", + "borderTransparent": "rgba(200, 200, 180, 0.25)" + }, + "info": { + "main": "#4d4593", + "text": "#a8e9ed", + "border": "#5d54a7" + }, + "error": { + "main": "#c63370" + }, + "success": { + "main": "#1A7F4B" + }, + "warning": { + "main": "#D448EA" + }, + "background": { + "canvas": "#1e273d", + "primary": "#12192e", + "secondary": "#212c47", + "elevated": "#212c47" + }, + "action": { + "hover": "#364057", + "selected": "#364260", + "selectedBorder": "#D448EA", + "focus": "#364057", + "hoverOpacity": 0.08, + "disabledText": "#838da5", + "disabledBackground": "rgba(54, 64, 87, 0.2)", + "disabledOpacity": 0.38 + }, + "gradients": { + "brandHorizontal": "linear-gradient(270deg, #D346EF 0%, #2C83FE 100%)", + "brandVertical": "linear-gradient(0deg, #D346EF 0%, #2C83FE 100%)" + }, + "contrastThreshold": 3, + "hoverFactor": 0.03, + "tonalOffset": 0.15 + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/sapphiredusk.ts b/packages/grafana-data/src/themes/themeDefinitions/sapphiredusk.ts deleted file mode 100644 index c777c61b055..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/sapphiredusk.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -const sapphireDuskTheme: NewThemeOptions = { - name: 'Sapphire dusk', - colors: { - mode: 'dark', - border: { - weak: '#232e47', - medium: '#2c3853', - strong: '#404d6b', - }, - text: { - primary: '#FFFFFF', - secondary: '#bcccdd', - disabled: '#838da5', - link: '#93EBF0', - maxContrast: '#FFFFFF', - }, - primary: { - main: '#93EBF0', - text: '#a8e9ed', - border: '#93ebf0', - name: 'primary', - shade: '#c0f5d9', - transparent: '#93EBF029', - contrastText: '#111614', - borderTransparent: '#93ebf040', - }, - secondary: { - main: '#2c364f', - shade: '#36415e', - transparent: 'rgba(200, 200, 180, 0.08)', - text: '#d1dfff', - contrastText: '#acfeff', - border: 'rgba(200, 200, 180, 0.08)', - name: 'secondary', - borderTransparent: 'rgba(200, 200, 180, 0.25)', - }, - info: { - main: '#4d4593', - text: '#a8e9ed', - border: '#5d54a7', - }, - error: { - main: '#c63370', - }, - success: { - main: '#1A7F4B', - }, - warning: { - main: '#D448EA', - }, - background: { - canvas: '#1e273d', - primary: '#12192e', - secondary: '#212c47', - elevated: '#212c47', - }, - action: { - hover: '#364057', - selected: '#364260', - selectedBorder: '#D448EA', - focus: '#364057', - hoverOpacity: 0.08, - disabledText: '#838da5', - disabledBackground: 'rgba(54, 64, 87, 0.2)', - disabledOpacity: 0.38, - }, - gradients: { - brandHorizontal: 'linear-gradient(270deg, #D346EF 0%, #2C83FE 100%)', - brandVertical: 'linear-gradient(0deg, #D346EF 0%, #2C83FE 100%)', - }, - contrastThreshold: 3, - hoverFactor: 0.03, - tonalOffset: 0.15, - }, -}; - -export default sapphireDuskTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/synthwave.json b/packages/grafana-data/src/themes/themeDefinitions/synthwave.json new file mode 100644 index 00000000000..377f09f2585 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/synthwave.json @@ -0,0 +1,50 @@ +{ + "name": "Synthwave", + "id": "synthwave", + "colors": { + "mode": "dark", + "border": { + "weak": "rgba(255, 20, 147, 0.12)", + "medium": "rgba(255, 20, 147, 0.20)", + "strong": "rgba(255, 20, 147, 0.30)" + }, + "text": { + "primary": "#E0E0E0", + "secondary": "rgba(224, 224, 224, 0.75)", + "disabled": "rgba(224, 224, 224, 0.5)", + "link": "#FF69B4", + "maxContrast": "#FFFFFF" + }, + "primary": { + "main": "#FF1493" + }, + "secondary": { + "main": "#37183a", + "text": "rgba(224, 224, 224, 0.75)", + "border": "rgba(255, 20, 147, 0.10)" + }, + "background": { + "canvas": "#1A1A2E", + "primary": "#16213E", + "secondary": "#0F3460", + "elevated": "#0F3460" + }, + "action": { + "hover": "rgba(255, 20, 147, 0.16)", + "selected": "rgba(255, 20, 147, 0.12)", + "selectedBorder": "#FF1493", + "focus": "rgba(255, 20, 147, 0.16)", + "hoverOpacity": 0.08, + "disabledText": "rgba(224, 224, 224, 0.5)", + "disabledBackground": "rgba(255, 20, 147, 0.08)", + "disabledOpacity": 0.38 + }, + "gradients": { + "brandHorizontal": "linear-gradient(270deg, #FF1493 0%, #1E90FF 100%)", + "brandVertical": "linear-gradient(0.01deg, #FF1493 0.01%, #1E90FF 99.99%)" + }, + "contrastThreshold": 3, + "hoverFactor": 0.03, + "tonalOffset": 0.15 + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/synthwave.ts b/packages/grafana-data/src/themes/themeDefinitions/synthwave.ts deleted file mode 100644 index 5fc53cda0bb..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/synthwave.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -const synthwaveTheme: NewThemeOptions = { - name: 'Synthwave', - colors: { - mode: 'dark', - border: { - weak: 'rgba(255, 20, 147, 0.12)', - medium: 'rgba(255, 20, 147, 0.20)', - strong: 'rgba(255, 20, 147, 0.30)', - }, - text: { - primary: '#E0E0E0', - secondary: 'rgba(224, 224, 224, 0.75)', - disabled: 'rgba(224, 224, 224, 0.5)', - link: '#FF69B4', - maxContrast: '#FFFFFF', - }, - primary: { - main: '#FF1493', - }, - secondary: { - main: '#37183a', - text: 'rgba(224, 224, 224, 0.75)', - border: 'rgba(255, 20, 147, 0.10)', - }, - background: { - canvas: '#1A1A2E', - primary: '#16213E', - secondary: '#0F3460', - elevated: '#0F3460', - }, - action: { - hover: 'rgba(255, 20, 147, 0.16)', - selected: 'rgba(255, 20, 147, 0.12)', - selectedBorder: '#FF1493', - focus: 'rgba(255, 20, 147, 0.16)', - hoverOpacity: 0.08, - disabledText: 'rgba(224, 224, 224, 0.5)', - disabledBackground: 'rgba(255, 20, 147, 0.08)', - disabledOpacity: 0.38, - }, - gradients: { - brandHorizontal: 'linear-gradient(270deg, #FF1493 0%, #1E90FF 100%)', - brandVertical: 'linear-gradient(0.01deg, #FF1493 0.01%, #1E90FF 99.99%)', - }, - contrastThreshold: 3, - hoverFactor: 0.03, - tonalOffset: 0.15, - }, -}; - -export default synthwaveTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/tron.json b/packages/grafana-data/src/themes/themeDefinitions/tron.json new file mode 100644 index 00000000000..a92cf07fcb0 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/tron.json @@ -0,0 +1,50 @@ +{ + "name": "Tron", + "id": "tron", + "colors": { + "mode": "dark", + "border": { + "weak": "rgba(0, 255, 255, 0.12)", + "medium": "rgba(0, 255, 255, 0.20)", + "strong": "rgba(0, 255, 255, 0.30)" + }, + "text": { + "primary": "#E0E0E0", + "secondary": "rgba(224, 224, 224, 0.75)", + "disabled": "rgba(224, 224, 224, 0.5)", + "link": "#00FFFF", + "maxContrast": "#FFFFFF" + }, + "primary": { + "main": "#00FFFF" + }, + "secondary": { + "main": "#0b2e36", + "text": "rgba(224, 224, 224, 0.75)", + "border": "rgba(0, 255, 255, 0.10)" + }, + "background": { + "canvas": "#0A0F18", + "primary": "#0F1B2A", + "secondary": "#152234", + "elevated": "#152234" + }, + "action": { + "hover": "rgba(0, 255, 255, 0.16)", + "selected": "rgba(0, 255, 255, 0.12)", + "selectedBorder": "#00FFFF", + "focus": "rgba(0, 255, 255, 0.16)", + "hoverOpacity": 0.08, + "disabledText": "rgba(224, 224, 224, 0.5)", + "disabledBackground": "rgba(0, 255, 255, 0.08)", + "disabledOpacity": 0.38 + }, + "gradients": { + "brandHorizontal": "linear-gradient(270deg, #00FFFF 0%, #29ABE2 100%)", + "brandVertical": "linear-gradient(0.01deg, #00FFFF 0.01%, #29ABE2 99.99%)" + }, + "contrastThreshold": 3, + "hoverFactor": 0.05, + "tonalOffset": 0.2 + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/tron.ts b/packages/grafana-data/src/themes/themeDefinitions/tron.ts deleted file mode 100644 index a9f0b8c3ed4..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/tron.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -const tronTheme: NewThemeOptions = { - name: 'Tron', - colors: { - mode: 'dark', - border: { - weak: 'rgba(0, 255, 255, 0.12)', - medium: 'rgba(0, 255, 255, 0.20)', - strong: 'rgba(0, 255, 255, 0.30)', - }, - text: { - primary: '#E0E0E0', - secondary: 'rgba(224, 224, 224, 0.75)', - disabled: 'rgba(224, 224, 224, 0.5)', - link: '#00FFFF', - maxContrast: '#FFFFFF', - }, - primary: { - main: '#00FFFF', - }, - secondary: { - main: '#0b2e36', - text: 'rgba(224, 224, 224, 0.75)', - border: 'rgba(0, 255, 255, 0.10)', - }, - background: { - canvas: '#0A0F18', - primary: '#0F1B2A', - secondary: '#152234', - elevated: '#152234', - }, - action: { - hover: 'rgba(0, 255, 255, 0.16)', - selected: 'rgba(0, 255, 255, 0.12)', - selectedBorder: '#00FFFF', - focus: 'rgba(0, 255, 255, 0.16)', - hoverOpacity: 0.08, - disabledText: 'rgba(224, 224, 224, 0.5)', - disabledBackground: 'rgba(0, 255, 255, 0.08)', - disabledOpacity: 0.38, - }, - gradients: { - brandHorizontal: 'linear-gradient(270deg, #00FFFF 0%, #29ABE2 100%)', - brandVertical: 'linear-gradient(0.01deg, #00FFFF 0.01%, #29ABE2 99.99%)', - }, - contrastThreshold: 3, - hoverFactor: 0.05, - tonalOffset: 0.2, - }, -}; - -export default tronTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/victorian.json b/packages/grafana-data/src/themes/themeDefinitions/victorian.json new file mode 100644 index 00000000000..14483578450 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/victorian.json @@ -0,0 +1,54 @@ +{ + "name": "Victorian", + "id": "victorian", + "colors": { + "mode": "dark", + "border": { + "weak": "#3A2C22", + "medium": "#3A2C22", + "strong": "#4B3D32" + }, + "text": { + "primary": "#D9D0A2", + "secondary": "#C4B89B", + "disabled": "#A89F91", + "link": "#C28A4D", + "maxContrast": "#FFFFFF" + }, + "primary": { + "main": "#C28A4D" + }, + "secondary": { + "main": "#3A2C22", + "text": "#C4B89B", + "border": "#4B3D32" + }, + "background": { + "canvas": "#1F1510", + "primary": "#2C1A13", + "secondary": "#402A21", + "elevated": "#402A21" + }, + "action": { + "hover": "#3A2C22", + "selected": "#4B3D32", + "selectedBorder": "#C28A4D", + "focus": "#C28A4D", + "hoverOpacity": 0.1, + "disabledText": "#A89F91", + "disabledBackground": "#402A21", + "disabledOpacity": 0.38 + }, + "gradients": { + "brandHorizontal": "linear-gradient(270deg, #D9D0a1 0%, #C28A4D 100%)", + "brandVertical": "linear-gradient(0.01deg, #D9D0a1 0.01%, #C28A4D 99.99%)" + }, + "contrastThreshold": 4, + "hoverFactor": 0.07, + "tonalOffset": 0.15 + }, + "typography": { + "fontFamily": "\"Georgia\", \"Times New Roman\", serif", + "fontFamilyMonospace": "'Courier New', monospace" + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/victorian.ts b/packages/grafana-data/src/themes/themeDefinitions/victorian.ts deleted file mode 100644 index 32ddbcb244e..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/victorian.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -const victorianTheme: NewThemeOptions = { - name: 'Victorian', - colors: { - mode: 'dark', - border: { - weak: '#3A2C22', - medium: '#3A2C22', - strong: '#4B3D32', - }, - text: { - primary: '#D9D0A2', - secondary: '#C4B89B', - disabled: '#A89F91', - link: '#C28A4D', - maxContrast: '#FFFFFF', - }, - primary: { - main: '#C28A4D', - }, - secondary: { - main: '#3A2C22', - text: '#C4B89B', - border: '#4B3D32', - }, - background: { - canvas: '#1F1510', - primary: '#2C1A13', - secondary: '#402A21', - elevated: '#402A21', - }, - action: { - hover: '#3A2C22', - selected: '#4B3D32', - selectedBorder: '#C28A4D', - focus: '#C28A4D', - hoverOpacity: 0.1, - disabledText: '#A89F91', - disabledBackground: '#402A21', - disabledOpacity: 0.38, - }, - gradients: { - brandHorizontal: 'linear-gradient(270deg, #D9D0a1 0%, #C28A4D 100%)', - brandVertical: 'linear-gradient(0.01deg, #D9D0a1 0.01%, #C28A4D 99.99%)', - }, - contrastThreshold: 4, - hoverFactor: 0.07, - tonalOffset: 0.15, - }, - typography: { - fontFamily: '"Georgia", "Times New Roman", serif', - fontFamilyMonospace: "'Courier New', monospace", - }, -}; - -export default victorianTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/zen.json b/packages/grafana-data/src/themes/themeDefinitions/zen.json new file mode 100644 index 00000000000..99a8b900052 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/zen.json @@ -0,0 +1,50 @@ +{ + "name": "Zen", + "id": "zen", + "colors": { + "mode": "light", + "text": { + "primary": "#333333", + "secondary": "#666666", + "disabled": "#B8B8B8", + "link": "#4F9F6E", + "maxContrast": "#000000" + }, + "border": { + "weak": "#B1B7B3", + "medium": "#A2A8A2", + "strong": "#7C7F7A" + }, + "primary": { + "main": "#6D8E6D" + }, + "secondary": { + "main": "#E0E0E0", + "text": "#666666", + "border": "#A2A8A2" + }, + "background": { + "canvas": "#F4F4F4", + "primary": "#E9E9E9", + "secondary": "#D8D8D8", + "elevated": "#E9E9E9" + }, + "action": { + "hover": "#D1D1D1", + "selected": "#B8B8B8", + "selectedBorder": "#88B88B", + "hoverOpacity": 0.1, + "focus": "#D1D1D1", + "disabledBackground": "#E0E0E0", + "disabledText": "#B8B8B8", + "disabledOpacity": 0.5 + }, + "gradients": { + "brandHorizontal": "linear-gradient(270deg, #88B88B 0%, #6D8E6D 100%)", + "brandVertical": "linear-gradient(0.01deg, #88B88B 0.01%, #6D8E6D 99.99%)" + }, + "contrastThreshold": 3, + "hoverFactor": 0.03, + "tonalOffset": 0.2 + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/zen.ts b/packages/grafana-data/src/themes/themeDefinitions/zen.ts deleted file mode 100644 index f2735f41b74..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/zen.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -const zenTheme: NewThemeOptions = { - name: 'Zen', - colors: { - mode: 'light', - text: { - primary: '#333333', - secondary: '#666666', - disabled: '#B8B8B8', - link: '#4F9F6E', - maxContrast: '#000000', - }, - border: { - weak: '#B1B7B3', - medium: '#A2A8A2', - strong: '#7C7F7A', - }, - primary: { - main: '#6D8E6D', - }, - secondary: { - main: '#E0E0E0', - text: '#666666', - border: '#A2A8A2', - }, - background: { - canvas: '#F4F4F4', - primary: '#E9E9E9', - secondary: '#D8D8D8', - elevated: '#E9E9E9', - }, - action: { - hover: '#D1D1D1', - selected: '#B8B8B8', - selectedBorder: '#88B88B', - hoverOpacity: 0.1, - focus: '#D1D1D1', - disabledBackground: '#E0E0E0', - disabledText: '#B8B8B8', - disabledOpacity: 0.5, - }, - gradients: { - brandHorizontal: 'linear-gradient(270deg, #88B88B 0%, #6D8E6D 100%)', - brandVertical: 'linear-gradient(0.01deg, #88B88B 0.01%, #6D8E6D 99.99%)', - }, - contrastThreshold: 3, - hoverFactor: 0.03, - tonalOffset: 0.2, - }, -}; - -export default zenTheme; diff --git a/packages/grafana-data/src/themes/types.ts b/packages/grafana-data/src/themes/types.ts index f586937cf3c..d77c53062d3 100644 --- a/packages/grafana-data/src/themes/types.ts +++ b/packages/grafana-data/src/themes/types.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + import { GrafanaTheme } from '../types/theme'; import { ThemeBreakpoints } from './breakpoints'; @@ -35,27 +37,36 @@ export interface GrafanaTheme2 { flags: {}; } -/** @alpha */ -export interface ThemeRichColor { +export const ThemeRichColorInputSchema = z.object({ /** color intent (primary, secondary, info, error, etc) */ - name: string; + name: z.string().optional(), /** Main color */ - main: string; + main: z.string().optional(), /** Used for hover */ - shade: string; + shade: z.string().optional(), /** Used for text */ - text: string; + text: z.string().optional(), /** Used for borders */ - border: string; + border: z.string().optional(), /** Used subtly colored backgrounds */ - transparent: string; + transparent: z.string().optional(), /** Used for weak colored borders like larger alert/banner boxes and smaller badges and tags */ - borderTransparent: string; + borderTransparent: z.string().optional(), /** Text color for text ontop of main */ - contrastText: string; -} + contrastText: z.string().optional(), +}); + +export const ThemeRichColorSchema = ThemeRichColorInputSchema.required(); + +/** @alpha */ +export type ThemeRichColor = z.infer; /** @internal */ export type DeepPartial = { [P in keyof T]?: DeepPartial; }; + +/** @internal */ +export type DeepRequired = Required<{ + [P in keyof T]: T[P] extends Required ? T[P] : DeepRequired; +}>; diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index b2d3c16a3b1..922d9273699 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -32,6 +32,7 @@ export type AppPluginConfig = { path: string; version: string; preload: boolean; + /** @deprecated it will be removed in a future release */ angular: AngularMeta; loadingStrategy: PluginLoadingStrategy; dependencies: PluginDependencies; @@ -219,6 +220,7 @@ export interface GrafanaConfig { snapshotEnabled: boolean; datasources: { [str: string]: DataSourceInstanceSettings }; panels: { [key: string]: PanelPluginMeta }; + /** @deprecated it will be removed in a future release */ apps: Record; auth: AuthSettings; minRefreshInterval: string; diff --git a/packages/grafana-data/src/types/dataLink.ts b/packages/grafana-data/src/types/dataLink.ts index 815b67f0352..ad556a75c76 100644 --- a/packages/grafana-data/src/types/dataLink.ts +++ b/packages/grafana-data/src/types/dataLink.ts @@ -1,5 +1,6 @@ import { ScopedVars } from './ScopedVars'; import { ExploreCorrelationHelperData, ExplorePanelsState } from './explore'; +import { LinkTarget } from './linkTarget'; import { InterpolateFunction } from './panel'; import { DataQuery } from './query'; import { TimeRange } from './time'; @@ -88,8 +89,6 @@ export interface InternalDataLink { range?: TimeRange; } -export type LinkTarget = '_blank' | '_self' | undefined; - /** * Processed Link Model. The values are ready to use */ diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 5d9ad02dbc7..eed0d330481 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -207,6 +207,10 @@ export interface FeatureToggles { */ reportingRetries?: boolean; /** + * Enables CSV encoding options in the reporting feature + */ + reportingCsvEncodingOptions?: boolean; + /** * Send query to the same datasource in a single request when using server side expressions. The `cloudWatchBatchQueries` feature toggle should be enabled if this used with CloudWatch. */ sseGroupByDatasource?: boolean; @@ -352,7 +356,7 @@ export interface FeatureToggles { */ dashboardScene?: boolean; /** - * Enables experimental new dashboard layouts + * Enables new dashboard layouts */ dashboardNewLayouts?: boolean; /** @@ -523,14 +527,14 @@ export interface FeatureToggles { */ dashboardTemplates?: boolean; /** - * Sets the logs table as default visualisation in logs explore - */ - logsExploreTableDefaultVisualization?: boolean; - /** * Enables the new alert list view design */ alertingListViewV2?: boolean; /** + * Enables the new Alerting navigation structure with improved menu grouping + */ + alertingNavigationV2?: boolean; + /** * Enables saved searches for alert rules list */ alertingSavedSearches?: boolean; @@ -622,10 +626,6 @@ export interface FeatureToggles { */ exploreLogsAggregatedMetrics?: boolean; /** - * Used in Logs Drilldown to limit the time range - */ - exploreLogsLimitedTimeRange?: boolean; - /** * Enables the gRPC client to authenticate with the App Platform by using ID & access tokens */ appPlatformGrpcClientAuth?: boolean; @@ -653,14 +653,6 @@ export interface FeatureToggles { */ rolePickerDrawer?: boolean; /** - * Enable unified storage search - */ - unifiedStorageSearch?: boolean; - /** - * Enable sprinkles on unified storage search - */ - unifiedStorageSearchSprinkles?: boolean; - /** * Pick the dual write mode from database configs */ managedDualWriter?: boolean; @@ -699,10 +691,6 @@ export interface FeatureToggles { */ passwordlessMagicLinkAuthentication?: boolean; /** - * Display Related Logs in Grafana Metrics Drilldown - */ - exploreMetricsRelatedLogs?: boolean; - /** * Adds support for quotes and special characters in label values for Prometheus queries */ prometheusSpecialCharsInLabelValues?: boolean; @@ -778,20 +766,11 @@ export interface FeatureToggles { */ elasticsearchCrossClusterSearch?: boolean; /** - * Displays the navigation history so the user can navigate back to previous pages - */ - unifiedHistory?: boolean; - /** * Defaults to using the Loki `/labels` API instead of `/series` * @default true */ lokiLabelNamesQueryApi?: boolean; /** - * Enable the investigations backend API - * @default false - */ - investigationsBackend?: boolean; - /** * Enable folder's api server counts * @default false */ @@ -962,7 +941,8 @@ export interface FeatureToggles { */ alertingBulkActionsInUI?: boolean; /** - * Registers AuthZ /apis endpoint + * Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead + * @deprecated */ kubernetesAuthzApis?: boolean; /** @@ -978,6 +958,18 @@ export interface FeatureToggles { */ kubernetesAuthzZanzanaSync?: boolean; /** + * Registers AuthZ Core Roles /apis endpoint + */ + kubernetesAuthzCoreRolesApi?: boolean; + /** + * Registers AuthZ Roles /apis endpoint + */ + kubernetesAuthzRolesApi?: boolean; + /** + * Registers AuthZ Role Bindings /apis endpoint + */ + kubernetesAuthzRoleBindingsApi?: boolean; + /** * Enables create, delete, and update mutations for resources owned by IAM identity */ kubernetesAuthnMutation?: boolean; @@ -996,6 +988,11 @@ export interface FeatureToggles { */ recentlyViewedDashboards?: boolean; /** + * A/A test for recently viewed dashboards feature + * @default false + */ + experimentRecentlyViewedDashboards?: boolean; + /** * Enable configuration of alert enrichments in Grafana Cloud. * @default false */ @@ -1129,11 +1126,6 @@ export interface FeatureToggles { */ pluginContainers?: boolean; /** - * Run search queries through the tempo backend - * @default false - */ - tempoSearchBackendMigration?: boolean; - /** * Prioritize loading plugins from the CDN before other sources * @default false */ @@ -1263,4 +1255,8 @@ export interface FeatureToggles { * Enables profiles exemplars support in profiles drilldown */ profilesExemplars?: boolean; + /** + * Use synchronized dispatch timer to minimize duplicate notifications across alertmanager HA pods + */ + alertingSyncDispatchTimer?: boolean; } diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index 34e672b66f5..3dc7215a2cf 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -52,6 +52,7 @@ export const availableIconsIndex = { bookmark: true, 'book-open': true, 'brackets-curly': true, + brain: true, 'browser-alt': true, bug: true, building: true, diff --git a/packages/grafana-data/src/types/linkTarget.ts b/packages/grafana-data/src/types/linkTarget.ts new file mode 100644 index 00000000000..2cdd963da7a --- /dev/null +++ b/packages/grafana-data/src/types/linkTarget.ts @@ -0,0 +1,4 @@ +/** + * Target for links - controls whether link opens in new tab or same tab + */ +export type LinkTarget = '_blank' | '_self' | undefined; diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts index f9ebb23fc07..815b9d04e2d 100644 --- a/packages/grafana-data/src/types/navModel.ts +++ b/packages/grafana-data/src/types/navModel.ts @@ -1,7 +1,7 @@ import { ComponentType } from 'react'; -import { LinkTarget } from './dataLink'; import { IconName } from './icon'; +import { LinkTarget } from './linkTarget'; export interface NavLinkDTO { id?: string; diff --git a/packages/grafana-data/src/types/panel.ts b/packages/grafana-data/src/types/panel.ts index acf1e36c905..b9d6491cf9b 100644 --- a/packages/grafana-data/src/types/panel.ts +++ b/packages/grafana-data/src/types/panel.ts @@ -11,6 +11,7 @@ import { DataFrame } from './dataFrame'; import { DataQueryError, DataQueryRequest, DataQueryTimings } from './datasource'; import { FieldConfigSource } from './fieldOverrides'; import { IconName } from './icon'; +import { LinkTarget } from './linkTarget'; import { OptionEditorConfig } from './options'; import { PluginMeta } from './plugin'; import { AbsoluteTimeRange, TimeRange, TimeZone } from './time'; @@ -191,6 +192,7 @@ export interface PanelMenuItem { onClick?: (event: React.MouseEvent) => void; shortcut?: string; href?: string; + target?: LinkTarget; subMenu?: PanelMenuItem[]; } diff --git a/packages/grafana-data/src/types/plugin.ts b/packages/grafana-data/src/types/plugin.ts index 045dfdcee0b..8b96ac8f70f 100644 --- a/packages/grafana-data/src/types/plugin.ts +++ b/packages/grafana-data/src/types/plugin.ts @@ -53,6 +53,7 @@ export interface PluginError { pluginType?: PluginType; } +/** @deprecated it will be removed in a future release */ export interface AngularMeta { detected: boolean; hideDeprecation: boolean; diff --git a/packages/grafana-data/src/unstable.ts b/packages/grafana-data/src/unstable.ts index 8a42447206f..43c2ff3071f 100644 --- a/packages/grafana-data/src/unstable.ts +++ b/packages/grafana-data/src/unstable.ts @@ -9,5 +9,4 @@ * and be subject to the standard policies */ -// This is a dummy export so typescript doesn't error importing an "empty module" -export const unstable = {}; +export {}; diff --git a/packages/grafana-data/tsconfig.json b/packages/grafana-data/tsconfig.json index 8e6013e32d9..3513caf9127 100644 --- a/packages/grafana-data/tsconfig.json +++ b/packages/grafana-data/tsconfig.json @@ -8,7 +8,8 @@ "emitDeclarationOnly": true, "isolatedModules": true, "rootDirs": ["."], - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "resolveJsonModule": true }, "exclude": ["dist/**/*"], "include": [ diff --git a/packages/grafana-i18n/package.json b/packages/grafana-i18n/package.json index 36a9faa541b..93dd9837897 100644 --- a/packages/grafana-i18n/package.json +++ b/packages/grafana-i18n/package.json @@ -29,7 +29,6 @@ "@grafana-app/source": "./src/internal/index.ts" }, "./eslint-plugin": { - "@grafana-app/source": "./src/eslint/index.cjs", "types": "./src/eslint/index.d.ts", "default": "./src/eslint/index.cjs" } diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 18cce14f236..99809235cab 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -86,6 +86,7 @@ export class GrafanaBootConfig { snapshotEnabled = true; datasources: { [str: string]: DataSourceInstanceSettings } = {}; panels: { [key: string]: PanelPluginMeta } = {}; + /** @deprecated it will be removed in a future release, use isAppPluginInstalled or getAppPluginVersion instead */ apps: Record = {}; auth: AuthSettings = {}; minRefreshInterval = ''; diff --git a/packages/grafana-runtime/src/index.ts b/packages/grafana-runtime/src/index.ts index 58b30be8542..380b87fee7d 100644 --- a/packages/grafana-runtime/src/index.ts +++ b/packages/grafana-runtime/src/index.ts @@ -77,3 +77,5 @@ export { getCorrelationsService, setCorrelationsService, } from './services/CorrelationsService'; +export { getAppPluginVersion, isAppPluginInstalled } from './services/pluginMeta/apps'; +export { useAppPluginInstalled, useAppPluginVersion } from './services/pluginMeta/hooks'; diff --git a/packages/grafana-runtime/src/internal/index.ts b/packages/grafana-runtime/src/internal/index.ts index aed6b86ebfb..fa13873c094 100644 --- a/packages/grafana-runtime/src/internal/index.ts +++ b/packages/grafana-runtime/src/internal/index.ts @@ -29,3 +29,5 @@ export { export { UserStorage } from '../utils/userStorage'; export { initOpenFeature, evaluateBooleanFlag } from './openFeature'; +export { getAppPluginMeta, getAppPluginMetas, setAppPluginMetas } from '../services/pluginMeta/apps'; +export { useAppPluginMeta, useAppPluginMetas } from '../services/pluginMeta/hooks'; diff --git a/packages/grafana-runtime/src/services/pluginMeta/apps.test.ts b/packages/grafana-runtime/src/services/pluginMeta/apps.test.ts new file mode 100644 index 00000000000..554917041cc --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/apps.test.ts @@ -0,0 +1,258 @@ +import { evaluateBooleanFlag } from '../../internal/openFeature'; + +import { + getAppPluginMeta, + getAppPluginMetas, + getAppPluginVersion, + isAppPluginInstalled, + setAppPluginMetas, +} from './apps'; +import { initPluginMetas } from './plugins'; +import { app } from './test-fixtures/config.apps'; + +jest.mock('./plugins', () => ({ ...jest.requireActual('./plugins'), initPluginMetas: jest.fn() })); +jest.mock('../../internal/openFeature', () => ({ + ...jest.requireActual('../../internal/openFeature'), + evaluateBooleanFlag: jest.fn(), +})); + +const initPluginMetasMock = jest.mocked(initPluginMetas); +const evaluateBooleanFlagMock = jest.mocked(evaluateBooleanFlag); + +describe('when useMTPlugins flag is enabled and apps is not initialized', () => { + beforeEach(() => { + setAppPluginMetas({}); + jest.resetAllMocks(); + initPluginMetasMock.mockResolvedValue({ items: [] }); + evaluateBooleanFlagMock.mockReturnValue(true); + }); + + it('getAppPluginMetas should call initPluginMetas and return correct result', async () => { + const apps = await getAppPluginMetas(); + + expect(apps).toEqual([]); + expect(initPluginMetasMock).toHaveBeenCalledTimes(1); + }); + + it('getAppPluginMeta should call initPluginMetas and return correct result', async () => { + const result = await getAppPluginMeta('myorg-someplugin-app'); + + expect(result).toEqual(null); + expect(initPluginMetasMock).toHaveBeenCalledTimes(1); + }); + + it('isAppPluginInstalled should call initPluginMetas and return false', async () => { + const installed = await isAppPluginInstalled('myorg-someplugin-app'); + + expect(installed).toEqual(false); + expect(initPluginMetasMock).toHaveBeenCalledTimes(1); + }); + + it('getAppPluginVersion should call initPluginMetas and return null', async () => { + const result = await getAppPluginVersion('myorg-someplugin-app'); + + expect(result).toEqual(null); + expect(initPluginMetasMock).toHaveBeenCalledTimes(1); + }); +}); + +describe('when useMTPlugins flag is enabled and apps is initialized', () => { + beforeEach(() => { + setAppPluginMetas({ 'myorg-someplugin-app': app }); + jest.resetAllMocks(); + evaluateBooleanFlagMock.mockReturnValue(true); + }); + + it('getAppPluginMetas should not call initPluginMetas and return correct result', async () => { + const apps = await getAppPluginMetas(); + + expect(apps).toEqual([app]); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('getAppPluginMeta should not call initPluginMetas and return correct result', async () => { + const result = await getAppPluginMeta('myorg-someplugin-app'); + + expect(result).toEqual(app); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('getAppPluginMeta should return null if the pluginId is not found', async () => { + const result = await getAppPluginMeta('otherorg-otherplugin-app'); + + expect(result).toEqual(null); + }); + + it('isAppPluginInstalled should not call initPluginMetas and return true', async () => { + const installed = await isAppPluginInstalled('myorg-someplugin-app'); + + expect(installed).toEqual(true); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('isAppPluginInstalled should return false if the pluginId is not found', async () => { + const result = await isAppPluginInstalled('otherorg-otherplugin-app'); + + expect(result).toEqual(false); + }); + + it('getAppPluginVersion should not call initPluginMetas and return correct result', async () => { + const result = await getAppPluginVersion('myorg-someplugin-app'); + + expect(result).toEqual('1.0.0'); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('getAppPluginVersion should return null if the pluginId is not found', async () => { + const result = await getAppPluginVersion('otherorg-otherplugin-app'); + + expect(result).toEqual(null); + }); +}); + +describe('when useMTPlugins flag is disabled and apps is not initialized', () => { + beforeEach(() => { + setAppPluginMetas({}); + jest.resetAllMocks(); + evaluateBooleanFlagMock.mockReturnValue(false); + }); + + it('getAppPluginMetas should not call initPluginMetas and return correct result', async () => { + const apps = await getAppPluginMetas(); + + expect(apps).toEqual([]); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('getAppPluginMeta should not call initPluginMetas and return correct result', async () => { + const result = await getAppPluginMeta('myorg-someplugin-app'); + + expect(result).toEqual(null); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('isAppPluginInstalled should not call initPluginMetas and return false', async () => { + const result = await isAppPluginInstalled('myorg-someplugin-app'); + + expect(result).toEqual(false); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('getAppPluginVersion should not call initPluginMetas and return correct result', async () => { + const result = await getAppPluginVersion('myorg-someplugin-app'); + + expect(result).toEqual(null); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); +}); + +describe('when useMTPlugins flag is disabled and apps is initialized', () => { + beforeEach(() => { + setAppPluginMetas({ 'myorg-someplugin-app': app }); + jest.resetAllMocks(); + evaluateBooleanFlagMock.mockReturnValue(false); + }); + + it('getAppPluginMetas should not call initPluginMetas and return correct result', async () => { + const apps = await getAppPluginMetas(); + + expect(apps).toEqual([app]); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('getAppPluginMeta should not call initPluginMetas and return correct result', async () => { + const result = await getAppPluginMeta('myorg-someplugin-app'); + + expect(result).toEqual(app); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('getAppPluginMeta should return null if the pluginId is not found', async () => { + const result = await getAppPluginMeta('otherorg-otherplugin-app'); + + expect(result).toEqual(null); + }); + + it('isAppPluginInstalled should not call initPluginMetas and return true', async () => { + const result = await isAppPluginInstalled('myorg-someplugin-app'); + + expect(result).toEqual(true); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('isAppPluginInstalled should return false if the pluginId is not found', async () => { + const result = await isAppPluginInstalled('otherorg-otherplugin-app'); + + expect(result).toEqual(false); + }); + + it('getAppPluginVersion should not call initPluginMetas and return correct result', async () => { + const result = await getAppPluginVersion('myorg-someplugin-app'); + + expect(result).toEqual('1.0.0'); + expect(initPluginMetasMock).not.toHaveBeenCalled(); + }); + + it('getAppPluginVersion should return null if the pluginId is not found', async () => { + const result = await getAppPluginVersion('otherorg-otherplugin-app'); + + expect(result).toEqual(null); + }); +}); + +describe('immutability', () => { + beforeEach(() => { + setAppPluginMetas({ 'myorg-someplugin-app': app }); + jest.resetAllMocks(); + evaluateBooleanFlagMock.mockReturnValue(false); + }); + + it('getAppPluginMetas should return a deep clone', async () => { + const mutatedApps = await getAppPluginMetas(); + + // assert we have correct props + expect(mutatedApps).toHaveLength(1); + expect(mutatedApps[0].dependencies.grafanaDependency).toEqual('>=10.4.0'); + expect(mutatedApps[0].extensions.addedLinks).toHaveLength(0); + + // mutate deep props + mutatedApps[0].dependencies.grafanaDependency = ''; + mutatedApps[0].extensions.addedLinks.push({ targets: [], title: '', description: '' }); + + // assert we have mutated props + expect(mutatedApps[0].dependencies.grafanaDependency).toEqual(''); + expect(mutatedApps[0].extensions.addedLinks).toHaveLength(1); + expect(mutatedApps[0].extensions.addedLinks[0]).toEqual({ targets: [], title: '', description: '' }); + + const apps = await getAppPluginMetas(); + + // assert that we have not mutated the source + expect(apps[0].dependencies.grafanaDependency).toEqual('>=10.4.0'); + expect(apps[0].extensions.addedLinks).toHaveLength(0); + }); + + it('getAppPluginMeta should return a deep clone', async () => { + const mutatedApp = await getAppPluginMeta('myorg-someplugin-app'); + + // assert we have correct props + expect(mutatedApp).toBeDefined(); + expect(mutatedApp!.dependencies.grafanaDependency).toEqual('>=10.4.0'); + expect(mutatedApp!.extensions.addedLinks).toHaveLength(0); + + // mutate deep props + mutatedApp!.dependencies.grafanaDependency = ''; + mutatedApp!.extensions.addedLinks.push({ targets: [], title: '', description: '' }); + + // assert we have mutated props + expect(mutatedApp!.dependencies.grafanaDependency).toEqual(''); + expect(mutatedApp!.extensions.addedLinks).toHaveLength(1); + expect(mutatedApp!.extensions.addedLinks[0]).toEqual({ targets: [], title: '', description: '' }); + + const result = await getAppPluginMeta('myorg-someplugin-app'); + + // assert that we have not mutated the source + expect(result).toBeDefined(); + expect(result!.dependencies.grafanaDependency).toEqual('>=10.4.0'); + expect(result!.extensions.addedLinks).toHaveLength(0); + }); +}); diff --git a/packages/grafana-runtime/src/services/pluginMeta/apps.ts b/packages/grafana-runtime/src/services/pluginMeta/apps.ts new file mode 100644 index 00000000000..7db359b5a4b --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/apps.ts @@ -0,0 +1,71 @@ +import type { AppPluginConfig } from '@grafana/data'; + +import { config } from '../../config'; +import { evaluateBooleanFlag } from '../../internal/openFeature'; + +import { getAppPluginMapper } from './mappers/mappers'; +import { initPluginMetas } from './plugins'; +import type { AppPluginMetas } from './types'; + +let apps: AppPluginMetas = {}; + +function initialized(): boolean { + return Boolean(Object.keys(apps).length); +} + +async function initAppPluginMetas(): Promise { + if (!evaluateBooleanFlag('useMTPlugins', false)) { + // eslint-disable-next-line no-restricted-syntax + apps = config.apps; + return; + } + + const metas = await initPluginMetas(); + const mapper = getAppPluginMapper(); + apps = mapper(metas); +} + +export async function getAppPluginMetas(): Promise { + if (!initialized()) { + await initAppPluginMetas(); + } + + return Object.values(structuredClone(apps)); +} + +export async function getAppPluginMeta(pluginId: string): Promise { + if (!initialized()) { + await initAppPluginMetas(); + } + + const app = apps[pluginId]; + return app ? structuredClone(app) : null; +} + +/** + * Check if an app plugin is installed. The function does not check if the app plugin is enabled. + * @param pluginId - The id of the app plugin. + * @returns True if the app plugin is installed, false otherwise. + */ +export async function isAppPluginInstalled(pluginId: string): Promise { + const app = await getAppPluginMeta(pluginId); + return Boolean(app); +} + +/** + * Get the version of an app plugin. + * @param pluginId - The id of the app plugin. + * @returns The version of the app plugin, or null if the plugin is not installed. + */ +export async function getAppPluginVersion(pluginId: string): Promise { + const app = await getAppPluginMeta(pluginId); + return app?.version ?? null; +} + +export function setAppPluginMetas(override: AppPluginMetas): void { + if (process.env.NODE_ENV !== 'test') { + throw new Error('setAppPluginMetas() function can only be called from tests.'); + } + + apps = structuredClone(override); +} diff --git a/packages/grafana-runtime/src/services/pluginMeta/hooks.test.tsx b/packages/grafana-runtime/src/services/pluginMeta/hooks.test.tsx new file mode 100644 index 00000000000..1e3c7311118 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/hooks.test.tsx @@ -0,0 +1,214 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +import { + getAppPluginMeta, + getAppPluginMetas, + getAppPluginVersion, + isAppPluginInstalled, + setAppPluginMetas, +} from './apps'; +import { useAppPluginMeta, useAppPluginMetas, useAppPluginInstalled, useAppPluginVersion } from './hooks'; +import { apps } from './test-fixtures/config.apps'; + +const actualApps = jest.requireActual('./apps'); +jest.mock('./apps', () => ({ + ...jest.requireActual('./apps'), + getAppPluginMetas: jest.fn(), + getAppPluginMeta: jest.fn(), + isAppPluginInstalled: jest.fn(), + getAppPluginVersion: jest.fn(), +})); +const getAppPluginMetaMock = jest.mocked(getAppPluginMeta); +const getAppPluginMetasMock = jest.mocked(getAppPluginMetas); +const isAppPluginInstalledMock = jest.mocked(isAppPluginInstalled); +const getAppPluginVersionMock = jest.mocked(getAppPluginVersion); + +describe('useAppPluginMeta', () => { + beforeEach(() => { + setAppPluginMetas(apps); + jest.resetAllMocks(); + getAppPluginMetaMock.mockImplementation(actualApps.getAppPluginMeta); + }); + + it('should return correct default values', async () => { + const { result } = renderHook(() => useAppPluginMeta('grafana-exploretraces-app')); + + expect(result.current.loading).toEqual(true); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toBeUndefined(); + + await waitFor(() => expect(result.current.loading).toEqual(true)); + }); + + it('should return correct values after loading', async () => { + const { result } = renderHook(() => useAppPluginMeta('grafana-exploretraces-app')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toEqual(apps['grafana-exploretraces-app']); + }); + + it('should return correct values if the pluginId does not exist', async () => { + const { result } = renderHook(() => useAppPluginMeta('otherorg-otherplugin-app')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toEqual(null); + }); + + it('should return correct values if useAppPluginMeta throws', async () => { + getAppPluginMetaMock.mockRejectedValue(new Error('Some error')); + + const { result } = renderHook(() => useAppPluginMeta('otherorg-otherplugin-app')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toEqual(new Error('Some error')); + expect(result.current.value).toBeUndefined(); + }); +}); + +describe('useAppPluginMetas', () => { + beforeEach(() => { + setAppPluginMetas(apps); + jest.resetAllMocks(); + getAppPluginMetasMock.mockImplementation(actualApps.getAppPluginMetas); + }); + + it('should return correct default values', async () => { + const { result } = renderHook(() => useAppPluginMetas()); + + expect(result.current.loading).toEqual(true); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toBeUndefined(); + + await waitFor(() => expect(result.current.loading).toEqual(true)); + }); + + it('should return correct values after loading', async () => { + const { result } = renderHook(() => useAppPluginMetas()); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toEqual(Object.values(apps)); + }); + + it('should return correct values if useAppPluginMetas throws', async () => { + getAppPluginMetasMock.mockRejectedValue(new Error('Some error')); + + const { result } = renderHook(() => useAppPluginMetas()); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toEqual(new Error('Some error')); + expect(result.current.value).toBeUndefined(); + }); +}); + +describe('useAppPluginInstalled', () => { + beforeEach(() => { + setAppPluginMetas(apps); + jest.resetAllMocks(); + isAppPluginInstalledMock.mockImplementation(actualApps.isAppPluginInstalled); + }); + + it('should return correct default values', async () => { + const { result } = renderHook(() => useAppPluginInstalled('grafana-exploretraces-app')); + + expect(result.current.loading).toEqual(true); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toBeUndefined(); + + await waitFor(() => expect(result.current.loading).toEqual(true)); + }); + + it('should return correct values after loading', async () => { + const { result } = renderHook(() => useAppPluginInstalled('grafana-exploretraces-app')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toEqual(true); + }); + + it('should return correct values if the pluginId does not exist', async () => { + const { result } = renderHook(() => useAppPluginInstalled('otherorg-otherplugin-app')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toEqual(false); + }); + + it('should return correct values if isAppPluginInstalled throws', async () => { + isAppPluginInstalledMock.mockRejectedValue(new Error('Some error')); + + const { result } = renderHook(() => useAppPluginInstalled('otherorg-otherplugin-app')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toEqual(new Error('Some error')); + expect(result.current.value).toBeUndefined(); + }); +}); + +describe('useAppPluginVersion', () => { + beforeEach(() => { + setAppPluginMetas(apps); + jest.resetAllMocks(); + getAppPluginVersionMock.mockImplementation(actualApps.getAppPluginVersion); + }); + + it('should return correct default values', async () => { + const { result } = renderHook(() => useAppPluginVersion('grafana-exploretraces-app')); + + expect(result.current.loading).toEqual(true); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toBeUndefined(); + + await waitFor(() => expect(result.current.loading).toEqual(true)); + }); + + it('should return correct values after loading', async () => { + const { result } = renderHook(() => useAppPluginVersion('grafana-exploretraces-app')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toEqual('1.2.2'); + }); + + it('should return correct values if the pluginId does not exist', async () => { + const { result } = renderHook(() => useAppPluginVersion('otherorg-otherplugin-app')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toEqual(null); + }); + + it('should return correct values if getAppPluginVersion throws', async () => { + getAppPluginVersionMock.mockRejectedValue(new Error('Some error')); + + const { result } = renderHook(() => useAppPluginVersion('otherorg-otherplugin-app')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.loading).toEqual(false); + expect(result.current.error).toEqual(new Error('Some error')); + expect(result.current.value).toBeUndefined(); + }); +}); diff --git a/packages/grafana-runtime/src/services/pluginMeta/hooks.tsx b/packages/grafana-runtime/src/services/pluginMeta/hooks.tsx new file mode 100644 index 00000000000..58ac42bbdd2 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/hooks.tsx @@ -0,0 +1,35 @@ +import { useAsync } from 'react-use'; + +import { getAppPluginMeta, getAppPluginMetas, getAppPluginVersion, isAppPluginInstalled } from './apps'; + +export function useAppPluginMetas() { + const { loading, error, value } = useAsync(async () => getAppPluginMetas()); + return { loading, error, value }; +} + +export function useAppPluginMeta(pluginId: string) { + const { loading, error, value } = useAsync(async () => getAppPluginMeta(pluginId)); + return { loading, error, value }; +} + +/** + * Hook that checks if an app plugin is installed. The hook does not check if the app plugin is enabled. + * @param pluginId - The ID of the app plugin. + * @returns loading, error, value of the app plugin installed status. + * The value is true if the app plugin is installed, false otherwise. + */ +export function useAppPluginInstalled(pluginId: string) { + const { loading, error, value } = useAsync(async () => isAppPluginInstalled(pluginId)); + return { loading, error, value }; +} + +/** + * Hook that gets the version of an app plugin. + * @param pluginId - The ID of the app plugin. + * @returns loading, error, value of the app plugin version. + * The value is the version of the app plugin, or null if the plugin is not installed. + */ +export function useAppPluginVersion(pluginId: string) { + const { loading, error, value } = useAsync(async () => getAppPluginVersion(pluginId)); + return { loading, error, value }; +} diff --git a/packages/grafana-runtime/src/services/pluginMeta/mappers/mappers.ts b/packages/grafana-runtime/src/services/pluginMeta/mappers/mappers.ts new file mode 100644 index 00000000000..15505b2edc0 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/mappers/mappers.ts @@ -0,0 +1,7 @@ +import { AppPluginMetasMapper, PluginMetasResponse } from '../types'; + +import { v0alpha1AppMapper } from './v0alpha1AppMapper'; + +export function getAppPluginMapper(): AppPluginMetasMapper { + return v0alpha1AppMapper; +} diff --git a/packages/grafana-runtime/src/services/pluginMeta/mappers/v0alpha1AppMapper.test.ts b/packages/grafana-runtime/src/services/pluginMeta/mappers/v0alpha1AppMapper.test.ts new file mode 100644 index 00000000000..dfc82d41b3e --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/mappers/v0alpha1AppMapper.test.ts @@ -0,0 +1,84 @@ +import { apps } from '../test-fixtures/config.apps'; +import { v0alpha1Response } from '../test-fixtures/v0alpha1Response'; + +import { v0alpha1AppMapper } from './v0alpha1AppMapper'; + +const PLUGIN_IDS = v0alpha1Response.items + .filter((i) => i.spec.pluginJson.type === 'app') + .map((i) => ({ pluginId: i.spec.pluginJson.id })); + +describe('v0alpha1AppMapper', () => { + describe.each(PLUGIN_IDS)('when called for pluginId:$pluginId', ({ pluginId }) => { + it('should map id property correctly', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(result[pluginId].id).toEqual(apps[pluginId].id); + }); + + it('should map path property correctly', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(result[pluginId].path).toEqual(apps[pluginId].path); + }); + + it('should map version property correctly', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(result[pluginId].version).toEqual(apps[pluginId].version); + }); + + it('should map preload property correctly', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(result[pluginId].preload).toEqual(apps[pluginId].preload); + }); + + it('should map angular property correctly', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(result[pluginId].angular).toEqual({}); + }); + + it('should map loadingStrategy property correctly', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(result[pluginId].loadingStrategy).toEqual(apps[pluginId].loadingStrategy); + }); + + it('should map dependencies property correctly', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(result[pluginId].dependencies).toEqual(apps[pluginId].dependencies); + }); + + it('should map extensions property correctly', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(result[pluginId].extensions.addedComponents).toEqual(apps[pluginId].extensions.addedComponents); + expect(result[pluginId].extensions.addedFunctions).toEqual(apps[pluginId].extensions.addedFunctions); + expect(result[pluginId].extensions.addedLinks).toEqual(apps[pluginId].extensions.addedLinks); + expect(result[pluginId].extensions.exposedComponents).toEqual(apps[pluginId].extensions.exposedComponents); + expect(result[pluginId].extensions.extensionPoints).toEqual(apps[pluginId].extensions.extensionPoints); + }); + + it('should map moduleHash property correctly', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(result[pluginId].moduleHash).toEqual(apps[pluginId].moduleHash); + }); + + it('should map buildMode property correctly', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(result[pluginId].buildMode).toEqual(apps[pluginId].buildMode); + }); + }); + + it('should only map specs with type app', () => { + const result = v0alpha1AppMapper(v0alpha1Response); + + expect(v0alpha1Response.items).toHaveLength(58); + expect(Object.keys(result)).toHaveLength(5); + expect(Object.keys(result)).toEqual(Object.keys(apps)); + }); +}); diff --git a/packages/grafana-runtime/src/services/pluginMeta/mappers/v0alpha1AppMapper.ts b/packages/grafana-runtime/src/services/pluginMeta/mappers/v0alpha1AppMapper.ts new file mode 100644 index 00000000000..aa5ca6e2ce0 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/mappers/v0alpha1AppMapper.ts @@ -0,0 +1,111 @@ +import { + type AngularMeta, + type AppPluginConfig, + type PluginDependencies, + type PluginExtensions, + PluginLoadingStrategy, + type PluginType, +} from '@grafana/data'; + +import type { AppPluginMetas, AppPluginMetasMapper, PluginMetasResponse } from '../types'; +import type { Spec as v0alpha1Spec } from '../types/types.spec.gen'; + +function angularyMapper(spec: v0alpha1Spec): AngularMeta { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return {} as AngularMeta; +} + +function dependenciesMapper(spec: v0alpha1Spec): PluginDependencies { + const plugins = (spec.pluginJson.dependencies?.plugins ?? []).map((v) => ({ + ...v, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + type: v.type as PluginType, + version: '', + })); + + const dependencies: PluginDependencies = { + ...spec.pluginJson.dependencies, + extensions: { + exposedComponents: spec.pluginJson.dependencies.extensions?.exposedComponents ?? [], + }, + grafanaDependency: spec.pluginJson.dependencies.grafanaDependency, + grafanaVersion: spec.pluginJson.dependencies.grafanaVersion ?? '', + plugins, + }; + + return dependencies; +} + +function extensionsMapper(spec: v0alpha1Spec): PluginExtensions { + const addedComponents = spec.pluginJson.extensions?.addedComponents ?? []; + const addedFunctions = spec.pluginJson.extensions?.addedFunctions ?? []; + const addedLinks = spec.pluginJson.extensions?.addedLinks ?? []; + const exposedComponents = (spec.pluginJson.extensions?.exposedComponents ?? []).map((v) => ({ + ...v, + description: v.description ?? '', + title: v.title ?? '', + })); + const extensionPoints = (spec.pluginJson.extensions?.extensionPoints ?? []).map((v) => ({ + ...v, + description: v.description ?? '', + title: v.title ?? '', + })); + + const extensions: PluginExtensions = { + addedComponents, + addedFunctions, + addedLinks, + exposedComponents, + extensionPoints, + }; + + return extensions; +} + +function loadingStrategyMapper(spec: v0alpha1Spec): PluginLoadingStrategy { + const loadingStrategy = spec.module?.loadingStrategy ?? PluginLoadingStrategy.fetch; + if (loadingStrategy === PluginLoadingStrategy.script) { + return PluginLoadingStrategy.script; + } + + return PluginLoadingStrategy.fetch; +} + +function specMapper(spec: v0alpha1Spec): AppPluginConfig { + const { id, info, preload = false } = spec.pluginJson; + const angular = angularyMapper(spec); + const dependencies = dependenciesMapper(spec); + const extensions = extensionsMapper(spec); + const loadingStrategy = loadingStrategyMapper(spec); + const path = spec.module?.path ?? ''; + const version = info.version; + const buildMode = spec.pluginJson.buildMode ?? 'production'; + const moduleHash = spec.module?.hash; + + return { + id, + angular, + dependencies, + extensions, + loadingStrategy, + path, + preload, + version, + buildMode, + moduleHash, + }; +} + +export const v0alpha1AppMapper: AppPluginMetasMapper = (response) => { + const result: AppPluginMetas = {}; + + return response.items.reduce((acc, curr) => { + if (curr.spec.pluginJson.type !== 'app') { + return acc; + } + + const config = specMapper(curr.spec); + acc[config.id] = config; + return acc; + }, result); +}; diff --git a/packages/grafana-runtime/src/services/pluginMeta/plugins.test.ts b/packages/grafana-runtime/src/services/pluginMeta/plugins.test.ts new file mode 100644 index 00000000000..9a5077d1b2b --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/plugins.test.ts @@ -0,0 +1,153 @@ +import { evaluateBooleanFlag } from '../../internal/openFeature'; + +import { clearCache, initPluginMetas } from './plugins'; +import { v0alpha1Meta } from './test-fixtures/v0alpha1Response'; + +jest.mock('../../internal/openFeature', () => ({ + ...jest.requireActual('../../internal/openFeature'), + evaluateBooleanFlag: jest.fn(), +})); + +const evaluateBooleanFlagMock = jest.mocked(evaluateBooleanFlag); + +describe('when useMTPlugins toggle is enabled and cache is not initialized', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + jest.resetAllMocks(); + clearCache(); + evaluateBooleanFlagMock.mockReturnValue(true); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('initPluginMetas should call loadPluginMetas and return correct result if response is ok', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ items: [v0alpha1Meta] }), + }); + + const response = await initPluginMetas(); + + expect(response.items).toHaveLength(1); + expect(response.items[0]).toEqual(v0alpha1Meta); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith('/apis/plugins.grafana.app/v0alpha1/namespaces/default/metas'); + }); + + it('initPluginMetas should call loadPluginMetas and return correct result if response is not ok', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not found', + }); + + await expect(initPluginMetas()).rejects.toThrow(new Error(`Failed to load plugin metas 404:Not found`)); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith('/apis/plugins.grafana.app/v0alpha1/namespaces/default/metas'); + }); +}); + +describe('when useMTPlugins toggle is enabled and cache is initialized', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + jest.resetAllMocks(); + clearCache(); + evaluateBooleanFlagMock.mockReturnValue(true); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('initPluginMetas should return cache', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ items: [v0alpha1Meta] }), + }); + + const original = await initPluginMetas(); + const cached = await initPluginMetas(); + + expect(original).toEqual(cached); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('initPluginMetas should return inflight promise', async () => { + jest.useFakeTimers(); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ items: [v0alpha1Meta] }), + }); + + const original = initPluginMetas(); + const cached = initPluginMetas(); + await jest.runAllTimersAsync(); + + expect(original).toEqual(cached); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); +}); + +describe('when useMTPlugins toggle is disabled and cache is not initialized', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + jest.resetAllMocks(); + clearCache(); + global.fetch = jest.fn(); + evaluateBooleanFlagMock.mockReturnValue(false); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('initPluginMetas should call loadPluginMetas and return correct result if response is ok', async () => { + const response = await initPluginMetas(); + + expect(response.items).toHaveLength(0); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); + +describe('when useMTPlugins toggle is disabled and cache is initialized', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + jest.resetAllMocks(); + clearCache(); + global.fetch = jest.fn(); + evaluateBooleanFlagMock.mockReturnValue(false); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('initPluginMetas should return cache', async () => { + const original = await initPluginMetas(); + const cached = await initPluginMetas(); + + expect(original).toEqual(cached); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('initPluginMetas should return inflight promise', async () => { + jest.useFakeTimers(); + + const original = initPluginMetas(); + const cached = initPluginMetas(); + await jest.runAllTimersAsync(); + + expect(original).toEqual(cached); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/grafana-runtime/src/services/pluginMeta/plugins.ts b/packages/grafana-runtime/src/services/pluginMeta/plugins.ts new file mode 100644 index 00000000000..ec2fa4a9d11 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/plugins.ts @@ -0,0 +1,41 @@ +import { config } from '../../config'; +import { evaluateBooleanFlag } from '../../internal/openFeature'; + +import type { PluginMetasResponse } from './types'; + +let initPromise: Promise | null = null; + +function getApiVersion(): string { + return 'v0alpha1'; +} + +async function loadPluginMetas(): Promise { + if (!evaluateBooleanFlag('useMTPlugins', false)) { + const result = { items: [] }; + return result; + } + + const metas = await fetch(`/apis/plugins.grafana.app/${getApiVersion()}/namespaces/${config.namespace}/metas`); + if (!metas.ok) { + throw new Error(`Failed to load plugin metas ${metas.status}:${metas.statusText}`); + } + + const result = await metas.json(); + return result; +} + +export function initPluginMetas(): Promise { + if (!initPromise) { + initPromise = loadPluginMetas(); + } + + return initPromise; +} + +export function clearCache() { + if (process.env.NODE_ENV !== 'test') { + throw new Error('clearCache() function can only be called from tests.'); + } + + initPromise = null; +} diff --git a/packages/grafana-runtime/src/services/pluginMeta/test-fixtures/config.apps.ts b/packages/grafana-runtime/src/services/pluginMeta/test-fixtures/config.apps.ts new file mode 100644 index 00000000000..365308bd76c --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/test-fixtures/config.apps.ts @@ -0,0 +1,303 @@ +import { cloneDeep } from 'lodash'; + +import { AngularMeta, AppPluginConfig, PluginLoadingStrategy } from '@grafana/data'; + +import { AppPluginMetas } from '../types'; + +export const app: AppPluginConfig = cloneDeep({ + id: 'myorg-someplugin-app', + path: 'public/plugins/myorg-someplugin-app/module.js', + version: '1.0.0', + preload: false, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + angular: { detected: false } as AngularMeta, + loadingStrategy: PluginLoadingStrategy.script, + extensions: { + addedLinks: [], + addedComponents: [], + exposedComponents: [], + extensionPoints: [], + addedFunctions: [], + }, + dependencies: { + grafanaDependency: '>=10.4.0', + grafanaVersion: '*', + plugins: [], + extensions: { + exposedComponents: [], + }, + }, + buildMode: 'production', +}); + +export const apps: AppPluginMetas = cloneDeep({ + 'grafana-exploretraces-app': { + id: 'grafana-exploretraces-app', + path: 'public/plugins/grafana-exploretraces-app/module.js', + version: '1.2.2', + preload: true, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + angular: { detected: false } as AngularMeta, + loadingStrategy: PluginLoadingStrategy.script, + extensions: { + addedLinks: [ + { + targets: ['grafana/dashboard/panel/menu'], + title: 'Open in Traces Drilldown', + description: 'Open current query in the Traces Drilldown app', + }, + { + targets: ['grafana/explore/toolbar/action'], + title: 'Open in Grafana Traces Drilldown', + description: 'Try our new queryless experience for traces', + }, + ], + addedComponents: [ + { + targets: ['grafana-asserts-app/entity-assertions-widget/v1'], + title: 'Asserts widget', + description: 'A block with assertions for a given service', + }, + { + targets: ['grafana-asserts-app/insights-timeline-widget/v1'], + title: 'Insights Timeline Widget', + description: 'Widget for displaying insights timeline in other apps', + }, + ], + exposedComponents: [ + { + id: 'grafana-exploretraces-app/open-in-explore-traces-button/v1', + title: 'Open in Traces Drilldown button', + description: 'A button that opens a traces view in the Traces Drilldown app.', + }, + { + id: 'grafana-exploretraces-app/embedded-trace-exploration/v1', + title: 'Embedded Trace Exploration', + description: + 'A component that renders a trace exploration view that can be embedded in other parts of Grafana.', + }, + ], + extensionPoints: [ + { + id: 'grafana-exploretraces-app/investigation/v1', + title: '', + description: '', + }, + { + id: 'grafana-exploretraces-app/get-logs-drilldown-link/v1', + title: '', + description: '', + }, + ], + addedFunctions: [], + }, + dependencies: { + grafanaDependency: '>=11.5.0', + grafanaVersion: '*', + plugins: [], + extensions: { + exposedComponents: [ + 'grafana-asserts-app/entity-assertions-widget/v1', + 'grafana-asserts-app/insights-timeline-widget/v1', + ], + }, + }, + buildMode: 'production', + }, + 'grafana-lokiexplore-app': { + id: 'grafana-lokiexplore-app', + path: 'public/plugins/grafana-lokiexplore-app/module.js', + version: '1.0.32', + preload: true, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + angular: { detected: false } as AngularMeta, + loadingStrategy: PluginLoadingStrategy.script, + extensions: { + addedLinks: [ + { + targets: [ + 'grafana/dashboard/panel/menu', + 'grafana/explore/toolbar/action', + 'grafana-metricsdrilldown-app/open-in-logs-drilldown/v1', + 'grafana-assistant-app/navigateToDrilldown/v1', + ], + title: 'Open in Grafana Logs Drilldown', + description: 'Open current query in the Grafana Logs Drilldown view', + }, + ], + addedComponents: [ + { + targets: ['grafana-asserts-app/insights-timeline-widget/v1'], + title: 'Insights Timeline Widget', + description: 'Widget for displaying insights timeline in other apps', + }, + ], + exposedComponents: [ + { + id: 'grafana-lokiexplore-app/open-in-explore-logs-button/v1', + title: 'Open in Logs Drilldown button', + description: 'A button that opens a logs view in the Logs Drilldown app.', + }, + { + id: 'grafana-lokiexplore-app/embedded-logs-exploration/v1', + title: 'Embedded Logs Exploration', + description: + 'A component that renders a logs exploration view that can be embedded in other parts of Grafana.', + }, + ], + extensionPoints: [ + { + id: 'grafana-lokiexplore-app/investigation/v1', + title: '', + description: '', + }, + ], + addedFunctions: [ + { + targets: ['grafana-exploretraces-app/get-logs-drilldown-link/v1'], + title: 'Open Logs Drilldown', + description: 'Returns url to logs drilldown app', + }, + ], + }, + dependencies: { + grafanaDependency: '>=11.6.0', + grafanaVersion: '*', + plugins: [], + extensions: { + exposedComponents: [ + 'grafana-adaptivelogs-app/temporary-exemptions/v1', + 'grafana-lokiexplore-app/embedded-logs-exploration/v1', + 'grafana-asserts-app/insights-timeline-widget/v1', + 'grafana/add-to-dashboard-form/v1', + ], + }, + }, + buildMode: 'production', + }, + 'grafana-metricsdrilldown-app': { + id: 'grafana-metricsdrilldown-app', + path: 'public/plugins/grafana-metricsdrilldown-app/module.js', + version: '1.0.26', + preload: true, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + angular: { detected: false } as AngularMeta, + loadingStrategy: PluginLoadingStrategy.script, + extensions: { + addedLinks: [ + { + targets: [ + 'grafana/dashboard/panel/menu', + 'grafana/explore/toolbar/action', + 'grafana-assistant-app/navigateToDrilldown/v1', + 'grafana/alerting/alertingrule/queryeditor', + ], + title: 'Open in Grafana Metrics Drilldown', + description: 'Open current query in the Grafana Metrics Drilldown view', + }, + { + targets: ['grafana-metricsdrilldown-app/grafana-assistant-app/navigateToDrilldown/v0-alpha'], + title: 'Navigate to metrics drilldown', + description: 'Build a url path to the metrics drilldown', + }, + { + targets: ['grafana/datasources/config/actions', 'grafana/datasources/config/status'], + title: 'Open in Metrics Drilldown', + description: 'Browse metrics in Grafana Metrics Drilldown', + }, + ], + addedComponents: [], + exposedComponents: [ + { + id: 'grafana-metricsdrilldown-app/label-breakdown-component/v1', + title: 'Label Breakdown', + description: 'A metrics label breakdown view from the Metrics Drilldown app.', + }, + { + id: 'grafana-metricsdrilldown-app/knowledge-graph-insight-metrics/v1', + title: 'Knowledge Graph Source Metrics', + description: 'Explore the underlying metrics related to a Knowledge Graph insight', + }, + ], + extensionPoints: [ + { + id: 'grafana-exploremetrics-app/investigation/v1', + title: '', + description: '', + }, + { + id: 'grafana-metricsdrilldown-app/open-in-logs-drilldown/v1', + title: '', + description: '', + }, + ], + addedFunctions: [], + }, + dependencies: { + grafanaDependency: '>=11.6.0', + grafanaVersion: '*', + plugins: [], + extensions: { + exposedComponents: ['grafana/add-to-dashboard-form/v1'], + }, + }, + buildMode: 'production', + }, + 'grafana-pyroscope-app': { + id: 'grafana-pyroscope-app', + path: 'public/plugins/grafana-pyroscope-app/module.js', + version: '1.14.2', + preload: true, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + angular: { detected: false } as AngularMeta, + loadingStrategy: PluginLoadingStrategy.script, + extensions: { + addedLinks: [ + { + targets: [ + 'grafana/explore/toolbar/action', + 'grafana/traceview/details', + 'grafana-assistant-app/navigateToDrilldown/v1', + ], + title: 'Open in Grafana Profiles Drilldown', + description: 'Try our new queryless experience for profiles', + }, + ], + addedComponents: [], + exposedComponents: [ + { + id: 'grafana-pyroscope-app/embedded-profiles-exploration/v1', + title: 'Embedded Profiles Exploration', + description: + 'A component that renders a profiles exploration view that can be embedded in other parts of Grafana.', + }, + ], + extensionPoints: [ + { + id: 'grafana-pyroscope-app/investigation/v1', + title: '', + description: '', + }, + { + id: 'grafana-pyroscope-app/settings/v1', + title: '', + description: '', + }, + ], + addedFunctions: [], + }, + dependencies: { + grafanaDependency: '>=11.5.0', + grafanaVersion: '*', + plugins: [], + extensions: { + exposedComponents: [ + 'grafana-o11yinsights-app/insights-launcher/v1', + 'grafana-adaptiveprofiles-app/resolution-boost/v1', + ], + }, + }, + buildMode: 'production', + }, + [app.id]: app, +}); diff --git a/packages/grafana-runtime/src/services/pluginMeta/test-fixtures/v0alpha1Response.ts b/packages/grafana-runtime/src/services/pluginMeta/test-fixtures/v0alpha1Response.ts new file mode 100644 index 00000000000..7bd4c38d9fa --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/test-fixtures/v0alpha1Response.ts @@ -0,0 +1,4378 @@ +import { cloneDeep } from 'lodash'; + +import type { PluginMetasResponse } from '../types'; +import type { Meta } from '../types/meta_object_gen'; + +export const v0alpha1Meta: Meta = cloneDeep({ + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'myorg-someplugin-app', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'myorg-someplugin-app', + type: 'app', + name: 'Some-Plugin', + info: { + keywords: ['app'], + logos: { + small: 'public/plugins/myorg-someplugin-app/img/logo.svg', + large: 'public/plugins/myorg-someplugin-app/img/logo.svg', + }, + updated: '2025-12-15', + version: '1.0.0', + author: { + name: 'Myorg', + }, + }, + dependencies: { + grafanaDependency: '>=10.4.0', + grafanaVersion: '*', + }, + includes: [ + { + type: 'page', + name: 'Page One', + role: 'Viewer', + action: 'plugins.app:access', + path: '/a/myorg-someplugin-app/one', + addToNav: true, + defaultNav: true, + }, + { + type: 'page', + name: 'Page Two', + role: 'Viewer', + action: 'plugins.app:access', + path: '/a/myorg-someplugin-app/two', + addToNav: true, + }, + { + type: 'page', + name: 'Page Three', + role: 'Viewer', + action: 'plugins.app:access', + path: '/a/myorg-someplugin-app/three', + addToNav: true, + }, + { + type: 'page', + name: 'Page Four', + role: 'Viewer', + action: 'plugins.app:access', + path: '/a/myorg-someplugin-app/four', + addToNav: true, + }, + { + type: 'page', + name: 'Configuration', + role: 'Admin', + path: '/plugins/myorg-someplugin-app', + addToNav: true, + icon: 'cog', + }, + ], + }, + class: 'external', + module: { + path: 'public/plugins/myorg-someplugin-app/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/myorg-someplugin-app', + signature: { + status: 'unsigned', + }, + angular: { + detected: false, + }, + }, + status: {}, +}); + +export const v0alpha1Response: PluginMetasResponse = cloneDeep({ + items: [ + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'alertlist', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'alertlist', + type: 'panel', + name: 'Alert list', + info: { + keywords: [], + logos: { + small: 'public/plugins/alertlist/img/icn-singlestat-panel.svg', + large: 'public/plugins/alertlist/img/icn-singlestat-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Shows list of alerts and their current status', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/alert-list/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + skipDataQuery: true, + }, + class: 'core', + module: { + path: 'core:plugin/alertlist', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/alertlist', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'alertmanager', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'alertmanager', + type: 'datasource', + name: 'Alertmanager', + info: { + keywords: ['alerts', 'alerting', 'prometheus', 'alertmanager', 'mimir', 'cortex'], + logos: { + small: 'public/plugins/alertmanager/img/logo.svg', + large: 'public/plugins/alertmanager/img/logo.svg', + }, + updated: '', + version: '', + author: { + name: 'Prometheus alertmanager', + url: 'https://grafana.com', + }, + description: + 'Add external Alertmanagers (supports Prometheus and Mimir implementations) so you can use the Grafana Alerting UI to manage silences, contact points, and notification policies.', + links: [ + { + name: 'Learn more', + url: 'https://prometheus.io/docs/alerting/latest/alertmanager/', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/alertmanager/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + routes: [ + { + path: 'alertmanager/api/v2/silences', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.instances.external:read', + }, + { + path: 'api/v2/silences', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.instances.external:read', + }, + { + path: 'alertmanager/api/v2/silences', + method: 'POST', + reqRole: 'Editor', + reqAction: 'alert.instances.external:write', + }, + { + path: 'api/v2/silences', + method: 'POST', + reqRole: 'Editor', + reqAction: 'alert.instances.external:write', + }, + { + path: 'alertmanager/api/v2/silence/', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.instances.external:read', + }, + { + path: 'api/v2/silence/', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.instances.external:read', + }, + { + path: 'alertmanager/api/v2/silence/', + method: 'DELETE', + reqRole: 'Editor', + reqAction: 'alert.instances.external:write', + }, + { + path: 'api/v2/silence/', + method: 'DELETE', + reqRole: 'Editor', + reqAction: 'alert.instances.external:write', + }, + { + path: 'alertmanager/api/v2/alerts/groups', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.instances.external:read', + }, + { + path: 'api/v2/alerts/groups', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.instances.external:read', + }, + { + path: 'alertmanager/api/v2/alerts', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.instances.external:read', + }, + { + path: 'api/v2/alerts', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.instances.external:read', + }, + { + path: 'alertmanager/api/v2/alerts', + method: 'POST', + reqRole: 'Editor', + reqAction: 'alert.instances.external:write', + }, + { + path: 'api/v2/alerts', + method: 'POST', + reqRole: 'Editor', + reqAction: 'alert.instances.external:write', + }, + { + path: 'alertmanager/api/v2/status', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.notifications.external:read', + }, + { + path: 'api/v2/status', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.notifications.external:read', + }, + { + path: 'alertmanager/api/v2/receivers', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.instances.external:read', + }, + { + path: 'api/v2/receivers', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.instances.external:read', + }, + { + path: 'api/v1/alerts', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.notifications.external:read', + }, + { + path: 'api/v1/alerts', + method: 'POST', + reqRole: 'Editor', + reqAction: 'alert.notifications.external:write', + }, + { + path: 'api/v1/alerts', + method: 'DELETE', + reqRole: 'Editor', + reqAction: 'alert.notifications.external:write', + }, + { + method: 'POST', + reqRole: 'Admin', + }, + { + method: 'PUT', + reqRole: 'Admin', + }, + { + method: 'DELETE', + reqRole: 'Admin', + }, + { + method: 'GET', + reqRole: 'Admin', + }, + ], + }, + class: 'core', + module: { + path: 'core:plugin/alertmanager', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/alertmanager', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'annolist', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'annolist', + type: 'panel', + name: 'Annotations list', + info: { + keywords: [], + logos: { + small: 'public/plugins/annolist/img/icn-annolist-panel.svg', + large: 'public/plugins/annolist/img/icn-annolist-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'List annotations', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/annotations/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + skipDataQuery: true, + }, + class: 'core', + module: { + path: 'core:plugin/annolist', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/annolist', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'barchart', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'barchart', + type: 'panel', + name: 'Bar chart', + info: { + keywords: [], + logos: { + small: 'public/plugins/barchart/img/barchart.svg', + large: 'public/plugins/barchart/img/barchart.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Categorical charts with group support', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/bar-chart/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/barchart', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/barchart', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'bargauge', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'bargauge', + type: 'panel', + name: 'Bar gauge', + info: { + keywords: [], + logos: { + small: 'public/plugins/bargauge/img/icon_bar_gauge.svg', + large: 'public/plugins/bargauge/img/icon_bar_gauge.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Horizontal and vertical gauges', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/bar-gauge/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/bargauge', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/bargauge', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'candlestick', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'candlestick', + type: 'panel', + name: 'Candlestick', + info: { + keywords: ['financial', 'price', 'currency', 'k-line'], + logos: { + small: 'public/plugins/candlestick/img/candlestick.svg', + large: 'public/plugins/candlestick/img/candlestick.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Graphical representation of price movements of a security, derivative, or currency.', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/candlestick/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/candlestick', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/candlestick', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'canvas', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'canvas', + type: 'panel', + name: 'Canvas', + info: { + keywords: [], + logos: { + small: 'public/plugins/canvas/img/icn-canvas.svg', + large: 'public/plugins/canvas/img/icn-canvas.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Explicit element placement', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/canvas/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/canvas', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/canvas', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'cloudwatch', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'cloudwatch', + type: 'datasource', + name: 'CloudWatch', + info: { + keywords: ['aws', 'amazon'], + logos: { + small: 'public/plugins/cloudwatch/img/amazon-web-services.png', + large: 'public/plugins/cloudwatch/img/amazon-web-services.png', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Data source for Amazon AWS monitoring service', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/aws-cloudwatch/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'cloud', + includes: [ + { + type: 'dashboard', + name: 'EC2', + role: 'Viewer', + path: 'dashboards/ec2.json', + }, + { + type: 'dashboard', + name: 'EBS', + role: 'Viewer', + path: 'dashboards/EBS.json', + }, + { + type: 'dashboard', + name: 'Lambda', + role: 'Viewer', + path: 'dashboards/Lambda.json', + }, + { + type: 'dashboard', + name: 'Logs', + role: 'Viewer', + path: 'dashboards/Logs.json', + }, + { + type: 'dashboard', + name: 'RDS', + role: 'Viewer', + path: 'dashboards/RDS.json', + }, + ], + logs: true, + metrics: true, + queryOptions: { + minInterval: true, + }, + }, + class: 'core', + module: { + path: 'core:plugin/cloudwatch', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/cloudwatch', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'dashboard', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'dashboard', + type: 'datasource', + name: '-- Dashboard --', + info: { + keywords: [], + logos: { + small: 'public/plugins/dashboard/img/icn-reusequeries.svg', + large: 'public/plugins/dashboard/img/icn-reusequeries.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Uses the result set from another panel in the same dashboard', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + builtIn: true, + metrics: true, + }, + class: 'core', + module: { + path: 'core:plugin/dashboard', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/dashboard', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'dashlist', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'dashlist', + type: 'panel', + name: 'Dashboard list', + info: { + keywords: [], + logos: { + small: 'public/plugins/dashlist/img/icn-dashlist-panel.svg', + large: 'public/plugins/dashlist/img/icn-dashlist-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'List of dynamic links to other dashboards', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/dashboard-list/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + skipDataQuery: true, + }, + class: 'core', + module: { + path: 'core:plugin/dashlist', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/dashlist', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'datagrid', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'datagrid', + type: 'panel', + name: 'Datagrid', + info: { + keywords: [], + logos: { + small: 'public/plugins/datagrid/img/icn-table-panel.svg', + large: 'public/plugins/datagrid/img/icn-table-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/datagrid/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + state: 'beta', + }, + class: 'core', + module: { + path: 'core:plugin/datagrid', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/datagrid', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'debug', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'debug', + type: 'panel', + name: 'Debug', + info: { + keywords: [], + logos: { + small: 'public/plugins/debug/img/icn-debug.svg', + large: 'public/plugins/debug/img/icn-debug.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Debug Panel for Grafana', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + state: 'alpha', + }, + class: 'core', + module: { + path: 'core:plugin/debug', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/debug', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'elasticsearch', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'elasticsearch', + type: 'datasource', + name: 'Elasticsearch', + info: { + keywords: ['elasticsearch', 'datasource', 'database', 'logs', 'nosql', 'traces'], + logos: { + small: 'public/plugins/elasticsearch/img/elasticsearch.svg', + large: 'public/plugins/elasticsearch/img/elasticsearch.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Open source logging & analytics database', + links: [ + { + name: 'Learn more', + url: 'https://grafana.com/docs/features/datasources/elasticsearch/', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/elasticsearch/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'logging', + logs: true, + metrics: true, + queryOptions: { + minInterval: true, + }, + }, + class: 'core', + module: { + path: 'core:plugin/elasticsearch', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/elasticsearch', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'flamegraph', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'flamegraph', + type: 'panel', + name: 'Flame Graph', + info: { + keywords: [], + logos: { + small: 'public/plugins/flamegraph/img/icn-flamegraph.svg', + large: 'public/plugins/flamegraph/img/icn-flamegraph.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/flame-graph/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/flamegraph', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/flamegraph', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'gauge', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'gauge', + type: 'panel', + name: 'Gauge', + info: { + keywords: [], + logos: { + small: 'public/plugins/gauge/img/icon_gauge.svg', + large: 'public/plugins/gauge/img/icon_gauge.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Standard gauge visualization', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/gauge/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/gauge', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/gauge', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'geomap', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'geomap', + type: 'panel', + name: 'Geomap', + info: { + keywords: [], + logos: { + small: 'public/plugins/geomap/img/icn-geomap.svg', + large: 'public/plugins/geomap/img/icn-geomap.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Geomap panel', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/geomap/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/geomap', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/geomap', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'gettingstarted', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'gettingstarted', + type: 'panel', + name: 'Getting Started', + info: { + keywords: [], + logos: { + small: 'public/plugins/gettingstarted/img/icn-dashlist-panel.svg', + large: 'public/plugins/gettingstarted/img/icn-dashlist-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + hideFromList: true, + skipDataQuery: true, + }, + class: 'core', + module: { + path: 'core:plugin/gettingstarted', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/gettingstarted', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'grafana', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'grafana', + type: 'datasource', + name: '-- Grafana --', + info: { + keywords: [], + logos: { + small: 'public/plugins/grafana/img/icn-grafanadb.svg', + large: 'public/plugins/grafana/img/icn-grafanadb.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: + 'A built-in data source that generates random walk data and can poll the Testdata data source. This helps you test visualizations and run experiments.', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + annotations: true, + backend: true, + builtIn: true, + metrics: true, + }, + class: 'core', + module: { + path: 'core:plugin/grafana', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/grafana', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'grafana-azure-monitor-datasource', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'grafana-azure-monitor-datasource', + type: 'datasource', + name: 'Azure Monitor', + info: { + keywords: ['azure', 'monitor', 'Application Insights', 'Log Analytics', 'App Insights'], + logos: { + small: 'public/plugins/grafana-azure-monitor-datasource/img/logo.jpg', + large: 'public/plugins/grafana-azure-monitor-datasource/img/logo.jpg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Data source for Microsoft Azure Monitor & Application Insights', + links: [ + { + name: 'Learn more', + url: 'https://grafana.com/docs/grafana/latest/datasources/azuremonitor/', + }, + { + name: 'License', + url: 'https://github.com/grafana/grafana/blob/HEAD/LICENSE', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/azure-monitor/', + }, + ], + screenshots: [ + { + name: 'Azure Contoso Loans', + path: 'public/plugins/grafana-azure-monitor-datasource/img/contoso_loans_grafana_dashboard.png', + }, + { + name: 'Azure Monitor Network', + path: 'public/plugins/grafana-azure-monitor-datasource/img/azure_monitor_network.png', + }, + { + name: 'Azure Monitor CPU', + path: 'public/plugins/grafana-azure-monitor-datasource/img/azure_monitor_cpu.png', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.3.0', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'cloud', + executable: 'gpx_azuremonitor', + includes: [ + { + type: 'dashboard', + name: 'Azure / Alert Consumption', + role: 'Viewer', + path: 'dashboards/v1Alerts.json', + }, + { + type: 'dashboard', + name: 'Azure / Infrastructure / Apps Monitoring', + role: 'Viewer', + path: 'dashboards/azureInfraApps.json', + }, + { + type: 'dashboard', + name: 'Azure / Infrastructure / Compute Monitoring', + role: 'Viewer', + path: 'dashboards/azureInfraCompute.json', + }, + { + type: 'dashboard', + name: 'Azure / Infrastructure / Data Monitoring', + role: 'Viewer', + path: 'dashboards/azureInfraData.json', + }, + { + type: 'dashboard', + name: 'Azure / Infrastructure / Network Monitoring', + role: 'Viewer', + path: 'dashboards/azureInfraNetwork.json', + }, + { + type: 'dashboard', + name: 'Azure / Infrastructure / Storage and Key Vaults Monitoring', + role: 'Viewer', + path: 'dashboards/azureInfraStorageVaults.json', + }, + { + type: 'dashboard', + name: 'Azure / Azure PostgreSQL / Flexible Server Monitoring', + role: 'Viewer', + path: 'dashboards/postgresFlexibleServer.json', + }, + { + type: 'dashboard', + name: 'Azure Monitor / Container Insights / Syslog', + role: 'Viewer', + path: 'dashboards/containerInsightsSyslog.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Applications', + role: 'Viewer', + path: 'dashboards/appInsights.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Applications / Performance / Operations', + role: 'Viewer', + path: 'dashboards/appInsightsPerfOperations.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Applications / Performance / Dependencies', + role: 'Viewer', + path: 'dashboards/appInsightsPerfDependencies.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Applications / Failures / Operations', + role: 'Viewer', + path: 'dashboards/appInsightsFailureOperations.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Applications / Failures / Dependencies', + role: 'Viewer', + path: 'dashboards/appInsightsFailureDependencies.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Applications / Failures / Exceptions', + role: 'Viewer', + path: 'dashboards/appInsightsFailureExceptions.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Applications Test Availability Geo Map', + role: 'Viewer', + path: 'dashboards/appInsightsGeoMap.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / CosmosDB', + role: 'Viewer', + path: 'dashboards/cosmosdb.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Data Explorer Clusters', + role: 'Viewer', + path: 'dashboards/adx.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Key Vaults', + role: 'Viewer', + path: 'dashboards/keyvault.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Networks', + role: 'Viewer', + path: 'dashboards/networkInsightsDashboard.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / SQL Database', + role: 'Viewer', + path: 'dashboards/sqldb.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Storage Accounts', + role: 'Viewer', + path: 'dashboards/storage.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Virtual Machines by Resource Group', + role: 'Viewer', + path: 'dashboards/vMInsightsRG.json', + }, + { + type: 'dashboard', + name: 'Azure / Insights / Virtual Machines by Workspace', + role: 'Viewer', + path: 'dashboards/vMInsightsWorkspace.json', + }, + { + type: 'dashboard', + name: 'Azure / Resources Overview', + role: 'Viewer', + path: 'dashboards/arg.json', + }, + ], + logs: true, + metrics: true, + tracing: true, + }, + class: 'core', + module: { + path: 'public/plugins/grafana-azure-monitor-datasource/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/grafana-azure-monitor-datasource', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + translations: { + 'cs-CZ': + 'public/plugins/grafana-azure-monitor-datasource/locales/cs-CZ/grafana-azure-monitor-datasource.json', + 'de-DE': + 'public/plugins/grafana-azure-monitor-datasource/locales/de-DE/grafana-azure-monitor-datasource.json', + 'en-US': + 'public/plugins/grafana-azure-monitor-datasource/locales/en-US/grafana-azure-monitor-datasource.json', + 'es-ES': + 'public/plugins/grafana-azure-monitor-datasource/locales/es-ES/grafana-azure-monitor-datasource.json', + 'fr-FR': + 'public/plugins/grafana-azure-monitor-datasource/locales/fr-FR/grafana-azure-monitor-datasource.json', + 'hu-HU': + 'public/plugins/grafana-azure-monitor-datasource/locales/hu-HU/grafana-azure-monitor-datasource.json', + 'id-ID': + 'public/plugins/grafana-azure-monitor-datasource/locales/id-ID/grafana-azure-monitor-datasource.json', + 'it-IT': + 'public/plugins/grafana-azure-monitor-datasource/locales/it-IT/grafana-azure-monitor-datasource.json', + 'ja-JP': + 'public/plugins/grafana-azure-monitor-datasource/locales/ja-JP/grafana-azure-monitor-datasource.json', + 'ko-KR': + 'public/plugins/grafana-azure-monitor-datasource/locales/ko-KR/grafana-azure-monitor-datasource.json', + 'nl-NL': + 'public/plugins/grafana-azure-monitor-datasource/locales/nl-NL/grafana-azure-monitor-datasource.json', + 'pl-PL': + 'public/plugins/grafana-azure-monitor-datasource/locales/pl-PL/grafana-azure-monitor-datasource.json', + 'pt-BR': + 'public/plugins/grafana-azure-monitor-datasource/locales/pt-BR/grafana-azure-monitor-datasource.json', + 'pt-PT': + 'public/plugins/grafana-azure-monitor-datasource/locales/pt-PT/grafana-azure-monitor-datasource.json', + 'ru-RU': + 'public/plugins/grafana-azure-monitor-datasource/locales/ru-RU/grafana-azure-monitor-datasource.json', + 'sv-SE': + 'public/plugins/grafana-azure-monitor-datasource/locales/sv-SE/grafana-azure-monitor-datasource.json', + 'tr-TR': + 'public/plugins/grafana-azure-monitor-datasource/locales/tr-TR/grafana-azure-monitor-datasource.json', + 'zh-Hans': + 'public/plugins/grafana-azure-monitor-datasource/locales/zh-Hans/grafana-azure-monitor-datasource.json', + 'zh-Hant': + 'public/plugins/grafana-azure-monitor-datasource/locales/zh-Hant/grafana-azure-monitor-datasource.json', + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'grafana-exploretraces-app', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'grafana-exploretraces-app', + type: 'app', + name: 'Grafana Traces Drilldown', + info: { + keywords: ['app', 'tempo', 'traces', 'explore'], + logos: { + small: 'public/plugins/grafana-exploretraces-app/img/logo.svg', + large: 'public/plugins/grafana-exploretraces-app/img/logo.svg', + }, + updated: '2025-12-04', + version: '1.2.2', + author: { + name: 'Grafana', + }, + description: + 'Use Rate, Errors, and Duration (RED) metrics derived from traces to investigate errors within complex distributed systems.', + links: [ + { + name: 'Github', + url: 'https://github.com/grafana/explore-traces', + }, + { + name: 'Report bug', + url: 'https://github.com/grafana/explore-traces/issues/new', + }, + ], + screenshots: [ + { + name: 'histogram-breakdown', + path: 'public/plugins/grafana-exploretraces-app/img/histogram-breakdown.png', + }, + { + name: 'errors-metric-flow', + path: 'public/plugins/grafana-exploretraces-app/img/errors-metric-flow.png', + }, + { + name: 'errors-root-cause', + path: 'public/plugins/grafana-exploretraces-app/img/errors-root-cause.png', + }, + ], + }, + dependencies: { + grafanaDependency: '>=11.5.0', + grafanaVersion: '*', + extensions: { + exposedComponents: [ + 'grafana-asserts-app/entity-assertions-widget/v1', + 'grafana-asserts-app/insights-timeline-widget/v1', + ], + }, + }, + autoEnabled: true, + includes: [ + { + type: 'page', + name: 'Explore', + role: 'Viewer', + action: 'datasources:explore', + path: '/a/grafana-exploretraces-app/', + addToNav: true, + defaultNav: true, + }, + ], + preload: true, + extensions: { + addedComponents: [ + { + targets: ['grafana-asserts-app/entity-assertions-widget/v1'], + title: 'Asserts widget', + description: 'A block with assertions for a given service', + }, + { + targets: ['grafana-asserts-app/insights-timeline-widget/v1'], + title: 'Insights Timeline Widget', + description: 'Widget for displaying insights timeline in other apps', + }, + ], + addedLinks: [ + { + targets: ['grafana/dashboard/panel/menu'], + title: 'Open in Traces Drilldown', + description: 'Open current query in the Traces Drilldown app', + }, + { + targets: ['grafana/explore/toolbar/action'], + title: 'Open in Grafana Traces Drilldown', + description: 'Try our new queryless experience for traces', + }, + ], + exposedComponents: [ + { + id: 'grafana-exploretraces-app/open-in-explore-traces-button/v1', + title: 'Open in Traces Drilldown button', + description: 'A button that opens a traces view in the Traces Drilldown app.', + }, + { + id: 'grafana-exploretraces-app/embedded-trace-exploration/v1', + title: 'Embedded Trace Exploration', + description: + 'A component that renders a trace exploration view that can be embedded in other parts of Grafana.', + }, + ], + extensionPoints: [ + { + id: 'grafana-exploretraces-app/investigation/v1', + }, + { + id: 'grafana-exploretraces-app/get-logs-drilldown-link/v1', + }, + ], + }, + }, + class: 'external', + module: { + path: 'public/plugins/grafana-exploretraces-app/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/grafana-exploretraces-app', + signature: { + status: 'valid', + type: 'grafana', + org: 'Grafana Labs', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'grafana-lokiexplore-app', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'grafana-lokiexplore-app', + type: 'app', + name: 'Grafana Logs Drilldown', + info: { + keywords: ['app', 'loki', 'explore', 'logs', 'drilldown', 'drill', 'down', 'drill-down'], + logos: { + small: 'public/plugins/grafana-lokiexplore-app/img/logo.svg', + large: 'public/plugins/grafana-lokiexplore-app/img/logo.svg', + }, + updated: '2025-12-09', + version: '1.0.32', + author: { + name: 'Grafana', + }, + description: + 'Visualize log volumes to easily detect anomalies or significant changes over time, without needing to compose LogQL queries.', + links: [ + { + name: 'Github', + url: 'https://github.com/grafana/explore-logs', + }, + { + name: 'Report bug', + url: 'https://github.com/grafana/explore-logs/issues/new', + }, + ], + screenshots: [ + { + name: 'patterns', + path: 'public/plugins/grafana-lokiexplore-app/img/patterns.png', + }, + { + name: 'fields', + path: 'public/plugins/grafana-lokiexplore-app/img/fields.png', + }, + { + name: 'table', + path: 'public/plugins/grafana-lokiexplore-app/img/table.png', + }, + ], + }, + dependencies: { + grafanaDependency: '>=11.6.0', + grafanaVersion: '*', + extensions: { + exposedComponents: [ + 'grafana-adaptivelogs-app/temporary-exemptions/v1', + 'grafana-lokiexplore-app/embedded-logs-exploration/v1', + 'grafana-asserts-app/insights-timeline-widget/v1', + 'grafana/add-to-dashboard-form/v1', + ], + }, + }, + autoEnabled: true, + includes: [ + { + type: 'page', + name: 'Grafana Logs Drilldown', + role: 'Viewer', + action: 'datasources:explore', + path: '/a/grafana-lokiexplore-app/explore', + addToNav: true, + defaultNav: true, + }, + ], + preload: true, + extensions: { + addedComponents: [ + { + targets: ['grafana-asserts-app/insights-timeline-widget/v1'], + title: 'Insights Timeline Widget', + description: 'Widget for displaying insights timeline in other apps', + }, + ], + addedLinks: [ + { + targets: [ + 'grafana/dashboard/panel/menu', + 'grafana/explore/toolbar/action', + 'grafana-metricsdrilldown-app/open-in-logs-drilldown/v1', + 'grafana-assistant-app/navigateToDrilldown/v1', + ], + title: 'Open in Grafana Logs Drilldown', + description: 'Open current query in the Grafana Logs Drilldown view', + }, + ], + addedFunctions: [ + { + targets: ['grafana-exploretraces-app/get-logs-drilldown-link/v1'], + title: 'Open Logs Drilldown', + description: 'Returns url to logs drilldown app', + }, + ], + exposedComponents: [ + { + id: 'grafana-lokiexplore-app/open-in-explore-logs-button/v1', + title: 'Open in Logs Drilldown button', + description: 'A button that opens a logs view in the Logs Drilldown app.', + }, + { + id: 'grafana-lokiexplore-app/embedded-logs-exploration/v1', + title: 'Embedded Logs Exploration', + description: + 'A component that renders a logs exploration view that can be embedded in other parts of Grafana.', + }, + ], + extensionPoints: [ + { + id: 'grafana-lokiexplore-app/investigation/v1', + }, + ], + }, + }, + class: 'external', + module: { + path: 'public/plugins/grafana-lokiexplore-app/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/grafana-lokiexplore-app', + signature: { + status: 'valid', + type: 'grafana', + org: 'Grafana Labs', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'grafana-metricsdrilldown-app', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'grafana-metricsdrilldown-app', + type: 'app', + name: 'Grafana Metrics Drilldown', + info: { + keywords: ['drilldown', 'metrics', 'app', 'prometheus', 'mimir'], + logos: { + small: 'public/plugins/grafana-metricsdrilldown-app/img/logo.svg', + large: 'public/plugins/grafana-metricsdrilldown-app/img/logo.svg', + }, + updated: '2025-12-17', + version: '1.0.26', + author: { + name: 'Grafana', + }, + description: + 'Quickly find related metrics with a few clicks, without needing to write PromQL queries to retrieve metrics.', + links: [ + { + name: 'GitHub', + url: 'https://github.com/grafana/metrics-drilldown', + }, + { + name: 'Report a bug', + url: 'https://github.com/grafana/metrics-drilldown/issues/new', + }, + ], + screenshots: [ + { + name: 'metricselect', + path: 'public/plugins/grafana-metricsdrilldown-app/img/metrics-drilldown.png', + }, + { + name: 'breakdown', + path: 'public/plugins/grafana-metricsdrilldown-app/img/breakdown.png', + }, + ], + }, + dependencies: { + grafanaDependency: '>=11.6.0', + grafanaVersion: '*', + extensions: { + exposedComponents: ['grafana/add-to-dashboard-form/v1'], + }, + }, + autoEnabled: true, + includes: [ + { + type: 'page', + name: 'Grafana Metrics Drilldown', + role: 'Viewer', + action: 'datasources:explore', + path: '/a/grafana-metricsdrilldown-app/drilldown', + addToNav: true, + defaultNav: true, + }, + ], + preload: true, + extensions: { + addedLinks: [ + { + targets: [ + 'grafana/dashboard/panel/menu', + 'grafana/explore/toolbar/action', + 'grafana-assistant-app/navigateToDrilldown/v1', + 'grafana/alerting/alertingrule/queryeditor', + ], + title: 'Open in Grafana Metrics Drilldown', + description: 'Open current query in the Grafana Metrics Drilldown view', + }, + { + targets: ['grafana-metricsdrilldown-app/grafana-assistant-app/navigateToDrilldown/v0-alpha'], + title: 'Navigate to metrics drilldown', + description: 'Build a url path to the metrics drilldown', + }, + { + targets: ['grafana/datasources/config/actions', 'grafana/datasources/config/status'], + title: 'Open in Metrics Drilldown', + description: 'Browse metrics in Grafana Metrics Drilldown', + }, + ], + exposedComponents: [ + { + id: 'grafana-metricsdrilldown-app/label-breakdown-component/v1', + title: 'Label Breakdown', + description: 'A metrics label breakdown view from the Metrics Drilldown app.', + }, + { + id: 'grafana-metricsdrilldown-app/knowledge-graph-insight-metrics/v1', + title: 'Knowledge Graph Source Metrics', + description: 'Explore the underlying metrics related to a Knowledge Graph insight', + }, + ], + extensionPoints: [ + { + id: 'grafana-exploremetrics-app/investigation/v1', + }, + { + id: 'grafana-metricsdrilldown-app/open-in-logs-drilldown/v1', + }, + ], + }, + }, + class: 'external', + module: { + path: 'public/plugins/grafana-metricsdrilldown-app/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/grafana-metricsdrilldown-app', + signature: { + status: 'valid', + type: 'grafana', + org: 'Grafana Labs', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'grafana-postgresql-datasource', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'grafana-postgresql-datasource', + type: 'datasource', + name: 'PostgreSQL', + info: { + keywords: [], + logos: { + small: 'public/plugins/grafana-postgresql-datasource/img/postgresql_logo.svg', + large: 'public/plugins/grafana-postgresql-datasource/img/postgresql_logo.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Data source for PostgreSQL and compatible databases', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/postgres/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=11.6.0', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'sql', + executable: 'gpx_grafana-postgresql-datasource', + logs: true, + metrics: true, + queryOptions: { + minInterval: true, + }, + }, + class: 'core', + module: { + path: 'public/plugins/grafana-postgresql-datasource/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/grafana-postgresql-datasource', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'grafana-pyroscope-app', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'grafana-pyroscope-app', + type: 'app', + name: 'Grafana Profiles Drilldown', + info: { + keywords: ['app', 'pyroscope', 'profiling', 'explore', 'profiles', 'performance', 'drilldown'], + logos: { + small: 'public/plugins/grafana-pyroscope-app/img/logo.svg', + large: 'public/plugins/grafana-pyroscope-app/img/logo.svg', + }, + updated: '2025-12-18', + version: '1.14.2', + author: { + name: 'Grafana', + }, + description: + 'View and analyze high-level service performance, identify problem processes for optimization, and diagnose issues to determine root causes.', + links: [ + { + name: 'GitHub', + url: 'https://github.com/grafana/profiles-drilldown', + }, + { + name: 'Report bug', + url: 'https://github.com/grafana/profiles-drilldown/issues/new', + }, + ], + screenshots: [ + { + name: 'Hero Image', + path: 'public/plugins/grafana-pyroscope-app/img/hero-image.png', + }, + ], + }, + dependencies: { + grafanaDependency: '>=11.5.0', + grafanaVersion: '*', + extensions: { + exposedComponents: [ + 'grafana-o11yinsights-app/insights-launcher/v1', + 'grafana-adaptiveprofiles-app/resolution-boost/v1', + ], + }, + }, + autoEnabled: true, + includes: [ + { + type: 'page', + name: 'Profiles', + role: 'Viewer', + action: 'datasources:explore', + path: '/a/grafana-pyroscope-app/explore', + addToNav: true, + defaultNav: true, + }, + ], + preload: true, + extensions: { + addedLinks: [ + { + targets: [ + 'grafana/explore/toolbar/action', + 'grafana/traceview/details', + 'grafana-assistant-app/navigateToDrilldown/v1', + ], + title: 'Open in Grafana Profiles Drilldown', + description: 'Try our new queryless experience for profiles', + }, + ], + exposedComponents: [ + { + id: 'grafana-pyroscope-app/embedded-profiles-exploration/v1', + title: 'Embedded Profiles Exploration', + description: + 'A component that renders a profiles exploration view that can be embedded in other parts of Grafana.', + }, + ], + extensionPoints: [ + { + id: 'grafana-pyroscope-app/investigation/v1', + }, + { + id: 'grafana-pyroscope-app/settings/v1', + }, + ], + }, + }, + class: 'external', + module: { + path: 'public/plugins/grafana-pyroscope-app/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/grafana-pyroscope-app', + signature: { + status: 'valid', + type: 'grafana', + org: 'Grafana Labs', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'grafana-pyroscope-datasource', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'grafana-pyroscope-datasource', + type: 'datasource', + name: 'Grafana Pyroscope', + info: { + keywords: [ + 'grafana', + 'datasource', + 'phlare', + 'flamegraph', + 'profiling', + 'continuous profiling', + 'pyroscope', + ], + logos: { + small: 'public/plugins/grafana-pyroscope-datasource/img/grafana_pyroscope_icon.svg', + large: 'public/plugins/grafana-pyroscope-datasource/img/grafana_pyroscope_icon.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://www.grafana.com', + }, + description: + 'Data source for Grafana Pyroscope, horizontally-scalable, highly-available, multi-tenant continuous profiling aggregation system.', + links: [ + { + name: 'GitHub Project', + url: 'https://github.com/grafana/pyroscope', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/pyroscope/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/pyroscope/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.3.0-0', + grafanaVersion: '*', + }, + backend: true, + category: 'profiling', + executable: 'gpx_grafana-pyroscope-datasource', + metrics: true, + }, + class: 'core', + module: { + path: 'public/plugins/grafana-pyroscope-datasource/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/grafana-pyroscope-datasource', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'grafana-testdata-datasource', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'grafana-testdata-datasource', + type: 'datasource', + name: 'TestData', + info: { + keywords: [], + logos: { + small: 'public/plugins/grafana-testdata-datasource/img/testdata.svg', + large: 'public/plugins/grafana-testdata-datasource/img/testdata.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Generates test data in different forms', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/testdata/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.3.0-0', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + executable: 'gpx_testdata', + includes: [ + { + type: 'dashboard', + name: 'Streaming Example', + role: 'Viewer', + path: 'dashboards/streaming.json', + }, + ], + logs: true, + metrics: true, + queryOptions: { + maxDataPoints: true, + minInterval: true, + }, + }, + class: 'core', + module: { + path: 'public/plugins/grafana-testdata-datasource/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/grafana-testdata-datasource', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'graphite', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'graphite', + type: 'datasource', + name: 'Graphite', + info: { + keywords: [], + logos: { + small: 'public/plugins/graphite/img/graphite_logo.png', + large: 'public/plugins/graphite/img/graphite_logo.png', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Open source time series database', + links: [ + { + name: 'Learn more', + url: 'https://graphiteapp.org/', + }, + { + name: 'Graphite 1.1 Release', + url: 'https://grafana.com/blog/2018/01/11/graphite-1.1-teaching-an-old-dog-new-tricks/', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/graphite/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.3.0', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'tsdb', + executable: 'gpx_graphite', + includes: [ + { + type: 'dashboard', + name: 'Graphite Carbon Metrics', + role: 'Viewer', + path: 'dashboards/carbon_metrics.json', + }, + { + type: 'dashboard', + name: 'Metrictank (Graphite alternative)', + role: 'Viewer', + path: 'dashboards/metrictank.json', + }, + ], + metrics: true, + queryOptions: { + maxDataPoints: true, + cacheTimeout: true, + }, + }, + class: 'core', + module: { + path: 'public/plugins/graphite/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/graphite', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'heatmap', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'heatmap', + type: 'panel', + name: 'Heatmap', + info: { + keywords: [], + logos: { + small: 'public/plugins/heatmap/img/icn-heatmap-panel.svg', + large: 'public/plugins/heatmap/img/icn-heatmap-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Like a histogram over time', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/heatmap/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/heatmap', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/heatmap', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'histogram', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'histogram', + type: 'panel', + name: 'Histogram', + info: { + keywords: ['distribution', 'bar chart', 'frequency', 'proportional'], + logos: { + small: 'public/plugins/histogram/img/histogram.svg', + large: 'public/plugins/histogram/img/histogram.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Distribution of values presented as a bar chart.', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/histogram/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/histogram', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/histogram', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'influxdb', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'influxdb', + type: 'datasource', + name: 'InfluxDB', + info: { + keywords: [], + logos: { + small: 'public/plugins/influxdb/img/influxdb_logo.svg', + large: 'public/plugins/influxdb/img/influxdb_logo.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Open source time series database', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/influxdb/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'tsdb', + logs: true, + metrics: true, + queryOptions: { + minInterval: true, + }, + }, + class: 'core', + module: { + path: 'core:plugin/influxdb', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/influxdb', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'jaeger', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'jaeger', + type: 'datasource', + name: 'Jaeger', + info: { + keywords: [], + logos: { + small: 'public/plugins/jaeger/img/jaeger_logo.svg', + large: 'public/plugins/jaeger/img/jaeger_logo.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Open source, end-to-end distributed tracing', + links: [ + { + name: 'Learn more', + url: 'https://www.jaegertracing.io', + }, + { + name: 'Jaeger GitHub Project', + url: 'https://github.com/jaegertracing/jaeger', + }, + { + name: 'Repository', + url: 'https://github.com/grafana/grafana', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/jaeger/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.3.0-0', + grafanaVersion: '*', + }, + backend: true, + category: 'tracing', + executable: 'gpx_jaeger', + metrics: true, + tracing: true, + }, + class: 'core', + module: { + path: 'public/plugins/jaeger/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/jaeger', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'live', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'live', + type: 'panel', + name: 'Live', + info: { + keywords: [], + logos: { + small: 'public/plugins/live/img/live.svg', + large: 'public/plugins/live/img/live.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + skipDataQuery: true, + state: 'alpha', + }, + class: 'core', + module: { + path: 'core:plugin/live', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/live', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'logs', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'logs', + type: 'panel', + name: 'Logs', + info: { + keywords: [], + logos: { + small: 'public/plugins/logs/img/icn-logs-panel.svg', + large: 'public/plugins/logs/img/icn-logs-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/logs/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/logs', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/logs', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'loki', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'loki', + type: 'datasource', + name: 'Loki', + info: { + keywords: [], + logos: { + small: 'public/plugins/loki/img/loki_icon.svg', + large: 'public/plugins/loki/img/loki_icon.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Like Prometheus but for logs. OSS logging solution from Grafana Labs', + links: [ + { + name: 'Learn more', + url: 'https://grafana.com/loki', + }, + { + name: 'GitHub Project', + url: 'https://github.com/grafana/loki', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/loki/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.4.0', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'logging', + executable: 'gpx_loki', + logs: true, + metrics: true, + queryOptions: { + maxDataPoints: true, + }, + streaming: true, + }, + class: 'core', + module: { + path: 'public/plugins/loki/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/loki', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'mixed', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'mixed', + type: 'datasource', + name: '-- Mixed --', + info: { + keywords: [], + logos: { + small: 'public/plugins/mixed/img/icn-mixeddatasources.svg', + large: 'public/plugins/mixed/img/icn-mixeddatasources.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Lets you query multiple data sources in the same panel.', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/#special-data-sources', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + builtIn: true, + metrics: true, + queryOptions: { + minInterval: true, + }, + }, + class: 'core', + module: { + path: 'core:plugin/mixed', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/mixed', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'mssql', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'mssql', + type: 'datasource', + name: 'Microsoft SQL Server', + info: { + keywords: [], + logos: { + small: 'public/plugins/mssql/img/sql_server_logo.svg', + large: 'public/plugins/mssql/img/sql_server_logo.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Data source for Microsoft SQL Server compatible databases', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/mssql/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.4.0', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'sql', + executable: 'gpx_mssql', + metrics: true, + queryOptions: { + minInterval: true, + }, + }, + class: 'core', + module: { + path: 'public/plugins/mssql/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/mssql', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + translations: { + 'cs-CZ': 'public/plugins/mssql/locales/cs-CZ/mssql.json', + 'de-DE': 'public/plugins/mssql/locales/de-DE/mssql.json', + 'en-US': 'public/plugins/mssql/locales/en-US/mssql.json', + 'es-ES': 'public/plugins/mssql/locales/es-ES/mssql.json', + 'fr-FR': 'public/plugins/mssql/locales/fr-FR/mssql.json', + 'hu-HU': 'public/plugins/mssql/locales/hu-HU/mssql.json', + 'id-ID': 'public/plugins/mssql/locales/id-ID/mssql.json', + 'it-IT': 'public/plugins/mssql/locales/it-IT/mssql.json', + 'ja-JP': 'public/plugins/mssql/locales/ja-JP/mssql.json', + 'ko-KR': 'public/plugins/mssql/locales/ko-KR/mssql.json', + 'nl-NL': 'public/plugins/mssql/locales/nl-NL/mssql.json', + 'pl-PL': 'public/plugins/mssql/locales/pl-PL/mssql.json', + 'pt-BR': 'public/plugins/mssql/locales/pt-BR/mssql.json', + 'pt-PT': 'public/plugins/mssql/locales/pt-PT/mssql.json', + 'ru-RU': 'public/plugins/mssql/locales/ru-RU/mssql.json', + 'sv-SE': 'public/plugins/mssql/locales/sv-SE/mssql.json', + 'tr-TR': 'public/plugins/mssql/locales/tr-TR/mssql.json', + 'zh-Hans': 'public/plugins/mssql/locales/zh-Hans/mssql.json', + 'zh-Hant': 'public/plugins/mssql/locales/zh-Hant/mssql.json', + }, + }, + status: {}, + }, + v0alpha1Meta, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'mysql', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'mysql', + type: 'datasource', + name: 'MySQL', + info: { + keywords: [], + logos: { + small: 'public/plugins/mysql/img/mysql_logo.svg', + large: 'public/plugins/mysql/img/mysql_logo.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Data source for MySQL databases', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/mysql/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.4.0', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'sql', + executable: 'gpx_mysql', + metrics: true, + queryOptions: { + minInterval: true, + }, + }, + class: 'core', + module: { + path: 'public/plugins/mysql/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/mysql', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'news', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'news', + type: 'panel', + name: 'News', + info: { + keywords: [], + logos: { + small: 'public/plugins/news/img/news.svg', + large: 'public/plugins/news/img/news.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'RSS feed reader', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/news/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + skipDataQuery: true, + state: 'beta', + }, + class: 'core', + module: { + path: 'core:plugin/news', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/news', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'nodeGraph', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'nodeGraph', + type: 'panel', + name: 'Node Graph', + info: { + keywords: [], + logos: { + small: 'public/plugins/nodeGraph/img/icn-node-graph.svg', + large: 'public/plugins/nodeGraph/img/icn-node-graph.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/node-graph/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/nodeGraph', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/nodeGraph', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'opentsdb', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'opentsdb', + type: 'datasource', + name: 'OpenTSDB', + info: { + keywords: [], + logos: { + small: 'public/plugins/opentsdb/img/opentsdb_logo.png', + large: 'public/plugins/opentsdb/img/opentsdb_logo.png', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Open source time series database', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/opentsdb/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.3.0-0', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'tsdb', + executable: 'gpx_opentsdb', + metrics: true, + }, + class: 'core', + module: { + path: 'public/plugins/opentsdb/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/opentsdb', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'parca', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'parca', + type: 'datasource', + name: 'Parca', + info: { + keywords: ['grafana', 'datasource', 'parca', 'profiling'], + logos: { + small: 'public/plugins/parca/img/logo-small.svg', + large: 'public/plugins/parca/img/logo-small.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://www.grafana.com', + }, + description: + 'Continuous profiling for analysis of CPU and memory usage, down to the line number and throughout time. Saving infrastructure cost, improving performance, and increasing reliability.', + links: [ + { + name: 'GitHub Project', + url: 'https://github.com/parca-dev/parca', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/parca/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.3.0-0', + grafanaVersion: '*', + }, + backend: true, + category: 'profiling', + executable: 'gpx_parca', + metrics: true, + }, + class: 'core', + module: { + path: 'public/plugins/parca/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/parca', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'piechart', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'piechart', + type: 'panel', + name: 'Pie chart', + info: { + keywords: [], + logos: { + small: 'public/plugins/piechart/img/icon_piechart.svg', + large: 'public/plugins/piechart/img/icon_piechart.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'The new core pie chart visualization', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/pie-chart/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/piechart', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/piechart', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'prometheus', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'prometheus', + type: 'datasource', + name: 'Prometheus', + info: { + keywords: [], + logos: { + small: 'public/plugins/prometheus/img/prometheus_logo.svg', + large: 'public/plugins/prometheus/img/prometheus_logo.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Open source time series database & alerting', + links: [ + { + name: 'Learn more', + url: 'https://prometheus.io/', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/prometheus/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'tsdb', + includes: [ + { + type: 'dashboard', + name: 'Prometheus Stats', + role: 'Viewer', + path: 'dashboards/prometheus_stats.json', + }, + { + type: 'dashboard', + name: 'Prometheus 2.0 Stats', + role: 'Viewer', + path: 'dashboards/prometheus_2_stats.json', + }, + { + type: 'dashboard', + name: 'Grafana Stats', + role: 'Viewer', + path: 'dashboards/grafana_stats.json', + }, + ], + metrics: true, + multiValueFilterOperators: true, + queryOptions: { + minInterval: true, + }, + routes: [ + { + path: 'api/v1/query', + method: 'POST', + reqRole: 'Viewer', + reqAction: 'datasources:query', + }, + { + path: 'api/v1/query_range', + method: 'POST', + reqRole: 'Viewer', + reqAction: 'datasources:query', + }, + { + path: 'api/v1/series', + method: 'POST', + reqRole: 'Viewer', + reqAction: 'datasources:query', + }, + { + path: 'api/v1/labels', + method: 'POST', + reqRole: 'Viewer', + reqAction: 'datasources:query', + }, + { + path: 'api/v1/query_exemplars', + method: 'POST', + reqRole: 'Viewer', + reqAction: 'datasources:query', + }, + { + path: '/rules', + method: 'GET', + reqRole: 'Viewer', + reqAction: 'alert.rules.external:read', + }, + { + path: '/rules', + method: 'POST', + reqRole: 'Editor', + reqAction: 'alert.rules.external:write', + }, + { + path: '/rules', + method: 'DELETE', + reqRole: 'Editor', + reqAction: 'alert.rules.external:write', + }, + { + path: '/config/v1/rules', + method: 'DELETE', + reqRole: 'Editor', + reqAction: 'alert.rules.external:write', + }, + { + path: '/config/v1/rules', + method: 'POST', + reqRole: 'Editor', + reqAction: 'alert.rules.external:write', + }, + ], + }, + class: 'core', + module: { + path: 'core:plugin/prometheus', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/prometheus', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'radialbar', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'radialbar', + type: 'panel', + name: 'New Gauge', + info: { + keywords: [], + logos: { + small: 'public/plugins/radialbar/img/icon_gauge.svg', + large: 'public/plugins/radialbar/img/icon_gauge.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Standard gauge visualization', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/gauge/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + state: 'alpha', + }, + class: 'core', + module: { + path: 'core:plugin/radialbar', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/radialbar', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'stackdriver', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'stackdriver', + type: 'datasource', + name: 'Google Cloud Monitoring', + info: { + keywords: [], + logos: { + small: 'public/plugins/stackdriver/img/cloud_monitoring_logo.svg', + large: 'public/plugins/stackdriver/img/cloud_monitoring_logo.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: "Data source for Google's monitoring service (formerly named Stackdriver)", + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/google-cloud-monitoring/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + alerting: true, + annotations: true, + backend: true, + category: 'cloud', + executable: 'gpx_cloudmonitoring', + includes: [ + { + type: 'dashboard', + name: 'Data Processing Monitoring', + role: 'Viewer', + path: 'dashboards/dataprocessing-monitoring.json', + }, + { + type: 'dashboard', + name: 'Cloud Functions Monitoring', + role: 'Viewer', + path: 'dashboards/cloudfunctions-monitoring.json', + }, + { + type: 'dashboard', + name: 'GCE VM Instance Monitoring', + role: 'Viewer', + path: 'dashboards/gce-vm-instance-monitoring.json', + }, + { + type: 'dashboard', + name: 'GKE Prometheus Pod/Node Monitoring', + role: 'Viewer', + path: 'dashboards/gke-prometheus-pod-node-monitoring.json', + }, + { + type: 'dashboard', + name: 'Firewall Insights Monitoring', + role: 'Viewer', + path: 'dashboards/firewall-insight-monitoring.json', + }, + { + type: 'dashboard', + name: 'GCE Network Monitoring', + role: 'Viewer', + path: 'dashboards/gce-network-monitoring.json', + }, + { + type: 'dashboard', + name: 'HTTP/S LB Backend Services', + role: 'Viewer', + path: 'dashboards/https-lb-backend-services-monitoring.json', + }, + { + type: 'dashboard', + name: 'HTTP/S Load Balancer Monitoring', + role: 'Viewer', + path: 'dashboards/https-loadbalancer-monitoring.json', + }, + { + type: 'dashboard', + name: 'Network TCP Load Balancer Monitoring', + role: 'Viewer', + path: 'dashboards/network-tcp-loadbalancer-monitoring.json', + }, + { + type: 'dashboard', + name: 'MicroService Monitoring', + role: 'Viewer', + path: 'dashboards/micro-service-monitoring.json', + }, + { + type: 'dashboard', + name: 'Cloud Storage Monitoring', + role: 'Viewer', + path: 'dashboards/cloud-storage-monitoring.json', + }, + { + type: 'dashboard', + name: 'Cloud SQL Monitoring', + role: 'Viewer', + path: 'dashboards/cloudsql-monitoring.json', + }, + { + type: 'dashboard', + name: 'Cloud SQL(MySQL) Monitoring', + role: 'Viewer', + path: 'dashboards/cloudsql-mysql-monitoring.json', + }, + ], + logs: true, + metrics: true, + queryOptions: { + maxDataPoints: true, + cacheTimeout: true, + }, + }, + class: 'core', + module: { + path: 'public/plugins/stackdriver/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/stackdriver', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'stat', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'stat', + type: 'panel', + name: 'Stat', + info: { + keywords: [], + logos: { + small: 'public/plugins/stat/img/icn-singlestat-panel.svg', + large: 'public/plugins/stat/img/icn-singlestat-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Big stat values & sparklines', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/stat/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/stat', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/stat', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'state-timeline', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'state-timeline', + type: 'panel', + name: 'State timeline', + info: { + keywords: [], + logos: { + small: 'public/plugins/state-timeline/img/timeline.svg', + large: 'public/plugins/state-timeline/img/timeline.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'State changes and durations', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/state-timeline/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/state-timeline', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/state-timeline', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'status-history', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'status-history', + type: 'panel', + name: 'Status history', + info: { + keywords: [], + logos: { + small: 'public/plugins/status-history/img/status.svg', + large: 'public/plugins/status-history/img/status.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Periodic status history', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/status-history/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/status-history', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/status-history', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'table', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'table', + type: 'panel', + name: 'Table', + info: { + keywords: [], + logos: { + small: 'public/plugins/table/img/icn-table-panel.svg', + large: 'public/plugins/table/img/icn-table-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Supports many column styles', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/table/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/table', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/table', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'tempo', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'tempo', + type: 'datasource', + name: 'Tempo', + info: { + keywords: [], + logos: { + small: 'public/plugins/tempo/img/tempo_logo.svg', + large: 'public/plugins/tempo/img/tempo_logo.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'High volume, minimal dependency trace storage. OSS tracing solution from Grafana Labs.', + links: [ + { + name: 'GitHub Project', + url: 'https://github.com/grafana/tempo', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/tempo/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.3.0-0', + grafanaVersion: '*', + }, + backend: true, + category: 'tracing', + executable: 'gpx_tempo', + metrics: true, + tracing: true, + }, + class: 'core', + module: { + path: 'public/plugins/tempo/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/tempo', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'text', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'text', + type: 'panel', + name: 'Text', + info: { + keywords: [], + logos: { + small: 'public/plugins/text/img/icn-text-panel.svg', + large: 'public/plugins/text/img/icn-text-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Supports markdown and html content', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/text/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + skipDataQuery: true, + }, + class: 'core', + module: { + path: 'core:plugin/text', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/text', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'timeseries', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'timeseries', + type: 'panel', + name: 'Time series', + info: { + keywords: [], + logos: { + small: 'public/plugins/timeseries/img/icn-timeseries-panel.svg', + large: 'public/plugins/timeseries/img/icn-timeseries-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Time based line, area and bar charts', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/time-series/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/timeseries', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/timeseries', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'traces', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'traces', + type: 'panel', + name: 'Traces', + info: { + keywords: [], + logos: { + small: 'public/plugins/traces/img/traces-panel.svg', + large: 'public/plugins/traces/img/traces-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/traces/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/traces', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/traces', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'trend', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'trend', + type: 'panel', + name: 'Trend', + info: { + keywords: [], + logos: { + small: 'public/plugins/trend/img/trend.svg', + large: 'public/plugins/trend/img/trend.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Like timeseries, but when x != time', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/trend/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + state: 'beta', + }, + class: 'core', + module: { + path: 'core:plugin/trend', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/trend', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'welcome', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'welcome', + type: 'panel', + name: 'Welcome', + info: { + keywords: [], + logos: { + small: 'public/plugins/welcome/img/icn-dashlist-panel.svg', + large: 'public/plugins/welcome/img/icn-dashlist-panel.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + hideFromList: true, + skipDataQuery: true, + }, + class: 'core', + module: { + path: 'core:plugin/welcome', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/welcome', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'xychart', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'xychart', + type: 'panel', + name: 'XY Chart', + info: { + keywords: ['scatter', 'plot'], + logos: { + small: 'public/plugins/xychart/img/icn-xychart.svg', + large: 'public/plugins/xychart/img/icn-xychart.svg', + }, + updated: '', + version: '', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Supports arbitrary X vs Y in a graph to visualize the relationship between two variables.', + links: [ + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/xy-chart/', + }, + ], + }, + dependencies: { + grafanaDependency: '', + grafanaVersion: '*', + }, + }, + class: 'core', + module: { + path: 'core:plugin/xychart', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/xychart', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + { + kind: 'Meta', + apiVersion: 'plugins.grafana.app/v0alpha1', + metadata: { + name: 'zipkin', + namespace: 'default', + }, + spec: { + pluginJson: { + id: 'zipkin', + type: 'datasource', + name: 'Zipkin', + info: { + keywords: [], + logos: { + small: 'public/plugins/zipkin/img/zipkin-logo.svg', + large: 'public/plugins/zipkin/img/zipkin-logo.svg', + }, + updated: '', + version: '12.4.0-pre', + author: { + name: 'Grafana Labs', + url: 'https://grafana.com', + }, + description: 'Placeholder for the distributed tracing system.', + links: [ + { + name: 'Learn more', + url: 'https://zipkin.io', + }, + { + name: 'Raise issue', + url: 'https://github.com/grafana/grafana/issues/new', + }, + { + name: 'Documentation', + url: 'https://grafana.com/docs/grafana/latest/datasources/zipkin/', + }, + ], + }, + dependencies: { + grafanaDependency: '>=10.3.0-0', + grafanaVersion: '*', + }, + backend: true, + category: 'tracing', + executable: 'gpx_zipkin', + metrics: true, + tracing: true, + }, + class: 'core', + module: { + path: 'public/plugins/zipkin/module.js', + loadingStrategy: 'script', + }, + baseURL: 'public/plugins/zipkin', + signature: { + status: 'internal', + }, + angular: { + detected: false, + }, + }, + status: {}, + }, + ], +}); diff --git a/packages/grafana-runtime/src/services/pluginMeta/types.ts b/packages/grafana-runtime/src/services/pluginMeta/types.ts new file mode 100644 index 00000000000..81efe0df7b3 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/types.ts @@ -0,0 +1,10 @@ +import type { AppPluginConfig } from '@grafana/data'; + +import type { Meta } from './types/meta_object_gen'; + +export type AppPluginMetas = Record; + +export type AppPluginMetasMapper = (response: T) => AppPluginMetas; +export interface PluginMetasResponse { + items: Meta[]; +} diff --git a/packages/grafana-runtime/src/services/pluginMeta/types/meta_object_gen.ts b/packages/grafana-runtime/src/services/pluginMeta/types/meta_object_gen.ts new file mode 100644 index 00000000000..044ec1f4cd8 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/types/meta_object_gen.ts @@ -0,0 +1,49 @@ +/* + * This file was generated by grafana-app-sdk. DO NOT EDIT. + */ +import { Spec } from './types.spec.gen'; +import { Status } from './types.status.gen'; + +export interface Metadata { + name: string; + namespace: string; + generateName?: string; + selfLink?: string; + uid?: string; + resourceVersion?: string; + generation?: number; + creationTimestamp?: string; + deletionTimestamp?: string; + deletionGracePeriodSeconds?: number; + labels?: Record; + annotations?: Record; + ownerReferences?: OwnerReference[]; + finalizers?: string[]; + managedFields?: ManagedFieldsEntry[]; +} + +export interface OwnerReference { + apiVersion: string; + kind: string; + name: string; + uid: string; + controller?: boolean; + blockOwnerDeletion?: boolean; +} + +export interface ManagedFieldsEntry { + manager?: string; + operation?: string; + apiVersion?: string; + time?: string; + fieldsType?: string; + subresource?: string; +} + +export interface Meta { + kind: string; + apiVersion: string; + metadata: Metadata; + spec: Spec; + status: Status; +} diff --git a/packages/grafana-runtime/src/services/pluginMeta/types/types.spec.gen.ts b/packages/grafana-runtime/src/services/pluginMeta/types/types.spec.gen.ts new file mode 100644 index 00000000000..51845e98454 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/types/types.spec.gen.ts @@ -0,0 +1,278 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +// JSON configuration schema for Grafana plugins +// Converted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json +export interface JSONData { + // Unique name of the plugin + id: string; + // Plugin type + type: "app" | "datasource" | "panel" | "renderer"; + // Human-readable name of the plugin + name: string; + // Metadata for the plugin + info: Info; + // Dependency information + dependencies: Dependencies; + // Optional fields + alerting?: boolean; + annotations?: boolean; + autoEnabled?: boolean; + backend?: boolean; + buildMode?: string; + builtIn?: boolean; + category?: "tsdb" | "logging" | "cloud" | "tracing" | "profiling" | "sql" | "enterprise" | "iot" | "other"; + enterpriseFeatures?: EnterpriseFeatures; + executable?: string; + hideFromList?: boolean; + // +listType=atomic + includes?: Include[]; + logs?: boolean; + metrics?: boolean; + multiValueFilterOperators?: boolean; + pascalName?: string; + preload?: boolean; + queryOptions?: QueryOptions; + // +listType=atomic + routes?: Route[]; + skipDataQuery?: boolean; + state?: "alpha" | "beta"; + streaming?: boolean; + suggestions?: boolean; + tracing?: boolean; + iam?: IAM; + // +listType=atomic + roles?: Role[]; + extensions?: Extensions; +} + +export const defaultJSONData = (): JSONData => ({ + id: "", + type: "app", + name: "", + info: defaultInfo(), + dependencies: defaultDependencies(), +}); + +export interface Info { + // Required fields + // +listType=set + keywords: string[]; + logos: { + small: string; + large: string; + }; + updated: string; + version: string; + // Optional fields + author?: { + name?: string; + email?: string; + url?: string; + }; + description?: string; + // +listType=atomic + links?: { + name?: string; + url?: string; + }[]; + // +listType=atomic + screenshots?: { + name?: string; + path?: string; + }[]; +} + +export const defaultInfo = (): Info => ({ + keywords: [], + logos: { + small: "", + large: "", +}, + updated: "", + version: "", +}); + +export interface Dependencies { + // Required field + grafanaDependency: string; + // Optional fields + grafanaVersion?: string; + // +listType=set + // +listMapKey=id + plugins?: { + id: string; + type: "app" | "datasource" | "panel"; + name: string; + }[]; + extensions?: { + // +listType=set + exposedComponents?: string[]; + }; +} + +export const defaultDependencies = (): Dependencies => ({ + grafanaDependency: "", +}); + +export interface EnterpriseFeatures { + // Allow additional properties + healthDiagnosticsErrors?: boolean; +} + +export const defaultEnterpriseFeatures = (): EnterpriseFeatures => ({ + healthDiagnosticsErrors: false, +}); + +export interface Include { + uid?: string; + type?: "dashboard" | "page" | "panel" | "datasource"; + name?: string; + component?: string; + role?: "Admin" | "Editor" | "Viewer" | "None"; + action?: string; + path?: string; + addToNav?: boolean; + defaultNav?: boolean; + icon?: string; +} + +export const defaultInclude = (): Include => ({ +}); + +export interface QueryOptions { + maxDataPoints?: boolean; + minInterval?: boolean; + cacheTimeout?: boolean; +} + +export const defaultQueryOptions = (): QueryOptions => ({ +}); + +export interface Route { + path?: string; + method?: string; + url?: string; + reqSignedIn?: boolean; + reqRole?: string; + reqAction?: string; + // +listType=atomic + headers?: string[]; + body?: Record; + tokenAuth?: { + url?: string; + // +listType=set + scopes?: string[]; + params?: Record; + }; + jwtTokenAuth?: { + url?: string; + // +listType=set + scopes?: string[]; + params?: Record; + }; + // +listType=atomic + urlParams?: { + name?: string; + content?: string; + }[]; +} + +export const defaultRoute = (): Route => ({ +}); + +export interface IAM { + // +listType=atomic + permissions?: { + action?: string; + scope?: string; + }[]; +} + +export const defaultIAM = (): IAM => ({ +}); + +export interface Role { + role?: { + name?: string; + description?: string; + // +listType=atomic + permissions?: { + action?: string; + scope?: string; + }[]; + }; + // +listType=set + grants?: string[]; +} + +export const defaultRole = (): Role => ({ +}); + +export interface Extensions { + // +listType=atomic + addedComponents?: { + // +listType=set + targets: string[]; + title: string; + description?: string; + }[]; + // +listType=atomic + addedLinks?: { + // +listType=set + targets: string[]; + title: string; + description?: string; + }[]; + // +listType=atomic + addedFunctions?: { + // +listType=set + targets: string[]; + title: string; + description?: string; + }[]; + // +listType=set + // +listMapKey=id + exposedComponents?: { + id: string; + title?: string; + description?: string; + }[]; + // +listType=set + // +listMapKey=id + extensionPoints?: { + id: string; + title?: string; + description?: string; + }[]; +} + +export const defaultExtensions = (): Extensions => ({ +}); + +export interface Spec { + pluginJson: JSONData; + class: "core" | "external"; + module?: { + path: string; + hash?: string; + loadingStrategy?: "fetch" | "script"; + }; + baseURL?: string; + signature?: { + status: "internal" | "valid" | "invalid" | "modified" | "unsigned"; + type?: "grafana" | "commercial" | "community" | "private" | "private-glob"; + org?: string; + }; + angular?: { + detected: boolean; + }; + translations?: Record; + // +listType=atomic + children?: string[]; +} + +export const defaultSpec = (): Spec => ({ + pluginJson: defaultJSONData(), + class: "core", +}); + diff --git a/packages/grafana-runtime/src/services/pluginMeta/types/types.status.gen.ts b/packages/grafana-runtime/src/services/pluginMeta/types/types.status.gen.ts new file mode 100644 index 00000000000..01be8df7961 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginMeta/types/types.status.gen.ts @@ -0,0 +1,30 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +export interface OperatorState { + // lastEvaluation is the ResourceVersion last evaluated + lastEvaluation: string; + // state describes the state of the lastEvaluation. + // It is limited to three possible states for machine evaluation. + state: "success" | "in_progress" | "failed"; + // descriptiveState is an optional more descriptive state field which has no requirements on format + descriptiveState?: string; + // details contains any extra information that is operator-specific + details?: Record; +} + +export const defaultOperatorState = (): OperatorState => ({ + lastEvaluation: "", + state: "success", +}); + +export interface Status { + // operatorStates is a map of operator ID to operator state evaluations. + // Any operator which consumes this kind SHOULD add its state evaluation information to this field. + operatorStates?: Record; + // additionalFields is reserved for future use + additionalFields?: Record; +} + +export const defaultStatus = (): Status => ({ +}); + diff --git a/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts index 8d06591b46b..1627b2dc29b 100644 --- a/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts @@ -10,7 +10,7 @@ import * as common from '@grafana/schema'; -export const pluginVersion = "12.4.0-pre"; +export const pluginVersion = "%VERSION%"; export type BucketAggregation = (DateHistogram | Histogram | Terms | Filters | GeoHashGrid | Nested); diff --git a/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts index c0f8481a7f5..daead8f5295 100644 --- a/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts @@ -29,11 +29,14 @@ export interface Options extends common.SingleStatBaseOptions { barWidthFactor: number; effects: GaugePanelEffects; endpointMarker?: ('point' | 'glow' | 'none'); + minVizHeight: number; + minVizWidth: number; segmentCount: number; segmentSpacing: number; shape: ('circle' | 'gauge'); showThresholdLabels: boolean; showThresholdMarkers: boolean; + sizing: common.BarGaugeSizing; sparkline?: boolean; textMode?: ('auto' | 'value_and_name' | 'value' | 'name' | 'none'); } @@ -43,11 +46,14 @@ export const defaultOptions: Partial = { barWidthFactor: 0.5, effects: {}, endpointMarker: 'point', + minVizHeight: 75, + minVizWidth: 75, segmentCount: 1, segmentSpacing: 0.3, shape: 'gauge', showThresholdLabels: false, showThresholdMarkers: true, + sizing: common.BarGaugeSizing.Auto, sparkline: true, textMode: 'auto', }; diff --git a/packages/grafana-test-utils/src/fixtures/scopes.ts b/packages/grafana-test-utils/src/fixtures/scopes.ts new file mode 100644 index 00000000000..1d7c6ee0143 --- /dev/null +++ b/packages/grafana-test-utils/src/fixtures/scopes.ts @@ -0,0 +1,500 @@ +/** + * Types for Scopes API - matching @grafana/data types + */ + +export interface ScopeFilter { + key: string; + value: string; + operator: 'equals' | 'not-equals' | 'regex-match' | 'regex-not-match'; +} + +export interface ScopeSpec { + title: string; + filters: ScopeFilter[]; +} + +export interface Scope { + metadata: { + name: string; + }; + spec: ScopeSpec; +} + +export interface ScopeNodeSpec { + nodeType: 'container' | 'leaf'; + title: string; + description?: string; + disableMultiSelect?: boolean; + linkType?: 'scope'; + linkId?: string; + parentName: string; +} + +export interface ScopeNode { + metadata: { + name: string; + }; + spec: ScopeNodeSpec; +} + +export interface ScopeDashboardBindingSpec { + dashboard: string; + scope: string; +} + +export interface ScopeDashboardBindingStatus { + dashboardTitle: string; + groups?: string[]; +} + +export interface ScopeDashboardBinding { + metadata: { + name: string; + }; + spec: ScopeDashboardBindingSpec; + status: ScopeDashboardBindingStatus; +} + +export interface ScopeNavigation { + metadata: { + name: string; + }; + spec: { + url: string; + scope: string; + subScope?: string; + preLoadSubScopeChildren?: boolean; + expandOnLoad?: boolean; + disableSubScopeSelection?: boolean; + }; + status: { + title: string; + groups?: string[]; + }; +} + +export const MOCK_SCOPES: Scope[] = [ + { + metadata: { name: 'cloud' }, + spec: { + title: 'Cloud', + filters: [{ key: 'cloud', value: '.*', operator: 'regex-match' }], + }, + }, + { + metadata: { name: 'dev' }, + spec: { + title: 'Dev', + filters: [{ key: 'cloud', value: 'dev', operator: 'equals' }], + }, + }, + { + metadata: { name: 'ops' }, + spec: { + title: 'Ops', + filters: [{ key: 'cloud', value: 'ops', operator: 'equals' }], + }, + }, + { + metadata: { name: 'prod' }, + spec: { + title: 'Prod', + filters: [{ key: 'cloud', value: 'prod', operator: 'equals' }], + }, + }, + { + metadata: { name: 'grafana' }, + spec: { + title: 'Grafana', + filters: [{ key: 'app', value: 'grafana', operator: 'equals' }], + }, + }, + { + metadata: { name: 'mimir' }, + spec: { + title: 'Mimir', + filters: [{ key: 'app', value: 'mimir', operator: 'equals' }], + }, + }, + { + metadata: { name: 'loki' }, + spec: { + title: 'Loki', + filters: [{ key: 'app', value: 'loki', operator: 'equals' }], + }, + }, + { + metadata: { name: 'tempo' }, + spec: { + title: 'Tempo', + filters: [{ key: 'app', value: 'tempo', operator: 'equals' }], + }, + }, + { + metadata: { name: 'dev-env' }, + spec: { + title: 'Development', + filters: [{ key: 'environment', value: 'dev', operator: 'equals' }], + }, + }, + { + metadata: { name: 'prod-env' }, + spec: { + title: 'Production', + filters: [{ key: 'environment', value: 'prod', operator: 'equals' }], + }, + }, +]; + +const dashboardBindingsGenerator = ( + scopes: string[], + dashboards: Array<{ dashboardTitle: string; dashboardKey?: string; groups?: string[] }> +) => + scopes.reduce((scopeAcc, scopeTitle) => { + const scope = scopeTitle.toLowerCase().replaceAll(' ', '-').replaceAll('/', '-'); + + return [ + ...scopeAcc, + ...dashboards.reduce((acc, { dashboardTitle, groups, dashboardKey }, idx) => { + dashboardKey = dashboardKey ?? dashboardTitle.toLowerCase().replaceAll(' ', '-').replaceAll('/', '-'); + const group = !groups + ? '' + : groups.length === 1 + ? groups[0] === '' + ? '' + : `${groups[0].toLowerCase().replaceAll(' ', '-').replaceAll('/', '-')}-` + : `multiple${idx}-`; + const dashboard = `${group}${dashboardKey}`; + + return [ + ...acc, + { + metadata: { name: `${scope}-${dashboard}` }, + spec: { + dashboard, + scope, + }, + status: { + dashboardTitle, + groups, + }, + }, + ]; + }, []), + ]; + }, []); + +export const MOCK_SCOPE_DASHBOARD_BINDINGS: ScopeDashboardBinding[] = [ + ...dashboardBindingsGenerator( + ['Grafana'], + [ + { dashboardTitle: 'Data Sources', groups: ['General'] }, + { dashboardTitle: 'Usage', groups: ['General'] }, + { dashboardTitle: 'Frontend Errors', groups: ['Observability'] }, + { dashboardTitle: 'Frontend Logs', groups: ['Observability'] }, + { dashboardTitle: 'Backend Errors', groups: ['Observability'] }, + { dashboardTitle: 'Backend Logs', groups: ['Observability'] }, + { dashboardTitle: 'Usage Overview', groups: ['Usage'] }, + { dashboardTitle: 'Data Sources', groups: ['Usage'] }, + { dashboardTitle: 'Stats', groups: ['Usage'] }, + { dashboardTitle: 'Overview', groups: [''] }, + { dashboardTitle: 'Frontend' }, + { dashboardTitle: 'Stats' }, + ] + ), + ...dashboardBindingsGenerator( + ['Loki', 'Tempo', 'Mimir'], + [ + { dashboardTitle: 'Ingester', groups: ['Components', 'Investigations'] }, + { dashboardTitle: 'Distributor', groups: ['Components', 'Investigations'] }, + { dashboardTitle: 'Compacter', groups: ['Components', 'Investigations'] }, + { dashboardTitle: 'Datasource Errors', groups: ['Observability', 'Investigations'] }, + { dashboardTitle: 'Datasource Logs', groups: ['Observability', 'Investigations'] }, + { dashboardTitle: 'Overview' }, + { dashboardTitle: 'Stats', dashboardKey: 'another-stats' }, + ] + ), + ...dashboardBindingsGenerator( + ['Dev', 'Ops', 'Prod'], + [ + { dashboardTitle: 'Overview', groups: ['Cardinality Management'] }, + { dashboardTitle: 'Metrics', groups: ['Cardinality Management'] }, + { dashboardTitle: 'Labels', groups: ['Cardinality Management'] }, + { dashboardTitle: 'Overview', groups: ['Usage Insights'] }, + { dashboardTitle: 'Data Sources', groups: ['Usage Insights'] }, + { dashboardTitle: 'Query Errors', groups: ['Usage Insights'] }, + { dashboardTitle: 'Alertmanager', groups: ['Usage Insights'] }, + { dashboardTitle: 'Metrics Ingestion', groups: ['Usage Insights'] }, + { dashboardTitle: 'Billing/Usage' }, + ] + ), +]; + +export const MOCK_NODES: ScopeNode[] = [ + { + metadata: { name: 'applications' }, + spec: { + nodeType: 'container', + title: 'Applications', + description: 'Application Scopes', + parentName: '', + }, + }, + { + metadata: { name: 'cloud' }, + spec: { + nodeType: 'container', + title: 'Cloud', + description: 'Cloud Scopes', + disableMultiSelect: true, + linkType: 'scope', + linkId: 'cloud', + parentName: '', + }, + }, + { + metadata: { name: 'applications-grafana' }, + spec: { + nodeType: 'leaf', + title: 'Grafana', + description: 'Grafana', + linkType: 'scope', + linkId: 'grafana', + parentName: 'applications', + }, + }, + { + metadata: { name: 'applications-mimir' }, + spec: { + nodeType: 'leaf', + title: 'Mimir', + description: 'Mimir', + linkType: 'scope', + linkId: 'mimir', + parentName: 'applications', + }, + }, + { + metadata: { name: 'applications-loki' }, + spec: { + nodeType: 'leaf', + title: 'Loki', + description: 'Loki', + linkType: 'scope', + linkId: 'loki', + parentName: 'applications', + }, + }, + { + metadata: { name: 'applications-tempo' }, + spec: { + nodeType: 'leaf', + title: 'Tempo', + description: 'Tempo', + linkType: 'scope', + linkId: 'tempo', + parentName: 'applications', + }, + }, + { + metadata: { name: 'applications-cloud' }, + spec: { + nodeType: 'container', + title: 'Cloud', + description: 'Application/Cloud Scopes', + linkType: 'scope', + linkId: 'cloud', + parentName: 'applications', + }, + }, + { + metadata: { name: 'applications-cloud-dev' }, + spec: { + nodeType: 'leaf', + title: 'Dev', + description: 'Dev', + linkType: 'scope', + linkId: 'dev', + parentName: 'applications-cloud', + }, + }, + { + metadata: { name: 'applications-cloud-ops' }, + spec: { + nodeType: 'leaf', + title: 'Ops', + description: 'Ops', + linkType: 'scope', + linkId: 'ops', + parentName: 'applications-cloud', + }, + }, + { + metadata: { name: 'applications-cloud-prod' }, + spec: { + nodeType: 'leaf', + title: 'Prod', + description: 'Prod', + linkType: 'scope', + linkId: 'prod', + parentName: 'applications-cloud', + }, + }, + { + metadata: { name: 'cloud-dev' }, + spec: { + nodeType: 'leaf', + title: 'Dev', + description: 'Dev', + linkType: 'scope', + linkId: 'dev', + parentName: 'cloud', + }, + }, + { + metadata: { name: 'cloud-ops' }, + spec: { + nodeType: 'leaf', + title: 'Ops', + description: 'Ops', + linkType: 'scope', + linkId: 'ops', + parentName: 'cloud', + }, + }, + { + metadata: { name: 'cloud-prod' }, + spec: { + nodeType: 'leaf', + title: 'Prod', + description: 'Prod', + linkType: 'scope', + linkId: 'prod', + parentName: 'cloud', + }, + }, + { + metadata: { name: 'cloud-applications' }, + spec: { + nodeType: 'container', + title: 'Applications', + description: 'Cloud/Application Scopes', + parentName: 'cloud', + }, + }, + { + metadata: { name: 'cloud-applications-grafana' }, + spec: { + nodeType: 'leaf', + title: 'Grafana', + description: 'Grafana', + linkType: 'scope', + linkId: 'grafana', + parentName: 'cloud-applications', + }, + }, + { + metadata: { name: 'cloud-applications-mimir' }, + spec: { + nodeType: 'leaf', + title: 'Mimir', + description: 'Mimir', + linkType: 'scope', + linkId: 'mimir', + parentName: 'cloud-applications', + }, + }, + { + metadata: { name: 'cloud-applications-loki' }, + spec: { + nodeType: 'leaf', + title: 'Loki', + description: 'Loki', + linkType: 'scope', + linkId: 'loki', + parentName: 'cloud-applications', + }, + }, + { + metadata: { name: 'cloud-applications-tempo' }, + spec: { + nodeType: 'leaf', + title: 'Tempo', + description: 'Tempo', + linkType: 'scope', + linkId: 'tempo', + parentName: 'cloud-applications', + }, + }, + { + metadata: { name: 'environments' }, + spec: { + nodeType: 'container', + title: 'Environments', + description: 'Environment Scopes', + disableMultiSelect: true, + parentName: '', + }, + }, + { + metadata: { name: 'environments-dev' }, + spec: { + nodeType: 'container', + title: 'Development', + description: 'Development Environment', + linkType: 'scope', + linkId: 'dev-env', + parentName: 'environments', + }, + }, + { + metadata: { name: 'environments-prod' }, + spec: { + nodeType: 'container', + title: 'Production', + description: 'Production Environment', + linkType: 'scope', + linkId: 'prod-env', + parentName: 'environments', + }, + }, +]; + +export const MOCK_SUB_SCOPE_MIMIR_ITEMS: ScopeNavigation[] = [ + { + metadata: { name: 'mimir-item-1' }, + spec: { + scope: 'mimir', + url: '/d/mimir-dashboard-1', + }, + status: { + title: 'Mimir Dashboard 1', + groups: ['General'], + }, + }, + { + metadata: { name: 'mimir-item-2' }, + spec: { + scope: 'mimir', + url: '/d/mimir-dashboard-2', + }, + status: { + title: 'Mimir Dashboard 2', + groups: ['Observability'], + }, + }, +]; + +export const MOCK_SUB_SCOPE_LOKI_ITEMS: ScopeNavigation[] = [ + { + metadata: { name: 'loki-item-1' }, + spec: { + scope: 'loki', + url: '/d/loki-dashboard-1', + }, + status: { + title: 'Loki Dashboard 1', + groups: ['General'], + }, + }, +]; diff --git a/packages/grafana-test-utils/src/handlers/all-handlers.ts b/packages/grafana-test-utils/src/handlers/all-handlers.ts index 5fa473b55d5..83a34d7455f 100644 --- a/packages/grafana-test-utils/src/handlers/all-handlers.ts +++ b/packages/grafana-test-utils/src/handlers/all-handlers.ts @@ -12,6 +12,7 @@ import appPlatformDashboardv0alpha1Handlers from './apis/dashboard.grafana.app/v import appPlatformDashboardv1beta1Handlers from './apis/dashboard.grafana.app/v1beta1/handlers'; import appPlatformFolderv1beta1Handlers from './apis/folder.grafana.app/v1beta1/handlers'; import appPlatformIamv0alpha1Handlers from './apis/iam.grafana.app/v0alpha1/handlers'; +import appPlatformScopev0alpha1Handlers from './apis/scope.grafana.app/v0alpha1/handlers'; const allHandlers: HttpHandler[] = [ // Legacy handlers @@ -29,6 +30,7 @@ const allHandlers: HttpHandler[] = [ ...appPlatformFolderv1beta1Handlers, ...appPlatformIamv0alpha1Handlers, ...appPlatformCollectionsv1alpha1Handlers, + ...appPlatformScopev0alpha1Handlers, ]; export default allHandlers; diff --git a/packages/grafana-test-utils/src/handlers/apis/scope.grafana.app/v0alpha1/handlers.ts b/packages/grafana-test-utils/src/handlers/apis/scope.grafana.app/v0alpha1/handlers.ts new file mode 100644 index 00000000000..098548caad7 --- /dev/null +++ b/packages/grafana-test-utils/src/handlers/apis/scope.grafana.app/v0alpha1/handlers.ts @@ -0,0 +1,131 @@ +import { HttpResponse, http } from 'msw'; + +import { + MOCK_NODES, + MOCK_SCOPES, + MOCK_SCOPE_DASHBOARD_BINDINGS, + MOCK_SUB_SCOPE_LOKI_ITEMS, + MOCK_SUB_SCOPE_MIMIR_ITEMS, + ScopeNavigation, +} from '../../../../fixtures/scopes'; +import { getErrorResponse } from '../../../helpers'; + +const API_BASE = '/apis/scope.grafana.app/v0alpha1/namespaces/:namespace'; + +/** + * GET /apis/scope.grafana.app/v0alpha1/namespaces/:namespace/scopes/:name + * + * Fetches a single scope by name. + */ +const getScopeHandler = () => + http.get<{ namespace: string; name: string }>(`${API_BASE}/scopes/:name`, ({ params }) => { + const { name } = params; + const scope = MOCK_SCOPES.find((s) => s.metadata.name === name); + + if (!scope) { + return HttpResponse.json(getErrorResponse(`scopes.scope.grafana.app "${name}" not found`, 404), { + status: 404, + }); + } + + return HttpResponse.json(scope); + }); + +/** + * GET /apis/scope.grafana.app/v0alpha1/namespaces/:namespace/scopenodes/:name + * + * Fetches a single scope node by name. + */ +const getScopeNodeHandler = () => + http.get<{ namespace: string; name: string }>(`${API_BASE}/scopenodes/:name`, ({ params }) => { + const { name } = params; + const node = MOCK_NODES.find((n) => n.metadata.name === name); + + if (!node) { + return HttpResponse.json(getErrorResponse(`scopenodes.scope.grafana.app "${name}" not found`, 404), { + status: 404, + }); + } + + return HttpResponse.json(node); + }); + +/** + * GET /apis/scope.grafana.app/v0alpha1/namespaces/:namespace/find/scope_node_children + * + * Finds scope node children based on parent and query filters. + */ +const findScopeNodeChildrenHandler = () => + http.get(`${API_BASE}/find/scope_node_children`, ({ request }) => { + const url = new URL(request.url); + const parent = url.searchParams.get('parent') ?? ''; + const query = url.searchParams.get('query') ?? ''; + const limitParam = url.searchParams.get('limit'); + const names = url.searchParams.getAll('names'); + + let filtered = MOCK_NODES.filter( + (node) => node.spec.parentName === parent && node.spec.title.toLowerCase().includes(query.toLowerCase()) + ); + + if (names.length > 0) { + filtered = MOCK_NODES.filter((node) => names.includes(node.metadata.name)); + } + + if (limitParam) { + const limit = parseInt(limitParam, 10); + filtered = filtered.slice(0, limit); + } + + return HttpResponse.json({ + items: filtered, + }); + }); + +/** + * GET /apis/scope.grafana.app/v0alpha1/namespaces/:namespace/find/scope_dashboard_bindings + * + * Finds scope dashboard bindings for the given scope names. + */ +const findScopeDashboardBindingsHandler = () => + http.get(`${API_BASE}/find/scope_dashboard_bindings`, ({ request }) => { + const url = new URL(request.url); + const scopeNames = url.searchParams.getAll('scope'); + + const bindings = MOCK_SCOPE_DASHBOARD_BINDINGS.filter((b) => scopeNames.includes(b.spec.scope)); + + return HttpResponse.json({ + items: bindings, + }); + }); + +/** + * GET /apis/scope.grafana.app/v0alpha1/namespaces/:namespace/find/scope_navigations + * + * Finds scope navigations for the given scope names. + */ +const findScopeNavigationsHandler = () => + http.get(`${API_BASE}/find/scope_navigations`, ({ request }) => { + const url = new URL(request.url); + const scopeNames = url.searchParams.getAll('scope'); + + let items: ScopeNavigation[] = []; + + if (scopeNames.includes('mimir')) { + items = [...items, ...MOCK_SUB_SCOPE_MIMIR_ITEMS]; + } + if (scopeNames.includes('loki')) { + items = [...items, ...MOCK_SUB_SCOPE_LOKI_ITEMS]; + } + + return HttpResponse.json({ + items, + }); + }); + +export default [ + getScopeHandler(), + getScopeNodeHandler(), + findScopeNodeChildrenHandler(), + findScopeDashboardBindingsHandler(), + findScopeNavigationsHandler(), +]; diff --git a/packages/grafana-test-utils/src/unstable.ts b/packages/grafana-test-utils/src/unstable.ts index d03bc685d9e..698d57a774c 100644 --- a/packages/grafana-test-utils/src/unstable.ts +++ b/packages/grafana-test-utils/src/unstable.ts @@ -2,3 +2,12 @@ import { wellFormedTree } from './fixtures/folders'; export const getFolderFixtures = wellFormedTree; export { MOCK_TEAMS, MOCK_TEAM_GROUPS } from './fixtures/teams'; +export { + MOCK_SCOPES, + MOCK_NODES, + MOCK_SCOPE_DASHBOARD_BINDINGS, + MOCK_SUB_SCOPE_MIMIR_ITEMS, + MOCK_SUB_SCOPE_LOKI_ITEMS, +} from './fixtures/scopes'; +export { default as allHandlers } from './handlers/all-handlers'; +export { default as scopeHandlers } from './handlers/apis/scope.grafana.app/v0alpha1/handlers'; diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 478c44704ed..e7b908fc08c 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -137,23 +137,23 @@ "@babel/core": "7.28.0", "@faker-js/faker": "^9.0.0", "@rollup/plugin-node-resolve": "16.0.1", - "@storybook/addon-a11y": "^8.6.2", - "@storybook/addon-actions": "^8.6.2", - "@storybook/addon-docs": "^8.6.2", - "@storybook/addon-essentials": "^8.6.2", - "@storybook/addon-storysource": "^8.6.2", + "@storybook/addon-a11y": "^8.6.15", + "@storybook/addon-actions": "^8.6.15", + "@storybook/addon-docs": "^8.6.15", + "@storybook/addon-essentials": "^8.6.15", + "@storybook/addon-storysource": "^8.6.15", "@storybook/addon-webpack5-compiler-swc": "^2.1.0", - "@storybook/blocks": "^8.6.2", - "@storybook/components": "^8.6.2", - "@storybook/core-events": "^8.6.2", - "@storybook/manager-api": "^8.6.2", + "@storybook/blocks": "^8.6.15", + "@storybook/components": "^8.6.15", + "@storybook/core-events": "^8.6.15", + "@storybook/manager-api": "^8.6.15", "@storybook/mdx2-csf": "1.1.0", "@storybook/preset-scss": "1.0.3", - "@storybook/preview-api": "^8.6.2", - "@storybook/react": "^8.6.2", - "@storybook/react-webpack5": "^8.6.2", + "@storybook/preview-api": "^8.6.15", + "@storybook/react": "^8.6.15", + "@storybook/react-webpack5": "^8.6.15", "@storybook/test-runner": "^0.23.0", - "@storybook/theming": "^8.6.2", + "@storybook/theming": "^8.6.15", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "6.6.4", "@testing-library/react": "16.3.0", @@ -200,7 +200,7 @@ "rollup-plugin-node-externals": "^8.0.0", "rollup-plugin-svg-import": "3.0.0", "sass-loader": "16.0.5", - "storybook": "^8.6.2", + "storybook": "^8.6.15", "style-loader": "4.0.0", "typescript": "5.9.2", "webpack": "5.101.0" diff --git a/packages/grafana-ui/src/components/Drawer/Drawer.tsx b/packages/grafana-ui/src/components/Drawer/Drawer.tsx index 00039adfc3f..7d115e3f9d0 100644 --- a/packages/grafana-ui/src/components/Drawer/Drawer.tsx +++ b/packages/grafana-ui/src/components/Drawer/Drawer.tsx @@ -1,9 +1,7 @@ import { css, cx } from '@emotion/css'; +import { FloatingFocusManager, useFloating } from '@floating-ui/react'; import RcDrawer from '@rc-component/drawer'; -import { useDialog } from '@react-aria/dialog'; -import { FocusScope } from '@react-aria/focus'; -import { useOverlay } from '@react-aria/overlays'; -import { ReactNode, useCallback, useEffect, useState } from 'react'; +import { ReactNode, useCallback, useEffect, useId, useState } from 'react'; import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; @@ -81,17 +79,16 @@ export function Drawer({ const styles = useStyles2(getStyles); const wrapperStyles = useStyles2(getWrapperStyles, size); const dragStyles = useStyles2(getDragStyles); + const titleId = useId(); - const overlayRef = React.useRef(null); - const { dialogProps, titleProps } = useDialog({}, overlayRef); - const { overlayProps } = useOverlay( - { - isDismissable: false, - isOpen: true, - onClose, + const { context, refs } = useFloating({ + open: true, + onOpenChange: (open) => { + if (!open) { + onClose?.(); + } }, - overlayRef - ); + }); // Adds body class while open so the toolbar nav can hide some actions while drawer is open useBodyClassWhileOpen(); @@ -117,6 +114,8 @@ export function Drawer({ minWidth, }, }} + aria-label={typeof title === 'string' ? selectors.components.Drawer.General.title(title) : undefined} + aria-labelledby={typeof title !== 'string' ? titleId : undefined} width={''} motion={{ motionAppear: true, @@ -129,18 +128,8 @@ export function Drawer({ motionName: styles.maskMotion, }} > - -
        + +
        {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
        {typeof title === 'string' ? ( - + {title} {subtitle && ( @@ -169,13 +158,13 @@ export function Drawer({ )} ) : ( - title +
        {title}
        )} {tabs &&
        {tabs}
        }
        {!scrollableContent ? content : {content}}
        - +
        ); } diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx index c5c04a59cf6..cefa72df356 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.mdx @@ -117,6 +117,44 @@ export const MyComponent = () => { }; ``` +### Custom Header Rendering + +Column headers can be customized using strings, React elements, or renderer functions. The `header` property accepts any value that matches React Table's `Renderer` type. + +**Important:** When using custom header content, prefer inline elements (like ``) over block elements (like `
        `) to avoid layout issues. Block-level elements can cause extra spacing and alignment problems in table headers because they disrupt the table's inline flow. Use `display: inline-flex` or `display: inline-block` when you need flexbox or block-like behavior. + +```tsx +const columns: Array> = [ + // React element header + { + id: 'checkbox', + header: ( + <> + + + + ), + cell: () => , + }, + + // Function renderer header + { + id: 'firstName', + header: () => ( + + + First Name + + ), + }, + + // String header + { id: 'lastName', header: 'Last name' }, +]; +``` + ### Custom Cell Rendering Individual cells can be rendered using custom content dy defining a `cell` property on the column definition. diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx index e0df9d9782f..d2b65c531a4 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx @@ -3,8 +3,11 @@ import { useCallback, useMemo, useState } from 'react'; import { CellProps } from 'react-table'; import { LinkButton } from '../Button/Button'; +import { Checkbox } from '../Forms/Checkbox'; import { Field } from '../Forms/Field'; +import { Icon } from '../Icon/Icon'; import { Input } from '../Input/Input'; +import { Text } from '../Text/Text'; import { FetchDataArgs, InteractiveTable, InteractiveTableHeaderTooltip } from './InteractiveTable'; import mdx from './InteractiveTable.mdx'; @@ -297,4 +300,40 @@ export const WithControlledSort: StoryFn = (args) => { return ; }; +export const WithCustomHeader: TableStoryObj = { + args: { + columns: [ + // React element header + { + id: 'checkbox', + header: ( + <> + + + + ), + cell: () => , + }, + // Function renderer header + { + id: 'firstName', + header: () => ( + + + First Name + + ), + sortType: 'string', + }, + // String header + { id: 'lastName', header: 'Last name', sortType: 'string' }, + { id: 'car', header: 'Car', sortType: 'string' }, + { id: 'age', header: 'Age', sortType: 'number' }, + ], + data: pageableData.slice(0, 10), + getRowId: (r) => r.id, + }, +}; export default meta; diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx index 651532409af..d3bc9918ad4 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx @@ -2,6 +2,9 @@ import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; +import { Checkbox } from '../Forms/Checkbox'; +import { Icon } from '../Icon/Icon'; + import { InteractiveTable } from './InteractiveTable'; import { Column } from './types'; @@ -247,4 +250,104 @@ describe('InteractiveTable', () => { expect(fetchData).toHaveBeenCalledWith({ sortBy: [{ id: 'id', desc: false }] }); }); }); + + describe('custom header rendering', () => { + it('should render string headers', () => { + const columns: Array> = [{ id: 'id', header: 'ID' }]; + const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }]; + render(); + + expect(screen.getByRole('columnheader', { name: 'ID' })).toBeInTheDocument(); + }); + + it('should render React element headers', () => { + const columns: Array> = [ + { + id: 'checkbox', + header: ( + <> + + + + ), + cell: () => , + }, + ]; + const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }]; + render(); + + expect(screen.getByTestId('header-checkbox')).toBeInTheDocument(); + expect(screen.getByTestId('cell-checkbox')).toBeInTheDocument(); + expect(screen.getByLabelText('Select all rows')).toBeInTheDocument(); + expect(screen.getByLabelText('Select row')).toBeInTheDocument(); + expect(screen.getByText('Select all rows')).toBeInTheDocument(); + }); + + it('should render function renderer headers', () => { + const columns: Array> = [ + { + id: 'firstName', + header: () => ( + + + First Name + + ), + sortType: 'string', + }, + ]; + const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }]; + render(); + + expect(screen.getByTestId('header-icon')).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /first name/i })).toBeInTheDocument(); + }); + + it('should render all header types together', () => { + const columns: Array> = [ + { + id: 'checkbox', + header: ( + <> + + + + ), + cell: () => , + }, + { + id: 'id', + header: () => ( + + + ID + + ), + sortType: 'string', + }, + { id: 'country', header: 'Country', sortType: 'string' }, + { id: 'value', header: 'Value' }, + ]; + const data: TableData[] = [ + { id: '1', value: 'Value 1', country: 'Sweden' }, + { id: '2', value: 'Value 2', country: 'Norway' }, + ]; + render(); + + expect(screen.getByTestId('header-checkbox')).toBeInTheDocument(); + expect(screen.getByTestId('header-icon')).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Country' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Value' })).toBeInTheDocument(); + + // Verify data is rendered + expect(screen.getByText('Sweden')).toBeInTheDocument(); + expect(screen.getByText('Norway')).toBeInTheDocument(); + expect(screen.getByText('Value 1')).toBeInTheDocument(); + expect(screen.getByText('Value 2')).toBeInTheDocument(); + }); + }); }); diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx index d5a25e2e480..1b06c7daa87 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx @@ -153,6 +153,10 @@ interface BaseProps { * Optional way to set how the table is sorted from the beginning. Must be memoized. */ initialSortBy?: Array>; + /** + * Disable the ability to remove sorting on columns (none -> asc -> desc -> asc) + */ + disableSortRemove?: boolean; } interface WithExpandableRow extends BaseProps { @@ -191,6 +195,7 @@ export function InteractiveTable({ showExpandAll = false, fetchData, initialSortBy = [], + disableSortRemove, }: Props) { const styles = useStyles2(getStyles); const tableColumns = useMemo(() => { @@ -222,6 +227,7 @@ export function InteractiveTable({ disableMultiSort: true, // If fetchData is provided, we disable client-side sorting manualSortBy: Boolean(fetchData), + disableSortRemove, getRowId, initialState: { hiddenColumns: [ diff --git a/packages/grafana-ui/src/components/InteractiveTable/types.ts b/packages/grafana-ui/src/components/InteractiveTable/types.ts index 47263d4730e..f466e7d9c6c 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/types.ts +++ b/packages/grafana-ui/src/components/InteractiveTable/types.ts @@ -1,5 +1,5 @@ import { ReactNode } from 'react'; -import { CellProps, DefaultSortTypes, IdType, SortByFn } from 'react-table'; +import { CellProps, DefaultSortTypes, HeaderProps, IdType, Renderer, SortByFn } from 'react-table'; export interface Column { /** @@ -11,9 +11,9 @@ export interface Column { */ cell?: (props: CellProps) => ReactNode; /** - * Header name. if `undefined` the header will be empty. Useful for action columns. + * Header name. Can be a string, renderer function, or undefined. If `undefined` the header will be empty. Useful for action columns. */ - header?: string; + header?: Renderer>; /** * Column sort type. If `undefined` the column will not be sortable. * */ @@ -26,4 +26,8 @@ export interface Column { * If the provided function returns `false` the column will be hidden. */ visible?: (data: TableData[]) => boolean; + /** + * Determines starting sort direction when the column header is clicked. + */ + sortDescFirst?: boolean; } diff --git a/packages/grafana-ui/src/components/InteractiveTable/utils.ts b/packages/grafana-ui/src/components/InteractiveTable/utils.ts index 2b664b16f6d..050419fe1d1 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/utils.ts +++ b/packages/grafana-ui/src/components/InteractiveTable/utils.ts @@ -33,6 +33,7 @@ export function getColumns( disableSortBy: !Boolean(column.sortType), width: column.disableGrow ? 0 : undefined, visible: column.visible, + ...(column.sortDescFirst !== undefined && { sortDescFirst: column.sortDescFirst }), ...(column.cell && { Cell: column.cell }), })), ]; diff --git a/packages/grafana-ui/src/components/Modal/Modal.tsx b/packages/grafana-ui/src/components/Modal/Modal.tsx index aaeb2c3e426..f6ea73c3d31 100644 --- a/packages/grafana-ui/src/components/Modal/Modal.tsx +++ b/packages/grafana-ui/src/components/Modal/Modal.tsx @@ -1,9 +1,7 @@ import { cx } from '@emotion/css'; -import { useDialog } from '@react-aria/dialog'; -import { FocusScope } from '@react-aria/focus'; -import { OverlayContainer, useOverlay } from '@react-aria/overlays'; -import { PropsWithChildren, useRef, type JSX } from 'react'; -import * as React from 'react'; +import { FloatingFocusManager, useDismiss, useFloating, useInteractions, useRole } from '@floating-ui/react'; +import { OverlayContainer } from '@react-aria/overlays'; +import { PropsWithChildren, ReactNode, useId, type JSX } from 'react'; import { t } from '@grafana/i18n'; @@ -66,23 +64,26 @@ export function Modal(props: PropsWithChildren) { trapFocus = true, } = props; const styles = useStyles2(getModalStyles); + const titleId = useId(); - const ref = useRef(null); - - // Handle interacting outside the dialog and pressing - // the Escape key to close the modal. - const { overlayProps, underlayProps } = useOverlay( - { isKeyboardDismissDisabled: !closeOnEscape, isOpen, onClose: onDismiss }, - ref - ); - - // Get props for the dialog and its title - const { dialogProps, titleProps } = useDialog( - { - 'aria-label': ariaLabel, + const { context, refs } = useFloating({ + open: isOpen, + onOpenChange: (open) => { + if (!open) { + onDismiss?.(); + } }, - ref - ); + }); + + const dismiss = useDismiss(context, { + enabled: closeOnEscape, + }); + + const role = useRole(context, { + role: 'dialog', + }); + + const { getFloatingProps } = useInteractions([dismiss, role]); if (!isOpen) { return null; @@ -96,12 +97,17 @@ export function Modal(props: PropsWithChildren) { role="presentation" className={styles.modalBackdrop} onClick={onClickBackdrop || (closeOnBackdropClick ? onDismiss : undefined)} - {...underlayProps} /> - -
        + +
        - {typeof title === 'string' && } + {typeof title === 'string' && } { // FIXME: custom title components won't get an accessible title. // Do we really want to support them or shall we just limit this ModalTabsHeader? @@ -118,12 +124,12 @@ export function Modal(props: PropsWithChildren) {
        {children}
        - +
        ); } -function ModalButtonRow({ leftItems, children }: { leftItems?: React.ReactNode; children: React.ReactNode }) { +function ModalButtonRow({ leftItems, children }: { leftItems?: ReactNode; children: ReactNode }) { const styles = useStyles2(getModalStyles); if (leftItems) { diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx index 6d6d05047d2..f60fafe36f3 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx @@ -1,8 +1,9 @@ -import { useId, memo, HTMLAttributes, ReactNode, SVGProps } from 'react'; +import { useId, memo, HTMLAttributes, SVGProps } from 'react'; import { FieldDisplay } from '@grafana/data'; -import { getBarEndcapColors, getGradientCss, getEndpointMarkerColors } from './colors'; +import { RadialArcPathEndpointMarks } from './RadialArcPathEndpointMarks'; +import { getBarEndcapColors, getGradientCss } from './colors'; import { RadialShape, RadialGaugeDimensions, GradientStop } from './types'; import { drawRadialArcPath, toRad } from './utils'; @@ -29,11 +30,6 @@ interface RadialArcPathPropsWithGradient extends RadialArcPathPropsBase { type RadialArcPathProps = RadialArcPathPropsWithColor | RadialArcPathPropsWithGradient; -const ENDPOINT_MARKER_MIN_ANGLE = 10; -const DOT_OPACITY = 0.5; -const DOT_RADIUS_FACTOR = 0.4; -const MAX_DOT_RADIUS = 8; - export const RadialArcPath = memo( ({ arcLengthDeg, @@ -68,67 +64,25 @@ export const RadialArcPath = memo( const xEnd = centerX + radius * Math.cos(endRadians); const yEnd = centerY + radius * Math.sin(endRadians); - const dotRadius = - endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2; - const bgDivStyle: HTMLAttributes['style'] = { width: boxSize, height: vizHeight, marginLeft: boxX }; - const pathProps: SVGProps = {}; - let barEndcapColors: [string, string] | undefined; - let endpointMarks: ReactNode = null; if (isGradient) { bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape); - - if (endpointMarker && (rest.gradient?.length ?? 0) > 0) { - switch (endpointMarker) { - case 'point': - const [pointColorStart, pointColorEnd] = getEndpointMarkerColors( - rest.gradient!, - fieldDisplay.display.percent - ); - endpointMarks = ( - <> - {arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE && ( - - )} - - - ); - break; - case 'glow': - const offsetAngle = toRad(ENDPOINT_MARKER_MIN_ANGLE); - const xStartMark = centerX + radius * Math.cos(endRadians + offsetAngle); - const yStartMark = centerY + radius * Math.sin(endRadians + offsetAngle); - endpointMarks = - arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE ? ( - - ) : null; - break; - default: - break; - } - } - - if (barEndcaps) { - barEndcapColors = getBarEndcapColors(rest.gradient, fieldDisplay.display.percent); - } - pathProps.fill = 'none'; pathProps.stroke = 'white'; } else { bgDivStyle.backgroundColor = rest.color; - pathProps.fill = 'none'; pathProps.stroke = rest.color; } + let barEndcapColors: [string, string] | undefined; + if (barEndcaps) { + barEndcapColors = isGradient + ? getBarEndcapColors(rest.gradient, fieldDisplay.display.percent) + : [rest.color, rest.color]; + } + const pathEl = ( ); @@ -158,7 +112,23 @@ export const RadialArcPath = memo( )} - {endpointMarks} + {endpointMarker && ( + + )} ); } diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialArcPathEndpointMarks.test.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialArcPathEndpointMarks.test.tsx new file mode 100644 index 00000000000..af601e65c21 --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/RadialArcPathEndpointMarks.test.tsx @@ -0,0 +1,143 @@ +import { render, RenderResult } from '@testing-library/react'; + +import { FieldDisplay } from '@grafana/data'; + +import { RadialArcPathEndpointMarks, RadialArcPathEndpointMarksProps } from './RadialArcPathEndpointMarks'; +import { RadialGaugeDimensions } from './types'; + +const ser = new XMLSerializer(); + +const expectHTML = (result: RenderResult, expected: string) => { + let actual = ser.serializeToString(result.asFragment()).replace(/xmlns=".*?" /g, ''); + expect(actual).toEqual(expected.replace(/^\s*|\n/gm, '')); +}; + +describe('RadialArcPathEndpointMarks', () => { + const defaultDimensions = Object.freeze({ + centerX: 100, + centerY: 100, + radius: 80, + barWidth: 20, + vizWidth: 200, + vizHeight: 200, + margin: 10, + barIndex: 0, + thresholdsBarRadius: 0, + thresholdsBarWidth: 0, + thresholdsBarSpacing: 0, + scaleLabelsFontSize: 0, + scaleLabelsSpacing: 0, + scaleLabelsRadius: 0, + gaugeBottomY: 0, + }) satisfies RadialGaugeDimensions; + + const defaultFieldDisplay = Object.freeze({ + name: 'Test', + field: {}, + display: { text: '50', numeric: 50, color: '#FF0000' }, + hasLinks: false, + }) satisfies FieldDisplay; + + const defaultProps = Object.freeze({ + arcLengthDeg: 90, + dimensions: defaultDimensions, + fieldDisplay: defaultFieldDisplay, + startAngle: 0, + xStart: 100, + xEnd: 150, + yStart: 100, + yEnd: 50, + }) satisfies Omit; + + it('renders the expected marks when endpointMarker is "point" w/ a static color', () => { + expectHTML( + render( + + + + ), + '' + ); + }); + + it('renders the expected marks when endpointMarker is "point" w/ a gradient color', () => { + expectHTML( + render( + + + + ), + '' + ); + }); + + it('renders the expected marks when endpointMarker is "glow" w/ a static color', () => { + expectHTML( + render( + + + + ), + '' + ); + }); + + it('renders the expected marks when endpointMarker is "glow" w/ a gradient color', () => { + expectHTML( + render( + + + + ), + '' + ); + }); + + it('does not render the start mark when arcLengthDeg is less than the minimum angle for "point" endpointMarker', () => { + expectHTML( + render( + + + + ), + '' + ); + }); + + it('does not render anything when arcLengthDeg is less than the minimum angle for "glow" endpointMarker', () => { + expectHTML( + render( + + + + ), + '' + ); + }); + + it('does not render anything if endpointMarker is some other value', () => { + expectHTML( + render( + + {/* @ts-ignore: confirming the component doesn't throw */} + + + ), + '' + ); + }); +}); diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialArcPathEndpointMarks.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialArcPathEndpointMarks.tsx new file mode 100644 index 00000000000..7bd9a8c435f --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/RadialArcPathEndpointMarks.tsx @@ -0,0 +1,98 @@ +import { FieldDisplay } from '@grafana/data'; + +import { getEndpointMarkerColors, getGuideDotColor } from './colors'; +import { GradientStop, RadialGaugeDimensions } from './types'; +import { toRad } from './utils'; + +interface RadialArcPathEndpointMarksPropsBase { + arcLengthDeg: number; + dimensions: RadialGaugeDimensions; + fieldDisplay: FieldDisplay; + endpointMarker: 'point' | 'glow'; + roundedBars?: boolean; + startAngle: number; + glowFilter?: string; + endpointMarkerGlowFilter?: string; + xStart: number; + xEnd: number; + yStart: number; + yEnd: number; +} + +interface RadialArcPathEndpointMarksPropsWithColor extends RadialArcPathEndpointMarksPropsBase { + color: string; +} + +interface RadialArcPathEndpointMarksPropsWithGradient extends RadialArcPathEndpointMarksPropsBase { + gradient: GradientStop[]; +} + +export type RadialArcPathEndpointMarksProps = + | RadialArcPathEndpointMarksPropsWithColor + | RadialArcPathEndpointMarksPropsWithGradient; + +const ENDPOINT_MARKER_MIN_ANGLE = 10; +const DOT_OPACITY = 0.5; +const DOT_RADIUS_FACTOR = 0.4; +const MAX_DOT_RADIUS = 8; + +export function RadialArcPathEndpointMarks({ + startAngle: angle, + arcLengthDeg, + dimensions, + endpointMarker, + fieldDisplay, + xStart, + xEnd, + yStart, + yEnd, + roundedBars, + endpointMarkerGlowFilter, + glowFilter, + ...rest +}: RadialArcPathEndpointMarksProps) { + const isGradient = 'gradient' in rest; + const { radius, centerX, centerY, barWidth } = dimensions; + const endRadians = toRad(angle + arcLengthDeg); + + switch (endpointMarker) { + case 'point': { + const [pointColorStart, pointColorEnd] = isGradient + ? getEndpointMarkerColors(rest.gradient, fieldDisplay.display.percent) + : [getGuideDotColor(rest.color), getGuideDotColor(rest.color)]; + + const dotRadius = + endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2; + + return ( + <> + {arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE && ( + + )} + + + ); + } + case 'glow': + const offsetAngle = toRad(ENDPOINT_MARKER_MIN_ANGLE); + const xStartMark = centerX + radius * Math.cos(endRadians + offsetAngle); + const yStartMark = centerY + radius * Math.sin(endRadians + offsetAngle); + if (arcLengthDeg <= ENDPOINT_MARKER_MIN_ANGLE) { + break; + } + return ( + + ); + default: + break; + } + + return null; +} diff --git a/packages/grafana-ui/src/components/RadialGauge/colors.ts b/packages/grafana-ui/src/components/RadialGauge/colors.ts index 3eb81c10899..a05817a7a54 100644 --- a/packages/grafana-ui/src/components/RadialGauge/colors.ts +++ b/packages/grafana-ui/src/components/RadialGauge/colors.ts @@ -175,7 +175,7 @@ export function getGradientCss(gradientStops: GradientStop[], shape: RadialShape const GRAY_05 = '#111217'; const GRAY_90 = '#fbfbfb'; const CONTRAST_THRESHOLD_MAX = 4.5; -const getGuideDotColor = (color: string): string => { +export const getGuideDotColor = (color: string): string => { const darkColor = GRAY_05; const lightColor = GRAY_90; return colorManipulator.getContrastRatio(darkColor, color) >= CONTRAST_THRESHOLD_MAX ? darkColor : lightColor; diff --git a/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.tsx b/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.tsx index a10ad157120..5b919c05ec2 100644 --- a/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.tsx +++ b/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.tsx @@ -14,6 +14,8 @@ export type Props = React.ComponentProps & { isConfigured: boolean; /** Called when the user clicks on the "Reset" button in order to clear the secret */ onReset: () => void; + /** If true, the text area will grow to fill available width. */ + grow?: boolean; }; export const CONFIGURED_TEXT = 'configured'; @@ -35,11 +37,11 @@ const getStyles = (theme: GrafanaTheme2) => { * * https://developers.grafana.com/ui/latest/index.html?path=/docs/inputs-secrettextarea--docs */ -export const SecretTextArea = ({ isConfigured, onReset, ...props }: Props) => { +export const SecretTextArea = ({ isConfigured, onReset, grow, ...props }: Props) => { const styles = useStyles2(getStyles); return ( - + {!isConfigured && } {isConfigured && (
        +

        This demonstrates using Toggletip inside a Drawer.

        + + + + + +
        + } + footer="Focus should work correctly within this Toggletip" + placement="bottom-start" + > + + +
        + + )} + + ); +}; + +InsideDrawer.parameters = { + controls: { + hideNoControlsWarning: true, + exclude: ['title', 'content', 'footer', 'children', 'placement', 'theme', 'closeButton', 'portalRoot'], + }, +}; + +export const InsideModal: StoryFn = () => { + const [isModalOpen, setIsModalOpen] = useState(false); + + return ( + <> + + setIsModalOpen(false)}> +
        +

        This demonstrates using Toggletip inside a Modal.

        + + + + + + +
        + } + footer="Focus should work correctly within this Toggletip" + placement="bottom-start" + > + + + +
        + + + ); +}; + +InsideDrawer.parameters = { + controls: { + hideNoControlsWarning: true, + exclude: ['title', 'content', 'footer', 'children', 'placement', 'theme', 'closeButton', 'portalRoot'], + }, +}; + export default meta; diff --git a/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx index b32145da0da..04718af9493 100644 --- a/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx +++ b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx @@ -23,7 +23,7 @@ export interface UsersIndicatorProps { * https://developers.grafana.com/ui/latest/index.html?path=/docs/iconography-usersindicator--docs */ export const UsersIndicator = ({ users, onClick, limit = 4 }: UsersIndicatorProps) => { - const styles = useStyles2(getStyles); + const styles = useStyles2(getStyles, limit); if (!users.length) { return null; } @@ -39,6 +39,9 @@ export const UsersIndicator = ({ users, onClick, limit = 4 }: UsersIndicatorProp className={styles.container} aria-label={t('grafana-ui.users-indicator.container-label', 'Users indicator container')} > + {users.slice(0, limitReached ? limit : limit + 1).map((userView, idx, arr) => ( + + ))} {limitReached && ( {tooManyUsers @@ -47,26 +50,30 @@ export const UsersIndicator = ({ users, onClick, limit = 4 }: UsersIndicatorProp : `+${extraUsers}`} )} - {users - .slice(0, limitReached ? limit : limit + 1) - .reverse() - .map((userView) => ( - - ))}
        ); }; -const getStyles = (theme: GrafanaTheme2) => { +const getStyles = (theme: GrafanaTheme2, limit: number) => { return { container: css({ display: 'flex', justifyContent: 'center', - flexDirection: 'row-reverse', marginLeft: theme.spacing(1), + isolation: 'isolate', '& > button': { marginLeft: theme.spacing(-1), // Overlay the elements a bit on top of each other + + // Ensure overlaying user icons are stacked correctly with z-index on each element + ...Object.fromEntries( + Array.from({ length: limit }).map((_, idx) => [ + `&:nth-of-type(${idx + 1})`, + { + zIndex: limit - idx, + }, + ]) + ), }, }), dots: css({ diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.test.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.test.tsx deleted file mode 100644 index 131133bcdfb..00000000000 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.test.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { render, screen } from '@testing-library/react'; - -import { VizLegendTable } from './VizLegendTable'; -import { VizLegendItem } from './types'; - -describe('VizLegendTable', () => { - const mockItems: VizLegendItem[] = [ - { label: 'Series 1', color: 'red', yAxis: 1 }, - { label: 'Series 2', color: 'blue', yAxis: 1 }, - { label: 'Series 3', color: 'green', yAxis: 1 }, - ]; - - it('renders without crashing', () => { - const { container } = render(); - expect(container.querySelector('table')).toBeInTheDocument(); - }); - - it('renders all items', () => { - render(); - expect(screen.getByText('Series 1')).toBeInTheDocument(); - expect(screen.getByText('Series 2')).toBeInTheDocument(); - expect(screen.getByText('Series 3')).toBeInTheDocument(); - }); - - it('renders table headers when items have display values', () => { - const itemsWithStats: VizLegendItem[] = [ - { - label: 'Series 1', - color: 'red', - yAxis: 1, - getDisplayValues: () => [ - { numeric: 100, text: '100', title: 'Max' }, - { numeric: 50, text: '50', title: 'Min' }, - ], - }, - ]; - render(); - expect(screen.getByText('Max')).toBeInTheDocument(); - expect(screen.getByText('Min')).toBeInTheDocument(); - }); - - it('renders sort icon when sorted', () => { - const { container } = render( - - ); - expect(container.querySelector('svg')).toBeInTheDocument(); - }); - - it('calls onToggleSort when header is clicked', () => { - const onToggleSort = jest.fn(); - render(); - const header = screen.getByText('Name'); - header.click(); - expect(onToggleSort).toHaveBeenCalledWith('Name'); - }); - - it('does not call onToggleSort when not sortable', () => { - const onToggleSort = jest.fn(); - render(); - const header = screen.getByText('Name'); - header.click(); - expect(onToggleSort).not.toHaveBeenCalled(); - }); - - it('renders with long labels', () => { - const itemsWithLongLabels: VizLegendItem[] = [ - { - label: 'This is a very long series name that should be scrollable within its table cell', - color: 'red', - yAxis: 1, - }, - ]; - render(); - expect( - screen.getByText('This is a very long series name that should be scrollable within its table cell') - ).toBeInTheDocument(); - }); -}); diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx index 0c2859453eb..b654a2d3ac6 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx @@ -119,6 +119,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ table: css({ width: '100%', 'th:first-child': { + width: '100%', borderBottom: `1px solid ${theme.colors.border.weak}`, }, }), diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.test.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.test.tsx deleted file mode 100644 index 4ca95aa395c..00000000000 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.test.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { render, screen } from '@testing-library/react'; - -import { LegendTableItem } from './VizLegendTableItem'; -import { VizLegendItem } from './types'; - -describe('LegendTableItem', () => { - const mockItem: VizLegendItem = { - label: 'Series 1', - color: 'red', - yAxis: 1, - }; - - it('renders without crashing', () => { - const { container } = render( - - - - -
        - ); - expect(container.querySelector('tr')).toBeInTheDocument(); - }); - - it('renders label text', () => { - render( - - - - -
        - ); - expect(screen.getByText('Series 1')).toBeInTheDocument(); - }); - - it('renders with long label text', () => { - const longLabelItem: VizLegendItem = { - ...mockItem, - label: 'This is a very long series name that should be scrollable in the table cell', - }; - render( - - - - -
        - ); - expect( - screen.getByText('This is a very long series name that should be scrollable in the table cell') - ).toBeInTheDocument(); - }); - - it('renders stat values when provided', () => { - const itemWithStats: VizLegendItem = { - ...mockItem, - getDisplayValues: () => [ - { numeric: 100, text: '100', title: 'Max' }, - { numeric: 50, text: '50', title: 'Min' }, - ], - }; - render( - - - - -
        - ); - expect(screen.getByText('100')).toBeInTheDocument(); - expect(screen.getByText('50')).toBeInTheDocument(); - }); - - it('renders right y-axis indicator when yAxis is 2', () => { - const rightAxisItem: VizLegendItem = { - ...mockItem, - yAxis: 2, - }; - render( - - - - -
        - ); - expect(screen.getByText('(right y-axis)')).toBeInTheDocument(); - }); - - it('calls onLabelClick when label is clicked', () => { - const onLabelClick = jest.fn(); - render( - - - - -
        - ); - const button = screen.getByRole('button'); - button.click(); - expect(onLabelClick).toHaveBeenCalledWith(mockItem, expect.any(Object)); - }); - - it('does not call onClick when readonly', () => { - const onLabelClick = jest.fn(); - render( - - - - -
        - ); - const button = screen.getByRole('button'); - expect(button).toBeDisabled(); - }); -}); diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx index 56ec6cb733e..335cf4309e9 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx @@ -69,7 +69,7 @@ export const LegendTableItem = ({ return ( - + -
        - -
        +
        {item.getDisplayValues && @@ -130,27 +128,6 @@ const getStyles = (theme: GrafanaTheme2) => { background: rowHoverBg, }, }), - labelCell: css({ - label: 'LegendLabelCell', - maxWidth: 0, - width: '100%', - }), - labelCellInner: css({ - label: 'LegendLabelCellInner', - display: 'block', - flex: 1, - minWidth: 0, - overflowX: 'auto', - overflowY: 'hidden', - paddingRight: theme.spacing(3), - scrollbarWidth: 'none', - msOverflowStyle: 'none', - maskImage: `linear-gradient(to right, black calc(100% - ${theme.spacing(3)}), transparent 100%)`, - WebkitMaskImage: `linear-gradient(to right, black calc(100% - ${theme.spacing(3)}), transparent 100%)`, - '&::-webkit-scrollbar': { - display: 'none', - }, - }), label: css({ label: 'LegendLabel', whiteSpace: 'nowrap', @@ -158,6 +135,9 @@ const getStyles = (theme: GrafanaTheme2) => { border: 'none', fontSize: 'inherit', padding: 0, + maxWidth: '600px', + textOverflow: 'ellipsis', + overflow: 'hidden', userSelect: 'text', }), labelDisabled: css({ diff --git a/packaging/docker/run.sh b/packaging/docker/run.sh index a4c91b49379..148d7ccb30f 100755 --- a/packaging/docker/run.sh +++ b/packaging/docker/run.sh @@ -1,4 +1,5 @@ -#!/bin/bash -e +#!/bin/bash +set -e PERMISSIONS_OK=0 @@ -26,14 +27,14 @@ if [ ! -d "$GF_PATHS_PLUGINS" ]; then fi if [ ! -z ${GF_AWS_PROFILES+x} ]; then - > "$GF_PATHS_HOME/.aws/credentials" + :> "$GF_PATHS_HOME/.aws/credentials" for profile in ${GF_AWS_PROFILES}; do access_key_varname="GF_AWS_${profile}_ACCESS_KEY_ID" secret_key_varname="GF_AWS_${profile}_SECRET_ACCESS_KEY" region_varname="GF_AWS_${profile}_REGION" - if [ ! -z "${!access_key_varname}" -a ! -z "${!secret_key_varname}" ]; then + if [ ! -z "${!access_key_varname}" ] && [ ! -z "${!secret_key_varname}" ]; then echo "[${profile}]" >> "$GF_PATHS_HOME/.aws/credentials" echo "aws_access_key_id = ${!access_key_varname}" >> "$GF_PATHS_HOME/.aws/credentials" echo "aws_secret_access_key = ${!secret_key_varname}" >> "$GF_PATHS_HOME/.aws/credentials" diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index a560ff47c5d..2e3b955a247 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -795,6 +795,10 @@ func (hs *HTTPServer) GetDashboardVersion(c *contextmodel.ReqContext) response.R // swagger:route POST /dashboards/uid/{uid}/restore dashboards versions restoreDashboardVersionByUID // // Restore a dashboard to a given dashboard version using UID. +// This API will be removed when /apis/dashboards.grafana.app/v1 is released. +// You can restore a dashboard by reading it from history, then creating it again. +// +// Deprecated: true // // Responses: // 200: postDashboardResponse diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index 49194a625f4..62035a0e749 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -76,21 +76,27 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *contextmodel.ReqContext) { return } - // Do not check permissions when the instance snapshot public mode is enabled - if !hs.Cfg.SnapshotPublicMode { - evaluator := ac.EvalAll(ac.EvalPermission(dashboards.ActionSnapshotsCreate), ac.EvalPermission(dashboards.ActionDashboardsRead, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(cmd.Dashboard.GetNestedString("uid")))) - if canSave, err := hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator); err != nil || !canSave { - c.JsonApiErr(http.StatusForbidden, "forbidden", err) - return - } - } - - dashboardsnapshots.CreateDashboardSnapshot(c, snapshot.SnapshotSharingOptions{ + cfg := snapshot.SnapshotSharingOptions{ SnapshotsEnabled: hs.Cfg.SnapshotEnabled, ExternalEnabled: hs.Cfg.ExternalEnabled, ExternalSnapshotName: hs.Cfg.ExternalSnapshotName, ExternalSnapshotURL: hs.Cfg.ExternalSnapshotUrl, - }, cmd, hs.dashboardsnapshotsService) + } + + if hs.Cfg.SnapshotPublicMode { + // Public mode: no user or dashboard validation needed + dashboardsnapshots.CreateDashboardSnapshotPublic(c, cfg, cmd, hs.dashboardsnapshotsService) + return + } + + // Regular mode: check permissions + evaluator := ac.EvalAll(ac.EvalPermission(dashboards.ActionSnapshotsCreate), ac.EvalPermission(dashboards.ActionDashboardsRead, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(cmd.Dashboard.GetNestedString("uid")))) + if canSave, err := hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator); err != nil || !canSave { + c.JsonApiErr(http.StatusForbidden, "forbidden", err) + return + } + + dashboardsnapshots.CreateDashboardSnapshot(c, cfg, cmd, hs.dashboardsnapshotsService) } // GET /api/snapshots/:key @@ -213,13 +219,6 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *contextmodel.ReqContext) respon return response.Error(http.StatusUnauthorized, "OrgID mismatch", nil) } - if queryResult.External { - err := dashboardsnapshots.DeleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL) - if err != nil { - return response.Error(http.StatusInternalServerError, "Failed to delete external dashboard", err) - } - } - // Dashboard can be empty (creation error or external snapshot). This means that the mustInt here returns a 0, // which before RBAC would result in a dashboard which has no ACL. A dashboard without an ACL would fallback // to the user’s org role, which for editors and admins would essentially always be allowed here. With RBAC, @@ -239,6 +238,13 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *contextmodel.ReqContext) respon } } + if queryResult.External { + err := dashboardsnapshots.DeleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to delete external dashboard", err) + } + } + cmd := &dashboardsnapshots.DeleteDashboardSnapshotCommand{DeleteKey: queryResult.DeleteKey} if err := hs.dashboardsnapshotsService.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil { diff --git a/pkg/api/dtos/prefs.go b/pkg/api/dtos/prefs.go index cc7da29b550..252810d8bd4 100644 --- a/pkg/api/dtos/prefs.go +++ b/pkg/api/dtos/prefs.go @@ -13,7 +13,7 @@ type UpdatePrefsCmd struct { // Deprecated: Use HomeDashboardUID instead HomeDashboardID int64 `json:"homeDashboardId"` HomeDashboardUID *string `json:"homeDashboardUID,omitempty"` - // Enum: utc,browser + // Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string Timezone string `json:"timezone"` WeekStart string `json:"weekStart"` QueryHistory *pref.QueryHistoryPreference `json:"queryHistory,omitempty"` @@ -31,7 +31,7 @@ type PatchPrefsCmd struct { // Default:0 // Deprecated: Use HomeDashboardUID instead HomeDashboardID *int64 `json:"homeDashboardId,omitempty"` - // Enum: utc,browser + // Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string Timezone *string `json:"timezone,omitempty"` WeekStart *string `json:"weekStart,omitempty"` Language *string `json:"language,omitempty"` diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index de38697f613..d0c8a58ebd8 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -32,6 +32,8 @@ import ( var ( logger = glog.New("data-proxy-log") client = newHTTPClient() + + errPluginProxyRouteAccessDenied = errors.New("plugin proxy route access denied") ) type DataSourceProxy struct { @@ -308,12 +310,21 @@ func (proxy *DataSourceProxy) validateRequest() error { if err != nil { return err } + // issues/116273: When we have an empty input route (or input that becomes relative to "."), we do not want it + // to be ".". This is because the `CleanRelativePath` function will never return "./" prefixes, and as such, + // the common prefix we need is an empty string. + if r1 == "." && proxy.proxyPath != "." { + r1 = "" + } + if r2 == "." && route.Path != "." { + r2 = "" + } if !strings.HasPrefix(r1, r2) { continue } if !proxy.hasAccessToRoute(route) { - return errors.New("plugin proxy route access denied") + return errPluginProxyRouteAccessDenied } proxy.matchedRoute = route diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index 002965a1606..38fd0006d4d 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -673,6 +673,94 @@ func TestIntegrationDataSourceProxy_routeRule(t *testing.T) { runDatasourceAuthTest(t, secretsService, secretsStore, cfg, test) } }) + + t.Run("Regression of 116273: Fallback routes should apply fallback route roles", func(t *testing.T) { + for _, tc := range []struct { + InputPath string + ConfigurationPath string + ExpectError bool + }{ + { + InputPath: "api/v2/leak-ur-secrets", + ConfigurationPath: "", + ExpectError: true, + }, + { + InputPath: "", + ConfigurationPath: "", + ExpectError: true, + }, + { + InputPath: ".", + ConfigurationPath: ".", + ExpectError: true, + }, + { + InputPath: "", + ConfigurationPath: ".", + ExpectError: false, + }, + { + InputPath: "api", + ConfigurationPath: ".", + ExpectError: false, + }, + } { + orEmptyStr := func(s string) string { + if s == "" { + return "" + } + return s + } + t.Run( + fmt.Sprintf("with inputPath=%s, configurationPath=%s, expectError=%v", + orEmptyStr(tc.InputPath), orEmptyStr(tc.ConfigurationPath), tc.ExpectError), + func(t *testing.T) { + ds := &datasources.DataSource{ + UID: "dsUID", + JsonData: simplejson.New(), + } + routes := []*plugins.Route{ + { + Path: tc.ConfigurationPath, + ReqRole: org.RoleAdmin, + Method: "GET", + }, + { + Path: tc.ConfigurationPath, + ReqRole: org.RoleAdmin, + Method: "POST", + }, + { + Path: tc.ConfigurationPath, + ReqRole: org.RoleAdmin, + Method: "PUT", + }, + { + Path: tc.ConfigurationPath, + ReqRole: org.RoleAdmin, + Method: "DELETE", + }, + } + + req, err := http.NewRequestWithContext(t.Context(), "GET", "http://localhost/"+tc.InputPath, nil) + require.NoError(t, err, "failed to create HTTP request") + ctx := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{OrgRole: org.RoleViewer}, + } + proxy, err := setupDSProxyTest(t, ctx, ds, routes, tc.InputPath) + require.NoError(t, err, "failed to setup proxy test") + err = proxy.validateRequest() + if tc.ExpectError { + require.ErrorIs(t, err, errPluginProxyRouteAccessDenied, "request was not denied due to access denied?") + } else { + require.NoError(t, err, "request was unexpectedly denied access") + } + }, + ) + } + }) } // test DataSourceProxy request handling. diff --git a/pkg/api/preferences.go b/pkg/api/preferences.go index 785b69f2b7c..9650b2d92c2 100644 --- a/pkg/api/preferences.go +++ b/pkg/api/preferences.go @@ -134,6 +134,10 @@ func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, te return response.Error(http.StatusBadRequest, "Invalid theme", nil) } + if dtoCmd.Timezone != nil && !pref.IsValidTimezone(*dtoCmd.Timezone) { + return response.Error(http.StatusBadRequest, "Invalid timezone. Must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string", nil) + } + // convert dashboard UID to ID in order to store internally if it exists in the query, otherwise take the id from query // nolint:staticcheck dashboardID := dtoCmd.HomeDashboardID diff --git a/pkg/apiserver/registry/generic/storage.go b/pkg/apiserver/registry/generic/storage.go index 98e2f1fe9df..adbe54f5a1f 100644 --- a/pkg/apiserver/registry/generic/storage.go +++ b/pkg/apiserver/registry/generic/storage.go @@ -1,26 +1,55 @@ package generic import ( + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/generic" "k8s.io/apiserver/pkg/registry/generic/registry" + "k8s.io/apiserver/pkg/storage" "github.com/grafana/grafana/pkg/apimachinery/utils" ) +// SelectableFieldsOptions allows customizing field selector behavior for a resource. +type SelectableFieldsOptions struct { + // GetAttrs returns labels and fields for the object. + // If nil, the default GetAttrs is used which only exposes metadata.name. + GetAttrs func(obj runtime.Object) (labels.Set, fields.Set, error) +} + func NewRegistryStore(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, optsGetter generic.RESTOptionsGetter) (*registry.Store, error) { + return NewRegistryStoreWithSelectableFields(scheme, resourceInfo, optsGetter, SelectableFieldsOptions{}) +} + +// NewRegistryStoreWithSelectableFields creates a registry store with custom selectable fields support. +// Use this when you need to filter resources by custom fields like spec.connection.name. +func NewRegistryStoreWithSelectableFields(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, optsGetter generic.RESTOptionsGetter, fieldOpts SelectableFieldsOptions) (*registry.Store, error) { gv := resourceInfo.GroupVersion() gv.Version = runtime.APIVersionInternal strategy := NewStrategy(scheme, gv) if resourceInfo.IsClusterScoped() { strategy = strategy.WithClusterScope() } + + // Use custom GetAttrs if provided, otherwise use default + var attrFunc storage.AttrFunc + var predicateFunc func(label labels.Selector, field fields.Selector) storage.SelectionPredicate + if fieldOpts.GetAttrs != nil { + attrFunc = fieldOpts.GetAttrs + // Pass nil predicateFunc to use default behavior with custom attrFunc + predicateFunc = nil + } else { + attrFunc = GetAttrs + predicateFunc = Matcher + } + store := ®istry.Store{ NewFunc: resourceInfo.NewFunc, NewListFunc: resourceInfo.NewListFunc, KeyRootFunc: KeyRootFunc(resourceInfo.GroupResource()), KeyFunc: NamespaceKeyFunc(resourceInfo.GroupResource()), - PredicateFunc: Matcher, + PredicateFunc: predicateFunc, DefaultQualifiedResource: resourceInfo.GroupResource(), SingularQualifiedResource: resourceInfo.SingularGroupResource(), TableConvertor: resourceInfo.TableConverter(), @@ -28,7 +57,7 @@ func NewRegistryStore(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, o UpdateStrategy: strategy, DeleteStrategy: strategy, } - options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: GetAttrs} + options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: attrFunc} if err := store.CompleteWithOptions(options); err != nil { return nil, err } diff --git a/pkg/build/e2e/README.md b/pkg/build/e2e/README.md new file mode 100644 index 00000000000..3edc6946727 --- /dev/null +++ b/pkg/build/e2e/README.md @@ -0,0 +1,20 @@ +## Build artifacts + +Put the resulting tar in your `grafana` OSS path: +```sh +go -C grafana run ./pkg/build/cmd artifacts -a targz:enterprise:linux/amd64 --alpine-base=alpine:3.22 --tag-format='{{ .version }}-{{ .buildID }}-{{ .arch }}' --grafana-dir="${PWD}/grafana" --enterprise-dir="${PWD}/grafana-enterprise" +``` + +Also build the e2e test runner: +```sh +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o ./e2e-runner ./e2e/ +``` + +And then `chmod +x ./e2e-runner`. + +## Running tests + +Reporting tests with Image Renderer: +```sh +go run ./pkg/build/e2e --suite=e2e/extensions/enterprise/smtp-suite --license=e2e/extensions/enterprise/license.jwt --image-renderer +``` diff --git a/pkg/build/e2e/main.go b/pkg/build/e2e/main.go index 976a7883bdc..7cd85ad2825 100644 --- a/pkg/build/e2e/main.go +++ b/pkg/build/e2e/main.go @@ -138,6 +138,10 @@ func run(ctx context.Context, cmd *cli.Command) error { } if code != 0 { + if stdout, _ := c.Stdout(ctx); len(stdout) > 0 { + log.Printf("e2e test suite stdout:\n%s", stdout) + } + return fmt.Errorf("e2e tests failed with exit code %d", code) } diff --git a/pkg/build/e2e/run.go b/pkg/build/e2e/run.go index e5d36d34b7b..c9bf85df3c8 100644 --- a/pkg/build/e2e/run.go +++ b/pkg/build/e2e/run.go @@ -8,10 +8,10 @@ import ( func RunSuite(d *dagger.Client, svc *dagger.Service, src *dagger.Directory, cache *dagger.CacheVolume, suite, runnerFlags string) *dagger.Container { command := fmt.Sprintf( - "./e2e-runner cypress --start-grafana=false --cypress-video"+ + "./e2e-runner cypress --browser=electron --start-grafana=false --cypress-video"+ " --grafana-base-url http://grafana:3001 --suite %s %s", suite, runnerFlags) - return WithYarnCache(WithGrafanaFrontend(d.Container().From("cypress/included:13.1.0"), src), cache). + return WithYarnCache(WithGrafanaFrontend(d.Container().From("cypress/included:14.3.2"), src), cache). WithWorkdir("/src"). WithServiceBinding("grafana", svc). WithExec([]string{"yarn", "install", "--immutable"}). diff --git a/pkg/build/e2e/service.go b/pkg/build/e2e/service.go index 31463f63783..f55bd765f3f 100644 --- a/pkg/build/e2e/service.go +++ b/pkg/build/e2e/service.go @@ -99,13 +99,15 @@ func GrafanaService(ctx context.Context, d *dagger.Client, opts GrafanaServiceOp } if opts.StartImageRenderer { - container = container.WithEnvVariable("START_IMAGE_RENDERER", "true"). - WithExec([]string{"apt-get", "update"}). - WithExec([]string{"apt-get", "install", "-y", "ca-certificates"}) + imageRendererSvc := d.Container().From("grafana/grafana-image-renderer:" + opts.ImageRendererVersion). + WithExposedPort(8081). + AsService() - if opts.ImageRendererVersion != "" { - container = container.WithEnvVariable("IMAGE_RENDERER_VERSION", opts.ImageRendererVersion) - } + container = container.WithServiceBinding("image-renderer", imageRendererSvc). + WithExec([]string{"apt-get", "update"}). + WithExec([]string{"apt-get", "install", "-y", "ca-certificates"}). + WithEnvVariable("GF_RENDERING_CALLBACK_URL", "http://grafana:3001/"). + WithEnvVariable("GF_RENDERING_SERVER_URL", "http://image-renderer:8081/render") } // We add all GF_ environment variables to allow for overriding Grafana configuration. diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index b0e748422a1..3a65082459e 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -11,9 +11,7 @@ import ( _ "github.com/Azure/azure-sdk-for-go/services/keyvault/v7.1/keyvault" _ "github.com/Azure/go-autorest/autorest" _ "github.com/Azure/go-autorest/autorest/adal" - _ "github.com/aws/aws-sdk-go-v2/credentials" _ "github.com/aws/aws-sdk-go-v2/service/secretsmanager" - _ "github.com/aws/aws-sdk-go-v2/service/sts" _ "github.com/beevik/etree" _ "github.com/blugelabs/bluge" _ "github.com/blugelabs/bluge_segment_api" diff --git a/pkg/operators/provisioning/config.go b/pkg/operators/provisioning/config.go index 05552e56095..868d2a8d717 100644 --- a/pkg/operators/provisioning/config.go +++ b/pkg/operators/provisioning/config.go @@ -36,7 +36,6 @@ import ( type provisioningControllerConfig struct { provisioningClient *client.Clientset resyncInterval time.Duration - repoFactory repository.Factory unified resources.ResourceStore clients resources.ClientFactory tokenExchangeClient *authn.TokenExchangeClient @@ -129,16 +128,6 @@ func setupFromConfig(cfg *setting.Cfg, registry prometheus.Registerer) (controll return nil, fmt.Errorf("failed to create provisioning client: %w", err) } - decrypter, err := setupDecrypter(cfg, tracer, tokenExchangeClient) - if err != nil { - return nil, fmt.Errorf("failed to setup decrypter: %w", err) - } - - repoFactory, err := setupRepoFactory(cfg, decrypter, provisioningClient, registry) - if err != nil { - return nil, fmt.Errorf("failed to setup repository getter: %w", err) - } - // HACK: This logic directly connects to unified storage. We are doing this for now as there is no global // search endpoint. But controllers, in general, should not connect directly to unified storage and instead // go through the api server. Once there is a global search endpoint, we will switch to that here as well. @@ -195,7 +184,6 @@ func setupFromConfig(cfg *setting.Cfg, registry prometheus.Registerer) (controll return &provisioningControllerConfig{ provisioningClient: provisioningClient, - repoFactory: repoFactory, unified: unified, clients: clients, resyncInterval: operatorSec.Key("resync_interval").MustDuration(60 * time.Second), diff --git a/pkg/operators/provisioning/connection_operator.go b/pkg/operators/provisioning/connection_operator.go new file mode 100644 index 00000000000..34624f4fe47 --- /dev/null +++ b/pkg/operators/provisioning/connection_operator.go @@ -0,0 +1,86 @@ +package provisioning + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/grafana/grafana-app-sdk/logging" + "github.com/prometheus/client_golang/prometheus" + "k8s.io/client-go/tools/cache" + + appcontroller "github.com/grafana/grafana/apps/provisioning/pkg/controller" + informer "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/controller" + "github.com/grafana/grafana/pkg/server" + "github.com/grafana/grafana/pkg/setting" +) + +// RunConnectionController starts the connection controller operator. +func RunConnectionController(deps server.OperatorDependencies) error { + logger := logging.NewSLogLogger(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })).With("logger", "provisioning-connection-controller") + logger.Info("Starting provisioning connection controller") + + controllerCfg, err := getConnectionControllerConfig(deps.Config, deps.Registerer) + if err != nil { + return fmt.Errorf("failed to setup operator: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigChan + fmt.Println("Received shutdown signal, stopping controllers") + cancel() + }() + + informerFactory := informer.NewSharedInformerFactoryWithOptions( + controllerCfg.provisioningClient, + controllerCfg.resyncInterval, + ) + + statusPatcher := appcontroller.NewConnectionStatusPatcher(controllerCfg.provisioningClient.ProvisioningV0alpha1()) + connInformer := informerFactory.Provisioning().V0alpha1().Connections() + + connController, err := controller.NewConnectionController( + controllerCfg.provisioningClient.ProvisioningV0alpha1(), + connInformer, + statusPatcher, + ) + if err != nil { + return fmt.Errorf("failed to create connection controller: %w", err) + } + + informerFactory.Start(ctx.Done()) + if !cache.WaitForCacheSync(ctx.Done(), connInformer.Informer().HasSynced) { + return fmt.Errorf("failed to sync informer cache") + } + + connController.Run(ctx, controllerCfg.workerCount) + return nil +} + +type connectionControllerConfig struct { + provisioningControllerConfig + workerCount int +} + +func getConnectionControllerConfig(cfg *setting.Cfg, registry prometheus.Registerer) (*connectionControllerConfig, error) { + controllerCfg, err := setupFromConfig(cfg, registry) + if err != nil { + return nil, err + } + + return &connectionControllerConfig{ + provisioningControllerConfig: *controllerCfg, + workerCount: cfg.SectionWithEnvOverrides("operator").Key("worker_count").MustInt(1), + }, nil +} diff --git a/pkg/operators/provisioning/repo_operator.go b/pkg/operators/provisioning/repo_operator.go index 416eb5c0e3f..c3a038b9378 100644 --- a/pkg/operators/provisioning/repo_operator.go +++ b/pkg/operators/provisioning/repo_operator.go @@ -106,6 +106,7 @@ func RunRepoController(deps server.OperatorDependencies) error { type repoControllerConfig struct { provisioningControllerConfig + repoFactory repository.Factory workerCount int parallelOperations int allowedTargets []string @@ -119,6 +120,17 @@ func getRepoControllerConfig(cfg *setting.Cfg, registry prometheus.Registerer) ( return nil, err } + // Setup repository factory for repo controller + decrypter, err := setupDecrypter(cfg, tracing.NewNoopTracerService(), controllerCfg.tokenExchangeClient) + if err != nil { + return nil, fmt.Errorf("failed to setup decrypter: %w", err) + } + + repoFactory, err := setupRepoFactory(cfg, decrypter, controllerCfg.provisioningClient, registry) + if err != nil { + return nil, fmt.Errorf("failed to setup repository factory: %w", err) + } + allowedTargets := []string{} cfg.SectionWithEnvOverrides("provisioning").Key("allowed_targets").Strings("|") if len(allowedTargets) == 0 { @@ -127,6 +139,7 @@ func getRepoControllerConfig(cfg *setting.Cfg, registry prometheus.Registerer) ( return &repoControllerConfig{ provisioningControllerConfig: *controllerCfg, + repoFactory: repoFactory, allowedTargets: allowedTargets, workerCount: cfg.SectionWithEnvOverrides("operator").Key("worker_count").MustInt(1), parallelOperations: cfg.SectionWithEnvOverrides("operator").Key("parallel_operations").MustInt(10), diff --git a/pkg/operators/register.go b/pkg/operators/register.go index b31b9837fb0..4d42591ca7b 100644 --- a/pkg/operators/register.go +++ b/pkg/operators/register.go @@ -13,6 +13,12 @@ func init() { RunFunc: provisioning.RunRepoController, }) + server.RegisterOperator(server.Operator{ + Name: "provisioning-connection", + Description: "Watch provisioning connections", + RunFunc: provisioning.RunConnectionController, + }) + server.RegisterOperator(server.Operator{ Name: "iam-folder-reconciler", Description: "Reconcile folder resources into Zanzana", diff --git a/pkg/plugins/go.mod b/pkg/plugins/go.mod new file mode 100644 index 00000000000..9bcc74e80f6 --- /dev/null +++ b/pkg/plugins/go.mod @@ -0,0 +1,130 @@ +module github.com/grafana/grafana/pkg/plugins + +go 1.25.5 + +require ( + github.com/Machiel/slugify v1.0.1 + github.com/ProtonMail/go-crypto v1.3.0 + github.com/gobwas/glob v0.2.3 + github.com/google/go-cmp v0.7.0 + github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 + github.com/grafana/grafana-plugin-sdk-go v0.284.0 + github.com/grafana/grafana/pkg/apimachinery v0.0.0 + github.com/grafana/grafana/pkg/semconv v0.0.0 + github.com/hashicorp/go-hclog v1.6.3 + github.com/hashicorp/go-plugin v1.7.0 + github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2 + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 + go.opentelemetry.io/otel v1.39.0 + go.opentelemetry.io/otel/trace v1.39.0 + google.golang.org/grpc v1.77.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/apache/arrow-go/v18 v18.4.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cheekybits/genny v1.0.0 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.5.2+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + 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/protobuf v1.5.4 // indirect + github.com/google/flatbuffers v25.2.10+incompatible // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect + github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect + github.com/grafana/otel-profiling-go v0.5.1 // indirect + github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/jaegertracing/jaeger-idl v0.5.0 // indirect + github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mattetti/filebuffer v1.0.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oklog/run v1.1.0 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/pierrec/lz4/v4 v4.1.22 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.4 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect + go.opentelemetry.io/contrib/propagators/jaeger v1.38.0 // indirect + go.opentelemetry.io/contrib/samplers/jaegerremote v0.32.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/sdk v1.39.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.40.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect + k8s.io/apimachinery v0.34.3 // indirect + k8s.io/apiserver v0.34.3 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect +) + +replace ( + github.com/grafana/grafana/pkg/apimachinery => ../apimachinery + github.com/grafana/grafana/pkg/semconv => ../semconv +) diff --git a/pkg/plugins/go.sum b/pkg/plugins/go.sum new file mode 100644 index 00000000000..2bd8adfbad2 --- /dev/null +++ b/pkg/plugins/go.sum @@ -0,0 +1,347 @@ +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E= +github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/apache/arrow-go/v18 v18.4.1 h1:q/jVkBWCJOB9reDgaIZIdruLQUb1kbkvOnOFezVH1C4= +github.com/apache/arrow-go/v18 v18.4.1/go.mod h1:tLyFubsAl17bvFdUAy24bsSvA/6ww95Iqi67fTpGu3E= +github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc= +github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g= +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/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE= +github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +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/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= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= +github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= +github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= +github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= +github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= +github.com/grafana/grafana-plugin-sdk-go v0.284.0 h1:1bK7eWsnPBLUWDcWJWe218Ik5ad0a5JpEL4mH9ry7Ws= +github.com/grafana/grafana-plugin-sdk-go v0.284.0/go.mod h1:lHPniaSxq3SL5MxDIPy04TYB1jnTp/ivkYO+xn5Rz3E= +github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= +github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= +github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2 h1:gCNiM4T5xEc4IpT8vM50CIO+AtElr5kO9l2Rxbq+Sz8= +github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2/go.mod h1:6ZM4ZdwClyAsiU2uDBmRHCvq0If/03BMbF9U+U7G5pA= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +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/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 h1:hgVxRoDDPtQE68PT4LFvNlPz2nBKd3OMlGKIQ69OmR4= +github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531/go.mod h1:fqTUQpVYBvhCNIsMXGl2GE9q6z94DIP6NtFKXCSTVbg= +github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d h1:J8tJzRyiddAFF65YVgxli+TyWBi0f79Sld6rJP6CBcY= +github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d/go.mod h1:b+Q3v8Yrg5o15d71PSUraUzYb+jWl6wQMSBXSGS/hv0= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= +github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattetti/filebuffer v1.0.1 h1:gG7pyfnSIZCxdoKq+cPa8T0hhYtD9NxCdI4D7PTjRLM= +github.com/mattetti/filebuffer v1.0.1/go.mod h1:YdMURNDOttIiruleeVr6f56OrMc+MydEnTcXwtkxNVs= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +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/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= +github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +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= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +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.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 h1:RN3ifU8y4prNWeEnQp2kRRHz8UwonAEYZl8tUzHEXAk= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0/go.mod h1:habDz3tEWiFANTo6oUE99EmaFUrCNYAAg3wiVmusm70= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 h1:2pn7OzMewmYRiNtv1doZnLo3gONcnMHlFnmOR8Vgt+8= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0/go.mod h1:rjbQTDEPQymPE0YnRQp9/NuPwwtL0sesz/fnqRW/v84= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= +go.opentelemetry.io/contrib/propagators/jaeger v1.38.0 h1:nXGeLvT1QtCAhkASkP/ksjkTKZALIaQBIW+JSIw1KIc= +go.opentelemetry.io/contrib/propagators/jaeger v1.38.0/go.mod h1:oMvOXk78ZR3KEuPMBgp/ThAMDy9ku/eyUVztr+3G6Wo= +go.opentelemetry.io/contrib/samplers/jaegerremote v0.32.0 h1:oPW/SRFyHgIgxrvNhSBzqvZER2N5kRlci3/rGTOuyWo= +go.opentelemetry.io/contrib/samplers/jaegerremote v0.32.0/go.mod h1:B9Oka5QVD0bnmZNO6gBbBta6nohD/1Z+f9waH2oXyBs= +go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= +go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2rQ4uiE+7ncwtiOdPfYK1NK2XA= +golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU= +google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= +k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apiserver v0.34.3 h1:uGH1qpDvSiYG4HVFqc6A3L4CKiX+aBWDrrsxHYK0Bdo= +k8s.io/apiserver v0.34.3/go.mod h1:QPnnahMO5C2m3lm6fPW3+JmyQbvHZQ8uudAu/493P2w= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go index 0b7ffa04ea4..0fa8b7d7115 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go @@ -224,7 +224,7 @@ func (a *dashboardSqlAccess) CountResources(ctx context.Context, opts MigrateOpt case "folder.grafana.app/folders": summary := &resourcepb.BulkResponse_Summary{} summary.Group = folders.GROUP - summary.Group = folders.RESOURCE + summary.Resource = folders.RESOURCE _, err = sess.SQL("SELECT COUNT(*) FROM "+sql.Table("dashboard")+ " WHERE is_folder=TRUE AND org_id=?", orgId).Get(&summary.Count) rsp.Summary = append(rsp.Summary, summary) diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index eed79dd6f0d..6973a443b12 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -389,6 +389,11 @@ func (b *DashboardsAPIBuilder) validateCreate(ctx context.Context, a admission.A return apierrors.NewBadRequest(err.Error()) } + // Validate tags + if err := validateDashboardTags(dashObj); err != nil { + return apierrors.NewBadRequest(err.Error()) + } + id, err := identity.GetRequester(ctx) if err != nil { return fmt.Errorf("error getting requester: %w", err) @@ -459,6 +464,11 @@ func (b *DashboardsAPIBuilder) validateUpdate(ctx context.Context, a admission.A return apierrors.NewBadRequest(err.Error()) } + // Validate tags + if err := validateDashboardTags(newDashObj); err != nil { + return apierrors.NewBadRequest(err.Error()) + } + // Validate folder existence if specified and changed if !a.IsDryRun() && newAccessor.GetFolder() != oldAccessor.GetFolder() && newAccessor.GetFolder() != "" { id, err := identity.GetRequester(ctx) @@ -556,6 +566,32 @@ func getDashboardProperties(obj runtime.Object) (string, string, error) { return title, refresh, nil } +// validateDashboardTags validates that all dashboard tags are within the maximum length +func validateDashboardTags(obj runtime.Object) error { + var tags []string + + switch d := obj.(type) { + case *dashv0.Dashboard: + tags = d.Spec.GetNestedStringSlice("tags") + case *dashv1.Dashboard: + tags = d.Spec.GetNestedStringSlice("tags") + case *dashv2alpha1.Dashboard: + tags = d.Spec.Tags + case *dashv2beta1.Dashboard: + tags = d.Spec.Tags + default: + return fmt.Errorf("unsupported dashboard version: %T", obj) + } + + for _, tag := range tags { + if len(tag) > 50 { + return dashboards.ErrDashboardTagTooLong + } + } + + return nil +} + func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error { storageOpts := apistore.StorageOptions{ EnableFolderSupport: true, diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index 08a943b8da6..bbc032e7382 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -142,6 +142,24 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) * Schema: spec.StringProperty(), }, }, + { + ParameterProps: spec3.ParameterProps{ + Name: "panelType", + In: "query", + Description: "find dashboards using panels of a given plugin type", + Required: false, + Schema: spec.StringProperty(), + }, + }, + { + ParameterProps: spec3.ParameterProps{ + Name: "dataSourceType", + In: "query", + Description: "find dashboards using datasources of a given plugin type", + Required: false, + Schema: spec.StringProperty(), + }, + }, { ParameterProps: spec3.ParameterProps{ Name: "permission", @@ -430,14 +448,11 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use } } - // The facet term fields + // Apply facet terms if facets, ok := queryParams["facet"]; ok { if queryParams.Has("facetLimit") { if parsed, err := strconv.Atoi(queryParams.Get("facetLimit")); err == nil && parsed > 0 { - facetLimit = parsed - if facetLimit > 1000 { - facetLimit = 1000 - } + facetLimit = min(parsed, 1000) } } searchRequest.Facet = make(map[string]*resourcepb.ResourceSearchRequest_Facet) @@ -449,21 +464,35 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use } } - // The tags filter - if tags, ok := queryParams["tag"]; ok { + if v, ok := queryParams["tag"]; ok { searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{ Key: "tags", Operator: "=", - Values: tags, + Values: v, }) } - // The libraryPanel filter - if libraryPanel, ok := queryParams["libraryPanel"]; ok { + if v, ok := queryParams["panelType"]; ok { + searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{ + Key: resource.SEARCH_FIELD_PREFIX + builders.DASHBOARD_PANEL_TYPES, + Operator: "=", + Values: v, + }) + } + + if v, ok := queryParams["dataSourceType"]; ok { + searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{ + Key: resource.SEARCH_FIELD_PREFIX + builders.DASHBOARD_DS_TYPES, + Operator: "=", + Values: v, + }) + } + + if v, ok := queryParams["libraryPanel"]; ok { searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{ Key: builders.DASHBOARD_LIBRARY_PANEL_REFERENCE, Operator: "=", - Values: libraryPanel, + Values: v, }) } @@ -523,6 +552,7 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use // gets dashboards that the user was granted read access to permissions := user.GetPermissions() dashboardPermissions := permissions[dashboards.ActionDashboardsRead] + folderPermissions := permissions[dashboards.ActionFoldersRead] dashboardUids := make([]string, 0) sharedDashboards := make([]string, 0) @@ -533,6 +563,13 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use } } } + for _, folderPermission := range folderPermissions { + if folderUid, found := strings.CutPrefix(folderPermission, dashboards.ScopeFoldersPrefix); found { + if !slices.Contains(dashboardUids, folderUid) && folderUid != foldermodel.SharedWithMeFolderUID && folderUid != foldermodel.GeneralFolderUID { + dashboardUids = append(dashboardUids, folderUid) + } + } + } if len(dashboardUids) == 0 { return sharedDashboards, nil @@ -543,9 +580,15 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use return sharedDashboards, err } + folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE) + if err != nil { + return sharedDashboards, err + } + dashboardSearchRequest := &resourcepb.ResourceSearchRequest{ - Fields: []string{"folder"}, - Limit: int64(len(dashboardUids)), + Federated: []*resourcepb.ResourceKey{folderKey}, + Fields: []string{"folder"}, + Limit: int64(len(dashboardUids)), Options: &resourcepb.ListOptions{ Key: key, Fields: []*resourcepb.Requirement{{ @@ -581,12 +624,6 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use } } - // only folders the user has access to will be returned here - folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE) - if err != nil { - return sharedDashboards, err - } - folderSearchRequest := &resourcepb.ResourceSearchRequest{ Fields: []string{"folder"}, Limit: int64(len(allFolders)), @@ -599,6 +636,7 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use }}, }, } + // only folders the user has access to will be returned here foldersResult, err := s.client.Search(ctx, folderSearchRequest) if err != nil { return sharedDashboards, err diff --git a/pkg/registry/apis/dashboard/search_test.go b/pkg/registry/apis/dashboard/search_test.go index 3b9935f8247..c7defba3fea 100644 --- a/pkg/registry/apis/dashboard/search_test.go +++ b/pkg/registry/apis/dashboard/search_test.go @@ -507,6 +507,15 @@ func TestSearchHandlerSharedDashboards(t *testing.T) { []byte("publicfolder"), // folder uid }, }, + { + Key: &resourcepb.ResourceKey{ + Name: "sharedfolder", + Resource: "folder", + }, + Cells: [][]byte{ + []byte("privatefolder"), // folder uid + }, + }, }, }, } @@ -550,6 +559,15 @@ func TestSearchHandlerSharedDashboards(t *testing.T) { []byte("privatefolder"), // folder uid }, }, + { + Key: &resourcepb.ResourceKey{ + Name: "sharedfolder", + Resource: "folder", + }, + Cells: [][]byte{ + []byte("privatefolder"), // folder uid + }, + }, }, }, } @@ -571,6 +589,7 @@ func TestSearchHandlerSharedDashboards(t *testing.T) { allPermissions := make(map[int64]map[string][]string) permissions := make(map[string][]string) permissions[dashboards.ActionDashboardsRead] = []string{"dashboards:uid:dashboardinroot", "dashboards:uid:dashboardinprivatefolder", "dashboards:uid:dashboardinpublicfolder"} + permissions[dashboards.ActionFoldersRead] = []string{"folders:uid:sharedfolder"} allPermissions[1] = permissions // "Permissions" is where we store the uid of dashboards shared with the user req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test", OrgID: 1, Permissions: allPermissions})) @@ -581,14 +600,19 @@ func TestSearchHandlerSharedDashboards(t *testing.T) { // first call gets all dashboards user has permission for firstCall := mockClient.MockCalls[0] - assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder"}) + assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder", "sharedfolder"}) + // verify federated field is set to include folders + assert.NotNil(t, firstCall.Federated) + assert.Equal(t, 1, len(firstCall.Federated)) + assert.Equal(t, "folder.grafana.app", firstCall.Federated[0].Group) + assert.Equal(t, "folders", firstCall.Federated[0].Resource) // second call gets folders associated with the previous dashboards secondCall := mockClient.MockCalls[1] assert.Equal(t, secondCall.Options.Fields[0].Values, []string{"privatefolder", "publicfolder"}) - // lastly, search ONLY for dashboards user has permission to read that are within folders the user does NOT have + // lastly, search ONLY for dashboards and folders user has permission to read that are within folders the user does NOT have // permission to read thirdCall := mockClient.MockCalls[2] - assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder"}) + assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder", "sharedfolder"}) resp := rr.Result() defer func() { diff --git a/pkg/registry/apis/datasource/plugincontext.go b/pkg/registry/apis/datasource/plugincontext.go index a0525030dcb..27479f60675 100644 --- a/pkg/registry/apis/datasource/plugincontext.go +++ b/pkg/registry/apis/datasource/plugincontext.go @@ -71,7 +71,6 @@ type cachingDatasourceProvider struct { } func (q *cachingDatasourceProvider) GetDatasourceProvider(pluginJson plugins.JSONData) PluginDatasourceProvider { - group, _ := plugins.GetDatasourceGroupNameFromPluginID(pluginJson.ID) return &scopedDatasourceProvider{ plugin: pluginJson, dsService: q.dsService, @@ -81,7 +80,7 @@ func (q *cachingDatasourceProvider) GetDatasourceProvider(pluginJson plugins.JSO mapper: q.converter.mapper, plugin: pluginJson.ID, alias: pluginJson.AliasIDs, - group: group, + group: pluginJson.ID, }, } } diff --git a/pkg/registry/apis/datasource/register.go b/pkg/registry/apis/datasource/register.go index 9222c020681..e23a3c1ab2a 100644 --- a/pkg/registry/apis/datasource/register.go +++ b/pkg/registry/apis/datasource/register.go @@ -37,6 +37,11 @@ var ( _ builder.APIGroupBuilder = (*DataSourceAPIBuilder)(nil) ) +type DataSourceAPIBuilderConfig struct { + LoadQueryTypes bool + UseDualWriter bool +} + // DataSourceAPIBuilder is used just so wire has something unique to return type DataSourceAPIBuilder struct { datasourceResourceInfo utils.ResourceInfo @@ -46,7 +51,7 @@ type DataSourceAPIBuilder struct { contextProvider PluginContextWrapper accessControl accesscontrol.AccessControl queryTypes *queryV0.QueryTypeDefinitionList - configCrudUseNewApis bool + cfg DataSourceAPIBuilderConfig dataSourceCRUDMetric *prometheus.HistogramVec } @@ -89,20 +94,24 @@ func RegisterAPIService( return nil, fmt.Errorf("plugin client is not a PluginClient: %T", pluginClient) } + groupName := pluginJSON.ID + ".datasource.grafana.app" builder, err = NewDataSourceAPIBuilder( + groupName, pluginJSON, client, datasources.GetDatasourceProvider(pluginJSON), contextProvider, accessControl, - //nolint:staticcheck // not yet migrated to OpenFeature - features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes), - //nolint:staticcheck // not yet migrated to OpenFeature - features.IsEnabledGlobally(featuremgmt.FlagQueryServiceWithConnections), + DataSourceAPIBuilderConfig{ + //nolint:staticcheck // not yet migrated to OpenFeature + LoadQueryTypes: features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes), + UseDualWriter: false, + }, ) if err != nil { return nil, err } + builder.SetDataSourceCRUDMetrics(dataSourceCRUDMetric) apiRegistrar.RegisterAPI(builder) @@ -120,31 +129,27 @@ type PluginClient interface { } func NewDataSourceAPIBuilder( + groupName string, plugin plugins.JSONData, client PluginClient, datasources PluginDatasourceProvider, contextProvider PluginContextWrapper, accessControl accesscontrol.AccessControl, - loadQueryTypes bool, - configCrudUseNewApis bool, + cfg DataSourceAPIBuilderConfig, ) (*DataSourceAPIBuilder, error) { - group, err := plugins.GetDatasourceGroupNameFromPluginID(plugin.ID) - if err != nil { - return nil, err - } - builder := &DataSourceAPIBuilder{ - datasourceResourceInfo: datasourceV0.DataSourceResourceInfo.WithGroupAndShortName(group, plugin.ID), + datasourceResourceInfo: datasourceV0.DataSourceResourceInfo.WithGroupAndShortName(groupName, plugin.ID), pluginJSON: plugin, client: client, datasources: datasources, contextProvider: contextProvider, accessControl: accessControl, - configCrudUseNewApis: configCrudUseNewApis, + cfg: cfg, } - if loadQueryTypes { + var err error + if cfg.LoadQueryTypes { // In the future, this will somehow come from the plugin - builder.queryTypes, err = getHardcodedQueryTypes(group) + builder.queryTypes, err = getHardcodedQueryTypes(groupName) } return builder, err } @@ -154,9 +159,9 @@ func getHardcodedQueryTypes(group string) (*queryV0.QueryTypeDefinitionList, err var err error var raw json.RawMessage switch group { - case "testdata.datasource.grafana.app": + case "testdata.datasource.grafana.app", "grafana-testdata-datasource": raw, err = kinds.QueryTypeDefinitionListJSON() - case "prometheus.datasource.grafana.app": + case "prometheus.datasource.grafana.app", "prometheus": raw, err = models.QueryTypeDefinitionListJSON() } if err != nil { @@ -233,7 +238,7 @@ func (b *DataSourceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver storage["connections"] = &noopREST{} // hidden from openapi storage["connections/query"] = storage[ds.StoragePath("query")] // deprecated in openapi - if b.configCrudUseNewApis { + if b.cfg.UseDualWriter { legacyStore := &legacyStorage{ datasources: b.datasources, resourceInfo: &ds, diff --git a/pkg/registry/apis/iam/authorizer.go b/pkg/registry/apis/iam/authorizer.go index 8abc67bf885..da81dd2d9a8 100644 --- a/pkg/registry/apis/iam/authorizer.go +++ b/pkg/registry/apis/iam/authorizer.go @@ -42,7 +42,6 @@ func newIAMAuthorizer( // Identity specific resources legacyAuthorizer := gfauthorizer.NewResourceAuthorizer(legacyAccessClient) - resourceAuthorizer[iamv0.TeamBindingResourceInfo.GetName()] = legacyAuthorizer resourceAuthorizer["display"] = legacyAuthorizer // Access specific resources @@ -53,8 +52,9 @@ func newIAMAuthorizer( resourceAuthorizer[iamv0.RoleBindingInfo.GetName()] = authorizer resourceAuthorizer[iamv0.ServiceAccountResourceInfo.GetName()] = authorizer resourceAuthorizer[iamv0.UserResourceInfo.GetName()] = authorizer - resourceAuthorizer[iamv0.ExternalGroupMappingResourceInfo.GetName()] = authorizer + resourceAuthorizer[iamv0.ExternalGroupMappingResourceInfo.GetName()] = allowAuthorizer resourceAuthorizer[iamv0.TeamResourceInfo.GetName()] = authorizer + resourceAuthorizer[iamv0.TeamBindingResourceInfo.GetName()] = allowAuthorizer resourceAuthorizer["searchUsers"] = serviceAuthorizer resourceAuthorizer["searchTeams"] = serviceAuthorizer diff --git a/pkg/registry/apis/iam/authorizer/external_group_mapping.go b/pkg/registry/apis/iam/authorizer/external_group_mapping.go new file mode 100644 index 00000000000..0537acbe81d --- /dev/null +++ b/pkg/registry/apis/iam/authorizer/external_group_mapping.go @@ -0,0 +1,150 @@ +package authorizer + +import ( + "context" + "fmt" + + "github.com/grafana/authlib/types" + "k8s.io/apimachinery/pkg/runtime" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer/storewrapper" + apierrors "k8s.io/apimachinery/pkg/api/errors" +) + +type ExternalGroupMappingAuthorizer struct { + accessClient types.AccessClient +} + +var _ storewrapper.ResourceStorageAuthorizer = (*ExternalGroupMappingAuthorizer)(nil) + +func NewExternalGroupMappingAuthorizer( + accessClient types.AccessClient, +) *ExternalGroupMappingAuthorizer { + return &ExternalGroupMappingAuthorizer{ + accessClient: accessClient, + } +} + +// AfterGet implements ResourceStorageAuthorizer. +func (r *ExternalGroupMappingAuthorizer) AfterGet(ctx context.Context, obj runtime.Object) error { + authInfo, ok := types.AuthInfoFrom(ctx) + if !ok { + return storewrapper.ErrUnauthenticated + } + + concreteObj, ok := obj.(*iamv0.ExternalGroupMapping) + if !ok { + return apierrors.NewInternalError(fmt.Errorf("expected ExternalGroupMapping, got %T: %w", obj, storewrapper.ErrUnexpectedType)) + } + + teamName := concreteObj.Spec.TeamRef.Name + checkReq := types.CheckRequest{ + Namespace: authInfo.GetNamespace(), + Group: iamv0.GROUP, + Resource: iamv0.TeamResourceInfo.GetName(), + Verb: utils.VerbGetPermissions, + Name: teamName, + } + res, err := r.accessClient.Check(ctx, authInfo, checkReq, "") + if err != nil { + return apierrors.NewInternalError(err) + } + + if !res.Allowed { + return apierrors.NewForbidden( + iamv0.ExternalGroupMappingResourceInfo.GroupResource(), + concreteObj.Name, + fmt.Errorf("user cannot access team %s", teamName), + ) + } + return nil +} + +// BeforeCreate implements ResourceStorageAuthorizer. +func (r *ExternalGroupMappingAuthorizer) BeforeCreate(ctx context.Context, obj runtime.Object) error { + return r.beforeWrite(ctx, obj) +} + +// BeforeDelete implements ResourceStorageAuthorizer. +func (r *ExternalGroupMappingAuthorizer) BeforeDelete(ctx context.Context, obj runtime.Object) error { + return r.beforeWrite(ctx, obj) +} + +// BeforeUpdate implements ResourceStorageAuthorizer. +func (r *ExternalGroupMappingAuthorizer) BeforeUpdate(ctx context.Context, obj runtime.Object) error { + // Update is not supported for ExternalGroupMapping resources and update attempts are blocked at a lower level, + // so this is just a safeguard. + return apierrors.NewMethodNotSupported(iamv0.ExternalGroupMappingResourceInfo.GroupResource(), "PUT/PATCH") +} + +func (r *ExternalGroupMappingAuthorizer) beforeWrite(ctx context.Context, obj runtime.Object) error { + authInfo, ok := types.AuthInfoFrom(ctx) + if !ok { + return storewrapper.ErrUnauthenticated + } + + concreteObj, ok := obj.(*iamv0.ExternalGroupMapping) + if !ok { + return apierrors.NewInternalError(fmt.Errorf("expected ExternalGroupMapping, got %T: %w", obj, storewrapper.ErrUnexpectedType)) + } + + teamName := concreteObj.Spec.TeamRef.Name + checkReq := types.CheckRequest{ + Namespace: authInfo.GetNamespace(), + Group: iamv0.GROUP, + Resource: iamv0.TeamResourceInfo.GetName(), + Verb: utils.VerbSetPermissions, + Name: teamName, + } + + res, err := r.accessClient.Check(ctx, authInfo, checkReq, "") + if err != nil { + return apierrors.NewInternalError(err) + } + + if !res.Allowed { + return apierrors.NewForbidden( + iamv0.ExternalGroupMappingResourceInfo.GroupResource(), + concreteObj.Name, + fmt.Errorf("user cannot write team %s", teamName), + ) + } + return nil +} + +// FilterList implements ResourceStorageAuthorizer. +func (r *ExternalGroupMappingAuthorizer) FilterList(ctx context.Context, list runtime.Object) (runtime.Object, error) { + authInfo, ok := types.AuthInfoFrom(ctx) + if !ok { + return nil, storewrapper.ErrUnauthenticated + } + + l, ok := list.(*iamv0.ExternalGroupMappingList) + if !ok { + return nil, apierrors.NewInternalError(fmt.Errorf("expected ExternalGroupMappingList, got %T: %w", list, storewrapper.ErrUnexpectedType)) + } + + var filteredItems []iamv0.ExternalGroupMapping + + listReq := types.ListRequest{ + Namespace: authInfo.GetNamespace(), + Group: iamv0.GROUP, + Resource: iamv0.TeamResourceInfo.GetName(), + Verb: utils.VerbGetPermissions, + } + canView, _, err := r.accessClient.Compile(ctx, authInfo, listReq) + if err != nil { + return nil, apierrors.NewInternalError(err) + } + + for _, item := range l.Items { + if canView(item.Spec.TeamRef.Name, "") { + filteredItems = append(filteredItems, item) + } + } + + l.Items = filteredItems + return l, nil +} diff --git a/pkg/registry/apis/iam/authorizer/external_group_mapping_test.go b/pkg/registry/apis/iam/authorizer/external_group_mapping_test.go new file mode 100644 index 00000000000..69ee7e5fed9 --- /dev/null +++ b/pkg/registry/apis/iam/authorizer/external_group_mapping_test.go @@ -0,0 +1,229 @@ +package authorizer + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/grafana/authlib/types" + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" +) + +func newExternalGroupMapping(teamName, name string) *iamv0.ExternalGroupMapping { + return &iamv0.ExternalGroupMapping{ + ObjectMeta: metav1.ObjectMeta{Namespace: "org-2", Name: name}, + Spec: iamv0.ExternalGroupMappingSpec{ + TeamRef: iamv0.ExternalGroupMappingTeamRef{ + Name: teamName, + }, + }, + } +} + +func TestExternalGroupMapping_AfterGet(t *testing.T) { + mapping := newExternalGroupMapping("team-1", "mapping-1") + + tests := []struct { + name string + shouldAllow bool + }{ + { + name: "allow access", + shouldAllow: true, + }, + { + name: "deny access", + shouldAllow: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkFunc := func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) { + require.NotNil(t, id) + require.Equal(t, "user:u001", id.GetUID()) + require.Equal(t, "org-2", id.GetNamespace()) + + require.Equal(t, "org-2", req.Namespace) + require.Equal(t, iamv0.GROUP, req.Group) + require.Equal(t, iamv0.TeamResourceInfo.GetName(), req.Resource) + require.Equal(t, "team-1", req.Name) + require.Equal(t, utils.VerbGetPermissions, req.Verb) + require.Equal(t, "", folder) + + return types.CheckResponse{Allowed: tt.shouldAllow}, nil + } + + accessClient := &fakeAccessClient{checkFunc: checkFunc} + authz := NewExternalGroupMappingAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + err := authz.AfterGet(ctx, mapping) + if tt.shouldAllow { + require.NoError(t, err) + } else { + require.Error(t, err) + } + require.True(t, accessClient.checkCalled) + }) + } +} + +func TestExternalGroupMapping_FilterList(t *testing.T) { + list := &iamv0.ExternalGroupMappingList{ + Items: []iamv0.ExternalGroupMapping{ + *newExternalGroupMapping("team-1", "mapping-1"), + *newExternalGroupMapping("team-2", "mapping-2"), + }, + ListMeta: metav1.ListMeta{ + SelfLink: "/apis/iam.grafana.app/v0alpha1/namespaces/org-2/externalgroupmappings", + }, + } + + compileFunc := func(id types.AuthInfo, req types.ListRequest) (types.ItemChecker, types.Zookie, error) { + require.NotNil(t, id) + require.Equal(t, "user:u001", id.GetUID()) + require.Equal(t, "org-2", id.GetNamespace()) + + require.Equal(t, "org-2", req.Namespace) + require.Equal(t, iamv0.GROUP, req.Group) + require.Equal(t, iamv0.TeamResourceInfo.GetName(), req.Resource) + require.Equal(t, utils.VerbGetPermissions, req.Verb) + + return func(name, folder string) bool { + return name == "team-1" + }, &types.NoopZookie{}, nil + } + + accessClient := &fakeAccessClient{compileFunc: compileFunc} + authz := NewExternalGroupMappingAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + obj, err := authz.FilterList(ctx, list) + require.NoError(t, err) + require.NotNil(t, list) + require.True(t, accessClient.compileCalled) + + filtered, ok := obj.(*iamv0.ExternalGroupMappingList) + require.True(t, ok) + require.Len(t, filtered.Items, 1) + require.Equal(t, "mapping-1", filtered.Items[0].Name) +} + +func TestExternalGroupMapping_BeforeCreate(t *testing.T) { + mapping := newExternalGroupMapping("team-1", "mapping-1") + + tests := []struct { + name string + shouldAllow bool + }{ + { + name: "allow create", + shouldAllow: true, + }, + { + name: "deny create", + shouldAllow: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkFunc := func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) { + require.NotNil(t, id) + require.Equal(t, "user:u001", id.GetUID()) + require.Equal(t, "org-2", id.GetNamespace()) + + require.Equal(t, "org-2", req.Namespace) + require.Equal(t, iamv0.GROUP, req.Group) + require.Equal(t, iamv0.TeamResourceInfo.GetName(), req.Resource) + require.Equal(t, "team-1", req.Name) + require.Equal(t, utils.VerbSetPermissions, req.Verb) + require.Equal(t, "", folder) + + return types.CheckResponse{Allowed: tt.shouldAllow}, nil + } + + accessClient := &fakeAccessClient{checkFunc: checkFunc} + authz := NewExternalGroupMappingAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + err := authz.BeforeCreate(ctx, mapping) + if tt.shouldAllow { + require.NoError(t, err) + } else { + require.Error(t, err) + } + require.True(t, accessClient.checkCalled) + }) + } +} + +func TestExternalGroupMapping_BeforeUpdate(t *testing.T) { + mapping := newExternalGroupMapping("team-1", "mapping-1") + + accessClient := &fakeAccessClient{ + checkFunc: func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) { + require.Fail(t, "check should not be called") + return types.CheckResponse{}, nil + }, + } + authz := NewExternalGroupMappingAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + err := authz.BeforeUpdate(ctx, mapping) + require.Error(t, err) + require.True(t, apierrors.IsMethodNotSupported(err)) + require.Contains(t, err.Error(), "PUT/PATCH") + require.False(t, accessClient.checkCalled) +} + +func TestExternalGroupMapping_BeforeDelete(t *testing.T) { + mapping := newExternalGroupMapping("team-1", "mapping-1") + + tests := []struct { + name string + shouldAllow bool + }{ + { + name: "allow delete", + shouldAllow: true, + }, + { + name: "deny delete", + shouldAllow: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkFunc := func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) { + require.NotNil(t, id) + require.Equal(t, "user:u001", id.GetUID()) + require.Equal(t, "org-2", id.GetNamespace()) + + require.Equal(t, "org-2", req.Namespace) + require.Equal(t, iamv0.GROUP, req.Group) + require.Equal(t, iamv0.TeamResourceInfo.GetName(), req.Resource) + require.Equal(t, "team-1", req.Name) + require.Equal(t, utils.VerbSetPermissions, req.Verb) + require.Equal(t, "", folder) + + return types.CheckResponse{Allowed: tt.shouldAllow}, nil + } + + accessClient := &fakeAccessClient{checkFunc: checkFunc} + authz := NewExternalGroupMappingAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + err := authz.BeforeDelete(ctx, mapping) + if tt.shouldAllow { + require.NoError(t, err) + } else { + require.Error(t, err) + } + require.True(t, accessClient.checkCalled) + }) + } +} diff --git a/pkg/registry/apis/iam/authorizer/resource_permissions.go b/pkg/registry/apis/iam/authorizer/resource_permissions.go index 0fbf413adac..098037c93b7 100644 --- a/pkg/registry/apis/iam/authorizer/resource_permissions.go +++ b/pkg/registry/apis/iam/authorizer/resource_permissions.go @@ -179,19 +179,17 @@ func (r *ResourcePermissionsAuthorizer) FilterList(ctx context.Context, list run canViewFuncs = map[schema.GroupResource]types.ItemChecker{} ) for _, item := range l.Items { - gr := schema.GroupResource{ - Group: item.Spec.Resource.ApiGroup, - Resource: item.Spec.Resource.Resource, - } + target := item.Spec.Resource + targetGR := schema.GroupResource{Group: target.ApiGroup, Resource: target.Resource} // Reuse the same canView for items with the same resource - canView, found := canViewFuncs[gr] + canView, found := canViewFuncs[targetGR] if !found { listReq := types.ListRequest{ Namespace: item.Namespace, - Group: item.Spec.Resource.ApiGroup, - Resource: item.Spec.Resource.Resource, + Group: target.ApiGroup, + Resource: target.Resource, Verb: utils.VerbGetPermissions, } @@ -200,12 +198,9 @@ func (r *ResourcePermissionsAuthorizer) FilterList(ctx context.Context, list run return nil, err } - canViewFuncs[gr] = canView + canViewFuncs[targetGR] = canView } - target := item.Spec.Resource - targetGR := schema.GroupResource{Group: target.ApiGroup, Resource: target.Resource} - parent := "" // Fetch the parent of the resource // It's not efficient to do for every item in the list, but it's a good starting point. diff --git a/pkg/registry/apis/iam/authorizer/resource_permissions_test.go b/pkg/registry/apis/iam/authorizer/resource_permissions_test.go index df777ee31e9..9e12bd87ebf 100644 --- a/pkg/registry/apis/iam/authorizer/resource_permissions_test.go +++ b/pkg/registry/apis/iam/authorizer/resource_permissions_test.go @@ -4,35 +4,15 @@ import ( "context" "testing" - "github.com/go-jose/go-jose/v4/jwt" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" - "github.com/grafana/authlib/authn" "github.com/grafana/authlib/types" iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" - "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" ) -var ( - user = authn.NewIDTokenAuthInfo( - authn.Claims[authn.AccessTokenClaims]{ - Claims: jwt.Claims{Issuer: "grafana", - Subject: types.NewTypeID(types.TypeAccessPolicy, "grafana"), Audience: []string{"iam.grafana.app"}}, - Rest: authn.AccessTokenClaims{ - Namespace: "*", - Permissions: identity.ServiceIdentityClaims.Rest.Permissions, - DelegatedPermissions: identity.ServiceIdentityClaims.Rest.DelegatedPermissions, - }, - }, &authn.Claims[authn.IDTokenClaims]{ - Claims: jwt.Claims{Subject: types.NewTypeID(types.TypeUser, "u001")}, - Rest: authn.IDTokenClaims{Namespace: "org-2", Identifier: "u001", Type: types.TypeUser}, - }, - ) -) - func newResourcePermission(apiGroup, resource, name string) *iamv0.ResourcePermission { return &iamv0.ResourcePermission{ ObjectMeta: metav1.ObjectMeta{Namespace: "org-2"}, @@ -222,26 +202,6 @@ func TestResourcePermissions_beforeWrite(t *testing.T) { } } -// fakeAccessClient is a mock implementation of claims.AccessClient -type fakeAccessClient struct { - checkCalled bool - checkFunc func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) - compileCalled bool - compileFunc func(id types.AuthInfo, req types.ListRequest) (types.ItemChecker, types.Zookie, error) -} - -func (m *fakeAccessClient) Check(ctx context.Context, id types.AuthInfo, req types.CheckRequest, folder string) (types.CheckResponse, error) { - m.checkCalled = true - return m.checkFunc(id, &req, folder) -} - -func (m *fakeAccessClient) Compile(ctx context.Context, id types.AuthInfo, req types.ListRequest) (types.ItemChecker, types.Zookie, error) { - m.compileCalled = true - return m.compileFunc(id, req) -} - -var _ types.AccessClient = (*fakeAccessClient)(nil) - type fakeParentProvider struct { hasParent bool getParentCalled bool diff --git a/pkg/registry/apis/iam/authorizer/team_binding_authorizer.go b/pkg/registry/apis/iam/authorizer/team_binding_authorizer.go new file mode 100644 index 00000000000..2a4f5ae5e51 --- /dev/null +++ b/pkg/registry/apis/iam/authorizer/team_binding_authorizer.go @@ -0,0 +1,156 @@ +package authorizer + +import ( + "context" + "fmt" + + "github.com/grafana/authlib/types" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer/storewrapper" +) + +type TeamBindingAuthorizer struct { + accessClient types.AccessClient +} + +var _ storewrapper.ResourceStorageAuthorizer = (*TeamBindingAuthorizer)(nil) + +func NewTeamBindingAuthorizer( + accessClient types.AccessClient, +) *TeamBindingAuthorizer { + return &TeamBindingAuthorizer{ + accessClient: accessClient, + } +} + +// AfterGet implements ResourceStorageAuthorizer. +func (r *TeamBindingAuthorizer) AfterGet(ctx context.Context, obj runtime.Object) error { + authInfo, ok := types.AuthInfoFrom(ctx) + if !ok { + return storewrapper.ErrUnauthenticated + } + + concreteObj, ok := obj.(*iamv0.TeamBinding) + if !ok { + return apierrors.NewInternalError(fmt.Errorf("expected TeamBinding, got %T: %w", obj, storewrapper.ErrUnexpectedType)) + } + + // Accesscontrol should check on the TeamResourceInfo group resource if the user can use VerbGetPermissions + // on the team (TeamRef.Name) (handled below) OR if the subject's name (TeamBindingSpec.Subject.Name) is equal to the current Identity's UID/Identifier. + if concreteObj.Spec.Subject.Name == authInfo.GetIdentifier() { + return nil + } + + teamName := concreteObj.Spec.TeamRef.Name + checkReq := types.CheckRequest{ + Namespace: authInfo.GetNamespace(), + Group: iamv0.TeamResourceInfo.GroupResource().Group, + Resource: iamv0.TeamResourceInfo.GroupResource().Resource, + Verb: utils.VerbGetPermissions, + Name: teamName, + } + res, err := r.accessClient.Check(ctx, authInfo, checkReq, "") + if err != nil { + return apierrors.NewInternalError(err) + } + + if !res.Allowed { + return apierrors.NewForbidden( + iamv0.TeamBindingResourceInfo.GroupResource(), + concreteObj.Name, + fmt.Errorf("user cannot access team %s", teamName), + ) + } + return nil +} + +// BeforeCreate implements ResourceStorageAuthorizer. +func (r *TeamBindingAuthorizer) BeforeCreate(ctx context.Context, obj runtime.Object) error { + return r.beforeWrite(ctx, obj) +} + +// BeforeDelete implements ResourceStorageAuthorizer. +func (r *TeamBindingAuthorizer) BeforeDelete(ctx context.Context, obj runtime.Object) error { + return r.beforeWrite(ctx, obj) +} + +// BeforeUpdate implements ResourceStorageAuthorizer. +func (r *TeamBindingAuthorizer) BeforeUpdate(ctx context.Context, obj runtime.Object) error { + return r.beforeWrite(ctx, obj) +} + +func (r *TeamBindingAuthorizer) beforeWrite(ctx context.Context, obj runtime.Object) error { + authInfo, ok := types.AuthInfoFrom(ctx) + if !ok { + return storewrapper.ErrUnauthenticated + } + + concreteObj, ok := obj.(*iamv0.TeamBinding) + if !ok { + return apierrors.NewInternalError(fmt.Errorf("expected TeamBinding, got %T: %w", obj, storewrapper.ErrUnexpectedType)) + } + + teamName := concreteObj.Spec.TeamRef.Name + checkReq := types.CheckRequest{ + Namespace: authInfo.GetNamespace(), + Group: iamv0.GROUP, + Resource: iamv0.TeamResourceInfo.GetName(), + Verb: utils.VerbSetPermissions, + Name: teamName, + } + + res, err := r.accessClient.Check(ctx, authInfo, checkReq, "") + if err != nil { + return apierrors.NewInternalError(err) + } + + if !res.Allowed { + return apierrors.NewForbidden( + iamv0.TeamBindingResourceInfo.GroupResource(), + concreteObj.Name, + fmt.Errorf("user cannot write team %s", teamName), + ) + } + return nil +} + +// FilterList implements ResourceStorageAuthorizer. +func (r *TeamBindingAuthorizer) FilterList(ctx context.Context, list runtime.Object) (runtime.Object, error) { + authInfo, ok := types.AuthInfoFrom(ctx) + if !ok { + return nil, storewrapper.ErrUnauthenticated + } + + l, ok := list.(*iamv0.TeamBindingList) + if !ok { + return nil, apierrors.NewInternalError(fmt.Errorf("expected TeamBindingList, got %T: %w", list, storewrapper.ErrUnexpectedType)) + } + + var filteredItems []iamv0.TeamBinding + + listReq := types.ListRequest{ + Namespace: authInfo.GetNamespace(), + Group: iamv0.TeamResourceInfo.GroupResource().Group, + Resource: iamv0.TeamResourceInfo.GroupResource().Resource, + Verb: utils.VerbGetPermissions, + } + canView, _, err := r.accessClient.Compile(ctx, authInfo, listReq) + if err != nil { + return nil, apierrors.NewInternalError(err) + } + + for _, item := range l.Items { + // Accesscontrol should check on the TeamResourceInfo group resource if the user can use VerbGetPermissions + // on the team (TeamRef.Name) OR if the subject's name (TeamBindingSpec.Subject.Name) is equal to the current Identity's UID/Identifier. + if item.Spec.Subject.Name == authInfo.GetIdentifier() || canView(item.Spec.TeamRef.Name, "") { + filteredItems = append(filteredItems, item) + } + } + + l.Items = filteredItems + return l, nil +} diff --git a/pkg/registry/apis/iam/authorizer/team_binding_authorizer_test.go b/pkg/registry/apis/iam/authorizer/team_binding_authorizer_test.go new file mode 100644 index 00000000000..f3bf8795de1 --- /dev/null +++ b/pkg/registry/apis/iam/authorizer/team_binding_authorizer_test.go @@ -0,0 +1,253 @@ +package authorizer + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/grafana/authlib/types" + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" +) + +func newTeamBinding(teamName, name, subjectName string) *iamv0.TeamBinding { + return &iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{Namespace: "org-2", Name: name}, + Spec: iamv0.TeamBindingSpec{ + TeamRef: iamv0.TeamBindingTeamRef{ + Name: teamName, + }, + Subject: iamv0.TeamBindingspecSubject{ + Name: subjectName, + }, + }, + } +} + +func TestTeamBinding_AfterGet(t *testing.T) { + tests := []struct { + name string + teamBinding *iamv0.TeamBinding + shouldAllow bool + checkCalled bool + }{ + { + name: "allow access via permission", + teamBinding: newTeamBinding("team-1", "binding-1", "other"), + shouldAllow: true, + checkCalled: true, + }, + { + name: "deny access", + teamBinding: newTeamBinding("team-1", "binding-1", "other"), + shouldAllow: false, + checkCalled: true, // called but returns allowed=false + }, + { + name: "allow access via subject match", + teamBinding: newTeamBinding("team-1", "binding-1", "u001"), + shouldAllow: true, + checkCalled: false, // short-circuits + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkFunc := func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) { + require.NotNil(t, id) + require.Equal(t, "u001", id.GetIdentifier()) + + require.Equal(t, "org-2", req.Namespace) + require.Equal(t, iamv0.GROUP, req.Group) + require.Equal(t, iamv0.TeamResourceInfo.GetName(), req.Resource) + require.Equal(t, "team-1", req.Name) + require.Equal(t, utils.VerbGetPermissions, req.Verb) + + return types.CheckResponse{Allowed: tt.shouldAllow}, nil + } + + accessClient := &fakeAccessClient{checkFunc: checkFunc} + authz := NewTeamBindingAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + err := authz.AfterGet(ctx, tt.teamBinding) + if tt.shouldAllow { + require.NoError(t, err) + } else { + require.Error(t, err) + } + require.Equal(t, tt.checkCalled, accessClient.checkCalled) + }) + } +} + +func TestTeamBinding_FilterList(t *testing.T) { + list := &iamv0.TeamBindingList{ + Items: []iamv0.TeamBinding{ + *newTeamBinding("team-1", "binding-1", "other"), // Access via permission + *newTeamBinding("team-2", "binding-2", "other"), // No access + *newTeamBinding("team-3", "binding-3", "u001"), // Access via subject match + }, + } + + compileFunc := func(id types.AuthInfo, req types.ListRequest) (types.ItemChecker, types.Zookie, error) { + require.NotNil(t, id) + require.Equal(t, "u001", id.GetIdentifier()) + + require.Equal(t, "org-2", req.Namespace) + require.Equal(t, iamv0.GROUP, req.Group) + require.Equal(t, iamv0.TeamResourceInfo.GroupResource().Resource, req.Resource) + require.Equal(t, utils.VerbGetPermissions, req.Verb) + + return func(name, folder string) bool { + return name == "team-1" + }, &types.NoopZookie{}, nil + } + + accessClient := &fakeAccessClient{compileFunc: compileFunc} + authz := NewTeamBindingAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + obj, err := authz.FilterList(ctx, list) + require.NoError(t, err) + require.NotNil(t, list) + require.True(t, accessClient.compileCalled) + + filtered, ok := obj.(*iamv0.TeamBindingList) + require.True(t, ok) + require.Len(t, filtered.Items, 2) + + names := []string{filtered.Items[0].Name, filtered.Items[1].Name} + require.Contains(t, names, "binding-1") + require.Contains(t, names, "binding-3") +} + +func TestTeamBinding_BeforeCreate(t *testing.T) { + binding := newTeamBinding("team-1", "binding-1", "other") + + tests := []struct { + name string + shouldAllow bool + }{ + { + name: "allow create", + shouldAllow: true, + }, + { + name: "deny create", + shouldAllow: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkFunc := func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) { + require.Equal(t, "org-2", req.Namespace) + require.Equal(t, iamv0.GROUP, req.Group) + require.Equal(t, iamv0.TeamResourceInfo.GetName(), req.Resource) + require.Equal(t, "team-1", req.Name) + require.Equal(t, utils.VerbSetPermissions, req.Verb) + + return types.CheckResponse{Allowed: tt.shouldAllow}, nil + } + + accessClient := &fakeAccessClient{checkFunc: checkFunc} + authz := NewTeamBindingAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + err := authz.BeforeCreate(ctx, binding) + if tt.shouldAllow { + require.NoError(t, err) + } else { + require.Error(t, err) + } + require.True(t, accessClient.checkCalled) + }) + } +} + +func TestTeamBinding_BeforeUpdate(t *testing.T) { + binding := newTeamBinding("team-1", "binding-1", "other") + + tests := []struct { + name string + shouldAllow bool + }{ + { + name: "allow update", + shouldAllow: true, + }, + { + name: "deny update", + shouldAllow: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkFunc := func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) { + require.Equal(t, "org-2", req.Namespace) + require.Equal(t, iamv0.GROUP, req.Group) + require.Equal(t, iamv0.TeamResourceInfo.GetName(), req.Resource) + require.Equal(t, "team-1", req.Name) + require.Equal(t, utils.VerbSetPermissions, req.Verb) + + return types.CheckResponse{Allowed: tt.shouldAllow}, nil + } + + accessClient := &fakeAccessClient{checkFunc: checkFunc} + authz := NewTeamBindingAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + err := authz.BeforeUpdate(ctx, binding) + if tt.shouldAllow { + require.NoError(t, err) + } else { + require.Error(t, err) + } + require.True(t, accessClient.checkCalled) + }) + } +} + +func TestTeamBinding_BeforeDelete(t *testing.T) { + binding := newTeamBinding("team-1", "binding-1", "other") + + tests := []struct { + name string + shouldAllow bool + }{ + { + name: "allow delete", + shouldAllow: true, + }, + { + name: "deny delete", + shouldAllow: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkFunc := func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) { + require.Equal(t, "org-2", req.Namespace) + require.Equal(t, iamv0.GROUP, req.Group) + require.Equal(t, iamv0.TeamResourceInfo.GetName(), req.Resource) + require.Equal(t, "team-1", req.Name) + require.Equal(t, utils.VerbSetPermissions, req.Verb) + + return types.CheckResponse{Allowed: tt.shouldAllow}, nil + } + + accessClient := &fakeAccessClient{checkFunc: checkFunc} + authz := NewTeamBindingAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + err := authz.BeforeDelete(ctx, binding) + if tt.shouldAllow { + require.NoError(t, err) + } else { + require.Error(t, err) + } + require.True(t, accessClient.checkCalled) + }) + } +} diff --git a/pkg/registry/apis/iam/authorizer/testutil.go b/pkg/registry/apis/iam/authorizer/testutil.go new file mode 100644 index 00000000000..37e40596583 --- /dev/null +++ b/pkg/registry/apis/iam/authorizer/testutil.go @@ -0,0 +1,48 @@ +package authorizer + +import ( + "context" + + "github.com/go-jose/go-jose/v4/jwt" + "github.com/grafana/authlib/authn" + "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/identity" +) + +var ( + // Shared test user identity + user = authn.NewIDTokenAuthInfo( + authn.Claims[authn.AccessTokenClaims]{ + Claims: jwt.Claims{Issuer: "grafana", + Subject: types.NewTypeID(types.TypeAccessPolicy, "grafana"), Audience: []string{"iam.grafana.app"}}, + Rest: authn.AccessTokenClaims{ + Namespace: "*", + Permissions: identity.ServiceIdentityClaims.Rest.Permissions, + DelegatedPermissions: identity.ServiceIdentityClaims.Rest.DelegatedPermissions, + }, + }, &authn.Claims[authn.IDTokenClaims]{ + Claims: jwt.Claims{Subject: types.NewTypeID(types.TypeUser, "u001")}, + Rest: authn.IDTokenClaims{Namespace: "org-2", Identifier: "u001", Type: types.TypeUser}, + }, + ) +) + +var _ types.AccessClient = (*fakeAccessClient)(nil) + +// fakeAccessClient is a mock implementation of claims.AccessClient +type fakeAccessClient struct { + checkCalled bool + checkFunc func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) + compileCalled bool + compileFunc func(id types.AuthInfo, req types.ListRequest) (types.ItemChecker, types.Zookie, error) +} + +func (m *fakeAccessClient) Check(ctx context.Context, id types.AuthInfo, req types.CheckRequest, folder string) (types.CheckResponse, error) { + m.checkCalled = true + return m.checkFunc(id, &req, folder) +} + +func (m *fakeAccessClient) Compile(ctx context.Context, id types.AuthInfo, req types.ListRequest) (types.ItemChecker, types.Zookie, error) { + m.compileCalled = true + return m.compileFunc(id, req) +} diff --git a/pkg/registry/apis/iam/common/common.go b/pkg/registry/apis/iam/common/common.go index b508084bcae..a5409ce5ac4 100644 --- a/pkg/registry/apis/iam/common/common.go +++ b/pkg/registry/apis/iam/common/common.go @@ -12,6 +12,7 @@ import ( legacyiamv0 "github.com/grafana/grafana/pkg/apis/iam/v0alpha1" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/team" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // OptonalFormatInt formats num as a string. If num is less or equal than 0 @@ -39,23 +40,17 @@ func MapUserTeamPermission(p team.PermissionType) legacyiamv0.TeamPermission { } } -// Resource is required to be implemented for list return types so we can -// perform authorization. -type Resource interface { - AuthID() string -} - -type ListResponse[T Resource] struct { +type ListResponse[T metav1.Object] struct { Items []T RV int64 Continue int64 } -type ListFunc[T Resource] func(ctx context.Context, ns authlib.NamespaceInfo, p Pagination) (*ListResponse[T], error) +type ListFunc[T metav1.Object] func(ctx context.Context, ns authlib.NamespaceInfo, p Pagination) (*ListResponse[T], error) // List is a helper function that will perform access check on resources if // prvovided with a authlib.AccessClient. -func List[T Resource]( +func List[T metav1.Object]( ctx context.Context, resource utils.ResourceInfo, ac authlib.AccessClient, @@ -78,7 +73,7 @@ func List[T Resource]( check, _, err = ac.Compile(ctx, ident, authlib.ListRequest{ Resource: resource.GroupResource().Resource, Group: resource.GroupResource().Group, - Verb: "list", + Verb: utils.VerbList, Namespace: ns.Value, }) @@ -95,7 +90,7 @@ func List[T Resource]( } for _, item := range first.Items { - if !check(item.AuthID(), "") { + if !check(item.GetName(), "") { continue } res.Items = append(res.Items, item) @@ -118,7 +113,7 @@ outer: break outer } - if !check(item.AuthID(), "") { + if !check(item.GetName(), "") { continue } diff --git a/pkg/registry/apis/iam/common/common_test.go b/pkg/registry/apis/iam/common/common_test.go index d835c238b8c..ea079043fd1 100644 --- a/pkg/registry/apis/iam/common/common_test.go +++ b/pkg/registry/apis/iam/common/common_test.go @@ -5,6 +5,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" "k8s.io/apiserver/pkg/endpoints/request" authlib "github.com/grafana/authlib/types" @@ -15,14 +17,6 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" ) -type item struct { - id string -} - -func (i item) AuthID() string { - return i.id -} - func TestList(t *testing.T) { ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures()) @@ -83,8 +77,8 @@ func TestList(t *testing.T) { assert.NoError(t, err) assert.Len(t, res.Items, 2) - assert.Equal(t, "1", res.Items[0].AuthID()) - assert.Equal(t, "3", res.Items[1].AuthID()) + assert.Equal(t, "1", res.Items[0].GetName()) + assert.Equal(t, "3", res.Items[1].GetName()) }) } @@ -103,3 +97,158 @@ func newIdent(permissions ...accesscontrol.Permission) *identity.StaticRequester Permissions: map[int64]map[string][]string{1: pmap}, } } + +var _ metav1.Object = (*item)(nil) + +type item struct { + id string +} + +// GetAnnotations implements v1.Object. +func (i item) GetAnnotations() map[string]string { + panic("unimplemented") +} + +// GetCreationTimestamp implements v1.Object. +func (i item) GetCreationTimestamp() metav1.Time { + panic("unimplemented") +} + +// GetDeletionGracePeriodSeconds implements v1.Object. +func (i item) GetDeletionGracePeriodSeconds() *int64 { + panic("unimplemented") +} + +// GetDeletionTimestamp implements v1.Object. +func (i item) GetDeletionTimestamp() *metav1.Time { + panic("unimplemented") +} + +// GetFinalizers implements v1.Object. +func (i item) GetFinalizers() []string { + panic("unimplemented") +} + +// GetGenerateName implements v1.Object. +func (i item) GetGenerateName() string { + panic("unimplemented") +} + +// GetGeneration implements v1.Object. +func (i item) GetGeneration() int64 { + panic("unimplemented") +} + +// GetLabels implements v1.Object. +func (i item) GetLabels() map[string]string { + panic("unimplemented") +} + +// GetManagedFields implements v1.Object. +func (i item) GetManagedFields() []metav1.ManagedFieldsEntry { + panic("unimplemented") +} + +// GetNamespace implements v1.Object. +func (i item) GetNamespace() string { + panic("unimplemented") +} + +// GetOwnerReferences implements v1.Object. +func (i item) GetOwnerReferences() []metav1.OwnerReference { + panic("unimplemented") +} + +// GetResourceVersion implements v1.Object. +func (i item) GetResourceVersion() string { + panic("unimplemented") +} + +// GetSelfLink implements v1.Object. +func (i item) GetSelfLink() string { + panic("unimplemented") +} + +// GetUID implements v1.Object. +func (i item) GetUID() types.UID { + panic("unimplemented") +} + +// SetAnnotations implements v1.Object. +func (i item) SetAnnotations(annotations map[string]string) { + panic("unimplemented") +} + +// SetCreationTimestamp implements v1.Object. +func (i item) SetCreationTimestamp(timestamp metav1.Time) { + panic("unimplemented") +} + +// SetDeletionGracePeriodSeconds implements v1.Object. +func (i item) SetDeletionGracePeriodSeconds(*int64) { + panic("unimplemented") +} + +// SetDeletionTimestamp implements v1.Object. +func (i item) SetDeletionTimestamp(timestamp *metav1.Time) { + panic("unimplemented") +} + +// SetFinalizers implements v1.Object. +func (i item) SetFinalizers(finalizers []string) { + panic("unimplemented") +} + +// SetGenerateName implements v1.Object. +func (i item) SetGenerateName(name string) { + panic("unimplemented") +} + +// SetGeneration implements v1.Object. +func (i item) SetGeneration(generation int64) { + panic("unimplemented") +} + +// SetLabels implements v1.Object. +func (i item) SetLabels(labels map[string]string) { + panic("unimplemented") +} + +// SetManagedFields implements v1.Object. +func (i item) SetManagedFields(managedFields []metav1.ManagedFieldsEntry) { + panic("unimplemented") +} + +// SetName implements v1.Object. +func (i item) SetName(name string) { + panic("unimplemented") +} + +// SetNamespace implements v1.Object. +func (i item) SetNamespace(namespace string) { + panic("unimplemented") +} + +// SetOwnerReferences implements v1.Object. +func (i item) SetOwnerReferences([]metav1.OwnerReference) { + panic("unimplemented") +} + +// SetResourceVersion implements v1.Object. +func (i item) SetResourceVersion(version string) { + panic("unimplemented") +} + +// SetSelfLink implements v1.Object. +func (i item) SetSelfLink(selfLink string) { + panic("unimplemented") +} + +// SetUID implements v1.Object. +func (i item) SetUID(uid types.UID) { + panic("unimplemented") +} + +func (i item) GetName() string { + return i.id +} diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index 8a3dcb9586b..895f1d0bf22 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -5,7 +5,9 @@ import ( "fmt" "maps" "strings" + "time" + "github.com/open-feature/go-sdk/openfeature" "github.com/prometheus/client_golang/prometheus" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -102,7 +104,7 @@ func RegisterAPIService( store: store, userLegacyStore: user.NewLegacyStore(store, accessClient, enableAuthnMutation, tracing), saLegacyStore: serviceaccount.NewLegacyStore(store, accessClient, enableAuthnMutation, tracing), - legacyTeamStore: team.NewLegacyStore(store, legacyAccessClient, enableAuthnMutation, tracing), + legacyTeamStore: team.NewLegacyStore(store, accessClient, enableAuthnMutation, tracing), teamBindingLegacyStore: teambinding.NewLegacyBindingStore(store, enableAuthnMutation, tracing), ssoLegacyStore: sso.NewLegacyStore(ssoService, tracing), coreRolesStorage: coreRolesStorage, @@ -209,8 +211,16 @@ func (b *IdentityAccessManagementAPIBuilder) GetGroupVersion() schema.GroupVersi } func (b *IdentityAccessManagementAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { - //nolint:staticcheck // not yet migrated to OpenFeature - if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzApis) { + client := openfeature.NewDefaultClient() + ctx, cancelFn := context.WithTimeout(context.Background(), time.Second*5) + defer cancelFn() + + // Check if any of the AuthZ APIs are enabled + enableCoreRolesApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzCoreRolesApi, false, openfeature.TransactionContext(ctx)) + enableRolesApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzRolesApi, false, openfeature.TransactionContext(ctx)) + enableRoleBindingsApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzRoleBindingsApi, false, openfeature.TransactionContext(ctx)) + + if enableCoreRolesApi || enableRolesApi || enableRoleBindingsApi { if err := iamv0.AddAuthZKnownTypes(scheme); err != nil { return err } @@ -244,9 +254,17 @@ func (b *IdentityAccessManagementAPIBuilder) AllowedV0Alpha1Resources() []string func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error { storage := map[string]rest.Storage{} + client := openfeature.NewDefaultClient() + ctx, cancelFn := context.WithTimeout(context.Background(), time.Second*5) + defer cancelFn() + //nolint:staticcheck // not yet migrated to OpenFeature enableZanzanaSync := b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzZanzanaSync) + enableCoreRolesApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzCoreRolesApi, false, openfeature.TransactionContext(ctx)) + enableRolesApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzRolesApi, false, openfeature.TransactionContext(ctx)) + enableRoleBindingsApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzRoleBindingsApi, false, openfeature.TransactionContext(ctx)) + // teams + users must have shorter names because they are often used as part of another name opts.StorageOptsRegister(iamv0.TeamResourceInfo.GroupResource(), apistore.StorageOptions{ MaximumNameLength: 80, @@ -255,6 +273,64 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge MaximumNameLength: 80, }) + if err := b.UpdateTeamsAPIGroup(opts, storage); err != nil { + return err + } + + if err := b.UpdateTeamBindingsAPIGroup(opts, storage, enableZanzanaSync); err != nil { + return err + } + + if err := b.UpdateUsersAPIGroup(opts, storage, enableZanzanaSync); err != nil { + return err + } + + if err := b.UpdateServiceAccountsAPIGroup(opts, storage); err != nil { + return err + } + + // SSO settings apis + if b.ssoLegacyStore != nil { + ssoResource := legacyiamv0.SSOSettingResourceInfo + storage[ssoResource.StoragePath()] = b.ssoLegacyStore + } + + if err := b.UpdateExternalGroupMappingAPIGroup(apiGroupInfo, opts, storage); err != nil { + return err + } + + if enableCoreRolesApi { + // v0alpha1 + if err := b.UpdateCoreRolesAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil { + return err + } + } + + if enableRolesApi { + // Role registration is delegated to the RoleApiInstaller + if err := b.roleApiInstaller.RegisterStorage(apiGroupInfo, &opts, storage); err != nil { + return err + } + } + + if enableRoleBindingsApi { + if err := b.UpdateRoleBindingsAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil { + return err + } + } + + //nolint:staticcheck // not yet migrated to OpenFeature + if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzResourcePermissionApis) { + if err := b.UpdateResourcePermissionsAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil { + return err + } + } + + apiGroupInfo.VersionedResourcesStorageMap[legacyiamv0.VERSION] = storage + return nil +} + +func (b *IdentityAccessManagementAPIBuilder) UpdateTeamsAPIGroup(opts builder.APIGroupOptions, storage map[string]rest.Storage) error { teamResource := iamv0.TeamResourceInfo teamUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, teamResource, opts.OptsGetter) if err != nil { @@ -271,17 +347,21 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge storage[teamResource.StoragePath()] = dw } - storage[teamResource.StoragePath("members")] = team.NewLegacyTeamMemberREST(b.store) + storage[teamResource.StoragePath("members")] = team.NewLegacyTeamMemberREST(b.store, b.accessClient) if b.teamGroupsHandler != nil { storage[teamResource.StoragePath("groups")] = b.teamGroupsHandler } + return nil +} + +func (b *IdentityAccessManagementAPIBuilder) UpdateTeamBindingsAPIGroup(opts builder.APIGroupOptions, storage map[string]rest.Storage, enableZanzanaSync bool) error { teamBindingResource := iamv0.TeamBindingResourceInfo teamBindingUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, teamBindingResource, opts.OptsGetter) if err != nil { return err } - storage[teamBindingResource.StoragePath()] = teamBindingUniStore + var teamBindingStore storewrapper.K8sStorage = teamBindingUniStore // Only teamBindingStore exposes the AfterCreate, AfterDelete, and BeginUpdate hooks if enableZanzanaSync { @@ -296,10 +376,20 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge if err != nil { return err } - storage[teamBindingResource.StoragePath()] = dw + + var ok bool + teamBindingStore, ok = dw.(storewrapper.K8sStorage) + if !ok { + return fmt.Errorf("expected storewrapper.K8sStorage, got %T", dw) + } } - // User store registration + authzWrapper := storewrapper.New(teamBindingStore, iamauthorizer.NewTeamBindingAuthorizer(b.accessClient)) + storage[teamBindingResource.StoragePath()] = authzWrapper + return nil +} + +func (b *IdentityAccessManagementAPIBuilder) UpdateUsersAPIGroup(opts builder.APIGroupOptions, storage map[string]rest.Storage, enableZanzanaSync bool) error { userResource := iamv0.UserResourceInfo userUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, userResource, opts.OptsGetter) if err != nil { @@ -325,7 +415,10 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge storage[userResource.StoragePath("teams")] = user.NewLegacyTeamMemberREST(b.store) - // Service Accounts store registration + return nil +} + +func (b *IdentityAccessManagementAPIBuilder) UpdateServiceAccountsAPIGroup(opts builder.APIGroupOptions, storage map[string]rest.Storage) error { saResource := iamv0.ServiceAccountResourceInfo saUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, saResource, opts.OptsGetter) if err != nil { @@ -343,17 +436,17 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge storage[saResource.StoragePath("tokens")] = serviceaccount.NewLegacyTokenREST(b.store) - if b.ssoLegacyStore != nil { - ssoResource := legacyiamv0.SSOSettingResourceInfo - storage[ssoResource.StoragePath()] = b.ssoLegacyStore - } + return nil +} +func (b *IdentityAccessManagementAPIBuilder) UpdateExternalGroupMappingAPIGroup(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions, storage map[string]rest.Storage) error { extGroupMappingResource := iamv0.ExternalGroupMappingResourceInfo extGroupMappingUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, extGroupMappingResource, opts.OptsGetter) if err != nil { return err } - storage[extGroupMappingResource.StoragePath()] = extGroupMappingUniStore + + var extGroupMappingStore storewrapper.K8sStorage = extGroupMappingUniStore if b.externalGroupMappingStorage != nil { extGroupMappingLegacyStore, err := NewLocalStore(extGroupMappingResource, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.externalGroupMappingStorage) @@ -365,50 +458,57 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge if err != nil { return err } - storage[extGroupMappingResource.StoragePath()] = dw - } - //nolint:staticcheck // not yet migrated to OpenFeature - if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzApis) { - // v0alpha1 - coreRoleStore, err := NewLocalStore(iamv0.CoreRoleInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.coreRolesStorage) - if err != nil { - return err - } - if enableZanzanaSync { - b.logger.Info("Enabling hooks for CoreRole to sync to Zanzana") - h := NewRoleHooks(b.zClient, b.zTickets, b.logger) - coreRoleStore.AfterCreate = h.AfterRoleCreate - coreRoleStore.AfterDelete = h.AfterRoleDelete - coreRoleStore.BeginUpdate = h.BeginRoleUpdate - } - storage[iamv0.CoreRoleInfo.StoragePath()] = coreRoleStore - - // Role registration is delegated to the RoleApiInstaller - if err := b.roleApiInstaller.RegisterStorage(apiGroupInfo, &opts, storage); err != nil { - return err - } - - roleBindingStore, err := NewLocalStore(iamv0.RoleBindingInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.roleBindingsStorage) - if err != nil { - return err - } - if enableZanzanaSync { - b.logger.Info("Enabling hooks for RoleBinding to sync to Zanzana") - roleBindingStore.AfterCreate = b.AfterRoleBindingCreate - roleBindingStore.AfterDelete = b.AfterRoleBindingDelete - roleBindingStore.BeginUpdate = b.BeginRoleBindingUpdate - } - storage[iamv0.RoleBindingInfo.StoragePath()] = roleBindingStore - } - //nolint:staticcheck // not yet migrated to OpenFeature - if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzResourcePermissionApis) { - if err := b.UpdateResourcePermissionsAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil { - return err + var ok bool + extGroupMappingStore, ok = dw.(storewrapper.K8sStorage) + if !ok { + return fmt.Errorf("expected storewrapper.K8sStorage, got %T", dw) } } - apiGroupInfo.VersionedResourcesStorageMap[legacyiamv0.VERSION] = storage + authzWrapper := storewrapper.New(extGroupMappingStore, iamauthorizer.NewExternalGroupMappingAuthorizer(b.accessClient)) + storage[extGroupMappingResource.StoragePath()] = authzWrapper + return nil +} + +func (b *IdentityAccessManagementAPIBuilder) UpdateCoreRolesAPIGroup( + apiGroupInfo *genericapiserver.APIGroupInfo, + opts builder.APIGroupOptions, + storage map[string]rest.Storage, + enableZanzanaSync bool, +) error { + coreRoleStore, err := NewLocalStore(iamv0.CoreRoleInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.coreRolesStorage) + if err != nil { + return err + } + if enableZanzanaSync { + b.logger.Info("Enabling hooks for CoreRole to sync to Zanzana") + h := NewRoleHooks(b.zClient, b.zTickets, b.logger) + coreRoleStore.AfterCreate = h.AfterRoleCreate + coreRoleStore.AfterDelete = h.AfterRoleDelete + coreRoleStore.BeginUpdate = h.BeginRoleUpdate + } + storage[iamv0.CoreRoleInfo.StoragePath()] = coreRoleStore + return nil +} + +func (b *IdentityAccessManagementAPIBuilder) UpdateRoleBindingsAPIGroup( + apiGroupInfo *genericapiserver.APIGroupInfo, + opts builder.APIGroupOptions, + storage map[string]rest.Storage, + enableZanzanaSync bool, +) error { + roleBindingStore, err := NewLocalStore(iamv0.RoleBindingInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.roleBindingsStorage) + if err != nil { + return err + } + if enableZanzanaSync { + b.logger.Info("Enabling hooks for RoleBinding to sync to Zanzana") + roleBindingStore.AfterCreate = b.AfterRoleBindingCreate + roleBindingStore.AfterDelete = b.AfterRoleBindingDelete + roleBindingStore.BeginUpdate = b.BeginRoleBindingUpdate + } + storage[iamv0.RoleBindingInfo.StoragePath()] = roleBindingStore return nil } diff --git a/pkg/registry/apis/iam/serviceaccount/store.go b/pkg/registry/apis/iam/serviceaccount/store.go index db4709a4e2e..6d1ac774174 100644 --- a/pkg/registry/apis/iam/serviceaccount/store.go +++ b/pkg/registry/apis/iam/serviceaccount/store.go @@ -178,7 +178,7 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt res, err := common.List( ctx, resource, s.ac, common.PaginationFromListOptions(options), - func(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (*common.ListResponse[iamv0alpha1.ServiceAccount], error) { + func(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (*common.ListResponse[*iamv0alpha1.ServiceAccount], error) { found, err := s.store.ListServiceAccounts(ctx, ns, legacy.ListServiceAccountsQuery{ Pagination: p, }) @@ -187,12 +187,13 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt return nil, err } - items := make([]iamv0alpha1.ServiceAccount, 0, len(found.Items)) + items := make([]*iamv0alpha1.ServiceAccount, 0, len(found.Items)) for _, sa := range found.Items { - items = append(items, s.toSAItem(sa, ns.Value)) + saItem := s.toSAItem(sa, ns.Value) + items = append(items, &saItem) } - return &common.ListResponse[iamv0alpha1.ServiceAccount]{ + return &common.ListResponse[*iamv0alpha1.ServiceAccount]{ Items: items, RV: found.RV, Continue: found.Continue, @@ -204,7 +205,12 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt return nil, err } - obj := &iamv0alpha1.ServiceAccountList{Items: res.Items} + items := make([]iamv0alpha1.ServiceAccount, len(res.Items)) + for i, sa := range res.Items { + items[i] = *sa + } + + obj := &iamv0alpha1.ServiceAccountList{Items: items} obj.Continue = common.OptionalFormatInt(res.Continue) obj.ResourceVersion = common.OptionalFormatInt(res.RV) return obj, nil diff --git a/pkg/registry/apis/iam/team/rest_members.go b/pkg/registry/apis/iam/team/rest_members.go index 6659823b9fa..586b074d710 100644 --- a/pkg/registry/apis/iam/team/rest_members.go +++ b/pkg/registry/apis/iam/team/rest_members.go @@ -2,14 +2,20 @@ package team import ( "context" + "fmt" "net/http" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/rest" claims "github.com/grafana/authlib/types" + iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apimachinery/utils" iamv0 "github.com/grafana/grafana/pkg/apis/iam/v0alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "github.com/grafana/grafana/pkg/registry/apis/iam/common" "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" @@ -23,12 +29,13 @@ var ( _ rest.Connecter = (*LegacyTeamMemberREST)(nil) ) -func NewLegacyTeamMemberREST(store legacy.LegacyIdentityStore) *LegacyTeamMemberREST { - return &LegacyTeamMemberREST{store} +func NewLegacyTeamMemberREST(store legacy.LegacyIdentityStore, ac claims.AccessClient) *LegacyTeamMemberREST { + return &LegacyTeamMemberREST{store: store, ac: ac} } type LegacyTeamMemberREST struct { store legacy.LegacyIdentityStore + ac claims.AccessClient } // New implements rest.Storage. @@ -62,6 +69,30 @@ func (s *LegacyTeamMemberREST) Connect(ctx context.Context, name string, options } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ident, err := identity.GetRequester(ctx) + if err != nil { + responder.Error(err) + return + } + + checkResp, err := s.ac.Check(ctx, ident, claims.CheckRequest{ + Group: iamv0alpha1.TeamResourceInfo.GroupResource().Group, + Resource: iamv0alpha1.TeamResourceInfo.GroupResource().Resource, + Name: name, + Namespace: ns.Value, + Verb: utils.VerbGetPermissions, + }, "") + + if err != nil { + responder.Error(err) + return + } + + if !checkResp.Allowed { + responder.Error(apierrors.NewForbidden(iamv0alpha1.TeamResourceInfo.GroupResource(), name, fmt.Errorf("permission denied"))) + return + } + res, err := s.store.ListTeamMembers(ctx, ns, legacy.ListTeamMembersQuery{ UID: name, Pagination: common.PaginationFromListQuery(r.URL.Query()), diff --git a/pkg/registry/apis/iam/team/store.go b/pkg/registry/apis/iam/team/store.go index c667c8ec374..834bba00335 100644 --- a/pkg/registry/apis/iam/team/store.go +++ b/pkg/registry/apis/iam/team/store.go @@ -174,7 +174,7 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt res, err := common.List( ctx, resource, s.ac, common.PaginationFromListOptions(options), - func(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (*common.ListResponse[iamv0alpha1.Team], error) { + func(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (*common.ListResponse[*iamv0alpha1.Team], error) { found, err := s.store.ListTeams(ctx, ns, legacy.ListTeamQuery{ Pagination: p, }) @@ -183,12 +183,13 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt return nil, err } - teams := make([]iamv0alpha1.Team, 0, len(found.Teams)) + teams := make([]*iamv0alpha1.Team, 0, len(found.Teams)) for _, t := range found.Teams { - teams = append(teams, toTeamObject(t, ns)) + team := toTeamObject(t, ns) + teams = append(teams, &team) } - return &common.ListResponse[iamv0alpha1.Team]{ + return &common.ListResponse[*iamv0alpha1.Team]{ Items: teams, RV: found.RV, Continue: found.Continue, @@ -200,7 +201,12 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt return nil, fmt.Errorf("failed to list teams: %w", err) } - list := &iamv0alpha1.TeamList{Items: res.Items} + items := make([]iamv0alpha1.Team, len(res.Items)) + for i, t := range res.Items { + items[i] = *t + } + + list := &iamv0alpha1.TeamList{Items: items} list.Continue = common.OptionalFormatInt(res.Continue) list.ResourceVersion = common.OptionalFormatInt(res.RV) diff --git a/pkg/registry/apis/iam/user/store.go b/pkg/registry/apis/iam/user/store.go index 53a146412ea..5cc9343111a 100644 --- a/pkg/registry/apis/iam/user/store.go +++ b/pkg/registry/apis/iam/user/store.go @@ -183,7 +183,7 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt res, err := common.List( ctx, userResource, s.ac, common.PaginationFromListOptions(options), - func(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (*common.ListResponse[iamv0alpha1.User], error) { + func(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (*common.ListResponse[*iamv0alpha1.User], error) { found, err := s.store.ListUsers(ctx, ns, legacy.ListUserQuery{ Pagination: p, }) @@ -192,12 +192,13 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt return nil, err } - users := make([]iamv0alpha1.User, 0, len(found.Items)) + users := make([]*iamv0alpha1.User, 0, len(found.Items)) for _, u := range found.Items { - users = append(users, toUserItem(&u, ns.Value)) + user := toUserItem(&u, ns.Value) + users = append(users, &user) } - return &common.ListResponse[iamv0alpha1.User]{ + return &common.ListResponse[*iamv0alpha1.User]{ Items: users, RV: found.RV, Continue: found.Continue, @@ -209,7 +210,12 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt return nil, err } - obj := &iamv0alpha1.UserList{Items: res.Items} + items := make([]iamv0alpha1.User, len(res.Items)) + for i, u := range res.Items { + items[i] = *u + } + + obj := &iamv0alpha1.UserList{Items: items} obj.Continue = common.OptionalFormatInt(res.Continue) obj.ResourceVersion = common.OptionalFormatInt(res.RV) return obj, nil diff --git a/pkg/registry/apis/preferences/legacy/preferences.go b/pkg/registry/apis/preferences/legacy/preferences.go index ab7eb2d4891..6f0210fa694 100644 --- a/pkg/registry/apis/preferences/legacy/preferences.go +++ b/pkg/registry/apis/preferences/legacy/preferences.go @@ -208,6 +208,11 @@ func (s *preferenceStorage) save(ctx context.Context, obj runtime.Object) (runti // Create implements rest.Creater. func (s *preferenceStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { + if createValidation != nil { + if err := createValidation(ctx, obj); err != nil { + return nil, err + } + } return s.save(ctx, obj) } @@ -223,6 +228,12 @@ func (s *preferenceStorage) Update(ctx context.Context, name string, objInfo res return nil, false, err } + if updateValidation != nil { + if err := updateValidation(ctx, obj, old); err != nil { + return nil, false, err + } + } + obj, err = s.save(ctx, obj) return obj, false, err } diff --git a/pkg/registry/apis/preferences/register.go b/pkg/registry/apis/preferences/register.go index e0e6ff947fc..59e6a0b9c05 100644 --- a/pkg/registry/apis/preferences/register.go +++ b/pkg/registry/apis/preferences/register.go @@ -1,9 +1,14 @@ package preferences import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/admission" "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/registry/rest" genericapiserver "k8s.io/apiserver/pkg/server" @@ -24,7 +29,8 @@ import ( ) var ( - _ builder.APIGroupBuilder = (*APIBuilder)(nil) + _ builder.APIGroupBuilder = (*APIBuilder)(nil) + _ builder.APIGroupValidation = (*APIBuilder)(nil) ) type APIBuilder struct { @@ -108,3 +114,31 @@ func (b *APIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes { defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} }) return b.merger.GetAPIRoutes(defs) } + +// Validate validates that the preference object has valid theme and timezone (if specified) +func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error { + if a.GetResource().Resource != "preferences" { + return nil + } + + op := a.GetOperation() + if op != admission.Create && op != admission.Update { + return nil + } + + obj := a.GetObject() + p, ok := obj.(*preferences.Preferences) + if !ok { + return apierrors.NewBadRequest(fmt.Sprintf("expected Preferences object, got %T", obj)) + } + + if p.Spec.Timezone != nil && !pref.IsValidTimezone(*p.Spec.Timezone) { + return apierrors.NewBadRequest("invalid timezone: must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string") + } + + if p.Spec.Theme != nil && *p.Spec.Theme != "" && !pref.IsValidThemeID(*p.Spec.Theme) { + return apierrors.NewBadRequest("invalid theme") + } + + return nil +} diff --git a/pkg/registry/apis/provisioning/connection_repositories.go b/pkg/registry/apis/provisioning/connection_repositories.go new file mode 100644 index 00000000000..c2e908fc685 --- /dev/null +++ b/pkg/registry/apis/provisioning/connection_repositories.go @@ -0,0 +1,69 @@ +package provisioning + +import ( + "context" + "net/http" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + "github.com/grafana/grafana-app-sdk/logging" + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +type connectionRepositoriesConnector struct{} + +func NewConnectionRepositoriesConnector() *connectionRepositoriesConnector { + return &connectionRepositoriesConnector{} +} + +func (*connectionRepositoriesConnector) New() runtime.Object { + return &provisioning.ExternalRepositoryList{} +} + +func (*connectionRepositoriesConnector) Destroy() {} + +func (*connectionRepositoriesConnector) ProducesMIMETypes(verb string) []string { + return []string{"application/json"} +} + +func (*connectionRepositoriesConnector) ProducesObject(verb string) any { + return &provisioning.ExternalRepositoryList{} +} + +func (*connectionRepositoriesConnector) ConnectMethods() []string { + return []string{http.MethodGet} +} + +func (*connectionRepositoriesConnector) NewConnectOptions() (runtime.Object, bool, string) { + return nil, false, "" +} + +func (c *connectionRepositoriesConnector) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { + logger := logging.FromContext(ctx).With("logger", "connection-repositories-connector", "connection_name", name) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + responder.Error(apierrors.NewMethodNotSupported(provisioning.ConnectionResourceInfo.GroupResource(), r.Method)) + return + } + + logger.Debug("repositories endpoint called but not yet implemented") + + // TODO: Implement repository listing from external git provider + // This will require: + // 1. Get the Connection object using logging.Context(r.Context(), logger) + // 2. Use the connection credentials to authenticate with the git provider + // 3. List repositories from the provider (GitHub, GitLab, Bitbucket) + // 4. Return ExternalRepositoryList with Name, Owner, and URL for each repository + + responder.Error(apierrors.NewMethodNotSupported(provisioning.ConnectionResourceInfo.GroupResource(), "repositories endpoint not yet implemented")) + }), nil +} + +var ( + _ rest.Storage = (*connectionRepositoriesConnector)(nil) + _ rest.Connecter = (*connectionRepositoriesConnector)(nil) + _ rest.StorageMetadata = (*connectionRepositoriesConnector)(nil) +) diff --git a/pkg/registry/apis/provisioning/connection_repositories_test.go b/pkg/registry/apis/provisioning/connection_repositories_test.go new file mode 100644 index 00000000000..792eb52bb64 --- /dev/null +++ b/pkg/registry/apis/provisioning/connection_repositories_test.go @@ -0,0 +1,101 @@ +package provisioning + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +func TestConnectionRepositoriesConnector(t *testing.T) { + connector := NewConnectionRepositoriesConnector() + + t.Run("New returns ExternalRepositoryList", func(t *testing.T) { + obj := connector.New() + require.IsType(t, &provisioning.ExternalRepositoryList{}, obj) + }) + + t.Run("ProducesMIMETypes returns application/json", func(t *testing.T) { + types := connector.ProducesMIMETypes("GET") + require.Equal(t, []string{"application/json"}, types) + }) + + t.Run("ProducesObject returns ExternalRepositoryList", func(t *testing.T) { + obj := connector.ProducesObject("GET") + require.IsType(t, &provisioning.ExternalRepositoryList{}, obj) + }) + + t.Run("ConnectMethods returns GET", func(t *testing.T) { + methods := connector.ConnectMethods() + require.Equal(t, []string{http.MethodGet}, methods) + }) + + t.Run("NewConnectOptions returns no path component", func(t *testing.T) { + obj, hasPath, path := connector.NewConnectOptions() + require.Nil(t, obj) + require.False(t, hasPath) + require.Empty(t, path) + }) + + t.Run("Connect returns handler that rejects non-GET methods", func(t *testing.T) { + ctx := context.Background() + responder := &mockResponder{} + + handler, err := connector.Connect(ctx, "test-connection", nil, responder) + require.NoError(t, err) + require.NotNil(t, handler) + + // Test POST method (should be rejected) + req := httptest.NewRequest(http.MethodPost, "/", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.True(t, responder.called) + require.NotNil(t, responder.err) + require.True(t, apierrors.IsMethodNotSupported(responder.err)) + }) + + t.Run("Connect returns handler that returns not implemented for GET", func(t *testing.T) { + ctx := context.Background() + responder := &mockResponder{} + + handler, err := connector.Connect(ctx, "test-connection", nil, responder) + require.NoError(t, err) + require.NotNil(t, handler) + + // Test GET method (should return not implemented) + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.True(t, responder.called) + require.NotNil(t, responder.err) + require.True(t, apierrors.IsMethodNotSupported(responder.err)) + require.Contains(t, responder.err.Error(), "not yet implemented") + }) +} + +// mockResponder implements rest.Responder for testing +type mockResponder struct { + called bool + err error + obj runtime.Object + code int +} + +func (m *mockResponder) Object(statusCode int, obj runtime.Object) { + m.called = true + m.code = statusCode + m.obj = obj +} + +func (m *mockResponder) Error(err error) { + m.called = true + m.err = err +} diff --git a/pkg/registry/apis/provisioning/controller/connection.go b/pkg/registry/apis/provisioning/controller/connection.go new file mode 100644 index 00000000000..be90908bd49 --- /dev/null +++ b/pkg/registry/apis/provisioning/controller/connection.go @@ -0,0 +1,254 @@ +package controller + +import ( + "context" + "errors" + "fmt" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" + + "github.com/grafana/grafana-app-sdk/logging" + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" + informer "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1" + listers "github.com/grafana/grafana/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1" +) + +const connectionLoggerName = "provisioning-connection-controller" + +const ( + connectionMaxAttempts = 3 + // connectionHealthyDuration defines how recent a health check must be to be considered "recent" when healthy + connectionHealthyDuration = 5 * time.Minute + // connectionUnhealthyDuration defines how recent a health check must be to be considered "recent" when unhealthy + connectionUnhealthyDuration = 1 * time.Minute +) + +type connectionQueueItem struct { + key string + attempts int +} + +// ConnectionStatusPatcher defines the interface for updating connection status. +// +//go:generate mockery --name=ConnectionStatusPatcher +type ConnectionStatusPatcher interface { + Patch(ctx context.Context, conn *provisioning.Connection, patchOperations ...map[string]interface{}) error +} + +// ConnectionController controls Connection resources. +type ConnectionController struct { + client client.ProvisioningV0alpha1Interface + connLister listers.ConnectionLister + connSynced cache.InformerSynced + logger logging.Logger + + statusPatcher ConnectionStatusPatcher + + queue workqueue.TypedRateLimitingInterface[*connectionQueueItem] +} + +// NewConnectionController creates a new ConnectionController. +func NewConnectionController( + provisioningClient client.ProvisioningV0alpha1Interface, + connInformer informer.ConnectionInformer, + statusPatcher ConnectionStatusPatcher, +) (*ConnectionController, error) { + cc := &ConnectionController{ + client: provisioningClient, + connLister: connInformer.Lister(), + connSynced: connInformer.Informer().HasSynced, + queue: workqueue.NewTypedRateLimitingQueueWithConfig( + workqueue.DefaultTypedControllerRateLimiter[*connectionQueueItem](), + workqueue.TypedRateLimitingQueueConfig[*connectionQueueItem]{ + Name: "provisioningConnectionController", + }, + ), + statusPatcher: statusPatcher, + logger: logging.DefaultLogger.With("logger", connectionLoggerName), + } + + _, err := connInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: cc.enqueue, + UpdateFunc: func(oldObj, newObj interface{}) { + cc.enqueue(newObj) + }, + }) + if err != nil { + return nil, err + } + + return cc, nil +} + +func (cc *ConnectionController) enqueue(obj interface{}) { + key, err := cache.DeletionHandlingMetaNamespaceKeyFunc(obj) + if err != nil { + cc.logger.Error("failed to get key for object", "error", err) + return + } + cc.queue.Add(&connectionQueueItem{key: key}) +} + +// Run starts the ConnectionController. +func (cc *ConnectionController) Run(ctx context.Context, workerCount int) { + defer utilruntime.HandleCrash() + defer cc.queue.ShutDown() + + cc.logger.Info("starting connection controller", "workers", workerCount) + + for i := 0; i < workerCount; i++ { + go wait.UntilWithContext(ctx, cc.runWorker, time.Second) + } + + <-ctx.Done() + cc.logger.Info("shutting down connection controller") +} + +func (cc *ConnectionController) runWorker(ctx context.Context) { + for cc.processNextWorkItem(ctx) { + } +} + +func (cc *ConnectionController) processNextWorkItem(ctx context.Context) bool { + item, quit := cc.queue.Get() + if quit { + return false + } + defer cc.queue.Done(item) + + logger := logging.FromContext(ctx).With("work_key", item.key) + logger.Info("ConnectionController processing key") + + err := cc.process(ctx, item) + if err == nil { + cc.queue.Forget(item) + return true + } + + item.attempts++ + logger = logger.With("error", err, "attempts", item.attempts) + logger.Error("ConnectionController failed to process key") + + if item.attempts >= connectionMaxAttempts { + logger.Error("ConnectionController failed too many times") + cc.queue.Forget(item) + return true + } + + if !apierrors.IsServiceUnavailable(err) { + logger.Info("ConnectionController will not retry") + cc.queue.Forget(item) + return true + } + + logger.Info("ConnectionController will retry as service is unavailable") + utilruntime.HandleError(fmt.Errorf("%v failed with: %v", item, err)) + cc.queue.AddRateLimited(item) + + return true +} + +func (cc *ConnectionController) process(ctx context.Context, item *connectionQueueItem) error { + logger := cc.logger.With("key", item.key) + ctx = logging.Context(ctx, logger) + + namespace, name, err := cache.SplitMetaNamespaceKey(item.key) + if err != nil { + return err + } + + conn, err := cc.connLister.Connections(namespace).Get(name) + switch { + case apierrors.IsNotFound(err): + return errors.New("connection not found in cache") + case err != nil: + return err + } + + // Skip if being deleted + if conn.DeletionTimestamp != nil { + logger.Info("connection is being deleted, skipping") + return nil + } + + hasSpecChanged := conn.Generation != conn.Status.ObservedGeneration + shouldCheckHealth := cc.shouldCheckHealth(conn) + + // Determine the main triggering condition + switch { + case hasSpecChanged: + logger.Info("spec changed, reconciling", "generation", conn.Generation, "observedGeneration", conn.Status.ObservedGeneration) + case shouldCheckHealth: + logger.Info("health is stale, refreshing", "lastChecked", conn.Status.Health.Checked, "healthy", conn.Status.Health.Healthy) + default: + logger.Debug("skipping as conditions are not met", "generation", conn.Generation, "observedGeneration", conn.Status.ObservedGeneration) + return nil + } + + // For now, just update the state to connected, health to healthy, and observed generation + // Future: Add credential validation logic here + patchOperations := []map[string]interface{}{} + + // Only update observedGeneration when spec changes + if hasSpecChanged { + patchOperations = append(patchOperations, map[string]interface{}{ + "op": "replace", + "path": "/status/observedGeneration", + "value": conn.Generation, + }) + } + + // Always update state and health + patchOperations = append(patchOperations, + map[string]interface{}{ + "op": "replace", + "path": "/status/state", + "value": provisioning.ConnectionStateConnected, + }, + map[string]interface{}{ + "op": "replace", + "path": "/status/health", + "value": provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().UnixMilli(), + }, + }, + ) + + if err := cc.statusPatcher.Patch(ctx, conn, patchOperations...); err != nil { + return fmt.Errorf("failed to update connection status: %w", err) + } + + logger.Info("connection reconciled successfully") + return nil +} + +// shouldCheckHealth determines if a connection health check should be performed. +func (cc *ConnectionController) shouldCheckHealth(conn *provisioning.Connection) bool { + // If the connection has been updated, always check health + if conn.Generation != conn.Status.ObservedGeneration { + return true + } + + // Check if health check is stale + return !cc.hasRecentHealthCheck(conn.Status.Health) +} + +// hasRecentHealthCheck checks if a health check was performed recently. +func (cc *ConnectionController) hasRecentHealthCheck(healthStatus provisioning.HealthStatus) bool { + if healthStatus.Checked == 0 { + return false // Never checked + } + + age := time.Since(time.UnixMilli(healthStatus.Checked)) + if healthStatus.Healthy { + return age <= connectionHealthyDuration + } + return age <= connectionUnhealthyDuration +} diff --git a/pkg/registry/apis/provisioning/controller/connection_test.go b/pkg/registry/apis/provisioning/controller/connection_test.go new file mode 100644 index 00000000000..b033ddb39a9 --- /dev/null +++ b/pkg/registry/apis/provisioning/controller/connection_test.go @@ -0,0 +1,287 @@ +package controller + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +func TestConnectionController_shouldCheckHealth(t *testing.T) { + testCases := []struct { + name string + conn *provisioning.Connection + expected bool + }{ + { + name: "should check health when generation differs from observed", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 2, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + }, + }, + expected: true, + }, + { + name: "should check health when never checked before", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Checked: 0, + }, + }, + }, + expected: true, + }, + { + name: "should check health when healthy check is stale (>5 min)", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-6 * time.Minute).UnixMilli(), + }, + }, + }, + expected: true, + }, + { + name: "should check health when unhealthy check is stale (>1 min)", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: false, + Checked: time.Now().Add(-2 * time.Minute).UnixMilli(), + }, + }, + }, + expected: true, + }, + { + name: "should not check health when healthy check is recent (<5 min)", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-2 * time.Minute).UnixMilli(), + }, + }, + }, + expected: false, + }, + { + name: "should not check health when unhealthy check is recent (<1 min)", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: false, + Checked: time.Now().Add(-30 * time.Second).UnixMilli(), + }, + }, + }, + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cc := &ConnectionController{} + result := cc.shouldCheckHealth(tc.conn) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestConnectionController_hasRecentHealthCheck(t *testing.T) { + testCases := []struct { + name string + healthStatus provisioning.HealthStatus + expected bool + }{ + { + name: "never checked", + healthStatus: provisioning.HealthStatus{ + Checked: 0, + }, + expected: false, + }, + { + name: "healthy and recent", + healthStatus: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-2 * time.Minute).UnixMilli(), + }, + expected: true, + }, + { + name: "healthy and stale", + healthStatus: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-10 * time.Minute).UnixMilli(), + }, + expected: false, + }, + { + name: "unhealthy and recent", + healthStatus: provisioning.HealthStatus{ + Healthy: false, + Checked: time.Now().Add(-30 * time.Second).UnixMilli(), + }, + expected: true, + }, + { + name: "unhealthy and stale", + healthStatus: provisioning.HealthStatus{ + Healthy: false, + Checked: time.Now().Add(-2 * time.Minute).UnixMilli(), + }, + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cc := &ConnectionController{} + result := cc.hasRecentHealthCheck(tc.healthStatus) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestConnectionController_reconcileConditions(t *testing.T) { + testCases := []struct { + name string + conn *provisioning.Connection + expectReconcile bool + expectSpecChanged bool + description string + }{ + { + name: "skip when being deleted", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-conn", + Namespace: "default", + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + }, + }, + expectReconcile: false, + expectSpecChanged: false, + description: "deleted connections should be skipped", + }, + { + name: "skip when no changes needed", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-conn", + Namespace: "default", + Generation: 1, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().UnixMilli(), + }, + }, + }, + expectReconcile: false, + expectSpecChanged: false, + description: "no reconcile when generation matches and health is recent", + }, + { + name: "reconcile when spec changed", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-conn", + Namespace: "default", + Generation: 2, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().UnixMilli(), + }, + }, + }, + expectReconcile: true, + expectSpecChanged: true, + description: "reconcile when generation differs", + }, + { + name: "reconcile when health is stale", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-conn", + Namespace: "default", + Generation: 1, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-10 * time.Minute).UnixMilli(), + }, + }, + }, + expectReconcile: true, + expectSpecChanged: false, + description: "reconcile when health check is stale", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cc := &ConnectionController{} + + // Test the core reconciliation conditions + if tc.conn.DeletionTimestamp != nil { + assert.False(t, tc.expectReconcile, tc.description) + return + } + + hasSpecChanged := tc.conn.Generation != tc.conn.Status.ObservedGeneration + shouldCheckHealth := cc.shouldCheckHealth(tc.conn) + + needsReconcile := hasSpecChanged || shouldCheckHealth + + assert.Equal(t, tc.expectReconcile, needsReconcile, tc.description) + assert.Equal(t, tc.expectSpecChanged, hasSpecChanged, "spec changed check") + }) + } +} + +func TestConnectionController_processNextWorkItem(t *testing.T) { + t.Run("returns false when queue is shut down", func(t *testing.T) { + cc := &ConnectionController{} + // This test verifies the structure is correct + assert.NotNil(t, cc) + }) +} diff --git a/pkg/registry/apis/provisioning/extras/register.go b/pkg/registry/apis/provisioning/extras/register.go index caa3c2a2fa5..43c7064173f 100644 --- a/pkg/registry/apis/provisioning/extras/register.go +++ b/pkg/registry/apis/provisioning/extras/register.go @@ -2,6 +2,8 @@ package extras import ( apisprovisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/connection" + ghconnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" "github.com/grafana/grafana/apps/provisioning/pkg/repository" "github.com/grafana/grafana/apps/provisioning/pkg/repository/git" "github.com/grafana/grafana/apps/provisioning/pkg/repository/github" @@ -42,6 +44,15 @@ func ProvideProvisioningOSSRepositoryExtras( } } +func ProvideProvisioningOSSConnectionExtras( + _ *setting.Cfg, + ghFactory ghconnection.GithubFactory, +) []connection.Extra { + return []connection.Extra{ + ghconnection.Extra(ghFactory), + } +} + func ProvideExtraWorkers(pullRequestWorker *pullrequest.PullRequestWorker) []jobs.Worker { return []jobs.Worker{pullRequestWorker} } @@ -54,3 +65,12 @@ func ProvideFactoryFromConfig(cfg *setting.Cfg, extras []repository.Extra) (repo return repository.ProvideFactory(enabledTypes, extras) } + +func ProvideConnectionFactoryFromConfig(cfg *setting.Cfg, extras []connection.Extra) (connection.Factory, error) { + enabledTypes := make(map[apisprovisioning.ConnectionType]struct{}, len(cfg.ProvisioningRepositoryTypes)) + for _, e := range cfg.ProvisioningRepositoryTypes { + enabledTypes[apisprovisioning.ConnectionType(e)] = struct{}{} + } + + return connection.ProvideFactory(enabledTypes, extras) +} diff --git a/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go b/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go index 45d8572e94a..efb2bd52697 100644 --- a/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go +++ b/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go @@ -71,6 +71,98 @@ func (_c *MockJobProgressRecorder_Complete_Call) RunAndReturn(run func(context.C return _c } +// HasDirPathFailedDeletion provides a mock function with given fields: folderPath +func (_m *MockJobProgressRecorder) HasDirPathFailedDeletion(folderPath string) bool { + ret := _m.Called(folderPath) + + if len(ret) == 0 { + panic("no return value specified for HasDirPathFailedDeletion") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(string) bool); ok { + r0 = rf(folderPath) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// MockJobProgressRecorder_HasDirPathFailedDeletion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasDirPathFailedDeletion' +type MockJobProgressRecorder_HasDirPathFailedDeletion_Call struct { + *mock.Call +} + +// HasDirPathFailedDeletion is a helper method to define mock.On call +// - folderPath string +func (_e *MockJobProgressRecorder_Expecter) HasDirPathFailedDeletion(folderPath interface{}) *MockJobProgressRecorder_HasDirPathFailedDeletion_Call { + return &MockJobProgressRecorder_HasDirPathFailedDeletion_Call{Call: _e.mock.On("HasDirPathFailedDeletion", folderPath)} +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedDeletion_Call) Run(run func(folderPath string)) *MockJobProgressRecorder_HasDirPathFailedDeletion_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedDeletion_Call) Return(_a0 bool) *MockJobProgressRecorder_HasDirPathFailedDeletion_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedDeletion_Call) RunAndReturn(run func(string) bool) *MockJobProgressRecorder_HasDirPathFailedDeletion_Call { + _c.Call.Return(run) + return _c +} + +// HasDirPathFailedCreation provides a mock function with given fields: path +func (_m *MockJobProgressRecorder) HasDirPathFailedCreation(path string) bool { + ret := _m.Called(path) + + if len(ret) == 0 { + panic("no return value specified for HasDirPathFailedCreation") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(string) bool); ok { + r0 = rf(path) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// MockJobProgressRecorder_HasDirPathFailedCreation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasDirPathFailedCreation' +type MockJobProgressRecorder_HasDirPathFailedCreation_Call struct { + *mock.Call +} + +// HasDirPathFailedCreation is a helper method to define mock.On call +// - path string +func (_e *MockJobProgressRecorder_Expecter) HasDirPathFailedCreation(path interface{}) *MockJobProgressRecorder_HasDirPathFailedCreation_Call { + return &MockJobProgressRecorder_HasDirPathFailedCreation_Call{Call: _e.mock.On("HasDirPathFailedCreation", path)} +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedCreation_Call) Run(run func(path string)) *MockJobProgressRecorder_HasDirPathFailedCreation_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedCreation_Call) Return(_a0 bool) *MockJobProgressRecorder_HasDirPathFailedCreation_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedCreation_Call) RunAndReturn(run func(string) bool) *MockJobProgressRecorder_HasDirPathFailedCreation_Call { + _c.Call.Return(run) + return _c +} + // Record provides a mock function with given fields: ctx, result func (_m *MockJobProgressRecorder) Record(ctx context.Context, result JobResourceResult) { _m.Called(ctx, result) diff --git a/pkg/registry/apis/provisioning/jobs/progress.go b/pkg/registry/apis/provisioning/jobs/progress.go index 2cb9dc9ddcf..3ba61eb6278 100644 --- a/pkg/registry/apis/provisioning/jobs/progress.go +++ b/pkg/registry/apis/provisioning/jobs/progress.go @@ -2,6 +2,7 @@ package jobs import ( "context" + "errors" "fmt" "sync" "time" @@ -9,6 +10,8 @@ import ( "github.com/grafana/grafana-app-sdk/logging" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/apps/provisioning/pkg/repository" + "github.com/grafana/grafana/apps/provisioning/pkg/safepath" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" ) // maybeNotifyProgress will only notify if a certain amount of time has passed @@ -58,6 +61,8 @@ type jobProgressRecorder struct { notifyImmediatelyFn ProgressFn maybeNotifyFn ProgressFn summaries map[string]*provisioning.JobResourceSummary + failedCreations []string // Tracks folder paths that failed to be created + failedDeletions []string // Tracks resource paths that failed to be deleted } func newJobProgressRecorder(ProgressFn ProgressFn) JobProgressRecorder { @@ -84,10 +89,26 @@ func (r *jobProgressRecorder) Record(ctx context.Context, result JobResourceResu if result.Error != nil { shouldLogError = true logErr = result.Error - if len(r.errors) < 20 { - r.errors = append(r.errors, result.Error.Error()) + + // Don't count ignored actions as errors in error count or error list + if result.Action != repository.FileActionIgnored { + if len(r.errors) < 20 { + r.errors = append(r.errors, result.Error.Error()) + } + r.errorCount++ + } + + // Automatically track failed operations based on error type and action + // Check if this is a PathCreationError (folder creation failure) + var pathErr *resources.PathCreationError + if errors.As(result.Error, &pathErr) { + r.failedCreations = append(r.failedCreations, pathErr.Path) + } + + // Track failed deletions, any deletion will stop the deletion of the parent folder (as it won't be empty) + if result.Action == repository.FileActionDeleted { + r.failedDeletions = append(r.failedDeletions, result.Path) } - r.errorCount++ } r.updateSummary(result) @@ -112,6 +133,8 @@ func (r *jobProgressRecorder) ResetResults() { r.errorCount = 0 r.errors = nil r.summaries = make(map[string]*provisioning.JobResourceSummary) + r.failedCreations = nil + r.failedDeletions = nil } func (r *jobProgressRecorder) SetMessage(ctx context.Context, msg string) { @@ -309,3 +332,29 @@ func (r *jobProgressRecorder) Complete(ctx context.Context, err error) provision return jobStatus } + +// HasDirPathFailedCreation checks if a path is nested under any failed folder creation +func (r *jobProgressRecorder) HasDirPathFailedCreation(path string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, failedCreation := range r.failedCreations { + if safepath.InDir(path, failedCreation) { + return true + } + } + return false +} + +// HasDirPathFailedDeletion checks if any resource deletions failed under a folder path +func (r *jobProgressRecorder) HasDirPathFailedDeletion(folderPath string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, failedDeletion := range r.failedDeletions { + if safepath.InDir(failedDeletion, folderPath) { + return true + } + } + return false +} diff --git a/pkg/registry/apis/provisioning/jobs/progress_test.go b/pkg/registry/apis/provisioning/jobs/progress_test.go index 7e849491bbe..0879ba111af 100644 --- a/pkg/registry/apis/provisioning/jobs/progress_test.go +++ b/pkg/registry/apis/provisioning/jobs/progress_test.go @@ -7,6 +7,7 @@ import ( provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/apps/provisioning/pkg/repository" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -252,3 +253,221 @@ func TestJobProgressRecorderWarningOnlyNoErrors(t *testing.T) { require.NotNil(t, finalStatus.Warnings) assert.Len(t, finalStatus.Warnings, 1) } + +func TestJobProgressRecorderFolderFailureTracking(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Record a folder creation failure with PathCreationError + pathErr := &resources.PathCreationError{ + Path: "folder1/", + Err: assert.AnError, + } + recorder.Record(ctx, JobResourceResult{ + Path: "folder1/file.json", + Action: repository.FileActionCreated, + Error: pathErr, + }) + + // Record another PathCreationError for a different folder + pathErr2 := &resources.PathCreationError{ + Path: "folder2/subfolder/", + Err: assert.AnError, + } + recorder.Record(ctx, JobResourceResult{ + Path: "folder2/subfolder/file.json", + Action: repository.FileActionCreated, + Error: pathErr2, + }) + + // Record a deletion failure + recorder.Record(ctx, JobResourceResult{ + Path: "folder3/file1.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + // Record another deletion failure + recorder.Record(ctx, JobResourceResult{ + Path: "folder4/subfolder/file2.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + // Verify failed creations are tracked + recorder.mu.RLock() + assert.Len(t, recorder.failedCreations, 2) + assert.Contains(t, recorder.failedCreations, "folder1/") + assert.Contains(t, recorder.failedCreations, "folder2/subfolder/") + + // Verify failed deletions are tracked + assert.Len(t, recorder.failedDeletions, 2) + assert.Contains(t, recorder.failedDeletions, "folder3/file1.json") + assert.Contains(t, recorder.failedDeletions, "folder4/subfolder/file2.json") + recorder.mu.RUnlock() +} + +func TestJobProgressRecorderHasDirPathFailedCreation(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Add failed creations via Record + pathErr1 := &resources.PathCreationError{ + Path: "folder1/", + Err: assert.AnError, + } + recorder.Record(ctx, JobResourceResult{ + Path: "folder1/file.json", + Action: repository.FileActionCreated, + Error: pathErr1, + }) + + pathErr2 := &resources.PathCreationError{ + Path: "folder2/subfolder/", + Err: assert.AnError, + } + recorder.Record(ctx, JobResourceResult{ + Path: "folder2/subfolder/file.json", + Action: repository.FileActionCreated, + Error: pathErr2, + }) + + // Test nested paths + assert.True(t, recorder.HasDirPathFailedCreation("folder1/file.json")) + assert.True(t, recorder.HasDirPathFailedCreation("folder1/nested/file.json")) + assert.True(t, recorder.HasDirPathFailedCreation("folder2/subfolder/file.json")) + + // Test non-nested paths + assert.False(t, recorder.HasDirPathFailedCreation("folder2/file2.json")) + assert.False(t, recorder.HasDirPathFailedCreation("folder2/othersubfolder/inside.json")) + assert.False(t, recorder.HasDirPathFailedCreation("other/file.json")) + assert.False(t, recorder.HasDirPathFailedCreation("folder3/file.json")) + assert.False(t, recorder.HasDirPathFailedCreation("file.json")) +} + +func TestJobProgressRecorderHasDirPathFailedDeletion(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Add failed deletions via Record + recorder.Record(ctx, JobResourceResult{ + Path: "folder1/file1.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + recorder.Record(ctx, JobResourceResult{ + Path: "folder2/subfolder/file2.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + recorder.Record(ctx, JobResourceResult{ + Path: "folder3/nested/deep/file3.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + // Test folder paths with failed deletions + assert.True(t, recorder.HasDirPathFailedDeletion("folder1/")) + assert.True(t, recorder.HasDirPathFailedDeletion("folder2/")) + assert.True(t, recorder.HasDirPathFailedDeletion("folder2/subfolder/")) + assert.True(t, recorder.HasDirPathFailedDeletion("folder3/")) + assert.True(t, recorder.HasDirPathFailedDeletion("folder3/nested/")) + assert.True(t, recorder.HasDirPathFailedDeletion("folder3/nested/deep/")) + + // Test folder paths without failed deletions + assert.False(t, recorder.HasDirPathFailedDeletion("other/")) + assert.False(t, recorder.HasDirPathFailedDeletion("different/")) + assert.False(t, recorder.HasDirPathFailedDeletion("folder2/othersubfolder/")) + assert.False(t, recorder.HasDirPathFailedDeletion("folder2/subfolder/othersubfolder/")) + assert.False(t, recorder.HasDirPathFailedDeletion("folder3/nested/anotherdeep/")) + assert.False(t, recorder.HasDirPathFailedDeletion("folder3/nested/deep/insidedeep/")) +} + +func TestJobProgressRecorderResetResults(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Add some data via Record + pathErr := &resources.PathCreationError{ + Path: "folder1/", + Err: assert.AnError, + } + recorder.Record(ctx, JobResourceResult{ + Path: "folder1/file.json", + Action: repository.FileActionCreated, + Error: pathErr, + }) + + recorder.Record(ctx, JobResourceResult{ + Path: "folder2/file.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + // Verify data is stored + recorder.mu.RLock() + assert.Len(t, recorder.failedCreations, 1) + assert.Len(t, recorder.failedDeletions, 1) + recorder.mu.RUnlock() + + // Reset results + recorder.ResetResults() + + // Verify data is cleared + recorder.mu.RLock() + assert.Nil(t, recorder.failedCreations) + assert.Nil(t, recorder.failedDeletions) + recorder.mu.RUnlock() +} + +func TestJobProgressRecorderIgnoredActionsDontCountAsErrors(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Record an ignored action with error + recorder.Record(ctx, JobResourceResult{ + Path: "folder1/file1.json", + Action: repository.FileActionIgnored, + Error: assert.AnError, + }) + + // Record a real error for comparison + recorder.Record(ctx, JobResourceResult{ + Path: "folder2/file2.json", + Action: repository.FileActionCreated, + Error: assert.AnError, + }) + + // Verify error count doesn't include ignored actions + recorder.mu.RLock() + assert.Equal(t, 1, recorder.errorCount, "ignored actions should not be counted as errors") + assert.Len(t, recorder.errors, 1, "ignored action errors should not be in error list") + recorder.mu.RUnlock() +} diff --git a/pkg/registry/apis/provisioning/jobs/queue.go b/pkg/registry/apis/provisioning/jobs/queue.go index e1992395efd..90b50aa4ee7 100644 --- a/pkg/registry/apis/provisioning/jobs/queue.go +++ b/pkg/registry/apis/provisioning/jobs/queue.go @@ -29,6 +29,10 @@ type JobProgressRecorder interface { StrictMaxErrors(maxErrors int) SetRefURLs(ctx context.Context, refURLs *provisioning.RepositoryURLs) Complete(ctx context.Context, err error) provisioning.JobStatus + // HasDirPathFailedCreation checks if a path has any folder creations that failed + HasDirPathFailedCreation(path string) bool + // HasDirPathFailedDeletion checks if a folderPath has any folder deletions that failed + HasDirPathFailedDeletion(folderPath string) bool } // Worker is a worker that can process a job diff --git a/pkg/registry/apis/provisioning/jobs/sync/full.go b/pkg/registry/apis/provisioning/jobs/sync/full.go index 10aad46693b..50ab5c39bcf 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/full.go +++ b/pkg/registry/apis/provisioning/jobs/sync/full.go @@ -75,11 +75,47 @@ func FullSync( return applyChanges(ctx, changes, clients, repositoryResources, progress, tracer, maxSyncWorkers, metrics) } +// shouldSkipChange checks if a change should be skipped based on previous failures on parent/child folders. +// If there is a previous failure on the path, we don't need to process the change as it will fail anyway. +func shouldSkipChange(ctx context.Context, change ResourceFileChange, progress jobs.JobProgressRecorder, tracer tracing.Tracer) bool { + if change.Action != repository.FileActionDeleted && progress.HasDirPathFailedCreation(change.Path) { + skipCtx, skipSpan := tracer.Start(ctx, "provisioning.sync.full.apply_changes.skip_nested_resource") + skipSpan.SetAttributes(attribute.String("path", change.Path)) + progress.Record(skipCtx, jobs.JobResourceResult{ + Path: change.Path, + Action: repository.FileActionIgnored, + Warning: fmt.Errorf("resource was not processed because the parent folder could not be created"), + }) + skipSpan.End() + return true + } + + if change.Action == repository.FileActionDeleted && safepath.IsDir(change.Path) && progress.HasDirPathFailedDeletion(change.Path) { + skipCtx, skipSpan := tracer.Start(ctx, "provisioning.sync.full.apply_changes.skip_folder_with_failed_deletions") + skipSpan.SetAttributes(attribute.String("path", change.Path)) + progress.Record(skipCtx, jobs.JobResourceResult{ + Path: change.Path, + Action: repository.FileActionIgnored, + Group: resources.FolderKind.Group, + Kind: resources.FolderKind.Kind, + Warning: fmt.Errorf("folder was not processed because children resources in its path could not be deleted"), + }) + skipSpan.End() + return true + } + + return false +} + func applyChange(ctx context.Context, change ResourceFileChange, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder, tracer tracing.Tracer) { if ctx.Err() != nil { return } + if shouldSkipChange(ctx, change, progress, tracer) { + return + } + if change.Action == repository.FileActionDeleted { deleteCtx, deleteSpan := tracer.Start(ctx, "provisioning.sync.full.apply_changes.delete") result := jobs.JobResourceResult{ @@ -138,6 +174,7 @@ func applyChange(ctx context.Context, change ResourceFileChange, clients resourc ensureFolderSpan.RecordError(err) ensureFolderSpan.End() progress.Record(ctx, result) + return } @@ -253,8 +290,6 @@ func applyChanges(ctx context.Context, changes []ResourceFileChange, clients res } func applyFoldersSerially(ctx context.Context, folders []ResourceFileChange, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder, tracer tracing.Tracer) error { - logger := logging.FromContext(ctx) - for _, folder := range folders { if ctx.Err() != nil { return ctx.Err() @@ -264,23 +299,9 @@ func applyFoldersSerially(ctx context.Context, folders []ResourceFileChange, cli return err } - folderCtx, cancel := context.WithTimeout(ctx, 15*time.Second) - - applyChange(folderCtx, folder, clients, repositoryResources, progress, tracer) - - if folderCtx.Err() == context.DeadlineExceeded { - logger.Error("operation timed out after 15 seconds", "path", folder.Path, "action", folder.Action) - - recordCtx, recordCancel := context.WithTimeout(context.Background(), 15*time.Second) - progress.Record(recordCtx, jobs.JobResourceResult{ - Path: folder.Path, - Action: folder.Action, - Error: fmt.Errorf("operation timed out after 15 seconds"), - }) - recordCancel() - } - - cancel() + wrapWithTimeout(ctx, 15*time.Second, func(timeoutCtx context.Context) { + applyChange(timeoutCtx, folder, clients, repositoryResources, progress, tracer) + }) } return nil @@ -318,7 +339,9 @@ loop: defer wg.Done() defer func() { <-sem }() - applyChangeWithTimeout(ctx, change, clients, repositoryResources, progress, tracer, logger) + wrapWithTimeout(ctx, 15*time.Second, func(timeoutCtx context.Context) { + applyChange(timeoutCtx, change, clients, repositoryResources, progress, tracer) + }) }(change) } @@ -331,21 +354,10 @@ loop: return ctx.Err() } -func applyChangeWithTimeout(ctx context.Context, change ResourceFileChange, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder, tracer tracing.Tracer, logger logging.Logger) { - changeCtx, cancel := context.WithTimeout(ctx, 15*time.Second) +// wrapWithTimeout wraps a function call with a timeout context +func wrapWithTimeout(ctx context.Context, timeout time.Duration, fn func(context.Context)) { + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - applyChange(changeCtx, change, clients, repositoryResources, progress, tracer) - - if changeCtx.Err() == context.DeadlineExceeded { - logger.Error("operation timed out after 15 seconds", "path", change.Path, "action", change.Action) - - recordCtx, recordCancel := context.WithTimeout(context.Background(), 15*time.Second) - progress.Record(recordCtx, jobs.JobResourceResult{ - Path: change.Path, - Action: change.Action, - Error: fmt.Errorf("operation timed out after 15 seconds"), - }) - recordCancel() - } + fn(timeoutCtx) } diff --git a/pkg/registry/apis/provisioning/jobs/sync/full_hierarchical_test.go b/pkg/registry/apis/provisioning/jobs/sync/full_hierarchical_test.go new file mode 100644 index 00000000000..2bc0ea8779e --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/sync/full_hierarchical_test.go @@ -0,0 +1,432 @@ +package sync + +import ( + "context" + "fmt" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" + k8testing "k8s.io/client-go/testing" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/repository" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" +) + +/* +TestFullSync_HierarchicalErrorHandling tests the hierarchical error handling behavior: + +FOLDER CREATION FAILURES: +- When a folder fails to be created with PathCreationError, all nested resources are skipped +- Nested resources are recorded with FileActionIgnored and error "folder was not processed because children resources in its path could not be deleted" +- Only the folder creation error counts toward error limits +- Nested resource skips do NOT count toward error limits + +FOLDER DELETION FAILURES: +- When a file deletion fails, it's tracked in failedDeletions +- When cleaning up folders, we check HasDirPathFailedDeletion() +- If children failed to delete, folder deletion is skipped with FileActionIgnored +- This prevents orphaning resources that still exist + +DELETIONS NOT AFFECTED BY CREATION FAILURES: +- If a folder creation fails, deletion operations for resources in that folder still proceed +- This is because the resource might already exist from a previous sync +- Only creations/updates/renames are affected by failed folder creation + +AUTOMATIC TRACKING: +- Record() automatically detects PathCreationError and adds to failedCreations +- Record() automatically detects deletion failures and adds to failedDeletions +- No manual calls to AddFailedCreation/AddFailedDeletion needed +*/ +func TestFullSync_HierarchicalErrorHandling(t *testing.T) { // nolint:gocyclo + tests := []struct { + name string + setupMocks func(*repository.MockRepository, *resources.MockRepositoryResources, *resources.MockResourceClients, *jobs.MockJobProgressRecorder, *dynamicfake.FakeDynamicClient) + changes []ResourceFileChange + description string + expectError bool + errorContains string + }{ + { + name: "folder creation fails, nested file skipped", + description: "When folder1/ fails to create, folder1/file.json should be skipped with FileActionIgnored", + changes: []ResourceFileChange{ + {Path: "folder1/file.json", Action: repository.FileActionCreated}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) { + // First, check if nested under failed creation - not yet + progress.On("HasDirPathFailedCreation", "folder1/file.json").Return(false).Once() + + // WriteResourceFromFile fails with PathCreationError for folder1/ + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file.json", ""). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + // File will be recorded with error, triggering automatic tracking of folder1/ failure + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file.json" && r.Error != nil && r.Action == repository.FileActionCreated + })).Return().Once() + }, + }, + { + name: "folder creation fails, multiple nested resources skipped", + description: "When folder1/ fails to create, all nested resources (subfolder, files) are skipped", + changes: []ResourceFileChange{ + {Path: "folder1/file1.json", Action: repository.FileActionCreated}, + {Path: "folder1/subfolder/file2.json", Action: repository.FileActionCreated}, + {Path: "folder1/file3.json", Action: repository.FileActionCreated}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) { + // First file triggers folder creation failure + progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file1.json", ""). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && r.Error != nil + })).Return().Once() + + // Subsequent files in same folder are skipped + progress.On("HasDirPathFailedCreation", "folder1/subfolder/file2.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/subfolder/file2.json" && + r.Action == repository.FileActionIgnored && + r.Warning != nil && + r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "folder1/file3.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file3.json" && + r.Action == repository.FileActionIgnored && + r.Warning != nil && + r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + }, + }, + { + name: "file deletion failure tracked", + description: "When a file deletion fails, it's automatically tracked in failedDeletions", + changes: []ResourceFileChange{ + { + Path: "folder1/file.json", + Action: repository.FileActionDeleted, + Existing: &provisioning.ResourceListItem{ + Name: "file1", + Group: "dashboard.grafana.app", + Resource: "dashboards", + }, + }, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) { + gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"} + gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"} + + clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool { + return gvr.Group == "dashboard.grafana.app" + })).Return(dynamicClient.Resource(gvr), gvk, nil) + + // File deletion fails + dynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("permission denied") + }) + + // File deletion recorded with error, automatically tracked in failedDeletions + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file.json" && + r.Action == repository.FileActionDeleted && + r.Error != nil + })).Return().Once() + }, + }, + { + name: "deletion proceeds despite creation failure", + description: "When folder1/ fails to create, deletion of folder1/file2.json still proceeds (resource might exist from previous sync)", + changes: []ResourceFileChange{ + {Path: "folder1/file1.json", Action: repository.FileActionCreated}, + { + Path: "folder1/file2.json", + Action: repository.FileActionDeleted, + Existing: &provisioning.ResourceListItem{ + Name: "file2", + Group: "dashboard.grafana.app", + Resource: "dashboards", + }, + }, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) { + // Creation fails + progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file1.json", ""). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && r.Error != nil + })).Return().Once() + + // Deletion proceeds (NOT checking HasDirPathFailedCreation for deletions) + // Note: deletion will fail because resource doesn't exist, but that's fine for this test + gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"} + gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"} + + clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool { + return gvr.Group == "dashboard.grafana.app" + })).Return(dynamicClient.Resource(gvr), gvk, nil) + + // Record deletion attempt (will have error since resource doesn't exist, but that's ok) + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file2.json" && + r.Action == repository.FileActionDeleted + // Not checking r.Error because resource doesn't exist in fake client + })).Return().Once() + }, + }, + { + name: "multi-level nesting - all skipped", + description: "When level1/ fails, level1/level2/level3/file.json is also skipped", + changes: []ResourceFileChange{ + {Path: "level1/file1.json", Action: repository.FileActionCreated}, + {Path: "level1/level2/file2.json", Action: repository.FileActionCreated}, + {Path: "level1/level2/level3/file3.json", Action: repository.FileActionCreated}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) { + // First file triggers level1/ failure + progress.On("HasDirPathFailedCreation", "level1/file1.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "level1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "level1/file1.json", ""). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "level1/file1.json" && r.Error != nil + })).Return().Once() + + // All nested files are skipped + for _, path := range []string{"level1/level2/file2.json", "level1/level2/level3/file3.json"} { + progress.On("HasDirPathFailedCreation", path).Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == path && r.Action == repository.FileActionIgnored + })).Return().Once() + } + }, + }, + { + name: "mixed success and failure", + description: "When success/ works and failure/ fails, only failure/* are skipped", + changes: []ResourceFileChange{ + {Path: "success/file1.json", Action: repository.FileActionCreated}, + {Path: "failure/file2.json", Action: repository.FileActionCreated}, + {Path: "failure/nested/file3.json", Action: repository.FileActionCreated}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) { + // Success path works + progress.On("HasDirPathFailedCreation", "success/file1.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "success/file1.json", ""). + Return("resource1", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "success/file1.json" && r.Error == nil + })).Return().Once() + + // Failure path fails + progress.On("HasDirPathFailedCreation", "failure/file2.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "failure/", Err: fmt.Errorf("disk full")} + repoResources.On("WriteResourceFromFile", mock.Anything, "failure/file2.json", ""). + Return("", schema.GroupVersionKind{}, folderErr).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/file2.json" && r.Error != nil + })).Return().Once() + + // Nested file in failure path is skipped + progress.On("HasDirPathFailedCreation", "failure/nested/file3.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/nested/file3.json" && r.Action == repository.FileActionIgnored + })).Return().Once() + }, + }, + { + name: "folder creation fails with explicit folder in changes", + description: "When folder1/ is explicitly in changes and fails to create, all nested resources (subfolders and files) are skipped", + changes: []ResourceFileChange{ + {Path: "folder1/", Action: repository.FileActionCreated}, + {Path: "folder1/subfolder/", Action: repository.FileActionCreated}, + {Path: "folder1/file1.json", Action: repository.FileActionCreated}, + {Path: "folder1/subfolder/file2.json", Action: repository.FileActionCreated}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) { + progress.On("HasDirPathFailedCreation", "folder1/").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("EnsureFolderPathExist", mock.Anything, "folder1/").Return("", folderErr).Once() + + progress.On("HasDirPathFailedCreation", "folder1/subfolder/").Return(true).Once() + progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(true).Once() + progress.On("HasDirPathFailedCreation", "folder1/subfolder/file2.json").Return(true).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/" && r.Error != nil + })).Return().Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/subfolder/" && r.Action == repository.FileActionIgnored + })).Return().Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && r.Action == repository.FileActionIgnored + })).Return().Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/subfolder/file2.json" && r.Action == repository.FileActionIgnored + })).Return().Once() + }, + }, + { + name: "folder deletion prevented when child deletion fails", + description: "When a file deletion fails, folder deletion is skipped with FileActionIgnored to prevent orphaning resources", + changes: []ResourceFileChange{ + { + Path: "folder1/file1.json", + Action: repository.FileActionDeleted, + Existing: &provisioning.ResourceListItem{Name: "file1", Group: "dashboard.grafana.app", Resource: "dashboards"}, + }, + {Path: "folder1/", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "folder1", Group: "folder.grafana.app", Resource: "Folder"}}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) { + gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"} + gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"} + + clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool { + return gvr.Group == "dashboard.grafana.app" + })).Return(dynamicClient.Resource(gvr), gvk, nil) + + dynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("permission denied") + }) + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && r.Error != nil + })).Return().Once() + + progress.On("HasDirPathFailedDeletion", "folder1/").Return(true).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/" && r.Action == repository.FileActionIgnored + })).Return().Once() + }, + }, + { + name: "multiple folder deletion failures", + description: "When multiple independent folders have child deletion failures, all folder deletions are skipped", + changes: []ResourceFileChange{ + {Path: "folder1/file1.json", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "file1", Group: "dashboard.grafana.app", Resource: "dashboards"}}, + {Path: "folder1/", Action: repository.FileActionDeleted}, + {Path: "folder2/file2.json", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "file2", Group: "dashboard.grafana.app", Resource: "dashboards"}}, + {Path: "folder2/", Action: repository.FileActionDeleted}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) { + gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"} + gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"} + clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool { + return gvr.Group == "dashboard.grafana.app" + })).Return(dynamicClient.Resource(gvr), gvk, nil) + + dynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("permission denied") + }) + + for _, path := range []string{"folder1/file1.json", "folder2/file2.json"} { + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == path && r.Error != nil + })).Return().Once() + } + + progress.On("HasDirPathFailedDeletion", "folder1/").Return(true).Once() + progress.On("HasDirPathFailedDeletion", "folder2/").Return(true).Once() + + for _, path := range []string{"folder1/", "folder2/"} { + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == path && r.Action == repository.FileActionIgnored + })).Return().Once() + } + }, + }, + { + name: "nested subfolder deletion failure", + description: "When a file deletion fails in a nested subfolder, both the subfolder and parent folder deletions are skipped", + changes: []ResourceFileChange{ + {Path: "parent/subfolder/file.json", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "file1", Group: "dashboard.grafana.app", Resource: "dashboards"}}, + {Path: "parent/subfolder/", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "subfolder", Group: "folder.grafana.app", Resource: "Folder"}}, + {Path: "parent/", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "parent", Group: "folder.grafana.app", Resource: "Folder"}}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) { + gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"} + gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"} + clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool { + return gvr.Group == "dashboard.grafana.app" + })).Return(dynamicClient.Resource(gvr), gvk, nil) + + dynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("permission denied") + }) + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "parent/subfolder/file.json" && r.Error != nil + })).Return().Once() + + progress.On("HasDirPathFailedDeletion", "parent/subfolder/").Return(true).Once() + progress.On("HasDirPathFailedDeletion", "parent/").Return(true).Once() + + for _, path := range []string{"parent/subfolder/", "parent/"} { + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == path && r.Action == repository.FileActionIgnored + })).Return().Once() + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + dynamicClient := dynamicfake.NewSimpleDynamicClient(scheme) + + repo := repository.NewMockRepository(t) + repoResources := resources.NewMockRepositoryResources(t) + clients := resources.NewMockResourceClients(t) + progress := jobs.NewMockJobProgressRecorder(t) + compareFn := NewMockCompareFn(t) + + repo.On("Config").Return(&provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{Name: "test-repo"}, + Spec: provisioning.RepositorySpec{Title: "Test Repo"}, + }) + + tt.setupMocks(repo, repoResources, clients, progress, dynamicClient) + + compareFn.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tt.changes, nil) + progress.On("SetTotal", mock.Anything, len(tt.changes)).Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + err := FullSync(context.Background(), repo, compareFn.Execute, clients, "ref", repoResources, progress, tracing.NewNoopTracerService(), 10, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + require.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + } + + progress.AssertExpectations(t) + repoResources.AssertExpectations(t) + }) + } +} diff --git a/pkg/registry/apis/provisioning/jobs/sync/full_test.go b/pkg/registry/apis/provisioning/jobs/sync/full_test.go index aaa61ee61db..d045c67c6be 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/full_test.go +++ b/pkg/registry/apis/provisioning/jobs/sync/full_test.go @@ -213,6 +213,10 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo return nil }) + progress.On("HasDirPathFailedCreation", mock.MatchedBy(func(path string) bool { + return path == "dashboards/one.json" || path == "dashboards/two.json" || path == "dashboards/three.json" + })).Return(false).Maybe() + repoResources.On("WriteResourceFromFile", mock.Anything, mock.MatchedBy(func(path string) bool { return path == "dashboards/one.json" || path == "dashboards/two.json" || path == "dashboards/three.json" }), "").Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil).Maybe() @@ -235,6 +239,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", ""). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) @@ -259,6 +264,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", ""). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write error")) @@ -285,6 +291,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", ""). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) @@ -309,6 +316,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", ""). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write error")) @@ -335,6 +343,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "one/two/three/").Return(false) repoResources.On("EnsureFolderPathExist", mock.Anything, "one/two/three/").Return("some-folder", nil) progress.On("Record", mock.Anything, jobs.JobResourceResult{ @@ -357,6 +366,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "one/two/three/").Return(false) repoResources.On( "EnsureFolderPathExist", @@ -581,6 +591,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedDeletion", "to-be-deleted/").Return(false) scheme := runtime.NewScheme() require.NoError(t, metav1.AddMetaToScheme(scheme)) @@ -640,6 +651,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedDeletion", "to-be-deleted/").Return(false) scheme := runtime.NewScheme() require.NoError(t, metav1.AddMetaToScheme(scheme)) @@ -695,6 +707,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "dashboards/slow.json").Return(false) repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/slow.json", ""). Run(func(args mock.Arguments) { @@ -708,19 +721,13 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }). Return("", schema.GroupVersionKind{}, context.DeadlineExceeded) + // applyChange records the error from WriteResourceFromFile progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { return result.Action == repository.FileActionCreated && result.Path == "dashboards/slow.json" && result.Error != nil && result.Error.Error() == "writing resource from file dashboards/slow.json: context deadline exceeded" })).Return().Once() - - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Action == repository.FileActionCreated && - result.Path == "dashboards/slow.json" && - result.Error != nil && - result.Error.Error() == "operation timed out after 15 seconds" - })).Return().Once() }, }, } diff --git a/pkg/registry/apis/provisioning/jobs/sync/incremental.go b/pkg/registry/apis/provisioning/jobs/sync/incremental.go index daa94d94636..5ae33f1e4d1 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/incremental.go +++ b/pkg/registry/apis/provisioning/jobs/sync/incremental.go @@ -60,7 +60,7 @@ func IncrementalSync(ctx context.Context, repo repository.Versioned, previousRef if len(affectedFolders) > 0 { cleanupStart := time.Now() span.AddEvent("checking if impacted folders should be deleted", trace.WithAttributes(attribute.Int("affected_folders", len(affectedFolders)))) - err := cleanupOrphanedFolders(ctx, repo, affectedFolders, repositoryResources, tracer) + err := cleanupOrphanedFolders(ctx, repo, affectedFolders, repositoryResources, tracer, progress) metrics.RecordIncrementalSyncPhase(jobs.IncrementalSyncPhaseCleanup, time.Since(cleanupStart)) if err != nil { return tracing.Error(span, fmt.Errorf("cleanup orphaned folders: %w", err)) @@ -85,6 +85,20 @@ func applyIncrementalChanges(ctx context.Context, diff []repository.VersionedFil return nil, tracing.Error(span, err) } + // Check if this resource is nested under a failed folder creation + // This only applies to creation/update/rename operations, not deletions + if change.Action != repository.FileActionDeleted && progress.HasDirPathFailedCreation(change.Path) { + // Skip this resource since its parent folder failed to be created + skipCtx, skipSpan := tracer.Start(ctx, "provisioning.sync.incremental.skip_nested_resource") + progress.Record(skipCtx, jobs.JobResourceResult{ + Path: change.Path, + Action: repository.FileActionIgnored, + Warning: fmt.Errorf("resource was not processed because the parent folder could not be created"), + }) + skipSpan.End() + continue + } + if err := resources.IsPathSupported(change.Path); err != nil { ensureFolderCtx, ensureFolderSpan := tracer.Start(ctx, "provisioning.sync.incremental.ensure_folder_path_exist") // Maintain the safe segment for empty folders @@ -98,7 +112,15 @@ func applyIncrementalChanges(ctx context.Context, diff []repository.VersionedFil if err != nil { ensureFolderSpan.RecordError(err) ensureFolderSpan.End() - return nil, tracing.Error(span, fmt.Errorf("unable to create empty file folder: %w", err)) + + progress.Record(ensureFolderCtx, jobs.JobResourceResult{ + Path: change.Path, + Action: repository.FileActionIgnored, + Group: resources.FolderKind.Group, + Kind: resources.FolderKind.Kind, + Error: err, + }) + continue } progress.Record(ensureFolderCtx, jobs.JobResourceResult{ @@ -185,6 +207,7 @@ func cleanupOrphanedFolders( affectedFolders map[string]string, repositoryResources resources.RepositoryResources, tracer tracing.Tracer, + progress jobs.JobProgressRecorder, ) error { ctx, span := tracer.Start(ctx, "provisioning.sync.incremental.cleanup_orphaned_folders") defer span.End() @@ -198,6 +221,12 @@ func cleanupOrphanedFolders( for path, folderName := range affectedFolders { span.SetAttributes(attribute.String("folder", folderName)) + // Check if any resources under this folder failed to delete + if progress.HasDirPathFailedDeletion(path) { + span.AddEvent("skipping orphaned folder cleanup: a child resource in its path failed to be deleted") + continue + } + // if we can no longer find the folder in git, then we can delete it from grafana _, err := readerRepo.Read(ctx, path, "") if err != nil && (errors.Is(err, repository.ErrFileNotFound) || apierrors.IsNotFound(err)) { diff --git a/pkg/registry/apis/provisioning/jobs/sync/incremental_hierarchical_test.go b/pkg/registry/apis/provisioning/jobs/sync/incremental_hierarchical_test.go new file mode 100644 index 00000000000..ff4212eff1e --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/sync/incremental_hierarchical_test.go @@ -0,0 +1,623 @@ +package sync + +import ( + "context" + "fmt" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/grafana/grafana/apps/provisioning/pkg/repository" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" +) + +/* +TestIncrementalSync_HierarchicalErrorHandling tests the hierarchical error handling behavior: + +FOLDER CREATION FAILURES: +- When EnsureFolderPathExist fails with PathCreationError, the path is tracked +- Subsequent resources under that path are skipped with FileActionIgnored +- Only the initial folder creation error counts toward error limits +- WriteResourceFromFile can also return PathCreationError for implicit folder creation + +FOLDER DELETION FAILURES (cleanupOrphanedFolders): +- When RemoveResourceFromFile fails, path is tracked in failedDeletions +- In cleanupOrphanedFolders, HasDirPathFailedDeletion() is checked before RemoveFolder +- If children failed to delete, folder cleanup is skipped with a span event + +DELETIONS NOT AFFECTED BY CREATION FAILURES: +- HasDirPathFailedCreation is NOT checked for FileActionDeleted +- Deletions proceed even if their parent folder failed to be created +- This handles cleanup of resources that exist from previous syncs + +RENAME OPERATIONS: +- RenameResourceFile can return PathCreationError for the destination folder +- Renames are affected by failed destination folder creation +- Renames are NOT skipped due to source folder creation failures + +AUTOMATIC TRACKING: +- Record() automatically detects PathCreationError via errors.As() and adds to failedCreations +- Record() automatically detects FileActionDeleted with error and adds to failedDeletions +- No manual tracking calls needed +*/ +func TestIncrementalSync_HierarchicalErrorHandling(t *testing.T) { // nolint:gocyclo + tests := []struct { + name string + setupMocks func(*repository.MockVersioned, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) + changes []repository.VersionedFileChange + previousRef string + currentRef string + description string + expectError bool + errorContains string + }{ + { + name: "folder creation fails, nested file skipped", + description: "When unsupported/ fails to create via EnsureFolderPathExist, nested file is skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "unsupported/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "unsupported/nested/file2.txt", Ref: "new-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // First file triggers folder creation which fails + progress.On("HasDirPathFailedCreation", "unsupported/file.txt").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "unsupported/", Err: fmt.Errorf("permission denied")} + repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/").Return("", folderErr).Once() + + // First file recorded with error (note: error is from folder creation, but recorded against file) + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "unsupported/file.txt" && + r.Action == repository.FileActionIgnored && + r.Error != nil + })).Return().Once() + + // Second file is skipped because parent folder failed + progress.On("HasDirPathFailedCreation", "unsupported/nested/file2.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "unsupported/nested/file2.txt" && + r.Action == repository.FileActionIgnored && + r.Warning != nil && + r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + }, + }, + { + name: "WriteResourceFromFile returns PathCreationError, nested resources skipped", + description: "When WriteResourceFromFile implicitly creates a folder and fails, nested resources are skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "folder1/file1.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "folder1/file2.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "folder1/nested/file3.json", Ref: "new-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // First file write fails with PathCreationError + progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file1.json", "new-ref"). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + // First file recorded with error, automatically tracked + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && + r.Action == repository.FileActionCreated && + r.Error != nil + })).Return().Once() + + // Subsequent files are skipped + progress.On("HasDirPathFailedCreation", "folder1/file2.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file2.json" && r.Action == repository.FileActionIgnored && r.Warning != nil + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "folder1/nested/file3.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/nested/file3.json" && r.Action == repository.FileActionIgnored && r.Warning != nil + })).Return().Once() + }, + }, + { + name: "file deletion fails, folder cleanup skipped", + description: "When RemoveResourceFromFile fails, cleanupOrphanedFolders skips folder removal", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionDeleted, Path: "dashboards/file1.json", PreviousRef: "old-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // File deletion fails (deletions don't check HasDirPathFailedCreation) + repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/file1.json", "old-ref"). + Return("dashboard-1", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard"}, fmt.Errorf("permission denied")).Once() + + // Error recorded, automatically tracked in failedDeletions + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "dashboards/file1.json" && + r.Action == repository.FileActionDeleted && + r.Error != nil + })).Return().Once() + + // During cleanup, folder deletion is skipped + progress.On("HasDirPathFailedDeletion", "dashboards/").Return(true).Once() + + // Note: RemoveFolder should NOT be called (verified via AssertNotCalled in test) + }, + }, + { + name: "deletion proceeds despite creation failure", + description: "When folder1/ creation fails, deletion of folder1/old.json still proceeds", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "folder1/new.json", Ref: "new-ref"}, + {Action: repository.FileActionDeleted, Path: "folder1/old.json", PreviousRef: "old-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // Creation fails + progress.On("HasDirPathFailedCreation", "folder1/new.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/new.json", "new-ref"). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/new.json" && r.Error != nil + })).Return().Once() + + // Deletion proceeds (NOT checking HasDirPathFailedCreation for deletions) + repoResources.On("RemoveResourceFromFile", mock.Anything, "folder1/old.json", "old-ref"). + Return("old-resource", "", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/old.json" && + r.Action == repository.FileActionDeleted && + r.Error == nil // Deletion succeeds! + })).Return().Once() + }, + }, + { + name: "multi-level nesting cascade", + description: "When level1/ fails, level1/level2/level3/file.json is also skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "level1/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "level1/level2/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "level1/level2/level3/file.txt", Ref: "new-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // First file triggers level1/ failure + progress.On("HasDirPathFailedCreation", "level1/file.txt").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "level1/", Err: fmt.Errorf("permission denied")} + repoResources.On("EnsureFolderPathExist", mock.Anything, "level1/").Return("", folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "level1/file.txt" && r.Action == repository.FileActionIgnored + })).Return().Once() + + // All nested files are skipped + for _, path := range []string{"level1/level2/file.txt", "level1/level2/level3/file.txt"} { + progress.On("HasDirPathFailedCreation", path).Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == path && r.Action == repository.FileActionIgnored + })).Return().Once() + } + }, + }, + { + name: "mixed success and failure", + description: "When success/ works and failure/ fails, only failure/* are skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "success/file1.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "success/nested/file2.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "failure/file3.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "failure/nested/file4.txt", Ref: "new-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // Success path works + progress.On("HasDirPathFailedCreation", "success/file1.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "success/file1.json", "new-ref"). + Return("resource-1", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "success/file1.json" && r.Error == nil + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "success/nested/file2.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "success/nested/file2.json", "new-ref"). + Return("resource-2", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "success/nested/file2.json" && r.Error == nil + })).Return().Once() + + // Failure path fails + progress.On("HasDirPathFailedCreation", "failure/file3.txt").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "failure/", Err: fmt.Errorf("disk full")} + repoResources.On("EnsureFolderPathExist", mock.Anything, "failure/").Return("", folderErr).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/file3.txt" && r.Action == repository.FileActionIgnored + })).Return().Once() + + // Nested file in failure path is skipped + progress.On("HasDirPathFailedCreation", "failure/nested/file4.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/nested/file4.txt" && r.Action == repository.FileActionIgnored + })).Return().Once() + }, + }, + { + name: "rename with failed destination folder", + description: "When RenameResourceFile fails with PathCreationError for destination, rename is skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + { + Action: repository.FileActionRenamed, + Path: "newfolder/file.json", + PreviousPath: "oldfolder/file.json", + Ref: "new-ref", + PreviousRef: "old-ref", + }, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // Rename fails with PathCreationError for destination folder + progress.On("HasDirPathFailedCreation", "newfolder/file.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "newfolder/", Err: fmt.Errorf("permission denied")} + repoResources.On("RenameResourceFile", mock.Anything, "oldfolder/file.json", "old-ref", "newfolder/file.json", "new-ref"). + Return("", "", schema.GroupVersionKind{}, folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "newfolder/file.json" && + r.Action == repository.FileActionRenamed && + r.Error != nil + })).Return().Once() + }, + }, + { + name: "renamed file still checked, subsequent nested resources skipped", + description: "After rename fails for folder1/file.json, other folder1/* files are skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionRenamed, Path: "folder1/file1.json", PreviousPath: "old/file1.json", Ref: "new-ref", PreviousRef: "old-ref"}, + {Action: repository.FileActionCreated, Path: "folder1/file2.json", Ref: "new-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // Rename is NOT skipped for creation failures (it's checking the destination path) + progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && + r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + // Second file also skipped + progress.On("HasDirPathFailedCreation", "folder1/file2.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file2.json" && r.Action == repository.FileActionIgnored && r.Warning != nil + })).Return().Once() + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runHierarchicalErrorHandlingTest(t, tt) + }) + } +} + +type compositeRepoForTest struct { + *repository.MockVersioned + *repository.MockReader +} + +func runHierarchicalErrorHandlingTest(t *testing.T, tt struct { + name string + setupMocks func(*repository.MockVersioned, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) + changes []repository.VersionedFileChange + previousRef string + currentRef string + description string + expectError bool + errorContains string +}) { + var repo repository.Versioned + mockVersioned := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + // For tests that need cleanup (folder deletion), use composite repo + if tt.name == "file deletion fails, folder cleanup skipped" { + mockReader := repository.NewMockReader(t) + repo = &compositeRepoForTest{ + MockVersioned: mockVersioned, + MockReader: mockReader, + } + } else { + repo = mockVersioned + } + + mockVersioned.On("CompareFiles", mock.Anything, tt.previousRef, tt.currentRef).Return(tt.changes, nil) + progress.On("SetTotal", mock.Anything, len(tt.changes)).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + tt.setupMocks(mockVersioned, repoResources, progress) + + err := IncrementalSync(context.Background(), repo, tt.previousRef, tt.currentRef, repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + require.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + } + + progress.AssertExpectations(t) + repoResources.AssertExpectations(t) + // For deletion tests, verify RemoveFolder was NOT called + if tt.name == "file deletion fails, folder cleanup skipped" { + repoResources.AssertNotCalled(t, "RemoveFolder", mock.Anything, mock.Anything) + } +} + +// TestIncrementalSync_HierarchicalErrorHandling_FailedFolderCreation tests nested resource skipping +func TestIncrementalSync_HierarchicalErrorHandling_FailedFolderCreation(t *testing.T) { + repo := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "unsupported/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "unsupported/subfolder/file2.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "unsupported/file3.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "other/file.json", Ref: "new-ref"}, + } + + repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 4).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + folderErr := &resources.PathCreationError{Path: "unsupported/", Err: fmt.Errorf("permission denied")} + // First check is before it fails. + progress.On("HasDirPathFailedCreation", "unsupported/file.txt").Return(false).Once() + repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/").Return("", folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "unsupported/file.txt" && r.Action == repository.FileActionIgnored && r.Error != nil + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "unsupported/subfolder/file2.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "unsupported/subfolder/file2.txt" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "unsupported/file3.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "unsupported/file3.json" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "other/file.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "other/file.json", "new-ref"). + Return("test-resource", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "other/file.json" && r.Action == repository.FileActionCreated && r.Error == nil + })).Return().Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) +} + +// TestIncrementalSync_HierarchicalErrorHandling_FailedFileDeletion tests folder cleanup prevention +func TestIncrementalSync_HierarchicalErrorHandling_FailedFileDeletion(t *testing.T) { + mockVersioned := repository.NewMockVersioned(t) + mockReader := repository.NewMockReader(t) + repo := &compositeRepoForTest{MockVersioned: mockVersioned, MockReader: mockReader} + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionDeleted, Path: "dashboards/file1.json", PreviousRef: "old-ref"}, + } + + mockVersioned.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 1).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + // Deletions don't check HasDirPathFailedCreation, they go straight to removal + repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/file1.json", "old-ref"). + Return("dashboard-1", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard"}, fmt.Errorf("permission denied")).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "dashboards/file1.json" && r.Action == repository.FileActionDeleted && + r.Error != nil && r.Error.Error() == "removing resource from file dashboards/file1.json: permission denied" + })).Return().Once() + + progress.On("HasDirPathFailedDeletion", "dashboards/").Return(true).Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) + repoResources.AssertNotCalled(t, "RemoveFolder", mock.Anything, mock.Anything) +} + +// TestIncrementalSync_HierarchicalErrorHandling_DeletionNotAffectedByCreationFailure tests deletions proceed despite creation failures +func TestIncrementalSync_HierarchicalErrorHandling_DeletionNotAffectedByCreationFailure(t *testing.T) { + repo := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "folder1/file.json", Ref: "new-ref"}, + {Action: repository.FileActionDeleted, Path: "folder1/old.json", PreviousRef: "old-ref"}, + } + + repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 2).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + // Creation fails + progress.On("HasDirPathFailedCreation", "folder1/file.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file.json", "new-ref"). + Return("", schema.GroupVersionKind{}, &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")}).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file.json" && r.Error != nil + })).Return().Once() + + // Deletion should NOT be skipped (not checking HasDirPathFailedCreation for deletions) + // Deletions don't check HasDirPathFailedCreation, they go straight to removal + repoResources.On("RemoveResourceFromFile", mock.Anything, "folder1/old.json", "old-ref"). + Return("old-resource", "", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/old.json" && r.Action == repository.FileActionDeleted && r.Error == nil + })).Return().Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) +} + +// TestIncrementalSync_HierarchicalErrorHandling_MultiLevelNesting tests multi-level cascade +func TestIncrementalSync_HierarchicalErrorHandling_MultiLevelNesting(t *testing.T) { + repo := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "level1/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "level1/level2/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "level1/level2/level3/file.txt", Ref: "new-ref"}, + } + + repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 3).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + folderErr := &resources.PathCreationError{Path: "level1/", Err: fmt.Errorf("permission denied")} + // First check is before it fails. + progress.On("HasDirPathFailedCreation", "level1/file.txt").Return(false).Once() + repoResources.On("EnsureFolderPathExist", mock.Anything, "level1/").Return("", folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "level1/file.txt" && r.Action == repository.FileActionIgnored && r.Error != nil + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "level1/level2/file.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "level1/level2/file.txt" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "level1/level2/level3/file.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "level1/level2/level3/file.txt" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) +} + +// TestIncrementalSync_HierarchicalErrorHandling_MixedSuccessAndFailure tests partial failures +func TestIncrementalSync_HierarchicalErrorHandling_MixedSuccessAndFailure(t *testing.T) { + repo := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "success/file1.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "success/nested/file2.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "failure/file3.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "failure/nested/file4.txt", Ref: "new-ref"}, + } + + repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 4).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + progress.On("HasDirPathFailedCreation", "success/file1.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "success/file1.json", "new-ref"). + Return("resource-1", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "success/file1.json" && r.Action == repository.FileActionCreated && r.Error == nil + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "success/nested/file2.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "success/nested/file2.json", "new-ref"). + Return("resource-2", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "success/nested/file2.json" && r.Action == repository.FileActionCreated && r.Error == nil + })).Return().Once() + + folderErr := &resources.PathCreationError{Path: "failure/", Err: fmt.Errorf("disk full")} + progress.On("HasDirPathFailedCreation", "failure/file3.txt").Return(false).Once() + repoResources.On("EnsureFolderPathExist", mock.Anything, "failure/").Return("", folderErr).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/file3.txt" && r.Action == repository.FileActionIgnored + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "failure/nested/file4.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/nested/file4.txt" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) + repoResources.AssertExpectations(t) +} + +// TestIncrementalSync_HierarchicalErrorHandling_RenameWithFailedFolderCreation tests rename operations affected by folder failures +func TestIncrementalSync_HierarchicalErrorHandling_RenameWithFailedFolderCreation(t *testing.T) { + repo := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionRenamed, Path: "newfolder/file.json", PreviousPath: "oldfolder/file.json", Ref: "new-ref", PreviousRef: "old-ref"}, + } + + repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 1).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + progress.On("HasDirPathFailedCreation", "newfolder/file.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "newfolder/file.json" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) +} diff --git a/pkg/registry/apis/provisioning/jobs/sync/incremental_test.go b/pkg/registry/apis/provisioning/jobs/sync/incremental_test.go index f694d7f5068..38c537635cf 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/incremental_test.go +++ b/pkg/registry/apis/provisioning/jobs/sync/incremental_test.go @@ -92,6 +92,10 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + // Mock HasDirPathFailedCreation checks + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) + progress.On("HasDirPathFailedCreation", "alerts/alert.yaml").Return(false) + // Mock successful resource writes repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "new-ref"). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) @@ -127,6 +131,9 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", "unsupported/path/file.txt").Return(false) + // Mock folder creation repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/path/"). Return("test-folder", nil) @@ -161,6 +168,9 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", ".unsupported/path/file.txt").Return(false) + progress.On("Record", mock.Anything, jobs.JobResourceResult{ Action: repository.FileActionIgnored, Path: ".unsupported/path/file.txt", @@ -222,6 +232,9 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", "dashboards/new.json").Return(false) + // Mock resource rename repoResources.On("RenameResourceFile", mock.Anything, "dashboards/old.json", "old-ref", "dashboards/new.json", "new-ref"). Return("renamed-dashboard", "", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) @@ -254,6 +267,10 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetTotal", mock.Anything, 1).Return() progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", "dashboards/ignored.json").Return(false) + progress.On("Record", mock.Anything, jobs.JobResourceResult{ Action: repository.FileActionIgnored, Path: "dashboards/ignored.json", @@ -277,16 +294,28 @@ func TestIncrementalSync(t *testing.T) { repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) progress.On("SetTotal", mock.Anything, 1).Return() progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", "unsupported/path/file.txt").Return(false) // Mock folder creation error repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/path/"). Return("", fmt.Errorf("failed to create folder")) + // Mock progress recording with error + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Action == repository.FileActionIgnored && + result.Path == "unsupported/path/file.txt" && + result.Error != nil && + result.Error.Error() == "failed to create folder" + })).Return() + progress.On("TooManyErrors").Return(nil) }, previousRef: "old-ref", currentRef: "new-ref", - expectedError: "unable to create empty file folder: failed to create folder", + expectedCalls: 1, }, { name: "error writing resource", @@ -303,6 +332,9 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) + // Mock resource write error repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "new-ref"). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write failed")) @@ -372,7 +404,8 @@ func TestIncrementalSync(t *testing.T) { repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) progress.On("SetTotal", mock.Anything, 1).Return() progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() - // Mock too many errors + + // Mock too many errors - this is checked before processing files, so HasDirPathFailedCreation won't be called progress.On("TooManyErrors").Return(fmt.Errorf("too many errors occurred")) }, previousRef: "old-ref", @@ -428,6 +461,9 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) { repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/old.json", "old-ref"). Return("old-dashboard", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) + // Mock HasDirPathFailedDeletion check for cleanup + progress.On("HasDirPathFailedDeletion", "dashboards/").Return(false) + // if the folder is not found in git, there should be a call to remove the folder from grafana repo.MockReader.On("Read", mock.Anything, "dashboards/", ""). Return((*repository.FileInfo)(nil), repository.ErrFileNotFound) @@ -453,6 +489,10 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) { progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/old.json", "old-ref"). Return("old-dashboard", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) + + // Mock HasDirPathFailedDeletion check for cleanup + progress.On("HasDirPathFailedDeletion", "dashboards/").Return(false) + // if the folder still exists in git, there should not be a call to delete it from grafana repo.MockReader.On("Read", mock.Anything, "dashboards/", ""). Return(&repository.FileInfo{}, nil) @@ -485,6 +525,13 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) { repoResources.On("RemoveResourceFromFile", mock.Anything, "alerts/old-alert.yaml", "old-ref"). Return("old-alert", "folder-uid-2", schema.GroupVersionKind{Kind: "Alert", Group: "alerts"}, nil) + progress.On("Record", mock.Anything, mock.Anything).Return() + progress.On("TooManyErrors").Return(nil) + + // Mock HasDirPathFailedDeletion checks for cleanup + progress.On("HasDirPathFailedDeletion", "dashboards/").Return(false) + progress.On("HasDirPathFailedDeletion", "alerts/").Return(false) + // both not found in git, both should be deleted repo.MockReader.On("Read", mock.Anything, "dashboards/", ""). Return((*repository.FileInfo)(nil), repository.ErrFileNotFound) @@ -492,9 +539,6 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) { Return((*repository.FileInfo)(nil), repository.ErrFileNotFound) repoResources.On("RemoveFolder", mock.Anything, "folder-uid-1").Return(nil) repoResources.On("RemoveFolder", mock.Anything, "folder-uid-2").Return(nil) - - progress.On("Record", mock.Anything, mock.Anything).Return() - progress.On("TooManyErrors").Return(nil) }, }, } diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 26e97f56b3c..901bc829c97 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -30,7 +30,7 @@ import ( provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/apps/provisioning/pkg/auth" - connectionvalidation "github.com/grafana/grafana/apps/provisioning/pkg/connection" + "github.com/grafana/grafana/apps/provisioning/pkg/connection" appcontroller "github.com/grafana/grafana/apps/provisioning/pkg/controller" clientset "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned" client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" @@ -105,20 +105,21 @@ type APIBuilder struct { jobs.Queue jobs.Store } - jobHistoryConfig *JobHistoryConfig - jobHistoryLoki *jobs.LokiJobHistory - resourceLister resources.ResourceLister - dashboardAccess legacy.MigrationDashboardAccessor - unified resource.ResourceClient - repoFactory repository.Factory - client client.ProvisioningV0alpha1Interface - access auth.AccessChecker - accessWithAdmin auth.AccessChecker - accessWithEditor auth.AccessChecker - accessWithViewer auth.AccessChecker - statusPatcher *appcontroller.RepositoryStatusPatcher - healthChecker *controller.HealthChecker - validator repository.RepositoryValidator + jobHistoryConfig *JobHistoryConfig + jobHistoryLoki *jobs.LokiJobHistory + resourceLister resources.ResourceLister + dashboardAccess legacy.MigrationDashboardAccessor + unified resource.ResourceClient + repoFactory repository.Factory + connectionFactory connection.Factory + client client.ProvisioningV0alpha1Interface + access auth.AccessChecker + accessWithAdmin auth.AccessChecker + accessWithEditor auth.AccessChecker + accessWithViewer auth.AccessChecker + statusPatcher *appcontroller.RepositoryStatusPatcher + healthChecker *controller.HealthChecker + repoValidator repository.RepositoryValidator // Extras provides additional functionality to the API. extras []Extra extraWorkers []jobs.Worker @@ -133,6 +134,7 @@ type APIBuilder struct { func NewAPIBuilder( onlyApiServer bool, repoFactory repository.Factory, + connectionFactory connection.Factory, features featuremgmt.FeatureToggles, unified resource.ResourceClient, configProvider apiserver.RestConfigProvider, @@ -176,6 +178,7 @@ func NewAPIBuilder( usageStats: usageStats, features: features, repoFactory: repoFactory, + connectionFactory: connectionFactory, clients: clients, parsers: parsers, repositoryResources: resources.NewRepositoryResourcesFactory(parsers, clients, resourceLister), @@ -192,7 +195,7 @@ func NewAPIBuilder( allowedTargets: allowedTargets, allowImageRendering: allowImageRendering, registry: registry, - validator: repository.NewValidator(minSyncInterval, allowedTargets, allowImageRendering), + repoValidator: repository.NewValidator(minSyncInterval, allowedTargets, allowImageRendering), useExclusivelyAccessCheckerForAuthz: useExclusivelyAccessCheckerForAuthz, } @@ -253,6 +256,7 @@ func RegisterAPIService( extraBuilders []ExtraBuilder, extraWorkers []jobs.Worker, repoFactory repository.Factory, + connectionFactory connection.Factory, ) (*APIBuilder, error) { //nolint:staticcheck // not yet migrated to OpenFeature if !features.IsEnabledGlobally(featuremgmt.FlagProvisioning) { @@ -271,6 +275,7 @@ func RegisterAPIService( builder := NewAPIBuilder( cfg.DisableControllers, repoFactory, + connectionFactory, features, client, configProvider, @@ -480,6 +485,16 @@ func (b *APIBuilder) authorizeConnectionSubresource(ctx context.Context, a autho Namespace: a.GetNamespace(), }, "")) + // Repositories is read-only + case "repositories": + return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{ + Verb: apiutils.VerbGet, + Group: provisioning.GROUP, + Resource: provisioning.ConnectionResourceInfo.GetName(), + Name: a.GetName(), + Namespace: a.GetNamespace(), + }, "")) + default: id, err := identity.GetRequester(ctx) if err != nil { @@ -549,6 +564,22 @@ func (b *APIBuilder) InstallSchema(scheme *runtime.Scheme) error { return err } + // Register custom field label conversion for Repository to enable field selectors like spec.connection.name + err = scheme.AddFieldLabelConversionFunc( + provisioning.SchemeGroupVersion.WithKind("Repository"), + func(label, value string) (string, string, error) { + switch label { + case "metadata.name", "metadata.namespace", "spec.connection.name": + return label, value, nil + default: + return "", "", fmt.Errorf("field label not supported for Repository: %s", label) + } + }, + ) + if err != nil { + return err + } + metav1.AddToGroupVersion(scheme, provisioning.SchemeGroupVersion) // Only 1 version (for now?) return scheme.SetVersionPriority(provisioning.SchemeGroupVersion) @@ -559,10 +590,19 @@ func (b *APIBuilder) AllowedV0Alpha1Resources() []string { } func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error { - repositoryStorage, err := grafanaregistry.NewRegistryStore(opts.Scheme, provisioning.RepositoryResourceInfo, opts.OptsGetter) + // Create repository storage with custom field selectors (e.g., spec.connection.name) + repositoryStorage, err := grafanaregistry.NewRegistryStoreWithSelectableFields( + opts.Scheme, + provisioning.RepositoryResourceInfo, + opts.OptsGetter, + grafanaregistry.SelectableFieldsOptions{ + GetAttrs: RepositoryGetAttrs, + }, + ) if err != nil { return fmt.Errorf("failed to create repository storage: %w", err) } + repositoryStatusStorage := grafanaregistry.NewRegistryStatusStore(opts.Scheme, repositoryStorage) b.store = repositoryStorage @@ -603,9 +643,10 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI storage[provisioning.ConnectionResourceInfo.StoragePath()] = connectionsStore storage[provisioning.ConnectionResourceInfo.StoragePath("status")] = connectionStatusStorage + storage[provisioning.ConnectionResourceInfo.StoragePath("repositories")] = NewConnectionRepositoriesConnector() // TODO: Add some logic so that the connectors can registered themselves and we don't have logic all over the place - storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = NewTestConnector(b, repository.NewRepositoryTesterWithExistingChecker(repository.NewSimpleRepositoryTester(b.validator), b.VerifyAgainstExistingRepositories)) + storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = NewTestConnector(b, repository.NewRepositoryTesterWithExistingChecker(repository.NewSimpleRepositoryTester(b.repoValidator), b.VerifyAgainstExistingRepositories)) storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = NewFilesConnector(b, b.parsers, b.clients, b.accessWithAdmin) storage[provisioning.RepositoryResourceInfo.StoragePath("refs")] = NewRefsConnector(b) storage[provisioning.RepositoryResourceInfo.StoragePath("resources")] = &listConnector{ @@ -646,10 +687,15 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis if ok { return nil } - // TODO: complete this as part of https://github.com/grafana/git-ui-sync-project/issues/700 + c, ok := obj.(*provisioning.Connection) if ok { - return connectionvalidation.MutateConnection(c) + conn, err := b.asConnection(ctx, c, nil) + if err != nil { + return err + } + + return conn.Mutate(ctx) } r, ok := obj.(*provisioning.Repository) @@ -700,9 +746,15 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm return nil } - connection, ok := obj.(*provisioning.Connection) + // Validate connections + c, ok := obj.(*provisioning.Connection) if ok { - return connectionvalidation.ValidateConnection(connection) + conn, err := b.asConnection(ctx, c, a.GetOldObject()) + if err != nil { + return err + } + + return conn.Validate(ctx) } // Validate Jobs @@ -722,7 +774,7 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm // the only time to add configuration checks here is if you need to compare // the incoming change to the current configuration isCreate := a.GetOperation() == admission.Create - list := b.validator.ValidateRepository(repo, isCreate) + list := b.repoValidator.ValidateRepository(repo, isCreate) cfg := repo.Config() if a.GetOperation() == admission.Update { @@ -795,7 +847,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH } b.statusPatcher = appcontroller.NewRepositoryStatusPatcher(b.GetClient()) - b.healthChecker = controller.NewHealthChecker(b.statusPatcher, b.registry, repository.NewSimpleRepositoryTester(b.validator)) + b.healthChecker = controller.NewHealthChecker(b.statusPatcher, b.registry, repository.NewSimpleRepositoryTester(b.repoValidator)) // if running solely CRUD, skip the rest of the setup if b.onlyApiServer { @@ -806,8 +858,10 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH sharedInformerFactory := informers.NewSharedInformerFactory(c, 60*time.Second) repoInformer := sharedInformerFactory.Provisioning().V0alpha1().Repositories() jobInformer := sharedInformerFactory.Provisioning().V0alpha1().Jobs() + connInformer := sharedInformerFactory.Provisioning().V0alpha1().Connections() go repoInformer.Informer().Run(postStartHookCtx.Done()) go jobInformer.Informer().Run(postStartHookCtx.Done()) + go connInformer.Informer().Run(postStartHookCtx.Done()) // Create the repository resources factory repositoryListerWrapper := func(ctx context.Context) ([]provisioning.Repository, error) { @@ -928,6 +982,18 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH go repoController.Run(postStartHookCtx.Context, repoControllerWorkers) + // Create and run connection controller + connStatusPatcher := appcontroller.NewConnectionStatusPatcher(b.GetClient()) + connController, err := controller.NewConnectionController( + b.GetClient(), + connInformer, + connStatusPatcher, + ) + if err != nil { + return err + } + go connController.Run(postStartHookCtx.Context, repoControllerWorkers) + // If Loki not used, initialize the API client-based history writer and start the controller for history jobs if b.jobHistoryLoki == nil { // Create HistoryJobController for cleanup of old job history entries @@ -1247,6 +1313,23 @@ spec: oas.Paths.Paths[repoprefix+"/jobs/{uid}"] = sub } + // Document connection repositories endpoint + connectionprefix := root + "namespaces/{namespace}/connections/{name}" + sub = oas.Paths.Paths[connectionprefix+"/repositories"] + if sub != nil { + sub.Get.Description = "List repositories available from the external git provider through this connection" + sub.Get.Summary = "List external repositories" + sub.Get.Parameters = []*spec3.Parameter{} + sub.Post = nil + sub.Put = nil + sub.Delete = nil + + // Replace the content type for this response + mt := sub.Get.Responses.StatusCodeResponses[200].Content + s := defs[defsBase+"ExternalRepositoryList"].Schema + mt["*/*"].Schema = &s + } + // Run all extra post-processors. for _, extra := range b.extras { if err := extra.PostProcessOpenAPI(oas); err != nil { @@ -1382,6 +1465,35 @@ func (b *APIBuilder) asRepository(ctx context.Context, obj runtime.Object, old r return b.repoFactory.Build(ctx, r) } +func (b *APIBuilder) asConnection(ctx context.Context, obj runtime.Object, old runtime.Object) (connection.Connection, error) { + if obj == nil { + return nil, fmt.Errorf("missing connection object") + } + + c, ok := obj.(*provisioning.Connection) + if !ok { + return nil, fmt.Errorf("expected connection object") + } + + // Copy previous values if they exist + if old != nil { + o, ok := old.(*provisioning.Connection) + if ok && !o.Secure.IsZero() { + if c.Secure.PrivateKey.IsZero() { + c.Secure.PrivateKey = o.Secure.PrivateKey + } + if c.Secure.Token.IsZero() { + c.Secure.Token = o.Secure.Token + } + if c.Secure.ClientSecret.IsZero() { + c.Secure.ClientSecret = o.Secure.ClientSecret + } + } + } + + return b.connectionFactory.Build(ctx, c) +} + func getJSONResponse(ref string) *spec3.Responses { return &spec3.Responses{ ResponsesProps: spec3.ResponsesProps{ diff --git a/pkg/registry/apis/provisioning/register_validate_test.go b/pkg/registry/apis/provisioning/register_validate_test.go index 18b366e4de8..f612d3a408c 100644 --- a/pkg/registry/apis/provisioning/register_validate_test.go +++ b/pkg/registry/apis/provisioning/register_validate_test.go @@ -28,7 +28,7 @@ func TestAPIBuilderValidate(t *testing.T) { repoFactory: factory, allowedTargets: []v0alpha1.SyncTargetType{v0alpha1.SyncTargetTypeFolder}, allowImageRendering: false, - validator: validator, + repoValidator: validator, } t.Run("min sync interval is less than 10 seconds", func(t *testing.T) { diff --git a/pkg/registry/apis/provisioning/repository_fields.go b/pkg/registry/apis/provisioning/repository_fields.go new file mode 100644 index 00000000000..0849c558e2f --- /dev/null +++ b/pkg/registry/apis/provisioning/repository_fields.go @@ -0,0 +1,44 @@ +package provisioning + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +// RepositoryToSelectableFields returns a field set that can be used for field selectors. +// This includes standard metadata fields plus custom fields like spec.connection.name. +func RepositoryToSelectableFields(obj *provisioning.Repository) fields.Set { + objectMetaFields := generic.ObjectMetaFieldsSet(&obj.ObjectMeta, true) + + // Add custom selectable fields + specificFields := fields.Set{ + "spec.connection.name": getConnectionName(obj), + } + + return generic.MergeFieldsSets(objectMetaFields, specificFields) +} + +// getConnectionName safely extracts the connection name from a Repository. +// Returns empty string if no connection is configured. +func getConnectionName(obj *provisioning.Repository) string { + if obj == nil || obj.Spec.Connection == nil { + return "" + } + return obj.Spec.Connection.Name +} + +// RepositoryGetAttrs returns labels and fields of a Repository object. +// This is used by the storage layer for filtering. +func RepositoryGetAttrs(obj runtime.Object) (labels.Set, fields.Set, error) { + repo, ok := obj.(*provisioning.Repository) + if !ok { + return nil, nil, fmt.Errorf("given object is not a Repository") + } + return labels.Set(repo.Labels), RepositoryToSelectableFields(repo), nil +} diff --git a/pkg/registry/apis/provisioning/repository_fields_test.go b/pkg/registry/apis/provisioning/repository_fields_test.go new file mode 100644 index 00000000000..89a2271477c --- /dev/null +++ b/pkg/registry/apis/provisioning/repository_fields_test.go @@ -0,0 +1,184 @@ +package provisioning + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +func TestGetConnectionName(t *testing.T) { + tests := []struct { + name string + repo *provisioning.Repository + expected string + }{ + { + name: "nil repository returns empty string", + repo: nil, + expected: "", + }, + { + name: "repository without connection returns empty string", + repo: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Title: "test-repo", + }, + }, + expected: "", + }, + { + name: "repository with connection returns connection name", + repo: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Title: "test-repo", + Connection: &provisioning.ConnectionInfo{ + Name: "my-connection", + }, + }, + }, + expected: "my-connection", + }, + { + name: "repository with empty connection name returns empty string", + repo: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Title: "test-repo", + Connection: &provisioning.ConnectionInfo{ + Name: "", + }, + }, + }, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getConnectionName(tt.repo) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestRepositoryToSelectableFields(t *testing.T) { + tests := []struct { + name string + repo *provisioning.Repository + expectedFields map[string]string + }{ + { + name: "includes metadata.name and metadata.namespace", + repo: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "default", + }, + Spec: provisioning.RepositorySpec{ + Title: "Test Repository", + }, + }, + expectedFields: map[string]string{ + "metadata.name": "test-repo", + "metadata.namespace": "default", + "spec.connection.name": "", + }, + }, + { + name: "includes spec.connection.name when set", + repo: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "repo-with-connection", + Namespace: "org-1", + }, + Spec: provisioning.RepositorySpec{ + Title: "Repo With Connection", + Connection: &provisioning.ConnectionInfo{ + Name: "github-connection", + }, + }, + }, + expectedFields: map[string]string{ + "metadata.name": "repo-with-connection", + "metadata.namespace": "org-1", + "spec.connection.name": "github-connection", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fields := RepositoryToSelectableFields(tt.repo) + + for key, expectedValue := range tt.expectedFields { + actualValue, exists := fields[key] + assert.True(t, exists, "field %s should exist", key) + assert.Equal(t, expectedValue, actualValue, "field %s should have correct value", key) + } + }) + } +} + +func TestRepositoryGetAttrs(t *testing.T) { + t.Run("returns error for non-Repository object", func(t *testing.T) { + // Pass a different runtime.Object type instead of a Repository + connection := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Name: "not-a-repository", + }, + } + _, _, err := RepositoryGetAttrs(connection) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a Repository") + }) + + t.Run("returns labels and fields for valid Repository", func(t *testing.T) { + repo := &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "default", + Labels: map[string]string{ + "app": "grafana", + "env": "test", + }, + }, + Spec: provisioning.RepositorySpec{ + Title: "Test Repository", + Connection: &provisioning.ConnectionInfo{ + Name: "my-connection", + }, + }, + } + + labels, fields, err := RepositoryGetAttrs(repo) + require.NoError(t, err) + + // Check labels + assert.Equal(t, "grafana", labels["app"]) + assert.Equal(t, "test", labels["env"]) + + // Check fields + assert.Equal(t, "test-repo", fields["metadata.name"]) + assert.Equal(t, "default", fields["metadata.namespace"]) + assert.Equal(t, "my-connection", fields["spec.connection.name"]) + }) + + t.Run("returns empty connection name when not set", func(t *testing.T) { + repo := &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "default", + }, + Spec: provisioning.RepositorySpec{ + Title: "Test Repository", + }, + } + + _, fields, err := RepositoryGetAttrs(repo) + require.NoError(t, err) + assert.Equal(t, "", fields["spec.connection.name"]) + }) +} diff --git a/pkg/registry/apis/provisioning/resources/folders.go b/pkg/registry/apis/provisioning/resources/folders.go index 8b8f4201745..b4a06f78691 100644 --- a/pkg/registry/apis/provisioning/resources/folders.go +++ b/pkg/registry/apis/provisioning/resources/folders.go @@ -20,6 +20,21 @@ import ( const MaxNumberOfFolders = 10000 +// PathCreationError represents an error that occurred while creating a folder path. +// It contains the path that failed and the underlying error. +type PathCreationError struct { + Path string + Err error +} + +func (e *PathCreationError) Unwrap() error { + return e.Err +} + +func (e *PathCreationError) Error() string { + return fmt.Sprintf("failed to create path %s: %v", e.Path, e.Err) +} + type FolderManager struct { repo repository.ReaderWriter tree FolderTree @@ -73,7 +88,11 @@ func (fm *FolderManager) EnsureFolderPathExist(ctx context.Context, filePath str } if err := fm.EnsureFolderExists(ctx, f, parent); err != nil { - return fmt.Errorf("ensure folder exists: %w", err) + // Wrap in PathCreationError to indicate which path failed + return &PathCreationError{ + Path: f.Path, + Err: fmt.Errorf("ensure folder exists: %w", err), + } } fm.tree.Add(f, parent) diff --git a/pkg/registry/apis/provisioning/resources/folders_test.go b/pkg/registry/apis/provisioning/resources/folders_test.go new file mode 100644 index 00000000000..ed593a7d26c --- /dev/null +++ b/pkg/registry/apis/provisioning/resources/folders_test.go @@ -0,0 +1,68 @@ +package resources_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" + "github.com/stretchr/testify/require" +) + +func TestPathCreationError(t *testing.T) { + t.Run("Error method returns formatted message", func(t *testing.T) { + underlyingErr := fmt.Errorf("underlying error") + pathErr := &resources.PathCreationError{ + Path: "grafana/folder-1", + Err: underlyingErr, + } + + expectedMsg := "failed to create path grafana/folder-1: underlying error" + require.Equal(t, expectedMsg, pathErr.Error()) + }) + + t.Run("Unwrap returns underlying error", func(t *testing.T) { + underlyingErr := fmt.Errorf("underlying error") + pathErr := &resources.PathCreationError{ + Path: "grafana/folder-1", + Err: underlyingErr, + } + + unwrapped := pathErr.Unwrap() + require.Equal(t, underlyingErr, unwrapped) + require.EqualError(t, unwrapped, "underlying error") + }) + + t.Run("errors.Is finds underlying error", func(t *testing.T) { + underlyingErr := fmt.Errorf("underlying error") + pathErr := &resources.PathCreationError{ + Path: "grafana/folder-1", + Err: underlyingErr, + } + + require.True(t, errors.Is(pathErr, underlyingErr)) + require.False(t, errors.Is(pathErr, fmt.Errorf("different error"))) + }) + + t.Run("errors.As extracts PathCreationError", func(t *testing.T) { + underlyingErr := fmt.Errorf("underlying error") + pathErr := &resources.PathCreationError{ + Path: "grafana/folder-1", + Err: underlyingErr, + } + + var extractedErr *resources.PathCreationError + require.True(t, errors.As(pathErr, &extractedErr)) + require.NotNil(t, extractedErr) + require.Equal(t, "grafana/folder-1", extractedErr.Path) + require.Equal(t, underlyingErr, extractedErr.Err) + }) + + t.Run("errors.As returns false for non-PathCreationError", func(t *testing.T) { + regularErr := fmt.Errorf("regular error") + + var extractedErr *resources.PathCreationError + require.False(t, errors.As(regularErr, &extractedErr)) + require.Nil(t, extractedErr) + }) +} diff --git a/pkg/registry/apis/wireset.go b/pkg/registry/apis/wireset.go index d95f01bef36..296b7d25b59 100644 --- a/pkg/registry/apis/wireset.go +++ b/pkg/registry/apis/wireset.go @@ -44,6 +44,7 @@ var provisioningExtras = wire.NewSet( pullrequest.ProvidePullRequestWorker, webhooks.ProvideWebhooksWithImages, extras.ProvideFactoryFromConfig, + extras.ProvideConnectionFactoryFromConfig, extras.ProvideProvisioningExtraAPIs, extras.ProvideExtraWorkers, ) diff --git a/pkg/registry/apps/alerting/notifications/receiver/legacy_storage.go b/pkg/registry/apps/alerting/notifications/receiver/legacy_storage.go index 84c0fe9f7fd..f8f663a8853 100644 --- a/pkg/registry/apps/alerting/notifications/receiver/legacy_storage.go +++ b/pkg/registry/apps/alerting/notifications/receiver/legacy_storage.go @@ -17,7 +17,6 @@ import ( "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" alertingac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage" ) var ( @@ -25,7 +24,7 @@ var ( ) type ReceiverService interface { - GetReceiver(ctx context.Context, q ngmodels.GetReceiverQuery, user identity.Requester) (*ngmodels.Receiver, error) + GetReceiver(ctx context.Context, uid string, decrypt bool, user identity.Requester) (*ngmodels.Receiver, error) GetReceivers(ctx context.Context, q ngmodels.GetReceiversQuery, user identity.Requester) ([]*ngmodels.Receiver, error) CreateReceiver(ctx context.Context, r *ngmodels.Receiver, orgID int64, user identity.Requester) (*ngmodels.Receiver, error) UpdateReceiver(ctx context.Context, r *ngmodels.Receiver, storedSecureFields map[string][]string, orgID int64, user identity.Requester) (*ngmodels.Receiver, error) @@ -116,22 +115,12 @@ func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOption return nil, err } - name, err := legacy_storage.UidToName(uid) - if err != nil { - return nil, apierrors.NewNotFound(ResourceInfo.GroupResource(), uid) - } - q := ngmodels.GetReceiverQuery{ - OrgID: info.OrgID, - Name: name, - Decrypt: false, - } - user, err := identity.GetRequester(ctx) if err != nil { return nil, err } - r, err := s.service.GetReceiver(ctx, q, user) + r, err := s.service.GetReceiver(ctx, uid, false, user) if err != nil { return nil, err } diff --git a/pkg/registry/apps/apps.go b/pkg/registry/apps/apps.go index a1ec8aafd65..dc52f4658e0 100644 --- a/pkg/registry/apps/apps.go +++ b/pkg/registry/apps/apps.go @@ -19,7 +19,6 @@ import ( "github.com/grafana/grafana/pkg/registry/apps/annotation" "github.com/grafana/grafana/pkg/registry/apps/correlations" "github.com/grafana/grafana/pkg/registry/apps/example" - "github.com/grafana/grafana/pkg/registry/apps/investigations" "github.com/grafana/grafana/pkg/registry/apps/logsdrilldown" "github.com/grafana/grafana/pkg/registry/apps/playlist" "github.com/grafana/grafana/pkg/registry/apps/plugins" @@ -107,7 +106,6 @@ func ProvideBuilderRunners( registrar builder.APIRegistrar, restConfigProvider apiserver.RestConfigProvider, features featuremgmt.FeatureToggles, - investigationAppProvider *investigations.InvestigationsAppProvider, grafanaCfg *setting.Cfg, ) (*Service, error) { cfgWrapper := func(ctx context.Context) (*rest.Config, error) { @@ -127,11 +125,6 @@ func ProvideBuilderRunners( var apiGroupRunner *runner.APIGroupRunner var err error providers := []app.Provider{} - //nolint:staticcheck // not yet migrated to OpenFeature - if features.IsEnabledGlobally(featuremgmt.FlagInvestigationsBackend) { - logger.Debug("Investigations backend is enabled") - providers = append(providers, investigationAppProvider) - } apiGroupRunner, err = runner.NewAPIGroupRunner(cfg, providers...) if err != nil { diff --git a/pkg/registry/apps/investigations/authorizer.go b/pkg/registry/apps/investigations/authorizer.go deleted file mode 100644 index 3178f3d2f9c..00000000000 --- a/pkg/registry/apps/investigations/authorizer.go +++ /dev/null @@ -1,38 +0,0 @@ -package investigations - -import ( - "context" - - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/services/accesscontrol" - - "k8s.io/apiserver/pkg/authorization/authorizer" -) - -func GetAuthorizer() authorizer.Authorizer { - return authorizer.AuthorizerFunc(func( - ctx context.Context, attr authorizer.Attributes, - ) (authorized authorizer.Decision, reason string, err error) { - if !attr.IsResourceRequest() { - return authorizer.DecisionNoOpinion, "", nil - } - - u, err := identity.GetRequester(ctx) - if err != nil { - return authorizer.DecisionDeny, "valid user is required", err - } - - p := u.GetPermissions() - if len(p) == 0 { - return authorizer.DecisionDeny, "no permissions", nil - } - - _, ok := p[accesscontrol.ActionDatasourcesExplore] - if !ok { - // defer to the default authorizer if datasources:explore is not present - return authorizer.DecisionNoOpinion, "", nil - } - - return authorizer.DecisionAllow, "", nil - }) -} diff --git a/pkg/registry/apps/investigations/authorizer_test.go b/pkg/registry/apps/investigations/authorizer_test.go deleted file mode 100644 index c6d870bc7e7..00000000000 --- a/pkg/registry/apps/investigations/authorizer_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package investigations - -import ( - "context" - "testing" - - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/stretchr/testify/assert" - "k8s.io/apiserver/pkg/authorization/authorizer" -) - -func TestGetAuthorizer(t *testing.T) { - tests := []struct { - name string - ctx context.Context - attr authorizer.Attributes - expectedDecision authorizer.Decision - expectedReason string - expectedErr error - }{ - { - name: "non-resource request", - ctx: context.TODO(), - attr: &mockAttributes{resourceRequest: false}, - expectedDecision: authorizer.DecisionNoOpinion, - expectedReason: "", - expectedErr: nil, - }, - { - name: "user has datasources:explore permission", - ctx: identity.WithRequester(context.TODO(), &mockUser{permissions: map[string][]string{accesscontrol.ActionDatasourcesExplore: {}}}), - attr: &mockAttributes{resourceRequest: true}, - expectedDecision: authorizer.DecisionAllow, - expectedReason: "", - expectedErr: nil, - }, - { - name: "user does not have datasources:explore permission", - ctx: identity.WithRequester(context.TODO(), &mockUser{}), - attr: &mockAttributes{resourceRequest: true}, - expectedDecision: authorizer.DecisionDeny, - expectedReason: "no permissions", - expectedErr: nil, - }, - { - name: "user does not have datasources:explore permission", - ctx: identity.WithRequester(context.TODO(), &mockUser{permissions: map[string][]string{"foo": {}}}), - attr: &mockAttributes{resourceRequest: true}, - expectedDecision: authorizer.DecisionNoOpinion, - expectedReason: "", - expectedErr: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - auth := GetAuthorizer() - decision, reason, err := auth.Authorize(tt.ctx, tt.attr) - assert.Equal(t, tt.expectedDecision, decision) - assert.Equal(t, tt.expectedReason, reason) - assert.Equal(t, tt.expectedErr, err) - }) - } -} - -type mockAttributes struct { - authorizer.Attributes - resourceRequest bool -} - -func (m *mockAttributes) IsResourceRequest() bool { - return m.resourceRequest -} - -// Implement other methods of authorizer.Attributes as needed - -type mockUser struct { - identity.Requester - permissions map[string][]string -} - -func (m *mockUser) GetPermissions() map[string][]string { - return m.permissions -} - -// Implement other methods of identity.Requester as needed diff --git a/pkg/registry/apps/investigations/register.go b/pkg/registry/apps/investigations/register.go deleted file mode 100644 index 6fbf2603491..00000000000 --- a/pkg/registry/apps/investigations/register.go +++ /dev/null @@ -1,33 +0,0 @@ -package investigations - -import ( - "github.com/grafana/grafana-app-sdk/app" - "github.com/grafana/grafana-app-sdk/simple" - "github.com/grafana/grafana/apps/investigations/pkg/apis" - investigationv0alpha1 "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1" - investigationapp "github.com/grafana/grafana/apps/investigations/pkg/app" - "github.com/grafana/grafana/pkg/services/apiserver/builder" - "github.com/grafana/grafana/pkg/services/apiserver/builder/runner" - "github.com/grafana/grafana/pkg/setting" -) - -type InvestigationsAppProvider struct { - app.Provider - cfg *setting.Cfg -} - -func RegisterApp( - cfg *setting.Cfg, -) *InvestigationsAppProvider { - provider := &InvestigationsAppProvider{ - cfg: cfg, - } - appCfg := &runner.AppBuilderConfig{ - OpenAPIDefGetter: investigationv0alpha1.GetOpenAPIDefinitions, - ManagedKinds: investigationapp.GetKinds(), - Authorizer: GetAuthorizer(), - AllowedV0Alpha1Resources: []string{builder.AllResourcesAllowed}, - } - provider.Provider = simple.NewAppProvider(apis.LocalManifest(), appCfg, investigationapp.New) - return provider -} diff --git a/pkg/registry/apps/wireset.go b/pkg/registry/apps/wireset.go index a2cef7b2d55..d2d4feb2b5c 100644 --- a/pkg/registry/apps/wireset.go +++ b/pkg/registry/apps/wireset.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/registry/apps/annotation" "github.com/grafana/grafana/pkg/registry/apps/correlations" "github.com/grafana/grafana/pkg/registry/apps/example" - "github.com/grafana/grafana/pkg/registry/apps/investigations" "github.com/grafana/grafana/pkg/registry/apps/logsdrilldown" "github.com/grafana/grafana/pkg/registry/apps/playlist" "github.com/grafana/grafana/pkg/registry/apps/plugins" @@ -21,7 +20,6 @@ var WireSet = wire.NewSet( ProvideAppInstallers, ProvideBuilderRunners, playlist.RegisterAppInstaller, - investigations.RegisterApp, plugins.ProvideAppInstaller, shorturl.RegisterAppInstaller, correlations.RegisterAppInstaller, diff --git a/pkg/server/ring.go b/pkg/server/ring.go index 90026e58391..4dc261d68c5 100644 --- a/pkg/server/ring.go +++ b/pkg/server/ring.go @@ -3,6 +3,7 @@ package server import ( "context" "fmt" + "strconv" "time" "github.com/grafana/dskit/flagext" @@ -15,11 +16,15 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" + grpc_retry "github.com/grpc-ecosystem/go-grpc-middleware/retry" + "github.com/grpc-ecosystem/go-grpc-middleware/util/metautils" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" "google.golang.org/grpc" + "google.golang.org/grpc/backoff" + "google.golang.org/grpc/codes" "google.golang.org/grpc/health/grpc_health_v1" ) @@ -111,14 +116,25 @@ func newClientPool(clientCfg grpcclient.Config, log log.Logger, reg prometheus.R Help: "Time spent executing requests to resource server.", Buckets: prometheus.ExponentialBuckets(0.008, 4, 7), }, []string{"operation", "status_code"}) + factoryRequestRetries := promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ + Name: "resource_server_client_request_retries_total", + Help: "Total number of retries for requests to the resource server.", + }, []string{"operation"}) factory := ringclient.PoolInstFunc(func(inst ring.InstanceDesc) (ringclient.PoolClient, error) { unaryInterceptors, streamInterceptors := grpcclient.Instrument(factoryRequestDuration) + + // Add retry interceptors for transient connection issues + unaryInterceptors = append(unaryInterceptors, ringClientRetryInterceptor()) + unaryInterceptors = append(unaryInterceptors, ringClientRetryInstrument(factoryRequestRetries)) + opts, err := clientCfg.DialOption(unaryInterceptors, streamInterceptors, nil) if err != nil { return nil, err } + opts = append(opts, connectionBackoffOptions()) + conn, err := grpc.NewClient(inst.Addr, opts...) if err != nil { return nil, fmt.Errorf("failed to dial resource server %s %s: %s", inst.Id, inst.Addr, err) @@ -135,3 +151,40 @@ func newClientPool(clientCfg grpcclient.Config, log log.Logger, reg prometheus.R return ringclient.NewPool(resource.RingName, poolCfg, nil, factory, clientsCount, log) } + +// ringClientRetryInterceptor creates an interceptor to perform retries for unary methods. +// It retries on ResourceExhausted and Unavailable codes, which are typical for +// transient connection issues and rate limiting. +func ringClientRetryInterceptor() grpc.UnaryClientInterceptor { + return grpc_retry.UnaryClientInterceptor( + grpc_retry.WithMax(3), + grpc_retry.WithBackoff(grpc_retry.BackoffExponentialWithJitter(time.Second, 0.1)), + grpc_retry.WithCodes(codes.ResourceExhausted, codes.Unavailable), + ) +} + +// ringClientRetryInstrument creates an interceptor to count retry attempts for metrics. +func ringClientRetryInstrument(metric *prometheus.CounterVec) grpc.UnaryClientInterceptor { + return func(ctx context.Context, method string, req, resp interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + // We can tell if a call is a retry by checking the retry attempt metadata. + attempt, err := strconv.Atoi(metautils.ExtractOutgoing(ctx).Get(grpc_retry.AttemptMetadataKey)) + if err == nil && attempt > 0 { + metric.WithLabelValues(method).Inc() + } + return invoker(ctx, method, req, resp, cc, opts...) + } +} + +// connectionBackoffOptions configures connection backoff parameters for faster recovery from +// transient connection failures (e.g., during pod restarts). +func connectionBackoffOptions() grpc.DialOption { + return grpc.WithConnectParams(grpc.ConnectParams{ + Backoff: backoff.Config{ + BaseDelay: 100 * time.Millisecond, + Multiplier: 1.6, + Jitter: 0.2, + MaxDelay: 10 * time.Second, + }, + MinConnectTimeout: 5 * time.Second, + }) +} diff --git a/pkg/server/test_env.go b/pkg/server/test_env.go index 76fa96a75c7..57c7e87bbb9 100644 --- a/pkg/server/test_env.go +++ b/pkg/server/test_env.go @@ -3,6 +3,7 @@ package server import ( "github.com/stretchr/testify/mock" + githubconnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" "github.com/grafana/grafana/apps/provisioning/pkg/repository/github" "github.com/grafana/grafana/apps/secret/pkg/decrypt" "github.com/grafana/grafana/pkg/infra/db" @@ -34,24 +35,26 @@ func ProvideTestEnv( featureMgmt featuremgmt.FeatureToggles, resourceClient resource.ResourceClient, idService auth.IDService, - githubFactory *github.Factory, + githubRepoFactory *github.Factory, + githubConnectionFactory githubconnection.GithubFactory, decryptService decrypt.DecryptService, ) (*TestEnv, error) { return &TestEnv{ - TestingT: testingT, - Server: server, - SQLStore: db, - Cfg: cfg, - NotificationService: ns, - GRPCServer: grpcServer, - PluginRegistry: pluginRegistry, - HTTPClientProvider: httpClientProvider, - OAuthTokenService: oAuthTokenService, - FeatureToggles: featureMgmt, - ResourceClient: resourceClient, - IDService: idService, - GitHubFactory: githubFactory, - DecryptService: decryptService, + TestingT: testingT, + Server: server, + SQLStore: db, + Cfg: cfg, + NotificationService: ns, + GRPCServer: grpcServer, + PluginRegistry: pluginRegistry, + HTTPClientProvider: httpClientProvider, + OAuthTokenService: oAuthTokenService, + FeatureToggles: featureMgmt, + ResourceClient: resourceClient, + IDService: idService, + GithubRepoFactory: githubRepoFactory, + GithubConnectionFactory: githubConnectionFactory, + DecryptService: decryptService, }, nil } @@ -60,18 +63,19 @@ type TestEnv struct { mock.TestingT Cleanup(func()) } - Server *Server - SQLStore db.DB - Cfg *setting.Cfg - NotificationService *notifications.NotificationServiceMock - GRPCServer grpcserver.Provider - PluginRegistry registry.Service - HTTPClientProvider httpclient.Provider - OAuthTokenService *oauthtokentest.Service - RequestMiddleware web.Middleware - FeatureToggles featuremgmt.FeatureToggles - ResourceClient resource.ResourceClient - IDService auth.IDService - GitHubFactory *github.Factory - DecryptService decrypt.DecryptService + Server *Server + SQLStore db.DB + Cfg *setting.Cfg + NotificationService *notifications.NotificationServiceMock + GRPCServer grpcserver.Provider + PluginRegistry registry.Service + HTTPClientProvider httpclient.Provider + OAuthTokenService *oauthtokentest.Service + RequestMiddleware web.Middleware + FeatureToggles featuremgmt.FeatureToggles + ResourceClient resource.ResourceClient + IDService auth.IDService + GithubRepoFactory *github.Factory + GithubConnectionFactory githubconnection.GithubFactory + DecryptService decrypt.DecryptService } diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 141079b1513..399794e9787 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -15,6 +15,7 @@ import ( "go.opentelemetry.io/otel/trace" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + ghconnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" "github.com/grafana/grafana/apps/provisioning/pkg/repository/github" "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/api/avatar" @@ -297,6 +298,7 @@ var wireBasicSet = wire.NewSet( notifications.ProvideService, notifications.ProvideSmtpService, github.ProvideFactory, + ghconnection.ProvideFactory, tracing.ProvideService, tracing.ProvideTracingConfig, wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)), diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index ee7653f79d4..218e9fabc36 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -10,6 +10,7 @@ import ( "github.com/google/wire" httpclient2 "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana/apps/advisor/pkg/app/checkregistry" + github2 "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" "github.com/grafana/grafana/apps/provisioning/pkg/repository/github" "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/api/avatar" @@ -84,7 +85,6 @@ import ( "github.com/grafana/grafana/pkg/registry/apps/annotation" correlations2 "github.com/grafana/grafana/pkg/registry/apps/correlations" "github.com/grafana/grafana/pkg/registry/apps/example" - "github.com/grafana/grafana/pkg/registry/apps/investigations" "github.com/grafana/grafana/pkg/registry/apps/logsdrilldown" "github.com/grafana/grafana/pkg/registry/apps/playlist" "github.com/grafana/grafana/pkg/registry/apps/plugins" @@ -848,8 +848,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api return nil, err } zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService, registerer) - investigationsAppProvider := investigations.RegisterApp(cfg) - appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg) + appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, cfg) if err != nil { return nil, err } @@ -916,7 +915,13 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - provisioningAPIBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, accessClient, migrationDashboardAccessor, dualwriteService, usageStats, tracingService, v3, v4, repositoryFactory) + githubFactory := github2.ProvideFactory() + v7 := extras.ProvideProvisioningOSSConnectionExtras(cfg, githubFactory) + connectionFactory, err := extras.ProvideConnectionFactoryFromConfig(cfg, v7) + if err != nil { + return nil, err + } + provisioningAPIBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, accessClient, migrationDashboardAccessor, dualwriteService, usageStats, tracingService, v3, v4, repositoryFactory, connectionFactory) if err != nil { return nil, err } @@ -1511,8 +1516,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac return nil, err } zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService, registerer) - investigationsAppProvider := investigations.RegisterApp(cfg) - appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg) + appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, cfg) if err != nil { return nil, err } @@ -1579,7 +1583,13 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - provisioningAPIBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, accessClient, migrationDashboardAccessor, dualwriteService, usageStats, tracingService, v3, v4, repositoryFactory) + githubFactory := github2.ProvideFactory() + v7 := extras.ProvideProvisioningOSSConnectionExtras(cfg, githubFactory) + connectionFactory, err := extras.ProvideConnectionFactoryFromConfig(cfg, v7) + if err != nil { + return nil, err + } + provisioningAPIBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, accessClient, migrationDashboardAccessor, dualwriteService, usageStats, tracingService, v3, v4, repositoryFactory, connectionFactory) if err != nil { return nil, err } @@ -1613,7 +1623,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - testEnv, err := ProvideTestEnv(testingT, server, sqlStore, cfg, notificationServiceMock, grpcserverProvider, inMemory, httpclientProvider, oauthtokentestService, featureToggles, resourceClient, idimplService, factory, decryptService) + testEnv, err := ProvideTestEnv(testingT, server, sqlStore, cfg, notificationServiceMock, grpcserverProvider, inMemory, httpclientProvider, oauthtokentestService, featureToggles, resourceClient, idimplService, factory, githubFactory, decryptService) if err != nil { return nil, err } @@ -1803,7 +1813,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)), validator3.ProvideService, provisioning.ProvideStubProvisioningService, legacy.ProvideMigratorDashboardAccessor, migrations2.ProvideUnifiedMigrator, 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, wire.Bind(new(installsync.ServerLock), new(*serverlock.ServerLockService)), 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, live.ProvideDashboardActivityChannel, pushhttp.ProvideService, contexthandler.ProvideService, service12.ProvideService, wire.Bind(new(service12.LDAP), new(*service12.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, service9.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service9.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets.Store), new(*database.SecretsStoreImpl)), garbagecollectionworker.ProvideWorker, grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database5.DashboardSnapshotStore)), database5.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service10.ServiceImpl)), service10.ProvideService, service9.ProvideDataSourceRetriever, service9.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service9.Service)), service9.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager3.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), dsquerierclient.NewNullQSDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service7.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service7.DashboardServiceImpl)), service7.ProvideDashboardService, service7.ProvideDashboardProvisioningService, service7.ProvideDashboardPluginService, service7.ProvideDashboardAccessService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), wire.Bind(new(folder.LegacyService), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), service11.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service11.ImportDashboardService)), service8.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service8.Service)), service8.ProvideDashboardUpdater, 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)), migrations3.ProvideDataSourceMigrationService, migrations3.ProvideSecretMigrationProvider, wire.Bind(new(migrations3.SecretMigrationProvider), new(*migrations3.SecretMigrationProviderImpl)), promtypemigration.ProvideAzurePromMigrationService, promtypemigration.ProvideAmazonPromMigrationService, promtypemigration.ProvidePromTypeMigrationProvider, wire.Bind(new(promtypemigration.PromTypeMigrationProvider), new(*promtypemigration.PromTypeMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, accesscontrol.ProvideFixedRolesLoader, 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, caching.ProvideCachingServiceClient, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, wire.Value([]decrypt.ExtraOwnerDecrypter(nil)), decrypt.ProvideDecryptService, inline.ProvideInlineSecureValueService, encryption.ProvideDataKeyStorage, encryption.ProvideGlobalDataKeyStorage, encryption.ProvideEncryptedValueStorage, encryption.ProvideGlobalEncryptedValueStorage, encryption.ProvideEncryptedValueMigrationExecutor, service5.ProvideSecureValueService, validator.ProvideKeeperValidator, validator.ProvideSecureValueValidator, mutator.ProvideKeeperMutator, mutator.ProvideSecureValueMutator, migrator.NewWithEngine, database4.ProvideDatabase, clock.ProvideClock, wire.Bind(new(contracts.Database), new(*database4.Database)), wire.Bind(new(contracts.Clock), new(*clock.Clock)), manager2.ProvideEncryptionManager, service4.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, migrations2.ProvideUnifiedStorageMigrationService, apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet, client.ProvideK8sClientWithFallback) +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)), validator3.ProvideService, provisioning.ProvideStubProvisioningService, legacy.ProvideMigratorDashboardAccessor, migrations2.ProvideUnifiedMigrator, 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, wire.Bind(new(installsync.ServerLock), new(*serverlock.ServerLockService)), 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, live.ProvideDashboardActivityChannel, pushhttp.ProvideService, contexthandler.ProvideService, service12.ProvideService, wire.Bind(new(service12.LDAP), new(*service12.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, github2.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, service9.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service9.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets.Store), new(*database.SecretsStoreImpl)), garbagecollectionworker.ProvideWorker, grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database5.DashboardSnapshotStore)), database5.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service10.ServiceImpl)), service10.ProvideService, service9.ProvideDataSourceRetriever, service9.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service9.Service)), service9.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager3.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), dsquerierclient.NewNullQSDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service7.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service7.DashboardServiceImpl)), service7.ProvideDashboardService, service7.ProvideDashboardProvisioningService, service7.ProvideDashboardPluginService, service7.ProvideDashboardAccessService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), wire.Bind(new(folder.LegacyService), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), service11.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service11.ImportDashboardService)), service8.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service8.Service)), service8.ProvideDashboardUpdater, 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)), migrations3.ProvideDataSourceMigrationService, migrations3.ProvideSecretMigrationProvider, wire.Bind(new(migrations3.SecretMigrationProvider), new(*migrations3.SecretMigrationProviderImpl)), promtypemigration.ProvideAzurePromMigrationService, promtypemigration.ProvideAmazonPromMigrationService, promtypemigration.ProvidePromTypeMigrationProvider, wire.Bind(new(promtypemigration.PromTypeMigrationProvider), new(*promtypemigration.PromTypeMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, accesscontrol.ProvideFixedRolesLoader, 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, caching.ProvideCachingServiceClient, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, wire.Value([]decrypt.ExtraOwnerDecrypter(nil)), decrypt.ProvideDecryptService, inline.ProvideInlineSecureValueService, encryption.ProvideDataKeyStorage, encryption.ProvideGlobalDataKeyStorage, encryption.ProvideEncryptedValueStorage, encryption.ProvideGlobalEncryptedValueStorage, encryption.ProvideEncryptedValueMigrationExecutor, service5.ProvideSecureValueService, validator.ProvideKeeperValidator, validator.ProvideSecureValueValidator, mutator.ProvideKeeperMutator, mutator.ProvideSecureValueMutator, migrator.NewWithEngine, database4.ProvideDatabase, clock.ProvideClock, wire.Bind(new(contracts.Database), new(*database4.Database)), wire.Bind(new(contracts.Clock), new(*clock.Clock)), manager2.ProvideEncryptionManager, service4.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, migrations2.ProvideUnifiedStorageMigrationService, apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet, client.ProvideK8sClientWithFallback) 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 4d70c3f2f4c..c0534f7fff4 100644 --- a/pkg/server/wireexts_oss.go +++ b/pkg/server/wireexts_oss.go @@ -72,6 +72,7 @@ import ( var provisioningExtras = wire.NewSet( extras.ProvideProvisioningOSSRepositoryExtras, + extras.ProvideProvisioningOSSConnectionExtras, ) var configProviderExtras = wire.NewSet( diff --git a/pkg/services/accesscontrol/dualwrite/collectors.go b/pkg/services/accesscontrol/dualwrite/collectors.go index 28ebd1edb02..87d00f0224b 100644 --- a/pkg/services/accesscontrol/dualwrite/collectors.go +++ b/pkg/services/accesscontrol/dualwrite/collectors.go @@ -4,6 +4,8 @@ import ( "context" openfgav1 "github.com/openfga/api/proto/openfga/v1" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/db" @@ -435,6 +437,11 @@ func anonymousRoleBindingsCollector(cfg *setting.Cfg, store db.DB) legacyTupleCo func zanzanaCollector(relations []string) zanzanaTupleCollector { return func(ctx context.Context, client zanzana.Client, object string, namespace string) (map[string]*openfgav1.TupleKey, error) { + ctx, span := tracer.Start(ctx, "accesscontrol.dualwrite.resourceReconciler.zanzanaTupleCollector", + trace.WithAttributes(attribute.String("namespace", namespace)), + ) + defer span.End() + // list will use continuation token to collect all tuples for object and relation list := func(relation string) ([]*openfgav1.Tuple, error) { first, err := client.Read(ctx, &authzextv1.ReadRequest{ diff --git a/pkg/services/accesscontrol/dualwrite/resource_reconciler.go b/pkg/services/accesscontrol/dualwrite/resource_reconciler.go index 0adf365ebde..c51e6a771c7 100644 --- a/pkg/services/accesscontrol/dualwrite/resource_reconciler.go +++ b/pkg/services/accesscontrol/dualwrite/resource_reconciler.go @@ -6,6 +6,8 @@ import ( "strings" openfgav1 "github.com/openfga/api/proto/openfga/v1" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" claims "github.com/grafana/authlib/types" @@ -48,6 +50,12 @@ func newResourceReconciler(name string, legacy legacyTupleCollector, zanzanaColl } func (r resourceReconciler) reconcile(ctx context.Context, namespace string) error { + ctx, span := tracer.Start(ctx, "accesscontrol.dualwrite.resourceReconciler.reconcile", + trace.WithAttributes(attribute.String("namespace", namespace)), + trace.WithAttributes(attribute.String("reconciler", r.name)), + ) + defer span.End() + info, err := claims.ParseNamespace(namespace) if err != nil { return err @@ -63,7 +71,12 @@ func (r resourceReconciler) reconcile(ctx context.Context, namespace string) err } // 1. Fetch grafana resources stored in grafana db. - res, err := r.legacy(ctx, info.OrgID) + legacyCtx, legacySpan := tracer.Start(ctx, "accesscontrol.dualwrite.resourceReconciler.legacyCollector", + trace.WithAttributes(attribute.String("namespace", namespace)), + trace.WithAttributes(attribute.String("reconciler", r.name)), + ) + res, err := r.legacy(legacyCtx, info.OrgID) + legacySpan.End() if err != nil { return fmt.Errorf("failed to collect legacy tuples for %s: %w", r.name, err) } @@ -211,6 +224,12 @@ func (r resourceReconciler) collectOrphanDeletes( } func (r resourceReconciler) readAllTuples(ctx context.Context, namespace string) ([]*authzextv1.Tuple, error) { + ctx, span := tracer.Start(ctx, "accesscontrol.dualwrite.resourceReconciler.zanzana.readAllTuples", + trace.WithAttributes(attribute.String("namespace", namespace)), + trace.WithAttributes(attribute.String("reconciler", r.name)), + ) + defer span.End() + var ( out []*authzextv1.Tuple continueToken string diff --git a/pkg/services/apiserver/auth/authorizer/authorizer.go b/pkg/services/apiserver/auth/authorizer/authorizer.go index dab8167deb0..b0dcbbc7872 100644 --- a/pkg/services/apiserver/auth/authorizer/authorizer.go +++ b/pkg/services/apiserver/auth/authorizer/authorizer.go @@ -3,7 +3,6 @@ package authorizer import ( "context" - "github.com/grafana/grafana/pkg/setting" "k8s.io/apimachinery/pkg/runtime/schema" k8suser "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authorization/authorizer" @@ -29,9 +28,9 @@ type GrafanaAuthorizer struct { // 4. We check authorizer that is configured speficially for an api. // 5. As a last fallback we check Role, this will only happen if an api have not configured // an authorizer or return authorizer.DecisionNoOpinion -func NewGrafanaBuiltInSTAuthorizer(cfg *setting.Cfg) *GrafanaAuthorizer { +func NewGrafanaBuiltInSTAuthorizer() *GrafanaAuthorizer { authorizers := []authorizer.Authorizer{ - newImpersonationAuthorizer(), + NewImpersonationAuthorizer(), authorizerfactory.NewPrivilegedGroups(k8suser.SystemPrivilegedGroup), newNamespaceAuthorizer(), } diff --git a/pkg/services/apiserver/auth/authorizer/impersonation.go b/pkg/services/apiserver/auth/authorizer/impersonation.go index c736173bd5f..067d364ef56 100644 --- a/pkg/services/apiserver/auth/authorizer/impersonation.go +++ b/pkg/services/apiserver/auth/authorizer/impersonation.go @@ -8,7 +8,7 @@ import ( var _ authorizer.Authorizer = (*impersonationAuthorizer)(nil) -func newImpersonationAuthorizer() *impersonationAuthorizer { +func NewImpersonationAuthorizer() *impersonationAuthorizer { return &impersonationAuthorizer{} } diff --git a/pkg/services/apiserver/builder/helper.go b/pkg/services/apiserver/builder/helper.go index c535443a91e..774f194e2a8 100644 --- a/pkg/services/apiserver/builder/helper.go +++ b/pkg/services/apiserver/builder/helper.go @@ -76,19 +76,7 @@ var PathRewriters = []filters.PathRewriter{ func GetDefaultBuildHandlerChainFunc(builders []APIGroupBuilder, reg prometheus.Registerer) BuildHandlerChainFunc { return func(delegateHandler http.Handler, c *genericapiserver.Config) http.Handler { - requestHandler, err := GetCustomRoutesHandler( - delegateHandler, - c.LoopbackClientConfig, - builders, - reg, - c.MergedResourceConfig, - ) - if err != nil { - panic(fmt.Sprintf("could not build the request handler for specified API builders: %s", err.Error())) - } - - // Needs to run last in request chain to function as expected, hence we register it first. - handler := filters.WithTracingHTTPLoggingAttributes(requestHandler) + handler := filters.WithTracingHTTPLoggingAttributes(delegateHandler) // filters.WithRequester needs to be after the K8s chain because it depends on the K8s user in context handler = filters.WithRequester(handler) diff --git a/pkg/services/apiserver/builder/request_handler.go b/pkg/services/apiserver/builder/request_handler.go index 50761f1d42c..4e5a355f453 100644 --- a/pkg/services/apiserver/builder/request_handler.go +++ b/pkg/services/apiserver/builder/request_handler.go @@ -3,146 +3,306 @@ package builder import ( "fmt" "net/http" + "strings" + "github.com/emicklei/go-restful/v3" "github.com/gorilla/mux" "github.com/prometheus/client_golang/prometheus" serverstorage "k8s.io/apiserver/pkg/server/storage" - restclient "k8s.io/client-go/rest" klog "k8s.io/klog/v2" "k8s.io/kube-openapi/pkg/spec3" ) -type requestHandler struct { - router *mux.Router +// convertHandlerToRouteFunction converts an http.HandlerFunc to a restful.RouteFunction +// It extracts path parameters from restful.Request and populates them in the request context +// so that mux.Vars can read them (for backward compatibility with handlers that use mux.Vars) +func convertHandlerToRouteFunction(handler http.HandlerFunc) restful.RouteFunction { + return func(req *restful.Request, resp *restful.Response) { + // Extract path parameters from restful.Request and populate mux.Vars + // This is needed for backward compatibility with handlers that use mux.Vars(r) + vars := make(map[string]string) + + // Get all path parameters from the restful.Request + // The restful.Request has PathParameters() method that returns a map + pathParams := req.PathParameters() + for key, value := range pathParams { + vars[key] = value + } + + // Set the vars in the request context using mux.SetURLVars + // This makes mux.Vars(r) work correctly + if len(vars) > 0 { + req.Request = mux.SetURLVars(req.Request, vars) + } + + handler(resp.ResponseWriter, req.Request) + } } -func GetCustomRoutesHandler(delegateHandler http.Handler, restConfig *restclient.Config, builders []APIGroupBuilder, metricsRegistry prometheus.Registerer, apiResourceConfig *serverstorage.ResourceConfig) (http.Handler, error) { - useful := false // only true if any routes exist anywhere - router := mux.NewRouter() +// AugmentWebServicesWithCustomRoutes adds custom routes from builders to existing WebServices +// in the container. +func AugmentWebServicesWithCustomRoutes( + container *restful.Container, + builders []APIGroupBuilder, + metricsRegistry prometheus.Registerer, + apiResourceConfig *serverstorage.ResourceConfig, +) error { + if container == nil { + return fmt.Errorf("container cannot be nil") + } metrics := NewCustomRouteMetrics(metricsRegistry) - for _, builder := range builders { - provider, ok := builder.(APIGroupRouteProvider) + // Build a map of existing WebServices by root path + existingWebServices := make(map[string]*restful.WebService) + for _, ws := range container.RegisteredWebServices() { + existingWebServices[ws.RootPath()] = ws + } + + for _, b := range builders { + provider, ok := b.(APIGroupRouteProvider) if !ok || provider == nil { continue } - for _, gv := range GetGroupVersions(builder) { - // filter out api groups that are disabled in APIEnablementOptions + for _, gv := range GetGroupVersions(b) { + // Filter out disabled API groups gvr := gv.WithResource("") if apiResourceConfig != nil && !apiResourceConfig.ResourceEnabled(gvr) { - klog.InfoS("Skipping custom route handler for disabled group version", "gv", gv.String()) + klog.InfoS("Skipping custom routes for disabled group version", "gv", gv.String()) continue } + routes := provider.GetAPIRoutes(gv) if routes == nil { continue } - prefix := "/apis/" + gv.String() - - // Root handlers - var sub *mux.Router - for _, route := range routes.Root { - if sub == nil { - sub = router.PathPrefix(prefix).Subrouter() - sub.MethodNotAllowedHandler = &methodNotAllowedHandler{} - } - - useful = true - methods, err := methodsFromSpec(route.Path, route.Spec) - if err != nil { - return nil, err - } - - instrumentedHandler := metrics.InstrumentHandler( - gv.Group, - gv.Version, - route.Path, // Use path as resource identifier - route.Handler, - ) - - sub.HandleFunc("/"+route.Path, instrumentedHandler). - Methods(methods...) + // Find or create WebService for this group version + rootPath := "/apis/" + gv.String() + ws, exists := existingWebServices[rootPath] + if !exists { + // Create a new WebService if one doesn't exist + ws = new(restful.WebService) + ws.Path(rootPath) + container.Add(ws) + existingWebServices[rootPath] = ws } - // Namespace handlers - sub = nil - prefix += "/namespaces/{namespace}" - for _, route := range routes.Namespace { - if sub == nil { - sub = router.PathPrefix(prefix).Subrouter() - sub.MethodNotAllowedHandler = &methodNotAllowedHandler{} - } - - useful = true - methods, err := methodsFromSpec(route.Path, route.Spec) - if err != nil { - return nil, err - } - + // Add root handlers using OpenAPI specs + for _, route := range routes.Root { instrumentedHandler := metrics.InstrumentHandler( gv.Group, gv.Version, - route.Path, // Use path as resource identifier + route.Path, route.Handler, ) + routeFunction := convertHandlerToRouteFunction(instrumentedHandler) - sub.HandleFunc("/"+route.Path, instrumentedHandler). - Methods(methods...) + // Use OpenAPI spec to configure routes properly + if err := addRouteFromSpec(ws, route.Path, route.Spec, routeFunction, false); err != nil { + return fmt.Errorf("failed to add root route %s: %w", route.Path, err) + } + } + + // Add namespace handlers using OpenAPI specs + for _, route := range routes.Namespace { + instrumentedHandler := metrics.InstrumentHandler( + gv.Group, + gv.Version, + route.Path, + route.Handler, + ) + routeFunction := convertHandlerToRouteFunction(instrumentedHandler) + + // Use OpenAPI spec to configure routes properly + if err := addRouteFromSpec(ws, route.Path, route.Spec, routeFunction, true); err != nil { + return fmt.Errorf("failed to add namespace route %s: %w", route.Path, err) + } } } } - if !useful { - return delegateHandler, nil - } - - // Per Gorilla Mux issue here: https://github.com/gorilla/mux/issues/616#issuecomment-798807509 - // default handler must come last - router.PathPrefix("/").Handler(delegateHandler) - - return &requestHandler{ - router: router, - }, nil + return nil } -func (h *requestHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { - h.router.ServeHTTP(w, req) +// addRouteFromSpec adds routes to a WebService using OpenAPI specs +func addRouteFromSpec(ws *restful.WebService, routePath string, pathProps *spec3.PathProps, handler restful.RouteFunction, isNamespaced bool) error { + if pathProps == nil { + return fmt.Errorf("pathProps cannot be nil for route %s", routePath) + } + + // Build the full path (relative to WebService root) + var fullPath string + if isNamespaced { + fullPath = "/namespaces/{namespace}/" + routePath + } else { + fullPath = "/" + routePath + } + + // Add routes for each HTTP method defined in the OpenAPI spec + operations := map[string]*spec3.Operation{ + "GET": pathProps.Get, + "POST": pathProps.Post, + "PUT": pathProps.Put, + "PATCH": pathProps.Patch, + "DELETE": pathProps.Delete, + } + + for method, operation := range operations { + if operation == nil { + continue + } + + // Create route builder for this method + var routeBuilder *restful.RouteBuilder + switch method { + case "GET": + routeBuilder = ws.GET(fullPath) + case "POST": + routeBuilder = ws.POST(fullPath) + case "PUT": + routeBuilder = ws.PUT(fullPath) + case "PATCH": + routeBuilder = ws.PATCH(fullPath) + case "DELETE": + routeBuilder = ws.DELETE(fullPath) + } + + // Set operation ID from OpenAPI spec (with K8s verb prefix if needed) + operationID := operation.OperationId + if operationID == "" { + // Generate from path if not specified + operationID = generateOperationNameFromPath(routePath) + } + operationID = prefixRouteIDWithK8sVerbIfNotPresent(operationID, method) + routeBuilder = routeBuilder.Operation(operationID) + + // Add description from OpenAPI spec + if operation.Description != "" { + routeBuilder = routeBuilder.Doc(operation.Description) + } + + // Check if namespace parameter is already in the OpenAPI spec + hasNamespaceParam := false + if operation.Parameters != nil { + for _, param := range operation.Parameters { + if param.Name == "namespace" && param.In == "path" { + hasNamespaceParam = true + break + } + } + } + + // Add namespace parameter for namespaced routes if not already in spec + if isNamespaced && !hasNamespaceParam { + routeBuilder = routeBuilder.Param(restful.PathParameter("namespace", "object name and auth scope, such as for teams and projects")) + } + + // Add parameters from OpenAPI spec + if operation.Parameters != nil { + for _, param := range operation.Parameters { + switch param.In { + case "path": + routeBuilder = routeBuilder.Param(restful.PathParameter(param.Name, param.Description)) + case "query": + routeBuilder = routeBuilder.Param(restful.QueryParameter(param.Name, param.Description)) + case "header": + routeBuilder = routeBuilder.Param(restful.HeaderParameter(param.Name, param.Description)) + } + } + } + + // Note: Request/response schemas are already defined in the OpenAPI spec from builders + // and will be added to the OpenAPI document via addBuilderRoutes in openapi.go. + // We don't duplicate that information here since restful uses the route metadata + // for OpenAPI generation, which is handled separately in this codebase. + + // Register the route with handler + ws.Route(routeBuilder.To(handler)) + } + + return nil } -func methodsFromSpec(slug string, props *spec3.PathProps) ([]string, error) { - if props == nil { - return []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, nil +func prefixRouteIDWithK8sVerbIfNotPresent(operationID string, method string) string { + for _, verb := range allowedK8sVerbs { + if len(operationID) > len(verb) && operationID[:len(verb)] == verb { + return operationID + } } - - methods := make([]string, 0) - if props.Get != nil { - methods = append(methods, "GET") - } - if props.Post != nil { - methods = append(methods, "POST") - } - if props.Put != nil { - methods = append(methods, "PUT") - } - if props.Patch != nil { - methods = append(methods, "PATCH") - } - if props.Delete != nil { - methods = append(methods, "DELETE") - } - - if len(methods) == 0 { - return nil, fmt.Errorf("invalid OpenAPI Spec for slug=%s without any methods in PathProps", slug) - } - - return methods, nil + return fmt.Sprintf("%s%s", httpMethodToK8sVerb[strings.ToUpper(method)], operationID) } -type methodNotAllowedHandler struct{} +var allowedK8sVerbs = []string{ + "get", "log", "read", "replace", "patch", "delete", "deletecollection", "watch", "connect", "proxy", "list", "create", "patch", +} -func (h *methodNotAllowedHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { - w.WriteHeader(405) // method not allowed +var httpMethodToK8sVerb = map[string]string{ + http.MethodGet: "get", + http.MethodPost: "create", + http.MethodPut: "replace", + http.MethodPatch: "patch", + http.MethodDelete: "delete", + http.MethodConnect: "connect", + http.MethodOptions: "connect", // No real equivalent to options and head + http.MethodHead: "connect", +} + +// generateOperationNameFromPath creates an operation name from a route path. +// The operation name is used by the OpenAPI generator and should be descriptive. +// It uses meaningful path segments to create readable yet unique operation names. +// Examples: +// - "/search" -> "Search" +// - "/snapshots/create" -> "SnapshotsCreate" +// - "ofrep/v1/evaluate/flags" -> "OfrepEvaluateFlags" +// - "ofrep/v1/evaluate/flags/{flagKey}" -> "OfrepEvaluateFlagsFlagKey" +func generateOperationNameFromPath(routePath string) string { + // Remove leading slash and split by path segments + parts := strings.Split(strings.TrimPrefix(routePath, "/"), "/") + + // Filter to keep meaningful segments and path parameters + var nameParts []string + skipPrefixes := map[string]bool{ + "namespaces": true, + "apis": true, + } + + for _, part := range parts { + if part == "" { + continue + } + + // Extract parameter name from {paramName} format + if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") { + paramName := part[1 : len(part)-1] + // Skip generic parameters like {namespace}, but keep specific ones like {flagKey} + if paramName != "namespace" && paramName != "name" { + nameParts = append(nameParts, strings.ToUpper(paramName[:1])+paramName[1:]) + } + continue + } + + // Skip common prefixes + if skipPrefixes[strings.ToLower(part)] { + continue + } + + // Skip version segments like v1, v0alpha1, v2beta1, etc. + if strings.HasPrefix(strings.ToLower(part), "v") && + (len(part) <= 3 || strings.Contains(strings.ToLower(part), "alpha") || strings.Contains(strings.ToLower(part), "beta")) { + continue + } + + // Capitalize first letter and add to parts + if len(part) > 0 { + nameParts = append(nameParts, strings.ToUpper(part[:1])+part[1:]) + } + } + + if len(nameParts) == 0 { + return "Route" + } + + return strings.Join(nameParts, "") } diff --git a/pkg/services/apiserver/config.go b/pkg/services/apiserver/config.go index 499cdb4df6f..a0a9f007bcd 100644 --- a/pkg/services/apiserver/config.go +++ b/pkg/services/apiserver/config.go @@ -5,7 +5,6 @@ import ( "net" "path/filepath" "strconv" - "strings" "github.com/grafana/grafana/pkg/services/apiserver/options" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -41,15 +40,6 @@ func applyGrafanaConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles, o apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver") runtimeConfig := apiserverCfg.Key("runtime_config").String() - runtimeConfigSplit := strings.Split(runtimeConfig, ",") - - // TODO: temporary fix to allow disabling local features service and still being able to use its authz handler - if !cfg.OpenFeature.APIEnabled { - runtimeConfigSplit = append(runtimeConfigSplit, "features.grafana.app/v0alpha1=false") - } - - runtimeConfig = strings.Join(runtimeConfigSplit, ",") - if runtimeConfig != "" { if err := o.APIEnablementOptions.RuntimeConfig.Set(runtimeConfig); err != nil { return fmt.Errorf("failed to set runtime config: %w", err) diff --git a/pkg/services/apiserver/options/storage.go b/pkg/services/apiserver/options/storage.go index 28f6e1046ab..057858caa01 100644 --- a/pkg/services/apiserver/options/storage.go +++ b/pkg/services/apiserver/options/storage.go @@ -11,11 +11,16 @@ import ( "github.com/spf13/pflag" "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" "google.golang.org/grpc" + "google.golang.org/grpc/backoff" + "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/keepalive" genericapiserver "k8s.io/apiserver/pkg/server" "k8s.io/apiserver/pkg/server/options" "k8s.io/client-go/rest" + grpc_retry "github.com/grpc-ecosystem/go-grpc-middleware/retry" + apiserverrest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/infra/tracing" secret "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" @@ -232,19 +237,16 @@ func (o *StorageOptions) ApplyTo(serverConfig *genericapiserver.RecommendedConfi if o.StorageType != StorageTypeUnifiedGrpc { return nil } - conn, err := grpc.NewClient(o.Address, - grpc.WithStatsHandler(otelgrpc.NewClientHandler()), - grpc.WithTransportCredentials(insecure.NewCredentials()), - ) + + grpcOpts := o.buildGrpcDialOptions() + + conn, err := grpc.NewClient(o.Address, grpcOpts...) if err != nil { return err } var indexConn *grpc.ClientConn if o.SearchServerAddress != "" { - indexConn, err = grpc.NewClient(o.SearchServerAddress, - grpc.WithStatsHandler(otelgrpc.NewClientHandler()), - grpc.WithTransportCredentials(insecure.NewCredentials()), - ) + indexConn, err = grpc.NewClient(o.SearchServerAddress, grpcOpts...) if err != nil { return err } @@ -293,3 +295,42 @@ func (o *StorageOptions) ApplyTo(serverConfig *genericapiserver.RecommendedConfi serverConfig.RESTOptionsGetter = getter return nil } + +// buildGrpcDialOptions creates gRPC dial options with resilience mechanisms: +// - Round-robin load balancing with client-side health checking +// - Retry interceptor for transient connection issues +// - Keepalive for long-lived connections +func (o *StorageOptions) buildGrpcDialOptions() []grpc.DialOption { + // Retry interceptor for transient connection issues (codes.Unavailable includes connection refused) + retryInterceptor := grpc_retry.UnaryClientInterceptor( + grpc_retry.WithMax(3), + grpc_retry.WithBackoff(grpc_retry.BackoffExponentialWithJitter(time.Second, 0.5)), + grpc_retry.WithCodes(codes.ResourceExhausted, codes.Unavailable), + ) + + opts := []grpc.DialOption{ + grpc.WithStatsHandler(otelgrpc.NewClientHandler()), + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithChainUnaryInterceptor(retryInterceptor), + grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`), + grpc.WithConnectParams(grpc.ConnectParams{ + Backoff: backoff.Config{ + BaseDelay: 100 * time.Millisecond, + Multiplier: 1.6, + Jitter: 0.2, + MaxDelay: 10 * time.Second, + }, + MinConnectTimeout: 5 * time.Second, + }), + } + + if o.GrpcClientKeepaliveTime > 0 { + opts = append(opts, grpc.WithKeepaliveParams(keepalive.ClientParameters{ + Time: o.GrpcClientKeepaliveTime, + Timeout: 10 * time.Second, + PermitWithoutStream: true, + })) + } + + return opts +} diff --git a/pkg/services/apiserver/service.go b/pkg/services/apiserver/service.go index 6c92350ec2a..7fd5e51a96b 100644 --- a/pkg/services/apiserver/service.go +++ b/pkg/services/apiserver/service.go @@ -155,7 +155,7 @@ func ProvideService( features: features, rr: rr, builders: []builder.APIGroupBuilder{}, - authorizer: authorizer.NewGrafanaBuiltInSTAuthorizer(cfg), + authorizer: authorizer.NewGrafanaBuiltInSTAuthorizer(), tracing: tracing, db: db, // For Unified storage metrics: reg, @@ -443,6 +443,19 @@ func (s *service) start(ctx context.Context) error { return err } + // Augment existing WebServices with custom routes from builders + // This directly adds routes to existing WebServices using the OpenAPI specs from builders + if server.Handler != nil && server.Handler.GoRestfulContainer != nil { + if err := builder.AugmentWebServicesWithCustomRoutes( + server.Handler.GoRestfulContainer, + builders, + s.metrics, + serverConfig.MergedResourceConfig, + ); err != nil { + return fmt.Errorf("failed to augment web services with custom routes: %w", err) + } + } + // stash the options for later use s.options = o diff --git a/pkg/services/authz/rbac/mapper.go b/pkg/services/authz/rbac/mapper.go index dcf2432fb2c..fd0b4f78b42 100644 --- a/pkg/services/authz/rbac/mapper.go +++ b/pkg/services/authz/rbac/mapper.go @@ -182,25 +182,6 @@ func newFolderTranslation() translation { return folderTranslation } -func newExternalGroupMappingTranslation() translation { - return translation{ - resource: "teams.permissions", - attribute: "uid", - verbMapping: map[string]string{ - utils.VerbGet: "teams.permissions:read", - utils.VerbList: "teams.permissions:read", - utils.VerbWatch: "teams.permissions:read", - utils.VerbCreate: "teams.permissions:write", - utils.VerbUpdate: "teams.permissions:write", - utils.VerbPatch: "teams.permissions:write", - utils.VerbDelete: "teams.permissions:write", - utils.VerbGetPermissions: "teams.permissions:write", - utils.VerbSetPermissions: "teams.permissions:write", - }, - folderSupport: false, - } -} - func NewMapperRegistry() MapperRegistry { skipScopeOnAllVerbs := map[string]bool{ utils.VerbCreate: true, @@ -229,8 +210,6 @@ func NewMapperRegistry() MapperRegistry { "serviceaccounts": newResourceTranslation("serviceaccounts", "uid", false, map[string]bool{utils.VerbCreate: true}), // Teams is a special case. We translate user permissions from id to uid based. "teams": newResourceTranslation("teams", "uid", false, map[string]bool{utils.VerbCreate: true}), - // ExternalGroupMappings is a special case. We translate team permissions from id to uid based. - "externalgroupmappings": newExternalGroupMappingTranslation(), "coreroles": translation{ resource: "roles", attribute: "uid", diff --git a/pkg/services/authz/zanzana.go b/pkg/services/authz/zanzana.go index 79eb19a8b5b..b1d49d9ee17 100644 --- a/pkg/services/authz/zanzana.go +++ b/pkg/services/authz/zanzana.go @@ -90,7 +90,7 @@ func ProvideZanzanaClient(cfg *setting.Cfg, db db.DB, tracer tracing.Tracer, fea authzv1.RegisterAuthzServiceServer(channel, srv) authzextv1.RegisterAuthzExtentionServiceServer(channel, srv) - client, err := zClient.New(channel) + client, err := zClient.New(channel, reg) if err != nil { return nil, fmt.Errorf("failed to initialize zanzana client: %w", err) } @@ -169,7 +169,7 @@ func NewRemoteZanzanaClient(cfg ZanzanaClientConfig, reg prometheus.Registerer) return nil, fmt.Errorf("failed to create zanzana client to remote server: %w", err) } - client, err := zClient.New(conn) + client, err := zClient.New(conn, reg) if err != nil { return nil, fmt.Errorf("failed to initialize zanzana client: %w", err) } diff --git a/pkg/services/authz/zanzana/client/client.go b/pkg/services/authz/zanzana/client/client.go index 3c51d707561..ac19d9b3fdc 100644 --- a/pkg/services/authz/zanzana/client/client.go +++ b/pkg/services/authz/zanzana/client/client.go @@ -9,6 +9,7 @@ import ( authzlib "github.com/grafana/authlib/authz" authzv1 "github.com/grafana/authlib/authz/proto/v1" authlib "github.com/grafana/authlib/types" + "github.com/prometheus/client_golang/prometheus" "github.com/grafana/grafana/pkg/infra/log" authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" @@ -25,15 +26,17 @@ type Client struct { authz authzv1.AuthzServiceClient authzext authzextv1.AuthzExtentionServiceClient authzlibclient *authzlib.ClientImpl + metrics *clientMetrics } -func New(cc grpc.ClientConnInterface) (*Client, error) { +func New(cc grpc.ClientConnInterface, reg prometheus.Registerer) (*Client, error) { authzlibclient := authzlib.NewClient(cc, authzlib.WithTracerClientOption(tracer)) c := &Client{ authzlibclient: authzlibclient, authz: authzv1.NewAuthzServiceClient(cc), authzext: authzextv1.NewAuthzExtentionServiceClient(cc), logger: log.New("zanzana.client"), + metrics: newClientMetrics(reg), } return c, nil @@ -43,6 +46,9 @@ func (c *Client) Check(ctx context.Context, id authlib.AuthInfo, req authlib.Che ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Check") defer span.End() + timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("Check", req.Namespace)) + defer timer.ObserveDuration() + return c.authzlibclient.Check(ctx, id, req, folder) } @@ -50,6 +56,9 @@ func (c *Client) Compile(ctx context.Context, id authlib.AuthInfo, req authlib.L ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Compile") defer span.End() + timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("Compile", req.Namespace)) + defer timer.ObserveDuration() + return c.authzlibclient.Compile(ctx, id, req) } @@ -64,6 +73,9 @@ func (c *Client) Write(ctx context.Context, req *authzextv1.WriteRequest) error ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Write") defer span.End() + timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("Write", req.Namespace)) + defer timer.ObserveDuration() + _, err := c.authzext.Write(ctx, req) return err } @@ -72,6 +84,9 @@ func (c *Client) BatchCheck(ctx context.Context, req *authzextv1.BatchCheckReque ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Check") defer span.End() + timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("BatchCheck", req.Namespace)) + defer timer.ObserveDuration() + return c.authzext.BatchCheck(ctx, req) } @@ -87,6 +102,9 @@ func (c *Client) Mutate(ctx context.Context, req *authzextv1.MutateRequest) erro ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Mutate") defer span.End() + timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("Mutate", req.Namespace)) + defer timer.ObserveDuration() + _, err := c.authzext.Mutate(ctx, req) return err } @@ -95,5 +113,8 @@ func (c *Client) Query(ctx context.Context, req *authzextv1.QueryRequest) (*auth ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Query") defer span.End() + timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("Query", req.Namespace)) + defer timer.ObserveDuration() + return c.authzext.Query(ctx, req) } diff --git a/pkg/services/authz/zanzana/client/metrics.go b/pkg/services/authz/zanzana/client/metrics.go index 3fe7b1dd590..070f1cecf2c 100644 --- a/pkg/services/authz/zanzana/client/metrics.go +++ b/pkg/services/authz/zanzana/client/metrics.go @@ -7,10 +7,10 @@ import ( const ( metricsNamespace = "iam" - metricsSubSystem = "authz_zanzana" + metricsSubSystem = "authz_zanzana_client" ) -type metrics struct { +type shadowClientMetrics struct { // evaluationsSeconds is a summary for evaluating access for a specific engine (RBAC and zanzana) evaluationsSeconds *prometheus.HistogramVec // compileSeconds is a summary for compiling item checker for a specific engine (RBAC and zanzana) @@ -19,8 +19,13 @@ type metrics struct { evaluationStatusTotal *prometheus.CounterVec } -func newShadowClientMetrics(reg prometheus.Registerer) *metrics { - return &metrics{ +type clientMetrics struct { + // requestDurationSeconds is a summary for zanzana client request duration + requestDurationSeconds *prometheus.HistogramVec +} + +func newShadowClientMetrics(reg prometheus.Registerer) *shadowClientMetrics { + return &shadowClientMetrics{ evaluationsSeconds: promauto.With(reg).NewHistogramVec( prometheus.HistogramOpts{ Name: "engine_evaluations_seconds", @@ -52,3 +57,18 @@ func newShadowClientMetrics(reg prometheus.Registerer) *metrics { ), } } + +func newClientMetrics(reg prometheus.Registerer) *clientMetrics { + return &clientMetrics{ + requestDurationSeconds: promauto.With(reg).NewHistogramVec( + prometheus.HistogramOpts{ + Name: "request_duration_seconds", + Help: "Histogram for zanzana client request duration", + Namespace: metricsNamespace, + Subsystem: metricsSubSystem, + Buckets: prometheus.ExponentialBuckets(0.00001, 4, 10), + }, + []string{"method", "request_namespace"}, + ), + } +} diff --git a/pkg/services/authz/zanzana/client/shadow_client.go b/pkg/services/authz/zanzana/client/shadow_client.go index 6f6e6dc6836..1e23c9b20f2 100644 --- a/pkg/services/authz/zanzana/client/shadow_client.go +++ b/pkg/services/authz/zanzana/client/shadow_client.go @@ -20,7 +20,7 @@ type ShadowClient struct { logger log.Logger accessClient authlib.AccessClient zanzanaClient authlib.AccessClient - metrics *metrics + metrics *shadowClientMetrics } // WithShadowClient returns a new access client that runs zanzana checks in the background. diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index ea37867d8c0..43fb5ce9a53 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -542,6 +542,9 @@ func (d *dashboardStore) saveDashboard(ctx context.Context, sess *db.Session, cm tags := dash.GetTags() if len(tags) > 0 { for _, tag := range tags { + if len(tag) > 50 { + return nil, dashboards.ErrDashboardTagTooLong + } if _, err := sess.Insert(dashboardTag{DashboardId: dash.ID, Term: tag, OrgID: dash.OrgID, DashboardUID: dash.UID}); err != nil { return nil, err } diff --git a/pkg/services/dashboards/errors.go b/pkg/services/dashboards/errors.go index e39b1ed4b21..b41d2d0bb23 100644 --- a/pkg/services/dashboards/errors.go +++ b/pkg/services/dashboards/errors.go @@ -79,6 +79,11 @@ var ( Reason: "message too long, max 500 characters", StatusCode: 400, } + ErrDashboardTagTooLong = dashboardaccess.DashboardErr{ + Reason: "dashboard tag too long, max 50 characters", + StatusCode: 400, + Status: "tag-too-long", + } ErrDashboardCannotSaveProvisionedDashboard = dashboardaccess.DashboardErr{ Reason: "Cannot save provisioned dashboard", StatusCode: 400, diff --git a/pkg/services/dashboardsnapshots/service.go b/pkg/services/dashboardsnapshots/service.go index 281afee38ac..98954b585a6 100644 --- a/pkg/services/dashboardsnapshots/service.go +++ b/pkg/services/dashboardsnapshots/service.go @@ -36,6 +36,9 @@ var client = &http.Client{ Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, } +// CreateDashboardSnapshot creates a snapshot when running Grafana in regular mode. +// It validates the user and dashboard exist before creating the snapshot. +// This mode supports both local and external snapshots. func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSharingOptions, cmd CreateDashboardSnapshotCommand, svc Service) { if !cfg.SnapshotsEnabled { c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil) @@ -43,6 +46,7 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSh } uid := cmd.Dashboard.GetNestedString("uid") + user, err := identity.GetRequester(c.Req.Context()) if err != nil { c.JsonApiErr(http.StatusBadRequest, "missing user in context", nil) @@ -59,21 +63,18 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSh return } + cmd.ExternalURL = "" + cmd.OrgID = user.GetOrgID() + cmd.UserID, _ = identity.UserIdentifier(user.GetID()) + if cmd.Name == "" { cmd.Name = "Unnamed snapshot" } - var snapshotUrl string - cmd.ExternalURL = "" - cmd.OrgID = user.GetOrgID() - cmd.UserID, _ = identity.UserIdentifier(user.GetID()) - originalDashboardURL, err := createOriginalDashboardURL(&cmd) - if err != nil { - c.JsonApiErr(http.StatusInternalServerError, "Invalid app URL", err) - return - } + var snapshotURL string if cmd.External { + // Handle external snapshot creation if !cfg.ExternalEnabled { c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil) return @@ -85,40 +86,83 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSh return } - snapshotUrl = resp.Url cmd.Key = resp.Key cmd.DeleteKey = resp.DeleteKey cmd.ExternalURL = resp.Url cmd.ExternalDeleteURL = resp.DeleteUrl cmd.Dashboard = &common.Unstructured{} + snapshotURL = resp.Url metrics.MApiDashboardSnapshotExternal.Inc() } else { - cmd.Dashboard.SetNestedField(originalDashboardURL, "snapshot", "originalUrl") - - if cmd.Key == "" { - var err error - cmd.Key, err = util.GetRandomString(32) - if err != nil { - c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err) - return - } + // Handle local snapshot creation + originalDashboardURL, err := createOriginalDashboardURL(&cmd) + if err != nil { + c.JsonApiErr(http.StatusInternalServerError, "Invalid app URL", err) + return } - if cmd.DeleteKey == "" { - var err error - cmd.DeleteKey, err = util.GetRandomString(32) - if err != nil { - c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err) - return - } + snapshotURL, err = prepareLocalSnapshot(&cmd, originalDashboardURL) + if err != nil { + c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err) + return } - snapshotUrl = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key) - metrics.MApiDashboardSnapshotCreate.Inc() } + saveAndRespond(c, svc, cmd, snapshotURL) +} + +// CreateDashboardSnapshotPublic creates a snapshot when running Grafana in public mode. +// In public mode, there is no user or dashboard information to validate. +// Only local snapshots are supported (external snapshots are not available). +func CreateDashboardSnapshotPublic(c *contextmodel.ReqContext, cfg snapshot.SnapshotSharingOptions, cmd CreateDashboardSnapshotCommand, svc Service) { + if !cfg.SnapshotsEnabled { + c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil) + return + } + + if cmd.Name == "" { + cmd.Name = "Unnamed snapshot" + } + + snapshotURL, err := prepareLocalSnapshot(&cmd, "") + if err != nil { + c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err) + return + } + + metrics.MApiDashboardSnapshotCreate.Inc() + + saveAndRespond(c, svc, cmd, snapshotURL) +} + +// prepareLocalSnapshot prepares the command for a local snapshot and returns the snapshot URL. +func prepareLocalSnapshot(cmd *CreateDashboardSnapshotCommand, originalDashboardURL string) (string, error) { + cmd.Dashboard.SetNestedField(originalDashboardURL, "snapshot", "originalUrl") + + if cmd.Key == "" { + key, err := util.GetRandomString(32) + if err != nil { + return "", err + } + cmd.Key = key + } + + if cmd.DeleteKey == "" { + deleteKey, err := util.GetRandomString(32) + if err != nil { + return "", err + } + cmd.DeleteKey = deleteKey + } + + return setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key), nil +} + +// saveAndRespond saves the snapshot and sends the response. +func saveAndRespond(c *contextmodel.ReqContext, svc Service, cmd CreateDashboardSnapshotCommand, snapshotURL string) { result, err := svc.CreateDashboardSnapshot(c.Req.Context(), &cmd) if err != nil { c.JsonApiErr(http.StatusInternalServerError, "Failed to create snapshot", err) @@ -128,7 +172,7 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSh c.JSON(http.StatusOK, snapshot.DashboardCreateResponse{ Key: result.Key, DeleteKey: result.DeleteKey, - URL: snapshotUrl, + URL: snapshotURL, DeleteURL: setting.ToAbsUrl("api/snapshots-delete/" + result.DeleteKey), }) } diff --git a/pkg/services/dashboardsnapshots/service_test.go b/pkg/services/dashboardsnapshots/service_test.go index c8e817b720e..7add1233581 100644 --- a/pkg/services/dashboardsnapshots/service_test.go +++ b/pkg/services/dashboardsnapshots/service_test.go @@ -20,40 +20,30 @@ import ( "github.com/grafana/grafana/pkg/web" ) -func TestCreateDashboardSnapshot_DashboardNotFound(t *testing.T) { - mockService := &MockService{} - cfg := snapshot.SnapshotSharingOptions{ - SnapshotsEnabled: true, - ExternalEnabled: false, +func createTestDashboard(t *testing.T) *common.Unstructured { + t.Helper() + dashboard := &common.Unstructured{} + dashboardData := map[string]any{ + "uid": "test-dashboard-uid", + "id": 123, } - testUser := &user.SignedInUser{ + dashboardBytes, _ := json.Marshal(dashboardData) + _ = json.Unmarshal(dashboardBytes, dashboard) + return dashboard +} + +func createTestUser() *user.SignedInUser { + return &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: snapshot.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)) +} +func createReqContext(t *testing.T, req *http.Request, testUser *user.SignedInUser) (*contextmodel.ReqContext, *httptest.ResponseRecorder) { + t.Helper() recorder := httptest.NewRecorder() ctx := &contextmodel.ReqContext{ Context: &web.Context{ @@ -63,13 +53,319 @@ func TestCreateDashboardSnapshot_DashboardNotFound(t *testing.T) { SignedInUser: testUser, Logger: log.NewNopLogger(), } + return ctx, recorder +} - CreateDashboardSnapshot(ctx, cfg, cmd, mockService) +// TestCreateDashboardSnapshot tests snapshot creation in regular mode (non-public instance). +// These tests cover scenarios when Grafana is running as a regular server with user authentication. +func TestCreateDashboardSnapshot(t *testing.T) { + t.Run("should return error when dashboard not found", func(t *testing.T) { + mockService := &MockService{} + cfg := snapshot.SnapshotSharingOptions{ + SnapshotsEnabled: true, + ExternalEnabled: false, + } + testUser := createTestUser() + dashboard := createTestDashboard(t) - 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"]) + cmd := CreateDashboardSnapshotCommand{ + DashboardCreateCommand: snapshot.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)) + ctx, recorder := createReqContext(t, req, testUser) + + CreateDashboardSnapshot(ctx, cfg, cmd, mockService) + + mockService.AssertExpectations(t) + assert.Equal(t, http.StatusBadRequest, recorder.Code) + var response map[string]any + err := json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "Dashboard not found", response["message"]) + }) + + t.Run("should create external snapshot when external is enabled", func(t *testing.T) { + externalServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/snapshots", r.URL.Path) + assert.Equal(t, "POST", r.Method) + + response := map[string]any{ + "key": "external-key", + "deleteKey": "external-delete-key", + "url": "https://external.example.com/dashboard/snapshot/external-key", + "deleteUrl": "https://external.example.com/api/snapshots-delete/external-delete-key", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer externalServer.Close() + + mockService := NewMockService(t) + cfg := snapshot.SnapshotSharingOptions{ + SnapshotsEnabled: true, + ExternalEnabled: true, + ExternalSnapshotURL: externalServer.URL, + } + testUser := createTestUser() + dashboard := createTestDashboard(t) + + cmd := CreateDashboardSnapshotCommand{ + DashboardCreateCommand: snapshot.DashboardCreateCommand{ + Dashboard: dashboard, + Name: "Test External Snapshot", + External: true, + }, + } + + mockService.On("ValidateDashboardExists", mock.Anything, int64(1), "test-dashboard-uid"). + Return(nil) + mockService.On("CreateDashboardSnapshot", mock.Anything, mock.Anything). + Return(&DashboardSnapshot{ + Key: "external-key", + DeleteKey: "external-delete-key", + }, nil) + + req, _ := http.NewRequest("POST", "/api/snapshots", nil) + req = req.WithContext(identity.WithRequester(req.Context(), testUser)) + ctx, recorder := createReqContext(t, req, testUser) + + CreateDashboardSnapshot(ctx, cfg, cmd, mockService) + + mockService.AssertExpectations(t) + assert.Equal(t, http.StatusOK, recorder.Code) + + var response map[string]any + err := json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "external-key", response["key"]) + assert.Equal(t, "external-delete-key", response["deleteKey"]) + assert.Equal(t, "https://external.example.com/dashboard/snapshot/external-key", response["url"]) + }) + + t.Run("should return forbidden when external is disabled", func(t *testing.T) { + mockService := NewMockService(t) + cfg := snapshot.SnapshotSharingOptions{ + SnapshotsEnabled: true, + ExternalEnabled: false, + } + testUser := createTestUser() + dashboard := createTestDashboard(t) + + cmd := CreateDashboardSnapshotCommand{ + DashboardCreateCommand: snapshot.DashboardCreateCommand{ + Dashboard: dashboard, + Name: "Test External Snapshot", + External: true, + }, + } + + mockService.On("ValidateDashboardExists", mock.Anything, int64(1), "test-dashboard-uid"). + Return(nil) + + req, _ := http.NewRequest("POST", "/api/snapshots", nil) + req = req.WithContext(identity.WithRequester(req.Context(), testUser)) + ctx, recorder := createReqContext(t, req, testUser) + + CreateDashboardSnapshot(ctx, cfg, cmd, mockService) + + mockService.AssertExpectations(t) + assert.Equal(t, http.StatusForbidden, recorder.Code) + + var response map[string]any + err := json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "External dashboard creation is disabled", response["message"]) + }) + + t.Run("should create local snapshot", func(t *testing.T) { + mockService := NewMockService(t) + cfg := snapshot.SnapshotSharingOptions{ + SnapshotsEnabled: true, + } + testUser := createTestUser() + dashboard := createTestDashboard(t) + + cmd := CreateDashboardSnapshotCommand{ + DashboardCreateCommand: snapshot.DashboardCreateCommand{ + Dashboard: dashboard, + Name: "Test Local Snapshot", + }, + Key: "local-key", + DeleteKey: "local-delete-key", + } + + mockService.On("ValidateDashboardExists", mock.Anything, int64(1), "test-dashboard-uid"). + Return(nil) + mockService.On("CreateDashboardSnapshot", mock.Anything, mock.Anything). + Return(&DashboardSnapshot{ + Key: "local-key", + DeleteKey: "local-delete-key", + }, nil) + + req, _ := http.NewRequest("POST", "/api/snapshots", nil) + req = req.WithContext(identity.WithRequester(req.Context(), testUser)) + ctx, recorder := createReqContext(t, req, testUser) + + CreateDashboardSnapshot(ctx, cfg, cmd, mockService) + + mockService.AssertExpectations(t) + assert.Equal(t, http.StatusOK, recorder.Code) + + var response map[string]any + err := json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "local-key", response["key"]) + assert.Equal(t, "local-delete-key", response["deleteKey"]) + assert.Contains(t, response["url"], "dashboard/snapshot/local-key") + assert.Contains(t, response["deleteUrl"], "api/snapshots-delete/local-delete-key") + }) +} + +// TestCreateDashboardSnapshotPublic tests snapshot creation in public mode. +// These tests cover scenarios when Grafana is running as a public snapshot server +// where no user authentication or dashboard validation is required. +func TestCreateDashboardSnapshotPublic(t *testing.T) { + t.Run("should create local snapshot without user context", func(t *testing.T) { + mockService := NewMockService(t) + cfg := snapshot.SnapshotSharingOptions{ + SnapshotsEnabled: true, + } + dashboard := createTestDashboard(t) + + cmd := CreateDashboardSnapshotCommand{ + DashboardCreateCommand: snapshot.DashboardCreateCommand{ + Dashboard: dashboard, + Name: "Test Snapshot", + }, + Key: "test-key", + DeleteKey: "test-delete-key", + } + + mockService.On("CreateDashboardSnapshot", mock.Anything, mock.Anything). + Return(&DashboardSnapshot{ + Key: "test-key", + DeleteKey: "test-delete-key", + }, nil) + + req, _ := http.NewRequest("POST", "/api/snapshots", nil) + recorder := httptest.NewRecorder() + ctx := &contextmodel.ReqContext{ + Context: &web.Context{ + Req: req, + Resp: web.NewResponseWriter("POST", recorder), + }, + Logger: log.NewNopLogger(), + } + + CreateDashboardSnapshotPublic(ctx, cfg, cmd, mockService) + + mockService.AssertExpectations(t) + assert.Equal(t, http.StatusOK, recorder.Code) + + var response map[string]any + err := json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "test-key", response["key"]) + assert.Equal(t, "test-delete-key", response["deleteKey"]) + assert.Contains(t, response["url"], "dashboard/snapshot/test-key") + assert.Contains(t, response["deleteUrl"], "api/snapshots-delete/test-delete-key") + }) + + t.Run("should return forbidden when snapshots are disabled", func(t *testing.T) { + mockService := NewMockService(t) + cfg := snapshot.SnapshotSharingOptions{ + SnapshotsEnabled: false, + } + dashboard := createTestDashboard(t) + + cmd := CreateDashboardSnapshotCommand{ + DashboardCreateCommand: snapshot.DashboardCreateCommand{ + Dashboard: dashboard, + Name: "Test Snapshot", + }, + } + + req, _ := http.NewRequest("POST", "/api/snapshots", nil) + recorder := httptest.NewRecorder() + ctx := &contextmodel.ReqContext{ + Context: &web.Context{ + Req: req, + Resp: web.NewResponseWriter("POST", recorder), + }, + Logger: log.NewNopLogger(), + } + + CreateDashboardSnapshotPublic(ctx, cfg, cmd, mockService) + + assert.Equal(t, http.StatusForbidden, recorder.Code) + + var response map[string]any + err := json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "Dashboard Snapshots are disabled", response["message"]) + }) +} + +// TestDeleteExternalDashboardSnapshot tests deletion of external snapshots. +// This function is called in public mode and doesn't require user context. +func TestDeleteExternalDashboardSnapshot(t *testing.T) { + t.Run("should return nil on successful deletion", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + err := DeleteExternalDashboardSnapshot(server.URL) + assert.NoError(t, err) + }) + + t.Run("should gracefully handle already deleted snapshot", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + response := map[string]any{ + "message": "Failed to get dashboard snapshot", + } + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + err := DeleteExternalDashboardSnapshot(server.URL) + assert.NoError(t, err) + }) + + t.Run("should return error on unexpected status code", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + err := DeleteExternalDashboardSnapshot(server.URL) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unexpected response when deleting external snapshot") + assert.Contains(t, err.Error(), "404") + }) + + t.Run("should return error on 500 with different message", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + response := map[string]any{ + "message": "Some other error", + } + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + err := DeleteExternalDashboardSnapshot(server.URL) + assert.Error(t, err) + assert.Contains(t, err.Error(), "500") + }) } diff --git a/pkg/services/featuremgmt/models.go b/pkg/services/featuremgmt/models.go index d59dff63c37..72f9d5ffc5b 100644 --- a/pkg/services/featuremgmt/models.go +++ b/pkg/services/featuremgmt/models.go @@ -133,7 +133,11 @@ type FeatureFlag struct { Stage FeatureFlagStage `json:"stage,omitempty"` Owner codeowner `json:"-"` // Owner person or team that owns this feature flag - // CEL-GO expression. Using the value "true" will mean this is on by default + // Expression defined by the feature_toggles configuration. + // Supports multiple types including boolean, string, integer, float, + // and structured values following the OpenFeature specification. + // Using the value "true" means the feature flag is enabled by default, + // Using the value "1.0" means the default value of the feature flag is 1.0 Expression string `json:"expression,omitempty"` // Special behavior properties diff --git a/pkg/services/featuremgmt/openfeature.go b/pkg/services/featuremgmt/openfeature.go index cd3b77322fb..22017b8de03 100644 --- a/pkg/services/featuremgmt/openfeature.go +++ b/pkg/services/featuremgmt/openfeature.go @@ -8,6 +8,7 @@ import ( clientauthmiddleware "github.com/grafana/grafana/pkg/clientauth/middleware" "github.com/grafana/grafana/pkg/setting" + "github.com/open-feature/go-sdk/openfeature/memprovider" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/open-feature/go-sdk/openfeature" @@ -26,7 +27,7 @@ type OpenFeatureConfig struct { // HTTPClient is a pre-configured HTTP client (optional, used by features-service + OFREP providers) HTTPClient *http.Client // StaticFlags are the feature flags to use with static provider - StaticFlags map[string]bool + StaticFlags map[string]memprovider.InMemoryFlag // TargetingKey is used for evaluation context TargetingKey string // ContextAttrs are additional attributes for evaluation context @@ -100,7 +101,7 @@ func InitOpenFeatureWithCfg(cfg *setting.Cfg) error { func createProvider( providerType string, u *url.URL, - staticFlags map[string]bool, + staticFlags map[string]memprovider.InMemoryFlag, httpClient *http.Client, ) (openfeature.FeatureProvider, error) { if providerType == setting.FeaturesServiceProviderType || providerType == setting.OFREPProviderType { @@ -117,7 +118,7 @@ func createProvider( } } - return newStaticProvider(staticFlags) + return newStaticProvider(staticFlags, standardFeatureFlags) } func createHTTPClient(m *clientauthmiddleware.TokenExchangeMiddleware) (*http.Client, error) { diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 5ec4bfb880b..72623cba2fa 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -49,7 +49,7 @@ var ( Name: "lokiExperimentalStreaming", Description: "Support new streaming approach for loki (prototype, needs special loki build)", Stage: FeatureStageExperimental, - Owner: grafanaObservabilityLogsSquad, + Owner: grafanaOSSBigTent, }, { Name: "featureHighlights", @@ -177,7 +177,7 @@ var ( Name: "lokiLogsDataplane", Description: "Changes logs responses from Loki to be compliant with the dataplane specification.", Stage: FeatureStageExperimental, - Owner: grafanaObservabilityLogsSquad, + Owner: grafanaOSSBigTent, }, { Name: "disableSSEDataplane", @@ -322,6 +322,13 @@ var ( Owner: grafanaOperatorExperienceSquad, RequiresRestart: true, }, + { + Name: "reportingCsvEncodingOptions", + Description: "Enables CSV encoding options in the reporting feature", + Stage: FeatureStageExperimental, + FrontendOnly: false, + Owner: grafanaOperatorExperienceSquad, + }, { Name: "sseGroupByDatasource", Description: "Send query to the same datasource in a single request when using server side expressions. The `cloudWatchBatchQueries` feature toggle should be enabled if this used with CloudWatch.", @@ -333,7 +340,7 @@ var ( Description: "Enables running Loki queries in parallel", Stage: FeatureStagePrivatePreview, FrontendOnly: false, - Owner: grafanaObservabilityLogsSquad, + Owner: grafanaOSSBigTent, }, { Name: "externalServiceAccounts", @@ -567,8 +574,8 @@ var ( }, { Name: "dashboardNewLayouts", - Description: "Enables experimental new dashboard layouts", - Stage: FeatureStageExperimental, + Description: "Enables new dashboard layouts", + Stage: FeatureStagePublicPreview, FrontendOnly: false, // The restore backend feature changes behavior based on this flag Owner: grafanaDashboardsSquad, }, @@ -738,7 +745,7 @@ var ( Name: "logQLScope", Description: "In-development feature that will allow injection of labels into loki queries.", Stage: FeatureStagePrivatePreview, - Owner: grafanaObservabilityLogsSquad, + Owner: grafanaOSSBigTent, Expression: "false", HideFromDocs: true, }, @@ -865,13 +872,6 @@ var ( Owner: grafanaSharingSquad, FrontendOnly: false, }, - { - Name: "logsExploreTableDefaultVisualization", - Description: "Sets the logs table as default visualisation in logs explore", - Stage: FeatureStageExperimental, - Owner: grafanaObservabilityLogsSquad, - FrontendOnly: true, - }, { Name: "alertingListViewV2", Description: "Enables the new alert list view design", @@ -879,6 +879,13 @@ var ( Owner: grafanaAlertingSquad, FrontendOnly: true, }, + { + Name: "alertingNavigationV2", + Description: "Enables the new Alerting navigation structure with improved menu grouping", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + FrontendOnly: false, + }, { Name: "alertingSavedSearches", Description: "Enables saved searches for alert rules list", @@ -981,7 +988,8 @@ var ( Stage: FeatureStageDeprecated, Owner: grafanaPartnerPluginsSquad, Expression: "true", // Enabled by default for now - }, { + }, + { Name: "alertingFilterV2", Description: "Enable the new alerting search experience", Stage: FeatureStageExperimental, @@ -1031,13 +1039,6 @@ var ( FrontendOnly: true, Owner: grafanaObservabilityLogsSquad, }, - { - Name: "exploreLogsLimitedTimeRange", - Description: "Used in Logs Drilldown to limit the time range", - Stage: FeatureStageExperimental, - FrontendOnly: true, - Owner: grafanaObservabilityLogsSquad, - }, { Name: "appPlatformGrpcClientAuth", Description: "Enables the gRPC client to authenticate with the App Platform by using ID & access tokens", @@ -1080,20 +1081,6 @@ var ( Stage: FeatureStageExperimental, Owner: identityAccessTeam, }, - { - Name: "unifiedStorageSearch", - Description: "Enable unified storage search", - Stage: FeatureStageExperimental, - Owner: grafanaSearchAndStorageSquad, - HideFromDocs: true, - }, - { - Name: "unifiedStorageSearchSprinkles", - Description: "Enable sprinkles on unified storage search", - Stage: FeatureStageExperimental, - Owner: grafanaSearchAndStorageSquad, - HideFromDocs: true, - }, { Name: "managedDualWriter", Description: "Pick the dual write mode from database configs", @@ -1155,14 +1142,6 @@ var ( Owner: identityAccessTeam, HideFromDocs: true, }, - { - Name: "exploreMetricsRelatedLogs", - Description: "Display Related Logs in Grafana Metrics Drilldown", - Stage: FeatureStageExperimental, - Owner: grafanaObservabilityMetricsSquad, - FrontendOnly: true, - HideFromDocs: false, - }, { Name: "prometheusSpecialCharsInLabelValues", Description: "Adds support for quotes and special characters in label values for Prometheus queries", @@ -1283,29 +1262,15 @@ var ( Owner: grafanaPartnerPluginsSquad, Expression: "false", }, - { - Name: "unifiedHistory", - Description: "Displays the navigation history so the user can navigate back to previous pages", - Stage: FeatureStageExperimental, - Owner: grafanaFrontendSearchNavOrganise, - FrontendOnly: true, - }, { // Remove this flag once Loki v4 is released and the min supported version is v3.0+, // since users on v2.9 need it to disable the feature, as it doesn't work for them. Name: "lokiLabelNamesQueryApi", Description: "Defaults to using the Loki `/labels` API instead of `/series`", Stage: FeatureStageGeneralAvailability, - Owner: grafanaObservabilityLogsSquad, + Owner: grafanaOSSBigTent, Expression: "true", }, - { - Name: "investigationsBackend", - Description: "Enable the investigations backend API", - Stage: FeatureStageExperimental, - Owner: grafanaAppPlatformSquad, - Expression: "false", - }, { Name: "k8SFolderCounts", Description: "Enable folder's api server counts", @@ -1592,8 +1557,8 @@ var ( }, { Name: "kubernetesAuthzApis", - Description: "Registers AuthZ /apis endpoint", - Stage: FeatureStageExperimental, + Description: "Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead", + Stage: FeatureStageDeprecated, Owner: identityAccessTeam, HideFromDocs: true, }, @@ -1618,6 +1583,27 @@ var ( Owner: identityAccessTeam, HideFromDocs: true, }, + { + Name: "kubernetesAuthzCoreRolesApi", + Description: "Registers AuthZ Core Roles /apis endpoint", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, + }, + { + Name: "kubernetesAuthzRolesApi", + Description: "Registers AuthZ Roles /apis endpoint", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, + }, + { + Name: "kubernetesAuthzRoleBindingsApi", + Description: "Registers AuthZ Role Bindings /apis endpoint", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, + }, { Name: "kubernetesAuthnMutation", Description: "Enables create, delete, and update mutations for resources owned by IAM identity", @@ -1647,6 +1633,15 @@ var ( FrontendOnly: true, Expression: "false", }, + { + Name: "experimentRecentlyViewedDashboards", + Description: "A/A test for recently viewed dashboards feature", + Stage: FeatureStageExperimental, + Owner: grafanaFrontendSearchNavOrganise, + FrontendOnly: true, + HideFromDocs: true, + Expression: "false", + }, { Name: "alertEnrichment", Description: "Enable configuration of alert enrichments in Grafana Cloud.", @@ -1866,14 +1861,6 @@ var ( Expression: "false", RequiresRestart: true, }, - { - Name: "tempoSearchBackendMigration", - Description: "Run search queries through the tempo backend", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaOSSBigTent, - Expression: "false", - RequiresRestart: true, - }, { Name: "cdnPluginsLoadFirst", Description: "Prioritize loading plugins from the CDN before other sources", @@ -2090,6 +2077,14 @@ var ( Owner: grafanaObservabilityTracesAndProfilingSquad, FrontendOnly: false, }, + { + Name: "alertingSyncDispatchTimer", + Description: "Use synchronized dispatch timer to minimize duplicate notifications across alertmanager HA pods", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + RequiresRestart: true, + HideFromDocs: true, + }, } ) diff --git a/pkg/services/featuremgmt/service.go b/pkg/services/featuremgmt/service.go index 2769a75d788..2c97666d9e9 100644 --- a/pkg/services/featuremgmt/service.go +++ b/pkg/services/featuremgmt/service.go @@ -47,7 +47,8 @@ func ProvideManagerService(cfg *setting.Cfg) (*FeatureManager, error) { } mgmt.warnings[key] = "unknown flag in config" } - mgmt.startup[key] = val + + mgmt.startup[key] = val.Variants[val.DefaultVariant] == true } // update the values diff --git a/pkg/services/featuremgmt/static_evaluator.go b/pkg/services/featuremgmt/static_evaluator.go index c3d46837d28..fdeef7a5858 100644 --- a/pkg/services/featuremgmt/static_evaluator.go +++ b/pkg/services/featuremgmt/static_evaluator.go @@ -29,7 +29,7 @@ func CreateStaticEvaluator(cfg *setting.Cfg) (StaticFlagEvaluator, error) { return nil, fmt.Errorf("failed to read feature flags from config: %w", err) } - staticProvider, err := newStaticProvider(staticFlags) + staticProvider, err := newStaticProvider(staticFlags, standardFeatureFlags) if err != nil { return nil, fmt.Errorf("failed to create static provider: %w", err) } diff --git a/pkg/services/featuremgmt/static_provider.go b/pkg/services/featuremgmt/static_provider.go index f384bd00de1..f6fe14d7de9 100644 --- a/pkg/services/featuremgmt/static_provider.go +++ b/pkg/services/featuremgmt/static_provider.go @@ -1,8 +1,13 @@ package featuremgmt import ( + "fmt" + "maps" + "github.com/open-feature/go-sdk/openfeature" "github.com/open-feature/go-sdk/openfeature/memprovider" + + "github.com/grafana/grafana/pkg/setting" ) // inMemoryBulkProvider is a wrapper around memprovider.InMemoryProvider that @@ -28,37 +33,21 @@ func (p *inMemoryBulkProvider) ListFlags() ([]string, error) { return keys, nil } -func newStaticProvider(confFlags map[string]bool) (openfeature.FeatureProvider, error) { - flags := make(map[string]memprovider.InMemoryFlag, len(standardFeatureFlags)) +func newStaticProvider(confFlags map[string]memprovider.InMemoryFlag, standardFlags []FeatureFlag) (openfeature.FeatureProvider, error) { + flags := make(map[string]memprovider.InMemoryFlag, len(standardFlags)) + + // Parse and add standard flags + for _, flag := range standardFlags { + inMemFlag, err := setting.ParseFlag(flag.Name, flag.Expression) + if err != nil { + return nil, fmt.Errorf("failed to parse flag %s: %w", flag.Name, err) + } + + flags[flag.Name] = inMemFlag + } // Add flags from config.ini file - for name, value := range confFlags { - flags[name] = createInMemoryFlag(name, value) - } - - // Add standard flags - for _, flag := range standardFeatureFlags { - if _, exists := flags[flag.Name]; !exists { - enabled := flag.Expression == "true" - flags[flag.Name] = createInMemoryFlag(flag.Name, enabled) - } - } + maps.Copy(flags, confFlags) return newInMemoryBulkProvider(flags), nil } - -func createInMemoryFlag(name string, enabled bool) memprovider.InMemoryFlag { - variant := "disabled" - if enabled { - variant = "enabled" - } - - return memprovider.InMemoryFlag{ - Key: name, - DefaultVariant: variant, - Variants: map[string]interface{}{ - "enabled": true, - "disabled": false, - }, - } -} diff --git a/pkg/services/featuremgmt/static_provider_test.go b/pkg/services/featuremgmt/static_provider_test.go index 29610baa8c4..c3245f7dd97 100644 --- a/pkg/services/featuremgmt/static_provider_test.go +++ b/pkg/services/featuremgmt/static_provider_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/grafana/grafana/pkg/setting" + "github.com/open-feature/go-sdk/openfeature/memprovider" "github.com/open-feature/go-sdk/openfeature" "github.com/stretchr/testify/assert" @@ -93,3 +94,144 @@ ABCD = true enabledFeatureManager := mgr.GetEnabled(ctx) assert.Equal(t, openFeatureEnabledFlags, enabledFeatureManager) } + +func Test_StaticProvider_TypedFlags(t *testing.T) { + tests := []struct { + flags FeatureFlag + defaultValue any + expectedValue any + }{ + { + flags: FeatureFlag{ + Name: "Flag", + Expression: "true", + }, + defaultValue: false, + expectedValue: true, + }, + { + flags: FeatureFlag{ + Name: "Flag", + Expression: "1.0", + }, + defaultValue: 0.0, + expectedValue: 1.0, + }, + { + flags: FeatureFlag{ + Name: "Flag", + Expression: "blue", + }, + defaultValue: "red", + expectedValue: "blue", + }, + { + flags: FeatureFlag{ + Name: "Flag", + Expression: "1", + }, + defaultValue: int64(0), + expectedValue: int64(1), + }, + { + flags: FeatureFlag{ + Name: "Flag", + Expression: `{ "foo": "bar" }`, + }, + expectedValue: map[string]any{"foo": "bar"}, + }, + } + + for _, tt := range tests { + provider, err := newStaticProvider(nil, []FeatureFlag{tt.flags}) + assert.NoError(t, err) + + var result any + switch tt.expectedValue.(type) { + case bool: + result = provider.BooleanEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(bool), openfeature.FlattenedContext{}).Value + case float64: + result = provider.FloatEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(float64), openfeature.FlattenedContext{}).Value + case string: + result = provider.StringEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(string), openfeature.FlattenedContext{}).Value + case int64: + result = provider.IntEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(int64), openfeature.FlattenedContext{}).Value + case map[string]any: + result = provider.ObjectEvaluation(t.Context(), tt.flags.Name, tt.defaultValue, openfeature.FlattenedContext{}).Value + } + + assert.Equal(t, tt.expectedValue, result) + } +} +func Test_StaticProvider_ConfigOverride(t *testing.T) { + tests := []struct { + name string + originalValue string + configValue any + }{ + { + name: "bool", + originalValue: "false", + configValue: true, + }, + { + name: "int", + originalValue: "0", + configValue: int64(1), + }, + { + name: "float", + originalValue: "0.0", + configValue: 1.0, + }, + { + name: "string", + originalValue: "foo", + configValue: "bar", + }, + { + name: "structure", + originalValue: "{}", + configValue: make(map[string]any), + }, + } + + for _, tt := range tests { + configFlags, standardFlags := makeFlags(tt) + provider, err := newStaticProvider(configFlags, standardFlags) + assert.NoError(t, err) + + var result any + switch tt.configValue.(type) { + case bool: + result = provider.BooleanEvaluation(t.Context(), tt.name, false, openfeature.FlattenedContext{}).Value + case float64: + result = provider.FloatEvaluation(t.Context(), tt.name, 0.0, openfeature.FlattenedContext{}).Value + case string: + result = provider.StringEvaluation(t.Context(), tt.name, "foo", openfeature.FlattenedContext{}).Value + case int64: + result = provider.IntEvaluation(t.Context(), tt.name, 1, openfeature.FlattenedContext{}).Value + case map[string]any: + result = provider.ObjectEvaluation(t.Context(), tt.name, make(map[string]any), openfeature.FlattenedContext{}).Value + } + + assert.Equal(t, tt.configValue, result) + } +} + +func makeFlags(tt struct { + name string + originalValue string + configValue any +}) (map[string]memprovider.InMemoryFlag, []FeatureFlag) { + orig := FeatureFlag{ + Name: tt.name, + Expression: tt.originalValue, + } + + config := map[string]memprovider.InMemoryFlag{ + tt.name: setting.NewInMemoryFlag(tt.name, tt.configValue), + } + + return config, []FeatureFlag{orig} +} diff --git a/pkg/services/featuremgmt/toggles-gitlog.csv b/pkg/services/featuremgmt/toggles-gitlog.csv index c924b05d7d5..644a1627577 100644 --- a/pkg/services/featuremgmt/toggles-gitlog.csv +++ b/pkg/services/featuremgmt/toggles-gitlog.csv @@ -409,7 +409,6 @@ lokiLabelNamesQueryApi,2024-12-13T14:31:41Z,,5ac7443fcec0db412d3333044a82c2c26b5 kubernetesCliDashboards,2024-12-13T22:55:43Z,2025-02-18T23:11:26Z,8f6e9f8ed0a5024a510cc337c9f1e6972bfb23d4,Stephanie Hingtgen useV2DashboardsAPI,2024-12-17T21:17:09Z,2025-03-12T17:43:32Z,070f0e4457c5967102ef157197073dc2662f6fb8,Dominik Prokop investigationsBackend,2024-12-18T08:31:03Z,,f46c07aba7b6faccd2ecafc83051d1410cacc867,Jackson Coelho -unifiedStorageSearchSprinkles,2024-12-18T17:00:54Z,,4837585cab0fd84184a8c6f5d6891f442a2b95f1,owensmallwood prometheusSpecialCharsInLabelValues,2024-12-18T21:31:08Z,,721c50a304588ebd7cea76e301ec0f68a5a55d68,Nick Richmond unifiedStorageSearchUI,2024-12-19T18:21:48Z,,a8f347144ddc16f2033fdeb4f3474e49239ba7ab,Scott Lepper playlistsReconciler,2024-12-20T03:09:31Z,,24bf337c562dc9b9d8684cc9acb7ea171ea83414,Charandas diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 20009d3f30b..61505b65571 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -3,7 +3,7 @@ disableEnvelopeEncryption,GA,@grafana/grafana-operator-experience-squad,false,fa panelTitleSearch,preview,@grafana/search-and-storage,false,false,false publicDashboardsEmailSharing,preview,@grafana/grafana-operator-experience-squad,false,false,false publicDashboardsScene,GA,@grafana/grafana-operator-experience-squad,false,false,true -lokiExperimentalStreaming,experimental,@grafana/observability-logs,false,false,false +lokiExperimentalStreaming,experimental,@grafana/oss-big-tent,false,false,false featureHighlights,GA,@grafana/grafana-operator-experience-squad,false,false,false storage,experimental,@grafana/search-and-storage,false,false,false canvasPanelNesting,experimental,@grafana/dataviz-squad,false,false,true @@ -22,7 +22,7 @@ starsFromAPIServer,experimental,@grafana/grafana-search-navigate-organise,false, kubernetesStars,experimental,@grafana/grafana-app-platform-squad,false,true,false influxqlStreamingParser,experimental,@grafana/partner-datasources,false,false,false influxdbRunQueriesInParallel,privatePreview,@grafana/partner-datasources,false,false,false -lokiLogsDataplane,experimental,@grafana/observability-logs,false,false,false +lokiLogsDataplane,experimental,@grafana/oss-big-tent,false,false,false disableSSEDataplane,experimental,@grafana/grafana-datasources-core-services,false,false,false renderAuthJWT,preview,@grafana/grafana-operator-experience-squad,false,false,false refactorVariablesTimeRange,preview,@grafana/dashboards-squad,false,false,false @@ -43,8 +43,9 @@ configurableSchedulerTick,experimental,@grafana/alerting-squad,false,true,false dashgpt,GA,@grafana/dashboards-squad,false,false,true aiGeneratedDashboardChanges,experimental,@grafana/dashboards-squad,false,false,true reportingRetries,preview,@grafana/grafana-operator-experience-squad,false,true,false +reportingCsvEncodingOptions,experimental,@grafana/grafana-operator-experience-squad,false,false,false sseGroupByDatasource,experimental,@grafana/grafana-datasources-core-services,false,false,false -lokiRunQueriesInParallel,privatePreview,@grafana/observability-logs,false,false,false +lokiRunQueriesInParallel,privatePreview,@grafana/oss-big-tent,false,false,false externalServiceAccounts,preview,@grafana/identity-access-team,false,false,false enableNativeHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,false,true,false disableClassicHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,false,true,false @@ -78,7 +79,7 @@ annotationPermissionUpdate,GA,@grafana/identity-access-team,false,false,false dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true dashboardSceneSolo,GA,@grafana/dashboards-squad,false,false,true dashboardScene,GA,@grafana/dashboards-squad,false,false,true -dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false +dashboardNewLayouts,preview,@grafana/dashboards-squad,false,false,false dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true drilldownRecommendations,experimental,@grafana/dashboards-squad,false,false,true @@ -101,7 +102,7 @@ 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 useMultipleScopeNodesEndpoint,experimental,@grafana/grafana-operator-experience-squad,false,false,true -logQLScope,privatePreview,@grafana/observability-logs,false,false,false +logQLScope,privatePreview,@grafana/oss-big-tent,false,false,false sqlExpressions,preview,@grafana/grafana-datasources-core-services,false,false,false sqlExpressionsColumnAutoComplete,experimental,@grafana/datapro,false,false,true kubernetesAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,false @@ -119,8 +120,8 @@ queryLibrary,preview,@grafana/sharing-squad,false,false,false dashboardLibrary,experimental,@grafana/sharing-squad,false,false,false suggestedDashboards,experimental,@grafana/sharing-squad,false,false,false dashboardTemplates,preview,@grafana/sharing-squad,false,false,false -logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true alertingListViewV2,privatePreview,@grafana/alerting-squad,false,false,true +alertingNavigationV2,experimental,@grafana/alerting-squad,false,false,false alertingSavedSearches,experimental,@grafana/alerting-squad,false,false,true alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false @@ -142,15 +143,12 @@ vizActionsAuth,preview,@grafana/dataviz-squad,false,false,true alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false,true exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true -exploreLogsLimitedTimeRange,experimental,@grafana/observability-logs,false,false,true appPlatformGrpcClientAuth,experimental,@grafana/identity-access-team,false,false,false groupAttributeSync,privatePreview,@grafana/identity-access-team,false,false,false alertingQueryAndExpressionsStepMode,GA,@grafana/alerting-squad,false,false,true improvedExternalSessionHandling,GA,@grafana/identity-access-team,false,false,false useSessionStorageForRedirection,GA,@grafana/identity-access-team,false,false,false rolePickerDrawer,experimental,@grafana/identity-access-team,false,false,false -unifiedStorageSearch,experimental,@grafana/search-and-storage,false,false,false -unifiedStorageSearchSprinkles,experimental,@grafana/search-and-storage,false,false,false managedDualWriter,experimental,@grafana/search-and-storage,false,false,false pluginsSriChecks,GA,@grafana/plugins-platform-backend,false,false,false unifiedStorageBigObjectsSupport,experimental,@grafana/search-and-storage,false,false,false @@ -160,7 +158,6 @@ newTimeRangeZoomShortcuts,experimental,@grafana/dataviz-squad,false,false,true azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false playlistsReconciler,experimental,@grafana/grafana-app-platform-squad,false,true,false passwordlessMagicLinkAuthentication,experimental,@grafana/identity-access-team,false,false,false -exploreMetricsRelatedLogs,experimental,@grafana/observability-metrics,false,false,true prometheusSpecialCharsInLabelValues,experimental,@grafana/oss-big-tent,false,false,true enableExtensionsAdminPage,experimental,@grafana/plugins-platform-backend,false,true,false enableSCIM,preview,@grafana/identity-access-team,false,false,false @@ -177,9 +174,7 @@ alertingAIAnalyzeCentralStateHistory,experimental,@grafana/alerting-squad,false, alertingNotificationsStepMode,GA,@grafana/alerting-squad,false,false,true unifiedStorageSearchUI,experimental,@grafana/search-and-storage,false,false,false elasticsearchCrossClusterSearch,GA,@grafana/partner-datasources,false,false,false -unifiedHistory,experimental,@grafana/grafana-search-navigate-organise,false,false,true -lokiLabelNamesQueryApi,GA,@grafana/observability-logs,false,false,false -investigationsBackend,experimental,@grafana/grafana-app-platform-squad,false,false,false +lokiLabelNamesQueryApi,GA,@grafana/oss-big-tent,false,false,false k8SFolderCounts,experimental,@grafana/search-and-storage,false,false,false k8SFolderMove,experimental,@grafana/search-and-storage,false,false,false improvedExternalSessionHandlingSAML,GA,@grafana/identity-access-team,false,false,false @@ -218,14 +213,18 @@ pluginsAutoUpdate,experimental,@grafana/plugins-platform-backend,false,false,fal alertingListViewV2PreviewToggle,privatePreview,@grafana/alerting-squad,false,false,true alertRuleUseFiredAtForStartsAt,experimental,@grafana/alerting-squad,false,false,false alertingBulkActionsInUI,GA,@grafana/alerting-squad,false,false,true -kubernetesAuthzApis,experimental,@grafana/identity-access-team,false,false,false +kubernetesAuthzApis,deprecated,@grafana/identity-access-team,false,false,false kubernetesAuthZHandlerRedirect,experimental,@grafana/identity-access-team,false,false,false kubernetesAuthzResourcePermissionApis,experimental,@grafana/identity-access-team,false,false,false kubernetesAuthzZanzanaSync,experimental,@grafana/identity-access-team,false,false,false +kubernetesAuthzCoreRolesApi,experimental,@grafana/identity-access-team,false,false,false +kubernetesAuthzRolesApi,experimental,@grafana/identity-access-team,false,false,false +kubernetesAuthzRoleBindingsApi,experimental,@grafana/identity-access-team,false,false,false kubernetesAuthnMutation,experimental,@grafana/identity-access-team,false,false,false kubernetesExternalGroupMapping,experimental,@grafana/identity-access-team,false,false,false restoreDashboards,experimental,@grafana/grafana-search-navigate-organise,false,false,false recentlyViewedDashboards,experimental,@grafana/grafana-search-navigate-organise,false,false,true +experimentRecentlyViewedDashboards,experimental,@grafana/grafana-search-navigate-organise,false,false,true alertEnrichment,experimental,@grafana/alerting-squad,false,false,false alertEnrichmentMultiStep,experimental,@grafana/alerting-squad,false,false,false alertEnrichmentConditional,experimental,@grafana/alerting-squad,false,false,false @@ -254,7 +253,6 @@ graphiteBackendMode,privatePreview,@grafana/partner-datasources,false,false,fals azureResourcePickerUpdates,GA,@grafana/partner-datasources,false,false,true prometheusTypeMigration,experimental,@grafana/partner-datasources,false,true,false pluginContainers,privatePreview,@grafana/plugins-platform-backend,false,true,false -tempoSearchBackendMigration,GA,@grafana/oss-big-tent,false,true,false cdnPluginsLoadFirst,experimental,@grafana/plugins-platform-backend,false,false,false cdnPluginsUrls,experimental,@grafana/plugins-platform-backend,false,false,false pluginInstallAPISync,experimental,@grafana/plugins-platform-backend,false,false,false @@ -283,3 +281,4 @@ multiPropsVariables,experimental,@grafana/dashboards-squad,false,false,true smoothingTransformation,experimental,@grafana/datapro,false,false,true secretsManagementAppPlatformAwsKeeper,experimental,@grafana/grafana-operator-experience-squad,false,false,false profilesExemplars,experimental,@grafana/observability-traces-and-profiling,false,false,false +alertingSyncDispatchTimer,experimental,@grafana/alerting-squad,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 062748b95df..db2b4484e42 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -135,6 +135,10 @@ const ( // Enables rendering retries for the reporting feature FlagReportingRetries = "reportingRetries" + // FlagReportingCsvEncodingOptions + // Enables CSV encoding options in the reporting feature + FlagReportingCsvEncodingOptions = "reportingCsvEncodingOptions" + // FlagSseGroupByDatasource // Send query to the same datasource in a single request when using server side expressions. The `cloudWatchBatchQueries` feature toggle should be enabled if this used with CloudWatch. FlagSseGroupByDatasource = "sseGroupByDatasource" @@ -256,7 +260,7 @@ const ( FlagAnnotationPermissionUpdate = "annotationPermissionUpdate" // FlagDashboardNewLayouts - // Enables experimental new dashboard layouts + // Enables new dashboard layouts FlagDashboardNewLayouts = "dashboardNewLayouts" // FlagPdfTables @@ -367,6 +371,10 @@ const ( // Enables a flow to get started with a new dashboard from a template FlagDashboardTemplates = "dashboardTemplates" + // FlagAlertingNavigationV2 + // Enables the new Alerting navigation structure with improved menu grouping + FlagAlertingNavigationV2 = "alertingNavigationV2" + // FlagAlertingDisableSendAlertsExternal // Disables the ability to send alerts to an external Alertmanager datasource. FlagAlertingDisableSendAlertsExternal = "alertingDisableSendAlertsExternal" @@ -451,14 +459,6 @@ const ( // Enables the new role picker drawer design FlagRolePickerDrawer = "rolePickerDrawer" - // FlagUnifiedStorageSearch - // Enable unified storage search - FlagUnifiedStorageSearch = "unifiedStorageSearch" - - // FlagUnifiedStorageSearchSprinkles - // Enable sprinkles on unified storage search - FlagUnifiedStorageSearchSprinkles = "unifiedStorageSearchSprinkles" - // FlagManagedDualWriter // Pick the dual write mode from database configs FlagManagedDualWriter = "managedDualWriter" @@ -539,10 +539,6 @@ const ( // Defaults to using the Loki `/labels` API instead of `/series` FlagLokiLabelNamesQueryApi = "lokiLabelNamesQueryApi" - // FlagInvestigationsBackend - // Enable the investigations backend API - FlagInvestigationsBackend = "investigationsBackend" - // FlagK8SFolderCounts // Enable folder's api server counts FlagK8SFolderCounts = "k8SFolderCounts" @@ -631,7 +627,7 @@ const ( FlagAlertRuleUseFiredAtForStartsAt = "alertRuleUseFiredAtForStartsAt" // FlagKubernetesAuthzApis - // Registers AuthZ /apis endpoint + // Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead FlagKubernetesAuthzApis = "kubernetesAuthzApis" // FlagKubernetesAuthZHandlerRedirect @@ -646,6 +642,18 @@ const ( // Enable sync of Zanzana authorization store on AuthZ CRD mutations FlagKubernetesAuthzZanzanaSync = "kubernetesAuthzZanzanaSync" + // FlagKubernetesAuthzCoreRolesApi + // Registers AuthZ Core Roles /apis endpoint + FlagKubernetesAuthzCoreRolesApi = "kubernetesAuthzCoreRolesApi" + + // FlagKubernetesAuthzRolesApi + // Registers AuthZ Roles /apis endpoint + FlagKubernetesAuthzRolesApi = "kubernetesAuthzRolesApi" + + // FlagKubernetesAuthzRoleBindingsApi + // Registers AuthZ Role Bindings /apis endpoint + FlagKubernetesAuthzRoleBindingsApi = "kubernetesAuthzRoleBindingsApi" + // FlagKubernetesAuthnMutation // Enables create, delete, and update mutations for resources owned by IAM identity FlagKubernetesAuthnMutation = "kubernetesAuthnMutation" @@ -730,10 +738,6 @@ const ( // Enables running plugins in containers FlagPluginContainers = "pluginContainers" - // FlagTempoSearchBackendMigration - // Run search queries through the tempo backend - FlagTempoSearchBackendMigration = "tempoSearchBackendMigration" - // FlagCdnPluginsLoadFirst // Prioritize loading plugins from the CDN before other sources FlagCdnPluginsLoadFirst = "cdnPluginsLoadFirst" @@ -789,4 +793,8 @@ const ( // FlagProfilesExemplars // Enables profiles exemplars support in profiles drilldown FlagProfilesExemplars = "profilesExemplars" + + // FlagAlertingSyncDispatchTimer + // Use synchronized dispatch timer to minimize duplicate notifications across alertmanager HA pods + FlagAlertingSyncDispatchTimer = "alertingSyncDispatchTimer" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index ddd6d3bbc0d..4832f93c309 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -348,6 +348,18 @@ "expression": "true" } }, + { + "metadata": { + "name": "alertingNavigationV2", + "resourceVersion": "1768320918269", + "creationTimestamp": "2026-01-13T16:15:18Z" + }, + "spec": { + "description": "Enables the new Alerting navigation structure with improved menu grouping", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad" + } + }, { "metadata": { "name": "alertingNotificationHistory", @@ -511,6 +523,20 @@ "frontend": true } }, + { + "metadata": { + "name": "alertingSyncDispatchTimer", + "resourceVersion": "1766161788928", + "creationTimestamp": "2025-12-19T16:29:48Z" + }, + "spec": { + "description": "Use synchronized dispatch timer to minimize duplicate notifications across alertmanager HA pods", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "requiresRestart": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "alertingTriage", @@ -662,7 +688,8 @@ "metadata": { "name": "auditLoggingAppPlatform", "resourceVersion": "1767013056996", - "creationTimestamp": "2025-12-29T12:57:36Z" + "creationTimestamp": "2025-12-29T12:57:36Z", + "deletionTimestamp": "2026-01-06T09:18:36Z" }, "spec": { "description": "Enable audit logging with Kubernetes under app platform", @@ -1015,12 +1042,15 @@ { "metadata": { "name": "dashboardNewLayouts", - "resourceVersion": "1764664939750", - "creationTimestamp": "2024-10-23T08:55:45Z" + "resourceVersion": "1768382835527", + "creationTimestamp": "2024-10-23T08:55:45Z", + "annotations": { + "grafana.app/updatedTimestamp": "2026-01-14 09:27:15.527103 +0000 UTC" + } }, "spec": { - "description": "Enables experimental new dashboard layouts", - "stage": "experimental", + "description": "Enables new dashboard layouts", + "stage": "preview", "codeowner": "@grafana/dashboards-squad" } }, @@ -1365,6 +1395,21 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "experimentRecentlyViewedDashboards", + "resourceVersion": "1768214542023", + "creationTimestamp": "2026-01-12T10:42:22Z" + }, + "spec": { + "description": "A/A test for recently viewed dashboards feature", + "stage": "experimental", + "codeowner": "@grafana/grafana-search-navigate-organise", + "frontend": true, + "hideFromDocs": true, + "expression": "false" + } + }, { "metadata": { "name": "exploreLogsAggregatedMetrics", @@ -1382,7 +1427,8 @@ "metadata": { "name": "exploreLogsLimitedTimeRange", "resourceVersion": "1764664939750", - "creationTimestamp": "2024-08-29T13:55:59Z" + "creationTimestamp": "2024-08-29T13:55:59Z", + "deletionTimestamp": "2026-01-12T22:18:14Z" }, "spec": { "description": "Used in Logs Drilldown to limit the time range", @@ -1408,7 +1454,8 @@ "metadata": { "name": "exploreMetricsRelatedLogs", "resourceVersion": "1764664939750", - "creationTimestamp": "2024-11-05T16:28:43Z" + "creationTimestamp": "2024-11-05T16:28:43Z", + "deletionTimestamp": "2026-01-09T22:14:53Z" }, "spec": { "description": "Display Related Logs in Grafana Metrics Drilldown", @@ -1793,7 +1840,8 @@ "metadata": { "name": "investigationsBackend", "resourceVersion": "1764664939750", - "creationTimestamp": "2024-12-18T08:31:03Z" + "creationTimestamp": "2024-12-18T08:31:03Z", + "deletionTimestamp": "2025-12-16T16:06:24Z" }, "spec": { "description": "Enable the investigations backend API", @@ -1950,11 +1998,27 @@ { "metadata": { "name": "kubernetesAuthzApis", - "resourceVersion": "1764664939750", - "creationTimestamp": "2025-06-18T07:43:01Z" + "resourceVersion": "1767954559317", + "creationTimestamp": "2025-06-18T07:43:01Z", + "annotations": { + "grafana.app/updatedTimestamp": "2026-01-09 10:29:19.317164 +0000 UTC" + } }, "spec": { - "description": "Registers AuthZ /apis endpoint", + "description": "Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead", + "stage": "deprecated", + "codeowner": "@grafana/identity-access-team", + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "kubernetesAuthzCoreRolesApi", + "resourceVersion": "1767954459090", + "creationTimestamp": "2026-01-09T10:27:39Z" + }, + "spec": { + "description": "Registers AuthZ Core Roles /apis endpoint", "stage": "experimental", "codeowner": "@grafana/identity-access-team", "hideFromDocs": true @@ -1974,6 +2038,32 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "kubernetesAuthzRoleBindingsApi", + "resourceVersion": "1767954459090", + "creationTimestamp": "2026-01-09T10:27:39Z" + }, + "spec": { + "description": "Registers AuthZ Role Bindings /apis endpoint", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team", + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "kubernetesAuthzRolesApi", + "resourceVersion": "1767954459090", + "creationTimestamp": "2026-01-09T10:27:39Z" + }, + "spec": { + "description": "Registers AuthZ Roles /apis endpoint", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team", + "hideFromDocs": true + } + }, { "metadata": { "name": "kubernetesAuthzZanzanaSync", @@ -2162,13 +2252,16 @@ { "metadata": { "name": "logQLScope", - "resourceVersion": "1764664939750", - "creationTimestamp": "2024-11-11T11:53:24Z" + "resourceVersion": "1768317398145", + "creationTimestamp": "2024-11-11T11:53:24Z", + "annotations": { + "grafana.app/updatedTimestamp": "2026-01-13 15:16:38.145488 +0000 UTC" + } }, "spec": { "description": "In-development feature that will allow injection of labels into loki queries.", "stage": "privatePreview", - "codeowner": "@grafana/observability-logs", + "codeowner": "@grafana/oss-big-tent", "hideFromDocs": true, "expression": "false" } @@ -2203,7 +2296,8 @@ "metadata": { "name": "logsExploreTableDefaultVisualization", "resourceVersion": "1764664939750", - "creationTimestamp": "2024-05-02T15:28:15Z" + "creationTimestamp": "2024-05-02T15:28:15Z", + "deletionTimestamp": "2026-01-12T14:11:46Z" }, "spec": { "description": "Sets the logs table as default visualisation in logs explore", @@ -2243,38 +2337,47 @@ { "metadata": { "name": "lokiExperimentalStreaming", - "resourceVersion": "1764664939750", - "creationTimestamp": "2023-06-19T10:03:51Z" + "resourceVersion": "1768317398145", + "creationTimestamp": "2023-06-19T10:03:51Z", + "annotations": { + "grafana.app/updatedTimestamp": "2026-01-13 15:16:38.145488 +0000 UTC" + } }, "spec": { "description": "Support new streaming approach for loki (prototype, needs special loki build)", "stage": "experimental", - "codeowner": "@grafana/observability-logs" + "codeowner": "@grafana/oss-big-tent" } }, { "metadata": { "name": "lokiLabelNamesQueryApi", - "resourceVersion": "1764664939750", - "creationTimestamp": "2024-12-13T14:31:41Z" + "resourceVersion": "1768317398145", + "creationTimestamp": "2024-12-13T14:31:41Z", + "annotations": { + "grafana.app/updatedTimestamp": "2026-01-13 15:16:38.145488 +0000 UTC" + } }, "spec": { "description": "Defaults to using the Loki `/labels` API instead of `/series`", "stage": "GA", - "codeowner": "@grafana/observability-logs", + "codeowner": "@grafana/oss-big-tent", "expression": "true" } }, { "metadata": { "name": "lokiLogsDataplane", - "resourceVersion": "1764664939750", - "creationTimestamp": "2023-07-13T07:58:00Z" + "resourceVersion": "1768317398145", + "creationTimestamp": "2023-07-13T07:58:00Z", + "annotations": { + "grafana.app/updatedTimestamp": "2026-01-13 15:16:38.145488 +0000 UTC" + } }, "spec": { "description": "Changes logs responses from Loki to be compliant with the dataplane specification.", "stage": "experimental", - "codeowner": "@grafana/observability-logs" + "codeowner": "@grafana/oss-big-tent" } }, { @@ -2307,13 +2410,16 @@ { "metadata": { "name": "lokiRunQueriesInParallel", - "resourceVersion": "1764664939750", - "creationTimestamp": "2023-09-19T09:34:01Z" + "resourceVersion": "1768317398145", + "creationTimestamp": "2023-09-19T09:34:01Z", + "annotations": { + "grafana.app/updatedTimestamp": "2026-01-13 15:16:38.145488 +0000 UTC" + } }, "spec": { "description": "Enables running Loki queries in parallel", "stage": "privatePreview", - "codeowner": "@grafana/observability-logs" + "codeowner": "@grafana/oss-big-tent" } }, { @@ -3136,6 +3242,18 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "reportingCsvEncodingOptions", + "resourceVersion": "1766080709938", + "creationTimestamp": "2025-12-18T17:58:29Z" + }, + "spec": { + "description": "Enables CSV encoding options in the reporting feature", + "stage": "experimental", + "codeowner": "@grafana/grafana-operator-experience-squad" + } + }, { "metadata": { "name": "reportingRetries", @@ -3571,8 +3689,12 @@ { "metadata": { "name": "unifiedHistory", - "resourceVersion": "1764664939750", - "creationTimestamp": "2024-12-13T10:41:18Z" + "resourceVersion": "1762958248290", + "creationTimestamp": "2024-12-13T10:41:18Z", + "deletionTimestamp": "2025-11-13T16:25:53Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC" + } }, "spec": { "description": "Displays the navigation history so the user can navigate back to previous pages", @@ -3638,7 +3760,8 @@ "metadata": { "name": "unifiedStorageSearch", "resourceVersion": "1764664939750", - "creationTimestamp": "2024-09-30T19:46:14Z" + "creationTimestamp": "2024-09-30T19:46:14Z", + "deletionTimestamp": "2026-01-12T10:02:12Z" }, "spec": { "description": "Enable unified storage search", @@ -3660,19 +3783,6 @@ "hideFromDocs": true } }, - { - "metadata": { - "name": "unifiedStorageSearchSprinkles", - "resourceVersion": "1764664939750", - "creationTimestamp": "2024-12-18T17:00:54Z" - }, - "spec": { - "description": "Enable sprinkles on unified storage search", - "stage": "experimental", - "codeowner": "@grafana/search-and-storage", - "hideFromDocs": true - } - }, { "metadata": { "name": "unifiedStorageSearchUI", diff --git a/pkg/services/featuremgmt/toggles_gen_test.go b/pkg/services/featuremgmt/toggles_gen_test.go index 57e308de4ec..dbfa4af0c1c 100644 --- a/pkg/services/featuremgmt/toggles_gen_test.go +++ b/pkg/services/featuremgmt/toggles_gen_test.go @@ -190,9 +190,6 @@ func verifyFlagsConfiguration(t *testing.T) { if flag.Stage == FeatureStageGeneralAvailability && flag.Expression == "" { t.Errorf("GA features must be explicitly enabled or disabled, please add the `Expression` property for %s", flag.Name) } - if flag.Expression != "" && flag.Expression != "true" && flag.Expression != "false" { - t.Errorf("the `Expression` property for %s is incorrect. valid values are: `true`, `false` or empty string for default", flag.Name) - } // Check camel case names if flag.Name != strcase.ToLowerCamel(flag.Name) && !legacyNames[flag.Name] { invalidNames = append(invalidNames, flag.Name) diff --git a/pkg/services/libraryelements/api.go b/pkg/services/libraryelements/api.go index df51717756e..5a00eca7401 100644 --- a/pkg/services/libraryelements/api.go +++ b/pkg/services/libraryelements/api.go @@ -501,9 +501,15 @@ type GetLibraryElementsParams struct { // required:false ExcludeUID string `json:"excludeUid"` // A comma separated list of folder ID(s) to filter the elements by. + // Deprecated: Use FolderFilterUIDs instead. // in:query // required:false + // deprecated:true FolderFilter string `json:"folderFilter"` + // A comma separated list of folder UID(s) to filter the elements by. + // in:query + // required:false + FolderFilterUIDs string `json:"folderFilterUIDs"` // The number of results per page. // in:query // required:false diff --git a/pkg/services/navtree/navtreeimpl/admin.go b/pkg/services/navtree/navtreeimpl/admin.go index ca91ab4f915..c1da10781d6 100644 --- a/pkg/services/navtree/navtreeimpl/admin.go +++ b/pkg/services/navtree/navtreeimpl/admin.go @@ -54,8 +54,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink } //nolint:staticcheck // not yet migrated to OpenFeature if c.HasRole(identity.RoleAdmin) && - (s.cfg.StackID == "" || // show OnPrem even when provisioning is disabled - s.features.IsEnabledGlobally(featuremgmt.FlagProvisioning)) { + s.features.IsEnabledGlobally(featuremgmt.FlagProvisioning) { generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{ Text: "Provisioning", Id: "provisioning", diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 5b9eb985bd8..a1ef1aab1ef 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -440,7 +440,7 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingTriage) { if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) { alertChildNavs = append(alertChildNavs, &navtree.NavLink{ - Text: "Alerts", SubTitle: "Visualize active and pending alerts", Id: "alert-alerts", Url: s.cfg.AppSubURL + "/alerting/alerts", Icon: "bell", IsNew: true, + Text: "Alert activity", SubTitle: "Visualize active and pending alerts", Id: "alert-alerts", Url: s.cfg.AppSubURL + "/alerting/alerts", Icon: "bell", IsNew: true, }) } } diff --git a/pkg/services/ngalert/models/receivers.go b/pkg/services/ngalert/models/receivers.go index 1601ddc94e5..106203e4e33 100644 --- a/pkg/services/ngalert/models/receivers.go +++ b/pkg/services/ngalert/models/receivers.go @@ -16,13 +16,6 @@ import ( "github.com/grafana/alerting/receivers/schema" ) -// GetReceiverQuery represents a query for a single receiver. -type GetReceiverQuery struct { - OrgID int64 - Name string - Decrypt bool -} - // GetReceiversQuery represents a query for receiver groups. type GetReceiversQuery struct { OrgID int64 diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index 53e49621117..5177107a602 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -213,6 +213,9 @@ func (ng *AlertNG) init() error { SkipVerify: ng.Cfg.Smtp.SkipVerify, StaticHeaders: ng.Cfg.Smtp.StaticHeaders, } + runtimeConfig := remoteClient.RuntimeConfig{ + DispatchTimer: notifier.GetDispatchTimer(ng.FeatureToggles).String(), + } cfg := remote.AlertmanagerConfig{ BasicAuthPassword: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.Password, @@ -222,6 +225,7 @@ func (ng *AlertNG) init() error { ExternalURL: ng.Cfg.AppURL, SmtpConfig: smtpCfg, Timeout: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.Timeout, + RuntimeConfig: runtimeConfig, } autogenFn := func(ctx context.Context, logger log.Logger, orgID int64, cfg *definitions.PostableApiAlertingConfig, invalidReceiverAction notifier.InvalidReceiversAction) error { return notifier.AddAutogenConfig(ctx, logger, ng.store, orgID, cfg, invalidReceiverAction, ng.FeatureToggles) diff --git a/pkg/services/ngalert/notifier/alertmanager.go b/pkg/services/ngalert/notifier/alertmanager.go index f192ed88058..6d81d51dc75 100644 --- a/pkg/services/ngalert/notifier/alertmanager.go +++ b/pkg/services/ngalert/notifier/alertmanager.go @@ -33,6 +33,9 @@ const ( // How long we keep silences in the kvstore after they've expired. silenceRetention = 5 * 24 * time.Hour + + // How long we keep flushes in the kvstore after they've expired. + flushRetention = 5 * 24 * time.Hour ) type AlertingStore interface { @@ -44,8 +47,10 @@ type AlertingStore interface { type stateStore interface { SaveSilences(ctx context.Context, st alertingNotify.State) (int64, error) SaveNotificationLog(ctx context.Context, st alertingNotify.State) (int64, error) + SaveFlushLog(ctx context.Context, st alertingNotify.State) (int64, error) GetSilences(ctx context.Context) (string, error) GetNotificationLog(ctx context.Context) (string, error) + GetFlushLog(ctx context.Context) (string, error) } type alertmanager struct { @@ -101,6 +106,10 @@ func NewAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store A if err != nil { return nil, err } + flushLog, err := stateStore.GetFlushLog(ctx) + if err != nil { + return nil, err + } silencesOptions := maintenanceOptions{ initialState: silences, @@ -123,12 +132,29 @@ func NewAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store A } l := log.New("ngalert.notifier") + dispatchTimer := GetDispatchTimer(featureToggles) + + var flushLogOptions *maintenanceOptions + if dispatchTimer == alertingNotify.DispatchTimerSync { + flushLogOptions = &maintenanceOptions{ + initialState: flushLog, + retention: flushRetention, + maintenanceFrequency: maintenanceInterval, + maintenanceFunc: func(state alertingNotify.State) (int64, error) { + // Detached context here is to make sure that when the service is shut down the persist operation is executed. + return stateStore.SaveFlushLog(context.Background(), state) + }, + } + } + opts := alertingNotify.GrafanaAlertmanagerOpts{ ExternalURL: cfg.AppURL, AlertStoreCallback: nil, PeerTimeout: cfg.UnifiedAlerting.HAPeerTimeout, Silences: silencesOptions, Nflog: nflogOptions, + FlushLog: flushLogOptions, + DispatchTimer: dispatchTimer, Limits: alertingNotify.Limits{ MaxSilences: cfg.UnifiedAlerting.AlertmanagerMaxSilencesCount, MaxSilenceSizeBytes: cfg.UnifiedAlerting.AlertmanagerMaxSilenceSizeBytes, diff --git a/pkg/services/ngalert/notifier/crypto.go b/pkg/services/ngalert/notifier/crypto.go index 246cd03d64d..04a651a174f 100644 --- a/pkg/services/ngalert/notifier/crypto.go +++ b/pkg/services/ngalert/notifier/crypto.go @@ -378,3 +378,29 @@ func EncryptedReceivers(receivers []*definitions.PostableApiReceiver, encryptFn } return encrypted, nil } + +// DecryptIntegrationSettings returns a function to decrypt integration settings. +func DecryptIntegrationSettings(ctx context.Context, ss secretService) models.DecryptFn { + return func(value string) (string, error) { + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return "", err + } + decrypted, err := ss.Decrypt(ctx, decoded) + if err != nil { + return "", err + } + return string(decrypted), nil + } +} + +// EncryptIntegrationSettings returns a function to encrypt integration settings. +func EncryptIntegrationSettings(ctx context.Context, ss secretService) models.EncryptFn { + return func(payload string) (string, error) { + encrypted, err := ss.Encrypt(ctx, []byte(payload), secrets.WithoutScope()) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(encrypted), nil + } +} diff --git a/pkg/services/ngalert/notifier/dispatch_timer.go b/pkg/services/ngalert/notifier/dispatch_timer.go new file mode 100644 index 00000000000..04eaf8cb296 --- /dev/null +++ b/pkg/services/ngalert/notifier/dispatch_timer.go @@ -0,0 +1,16 @@ +package notifier + +import ( + alertingNotify "github.com/grafana/alerting/notify" + "github.com/grafana/grafana/pkg/services/featuremgmt" +) + +// GetDispatchTimer returns the appropriate dispatch timer based on feature toggles. +func GetDispatchTimer(features featuremgmt.FeatureToggles) (dt alertingNotify.DispatchTimer) { + //nolint:staticcheck // not yet migrated to OpenFeature + enabled := features.IsEnabledGlobally(featuremgmt.FlagAlertingSyncDispatchTimer) + if enabled { + dt = alertingNotify.DispatchTimerSync + } + return +} diff --git a/pkg/services/ngalert/notifier/dispatch_timer_test.go b/pkg/services/ngalert/notifier/dispatch_timer_test.go new file mode 100644 index 00000000000..3b42a562a32 --- /dev/null +++ b/pkg/services/ngalert/notifier/dispatch_timer_test.go @@ -0,0 +1,36 @@ +package notifier + +import ( + "testing" + + alertingNotify "github.com/grafana/alerting/notify" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/stretchr/testify/require" +) + +func TestGetDispatchTimer(t *testing.T) { + tests := []struct { + name string + featureFlagValue bool + expected alertingNotify.DispatchTimer + }{ + { + name: "feature flag enabled returns sync timer", + featureFlagValue: true, + expected: alertingNotify.DispatchTimerSync, + }, + { + name: "feature flag disabled returns default timer", + featureFlagValue: false, + expected: alertingNotify.DispatchTimerDefault, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + features := featuremgmt.WithFeatures(featuremgmt.FlagAlertingSyncDispatchTimer, tt.featureFlagValue) + result := GetDispatchTimer(features) + require.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/services/ngalert/notifier/errors.go b/pkg/services/ngalert/notifier/errors.go index 337dfa7cb3b..08f4c967049 100644 --- a/pkg/services/ngalert/notifier/errors.go +++ b/pkg/services/ngalert/notifier/errors.go @@ -1,9 +1,37 @@ package notifier -import "github.com/grafana/grafana/pkg/apimachinery/errutil" +import ( + "errors" + "slices" + + "github.com/grafana/alerting/receivers/schema" + + "github.com/grafana/grafana/pkg/apimachinery/errutil" +) // WithPublicError sets the public message of an errutil error to the error message. func WithPublicError(err errutil.Error) error { err.PublicMessage = err.Error() return err } + +// If provided error is errutil.Error it appends fields that caused the error to public payload +func makeProtectedFieldsAuthzError(err error, diff map[string][]schema.IntegrationFieldPath) error { + var authzErr errutil.Error + if !errors.As(err, &authzErr) { + return err + } + if authzErr.PublicPayload == nil { + authzErr.PublicPayload = map[string]interface{}{} + } + fields := make(map[string][]string, len(diff)) + for field, paths := range diff { + fields[field] = make([]string, len(paths)) + for i, path := range paths { + fields[field][i] = path.String() + } + slices.Sort(fields[field]) + } + authzErr.PublicPayload["changed_protected_fields"] = fields + return authzErr +} diff --git a/pkg/services/ngalert/notifier/file_store.go b/pkg/services/ngalert/notifier/file_store.go index e9628cb536e..3bb128d692d 100644 --- a/pkg/services/ngalert/notifier/file_store.go +++ b/pkg/services/ngalert/notifier/file_store.go @@ -15,6 +15,7 @@ const ( KVNamespace = "alertmanager" NotificationLogFilename = "notifications" SilencesFilename = "silences" + FlushLogFilename = "flushes" ) // FileStore is in charge of persisting the alertmanager files to the database. @@ -42,6 +43,10 @@ func (fileStore *FileStore) GetNotificationLog(ctx context.Context) (string, err return fileStore.contentFor(ctx, NotificationLogFilename) } +func (fileStore *FileStore) GetFlushLog(ctx context.Context) (string, error) { + return fileStore.contentFor(ctx, FlushLogFilename) +} + // contentFor returns the content for the given Alertmanager kvstore key. func (fileStore *FileStore) contentFor(ctx context.Context, filename string) (string, error) { // Then, let's attempt to read it from the database. @@ -74,6 +79,11 @@ func (fileStore *FileStore) SaveNotificationLog(ctx context.Context, st alerting return fileStore.persist(ctx, NotificationLogFilename, st) } +// SaveFlushLog saves the flush log to the database and returns the size of the unencoded state. +func (fileStore *FileStore) SaveFlushLog(ctx context.Context, st alertingNotify.State) (int64, error) { + return fileStore.persist(ctx, FlushLogFilename, st) +} + // persist takes care of persisting the binary representation of internal state to the database as a base64 encoded string. func (fileStore *FileStore) persist(ctx context.Context, filename string, st alertingNotify.State) (int64, error) { var size int64 diff --git a/pkg/services/ngalert/notifier/file_store_test.go b/pkg/services/ngalert/notifier/file_store_test.go index 1952eb5a0f1..7d4de602a0f 100644 --- a/pkg/services/ngalert/notifier/file_store_test.go +++ b/pkg/services/ngalert/notifier/file_store_test.go @@ -106,3 +106,48 @@ func TestFileStore_NotificationLog(t *testing.T) { t.Errorf("Unexpected Diff: %v", cmp.Diff(newState, decoded)) } } + +func TestFileStore_FlushLog(t *testing.T) { + store := fakes.NewFakeKVStore(t) + ctx := context.Background() + var orgId int64 = 1 + + // Initialize kvstore with empty flush log state. + initialState := flushLogState{} // FlushLog uses the same structure as nflog + decodedState, err := initialState.MarshalBinary() + require.NoError(t, err) + encodedState := base64.StdEncoding.EncodeToString(decodedState) + err = store.Set(ctx, orgId, KVNamespace, FlushLogFilename, encodedState) + require.NoError(t, err) + + fs := NewFileStore(orgId, store) + + // Load initial (empty). + flushLog, err := fs.GetFlushLog(ctx) + require.NoError(t, err) + decoded, err := decodeFlushLogState(strings.NewReader(flushLog)) + require.NoError(t, err) + if !cmp.Equal(initialState, decoded) { + t.Errorf("Unexpected Diff: %v", cmp.Diff(initialState, decoded)) + } + + // Save new flush log state. + now := time.Now() + oneHour := now.Add(time.Hour) + + v1 := createFlushLog(1, now, oneHour) + v2 := createFlushLog(2, now, oneHour) + newState := flushLogState{1: v1, 2: v2} + size, err := fs.SaveFlushLog(ctx, newState) + require.NoError(t, err) + require.Greater(t, size, int64(0)) + + // Load new. + flushLog, err = fs.GetFlushLog(ctx) + require.NoError(t, err) + decoded, err = decodeFlushLogState(strings.NewReader(flushLog)) + require.NoError(t, err) + if !cmp.Equal(newState, decoded) { + t.Errorf("Unexpected Diff: %v", cmp.Diff(newState, decoded)) + } +} diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager.go b/pkg/services/ngalert/notifier/multiorg_alertmanager.go index 4aa0151d18f..a10aee29ef6 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager.go @@ -82,6 +82,7 @@ type Alertmanager interface { type ExternalState struct { Silences []byte Nflog []byte + FlushLog []byte } // StateMerger describes a type that is able to merge external state (nflog, silences) with its own. @@ -378,7 +379,7 @@ func (moa *MultiOrgAlertmanager) SyncAlertmanagersForOrgs(ctx context.Context, o func (moa *MultiOrgAlertmanager) cleanupOrphanLocalOrgState(ctx context.Context, activeOrganizations map[int64]struct{}, ) { - storedFiles := []string{NotificationLogFilename, SilencesFilename} + storedFiles := []string{NotificationLogFilename, SilencesFilename, FlushLogFilename} for _, fileName := range storedFiles { keys, err := moa.kvStore.Keys(ctx, kvstore.AllOrganizations, KVNamespace, fileName) if err != nil { diff --git a/pkg/services/ngalert/notifier/receiver_svc.go b/pkg/services/ngalert/notifier/receiver_svc.go index e05b12a2920..dc3e9d4b8eb 100644 --- a/pkg/services/ngalert/notifier/receiver_svc.go +++ b/pkg/services/ngalert/notifier/receiver_svc.go @@ -2,7 +2,6 @@ package notifier import ( "context" - "encoding/base64" "errors" "fmt" "strings" @@ -133,30 +132,33 @@ func (rs *ReceiverService) loadProvenances(ctx context.Context, orgID int64) (ma return rs.provisioningStore.GetProvenances(ctx, orgID, (&models.Integration{}).ResourceType()) } -// GetReceiver returns a receiver by name. +// GetReceiver returns a receiver by its UID. // The receiver's secure settings are decrypted if requested and the user has access to do so. -func (rs *ReceiverService) GetReceiver(ctx context.Context, q models.GetReceiverQuery, user identity.Requester) (*models.Receiver, error) { +func (rs *ReceiverService) GetReceiver(ctx context.Context, uid string, decrypt bool, user identity.Requester) (*models.Receiver, error) { + if user == nil { + return nil, errors.New("user is required") + } ctx, span := rs.tracer.Start(ctx, "alerting.receivers.get", trace.WithAttributes( - attribute.Int64("query_org_id", q.OrgID), - attribute.String("query_name", q.Name), - attribute.Bool("query_decrypt", q.Decrypt), + attribute.Int64("query_org_id", user.GetOrgID()), + attribute.String("query_uid", uid), + attribute.Bool("query_decrypt", decrypt), )) defer span.End() - revision, err := rs.cfgStore.Get(ctx, q.OrgID) + revision, err := rs.cfgStore.Get(ctx, user.GetOrgID()) if err != nil { return nil, err } - prov, err := rs.loadProvenances(ctx, q.OrgID) + prov, err := rs.loadProvenances(ctx, user.GetOrgID()) if err != nil { return nil, err } - rcv, err := revision.GetReceiver(legacy_storage.NameToUid(q.Name), prov) + rcv, err := revision.GetReceiver(uid, prov) if err != nil { if errors.Is(err, legacy_storage.ErrReceiverNotFound) && rs.includeImported { - imported := rs.getImportedReceivers(ctx, span, []string{legacy_storage.NameToUid(q.Name)}, revision) + imported := rs.getImportedReceivers(ctx, span, []string{uid}, revision) if len(imported) > 0 { rcv = imported[0] } @@ -171,14 +173,14 @@ func (rs *ReceiverService) GetReceiver(ctx context.Context, q models.GetReceiver )) auth := rs.authz.AuthorizeReadDecrypted - if !q.Decrypt { + if !decrypt { auth = rs.authz.AuthorizeRead } if err := auth(ctx, user, rcv); err != nil { return nil, err } - if q.Decrypt { + if decrypt { err := rcv.Decrypt(rs.decryptor(ctx)) if err != nil { rs.log.FromContext(ctx).Warn("Failed to decrypt secure settings", "name", rcv.Name, "error", err) @@ -684,28 +686,12 @@ func (rs *ReceiverService) deleteProvenances(ctx context.Context, orgID int64, i // decryptor returns a models.DecryptFn that decrypts a secure setting. If decryption fails, the fallback value is used. func (rs *ReceiverService) decryptor(ctx context.Context) models.DecryptFn { - return func(value string) (string, error) { - decoded, err := base64.StdEncoding.DecodeString(value) - if err != nil { - return "", err - } - decrypted, err := rs.encryptionService.Decrypt(ctx, decoded) - if err != nil { - return "", err - } - return string(decrypted), nil - } + return DecryptIntegrationSettings(ctx, rs.encryptionService) } // encryptor creates an encrypt function that delegates to secrets.Service and returns the base64 encoded result. func (rs *ReceiverService) encryptor(ctx context.Context) models.EncryptFn { - return func(payload string) (string, error) { - s, err := rs.encryptionService.Encrypt(ctx, []byte(payload), secrets.WithoutScope()) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(s), nil - } + return EncryptIntegrationSettings(ctx, rs.encryptionService) } // checkOptimisticConcurrency checks if the existing receiver's version matches the desired version. diff --git a/pkg/services/ngalert/notifier/receiver_svc_err.go b/pkg/services/ngalert/notifier/receiver_svc_err.go deleted file mode 100644 index 06b27602f75..00000000000 --- a/pkg/services/ngalert/notifier/receiver_svc_err.go +++ /dev/null @@ -1,30 +0,0 @@ -package notifier - -import ( - "errors" - "slices" - - "github.com/grafana/alerting/receivers/schema" - - "github.com/grafana/grafana/pkg/apimachinery/errutil" -) - -func makeProtectedFieldsAuthzError(err error, diff map[string][]schema.IntegrationFieldPath) error { - var authzErr errutil.Error - if !errors.As(err, &authzErr) { - return err - } - if authzErr.PublicPayload == nil { - authzErr.PublicPayload = map[string]interface{}{} - } - fields := make(map[string][]string, len(diff)) - for field, paths := range diff { - fields[field] = make([]string, len(paths)) - for i, path := range paths { - fields[field][i] = path.String() - } - slices.Sort(fields[field]) - } - authzErr.PublicPayload["changed_protected_fields"] = fields - return authzErr -} diff --git a/pkg/services/ngalert/notifier/receiver_svc_test.go b/pkg/services/ngalert/notifier/receiver_svc_test.go index 10e84f33b62..08c66411f16 100644 --- a/pkg/services/ngalert/notifier/receiver_svc_test.go +++ b/pkg/services/ngalert/notifier/receiver_svc_test.go @@ -50,7 +50,7 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) { t.Run("service gets receiver from AM config", func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService) - recv, err := sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), redactedUser) + recv, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("slack receiver"), false, redactedUser) require.NoError(t, err) require.Equal(t, "slack receiver", recv.Name) require.Len(t, recv.Integrations, 1) @@ -60,7 +60,7 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) { t.Run("service returns error when receiver does not exist", func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService) - _, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser) + _, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, redactedUser) require.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound) }) @@ -68,7 +68,7 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) { t.Run("gets imported receivers", func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService, withImportedIncluded) - recv, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser) + recv, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, redactedUser) require.NoError(t, err) assert.Equal(t, models.ResourceOriginImported, recv.Origin) assert.Equal(t, "receiver1", recv.Name) @@ -81,9 +81,9 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) { t.Run("falls to only Grafana if cannot read imported receivers", func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService, withImportedIncluded, withInvalidExtraConfig) - _, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser) + _, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, redactedUser) require.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound) - _, err = sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), redactedUser) + _, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid("slack receiver"), false, redactedUser) require.NoError(t, err) }) }) @@ -187,12 +187,6 @@ func TestIntegrationReceiverService_DecryptRedact(t *testing.T) { user: readUser, err: "[alerting.unauthorized] user is not authorized to read any decrypted receiver", }, - { - name: "service returns error if user is nil and decrypt is true", - decrypt: true, - user: nil, - err: "[alerting.unauthorized] user is not authorized to read any decrypted receiver", - }, { name: "service decrypts receivers with permission", decrypt: true, @@ -224,18 +218,16 @@ func TestIntegrationReceiverService_DecryptRedact(t *testing.T) { }, } - for _, o := range origin { - for _, method := range getMethods { - t.Run(fmt.Sprintf("%s %s", tc.name, method), func(t *testing.T) { + for _, method := range getMethods { + t.Run(fmt.Sprintf("%s %s", tc.name, method), func(t *testing.T) { + for _, o := range origin { t.Run(fmt.Sprintf("%s %s (%s)", tc.name, method, o.origin), func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService, o.opts...) var res *models.Receiver var err error if method == "single" { - q := singleQ(1, o.receiver) - q.Decrypt = tc.decrypt - res, err = sut.GetReceiver(context.Background(), q, tc.user) + res, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid(o.receiver), tc.decrypt, tc.user) } else { q := multiQ(1, o.receiver) q.Decrypt = tc.decrypt @@ -267,8 +259,8 @@ func TestIntegrationReceiverService_DecryptRedact(t *testing.T) { require.NotEqual(t, o.decryptedSettingValue, res.Integrations[0].SecureSettings[o.secureSettingKey]) } }) - }) - } + } + }) } } } @@ -412,8 +404,7 @@ func TestReceiverService_Delete(t *testing.T) { // Ensure receiver saved to store is correct. name, err := legacy_storage.UidToName(tc.deleteUID) require.NoError(t, err) - q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: name} - _, err = sut.GetReceiver(context.Background(), q, writer) + _, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid(name), false, writer) assert.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound) provenances, err := sut.provisioningStore.GetProvenances(context.Background(), tc.user.GetOrgID(), (&definitions.EmbeddedContactPoint{}).ResourceType()) @@ -626,8 +617,7 @@ func TestReceiverService_Create(t *testing.T) { assert.Equal(t, tc.expectedCreate, *created) // Ensure receiver saved to store is correct. - q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: tc.receiver.Name, Decrypt: true} - stored, err := sut.GetReceiver(context.Background(), q, decryptUser) + stored, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(tc.receiver.Name), true, decryptUser) require.NoError(t, err) decrypted := models.CopyReceiverWith(tc.expectedCreate, models.ReceiverMuts.Decrypted(models.Base64Decrypt)) decrypted.Version = tc.expectedCreate.Version // Version is calculated before decryption. @@ -931,8 +921,7 @@ func TestReceiverService_Update(t *testing.T) { assert.Equal(t, tc.expectedUpdate, *updated) // Ensure receiver saved to store is correct. - q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: tc.receiver.Name, Decrypt: true} - stored, err := sut.GetReceiver(context.Background(), q, decryptUser) + stored, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(tc.receiver.Name), true, decryptUser) require.NoError(t, err) decrypted := models.CopyReceiverWith(tc.expectedUpdate, models.ReceiverMuts.Decrypted(models.Base64Decrypt)) decrypted.Version = tc.expectedUpdate.Version // Version is calculated before decryption. @@ -1054,7 +1043,7 @@ func TestReceiverService_UpdateReceiverName(t *testing.T) { sut.ruleNotificationsStore = ruleStore newReceiverName = "receiver1" - actual, err := sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: writer.GetOrgID(), Name: newReceiverName}, writer) + actual, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(newReceiverName), false, writer) require.NoError(t, err) require.Equal(t, models.ResourceOriginImported, actual.Origin) require.Equal(t, newReceiverName, actual.Name) @@ -1067,7 +1056,7 @@ func TestReceiverService_UpdateReceiverName(t *testing.T) { require.NotEqual(t, actual, recv) require.Equal(t, models.ResourceOriginGrafana, recv.Origin) - actual, err = sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: writer.GetOrgID(), Name: newReceiverName}, writer) + actual, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid(newReceiverName), false, writer) require.NoError(t, err) require.Equal(t, recv.Name, actual.Name) }) @@ -1185,7 +1174,7 @@ func TestReceiverServiceAC_Read(t *testing.T) { return false } for _, recv := range allReceivers() { - response, err := sut.GetReceiver(context.Background(), singleQ(orgId, recv.Name), usr) + response, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(recv.Name), false, usr) if isVisible(recv.UID) { require.NoErrorf(t, err, "receiver '%s' should be visible, but isn't", recv.Name) assert.NotNil(t, response) @@ -1207,7 +1196,7 @@ func TestReceiverServiceAC_Read(t *testing.T) { } sut.authz = ac.NewReceiverAccess[*models.Receiver](acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), true) for _, recv := range allReceivers() { - response, err := sut.GetReceiver(context.Background(), singleQ(orgId, recv.Name), usr) + response, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(recv.Name), false, usr) if isVisibleInProvisioning(recv.UID) { require.NoErrorf(t, err, "receiver '%s' should be visible, but isn't", recv.Name) assert.NotNil(t, response) @@ -1766,7 +1755,7 @@ func TestReceiverService_AccessControlMetadata(t *testing.T) { }, }} - r, err := sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: 1, Name: "receiver1"}, admin) + r, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, admin) require.NoError(t, err) t.Run("should override metadata for imported receivers", func(t *testing.T) { @@ -1842,13 +1831,6 @@ func createEncryptedConfig(t *testing.T, secretService secretService, extraConfi return string(bytes) } -func singleQ(orgID int64, name string) models.GetReceiverQuery { - return models.GetReceiverQuery{ - OrgID: orgID, - Name: name, - } -} - func multiQ(orgID int64, names ...string) models.GetReceiversQuery { return models.GetReceiversQuery{ OrgID: orgID, diff --git a/pkg/services/ngalert/notifier/state.go b/pkg/services/ngalert/notifier/state.go index c8551d2ed1a..04ba9d9a31d 100644 --- a/pkg/services/ngalert/notifier/state.go +++ b/pkg/services/ngalert/notifier/state.go @@ -5,5 +5,8 @@ func (am *alertmanager) MergeState(state ExternalState) error { if err := am.Base.MergeNflog(state.Nflog); err != nil { return err } - return am.Base.MergeSilences(state.Silences) + if err := am.Base.MergeSilences(state.Silences); err != nil { + return err + } + return am.Base.MergeFlushLog(state.FlushLog) } diff --git a/pkg/services/ngalert/notifier/testing.go b/pkg/services/ngalert/notifier/testing.go index 9fccf6d2f0d..c2b2190183f 100644 --- a/pkg/services/ngalert/notifier/testing.go +++ b/pkg/services/ngalert/notifier/testing.go @@ -11,6 +11,7 @@ import ( "time" "github.com/matttproud/golang_protobuf_extensions/pbutil" + "github.com/prometheus/alertmanager/flushlog/flushlogpb" "github.com/prometheus/alertmanager/nflog/nflogpb" "github.com/prometheus/alertmanager/silence/silencepb" "github.com/prometheus/common/model" @@ -228,15 +229,13 @@ func (f *FakeOrgStore) FetchOrgIds(_ context.Context) ([]int64, error) { return f.orgs, nil } -type NoValidation struct { -} +type NoValidation struct{} func (n NoValidation) Validate(_ models.NotificationSettings) error { return nil } -type RejectingValidation struct { -} +type RejectingValidation struct{} func (n RejectingValidation) Validate(s models.NotificationSettings) error { return ErrorReceiverDoesNotExist{ErrorReferenceInvalid: ErrorReferenceInvalid{Reference: s.Receiver}} @@ -365,6 +364,51 @@ func createNotificationLog(groupKey string, receiverName string, sentAt, expires } } +// https://github.com/grafana/prometheus-alertmanager/blob/main/flushlog/flushlog.go#L136-L136 +type flushLogState map[uint64]*flushlogpb.MeshFlushLog + +func (s flushLogState) MarshalBinary() ([]byte, error) { + var buf bytes.Buffer + + for _, e := range s { + if _, err := pbutil.WriteDelimited(&buf, e); err != nil { + return nil, err + } + } + return buf.Bytes(), nil +} + +func createFlushLog(groupFingerprint uint64, ts, expiresAt time.Time) *flushlogpb.MeshFlushLog { + return &flushlogpb.MeshFlushLog{ + FlushLog: &flushlogpb.FlushLog{ + GroupFingerprint: groupFingerprint, + Timestamp: ts, + }, + ExpiresAt: expiresAt, + } +} + +// decodeFlushLogState copied from decodeState in prometheus-alertmanager/flushlog/flushlog.go +func decodeFlushLogState(r io.Reader) (flushLogState, error) { + st := flushLogState{} + for { + var e flushlogpb.MeshFlushLog + _, err := pbutil.ReadDelimited(r, &e) + if err == nil { + if e.FlushLog == nil || e.FlushLog.GroupFingerprint == 0 || e.FlushLog.Timestamp.IsZero() { + return nil, errInvalidState + } + st[e.FlushLog.GroupFingerprint] = &e + continue + } + if errors.Is(err, io.EOF) { + break + } + return nil, err + } + return st, nil +} + type call struct { Method string Args []interface{} diff --git a/pkg/services/ngalert/remote/alertmanager.go b/pkg/services/ngalert/remote/alertmanager.go index a7c99f8518c..07fa5e3138f 100644 --- a/pkg/services/ngalert/remote/alertmanager.go +++ b/pkg/services/ngalert/remote/alertmanager.go @@ -40,12 +40,14 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/notifier" remoteClient "github.com/grafana/grafana/pkg/services/ngalert/remote/client" "github.com/grafana/grafana/pkg/services/ngalert/sender" + "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/util/cmputil" ) type stateStore interface { GetSilences(ctx context.Context) (string, error) GetNotificationLog(ctx context.Context) (string, error) + GetFlushLog(ctx context.Context) (string, error) } // AutogenFn is a function that adds auto-generated routes to a configuration. @@ -57,6 +59,7 @@ func NoopAutogenFn(_ context.Context, _ log.Logger, _ int64, _ *apimodels.Postab } type Crypto interface { + Encrypt(ctx context.Context, payload []byte, opt secrets.EncryptionOptions) ([]byte, error) Decrypt(ctx context.Context, payload []byte) ([]byte, error) DecryptExtraConfigs(ctx context.Context, config *apimodels.PostableUserConfig) error } @@ -84,6 +87,8 @@ type Alertmanager struct { promoteConfig bool externalURL string + + runtimeConfig remoteClient.RuntimeConfig } type AlertmanagerConfig struct { @@ -109,6 +114,9 @@ type AlertmanagerConfig struct { // Timeout for the HTTP client. Timeout time.Duration + + // RuntimeConfig specifies runtime behavior settings for the remote Alertmanager. + RuntimeConfig remoteClient.RuntimeConfig } func (cfg *AlertmanagerConfig) Validate() error { @@ -201,6 +209,7 @@ func NewAlertmanager(ctx context.Context, cfg AlertmanagerConfig, store stateSto externalURL: cfg.ExternalURL, promoteConfig: cfg.PromoteConfig, smtp: cfg.SmtpConfig, + runtimeConfig: cfg.RuntimeConfig, } // Parse the default configuration once and remember its hash so we can compare it later. @@ -289,20 +298,6 @@ func (am *Alertmanager) isDefaultConfiguration(configHash string) bool { return configHash == am.defaultConfigHash } -func decrypter(ctx context.Context, crypto Crypto) models.DecryptFn { - return func(value string) (string, error) { - decoded, err := base64.StdEncoding.DecodeString(value) - if err != nil { - return "", err - } - decrypted, err := crypto.Decrypt(ctx, decoded) - if err != nil { - return "", err - } - return string(decrypted), nil - } -} - // 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, createdAtEpoch int64, autogenInvalidReceiverAction notifier.InvalidReceiversAction) (remoteClient.UserGrafanaConfig, error) { @@ -317,7 +312,7 @@ func (am *Alertmanager) buildConfiguration(ctx context.Context, raw []byte, crea } // Decrypt the receivers in the configuration. - decryptedReceivers, err := notifier.DecryptedReceivers(c.AlertmanagerConfig.Receivers, decrypter(ctx, am.crypto)) + decryptedReceivers, err := notifier.DecryptedReceivers(c.AlertmanagerConfig.Receivers, notifier.DecryptIntegrationSettings(ctx, am.crypto)) if err != nil { return remoteClient.UserGrafanaConfig{}, fmt.Errorf("unable to decrypt receivers: %w", err) } @@ -343,10 +338,11 @@ func (am *Alertmanager) buildConfiguration(ctx context.Context, raw []byte, crea AlertmanagerConfig: mergeResult.Config, Templates: templates, }, - CreatedAt: createdAtEpoch, - Promoted: am.promoteConfig, - ExternalURL: am.externalURL, - SmtpConfig: am.smtp, + CreatedAt: createdAtEpoch, + Promoted: am.promoteConfig, + ExternalURL: am.externalURL, + SmtpConfig: am.smtp, + RuntimeConfig: am.runtimeConfig, } cfgHash, err := calculateUserGrafanaConfigHash(payload) @@ -400,6 +396,8 @@ func (am *Alertmanager) GetRemoteState(ctx context.Context) (notifier.ExternalSt rs.Silences = p.Data case "nfl": rs.Nflog = p.Data + case "fls": + rs.FlushLog = p.Data default: return rs, fmt.Errorf("unknown part key %q", p.Key) } @@ -619,7 +617,7 @@ func (am *Alertmanager) GetReceivers(ctx context.Context) ([]apimodels.Receiver, } func (am *Alertmanager) TestReceivers(ctx context.Context, c apimodels.TestReceiversConfigBodyParams) (*alertingNotify.TestReceiversResult, int, error) { - decryptedReceivers, err := notifier.DecryptedReceivers(c.Receivers, decrypter(ctx, am.crypto)) + decryptedReceivers, err := notifier.DecryptedReceivers(c.Receivers, notifier.DecryptIntegrationSettings(ctx, am.crypto)) if err != nil { return nil, 0, fmt.Errorf("failed to decrypt receivers: %w", err) } @@ -689,6 +687,12 @@ func (am *Alertmanager) getFullState(ctx context.Context) (string, error) { } parts = append(parts, alertingClusterPB.Part{Key: notifier.NotificationLogFilename, Data: []byte(notificationLog)}) + flushLog, err := am.state.GetFlushLog(ctx) + if err != nil { + return "", fmt.Errorf("error getting flush log: %w", err) + } + parts = append(parts, alertingClusterPB.Part{Key: notifier.FlushLogFilename, Data: []byte(flushLog)}) + fs := alertingClusterPB.FullState{ Parts: parts, } diff --git a/pkg/services/ngalert/remote/alertmanager_test.go b/pkg/services/ngalert/remote/alertmanager_test.go index d6f66756454..1a1d3943324 100644 --- a/pkg/services/ngalert/remote/alertmanager_test.go +++ b/pkg/services/ngalert/remote/alertmanager_test.go @@ -43,7 +43,6 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/ngalert/remote/client" ngfakes "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" - "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/database" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" @@ -298,13 +297,7 @@ func TestIntegrationApplyConfig(t *testing.T) { var c apimodels.PostableUserConfig require.NoError(t, json.Unmarshal([]byte(testGrafanaConfigWithSecret), &c)) secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(db.InitTestDB(t))) - encryptedReceivers, err := notifier.EncryptedReceivers(c.AlertmanagerConfig.Receivers, func(payload string) (string, error) { - encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope()) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(encrypted), nil - }) + encryptedReceivers, err := notifier.EncryptedReceivers(c.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService)) c.AlertmanagerConfig.Receivers = encryptedReceivers require.NoError(t, err) @@ -462,13 +455,7 @@ func TestCompareAndSendConfiguration(t *testing.T) { // Create a config with correctly encrypted and encoded secrets. var inputCfg apimodels.PostableUserConfig require.NoError(t, json.Unmarshal([]byte(testGrafanaConfigWithSecret), &inputCfg)) - encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, func(payload string) (string, error) { - encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope()) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(encrypted), nil - }) + encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService)) inputCfg.AlertmanagerConfig.Receivers = encryptedReceivers require.NoError(t, err) testGrafanaConfigWithEncryptedSecret, err := json.Marshal(inputCfg) @@ -663,13 +650,7 @@ func Test_TestReceiversDecryptsSecureSettings(t *testing.T) { var inputCfg apimodels.PostableUserConfig require.NoError(t, json.Unmarshal([]byte(testGrafanaConfigWithSecret), &inputCfg)) - encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, func(payload string) (string, error) { - encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope()) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(encrypted), nil - }) + encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService)) inputCfg.AlertmanagerConfig.Receivers = encryptedReceivers require.NoError(t, err) @@ -1037,13 +1018,7 @@ func TestIntegrationRemoteAlertmanagerConfiguration(t *testing.T) { { postableCfg, err := notifier.Load([]byte(testGrafanaConfigWithSecret)) require.NoError(t, err) - encryptedReceivers, err := notifier.EncryptedReceivers(postableCfg.AlertmanagerConfig.Receivers, func(payload string) (string, error) { - encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope()) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(encrypted), nil - }) + encryptedReceivers, err := notifier.EncryptedReceivers(postableCfg.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService)) postableCfg.AlertmanagerConfig.Receivers = encryptedReceivers require.NoError(t, err) diff --git a/pkg/services/ngalert/remote/client/alertmanager_configuration.go b/pkg/services/ngalert/remote/client/alertmanager_configuration.go index a53132a8812..75246a6f32d 100644 --- a/pkg/services/ngalert/remote/client/alertmanager_configuration.go +++ b/pkg/services/ngalert/remote/client/alertmanager_configuration.go @@ -29,6 +29,10 @@ func (u *GrafanaAlertmanagerConfig) MarshalJSON() ([]byte, error) { return definition.MarshalJSONWithSecrets((*cfg)(u)) } +type RuntimeConfig struct { + DispatchTimer string `json:"dispatch_timer"` +} + type UserGrafanaConfig struct { GrafanaAlertmanagerConfig GrafanaAlertmanagerConfig `json:"configuration"` Hash string `json:"configuration_hash"` @@ -37,6 +41,7 @@ type UserGrafanaConfig struct { Promoted bool `json:"promoted"` ExternalURL string `json:"external_url"` SmtpConfig SmtpConfig `json:"smtp_config"` + RuntimeConfig RuntimeConfig `json:"runtime_config"` } func (mc *Mimir) GetGrafanaAlertmanagerConfig(ctx context.Context) (*UserGrafanaConfig, error) { diff --git a/pkg/services/org/model.go b/pkg/services/org/model.go index ac0268e051c..fa11375d778 100644 --- a/pkg/services/org/model.go +++ b/pkg/services/org/model.go @@ -151,7 +151,7 @@ type OrgUserDTO struct { Role string `json:"role"` LastSeenAt time.Time `json:"lastSeenAt"` Updated time.Time `json:"-"` - Created time.Time `json:"-"` + Created time.Time `json:"created"` LastSeenAtAge string `json:"lastSeenAtAge"` AccessControl map[string]bool `json:"accessControl,omitempty"` IsDisabled bool `json:"isDisabled"` diff --git a/pkg/services/preference/generate_themes.go b/pkg/services/preference/generate_themes.go new file mode 100644 index 00000000000..464e26dbea2 --- /dev/null +++ b/pkg/services/preference/generate_themes.go @@ -0,0 +1,90 @@ +//go:build ignore + +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +type Colors struct { + Mode string `json:"mode"` +} + +type ThemeDefinition struct { + Colors Colors `json:"colors"` + Id string `json:"id"` +} + +func main() { + themesPath := filepath.Join("..", "..", "..", "packages", "grafana-data", "src", "themes", "themeDefinitions") + + // Check if the themes directory exists + if _, err := os.Stat(themesPath); os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Themes directory not found: %s\n", themesPath) + os.Exit(1) + } + + output := `// Code generated by go generate; DO NOT EDIT. + +package pref + +var themes = []ThemeDTO{ + {ID: "light", Type: "light"}, + {ID: "dark", Type: "dark"}, + {ID: "system", Type: "dark"}, +` + + err := filepath.WalkDir(themesPath, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + // Only process json files + if d.IsDir() || !strings.HasSuffix(d.Name(), ".json") { + return nil + } + + fileBytes, readErr := os.ReadFile(path) + if readErr != nil { + fmt.Fprintf(os.Stderr, "Error reading file %s: %v\n", path, readErr) + return nil // Continue processing other files + } + + var themeDef ThemeDefinition + jsonErr := json.Unmarshal(fileBytes, &themeDef) + if jsonErr != nil { + fmt.Fprintf(os.Stderr, "Error parsing JSON from %s: %v\n", path, jsonErr) + return nil // Continue processing other files + } + + themeId := themeDef.Id + themeType := "dark" // default fallback + if themeDef.Colors.Mode != "" { + themeType = themeDef.Colors.Mode + } + + output += fmt.Sprintf("\t{ID: %q, Type: %q, IsExtra: true},\n", themeId, themeType) + + return nil + }) + + if err != nil { + fmt.Fprintf(os.Stderr, "Error walking themes directory: %v\n", err) + os.Exit(1) + } + + output += "}\n" + + // Write the generated file + outputPath := filepath.Join("themes_generated.go") + if err := os.WriteFile(outputPath, []byte(output), 0644); err != nil { + fmt.Fprintf(os.Stderr, "Error writing output file: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Successfully generated themes_generated.go\n") +} diff --git a/pkg/services/preference/prefapi/api.go b/pkg/services/preference/prefapi/api.go index 6bf8057ac68..15d5c22c4bc 100644 --- a/pkg/services/preference/prefapi/api.go +++ b/pkg/services/preference/prefapi/api.go @@ -20,6 +20,10 @@ func UpdatePreferencesFor(ctx context.Context, return response.Error(http.StatusBadRequest, "Invalid theme", nil) } + if !pref.IsValidTimezone(dtoCmd.Timezone) { + return response.Error(http.StatusBadRequest, "Invalid timezone. Must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string", nil) + } + // convert dashboard UID to ID in order to store internally if it exists in the query, otherwise take the id from query // nolint:staticcheck dashboardID := dtoCmd.HomeDashboardID diff --git a/pkg/services/preference/themes.go b/pkg/services/preference/themes.go index 73366b64d6d..e7164921ccd 100644 --- a/pkg/services/preference/themes.go +++ b/pkg/services/preference/themes.go @@ -1,3 +1,5 @@ +//go:generate go run generate_themes.go + package pref type ThemeDTO struct { @@ -6,24 +8,6 @@ type ThemeDTO struct { IsExtra bool `json:"isExtra"` } -var themes = []ThemeDTO{ - {ID: "light", Type: "light"}, - {ID: "dark", Type: "dark"}, - {ID: "system", Type: "dark"}, - {ID: "debug", Type: "dark", IsExtra: true}, - {ID: "aubergine", Type: "dark", IsExtra: true}, - {ID: "desertbloom", Type: "light", IsExtra: true}, - {ID: "gildedgrove", Type: "dark", IsExtra: true}, - {ID: "mars", Type: "dark", IsExtra: true}, - {ID: "matrix", Type: "dark", IsExtra: true}, - {ID: "sapphiredusk", Type: "dark", IsExtra: true}, - {ID: "synthwave", Type: "dark", IsExtra: true}, - {ID: "tron", Type: "dark", IsExtra: true}, - {ID: "victorian", Type: "dark", IsExtra: true}, - {ID: "zen", Type: "light", IsExtra: true}, - {ID: "gloom", Type: "dark", IsExtra: true}, -} - func GetThemeByID(id string) *ThemeDTO { for _, theme := range themes { if theme.ID == id { diff --git a/pkg/services/preference/themes_generated.go b/pkg/services/preference/themes_generated.go new file mode 100644 index 00000000000..ff09e0f4935 --- /dev/null +++ b/pkg/services/preference/themes_generated.go @@ -0,0 +1,21 @@ +// Code generated by go generate; DO NOT EDIT. + +package pref + +var themes = []ThemeDTO{ + {ID: "light", Type: "light"}, + {ID: "dark", Type: "dark"}, + {ID: "system", Type: "dark"}, + {ID: "aubergine", Type: "dark", IsExtra: true}, + {ID: "debug", Type: "dark", IsExtra: true}, + {ID: "desertbloom", Type: "light", IsExtra: true}, + {ID: "gildedgrove", Type: "dark", IsExtra: true}, + {ID: "gloom", Type: "dark", IsExtra: true}, + {ID: "mars", Type: "dark", IsExtra: true}, + {ID: "matrix", Type: "dark", IsExtra: true}, + {ID: "sapphiredusk", Type: "dark", IsExtra: true}, + {ID: "synthwave", Type: "dark", IsExtra: true}, + {ID: "tron", Type: "dark", IsExtra: true}, + {ID: "victorian", Type: "dark", IsExtra: true}, + {ID: "zen", Type: "light", IsExtra: true}, +} diff --git a/pkg/services/preference/timezone.go b/pkg/services/preference/timezone.go new file mode 100644 index 00000000000..e69e8eda591 --- /dev/null +++ b/pkg/services/preference/timezone.go @@ -0,0 +1,21 @@ +package pref + +import ( + "time" +) + +// IsValidTimezone checks if the timezone string is valid. +// It accepts: +// - "" - uses default +// - "utc" +// - "browser" +// - Any valid IANA timezone (e.g., "America/New_York", "Europe/London") +func IsValidTimezone(timezone string) bool { + if timezone == "" || timezone == "utc" || timezone == "browser" { + return true + } + + // try to load as IANA timezone + _, err := time.LoadLocation(timezone) + return err == nil +} diff --git a/pkg/services/preference/timezone_test.go b/pkg/services/preference/timezone_test.go new file mode 100644 index 00000000000..e9bfceb6203 --- /dev/null +++ b/pkg/services/preference/timezone_test.go @@ -0,0 +1,38 @@ +package pref + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsValidTimezone(t *testing.T) { + tests := []struct { + timezone string + valid bool + }{ + { + timezone: "utc", + valid: true, + }, + { + timezone: "browser", + valid: true, + }, + { + timezone: "Europe/London", + valid: true, + }, + { + timezone: "invalid", + valid: false, + }, + { + timezone: "", + valid: true, + }, + } + for _, test := range tests { + assert.Equal(t, test.valid, IsValidTimezone(test.timezone)) + } +} diff --git a/pkg/services/store/kind/dashboard/ds_lookup.go b/pkg/services/store/kind/dashboard/ds_lookup.go index 5d132d569be..7330e598409 100644 --- a/pkg/services/store/kind/dashboard/ds_lookup.go +++ b/pkg/services/store/kind/dashboard/ds_lookup.go @@ -100,6 +100,9 @@ func (d *DsLookup) ByRef(ref *DataSourceRef) *DataSourceRef { if ref == nil { return d.defaultDS } + if ref.UID == "default" && ref.Type == "" { + return d.defaultDS + } key := "" if ref.UID != "" { @@ -117,7 +120,13 @@ func (d *DsLookup) ByRef(ref *DataSourceRef) *DataSourceRef { return ds } - return d.byName[key] + ds, ok = d.byName[key] + if ok { + return ds + } + + // With nothing was found (or configured), use the original reference + return ref } func (d *DsLookup) ByType(dsType string) []DataSourceRef { diff --git a/pkg/services/store/kind/dashboard/testdata/panel-with-library-panel-field-info.json b/pkg/services/store/kind/dashboard/testdata/panel-with-library-panel-field-info.json index 1ffaecb605b..2a687ad8865 100644 --- a/pkg/services/store/kind/dashboard/testdata/panel-with-library-panel-field-info.json +++ b/pkg/services/store/kind/dashboard/testdata/panel-with-library-panel-field-info.json @@ -4,8 +4,8 @@ "tags": null, "datasource": [ { - "uid": "default.uid", - "type": "default.type" + "uid": "000000001", + "type": "graphite" } ], "panels": [ @@ -16,8 +16,8 @@ "libraryPanel": "dfkljg98345dkf", "datasource": [ { - "uid": "default.uid", - "type": "default.type" + "uid": "000000001", + "type": "graphite" } ] } diff --git a/pkg/services/store/kind/dashboard/types.go b/pkg/services/store/kind/dashboard/types.go index 51aa00a79fd..c1dea30abb7 100644 --- a/pkg/services/store/kind/dashboard/types.go +++ b/pkg/services/store/kind/dashboard/types.go @@ -1,5 +1,7 @@ package dashboard +import "iter" + type PanelSummaryInfo struct { ID int64 `json:"id"` Title string `json:"title"` @@ -30,3 +32,20 @@ type DashboardSummaryInfo struct { Refresh string `json:"refresh,omitempty"` ReadOnly bool `json:"readOnly,omitempty"` // editable = false } + +func (d *DashboardSummaryInfo) PanelIterator() iter.Seq[PanelSummaryInfo] { + return func(yield func(PanelSummaryInfo) bool) { + for _, p := range d.Panels { + if len(p.Collapsed) > 0 { + for _, c := range p.Collapsed { + if !yield(c) { // NOTE, rows can only be one level deep! + return + } + } + } + if !yield(p) { + return + } + } + } +} diff --git a/pkg/services/updatemanager/plugins.go b/pkg/services/updatemanager/plugins.go index 7ee11b261f9..815e9e67779 100644 --- a/pkg/services/updatemanager/plugins.go +++ b/pkg/services/updatemanager/plugins.go @@ -13,6 +13,8 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/open-feature/go-sdk/openfeature" "go.opentelemetry.io/otel/codes" @@ -47,6 +49,7 @@ type PluginsService struct { updateStrategy string features featuremgmt.FeatureToggles + cfg *setting.Cfg } func ProvidePluginsService(cfg *setting.Cfg, @@ -89,6 +92,7 @@ func ProvidePluginsService(cfg *setting.Cfg, features: features, updateChecker: updateChecker, updateStrategy: cfg.PluginUpdateStrategy, + cfg: cfg, }, nil } @@ -136,7 +140,7 @@ func (s *PluginsService) HasUpdate(ctx context.Context, pluginID string) (string // checkAndUpdate checks for updates and applies them if auto-update is enabled. func (s *PluginsService) checkAndUpdate(ctx context.Context) { s.instrumentedCheckForUpdates(ctx) - if openfeature.NewDefaultClient().Boolean(ctx, featuremgmt.FlagPluginsAutoUpdate, false, openfeature.TransactionContext(ctx)) { + if s.checkFlagPluginsAutoUpdate(ctx) { s.updateAll(ctx) } } @@ -218,6 +222,17 @@ func (s *PluginsService) checkForUpdates(ctx context.Context) error { return nil } +func (s *PluginsService) checkFlagPluginsAutoUpdate(ctx context.Context) bool { + ns := request.GetNamespaceMapper(s.cfg)(1) + ctx = identity.WithServiceIdentityForSingleNamespaceContext(ctx, ns) + flag, err := openfeature.NewDefaultClient().BooleanValueDetails(ctx, featuremgmt.FlagPluginsAutoUpdate, false, openfeature.TransactionContext(ctx)) + if err != nil { + s.log.Error("flag evaluation error", "flag", featuremgmt.FlagPluginsAutoUpdate, "error", err) + } + + return flag.Value +} + func (s *PluginsService) canUpdate(ctx context.Context, plugin pluginstore.Plugin, gcomVersion string) bool { if !s.updateChecker.IsUpdatable(ctx, plugin) { return false @@ -227,7 +242,7 @@ func (s *PluginsService) canUpdate(ctx context.Context, plugin pluginstore.Plugi return false } - if openfeature.NewDefaultClient().Boolean(ctx, featuremgmt.FlagPluginsAutoUpdate, false, openfeature.TransactionContext(ctx)) { + if s.checkFlagPluginsAutoUpdate(ctx) { return s.updateChecker.CanUpdate(plugin.ID, plugin.Info.Version, gcomVersion, s.updateStrategy == setting.PluginUpdateStrategyMinor) } diff --git a/pkg/services/updatemanager/plugins_test.go b/pkg/services/updatemanager/plugins_test.go index 75834c44c7d..93b3d54b321 100644 --- a/pkg/services/updatemanager/plugins_test.go +++ b/pkg/services/updatemanager/plugins_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/open-feature/go-sdk/openfeature" + "github.com/open-feature/go-sdk/openfeature/memprovider" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log" @@ -378,8 +379,10 @@ func setupOpenFeatureProvider(t *testing.T, flagValue bool) { err := featuremgmt.InitOpenFeature(featuremgmt.OpenFeatureConfig{ ProviderType: setting.StaticProviderType, - StaticFlags: map[string]bool{ - featuremgmt.FlagPluginsAutoUpdate: flagValue, + StaticFlags: map[string]memprovider.InMemoryFlag{ + featuremgmt.FlagPluginsAutoUpdate: { + Key: featuremgmt.FlagPluginsAutoUpdate, Variants: map[string]any{"": flagValue}, + }, }, }) require.NoError(t, err) diff --git a/pkg/services/user/model.go b/pkg/services/user/model.go index fc8d9589357..12b0ac6ed57 100644 --- a/pkg/services/user/model.go +++ b/pkg/services/user/model.go @@ -146,6 +146,7 @@ type UserSearchHitDTO struct { LastSeenAtAge string `json:"lastSeenAtAge"` AuthLabels []string `json:"authLabels"` AuthModule AuthModuleConversion `json:"-"` + Created time.Time `json:"created" xorm:"created"` } type GetUserProfileQuery struct { diff --git a/pkg/services/user/userimpl/store.go b/pkg/services/user/userimpl/store.go index a7773147373..64780c5c2eb 100644 --- a/pkg/services/user/userimpl/store.go +++ b/pkg/services/user/userimpl/store.go @@ -541,7 +541,7 @@ func (ss *sqlStore) Search(ctx context.Context, query *user.SearchUsersQuery) (* sess.Limit(query.Limit, offset) } - sess.Cols("u.id", "u.uid", "u.email", "u.name", "u.login", "u.is_admin", "u.is_disabled", "u.last_seen_at", "user_auth.auth_module", "u.is_provisioned") + sess.Cols("u.id", "u.uid", "u.email", "u.name", "u.login", "u.is_admin", "u.is_disabled", "u.last_seen_at", "user_auth.auth_module", "u.is_provisioned", "u.created") if len(query.SortOpts) > 0 { for i := range query.SortOpts { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index aafc9167ce4..1e26b9067ef 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -588,8 +588,10 @@ type Cfg struct { // Unified Storage UnifiedStorage map[string]UnifiedStorageConfig // DisableDataMigrations will disable resources data migration to unified storage at startup - DisableDataMigrations bool - MaxPageSizeBytes int + DisableDataMigrations bool + MaxPageSizeBytes int + // IndexPath the directory where index files are stored. + // Note: Bleve locks index files, so mounts cannot be shared between multiple instances. IndexPath string IndexWorkers int IndexRebuildWorkers int @@ -598,6 +600,7 @@ type Cfg struct { IndexRebuildInterval time.Duration IndexCacheTTL time.Duration IndexMinUpdateInterval time.Duration // Don't update index if it was updated less than this interval ago. + IndexScoringModel string // Note: Temporary config to switch the index scoring model and will be removed soon. MaxFileIndexAge time.Duration // Max age of file-based indexes. Index older than this will be rebuilt asynchronously. MinFileIndexBuildVersion string // Minimum version of Grafana that built the file-based index. If index was built with older Grafana, it will be rebuilt asynchronously. EnableSharding bool @@ -637,6 +640,8 @@ type UnifiedStorageConfig struct { // EnableMigration indicates whether migration is enabled for the resource. // If not set, will use the default from MigratedUnifiedResources. EnableMigration bool + // AutoMigrationThreshold is the threshold below which a resource is automatically migrated. + AutoMigrationThreshold int } type InstallPlugin struct { diff --git a/pkg/setting/setting_feature_toggles.go b/pkg/setting/setting_feature_toggles.go index e09b45e9edb..38bfd0269e1 100644 --- a/pkg/setting/setting_feature_toggles.go +++ b/pkg/setting/setting_feature_toggles.go @@ -1,13 +1,20 @@ package setting import ( + "encoding/json" + "math" "strconv" "gopkg.in/ini.v1" + "github.com/open-feature/go-sdk/openfeature/memprovider" + "github.com/grafana/grafana/pkg/util" ) +// DefaultVariantName a placeholder name for config-based Feature Flags +const DefaultVariantName = "default" + // Deprecated: should use `featuremgmt.FeatureToggles` func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error { section := iniFile.Section("feature_toggles") @@ -15,18 +22,27 @@ func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error { if err != nil { return err } + // TODO IsFeatureToggleEnabled has been deprecated for 2 years now, we should remove this function completely // nolint:staticcheck - cfg.IsFeatureToggleEnabled = func(key string) bool { return toggles[key] } + cfg.IsFeatureToggleEnabled = func(key string) bool { + toggle, ok := toggles[key] + if !ok { + return false + } + + value, ok := toggle.Variants[toggle.DefaultVariant].(bool) + return value && ok + } return nil } -func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]bool, error) { - featureToggles := make(map[string]bool, 10) +func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]memprovider.InMemoryFlag, error) { + featureToggles := make(map[string]memprovider.InMemoryFlag, 10) // parse the comma separated list in `enable`. featuresTogglesStr := valueAsString(featureTogglesSection, "enable", "") for _, feature := range util.SplitString(featuresTogglesStr) { - featureToggles[feature] = true + featureToggles[feature] = memprovider.InMemoryFlag{Key: feature, DefaultVariant: DefaultVariantName, Variants: map[string]any{DefaultVariantName: true}} } // read all other settings under [feature_toggles]. If a toggle is @@ -36,7 +52,7 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str continue } - b, err := strconv.ParseBool(v.Value()) + b, err := ParseFlag(v.Name(), v.Value()) if err != nil { return featureToggles, err } @@ -45,3 +61,57 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str } return featureToggles, nil } + +func ParseFlag(name, value string) (memprovider.InMemoryFlag, error) { + var structure map[string]any + + if integer, err := strconv.Atoi(value); err == nil { + return NewInMemoryFlag(name, integer), nil + } + if float, err := strconv.ParseFloat(value, 64); err == nil { + return NewInMemoryFlag(name, float), nil + } + if err := json.Unmarshal([]byte(value), &structure); err == nil { + return NewInMemoryFlag(name, structure), nil + } + if boolean, err := strconv.ParseBool(value); err == nil { + return NewInMemoryFlag(name, boolean), nil + } + + return NewInMemoryFlag(name, value), nil +} + +func NewInMemoryFlag(name string, value any) memprovider.InMemoryFlag { + return memprovider.InMemoryFlag{Key: name, DefaultVariant: DefaultVariantName, Variants: map[string]any{DefaultVariantName: value}} +} + +func AsStringMap(m map[string]memprovider.InMemoryFlag) map[string]string { + var res = map[string]string{} + for k, v := range m { + res[k] = serializeFlagValue(v) + } + return res +} + +func serializeFlagValue(flag memprovider.InMemoryFlag) string { + value := flag.Variants[flag.DefaultVariant] + + switch castedValue := value.(type) { + case bool: + return strconv.FormatBool(castedValue) + case int64: + return strconv.FormatInt(castedValue, 10) + case float64: + // handle cases with a single or no zeros after the decimal point + if math.Trunc(castedValue) == castedValue { + return strconv.FormatFloat(castedValue, 'f', 1, 64) + } + + return strconv.FormatFloat(castedValue, 'g', -1, 64) + case string: + return castedValue + default: + val, _ := json.Marshal(value) + return string(val) + } +} diff --git a/pkg/setting/setting_feature_toggles_test.go b/pkg/setting/setting_feature_toggles_test.go index b0c3730bcad..040a9ef7427 100644 --- a/pkg/setting/setting_feature_toggles_test.go +++ b/pkg/setting/setting_feature_toggles_test.go @@ -1,9 +1,11 @@ package setting import ( - "strconv" "testing" + "github.com/google/go-cmp/cmp" + "github.com/open-feature/go-sdk/openfeature/memprovider" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/ini.v1" ) @@ -12,17 +14,16 @@ func TestFeatureToggles(t *testing.T) { testCases := []struct { name string conf map[string]string - err error - expectedToggles map[string]bool + expectedToggles map[string]memprovider.InMemoryFlag }{ { name: "can parse feature toggles passed in the `enable` array", conf: map[string]string{ "enable": "feature1,feature2", }, - expectedToggles: map[string]bool{ - "feature1": true, - "feature2": true, + expectedToggles: map[string]memprovider.InMemoryFlag{ + "feature1": NewInMemoryFlag("feature1", true), + "feature2": NewInMemoryFlag("feature2", true), }, }, { @@ -31,10 +32,10 @@ func TestFeatureToggles(t *testing.T) { "enable": "feature1,feature2", "feature3": "true", }, - expectedToggles: map[string]bool{ - "feature1": true, - "feature2": true, - "feature3": true, + expectedToggles: map[string]memprovider.InMemoryFlag{ + "feature1": NewInMemoryFlag("feature1", true), + "feature2": NewInMemoryFlag("feature2", true), + "feature3": NewInMemoryFlag("feature3", true), }, }, { @@ -43,19 +44,26 @@ func TestFeatureToggles(t *testing.T) { "enable": "feature1,feature2", "feature2": "false", }, - expectedToggles: map[string]bool{ - "feature1": true, - "feature2": false, + expectedToggles: map[string]memprovider.InMemoryFlag{ + "feature1": NewInMemoryFlag("feature1", true), + "feature2": NewInMemoryFlag("feature2", false), }, }, { - name: "invalid boolean value should return syntax error", + name: "feature flags of different types are handled correctly", conf: map[string]string{ - "enable": "feature1,feature2", - "feature2": "invalid", + "feature1": "1", "feature2": "1.0", + "feature3": `{"foo":"bar"}`, "feature4": "bar", + "feature5": "t", "feature6": "T", + }, + expectedToggles: map[string]memprovider.InMemoryFlag{ + "feature1": NewInMemoryFlag("feature1", 1), + "feature2": NewInMemoryFlag("feature2", 1.0), + "feature3": NewInMemoryFlag("feature3", map[string]any{"foo": "bar"}), + "feature4": NewInMemoryFlag("feature4", "bar"), + "feature5": NewInMemoryFlag("feature5", true), + "feature6": NewInMemoryFlag("feature6", true), }, - expectedToggles: map[string]bool{}, - err: strconv.ErrSyntax, }, } @@ -69,12 +77,35 @@ func TestFeatureToggles(t *testing.T) { } featureToggles, err := ReadFeatureTogglesFromInitFile(toggles) - require.ErrorIs(t, err, tc.err) + require.NoError(t, err) - if err == nil { - for k, v := range featureToggles { - require.Equal(t, tc.expectedToggles[k], v, tc.name) - } + for k, v := range featureToggles { + toggle := tc.expectedToggles[k] + require.Equal(t, toggle, v, tc.name) + } + } +} + +func TestFlagValueSerialization(t *testing.T) { + testCases := []memprovider.InMemoryFlag{ + NewInMemoryFlag("int", 1), + NewInMemoryFlag("1.0f", 1.0), + NewInMemoryFlag("1.01f", 1.01), + NewInMemoryFlag("1.10f", 1.10), + NewInMemoryFlag("struct", map[string]any{"foo": "bar"}), + NewInMemoryFlag("string", "bar"), + NewInMemoryFlag("true", true), + NewInMemoryFlag("false", false), + } + + for _, tt := range testCases { + asStringMap := AsStringMap(map[string]memprovider.InMemoryFlag{tt.Key: tt}) + + deserialized, err := ParseFlag(tt.Key, asStringMap[tt.Key]) + assert.NoError(t, err) + + if diff := cmp.Diff(tt, deserialized); diff != "" { + t.Errorf("(-want, +got) = %v", diff) } } } diff --git a/pkg/setting/setting_secrets_manager.go b/pkg/setting/setting_secrets_manager.go index 5730d27a74f..ed7386813ef 100644 --- a/pkg/setting/setting_secrets_manager.go +++ b/pkg/setting/setting_secrets_manager.go @@ -36,6 +36,8 @@ type SecretsManagerSettings struct { // How long to wait for the process to clean up a secure value to complete. GCWorkerPerSecureValueCleanupTimeout time.Duration + // Whether to register the MT CRUD API + RegisterAPIServer bool // Whether to create the MT secrets management database RunSecretsDBMigrations bool // Whether to run the data key id migration. Requires that RunSecretsDBMigrations is also true. @@ -64,6 +66,7 @@ func (cfg *Cfg) readSecretsManagerSettings() { cfg.SecretsManagement.GCWorkerPollInterval = secretsMgmt.Key("gc_worker_poll_interval").MustDuration(1 * time.Minute) cfg.SecretsManagement.GCWorkerPerSecureValueCleanupTimeout = secretsMgmt.Key("gc_worker_per_request_timeout").MustDuration(5 * time.Second) + cfg.SecretsManagement.RegisterAPIServer = secretsMgmt.Key("register_api_server").MustBool(true) cfg.SecretsManagement.RunSecretsDBMigrations = secretsMgmt.Key("run_secrets_db_migrations").MustBool(true) cfg.SecretsManagement.RunDataKeyMigration = secretsMgmt.Key("run_data_key_migration").MustBool(true) diff --git a/pkg/setting/setting_secrets_manager_test.go b/pkg/setting/setting_secrets_manager_test.go index 34f88a481b5..c326c250821 100644 --- a/pkg/setting/setting_secrets_manager_test.go +++ b/pkg/setting/setting_secrets_manager_test.go @@ -171,6 +171,28 @@ domain = example.com assert.Empty(t, cfg.SecretsManagement.ConfiguredKMSProviders) }) + t.Run("should handle configuration with register_api_server disabled", func(t *testing.T) { + iniContent := ` +[secrets_manager] +register_api_server = false +` + cfg, err := NewCfgFromBytes([]byte(iniContent)) + require.NoError(t, err) + + assert.False(t, cfg.SecretsManagement.RegisterAPIServer) + }) + + t.Run("should handle configuration without register_api_server set", func(t *testing.T) { + iniContent := ` +[secrets_manager] +encryption_provider = aws_kms +` + cfg, err := NewCfgFromBytes([]byte(iniContent)) + require.NoError(t, err) + + assert.True(t, cfg.SecretsManagement.RegisterAPIServer) + }) + t.Run("should handle configuration with run_secrets_db_migrations disabled", func(t *testing.T) { iniContent := ` [secrets_manager] diff --git a/pkg/setting/setting_unified_storage.go b/pkg/setting/setting_unified_storage.go index 8086151c40a..b47e8879826 100644 --- a/pkg/setting/setting_unified_storage.go +++ b/pkg/setting/setting_unified_storage.go @@ -8,6 +8,10 @@ import ( "github.com/grafana/grafana/pkg/util/osutil" ) +// DefaultAutoMigrationThreshold is the default threshold for auto migration switching. +// If a resource has entries at or below this count, it will be migrated. +const DefaultAutoMigrationThreshold = 10 + const ( PlaylistResource = "playlists.playlist.grafana.app" FolderResource = "folders.folder.grafana.app" @@ -21,6 +25,13 @@ var MigratedUnifiedResources = map[string]bool{ DashboardResource: false, } +// AutoMigratedUnifiedResources maps resources that support auto-migration +// TODO: remove this before Grafana 13 GA: https://github.com/grafana/search-and-storage-team/issues/613 +var AutoMigratedUnifiedResources = map[string]bool{ + FolderResource: true, + DashboardResource: true, +} + // read storage configs from ini file. They look like: // [unified_storage..] // = @@ -59,6 +70,13 @@ func (cfg *Cfg) setUnifiedStorageConfig() { enableMigration = section.Key("enableMigration").MustBool(MigratedUnifiedResources[resourceName]) } + // parse autoMigrationThreshold from resource section + autoMigrationThreshold := 0 + autoMigrate := AutoMigratedUnifiedResources[resourceName] + if autoMigrate { + autoMigrationThreshold = section.Key("autoMigrationThreshold").MustInt(DefaultAutoMigrationThreshold) + } + storageConfig[resourceName] = UnifiedStorageConfig{ DualWriterMode: rest.DualWriterMode(dualWriterMode), DualWriterPeriodicDataSyncJobEnabled: dualWriterPeriodicDataSyncJobEnabled, @@ -66,6 +84,7 @@ func (cfg *Cfg) setUnifiedStorageConfig() { DataSyncerRecordsLimit: dataSyncerRecordsLimit, DataSyncerInterval: dataSyncerInterval, EnableMigration: enableMigration, + AutoMigrationThreshold: autoMigrationThreshold, } } cfg.UnifiedStorage = storageConfig @@ -73,13 +92,13 @@ func (cfg *Cfg) setUnifiedStorageConfig() { // Set indexer config for unified storage section := cfg.Raw.Section("unified_storage") cfg.DisableDataMigrations = section.Key("disable_data_migrations").MustBool(false) - if !cfg.DisableDataMigrations && cfg.getUnifiedStorageType() == "unified" { + if !cfg.DisableDataMigrations && cfg.UnifiedStorageType() == "unified" { // Helper log to find instances running migrations in the future cfg.Logger.Info("Unified migration configs enforced") cfg.enforceMigrationToUnifiedConfigs() } else { // Helper log to find instances disabling migration - cfg.Logger.Info("Unified migration configs enforcement disabled", "storage_type", cfg.getUnifiedStorageType(), "disable_data_migrations", cfg.DisableDataMigrations) + cfg.Logger.Info("Unified migration configs enforcement disabled", "storage_type", cfg.UnifiedStorageType(), "disable_data_migrations", cfg.DisableDataMigrations) } cfg.EnableSearch = section.Key("enable_search").MustBool(false) cfg.MaxPageSizeBytes = section.Key("max_page_size_bytes").MustInt(0) @@ -104,6 +123,10 @@ func (cfg *Cfg) setUnifiedStorageConfig() { cfg.IndexRebuildInterval = section.Key("index_rebuild_interval").MustDuration(24 * time.Hour) cfg.IndexCacheTTL = section.Key("index_cache_ttl").MustDuration(10 * time.Minute) cfg.IndexMinUpdateInterval = section.Key("index_min_update_interval").MustDuration(0) + cfg.IndexScoringModel = section.Key("index_scoring_model").MustString("") + if cfg.IndexScoringModel != "" { + cfg.Logger.Info("Index scoring model set", "model", cfg.IndexScoringModel) + } cfg.SprinklesApiServer = section.Key("sprinkles_api_server").String() cfg.SprinklesApiServerPageLimit = section.Key("sprinkles_api_server_page_limit").MustInt(10000) cfg.CACertPath = section.Key("ca_cert_path").String() @@ -147,14 +170,15 @@ func (cfg *Cfg) enforceMigrationToUnifiedConfigs() { DualWriterMode: 5, DualWriterMigrationDataSyncDisabled: true, EnableMigration: true, + AutoMigrationThreshold: resourceCfg.AutoMigrationThreshold, } } } -// getUnifiedStorageType returns the configured storage type without creating or mutating keys. +// UnifiedStorageType returns the configured storage type without creating or mutating keys. // Precedence: env > ini > default ("unified"). // Used to decide unified storage behavior early without side effects. -func (cfg *Cfg) getUnifiedStorageType() string { +func (cfg *Cfg) UnifiedStorageType() string { const ( grafanaAPIServerSectionName = "grafana-apiserver" storageTypeKeyName = "storage_type" @@ -168,3 +192,23 @@ func (cfg *Cfg) getUnifiedStorageType() string { } return defaultStorageType } + +// UnifiedStorageConfig returns the UnifiedStorageConfig for a resource. +func (cfg *Cfg) UnifiedStorageConfig(resource string) UnifiedStorageConfig { + if cfg.UnifiedStorage == nil { + return UnifiedStorageConfig{} + } + return cfg.UnifiedStorage[resource] +} + +// EnableMode5 enables migration and sets mode 5 for a resource. +func (cfg *Cfg) EnableMode5(resource string) { + if cfg.UnifiedStorage == nil { + cfg.UnifiedStorage = make(map[string]UnifiedStorageConfig) + } + config := cfg.UnifiedStorage[resource] + config.DualWriterMode = rest.Mode5 + config.DualWriterMigrationDataSyncDisabled = true + config.EnableMigration = true + cfg.UnifiedStorage[resource] = config +} diff --git a/pkg/setting/setting_unified_storage_test.go b/pkg/setting/setting_unified_storage_test.go index c112dc85962..58f0fe56a60 100644 --- a/pkg/setting/setting_unified_storage_test.go +++ b/pkg/setting/setting_unified_storage_test.go @@ -43,10 +43,16 @@ func TestCfg_setUnifiedStorageConfig(t *testing.T) { } assert.Equal(t, exists, true, migratedResource) + expectedThreshold := 0 + if AutoMigratedUnifiedResources[migratedResource] { + expectedThreshold = DefaultAutoMigrationThreshold + } + assert.Equal(t, UnifiedStorageConfig{ DualWriterMode: 5, DualWriterMigrationDataSyncDisabled: true, EnableMigration: isEnabled, + AutoMigrationThreshold: expectedThreshold, }, resourceCfg, migratedResource) } } @@ -71,6 +77,7 @@ func TestCfg_setUnifiedStorageConfig(t *testing.T) { DualWriterPeriodicDataSyncJobEnabled: true, DataSyncerRecordsLimit: 1001, DataSyncerInterval: time.Minute * 10, + AutoMigrationThreshold: 0, }) validateMigratedResources(false) diff --git a/pkg/storage/secret/metadata/decrypt_store.go b/pkg/storage/secret/metadata/decrypt_store.go index 0896ab89df4..b8c241ccf7f 100644 --- a/pkg/storage/secret/metadata/decrypt_store.go +++ b/pkg/storage/secret/metadata/decrypt_store.go @@ -127,6 +127,10 @@ func (s *decryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, // function call happens after this. sv, err := s.secureValueMetadataStorage.Read(ctx, namespace, name, contracts.ReadOpts{}) if err != nil { + if errors.Is(err, context.Canceled) { + return "", fmt.Errorf("operation canceled while reading secure value metadata storage: %v (%w)", err, context.Canceled) + } + return "", fmt.Errorf("failed to read secure value metadata storage: %v (%w)", err, contracts.ErrDecryptNotFound) } @@ -137,6 +141,10 @@ func (s *decryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, keeperConfig, err := s.keeperMetadataStorage.GetKeeperConfig(ctx, namespace.String(), sv.Status.Keeper, contracts.ReadOpts{}) if err != nil { + if errors.Is(err, context.Canceled) { + return "", fmt.Errorf("operation canceled while reading keeper config metadata storage: %v (%w)", err, context.Canceled) + } + return "", fmt.Errorf("failed to read keeper config metadata storage: %v (%w)", err, contracts.ErrDecryptFailed) } @@ -148,13 +156,22 @@ func (s *decryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, if sv.Spec.Ref != nil { exposedValue, err := keeper.RetrieveReference(ctx, keeperConfig, *sv.Spec.Ref) if err != nil { + if errors.Is(err, context.Canceled) { + return "", fmt.Errorf("operation canceled while exposing secret using reference: %v (%w)", err, context.Canceled) + } + return "", fmt.Errorf("failed to expose secret using reference: %v (%w)", err, contracts.ErrDecryptFailed) } + return exposedValue, nil } exposedValue, err := keeper.Expose(ctx, keeperConfig, namespace, name, sv.Status.Version) if err != nil { + if errors.Is(err, context.Canceled) { + return "", fmt.Errorf("operation canceled while exposing secret: %v (%w)", err, context.Canceled) + } + return "", fmt.Errorf("failed to expose secret: %v (%w)", err, contracts.ErrDecryptFailed) } diff --git a/pkg/storage/secret/metadata/decrypt_store_test.go b/pkg/storage/secret/metadata/decrypt_store_test.go index 36e49df0146..9d8b1b46e76 100644 --- a/pkg/storage/secret/metadata/decrypt_store_test.go +++ b/pkg/storage/secret/metadata/decrypt_store_test.go @@ -68,6 +68,40 @@ func TestIntegrationDecrypt(t *testing.T) { } }) + t.Run("when the context is cancelled, it returns an error", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + + svcIdentity := "svc" + + // Create auth context with proper permissions that match the decrypters + authCtx := createAuthContext(ctx, "default", []string{"secret.grafana.app/securevalues:decrypt"}, svcIdentity, types.TypeUser) + + // Setup service + sut := testutils.Setup(t) + + // Create a secure value + spec := secretv1beta1.SecureValueSpec{ + Description: "description", + Decrypters: []string{svcIdentity}, + Value: ptr.To(secretv1beta1.NewExposedSecureValue("value")), + } + sv := &secretv1beta1.SecureValue{Spec: spec} + sv.Name = "sv-test" + sv.Namespace = "default" + + _, err := sut.CreateSv(authCtx, testutils.CreateSvWithSv(sv)) + require.NoError(t, err) + + // Cancel immediately! + cancel() + + exposed, err := sut.DecryptStorage.Decrypt(authCtx, "default", "sv-test") + require.ErrorIs(t, err, context.Canceled) + require.Empty(t, exposed) + }) + t.Run("when happy path with valid auth and permissions, it returns decrypted value", func(t *testing.T) { t.Parallel() diff --git a/pkg/storage/secret/metadata/metrics/metrics.go b/pkg/storage/secret/metadata/metrics/metrics.go index 094a19fa30d..3dce608d7d7 100644 --- a/pkg/storage/secret/metadata/metrics/metrics.go +++ b/pkg/storage/secret/metadata/metrics/metrics.go @@ -1,6 +1,7 @@ package metrics import ( + "context" "errors" "sync" @@ -185,6 +186,8 @@ func DecryptResultLabel(err error) string { return "error_not_found" } else if errors.Is(err, contracts.ErrDecryptNotAuthorized) { return "error_unauthorized" + } else if errors.Is(err, context.Canceled) { + return "error_context_canceled" } return "error_generic_failure" diff --git a/pkg/storage/unified/README.md b/pkg/storage/unified/README.md index aef27df8e05..5aadb0ad2b0 100644 --- a/pkg/storage/unified/README.md +++ b/pkg/storage/unified/README.md @@ -236,9 +236,7 @@ kubernetesDashboards = true kubernetesFolders = true unifiedStorage = true unifiedStorageHistoryPruner = true -unifiedStorageSearch = true unifiedStorageSearchPermissionFiltering = false -unifiedStorageSearchSprinkles = false [unified_storage] enable_search = true @@ -316,9 +314,6 @@ To enable it, add the following to your `custom.ini` under the `[feature_toggles ; Used by the Grafana instance unifiedStorageSearchUI = true -; (optional) Allows you to sort dashboards by usage insights fields when using enterprise -; unifiedStorageSearchSprinkles = true - [unified_storage] ; Used by unified storage server enable_search = true @@ -935,7 +930,6 @@ Unified Search requires several feature flags to be enabled depending on the des | Feature Flag | Purpose | Stage | Required For | |--------------|---------|-------|--------------| | `unifiedStorageSearchUI` | Frontend search interface | Experimental | Grafana UI search | -| `unifiedStorageSearchSprinkles` | Usage insights integration | Experimental | Dashboard usage sorting (Enterprise) | | `unifiedStorageSearchDualReaderEnabled` | Shadow traffic to unified search | Experimental | Shadow traffic during migration | #### Unified Search Specific Configuration @@ -956,9 +950,6 @@ unifiedStorageSearchUI = true ; Enable shadow traffic during migration (optional) unifiedStorageSearchDualReaderEnabled = true -; Enable usage insights sorting (Enterprise only) -unifiedStorageSearchSprinkles = true - [unified_storage] ; Enable core search functionality (required) enable_search = true diff --git a/pkg/storage/unified/client.go b/pkg/storage/unified/client.go index 82336b3a5d1..e5c396907e3 100644 --- a/pkg/storage/unified/client.go +++ b/pkg/storage/unified/client.go @@ -271,7 +271,7 @@ func grpcConn(address string, metrics *clientMetrics, clientKeepaliveTime time.D retryCfg := retryConfig{ Max: 3, Backoff: time.Second, - BackoffJitter: 0.5, + BackoffJitter: 0.1, } unary = append(unary, unaryRetryInterceptor(retryCfg)) unary = append(unary, unaryRetryInstrument(metrics.requestRetries)) @@ -288,13 +288,15 @@ func grpcConn(address string, metrics *clientMetrics, clientKeepaliveTime time.D opts = append(opts, grpc.WithStatsHandler(otelgrpc.NewClientHandler())) opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials())) - // Use round_robin to balances requests more evenly over the available Storage server. + // Use round_robin to balance requests more evenly over the available Storage server. opts = append(opts, grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`)) // Disable looking up service config from TXT DNS records. // This reduces the number of requests made to the DNS servers. opts = append(opts, grpc.WithDisableServiceConfig()) + opts = append(opts, connectionBackoffOptions()) + if clientKeepaliveTime > 0 { opts = append(opts, grpc.WithKeepaliveParams(keepalive.ClientParameters{ Time: clientKeepaliveTime, diff --git a/pkg/storage/unified/client_retry.go b/pkg/storage/unified/client_retry.go index 47b899e4198..3df79807673 100644 --- a/pkg/storage/unified/client_retry.go +++ b/pkg/storage/unified/client_retry.go @@ -9,6 +9,7 @@ import ( "github.com/grpc-ecosystem/go-grpc-middleware/util/metautils" "github.com/prometheus/client_golang/prometheus" "google.golang.org/grpc" + "google.golang.org/grpc/backoff" "google.golang.org/grpc/codes" ) @@ -44,3 +45,17 @@ func unaryRetryInstrument(metric *prometheus.CounterVec) grpc.UnaryClientInterce return invoker(ctx, method, req, resp, cc, opts...) } } + +// connectionBackoffOptions configures connection backoff parameters for faster recovery from +// transient connection failures (e.g., during pod restarts). +func connectionBackoffOptions() grpc.DialOption { + return grpc.WithConnectParams(grpc.ConnectParams{ + Backoff: backoff.Config{ + BaseDelay: 100 * time.Millisecond, + Multiplier: 1.6, + Jitter: 0.2, + MaxDelay: 10 * time.Second, + }, + MinConnectTimeout: 5 * time.Second, + }) +} diff --git a/pkg/storage/unified/migrations/migrator_test.go b/pkg/storage/unified/migrations/migrator_test.go index 8552092d066..cfd909a9255 100644 --- a/pkg/storage/unified/migrations/migrator_test.go +++ b/pkg/storage/unified/migrations/migrator_test.go @@ -214,8 +214,18 @@ func runMigrationTestSuite(t *testing.T, testCases []resourceMigratorTestCase) { for _, state := range testStates { t.Run(state.tc.name(), func(t *testing.T) { - // Verify resources now exist in unified storage after migration - state.tc.verify(t, helper, true) + shouldExist := true + for _, gvr := range state.tc.resources() { + resourceKey := fmt.Sprintf("%s.%s", gvr.Resource, gvr.Group) + // Resources exist if they're either: + // 1. In MigratedUnifiedResources (enabled by default), OR + // 2. In AutoMigratedUnifiedResources (auto-migrated because count is below threshold) + if !setting.MigratedUnifiedResources[resourceKey] && !setting.AutoMigratedUnifiedResources[resourceKey] { + shouldExist = false + break + } + } + state.tc.verify(t, helper, shouldExist) }) } @@ -270,7 +280,7 @@ const ( var migrationIDsToDefault = map[string]bool{ playlistsID: true, - foldersAndDashboardsID: false, + foldersAndDashboardsID: true, // Auto-migrated when resource count is below threshold } func verifyRegisteredMigrations(t *testing.T, helper *apis.K8sTestHelper, onlyDefault bool, optOut bool) { diff --git a/pkg/storage/unified/migrations/resource_migration.go b/pkg/storage/unified/migrations/resource_migration.go index 5bbed9c5722..d551ae32bde 100644 --- a/pkg/storage/unified/migrations/resource_migration.go +++ b/pkg/storage/unified/migrations/resource_migration.go @@ -10,9 +10,11 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/util/xorm" + "github.com/grafana/grafana/pkg/util/xorm/core" "k8s.io/apimachinery/pkg/runtime/schema" ) @@ -31,6 +33,20 @@ type ResourceMigration struct { migrationID string validators []Validator // Optional: custom validation logic for this migration log log.Logger + cfg *setting.Cfg + autoMigrate bool // If true, auto-migrate resource if count is below threshold + hadErrors bool // Tracks if errors occurred during migration (used with ignoreErrors) +} + +// ResourceMigrationOption is a functional option for configuring ResourceMigration. +type ResourceMigrationOption func(*ResourceMigration) + +// WithAutoMigrate configures the migration to auto-migrate resource if count is below threshold. +func WithAutoMigrate(cfg *setting.Cfg) ResourceMigrationOption { + return func(m *ResourceMigration) { + m.cfg = cfg + m.autoMigrate = true + } } // NewResourceMigration creates a new migration for the specified resources. @@ -39,14 +55,24 @@ func NewResourceMigration( resources []schema.GroupResource, migrationID string, validators []Validator, + opts ...ResourceMigrationOption, ) *ResourceMigration { - return &ResourceMigration{ + m := &ResourceMigration{ migrator: migrator, resources: resources, migrationID: migrationID, validators: validators, log: log.New("storage.unified.resource_migration." + migrationID), } + for _, opt := range opts { + opt(m) + } + return m +} + +func (m *ResourceMigration) SkipMigrationLog() bool { + // Skip populating the log table if auto-migrate is enabled and errors occurred + return m.autoMigrate && m.hadErrors } var _ migrator.CodeMigration = (*ResourceMigration)(nil) @@ -57,7 +83,23 @@ func (m *ResourceMigration) SQL(_ migrator.Dialect) string { } // Exec implements migrator.CodeMigration interface. Executes the migration across all organizations. -func (m *ResourceMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) error { +func (m *ResourceMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) (err error) { + // Track any errors that occur during migration + defer func() { + if err != nil { + if m.autoMigrate { + m.log.Warn( + `[WARN] Resource migration failed and is currently skipped. +This migration will be enforced in the next major Grafana release, where failures will block startup or resource loading. + +This warning is intended to help you detect and report issues early. +Please investigate the failure and report it to the Grafana team so it can be addressed before the next major release.`, + "error", err) + } + m.hadErrors = true + } + }() + ctx := context.Background() orgs, err := m.getAllOrgs(sess) @@ -75,7 +117,8 @@ func (m *ResourceMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) erro if mg.Dialect.DriverName() == migrator.SQLite { // reuse transaction in SQLite to avoid "database is locked" errors - tx, err := sess.Tx() + var tx *core.Tx + tx, err = sess.Tx() if err != nil { m.log.Error("Failed to get transaction from session", "error", err) return fmt.Errorf("failed to get transaction: %w", err) @@ -85,12 +128,22 @@ func (m *ResourceMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) erro } for _, org := range orgs { - if err := m.migrateOrg(ctx, sess, org); err != nil { + if err = m.migrateOrg(ctx, sess, org); err != nil { return err } } + // Auto-enable mode 5 for resources after successful migration + // TODO: remove this before Grafana 13 GA: https://github.com/grafana/search-and-storage-team/issues/613 + if m.autoMigrate { + for _, gr := range m.resources { + m.log.Info("Auto-enabling mode 5 for resource", "resource", gr.Resource+"."+gr.Group) + m.cfg.EnableMode5(gr.Resource + "." + gr.Group) + } + } + m.log.Info("Migration completed successfully for all organizations", "org_count", len(orgs)) + return nil } diff --git a/pkg/storage/unified/migrations/resources.go b/pkg/storage/unified/migrations/resources.go index ea32bb54322..debbb270642 100644 --- a/pkg/storage/unified/migrations/resources.go +++ b/pkg/storage/unified/migrations/resources.go @@ -1,11 +1,13 @@ package migrations import ( + "context" "fmt" v1beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" playlists "github.com/grafana/grafana/apps/playlist/pkg/apis/playlist/v0alpha1" + "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" sqlstoremigrator "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/setting" @@ -14,69 +16,70 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" ) -type ResourceDefinition struct { - GroupResource schema.GroupResource - MigratorFunc string // Name of the method: "MigrateFolders", "MigrateDashboards", etc. +type resourceDefinition struct { + groupResource schema.GroupResource + migratorFunc string // Name of the method: "MigrateFolders", "MigrateDashboards", etc. } type migrationDefinition struct { name string + migrationID string // The ID stored in the migration log table (e.g., "playlists migration") resources []string - registerFunc func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) + registerFunc func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient, opts ...ResourceMigrationOption) } -var resourceRegistry = []ResourceDefinition{ +var resourceRegistry = []resourceDefinition{ { - GroupResource: schema.GroupResource{Group: folders.GROUP, Resource: folders.RESOURCE}, - MigratorFunc: "MigrateFolders", + groupResource: schema.GroupResource{Group: folders.GROUP, Resource: folders.RESOURCE}, + migratorFunc: "MigrateFolders", }, { - GroupResource: schema.GroupResource{Group: v1beta1.GROUP, Resource: v1beta1.LIBRARY_PANEL_RESOURCE}, - MigratorFunc: "MigrateLibraryPanels", + groupResource: schema.GroupResource{Group: v1beta1.GROUP, Resource: v1beta1.LIBRARY_PANEL_RESOURCE}, + migratorFunc: "MigrateLibraryPanels", }, { - GroupResource: schema.GroupResource{Group: v1beta1.GROUP, Resource: v1beta1.DASHBOARD_RESOURCE}, - MigratorFunc: "MigrateDashboards", + groupResource: schema.GroupResource{Group: v1beta1.GROUP, Resource: v1beta1.DASHBOARD_RESOURCE}, + migratorFunc: "MigrateDashboards", }, { - GroupResource: schema.GroupResource{Group: playlists.APIGroup, Resource: "playlists"}, - MigratorFunc: "MigratePlaylists", + groupResource: schema.GroupResource{Group: playlists.APIGroup, Resource: "playlists"}, + migratorFunc: "MigratePlaylists", }, } var migrationRegistry = []migrationDefinition{ { name: "playlists", + migrationID: "playlists migration", resources: []string{setting.PlaylistResource}, registerFunc: registerPlaylistMigration, }, { name: "folders and dashboards", + migrationID: "folders and dashboards migration", resources: []string{setting.FolderResource, setting.DashboardResource}, registerFunc: registerDashboardAndFolderMigration, }, } -func registerMigrations(cfg *setting.Cfg, mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) error { +func registerMigrations(ctx context.Context, + cfg *setting.Cfg, + mg *sqlstoremigrator.Migrator, + migrator UnifiedMigrator, + client resource.ResourceClient, + sqlStore db.DB, +) error { for _, migration := range migrationRegistry { - var ( - hasValue bool - allEnabled bool - ) - - for _, res := range migration.resources { - enabled := cfg.UnifiedStorage[res].EnableMigration - if !hasValue { - allEnabled = enabled - hasValue = true - continue - } - if enabled != allEnabled { - return fmt.Errorf("cannot migrate resources separately: %v migration must be either all enabled or all disabled", migration.resources) - } + if shouldAutoMigrate(ctx, migration, cfg, sqlStore) { + migration.registerFunc(mg, migrator, client, WithAutoMigrate(cfg)) + continue } - if !allEnabled { + enabled, err := isMigrationEnabled(migration, cfg) + if err != nil { + return err + } + if !enabled { logger.Info("Migration is disabled in config, skipping", "migration", migration.name) continue } @@ -85,10 +88,193 @@ func registerMigrations(cfg *setting.Cfg, mg *sqlstoremigrator.Migrator, migrato return nil } -func getResourceDefinition(group, resource string) *ResourceDefinition { +func registerDashboardAndFolderMigration(mg *sqlstoremigrator.Migrator, + migrator UnifiedMigrator, + client resource.ResourceClient, + opts ...ResourceMigrationOption, +) { + foldersDef := getResourceDefinition("folder.grafana.app", "folders") + dashboardsDef := getResourceDefinition("dashboard.grafana.app", "dashboards") + driverName := mg.Dialect.DriverName() + + folderCountValidator := NewCountValidator( + client, + foldersDef.groupResource, + "dashboard", + "org_id = ? and is_folder = true", + driverName, + ) + + dashboardCountValidator := NewCountValidator( + client, + dashboardsDef.groupResource, + "dashboard", + "org_id = ? and is_folder = false", + driverName, + ) + + folderTreeValidator := NewFolderTreeValidator(client, foldersDef.groupResource, driverName) + + dashboardsAndFolders := NewResourceMigration( + migrator, + []schema.GroupResource{foldersDef.groupResource, dashboardsDef.groupResource}, + "folders-dashboards", + []Validator{folderCountValidator, dashboardCountValidator, folderTreeValidator}, + opts..., + ) + mg.AddMigration("folders and dashboards migration", dashboardsAndFolders) +} + +func registerPlaylistMigration(mg *sqlstoremigrator.Migrator, + migrator UnifiedMigrator, + client resource.ResourceClient, + opts ...ResourceMigrationOption, +) { + playlistsDef := getResourceDefinition("playlist.grafana.app", "playlists") + driverName := mg.Dialect.DriverName() + + playlistCountValidator := NewCountValidator( + client, + playlistsDef.groupResource, + "playlist", + "org_id = ?", + driverName, + ) + + playlistsMigration := NewResourceMigration( + migrator, + []schema.GroupResource{playlistsDef.groupResource}, + "playlists", + []Validator{playlistCountValidator}, + opts..., + ) + mg.AddMigration("playlists migration", playlistsMigration) +} + +// TODO: remove this before Grafana 13 GA: https://github.com/grafana/search-and-storage-team/issues/613 +func shouldAutoMigrate(ctx context.Context, migration migrationDefinition, cfg *setting.Cfg, sqlStore db.DB) bool { + autoMigrate := false + + for _, res := range migration.resources { + config := cfg.UnifiedStorageConfig(res) + + if config.DualWriterMode == 5 { + return false + } + + if !setting.AutoMigratedUnifiedResources[res] { + continue + } + + if checkIfAlreadyMigrated(ctx, migration, sqlStore) { + for _, res := range migration.resources { + cfg.EnableMode5(res) + } + logger.Info("Auto-migration already completed, enabling mode 5 for resources", "migration", migration.name) + return true + } + + autoMigrate = true + threshold := int64(setting.DefaultAutoMigrationThreshold) + if config.AutoMigrationThreshold > 0 { + threshold = int64(config.AutoMigrationThreshold) + } + + count, err := countResource(ctx, sqlStore, res) + if err != nil { + logger.Warn("Failed to count resource for auto migration check", "resource", res, "error", err) + return false + } + + logger.Info("Resource count for auto migration check", "resource", res, "count", count, "threshold", threshold) + + if count > threshold { + return false + } + } + + if !autoMigrate { + return false + } + + logger.Info("Auto-migration enabled for migration", "migration", migration.name) + return true +} + +func checkIfAlreadyMigrated(ctx context.Context, migration migrationDefinition, sqlStore db.DB) bool { + if migration.migrationID == "" { + return false + } + + exists, err := migrationExists(ctx, sqlStore, migration.migrationID) + if err != nil { + logger.Warn("Failed to check if migration exists", "migration", migration.name, "error", err) + return false + } + + return exists +} + +func isMigrationEnabled(migration migrationDefinition, cfg *setting.Cfg) (bool, error) { + var ( + hasValue bool + allEnabled bool + ) + + for _, res := range migration.resources { + enabled := cfg.UnifiedStorage[res].EnableMigration + if !hasValue { + allEnabled = enabled + hasValue = true + continue + } + if enabled != allEnabled { + return false, fmt.Errorf("cannot migrate resources separately: %v migration must be either all enabled or all disabled", migration.resources) + } + } + + return allEnabled, nil +} + +// TODO: remove this before Grafana 13 GA: https://github.com/grafana/search-and-storage-team/issues/613 +func countResource(ctx context.Context, sqlStore db.DB, resourceName string) (int64, error) { + var count int64 + err := sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + switch resourceName { + case setting.DashboardResource: + var err error + count, err = sess.Table("dashboard").Where("is_folder = ?", false).Count() + return err + case setting.FolderResource: + var err error + count, err = sess.Table("dashboard").Where("is_folder = ?", true).Count() + return err + default: + return fmt.Errorf("unknown resource: %s", resourceName) + } + }) + return count, err +} + +const migrationLogTableName = "unifiedstorage_migration_log" + +func migrationExists(ctx context.Context, sqlStore db.DB, migrationID string) (bool, error) { + var count int64 + err := sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + var err error + count, err = sess.Table(migrationLogTableName).Where("migration_id = ?", migrationID).Count() + return err + }) + if err != nil { + return false, fmt.Errorf("failed to check migration existence: %w", err) + } + return count > 0, nil +} + +func getResourceDefinition(group, resource string) *resourceDefinition { for i := range resourceRegistry { r := &resourceRegistry[i] - if r.GroupResource.Group == group && r.GroupResource.Resource == resource { + if r.groupResource.Group == group && r.groupResource.Resource == resource { return r } } @@ -102,8 +288,8 @@ func buildResourceKey(group, resource, namespace string) *resourcepb.ResourceKey } return &resourcepb.ResourceKey{ Namespace: namespace, - Group: def.GroupResource.Group, - Resource: def.GroupResource.Resource, + Group: def.groupResource.Group, + Resource: def.groupResource.Resource, } } @@ -113,7 +299,7 @@ func getMigratorFunc(accessor legacy.MigrationDashboardAccessor, group, resource return nil } - switch def.MigratorFunc { + switch def.migratorFunc { case "MigrateFolders": return accessor.MigrateFolders case "MigrateLibraryPanels": @@ -130,7 +316,7 @@ func getMigratorFunc(accessor legacy.MigrationDashboardAccessor, group, resource func validateRegisteredResources() error { registeredMap := make(map[string]bool) for _, gr := range resourceRegistry { - key := fmt.Sprintf("%s.%s", gr.GroupResource.Resource, gr.GroupResource.Group) + key := fmt.Sprintf("%s.%s", gr.groupResource.Resource, gr.groupResource.Group) registeredMap[key] = true } diff --git a/pkg/storage/unified/migrations/resources_test.go b/pkg/storage/unified/migrations/resources_test.go index 5a519e9ad62..261cd49e244 100644 --- a/pkg/storage/unified/migrations/resources_test.go +++ b/pkg/storage/unified/migrations/resources_test.go @@ -1,12 +1,15 @@ package migrations import ( + "context" + "strings" "testing" sqlstoremigrator "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime/schema" ) // TestRegisterMigrations exercises registerMigrations with various EnableMigration configs using a table-driven test. @@ -14,20 +17,28 @@ func TestRegisterMigrations(t *testing.T) { origRegistry := migrationRegistry t.Cleanup(func() { migrationRegistry = origRegistry }) + // Use fake resource names that are NOT in setting.AutoMigratedUnifiedResources + // to avoid triggering the auto-migrate code path which requires a non-nil sqlStore. + const ( + fakePlaylistResource = "fake.playlists.resource" + fakeFolderResource = "fake.folders.resource" + fakeDashboardResource = "fake.dashboards.resource" + ) + // helper to build a fake registry with custom register funcs that bump counters makeFakeRegistry := func(migrationCalls map[string]int) []migrationDefinition { return []migrationDefinition{ { name: "playlists", - resources: []string{setting.PlaylistResource}, - registerFunc: func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) { + resources: []string{fakePlaylistResource}, + registerFunc: func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient, opts ...ResourceMigrationOption) { migrationCalls["playlists"]++ }, }, { name: "folders and dashboards", - resources: []string{setting.FolderResource, setting.DashboardResource}, - registerFunc: func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) { + resources: []string{fakeFolderResource, fakeDashboardResource}, + registerFunc: func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient, opts ...ResourceMigrationOption) { migrationCalls["folders and dashboards"]++ }, }, @@ -38,7 +49,9 @@ func TestRegisterMigrations(t *testing.T) { makeCfg := func(vals map[string]bool) *setting.Cfg { cfg := &setting.Cfg{UnifiedStorage: make(map[string]setting.UnifiedStorageConfig)} for k, v := range vals { - cfg.UnifiedStorage[k] = setting.UnifiedStorageConfig{EnableMigration: v} + cfg.UnifiedStorage[k] = setting.UnifiedStorageConfig{ + EnableMigration: v, + } } return cfg } @@ -71,13 +84,13 @@ func TestRegisterMigrations(t *testing.T) { migrationRegistry = makeFakeRegistry(migrationCalls) cfg := makeCfg(map[string]bool{ - setting.PlaylistResource: tt.enablePlaylist, - setting.FolderResource: tt.enableFolder, - setting.DashboardResource: tt.enableDashboard, + fakePlaylistResource: tt.enablePlaylist, + fakeFolderResource: tt.enableFolder, + fakeDashboardResource: tt.enableDashboard, }) // We pass nils for migrator dependencies because our fake registerFuncs don't use them - err := registerMigrations(cfg, nil, nil, nil) + err := registerMigrations(context.Background(), cfg, nil, nil, nil, nil) if tt.wantErr { require.Error(t, err, "expected error for mismatched enablement") @@ -90,3 +103,176 @@ func TestRegisterMigrations(t *testing.T) { }) } } + +// TestResourceMigration_AutoMigrateEnablesMode5 verifies the autoMigrate behavior: +// - When autoMigrate=true AND cfg is set AND storage type is "unified", mode 5 should be enabled +// - In all other cases, mode 5 should NOT be enabled +func TestResourceMigration_AutoMigrateEnablesMode5(t *testing.T) { + // Helper to create a cfg with unified storage type + makeUnifiedCfg := func() *setting.Cfg { + cfg := setting.NewCfg() + cfg.Raw.Section("grafana-apiserver").Key("storage_type").SetValue("unified") + cfg.UnifiedStorage = make(map[string]setting.UnifiedStorageConfig) + return cfg + } + + // Helper to create a cfg with legacy storage type + makeLegacyCfg := func() *setting.Cfg { + cfg := setting.NewCfg() + cfg.Raw.Section("grafana-apiserver").Key("storage_type").SetValue("legacy") + cfg.UnifiedStorage = make(map[string]setting.UnifiedStorageConfig) + return cfg + } + + tests := []struct { + name string + autoMigrate bool + cfg *setting.Cfg + resources []string + wantMode5Enabled bool + description string + }{ + { + name: "autoMigrate enabled with unified storage", + autoMigrate: true, + cfg: makeUnifiedCfg(), + resources: []string{setting.DashboardResource}, + wantMode5Enabled: true, + description: "Should enable mode 5 when autoMigrate=true and storage type is unified", + }, + { + name: "autoMigrate disabled with unified storage", + autoMigrate: false, + cfg: makeUnifiedCfg(), + resources: []string{setting.DashboardResource}, + wantMode5Enabled: false, + description: "Should NOT enable mode 5 when autoMigrate=false", + }, + { + name: "autoMigrate enabled with legacy storage", + autoMigrate: true, + cfg: makeLegacyCfg(), + resources: []string{setting.DashboardResource}, + wantMode5Enabled: false, + description: "Should NOT enable mode 5 when storage type is legacy", + }, + { + name: "autoMigrate enabled with nil cfg", + autoMigrate: true, + cfg: nil, + resources: []string{setting.DashboardResource}, + wantMode5Enabled: false, + description: "Should NOT enable mode 5 when cfg is nil", + }, + { + name: "autoMigrate enabled with multiple resources", + autoMigrate: true, + cfg: makeUnifiedCfg(), + resources: []string{setting.FolderResource, setting.DashboardResource}, + wantMode5Enabled: true, + description: "Should enable mode 5 for all resources when autoMigrate=true", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Build schema.GroupResource from resource strings + resources := make([]schema.GroupResource, 0, len(tt.resources)) + for _, r := range tt.resources { + parts := strings.SplitN(r, ".", 2) + resources = append(resources, schema.GroupResource{ + Resource: parts[0], + Group: parts[1], + }) + } + + // Create the migration with options + var opts []ResourceMigrationOption + if tt.autoMigrate { + opts = append(opts, WithAutoMigrate(tt.cfg)) + } + + m := NewResourceMigration(nil, resources, "test-auto-migrate", nil, opts...) + + // Simulate what happens at the end of a successful migration + // This is the logic from Exec() that we're testing + if m.autoMigrate && m.cfg != nil && m.cfg.UnifiedStorageType() == "unified" { + for _, gr := range m.resources { + m.cfg.EnableMode5(gr.Resource + "." + gr.Group) + } + } + + // Verify mode 5 was enabled (or not) for each resource + for _, resourceName := range tt.resources { + if tt.cfg == nil { + // If cfg is nil, we can't check - just verify we didn't panic + continue + } + config := tt.cfg.UnifiedStorageConfig(resourceName) + if tt.wantMode5Enabled { + require.Equal(t, 5, int(config.DualWriterMode), "%s: %s", tt.description, resourceName) + require.True(t, config.EnableMigration, "%s: EnableMigration should be true for %s", tt.description, resourceName) + require.True(t, config.DualWriterMigrationDataSyncDisabled, "%s: DualWriterMigrationDataSyncDisabled should be true for %s", tt.description, resourceName) + } else { + require.Equal(t, 0, int(config.DualWriterMode), "%s: mode should be 0 for %s", tt.description, resourceName) + } + } + }) + } +} + +// TestResourceMigration_SkipMigrationLog verifies the SkipMigrationLog behavior: +// - When ignoreErrors=true AND errors occurred (hadErrors=true), skip writing to migration log +// This allows the migration to be re-run on the next startup +// - In all other cases, write to migration log normally +// +// This is important for the folders/dashboards migration which uses WithIgnoreErrors() to handle +// partial failures gracefully while still allowing retry on next startup. +func TestResourceMigration_SkipMigrationLog(t *testing.T) { + tests := []struct { + name string + autoMigrate bool + hadErrors bool + want bool + description string + }{ + { + name: "normal migration success", + autoMigrate: false, + hadErrors: false, + want: false, + description: "Normal successful migration should write to log", + }, + { + name: "ignoreErrors migration success", + autoMigrate: true, + hadErrors: false, + want: false, + description: "Migration with ignoreErrors that succeeds should still write to log", + }, + { + name: "normal migration with errors", + autoMigrate: false, + hadErrors: true, + want: false, + description: "Migration that fails without ignoreErrors should write error to log", + }, + { + name: "ignoreErrors migration with errors - skip log", + autoMigrate: true, + hadErrors: true, + want: true, + description: "Migration with ignoreErrors that has errors should SKIP log to allow retry", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &ResourceMigration{ + autoMigrate: tt.autoMigrate, + hadErrors: tt.hadErrors, + } + require.Equal(t, tt.want, m.SkipMigrationLog(), tt.description) + }) + } +} diff --git a/pkg/storage/unified/migrations/service.go b/pkg/storage/unified/migrations/service.go index 4ac9cdad218..8dec7ed019e 100644 --- a/pkg/storage/unified/migrations/service.go +++ b/pkg/storage/unified/migrations/service.go @@ -14,7 +14,6 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel" - "k8s.io/apimachinery/pkg/runtime/schema" ) var tracer = otel.Tracer("github.com/grafana/grafana/pkg/storage/unified/migrations") @@ -54,6 +53,7 @@ func (p *UnifiedStorageMigrationServiceImpl) Run(ctx context.Context) error { logger.Info("Data migrations are disabled, skipping") return nil } + logger.Info("Running migrations for unified storage") metrics.MUnifiedStorageMigrationStatus.Set(3) return RegisterMigrations(ctx, p.migrator, p.cfg, p.sqlStore, p.client) @@ -79,7 +79,7 @@ func RegisterMigrations( return err } - if err := registerMigrations(cfg, mg, migrator, client); err != nil { + if err := registerMigrations(ctx, cfg, mg, migrator, client, sqlStore); err != nil { return err } @@ -92,65 +92,13 @@ func RegisterMigrations( db.SetMaxOpenConns(3) defer db.SetMaxOpenConns(maxOpenConns) } - if err := mg.RunMigrations(ctx, + err := mg.RunMigrations(ctx, sec.Key("migration_locking").MustBool(true), - sec.Key("locking_attempt_timeout_sec").MustInt()); err != nil { + sec.Key("locking_attempt_timeout_sec").MustInt()) + if err != nil { return fmt.Errorf("unified storage data migration failed: %w", err) } logger.Info("Unified storage migrations completed successfully") return nil } - -func registerDashboardAndFolderMigration(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) { - foldersDef := getResourceDefinition("folder.grafana.app", "folders") - dashboardsDef := getResourceDefinition("dashboard.grafana.app", "dashboards") - driverName := mg.Dialect.DriverName() - - folderCountValidator := NewCountValidator( - client, - foldersDef.GroupResource, - "dashboard", - "org_id = ? and is_folder = true", - driverName, - ) - - dashboardCountValidator := NewCountValidator( - client, - dashboardsDef.GroupResource, - "dashboard", - "org_id = ? and is_folder = false", - driverName, - ) - - folderTreeValidator := NewFolderTreeValidator(client, foldersDef.GroupResource, driverName) - - dashboardsAndFolders := NewResourceMigration( - migrator, - []schema.GroupResource{foldersDef.GroupResource, dashboardsDef.GroupResource}, - "folders-dashboards", - []Validator{folderCountValidator, dashboardCountValidator, folderTreeValidator}, - ) - mg.AddMigration("folders and dashboards migration", dashboardsAndFolders) -} - -func registerPlaylistMigration(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) { - playlistsDef := getResourceDefinition("playlist.grafana.app", "playlists") - driverName := mg.Dialect.DriverName() - - playlistCountValidator := NewCountValidator( - client, - playlistsDef.GroupResource, - "playlist", - "org_id = ?", - driverName, - ) - - playlistsMigration := NewResourceMigration( - migrator, - []schema.GroupResource{playlistsDef.GroupResource}, - "playlists", - []Validator{playlistCountValidator}, - ) - mg.AddMigration("playlists migration", playlistsMigration) -} diff --git a/pkg/storage/unified/migrations/threshold/migrator_threshold_test.go b/pkg/storage/unified/migrations/threshold/migrator_threshold_test.go new file mode 100644 index 00000000000..4a542b3ae0b --- /dev/null +++ b/pkg/storage/unified/migrations/threshold/migrator_threshold_test.go @@ -0,0 +1,211 @@ +package threshold + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" + + authlib "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" + "github.com/grafana/grafana/pkg/util/testutil" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// TODO: remove this test before Grafana 13 GA +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +// TestIntegrationAutoMigrateThresholdExceeded verifies that auto-migration is skipped when +// resource count exceeds the configured threshold. +// TODO: remove this test before Grafana 13 GA +func TestIntegrationAutoMigrateThresholdExceeded(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + if db.IsTestDbSQLite() { + // Share the same SQLite DB file between steps + tmpDir := t.TempDir() + dbPath := tmpDir + "/shared-threshold-test.db" + + oldVal := os.Getenv("SQLITE_TEST_DB") + require.NoError(t, os.Setenv("SQLITE_TEST_DB", dbPath)) + t.Cleanup(func() { + if oldVal == "" { + _ = os.Unsetenv("SQLITE_TEST_DB") + } else { + _ = os.Setenv("SQLITE_TEST_DB", oldVal) + } + }) + t.Logf("Using shared database path: %s", dbPath) + } + + var org1 *apis.OrgUsers + var orgB *apis.OrgUsers + + dashboardGVR := schema.GroupVersionResource{ + Group: "dashboard.grafana.app", + Version: "v1beta1", + Resource: "dashboards", + } + folderGVR := schema.GroupVersionResource{ + Group: "folder.grafana.app", + Version: "v1beta1", + Resource: "folders", + } + + dashboardKey := fmt.Sprintf("%s.%s", dashboardGVR.Resource, dashboardGVR.Group) + folderKey := fmt.Sprintf("%s.%s", folderGVR.Resource, folderGVR.Group) + playlistKey := "playlists.playlist.grafana.app" + + // Step 1: Create resources exceeding the threshold (3 resources, threshold=1) + t.Run("Step 1: Create resources exceeding threshold", func(t *testing.T) { + unifiedConfig := map[string]setting.UnifiedStorageConfig{} + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + DisableDataMigrations: true, + DisableDBCleanup: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: unifiedConfig, + }) + org1 = &helper.Org1 + orgB = &helper.OrgB + + // Create 3 dashboards + for i := 1; i <= 3; i++ { + createTestDashboard(t, helper, fmt.Sprintf("Threshold Dashboard %d", i)) + } + + // Create 3 folders + for i := 1; i <= 3; i++ { + createTestFolder(t, helper, fmt.Sprintf("folder-%d", i), fmt.Sprintf("Threshold Folder %d", i), "") + } + + // Explicitly shutdown helper before Step 1 ends to ensure database is properly closed + helper.Shutdown() + }) + + // Set SKIP_DB_TRUNCATE to prevent truncation in subsequent steps + oldSkipTruncate := os.Getenv("SKIP_DB_TRUNCATE") + require.NoError(t, os.Setenv("SKIP_DB_TRUNCATE", "true")) + t.Cleanup(func() { + if oldSkipTruncate == "" { + _ = os.Unsetenv("SKIP_DB_TRUNCATE") + } else { + _ = os.Setenv("SKIP_DB_TRUNCATE", oldSkipTruncate) + } + }) + + // Step 2: Verify auto-migration is skipped due to threshold + t.Run("Step 2: Verify auto-migration skipped (threshold exceeded)", func(t *testing.T) { + // Set threshold=1, but we have 3 resources of each type, so migration should be skipped + // Disable playlists migration since we're only testing dashboard/folder threshold behavior + unifiedConfig := map[string]setting.UnifiedStorageConfig{ + dashboardKey: {AutoMigrationThreshold: 1, EnableMigration: false}, + folderKey: {AutoMigrationThreshold: 1, EnableMigration: false}, + playlistKey: {EnableMigration: false}, + } + helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{ + GrafanaOpts: testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + DisableDataMigrations: false, // Allow migration system to run + APIServerStorageType: "unified", + UnifiedStorageConfig: unifiedConfig, + }, + Org1Users: org1, + OrgBUsers: orgB, + }) + t.Cleanup(helper.Shutdown) + + namespace := authlib.OrgNamespaceFormatter(helper.Org1.OrgID) + + dashCli := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: namespace, + GVR: dashboardGVR, + }) + verifyResourceCount(t, dashCli, 3) + + folderCli := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: namespace, + GVR: folderGVR, + }) + verifyResourceCount(t, folderCli, 3) + + // Verify migration did NOT run by checking the migration log + count, err := helper.GetEnv().SQLStore.GetEngine().Table("unifiedstorage_migration_log"). + Where("migration_id = ?", "folders and dashboards migration"). + Count() + require.NoError(t, err) + require.Equal(t, int64(0), count, "Migration should not have run") + }) +} + +func createTestDashboard(t *testing.T, helper *apis.K8sTestHelper, title string) string { + t.Helper() + + payload := fmt.Sprintf(`{"dashboard": {"title": "%s", "panels": []}, "overwrite": false}`, title) + + result := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: "POST", + Path: "/api/dashboards/db", + Body: []byte(payload), + }, &map[string]interface{}{}) + + require.NotNil(t, result.Response) + require.Equal(t, 200, result.Response.StatusCode) + + uid := (*result.Result)["uid"].(string) + require.NotEmpty(t, uid) + return uid +} + +func createTestFolder(t *testing.T, helper *apis.K8sTestHelper, uid, title, parentUID string) *folder.Folder { + t.Helper() + + payload := fmt.Sprintf(`{ + "title": "%s", + "uid": "%s"`, title, uid) + + if parentUID != "" { + payload += fmt.Sprintf(`, + "parentUid": "%s"`, parentUID) + } + + payload += "}" + + folderCreate := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: http.MethodPost, + Path: "/api/folders", + Body: []byte(payload), + }, &folder.Folder{}) + + require.NotNil(t, folderCreate.Result) + return folderCreate.Result +} + +// verifyResourceCount verifies that the expected number of resources exist in K8s storage +func verifyResourceCount(t *testing.T, client *apis.K8sResourceClient, expectedCount int) { + t.Helper() + + l, err := client.Resource.List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + + resources, err := meta.ExtractList(l) + require.NoError(t, err) + require.Equal(t, expectedCount, len(resources)) +} diff --git a/pkg/storage/unified/proto/search.proto b/pkg/storage/unified/proto/search.proto index 5018c97c9db..62c6afb323a 100644 --- a/pkg/storage/unified/proto/search.proto +++ b/pkg/storage/unified/proto/search.proto @@ -9,11 +9,13 @@ import "resource.proto"; // Unlike the ResourceStore, this service can be exposed to clients directly // It should be implemented with efficient indexes and does not need read-after-write semantics service ResourceIndex { + // Query for documents rpc Search(ResourceSearchRequest) returns (ResourceSearchResponse); // Get the resource stats rpc GetStats(ResourceStatsRequest) returns (ResourceStatsResponse); + // Rebuild the search index rpc RebuildIndexes(RebuildIndexesRequest) returns (RebuildIndexesResponse); } @@ -49,6 +51,20 @@ message ResourceStatsResponse { repeated Stats stats = 2; } +// This controls what query and analyzers are applied to the specified field +// See: https://blevesearch.com/docs/Analyzers/ +enum QueryFieldType { + // Picks a reasonable analyzer given the input. Currently this always uses TEXT + // In the future, it may change to depend on the indexed field type + DEFAULT = 0; + // Use free text analyzer. The query is broken into a normalized set of tokens + TEXT = 1; + // The query must exactly match the indexed token + KEYWORD = 2; + // Like a text query, but the position and offsets influence the score + PHRASE = 3; +} + // Search within a single resource message ResourceSearchRequest { message Sort { @@ -64,6 +80,18 @@ message ResourceSearchRequest { // date queries } + // Defines the field in the index to query + // Boost is optional, and allows weighting the field higher in the results + message QueryField { + // The field name in the index to query + string name = 1; + + QueryFieldType type = 2; + + // Boost value for this field + float boost = 3; + } + // The key must include namespace + group + resource ListOptions options = 1; @@ -99,6 +127,9 @@ message ResourceSearchRequest { int64 page = 11; int64 permission = 12; + + // Optionally specify which fields are included in the query + repeated QueryField query_fields = 13; } message ResourceSearchResponse { diff --git a/pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql b/pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql index 8372eb73463..72026e26362 100644 --- a/pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql +++ b/pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql @@ -12,7 +12,7 @@ INSERT INTO {{ .Ident .TableName }} VALUES ( {{ .Arg .GUID }}, {{ .Arg .KeyPath }}, - COALESCE({{ .Arg .Value }}, ""), + {{ .Arg .Value }}, {{ .Arg .Group }}, {{ .Arg .Resource }}, {{ .Arg .Namespace }}, diff --git a/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql index 437d3ae9107..77680d190ff 100644 --- a/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql +++ b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql @@ -10,7 +10,7 @@ INSERT INTO {{ .Ident "resource_history" }} {{ .Ident "folder" }} ) VALUES ( - COALESCE({{ .Arg .Value }}, ""), + {{ .Arg .Value }}, {{ .Arg .GUID }}, {{ .Arg .Group }}, {{ .Arg .Resource }}, diff --git a/pkg/storage/unified/resource/data/sqlkv_save_event.sql b/pkg/storage/unified/resource/data/sqlkv_save_event.sql index 669497dbb19..0091c6e1347 100644 --- a/pkg/storage/unified/resource/data/sqlkv_save_event.sql +++ b/pkg/storage/unified/resource/data/sqlkv_save_event.sql @@ -5,7 +5,7 @@ INSERT INTO {{ .Ident .TableName }} ) VALUES ( {{ .Arg .KeyPath }}, - COALESCE({{ .Arg .Value }}, "") + {{ .Arg .Value }} ) {{- if eq .DialectName "mysql" }} ON DUPLICATE KEY UPDATE {{ .Ident "value" }} = {{ .Arg .Value }} diff --git a/pkg/storage/unified/resource/datastore.go b/pkg/storage/unified/resource/datastore.go index 313f7d43852..0cc56e51d72 100644 --- a/pkg/storage/unified/resource/datastore.go +++ b/pkg/storage/unified/resource/datastore.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/validation" "github.com/grafana/grafana/pkg/storage/unified/sql/db" "github.com/grafana/grafana/pkg/storage/unified/sql/dbutil" + "github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" gocache "github.com/patrickmn/go-cache" ) @@ -864,11 +865,23 @@ func (d *dataStore) applyBackwardsCompatibleChanges(ctx context.Context, tx db.T return nil } + generation := event.Object.GetGeneration() + if key.Action == DataActionDeleted { + generation = 0 + } + + // In compatibility mode, the previous RV, when available, is saved as a microsecond + // timestamp, as is done in the SQL backend. + previousRV := event.PreviousRV + if event.PreviousRV > 0 && isSnowflake(event.PreviousRV) { + previousRV = rvmanager.RVFromSnowflake(event.PreviousRV) + } + _, err := dbutil.Exec(ctx, tx, sqlKVUpdateLegacyResourceHistory, sqlKVLegacyUpdateHistoryRequest{ SQLTemplate: sqltemplate.New(kv.dialect), GUID: key.GUID, - PreviousRV: event.PreviousRV, - Generation: event.Object.GetGeneration(), + PreviousRV: previousRV, + Generation: generation, }) if err != nil { @@ -896,7 +909,7 @@ func (d *dataStore) applyBackwardsCompatibleChanges(ctx context.Context, tx db.T Name: key.Name, Action: action, Folder: key.Folder, - PreviousRV: event.PreviousRV, + PreviousRV: previousRV, }) if err != nil { @@ -910,8 +923,9 @@ func (d *dataStore) applyBackwardsCompatibleChanges(ctx context.Context, tx db.T Resource: key.Resource, Namespace: key.Namespace, Name: key.Name, + Action: action, Folder: key.Folder, - PreviousRV: event.PreviousRV, + PreviousRV: previousRV, }) if err != nil { @@ -920,6 +934,7 @@ func (d *dataStore) applyBackwardsCompatibleChanges(ctx context.Context, tx db.T case DataActionDeleted: _, err := dbutil.Exec(ctx, tx, sqlKVDeleteLegacyResource, sqlKVLegacySaveRequest{ SQLTemplate: sqltemplate.New(kv.dialect), + Group: key.Group, Resource: key.Resource, Namespace: key.Namespace, Name: key.Name, @@ -932,3 +947,15 @@ func (d *dataStore) applyBackwardsCompatibleChanges(ctx context.Context, tx db.T return nil } + +// isSnowflake returns whether the argument passed is a snowflake ID (new) or a microsecond timestamp (old). +// We try to interpret the number as a microsecond timestamp first. If it represents a time in the past, +// it is considered a microsecond timestamp. Snowflake IDs are much larger integers and would lead +// to dates in the future if interpreted as a microsecond timestamp. +func isSnowflake(rv int64) bool { + ts := time.UnixMicro(rv) + oneHourFromNow := time.Now().Add(time.Hour) + isMicroSecRV := ts.Before(oneHourFromNow) + + return !isMicroSecRV +} diff --git a/pkg/storage/unified/resource/document.go b/pkg/storage/unified/resource/document.go index 4e528b96df0..6a41689c0da 100644 --- a/pkg/storage/unified/resource/document.go +++ b/pkg/storage/unified/resource/document.go @@ -290,7 +290,6 @@ const SEARCH_FIELD_NAMESPACE = "namespace" const SEARCH_FIELD_NAME = "name" const SEARCH_FIELD_RV = "rv" const SEARCH_FIELD_TITLE = "title" -const SEARCH_FIELD_TITLE_NGRAM = "title_ngram" const SEARCH_FIELD_TITLE_PHRASE = "title_phrase" // filtering/sorting on title by full phrase const SEARCH_FIELD_DESCRIPTION = "description" const SEARCH_FIELD_TAGS = "tags" diff --git a/pkg/storage/unified/resource/notifier.go b/pkg/storage/unified/resource/notifier.go index 5dd6a17ad29..12cefa5add5 100644 --- a/pkg/storage/unified/resource/notifier.go +++ b/pkg/storage/unified/resource/notifier.go @@ -19,13 +19,18 @@ const ( defaultBufferSize = 10000 ) -type notifier struct { +type notifier interface { + Watch(context.Context, watchOptions) <-chan Event +} + +type pollingNotifier struct { eventStore *eventStore log logging.Logger } type notifierOptions struct { - log logging.Logger + log logging.Logger + useChannelNotifier bool } type watchOptions struct { @@ -44,15 +49,26 @@ func defaultWatchOptions() watchOptions { } } -func newNotifier(eventStore *eventStore, opts notifierOptions) *notifier { +func newNotifier(eventStore *eventStore, opts notifierOptions) notifier { if opts.log == nil { opts.log = &logging.NoOpLogger{} } - return ¬ifier{eventStore: eventStore, log: opts.log} + + if opts.useChannelNotifier { + return &channelNotifier{} + } + + return &pollingNotifier{eventStore: eventStore, log: opts.log} +} + +type channelNotifier struct{} + +func (cn *channelNotifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { + return nil } // Return the last resource version from the event store -func (n *notifier) lastEventResourceVersion(ctx context.Context) (int64, error) { +func (n *pollingNotifier) lastEventResourceVersion(ctx context.Context) (int64, error) { e, err := n.eventStore.LastEventKey(ctx) if err != nil { return 0, err @@ -60,11 +76,11 @@ func (n *notifier) lastEventResourceVersion(ctx context.Context) (int64, error) return e.ResourceVersion, nil } -func (n *notifier) cacheKey(evt Event) string { +func (n *pollingNotifier) cacheKey(evt Event) string { return fmt.Sprintf("%s~%s~%s~%s~%d", evt.Namespace, evt.Group, evt.Resource, evt.Name, evt.ResourceVersion) } -func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { +func (n *pollingNotifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { if opts.MinBackoff <= 0 { opts.MinBackoff = defaultMinBackoff } @@ -78,13 +94,13 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { cache := gocache.New(cacheTTL, cacheCleanupInterval) events := make(chan Event, opts.BufferSize) - initialRV, err := n.lastEventResourceVersion(ctx) + lastRV, err := n.lastEventResourceVersion(ctx) if errors.Is(err, ErrNotFound) { - initialRV = snowflakeFromTime(time.Now()) // No events yet, start from the beginning + lastRV = 0 // No events yet, start from the beginning } else if err != nil { n.log.Error("Failed to get last event resource version", "error", err) } - lastRV := initialRV + 1 // We want to start watching from the next event + lastRV = lastRV + 1 // We want to start watching from the next event go func() { defer close(events) @@ -110,7 +126,7 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { } // Skip old events lower than the requested resource version - if evt.ResourceVersion <= initialRV { + if evt.ResourceVersion < lastRV { continue } diff --git a/pkg/storage/unified/resource/notifier_test.go b/pkg/storage/unified/resource/notifier_test.go index 060f8eecfbe..5e1c0dacea8 100644 --- a/pkg/storage/unified/resource/notifier_test.go +++ b/pkg/storage/unified/resource/notifier_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/require" ) -func setupTestNotifier(t *testing.T) (*notifier, *eventStore) { +func setupTestNotifier(t *testing.T) (*pollingNotifier, *eventStore) { db := setupTestBadgerDB(t) t.Cleanup(func() { err := db.Close() @@ -22,11 +22,10 @@ func setupTestNotifier(t *testing.T) (*notifier, *eventStore) { kv := NewBadgerKV(db) eventStore := newEventStore(kv) notifier := newNotifier(eventStore, notifierOptions{log: &logging.NoOpLogger{}}) - return notifier, eventStore + return notifier.(*pollingNotifier), eventStore } -// nolint:unused -func setupTestNotifierSqlKv(t *testing.T) (*notifier, *eventStore) { +func setupTestNotifierSqlKv(t *testing.T) (*pollingNotifier, *eventStore) { dbstore := db.InitTestDB(t) eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) require.NoError(t, err) @@ -34,7 +33,7 @@ func setupTestNotifierSqlKv(t *testing.T) (*notifier, *eventStore) { require.NoError(t, err) eventStore := newEventStore(kv) notifier := newNotifier(eventStore, notifierOptions{log: &logging.NoOpLogger{}}) - return notifier, eventStore + return notifier.(*pollingNotifier), eventStore } func TestNewNotifier(t *testing.T) { @@ -50,7 +49,7 @@ func TestDefaultWatchOptions(t *testing.T) { assert.Equal(t, defaultBufferSize, opts.BufferSize) } -func runNotifierTestWith(t *testing.T, storeName string, newStoreFn func(*testing.T) (*notifier, *eventStore), testFn func(*testing.T, context.Context, *notifier, *eventStore)) { +func runNotifierTestWith(t *testing.T, storeName string, newStoreFn func(*testing.T) (*pollingNotifier, *eventStore), testFn func(*testing.T, context.Context, *pollingNotifier, *eventStore)) { t.Run(storeName, func(t *testing.T) { ctx := context.Background() notifier, eventStore := newStoreFn(t) @@ -60,11 +59,10 @@ func runNotifierTestWith(t *testing.T, storeName string, newStoreFn func(*testin func TestNotifier_lastEventResourceVersion(t *testing.T) { runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierLastEventResourceVersion) - // enable this when sqlkv is ready - // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion) + runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion) } -func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { +func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) { // Test with no events rv, err := notifier.lastEventResourceVersion(ctx) assert.Error(t, err) @@ -112,11 +110,10 @@ func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, not func TestNotifier_cachekey(t *testing.T) { runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierCachekey) - // enable this when sqlkv is ready - // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey) + runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey) } -func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { +func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) { tests := []struct { name string event Event @@ -167,11 +164,10 @@ func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier, func TestNotifier_Watch_NoEvents(t *testing.T) { runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchNoEvents) - // enable this when sqlkv is ready - // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents) + runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents) } -func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { +func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) { ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) defer cancel() @@ -209,11 +205,10 @@ func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *noti func TestNotifier_Watch_WithExistingEvents(t *testing.T) { runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchWithExistingEvents) - // enable this when sqlkv is ready - // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents) + runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents) } -func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { +func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) { ctx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() @@ -284,11 +279,10 @@ func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, noti func TestNotifier_Watch_EventDeduplication(t *testing.T) { runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchEventDeduplication) - // enable this when sqlkv is ready - // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication) + runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication) } -func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { +func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) { ctx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() @@ -351,11 +345,10 @@ func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, noti func TestNotifier_Watch_ContextCancellation(t *testing.T) { runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchContextCancellation) - // enable this when sqlkv is ready - // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation) + runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation) } -func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { +func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) { ctx, cancel := context.WithCancel(ctx) // Add an initial event so that lastEventResourceVersion doesn't return ErrNotFound @@ -398,11 +391,10 @@ func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, not func TestNotifier_Watch_MultipleEvents(t *testing.T) { runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchMultipleEvents) - // enable this when sqlkv is ready - // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents) + runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents) } -func testNotifierWatchMultipleEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { +func testNotifierWatchMultipleEvents(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) { ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() rv := time.Now().UnixNano() @@ -464,33 +456,27 @@ func testNotifierWatchMultipleEvents(t *testing.T, ctx context.Context, notifier }, } + errCh := make(chan error) go func() { for _, event := range testEvents { - err := eventStore.Save(ctx, event) - require.NoError(t, err) + errCh <- eventStore.Save(ctx, event) } }() // Receive events - receivedEvents := make([]Event, 0, len(testEvents)) - for i := 0; i < len(testEvents); i++ { + receivedEvents := make([]string, 0, len(testEvents)) + for len(receivedEvents) != len(testEvents) { select { case event := <-events: - receivedEvents = append(receivedEvents, event) + receivedEvents = append(receivedEvents, event.Name) + case err := <-errCh: + require.NoError(t, err) case <-time.After(1 * time.Second): - t.Fatalf("Timed out waiting for event %d", i+1) + t.Fatalf("Timed out waiting for event %d", len(receivedEvents)+1) } } - // Verify all events were received - assert.Len(t, receivedEvents, len(testEvents)) - // Verify the events match and ordered by resource version - receivedNames := make([]string, len(receivedEvents)) - for i, event := range receivedEvents { - receivedNames[i] = event.Name - } - expectedNames := []string{"test-resource-1", "test-resource-2", "test-resource-3"} - assert.ElementsMatch(t, expectedNames, receivedNames) + assert.ElementsMatch(t, expectedNames, receivedEvents) } diff --git a/pkg/storage/unified/resource/search.go b/pkg/storage/unified/resource/search.go index bca5fb98491..ade86760502 100644 --- a/pkg/storage/unified/resource/search.go +++ b/pkg/storage/unified/resource/search.go @@ -863,7 +863,7 @@ func newRebuildRequest(key NamespacedResource, minBuildTime, lastImportTime time func (s *searchSupport) getOrCreateIndex(ctx context.Context, stats *SearchStats, 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?)") + return nil, fmt.Errorf("search is not configured properly (missing enable_search config?)") } ctx, span := tracer.Start(ctx, "resource.searchSupport.getOrCreateIndex") diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index 7c890e72b00..d722ce1ee9f 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -28,6 +28,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" secrets "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" + "github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager" "github.com/grafana/grafana/pkg/util/scheduler" ) @@ -815,7 +816,7 @@ func (s *server) update(ctx context.Context, user claims.AuthInfo, req *resource // TODO: once we know the client is always sending the RV, require ResourceVersion > 0 // See: https://github.com/grafana/grafana/pull/111866 - if req.ResourceVersion > 0 && latest.ResourceVersion != req.ResourceVersion { + if req.ResourceVersion > 0 && !rvmanager.IsRvEqual(latest.ResourceVersion, req.ResourceVersion) { return &resourcepb.UpdateResponse{ Error: &ErrOptimisticLockingFailed, }, nil @@ -883,7 +884,7 @@ func (s *server) delete(ctx context.Context, user claims.AuthInfo, req *resource rsp.Error = latest.Error return rsp, nil } - if req.ResourceVersion > 0 && latest.ResourceVersion != req.ResourceVersion { + if req.ResourceVersion > 0 && !rvmanager.IsRvEqual(latest.ResourceVersion, req.ResourceVersion) { rsp.Error = &ErrOptimisticLockingFailed return rsp, nil } diff --git a/pkg/storage/unified/resource/sqlkv.go b/pkg/storage/unified/resource/sqlkv.go index bae3c776d79..a0d47b69c35 100644 --- a/pkg/storage/unified/resource/sqlkv.go +++ b/pkg/storage/unified/resource/sqlkv.go @@ -349,6 +349,11 @@ func (w *sqlWriteCloser) Close() error { } w.closed = true + value := w.buf.Bytes() + if value == nil { + // to prevent NOT NULL constraint violations + value = []byte{} + } // do regular kv save: simple key_path + value insert with conflict check. // can only do this on resource_events for now, until we drop the columns in resource_history @@ -356,7 +361,7 @@ func (w *sqlWriteCloser) Close() error { _, err := dbutil.Exec(w.ctx, w.kv.db, sqlKVSaveEvent, sqlKVSaveRequest{ SQLTemplate: sqltemplate.New(w.kv.dialect), sqlKVSectionKey: w.sectionKey, - Value: w.buf.Bytes(), + Value: value, }) if err != nil { @@ -380,7 +385,7 @@ func (w *sqlWriteCloser) Close() error { SQLTemplate: sqltemplate.New(w.kv.dialect), sqlKVSectionKey: w.sectionKey, GUID: uuid.New().String(), - Value: w.buf.Bytes(), + Value: value, }) if err != nil { @@ -397,7 +402,7 @@ func (w *sqlWriteCloser) Close() error { _, err = dbutil.Exec(w.ctx, w.kv.db, sqlKVUpdateData, sqlKVSaveRequest{ SQLTemplate: sqltemplate.New(w.kv.dialect), sqlKVSectionKey: w.sectionKey, - Value: w.buf.Bytes(), + Value: value, }) if err != nil { @@ -432,8 +437,8 @@ func (w *sqlWriteCloser) Close() error { _, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResourceHistory, sqlKVSaveRequest{ SQLTemplate: sqltemplate.New(w.kv.dialect), - sqlKVSectionKey: w.sectionKey, - Value: w.buf.Bytes(), + sqlKVSectionKey: w.sectionKey, // unused: key_path is set by rvmanager + Value: value, GUID: dataKey.GUID, Group: dataKey.Group, Resource: dataKey.Resource, @@ -468,8 +473,6 @@ func (k *sqlKV) Delete(ctx context.Context, section string, key string) error { return ErrNotFound } - // TODO reflect change to resource table - return nil } diff --git a/pkg/storage/unified/resource/storage_backend.go b/pkg/storage/unified/resource/storage_backend.go index dffecbd789c..2708f90a1f8 100644 --- a/pkg/storage/unified/resource/storage_backend.go +++ b/pkg/storage/unified/resource/storage_backend.go @@ -61,7 +61,7 @@ type kvStorageBackend struct { bulkLock *BulkLock dataStore *dataStore eventStore *eventStore - notifier *notifier + notifier notifier builder DocumentBuilder log logging.Logger withPruner bool @@ -91,6 +91,7 @@ type KVBackendOptions struct { Tracer trace.Tracer // TODO add tracing Reg prometheus.Registerer // TODO add metrics + UseChannelNotifier bool // Adding RvManager overrides the RV generated with snowflake in order to keep backwards compatibility with // unified/sql RvManager *rvmanager.ResourceVersionManager @@ -121,7 +122,7 @@ func NewKVStorageBackend(opts KVBackendOptions) (KVBackend, error) { bulkLock: NewBulkLock(), dataStore: newDataStore(kv), eventStore: eventStore, - notifier: newNotifier(eventStore, notifierOptions{}), + notifier: newNotifier(eventStore, notifierOptions{useChannelNotifier: opts.UseChannelNotifier}), snowflake: s, builder: StandardDocumentBuilder(), // For now we use the standard document builder. log: &logging.NoOpLogger{}, // Make this configurable @@ -346,6 +347,7 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in return 0, fmt.Errorf("failed to write data: %w", err) } + rv = rvmanager.SnowflakeFromRV(rv) dataKey.ResourceVersion = rv } else { err := k.dataStore.Save(ctx, dataKey, bytes.NewReader(event.Value)) @@ -372,22 +374,14 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in } // Check if the RV we just wrote is the latest. If not, a concurrent write with higher RV happened - if !rvmanager.IsRvEqual(latestKey.ResourceVersion, rv) { + if latestKey.ResourceVersion != dataKey.ResourceVersion { // Delete the data we just wrote since it's not the latest - // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete - if k.rvManager != nil { - dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) - } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: concurrent modification detected") } if !rvmanager.IsRvEqual(prevKey.ResourceVersion, event.PreviousRV) { // Another concurrent write happened between our read and write - // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete - if k.rvManager != nil { - dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) - } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: resource was modified concurrently (expected previous RV %d, found %d)", event.PreviousRV, prevKey.ResourceVersion) } @@ -406,12 +400,8 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in } // Check if the RV we just wrote is the latest. If not, a concurrent create with higher RV happened - if !rvmanager.IsRvEqual(latestKey.ResourceVersion, rv) { + if latestKey.ResourceVersion != dataKey.ResourceVersion { // Delete the data we just wrote since it's not the latest - // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete - if k.rvManager != nil { - dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) - } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: concurrent create detected") } @@ -419,10 +409,6 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in // Verify that the immediate predecessor is not a create if prevKey.Action == DataActionCreated { // Another concurrent create happened - delete our write and return error - // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete - if k.rvManager != nil { - dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) - } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: concurrent create detected") } @@ -434,7 +420,7 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in Group: event.Key.Group, Resource: event.Key.Resource, Name: event.Key.Name, - ResourceVersion: rv, + ResourceVersion: dataKey.ResourceVersion, Action: action, Folder: obj.GetFolder(), PreviousRV: event.PreviousRV, @@ -703,9 +689,6 @@ func validateListHistoryRequest(req *resourcepb.ListRequest) error { if key.Namespace == "" { return fmt.Errorf("namespace is required") } - if key.Name == "" { - return fmt.Errorf("name is required") - } return nil } diff --git a/pkg/storage/unified/resourcepb/search.pb.go b/pkg/storage/unified/resourcepb/search.pb.go index 459e9aa3429..e523c112093 100644 --- a/pkg/storage/unified/resourcepb/search.pb.go +++ b/pkg/storage/unified/resourcepb/search.pb.go @@ -21,6 +21,65 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// This controls what query and analyzers are applied to the specified field +// See: https://blevesearch.com/docs/Analyzers/ +type QueryFieldType int32 + +const ( + // Picks a reasonable analyzer given the input. Currently this always uses TEXT + // In the future, it may change to depend on the indexed field type + QueryFieldType_DEFAULT QueryFieldType = 0 + // Use free text analyzer. The query is broken into a normalized set of tokens + QueryFieldType_TEXT QueryFieldType = 1 + // The query must exactly match the indexed token + QueryFieldType_KEYWORD QueryFieldType = 2 + // Like a text query, but the position and offsets influence the score + QueryFieldType_PHRASE QueryFieldType = 3 +) + +// Enum value maps for QueryFieldType. +var ( + QueryFieldType_name = map[int32]string{ + 0: "DEFAULT", + 1: "TEXT", + 2: "KEYWORD", + 3: "PHRASE", + } + QueryFieldType_value = map[string]int32{ + "DEFAULT": 0, + "TEXT": 1, + "KEYWORD": 2, + "PHRASE": 3, + } +) + +func (x QueryFieldType) Enum() *QueryFieldType { + p := new(QueryFieldType) + *p = x + return p +} + +func (x QueryFieldType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (QueryFieldType) Descriptor() protoreflect.EnumDescriptor { + return file_search_proto_enumTypes[0].Descriptor() +} + +func (QueryFieldType) Type() protoreflect.EnumType { + return &file_search_proto_enumTypes[0] +} + +func (x QueryFieldType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use QueryFieldType.Descriptor instead. +func (QueryFieldType) EnumDescriptor() ([]byte, []int) { + return file_search_proto_rawDescGZIP(), []int{0} +} + // Get statistics across multiple resources // For these queries, we do not need authorization to see the actual values type ResourceStatsRequest struct { @@ -165,10 +224,12 @@ type ResourceSearchRequest struct { // the return fields (empty will return everything) Fields []string `protobuf:"bytes,8,rep,name=fields,proto3" json:"fields,omitempty"` // explain each result (added to the each row) - Explain bool `protobuf:"varint,9,opt,name=explain,proto3" json:"explain,omitempty"` - IsDeleted bool `protobuf:"varint,10,opt,name=is_deleted,json=isDeleted,proto3" json:"is_deleted,omitempty"` - Page int64 `protobuf:"varint,11,opt,name=page,proto3" json:"page,omitempty"` - Permission int64 `protobuf:"varint,12,opt,name=permission,proto3" json:"permission,omitempty"` + Explain bool `protobuf:"varint,9,opt,name=explain,proto3" json:"explain,omitempty"` + IsDeleted bool `protobuf:"varint,10,opt,name=is_deleted,json=isDeleted,proto3" json:"is_deleted,omitempty"` + Page int64 `protobuf:"varint,11,opt,name=page,proto3" json:"page,omitempty"` + Permission int64 `protobuf:"varint,12,opt,name=permission,proto3" json:"permission,omitempty"` + // Optionally specify which fields are included in the query + QueryFields []*ResourceSearchRequest_QueryField `protobuf:"bytes,13,rep,name=query_fields,json=queryFields,proto3" json:"query_fields,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -287,6 +348,13 @@ func (x *ResourceSearchRequest) GetPermission() int64 { return 0 } +func (x *ResourceSearchRequest) GetQueryFields() []*ResourceSearchRequest_QueryField { + if x != nil { + return x.QueryFields + } + return nil +} + type ResourceSearchResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Error details @@ -670,6 +738,70 @@ func (x *ResourceSearchRequest_Facet) GetLimit() int64 { return 0 } +// Defines the field in the index to query +// Boost is optional, and allows weighting the field higher in the results +type ResourceSearchRequest_QueryField struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The field name in the index to query + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Type QueryFieldType `protobuf:"varint,2,opt,name=type,proto3,enum=resource.QueryFieldType" json:"type,omitempty"` + // Boost value for this field + Boost float32 `protobuf:"fixed32,3,opt,name=boost,proto3" json:"boost,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResourceSearchRequest_QueryField) Reset() { + *x = ResourceSearchRequest_QueryField{} + mi := &file_search_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceSearchRequest_QueryField) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceSearchRequest_QueryField) ProtoMessage() {} + +func (x *ResourceSearchRequest_QueryField) ProtoReflect() protoreflect.Message { + mi := &file_search_proto_msgTypes[9] + 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 ResourceSearchRequest_QueryField.ProtoReflect.Descriptor instead. +func (*ResourceSearchRequest_QueryField) Descriptor() ([]byte, []int) { + return file_search_proto_rawDescGZIP(), []int{2, 2} +} + +func (x *ResourceSearchRequest_QueryField) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ResourceSearchRequest_QueryField) GetType() QueryFieldType { + if x != nil { + return x.Type + } + return QueryFieldType_DEFAULT +} + +func (x *ResourceSearchRequest_QueryField) GetBoost() float32 { + if x != nil { + return x.Boost + } + return 0 +} + type ResourceSearchResponse_Facet struct { state protoimpl.MessageState `protogen:"open.v1"` Field string `protobuf:"bytes,1,opt,name=field,proto3" json:"field,omitempty"` @@ -685,7 +817,7 @@ type ResourceSearchResponse_Facet struct { func (x *ResourceSearchResponse_Facet) Reset() { *x = ResourceSearchResponse_Facet{} - mi := &file_search_proto_msgTypes[10] + mi := &file_search_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -697,7 +829,7 @@ func (x *ResourceSearchResponse_Facet) String() string { func (*ResourceSearchResponse_Facet) ProtoMessage() {} func (x *ResourceSearchResponse_Facet) ProtoReflect() protoreflect.Message { - mi := &file_search_proto_msgTypes[10] + mi := &file_search_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -751,7 +883,7 @@ type ResourceSearchResponse_TermFacet struct { func (x *ResourceSearchResponse_TermFacet) Reset() { *x = ResourceSearchResponse_TermFacet{} - mi := &file_search_proto_msgTypes[11] + mi := &file_search_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -763,7 +895,7 @@ func (x *ResourceSearchResponse_TermFacet) String() string { func (*ResourceSearchResponse_TermFacet) ProtoMessage() {} func (x *ResourceSearchResponse_TermFacet) ProtoReflect() protoreflect.Message { - mi := &file_search_proto_msgTypes[11] + mi := &file_search_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -818,7 +950,7 @@ var file_search_proto_rawDesc = string([]byte{ 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, - 0x74, 0x22, 0x8e, 0x05, 0x0a, 0x15, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, + 0x74, 0x22, 0xc3, 0x06, 0x0a, 0x15, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2f, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, @@ -846,93 +978,109 @@ var file_search_proto_rawDesc = string([]byte{ 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x67, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x70, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, - 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0x30, 0x0a, 0x04, 0x53, 0x6f, - 0x72, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x65, 0x73, 0x63, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x64, 0x65, 0x73, 0x63, 0x1a, 0x33, 0x0a, 0x05, - 0x46, 0x61, 0x63, 0x65, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, - 0x69, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, - 0x74, 0x1a, 0x5f, 0x0a, 0x0a, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, - 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, - 0x79, 0x12, 0x3b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x25, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, + 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x4d, 0x0a, 0x0c, 0x71, 0x75, + 0x65, 0x72, 0x79, 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x2a, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, - 0x38, 0x01, 0x22, 0xea, 0x04, 0x0a, 0x16, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, - 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, - 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, - 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, - 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, - 0x6b, 0x65, 0x79, 0x12, 0x31, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x07, 0x72, - 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, - 0x68, 0x69, 0x74, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x6f, 0x74, 0x61, - 0x6c, 0x48, 0x69, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x71, 0x75, 0x65, 0x72, 0x79, 0x5f, 0x63, - 0x6f, 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x01, 0x52, 0x09, 0x71, 0x75, 0x65, 0x72, 0x79, - 0x43, 0x6f, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x61, 0x78, 0x5f, 0x73, 0x63, 0x6f, 0x72, - 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x01, 0x52, 0x08, 0x6d, 0x61, 0x78, 0x53, 0x63, 0x6f, 0x72, - 0x65, 0x12, 0x41, 0x0a, 0x05, 0x66, 0x61, 0x63, 0x65, 0x74, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x2b, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, + 0x74, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x52, 0x0b, 0x71, 0x75, + 0x65, 0x72, 0x79, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x1a, 0x30, 0x0a, 0x04, 0x53, 0x6f, 0x72, + 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x65, 0x73, 0x63, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x64, 0x65, 0x73, 0x63, 0x1a, 0x33, 0x0a, 0x05, 0x46, + 0x61, 0x63, 0x65, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, + 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, + 0x1a, 0x64, 0x0a, 0x0a, 0x51, 0x75, 0x65, 0x72, 0x79, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x12, + 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, + 0x6d, 0x65, 0x12, 0x2c, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, + 0x32, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x51, 0x75, 0x65, 0x72, + 0x79, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, + 0x12, 0x14, 0x0a, 0x05, 0x62, 0x6f, 0x6f, 0x73, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x02, 0x52, + 0x05, 0x62, 0x6f, 0x6f, 0x73, 0x74, 0x1a, 0x5f, 0x0a, 0x0a, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x3b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xea, 0x04, 0x0a, 0x16, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x05, 0x66, - 0x61, 0x63, 0x65, 0x74, 0x1a, 0x8f, 0x01, 0x0a, 0x05, 0x46, 0x61, 0x63, 0x65, 0x74, 0x12, 0x14, - 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, - 0x69, 0x65, 0x6c, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x05, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x69, - 0x73, 0x73, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x6d, 0x69, 0x73, - 0x73, 0x69, 0x6e, 0x67, 0x12, 0x40, 0x0a, 0x05, 0x74, 0x65, 0x72, 0x6d, 0x73, 0x18, 0x04, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x54, 0x65, 0x72, 0x6d, 0x46, 0x61, 0x63, 0x65, 0x74, 0x52, - 0x05, 0x74, 0x65, 0x72, 0x6d, 0x73, 0x1a, 0x35, 0x0a, 0x09, 0x54, 0x65, 0x72, 0x6d, 0x46, 0x61, - 0x63, 0x65, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x72, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x74, 0x65, 0x72, 0x6d, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x1a, 0x60, 0x0a, - 0x0a, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, - 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x3c, 0x0a, - 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x72, + 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, + 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, + 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x46, - 0x61, 0x63, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, - 0x60, 0x0a, 0x15, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, - 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, - 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, - 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x29, 0x0a, 0x04, 0x6b, 0x65, 0x79, 0x73, 0x18, 0x02, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x04, 0x6b, 0x65, 0x79, - 0x73, 0x22, 0x83, 0x01, 0x0a, 0x16, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x49, 0x6e, 0x64, - 0x65, 0x78, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x22, 0x0a, 0x0c, - 0x72, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x0c, 0x72, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, - 0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x07, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, - 0x72, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, - 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x32, 0xfe, 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x4b, 0x0a, 0x06, 0x53, 0x65, 0x61, - 0x72, 0x63, 0x68, 0x12, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, - 0x74, 0x73, 0x12, 0x1e, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x0e, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x49, 0x6e, - 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x2e, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x2e, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x3b, 0x5a, 0x39, 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, 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x61, - 0x67, 0x65, 0x2f, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x31, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, + 0x6c, 0x74, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, + 0x6c, 0x65, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x74, + 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x68, 0x69, 0x74, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x09, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x48, 0x69, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x71, 0x75, + 0x65, 0x72, 0x79, 0x5f, 0x63, 0x6f, 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x01, 0x52, 0x09, + 0x71, 0x75, 0x65, 0x72, 0x79, 0x43, 0x6f, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x61, 0x78, + 0x5f, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x01, 0x52, 0x08, 0x6d, 0x61, + 0x78, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x41, 0x0a, 0x05, 0x66, 0x61, 0x63, 0x65, 0x74, 0x18, + 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, 0x6e, 0x74, + 0x72, 0x79, 0x52, 0x05, 0x66, 0x61, 0x63, 0x65, 0x74, 0x1a, 0x8f, 0x01, 0x0a, 0x05, 0x46, 0x61, + 0x63, 0x65, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x74, + 0x61, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x12, + 0x18, 0x0a, 0x07, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x07, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6e, 0x67, 0x12, 0x40, 0x0a, 0x05, 0x74, 0x65, 0x72, + 0x6d, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, + 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x54, 0x65, 0x72, 0x6d, 0x46, + 0x61, 0x63, 0x65, 0x74, 0x52, 0x05, 0x74, 0x65, 0x72, 0x6d, 0x73, 0x1a, 0x35, 0x0a, 0x09, 0x54, + 0x65, 0x72, 0x6d, 0x46, 0x61, 0x63, 0x65, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x72, 0x6d, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x65, 0x72, 0x6d, 0x12, 0x14, 0x0a, 0x05, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x1a, 0x60, 0x0a, 0x0a, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, + 0x65, 0x79, 0x12, 0x3c, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x26, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x3a, 0x02, 0x38, 0x01, 0x22, 0x60, 0x0a, 0x15, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x49, + 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, + 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x29, 0x0a, 0x04, 0x6b, + 0x65, 0x79, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, + 0x52, 0x04, 0x6b, 0x65, 0x79, 0x73, 0x22, 0x83, 0x01, 0x0a, 0x16, 0x52, 0x65, 0x62, 0x75, 0x69, + 0x6c, 0x64, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x72, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f, 0x75, 0x6e, + 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x72, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, + 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, + 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, + 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, + 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x2a, 0x40, 0x0a, 0x0e, + 0x51, 0x75, 0x65, 0x72, 0x79, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, + 0x0a, 0x07, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x54, + 0x45, 0x58, 0x54, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x4b, 0x45, 0x59, 0x57, 0x4f, 0x52, 0x44, + 0x10, 0x02, 0x12, 0x0a, 0x0a, 0x06, 0x50, 0x48, 0x52, 0x41, 0x53, 0x45, 0x10, 0x03, 0x32, 0xfe, + 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x6e, 0x64, 0x65, 0x78, + 0x12, 0x4b, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x1f, 0x2e, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, + 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, + 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, + 0x08, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x1e, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, + 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, + 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x0e, 0x52, 0x65, + 0x62, 0x75, 0x69, 0x6c, 0x64, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x1f, 0x2e, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x49, + 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, + 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, + 0x3b, 0x5a, 0x39, 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, 0x70, 0x6b, + 0x67, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2f, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, + 0x64, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x33, }) var ( @@ -947,53 +1095,58 @@ func file_search_proto_rawDescGZIP() []byte { return file_search_proto_rawDescData } -var file_search_proto_msgTypes = make([]protoimpl.MessageInfo, 13) +var file_search_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_search_proto_msgTypes = make([]protoimpl.MessageInfo, 14) var file_search_proto_goTypes = []any{ - (*ResourceStatsRequest)(nil), // 0: resource.ResourceStatsRequest - (*ResourceStatsResponse)(nil), // 1: resource.ResourceStatsResponse - (*ResourceSearchRequest)(nil), // 2: resource.ResourceSearchRequest - (*ResourceSearchResponse)(nil), // 3: resource.ResourceSearchResponse - (*RebuildIndexesRequest)(nil), // 4: resource.RebuildIndexesRequest - (*RebuildIndexesResponse)(nil), // 5: resource.RebuildIndexesResponse - (*ResourceStatsResponse_Stats)(nil), // 6: resource.ResourceStatsResponse.Stats - (*ResourceSearchRequest_Sort)(nil), // 7: resource.ResourceSearchRequest.Sort - (*ResourceSearchRequest_Facet)(nil), // 8: resource.ResourceSearchRequest.Facet - nil, // 9: resource.ResourceSearchRequest.FacetEntry - (*ResourceSearchResponse_Facet)(nil), // 10: resource.ResourceSearchResponse.Facet - (*ResourceSearchResponse_TermFacet)(nil), // 11: resource.ResourceSearchResponse.TermFacet - nil, // 12: resource.ResourceSearchResponse.FacetEntry - (*ErrorResult)(nil), // 13: resource.ErrorResult - (*ListOptions)(nil), // 14: resource.ListOptions - (*ResourceKey)(nil), // 15: resource.ResourceKey - (*ResourceTable)(nil), // 16: resource.ResourceTable + (QueryFieldType)(0), // 0: resource.QueryFieldType + (*ResourceStatsRequest)(nil), // 1: resource.ResourceStatsRequest + (*ResourceStatsResponse)(nil), // 2: resource.ResourceStatsResponse + (*ResourceSearchRequest)(nil), // 3: resource.ResourceSearchRequest + (*ResourceSearchResponse)(nil), // 4: resource.ResourceSearchResponse + (*RebuildIndexesRequest)(nil), // 5: resource.RebuildIndexesRequest + (*RebuildIndexesResponse)(nil), // 6: resource.RebuildIndexesResponse + (*ResourceStatsResponse_Stats)(nil), // 7: resource.ResourceStatsResponse.Stats + (*ResourceSearchRequest_Sort)(nil), // 8: resource.ResourceSearchRequest.Sort + (*ResourceSearchRequest_Facet)(nil), // 9: resource.ResourceSearchRequest.Facet + (*ResourceSearchRequest_QueryField)(nil), // 10: resource.ResourceSearchRequest.QueryField + nil, // 11: resource.ResourceSearchRequest.FacetEntry + (*ResourceSearchResponse_Facet)(nil), // 12: resource.ResourceSearchResponse.Facet + (*ResourceSearchResponse_TermFacet)(nil), // 13: resource.ResourceSearchResponse.TermFacet + nil, // 14: resource.ResourceSearchResponse.FacetEntry + (*ErrorResult)(nil), // 15: resource.ErrorResult + (*ListOptions)(nil), // 16: resource.ListOptions + (*ResourceKey)(nil), // 17: resource.ResourceKey + (*ResourceTable)(nil), // 18: resource.ResourceTable } var file_search_proto_depIdxs = []int32{ - 13, // 0: resource.ResourceStatsResponse.error:type_name -> resource.ErrorResult - 6, // 1: resource.ResourceStatsResponse.stats:type_name -> resource.ResourceStatsResponse.Stats - 14, // 2: resource.ResourceSearchRequest.options:type_name -> resource.ListOptions - 15, // 3: resource.ResourceSearchRequest.federated:type_name -> resource.ResourceKey - 7, // 4: resource.ResourceSearchRequest.sortBy:type_name -> resource.ResourceSearchRequest.Sort - 9, // 5: resource.ResourceSearchRequest.facet:type_name -> resource.ResourceSearchRequest.FacetEntry - 13, // 6: resource.ResourceSearchResponse.error:type_name -> resource.ErrorResult - 15, // 7: resource.ResourceSearchResponse.key:type_name -> resource.ResourceKey - 16, // 8: resource.ResourceSearchResponse.results:type_name -> resource.ResourceTable - 12, // 9: resource.ResourceSearchResponse.facet:type_name -> resource.ResourceSearchResponse.FacetEntry - 15, // 10: resource.RebuildIndexesRequest.keys:type_name -> resource.ResourceKey - 13, // 11: resource.RebuildIndexesResponse.error:type_name -> resource.ErrorResult - 8, // 12: resource.ResourceSearchRequest.FacetEntry.value:type_name -> resource.ResourceSearchRequest.Facet - 11, // 13: resource.ResourceSearchResponse.Facet.terms:type_name -> resource.ResourceSearchResponse.TermFacet - 10, // 14: resource.ResourceSearchResponse.FacetEntry.value:type_name -> resource.ResourceSearchResponse.Facet - 2, // 15: resource.ResourceIndex.Search:input_type -> resource.ResourceSearchRequest - 0, // 16: resource.ResourceIndex.GetStats:input_type -> resource.ResourceStatsRequest - 4, // 17: resource.ResourceIndex.RebuildIndexes:input_type -> resource.RebuildIndexesRequest - 3, // 18: resource.ResourceIndex.Search:output_type -> resource.ResourceSearchResponse - 1, // 19: resource.ResourceIndex.GetStats:output_type -> resource.ResourceStatsResponse - 5, // 20: resource.ResourceIndex.RebuildIndexes:output_type -> resource.RebuildIndexesResponse - 18, // [18:21] is the sub-list for method output_type - 15, // [15:18] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name + 15, // 0: resource.ResourceStatsResponse.error:type_name -> resource.ErrorResult + 7, // 1: resource.ResourceStatsResponse.stats:type_name -> resource.ResourceStatsResponse.Stats + 16, // 2: resource.ResourceSearchRequest.options:type_name -> resource.ListOptions + 17, // 3: resource.ResourceSearchRequest.federated:type_name -> resource.ResourceKey + 8, // 4: resource.ResourceSearchRequest.sortBy:type_name -> resource.ResourceSearchRequest.Sort + 11, // 5: resource.ResourceSearchRequest.facet:type_name -> resource.ResourceSearchRequest.FacetEntry + 10, // 6: resource.ResourceSearchRequest.query_fields:type_name -> resource.ResourceSearchRequest.QueryField + 15, // 7: resource.ResourceSearchResponse.error:type_name -> resource.ErrorResult + 17, // 8: resource.ResourceSearchResponse.key:type_name -> resource.ResourceKey + 18, // 9: resource.ResourceSearchResponse.results:type_name -> resource.ResourceTable + 14, // 10: resource.ResourceSearchResponse.facet:type_name -> resource.ResourceSearchResponse.FacetEntry + 17, // 11: resource.RebuildIndexesRequest.keys:type_name -> resource.ResourceKey + 15, // 12: resource.RebuildIndexesResponse.error:type_name -> resource.ErrorResult + 0, // 13: resource.ResourceSearchRequest.QueryField.type:type_name -> resource.QueryFieldType + 9, // 14: resource.ResourceSearchRequest.FacetEntry.value:type_name -> resource.ResourceSearchRequest.Facet + 13, // 15: resource.ResourceSearchResponse.Facet.terms:type_name -> resource.ResourceSearchResponse.TermFacet + 12, // 16: resource.ResourceSearchResponse.FacetEntry.value:type_name -> resource.ResourceSearchResponse.Facet + 3, // 17: resource.ResourceIndex.Search:input_type -> resource.ResourceSearchRequest + 1, // 18: resource.ResourceIndex.GetStats:input_type -> resource.ResourceStatsRequest + 5, // 19: resource.ResourceIndex.RebuildIndexes:input_type -> resource.RebuildIndexesRequest + 4, // 20: resource.ResourceIndex.Search:output_type -> resource.ResourceSearchResponse + 2, // 21: resource.ResourceIndex.GetStats:output_type -> resource.ResourceStatsResponse + 6, // 22: resource.ResourceIndex.RebuildIndexes:output_type -> resource.RebuildIndexesResponse + 20, // [20:23] is the sub-list for method output_type + 17, // [17:20] is the sub-list for method input_type + 17, // [17:17] is the sub-list for extension type_name + 17, // [17:17] is the sub-list for extension extendee + 0, // [0:17] is the sub-list for field type_name } func init() { file_search_proto_init() } @@ -1007,13 +1160,14 @@ func file_search_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_search_proto_rawDesc), len(file_search_proto_rawDesc)), - NumEnums: 0, - NumMessages: 13, + NumEnums: 1, + NumMessages: 14, NumExtensions: 0, NumServices: 1, }, GoTypes: file_search_proto_goTypes, DependencyIndexes: file_search_proto_depIdxs, + EnumInfos: file_search_proto_enumTypes, MessageInfos: file_search_proto_msgTypes, }.Build() File_search_proto = out.File diff --git a/pkg/storage/unified/resourcepb/search_grpc.pb.go b/pkg/storage/unified/resourcepb/search_grpc.pb.go index d69cbd14e38..d8db878ef55 100644 --- a/pkg/storage/unified/resourcepb/search_grpc.pb.go +++ b/pkg/storage/unified/resourcepb/search_grpc.pb.go @@ -31,9 +31,11 @@ const ( // Unlike the ResourceStore, this service can be exposed to clients directly // It should be implemented with efficient indexes and does not need read-after-write semantics type ResourceIndexClient interface { + // Query for documents Search(ctx context.Context, in *ResourceSearchRequest, opts ...grpc.CallOption) (*ResourceSearchResponse, error) // Get the resource stats GetStats(ctx context.Context, in *ResourceStatsRequest, opts ...grpc.CallOption) (*ResourceStatsResponse, error) + // Rebuild the search index RebuildIndexes(ctx context.Context, in *RebuildIndexesRequest, opts ...grpc.CallOption) (*RebuildIndexesResponse, error) } @@ -82,9 +84,11 @@ func (c *resourceIndexClient) RebuildIndexes(ctx context.Context, in *RebuildInd // Unlike the ResourceStore, this service can be exposed to clients directly // It should be implemented with efficient indexes and does not need read-after-write semantics type ResourceIndexServer interface { + // Query for documents Search(context.Context, *ResourceSearchRequest) (*ResourceSearchResponse, error) // Get the resource stats GetStats(context.Context, *ResourceStatsRequest) (*ResourceStatsResponse, error) + // Rebuild the search index RebuildIndexes(context.Context, *RebuildIndexesRequest) (*RebuildIndexesResponse, error) } diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index d6ff00a81c0..09ef2dc9230 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -45,7 +45,7 @@ import ( const ( indexStorageMemory = "memory" indexStorageFile = "file" - boltTimeout = "500ms" + boltTimeout = "1s" ) // Keys used to store internal data in index. @@ -81,6 +81,11 @@ type BleveOptions struct { // Indexes that are not owned by current instance are eligible for cleanup. // If nil, all indexes are owned by the current instance. OwnsIndex func(key resource.NamespacedResource) (bool, error) + + // ScoringModel defines the scoring model used for the bleve indexes + // Default: index.TFIDFScoring + // Supported values: index.TFIDFScoring and index.BM25Scoring + ScoringModel string } type bleveBackend struct { @@ -368,7 +373,7 @@ func (b *bleveBackend) BuildIndex( attribute.String("reason", indexBuildReason), ) - mapper, err := GetBleveMappings(fields) + mapper, err := GetBleveMappings(b.opts.ScoringModel, fields) if err != nil { return nil, err } @@ -417,25 +422,18 @@ func (b *bleveBackend) BuildIndex( // This happens on startup, or when memory-based index has expired. (We don't expire file-based indexes) // If we do have an unexpired cached index already, we always build a new index from scratch. if cachedIndex == nil && !rebuild { - result := b.findPreviousFileBasedIndex(resourceDir) - if result != nil && result.IsOpen { - // Index file exists but is opened by another process, fallback to memory. - // Keep the name so we can skip cleanup of that directory. - newIndexType = indexStorageMemory - fileIndexName = result.Name - } else if result != nil && result.Index != nil { - // Found and opened existing index successfully - index = result.Index - fileIndexName = result.Name - indexRV = result.RV + var findErr error + index, fileIndexName, indexRV, findErr = b.findPreviousFileBasedIndex(resourceDir) + if findErr != nil { + return nil, findErr } } - if newIndexType == indexStorageFile && index != nil { + if index != nil { build = false logWithDetails.Debug("Existing index found on filesystem", "indexRV", indexRV, "directory", filepath.Join(resourceDir, fileIndexName)) defer closeIndexOnExit(index, "") // Close index, but don't delete directory. - } else if newIndexType == indexStorageFile { + } 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. @@ -462,9 +460,7 @@ func (b *bleveBackend) BuildIndex( logWithDetails.Info("Building index using filesystem", "directory", indexDir) defer closeIndexOnExit(index, indexDir) // Close index, and delete new index directory. } - } - - if newIndexType == indexStorageMemory { + } else { index, err = newBleveIndex("", mapper, time.Now(), b.opts.BuildVersion) if err != nil { return nil, fmt.Errorf("error creating new in-memory bleve index: %w", err) @@ -567,7 +563,7 @@ func cleanFileSegment(input string) string { return input } -// cleanOldIndexes deletes all subdirectories inside resourceDir, skipping directory with "skipName". +// cleanOldIndexes deletes all subdirectories inside dir, skipping directory with "skipName". // "skipName" can be empty. func (b *bleveBackend) cleanOldIndexes(resourceDir string, skipName string) { entries, err := os.ReadDir(resourceDir) @@ -578,19 +574,19 @@ func (b *bleveBackend) cleanOldIndexes(resourceDir string, skipName string) { b.log.Warn("error cleaning folders from", "directory", resourceDir, "error", err) return } - for _, ent := range entries { - if ent.IsDir() && ent.Name() != skipName { - indexDir := filepath.Join(resourceDir, ent.Name()) - if !isPathWithinRoot(indexDir, b.opts.Root) { - b.log.Warn("Skipping cleanup of directory", "directory", indexDir) + for _, entry := range entries { + if entry.IsDir() && entry.Name() != skipName { + entryDir := filepath.Join(resourceDir, entry.Name()) + if !isPathWithinRoot(entryDir, b.opts.Root) { + b.log.Warn("Skipping cleanup of directory", "directory", entryDir) continue } - err = os.RemoveAll(indexDir) + err = os.RemoveAll(entryDir) if err != nil { - b.log.Error("Unable to remove old index folder", "directory", indexDir, "error", err) + b.log.Error("Unable to remove old index folder", "directory", entryDir, "error", err) } else { - b.log.Info("Removed old index folder", "directory", indexDir) + b.log.Info("Removed old index folder", "directory", entryDir) } } } @@ -637,17 +633,10 @@ func formatIndexName(now time.Time) string { return now.Format("20060102-150405") } -type fileIndex struct { - Index bleve.Index - Name string - RV int64 - IsOpen bool -} - -func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) *fileIndex { +func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) (bleve.Index, string, int64, error) { entries, err := os.ReadDir(resourceDir) if err != nil { - return nil + return nil, "", 0, nil } for _, ent := range entries { @@ -657,14 +646,15 @@ func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) *fileIndex indexName := ent.Name() indexDir := filepath.Join(resourceDir, indexName) - idx, err := bleve.OpenUsing(indexDir, map[string]interface{}{"bolt_timeout": boltTimeout}) if err != nil { + // On timeout, the file probably is locked by another process. + // This indicates a setup issue that should be fixed rather than worked around by creating a new index file. if errors.Is(err, bolterrors.ErrTimeout) { - b.log.Debug("Index is opened by another process (timeout), skipping", "indexDir", indexDir) - return &fileIndex{Name: indexName, IsOpen: true} + b.log.Error("index is locked by another process", "indexDir", indexDir, "err", err) + return nil, "", 0, fmt.Errorf("index is locked by another process: indexDir=%s, err=%w", indexDir, err) } - b.log.Debug("error opening index", "indexDir", indexDir, "err", err) + b.log.Error("error opening index", "indexDir", indexDir, "err", err) continue } @@ -675,14 +665,10 @@ func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) *fileIndex continue } - return &fileIndex{ - Index: idx, - Name: indexName, - RV: indexRV, - } + return idx, indexName, indexRV, nil } - return nil + return nil, "", 0, nil } // Stop closes all indexes and stops background tasks. @@ -1196,6 +1182,7 @@ func (b *bleveIndex) getIndex( return b.index, nil } +// nolint:gocyclo func (b *bleveIndex) toBleveSearchRequest(ctx context.Context, req *resourcepb.ResourceSearchRequest, access authlib.AccessClient) (*bleve.SearchRequest, *resourcepb.ErrorResult) { ctx, span := tracer.Start(ctx, "search.bleveIndex.toBleveSearchRequest") defer span.End() @@ -1254,40 +1241,62 @@ func (b *bleveIndex) toBleveSearchRequest(ctx context.Context, req *resourcepb.R } } - if len(req.Query) > 1 && strings.Contains(req.Query, "*") { - // wildcard query is expensive - should be used with caution - wildcard := bleve.NewWildcardQuery(req.Query) - queries = append(queries, wildcard) - } + if len(req.Query) > 1 { + if strings.Contains(req.Query, "*") { + // wildcard query is expensive - should be used with caution + wildcard := bleve.NewWildcardQuery(req.Query) + queries = append(queries, wildcard) + } else { + // When using a + searchrequest.Fields = append(searchrequest.Fields, resource.SEARCH_FIELD_SCORE) + disjoin := bleve.NewDisjunctionQuery() + queries = append(queries, disjoin) - if req.Query != "" && !strings.Contains(req.Query, "*") { - // Add a text query - searchrequest.Fields = append(searchrequest.Fields, resource.SEARCH_FIELD_SCORE) + queryFields := req.QueryFields + if len(queryFields) == 0 { + queryFields = []*resourcepb.ResourceSearchRequest_QueryField{ + { + Name: resource.SEARCH_FIELD_TITLE, + Type: resourcepb.QueryFieldType_KEYWORD, + Boost: 10, // exact match -- includes ngrams! If they lived on their own field, we could score them differently + }, { + Name: resource.SEARCH_FIELD_TITLE, + Type: resourcepb.QueryFieldType_TEXT, + Boost: 2, // standard analyzer (with ngrams!) + }, { + Name: resource.SEARCH_FIELD_TITLE_PHRASE, + Type: resourcepb.QueryFieldType_TEXT, + Boost: 5, // standard analyzer + }, + } + } - // There are multiple ways to match the query string to documents. The following queries are ordered by priority: + for _, field := range queryFields { + switch field.Type { + case resourcepb.QueryFieldType_TEXT, resourcepb.QueryFieldType_DEFAULT: + q := bleve.NewMatchQuery(removeSmallTerms(req.Query)) // removeSmallTerms should be part of the analyzer + q.SetBoost(float64(field.Boost)) + q.SetField(field.Name) + q.Analyzer = standard.Name // analyze the text + q.Operator = query.MatchQueryOperatorAnd // all terms must match + disjoin.AddQuery(q) - // Query 1: Match the exact query string - queryExact := bleve.NewMatchQuery(req.Query) - queryExact.SetBoost(10.0) - queryExact.SetField(resource.SEARCH_FIELD_TITLE) - queryExact.Analyzer = keyword.Name // don't analyze the query input - treat it as a single token - queryExact.Operator = query.MatchQueryOperatorAnd // This doesn't make a difference for keyword analyzer, we add it just to be explicit. + case resourcepb.QueryFieldType_KEYWORD: + q := bleve.NewMatchQuery(req.Query) + q.SetBoost(float64(field.Boost)) + q.SetField(field.Name) + q.Analyzer = keyword.Name // don't analyze the query input - treat it as a single token + disjoin.AddQuery(q) - // Query 2: Phrase query with standard analyzer - queryPhrase := bleve.NewMatchPhraseQuery(req.Query) - queryPhrase.SetBoost(5.0) - queryPhrase.SetField(resource.SEARCH_FIELD_TITLE) - queryPhrase.Analyzer = standard.Name - - // Query 3: Match query with standard analyzer - queryAnalyzed := bleve.NewMatchQuery(removeSmallTerms(req.Query)) - queryAnalyzed.SetField(resource.SEARCH_FIELD_TITLE) - queryAnalyzed.Analyzer = standard.Name - queryAnalyzed.Operator = query.MatchQueryOperatorAnd // Make sure all terms from the query are matched - - // At least one of the queries must match - searchQuery := bleve.NewDisjunctionQuery(queryExact, queryAnalyzed, queryPhrase) - queries = append(queries, searchQuery) + case resourcepb.QueryFieldType_PHRASE: + q := bleve.NewMatchPhraseQuery(req.Query) + q.SetBoost(float64(field.Boost)) + q.SetField(field.Name) + q.Analyzer = standard.Name + disjoin.AddQuery(q) + } + } + } } switch len(queries) { @@ -1889,7 +1898,7 @@ func (q *permissionScopedQuery) Searcher(ctx context.Context, i index.IndexReade if err != nil { return nil, err } - filteringSearcher := bleveSearch.NewFilteringSearcher(ctx, searcher, func(d *search.DocumentMatch) bool { + filteringSearcher := bleveSearch.NewFilteringSearcher(ctx, searcher, func(_ *search.SearchContext, d *search.DocumentMatch) bool { // The doc ID has the format: /// // IndexInternalID will be the same as the doc ID when using an in-memory index, but when using a file-based // index it becomes a binary encoded number that has some other internal meaning. Using ExternalID() will get the diff --git a/pkg/storage/unified/search/bleve_integration_test.go b/pkg/storage/unified/search/bleve_integration_test.go index 819fd5a8d9a..1f34444574f 100644 --- a/pkg/storage/unified/search/bleve_integration_test.go +++ b/pkg/storage/unified/search/bleve_integration_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + index "github.com/blevesearch/bleve_index_api" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/storage/unified/resource" @@ -19,6 +20,7 @@ func TestBleveSearchBackend(t *testing.T) { backend, err := NewBleveBackend(BleveOptions{ Root: tempDir, FileThreshold: 5, + ScoringModel: index.BM25Scoring, }, nil) require.NoError(t, err) require.NotNil(t, backend) @@ -52,3 +54,32 @@ func TestSearchBackendBenchmark(t *testing.T) { unitest.BenchmarkSearchBackend(t, backend, opts) } + +func BenchmarkScoringModels(b *testing.B) { + models := []string{index.TFIDFScoring, index.BM25Scoring} + + for _, model := range models { + b.Run(model, func(b *testing.B) { + tempDir := b.TempDir() + + backend, err := NewBleveBackend(BleveOptions{ + Root: tempDir, + ScoringModel: model, + }, nil) + require.NoError(b, err) + require.NotNil(b, backend) + + b.Cleanup(backend.Stop) + + opts := &unitest.BenchmarkOptions{ + NumResources: 1000, + Concurrency: 4, + NumNamespaces: 10, + NumGroups: 10, + NumResourceTypes: 10, + } + + unitest.BenchmarkSearchBackend(b, backend, opts) + }) + } +} diff --git a/pkg/storage/unified/search/bleve_mappings.go b/pkg/storage/unified/search/bleve_mappings.go index 43adcbc607e..20eb2ffb8df 100644 --- a/pkg/storage/unified/search/bleve_mappings.go +++ b/pkg/storage/unified/search/bleve_mappings.go @@ -5,13 +5,15 @@ import ( "github.com/blevesearch/bleve/v2/analysis/analyzer/keyword" "github.com/blevesearch/bleve/v2/analysis/analyzer/standard" "github.com/blevesearch/bleve/v2/mapping" - "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" ) -func GetBleveMappings(fields resource.SearchableDocumentFields) (mapping.IndexMapping, error) { +func GetBleveMappings(scoringModel string, fields resource.SearchableDocumentFields) (mapping.IndexMapping, error) { mapper := bleve.NewIndexMapping() + if scoringModel != "" { + mapper.ScoringModel = scoringModel + } err := RegisterCustomAnalyzers(mapper) if err != nil { diff --git a/pkg/storage/unified/search/bleve_mappings_test.go b/pkg/storage/unified/search/bleve_mappings_test.go index 3b8027ee06e..821cf987990 100644 --- a/pkg/storage/unified/search/bleve_mappings_test.go +++ b/pkg/storage/unified/search/bleve_mappings_test.go @@ -13,7 +13,7 @@ import ( ) func TestDocumentMapping(t *testing.T) { - mappings, err := search.GetBleveMappings(nil) + mappings, err := search.GetBleveMappings("", nil) require.NoError(t, err) data := resource.IndexableDocument{ Title: "title", diff --git a/pkg/storage/unified/search/bleve_search_test.go b/pkg/storage/unified/search/bleve_search_test.go index c10aa3f6726..b221a60a7d6 100644 --- a/pkg/storage/unified/search/bleve_search_test.go +++ b/pkg/storage/unified/search/bleve_search_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/blevesearch/bleve/v2" + index "github.com/blevesearch/bleve_index_api" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/apimachinery/identity" @@ -258,6 +259,7 @@ func newTestDashboardsIndex(t testing.TB, threshold int64, size int64, writer re backend, err := search.NewBleveBackend(search.BleveOptions{ Root: t.TempDir(), FileThreshold: threshold, // use in-memory for tests + ScoringModel: index.BM25Scoring, }, nil) require.NoError(t, err) diff --git a/pkg/storage/unified/search/bleve_test.go b/pkg/storage/unified/search/bleve_test.go index c879440e7b6..a88951a100a 100644 --- a/pkg/storage/unified/search/bleve_test.go +++ b/pkg/storage/unified/search/bleve_test.go @@ -14,15 +14,16 @@ import ( "time" "github.com/blevesearch/bleve/v2" + index "github.com/blevesearch/bleve_index_api" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + bolterrors "go.etcd.io/bbolt/errors" "go.uber.org/atomic" "go.uber.org/goleak" authlib "github.com/grafana/authlib/types" - "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/infra/log" @@ -50,6 +51,7 @@ func TestBleveBackend(t *testing.T) { backend, err := NewBleveBackend(BleveOptions{ Root: tmpdir, FileThreshold: 5, // with more than 5 items we create a file on disk + ScoringModel: index.BM25Scoring, }, nil) require.NoError(t, err) t.Cleanup(backend.Stop) @@ -773,6 +775,7 @@ func setupBleveBackend(t *testing.T, options ...setupOption) (*bleveBackend, pro IndexCacheTTL: defaultIndexCacheTTL, Logger: log.NewNopLogger(), BuildVersion: buildVersion, + ScoringModel: index.BM25Scoring, } for _, opt := range options { opt(&opts) @@ -1584,7 +1587,7 @@ func docCount(t *testing.T, idx resource.ResourceIndex) int { return int(cnt) } -func TestBleveBackendFallsBackToMemory(t *testing.T) { +func TestBuildIndexReturnsErrorWhenIndexLocked(t *testing.T) { ns := resource.NamespacedResource{ Namespace: "test", Group: "group", @@ -1605,53 +1608,19 @@ func TestBleveBackendFallsBackToMemory(t *testing.T) { require.Equal(t, indexStorageFile, bleveIdx1.indexStorage) checkOpenIndexes(t, reg1, 0, 1) - // Now create a second backend using the same directory - // This simulates another instance trying to open the same index - backend2, reg2 := setupBleveBackend(t, withRootDir(tmpDir)) - - // BuildIndex should detect the file is locked and fallback to memory - index2, err := backend2.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) - require.NoError(t, err) - require.NotNil(t, index2) - - // Verify second index fell back to in-memory despite size being above file threshold - bleveIdx2, ok := index2.(*bleveIndex) - require.True(t, ok) - require.Equal(t, indexStorageMemory, bleveIdx2.indexStorage) - - // Verify metrics show 1 memory index and 0 file indexes for backend2 - checkOpenIndexes(t, reg2, 1, 0) - - // Verify the in-memory index works correctly - require.Equal(t, 10, docCount(t, index2)) - - // Clean up: close first backend to release the file lock - backend1.Stop() -} - -func TestBleveSkipCleanOldIndexesOnMemoryFallback(t *testing.T) { - ns := resource.NamespacedResource{ - Namespace: "test", - Group: "group", - Resource: "resource", - } - - tmpDir := t.TempDir() - - backend1, _ := setupBleveBackend(t, withRootDir(tmpDir)) - _, err := backend1.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) - require.NoError(t, err) - // Now create a second backend using the same directory // This simulates another instance trying to open the same index backend2, _ := setupBleveBackend(t, withRootDir(tmpDir)) - // BuildIndex should detect the file is locked and fallback to memory - _, err = backend2.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) + // BuildIndex should detect the file is locked and return an error after timeout + now := time.Now() + timeout, err := time.ParseDuration(boltTimeout) require.NoError(t, err) - - // Verify that the index directory still exists (i.e., cleanOldIndexes was skipped) - verifyDirEntriesCount(t, backend2.getResourceDir(ns), 1) + index2, err := backend2.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) + require.Error(t, err) + require.ErrorIs(t, err, bolterrors.ErrTimeout) + require.Nil(t, index2) + require.GreaterOrEqual(t, time.Since(now).Milliseconds(), timeout.Milliseconds()-500, "BuildIndex should have waited for approximately boltTimeout duration") // Clean up: close first backend to release the file lock backend1.Stop() diff --git a/pkg/storage/unified/search/builders/dashboard.go b/pkg/storage/unified/search/builders/dashboard.go index 4f8d55111a9..a2963d71186 100644 --- a/pkg/storage/unified/search/builders/dashboard.go +++ b/pkg/storage/unified/search/builders/dashboard.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "slices" "sort" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -18,6 +19,7 @@ import ( const DASHBOARD_SCHEMA_VERSION = "schema_version" const DASHBOARD_LINK_COUNT = "link_count" const DASHBOARD_PANEL_TYPES = "panel_types" +const DASHBOARD_PANEL_TITLE = "panel_title" const DASHBOARD_DS_TYPES = "ds_types" const DASHBOARD_TRANSFORMATIONS = "transformation" const DASHBOARD_LIBRARY_PANEL_REFERENCE = "reference.LibraryPanel" @@ -53,11 +55,21 @@ func DashboardBuilder(namespaced resource.NamespacedDocumentSupplier) (resource. Type: resourcepb.ResourceTableColumnDefinition_INT32, Description: "How many links appear on the page", }, + { + Name: DASHBOARD_PANEL_TITLE, + Type: resourcepb.ResourceTableColumnDefinition_STRING, + IsArray: true, + Description: "The panel title text", + Properties: &resourcepb.ResourceTableColumnDefinition_Properties{ + Filterable: false, // full text + FreeText: true, + }, + }, { Name: DASHBOARD_PANEL_TYPES, Type: resourcepb.ResourceTableColumnDefinition_STRING, IsArray: true, - Description: "How many links appear on the page", + Description: "The panel types used in this dashboard", Properties: &resourcepb.ResourceTableColumnDefinition_Properties{ Filterable: true, }, @@ -269,14 +281,22 @@ func (s *DashboardDocumentBuilder) BuildDocument(ctx context.Context, key *resou doc.Description = summary.Description doc.Tags = summary.Tags + panelTitles := []string{} panelTypes := []string{} transformations := []string{} dsTypes := []string{} - for _, p := range summary.Panels { - if p.Type != "" { + for p := range summary.PanelIterator() { + switch p.Type { + case "": // ignore + case "row": // row should map to a layout type when we support v2 constructs + default: panelTypes = append(panelTypes, p.Type) } + + if len(p.Title) > 0 { + panelTitles = append(panelTitles, p.Title) + } if len(p.Transformer) > 0 { transformations = append(transformations, p.Transformer...) } @@ -309,17 +329,20 @@ func (s *DashboardDocumentBuilder) BuildDocument(ctx context.Context, key *resou resource.SEARCH_FIELD_LEGACY_ID: summary.ID, } + if len(panelTitles) > 0 { + doc.Fields[DASHBOARD_PANEL_TITLE] = panelTitles + } if len(panelTypes) > 0 { sort.Strings(panelTypes) - doc.Fields[DASHBOARD_PANEL_TYPES] = panelTypes + doc.Fields[DASHBOARD_PANEL_TYPES] = slices.Compact(panelTypes) // distinct values } if len(dsTypes) > 0 { sort.Strings(dsTypes) - doc.Fields[DASHBOARD_DS_TYPES] = dsTypes + doc.Fields[DASHBOARD_DS_TYPES] = slices.Compact(dsTypes) // distinct values } if len(transformations) > 0 { sort.Strings(transformations) - doc.Fields[DASHBOARD_TRANSFORMATIONS] = transformations + doc.Fields[DASHBOARD_TRANSFORMATIONS] = slices.Compact(transformations) // distinct values } for k, v := range s.Stats[summary.UID] { diff --git a/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa-out.json b/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa-out.json index a77725e2cc2..fdb77b02c3b 100644 --- a/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa-out.json +++ b/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa-out.json @@ -32,10 +32,16 @@ "errors_last_7_days": 1, "grafana.app/deprecatedInternalID": 141, "link_count": 0, + "panel_title": [ + "green pie", + "red pie", + "blue pie", + "collapsed row" + ], "panel_types": [ "barchart", "graph", - "row" + "pie" ], "schema_version": 38 }, @@ -46,6 +52,12 @@ "kind": "DataSource", "name": "DSUID" }, + { + "relation": "depends-on", + "group": "dashboards.grafana.app", + "kind": "LibraryPanel", + "name": "l3d2s634-fdgf-75u4-3fg3-67j966ii7jur" + }, { "relation": "depends-on", "group": "dashboards.grafana.app", diff --git a/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa.json b/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa.json index d9ecbfc6aec..24360ee929b 100644 --- a/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa.json +++ b/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa.json @@ -67,7 +67,7 @@ "name": "red pie", "uid": "e1d5f519-dabd-47c6-9ad7-83d181ce1cee" }, - "title": "green pie" + "title": "red pie" }, { "id": 7, @@ -78,6 +78,14 @@ "id": 8, "type": "graph" }, + { + "id": 20, + "type": "graph" + }, + { + "id": 30, + "type": "graph" + }, { "collapsed": true, "gridPos": { @@ -101,6 +109,10 @@ "uid": "l3d2s634-fdgf-75u4-3fg3-67j966ii7jur" }, "title": "blue pie" + }, + { + "id": 40, + "type": "pie" } ], "title": "collapsed row", diff --git a/pkg/storage/unified/search/options.go b/pkg/storage/unified/search/options.go index 20a0874b598..64cf074f52c 100644 --- a/pkg/storage/unified/search/options.go +++ b/pkg/storage/unified/search/options.go @@ -19,7 +19,7 @@ func NewSearchOptions( ownsIndexFn func(key resource.NamespacedResource) (bool, error), ) (resource.SearchOptions, error) { //nolint:staticcheck // not yet migrated to OpenFeature - if cfg.EnableSearch || features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageSearch) || features.IsEnabledGlobally(featuremgmt.FlagProvisioning) { + if cfg.EnableSearch || features.IsEnabledGlobally(featuremgmt.FlagProvisioning) { root := cfg.IndexPath if root == "" { root = filepath.Join(cfg.DataPath, "unified-search", "bleve") @@ -46,6 +46,7 @@ func NewSearchOptions( BuildVersion: cfg.BuildVersion, OwnsIndex: ownsIndexFn, IndexMinUpdateInterval: cfg.IndexMinUpdateInterval, + ScoringModel: cfg.IndexScoringModel, }, indexMetrics) if err != nil { diff --git a/pkg/storage/unified/search/testdata/manual-dashboard.json b/pkg/storage/unified/search/testdata/manual-dashboard.json index 4208a58dcd3..2ae346d072c 100644 --- a/pkg/storage/unified/search/testdata/manual-dashboard.json +++ b/pkg/storage/unified/search/testdata/manual-dashboard.json @@ -71,11 +71,18 @@ "description": "How many links appear on the page", "priority": 0 }, + { + "name": "panel_title", + "type": "string", + "format": "", + "description": "The panel title text", + "priority": 0 + }, { "name": "panel_types", "type": "string", "format": "", - "description": "How many links appear on the page", + "description": "The panel types used in this dashboard", "priority": 0 }, { @@ -214,6 +221,7 @@ null, null, null, + null, null ], "object": { @@ -239,6 +247,7 @@ "repo", null, null, + null, [ "timeseries" ], @@ -282,6 +291,7 @@ "repo", null, null, + null, [ "timeseries", "table" diff --git a/pkg/storage/unified/sql/db/migrations/resource_mig.go b/pkg/storage/unified/sql/db/migrations/resource_mig.go index 0f19ae01d93..8f7abe1306e 100644 --- a/pkg/storage/unified/sql/db/migrations/resource_mig.go +++ b/pkg/storage/unified/sql/db/migrations/resource_mig.go @@ -2,8 +2,11 @@ package migrations import ( "fmt" + "strings" + "github.com/bwmarrin/snowflake" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/util/xorm" ) func initResourceTables(mg *migrator.Migrator) string { @@ -217,5 +220,145 @@ func initResourceTables(mg *migrator.Migrator) string { migrator.ConvertUniqueKeyToPrimaryKey(mg, oldResourceVersionUniqueKey, updatedResourceVersionTable) + mg.AddMigration("Change key_path collation of resource_history in postgres", migrator.NewRawSQLMigration("").Postgres(`ALTER TABLE resource_history ALTER COLUMN key_path TYPE VARCHAR(2048) COLLATE "C";`)) + mg.AddMigration("Change key_path collation of resource_events in postgres", migrator.NewRawSQLMigration("").Postgres(`ALTER TABLE resource_events ALTER COLUMN key_path TYPE VARCHAR(2048) COLLATE "C";`)) + + mg.AddMigration("resource_history key_path backfill", &ResourceHistoryKeyPathBackfillMigration{}) + return marker } + +type ResourceHistoryKeyPathBackfillMigration struct { + migrator.MigrationBase +} + +func (m *ResourceHistoryKeyPathBackfillMigration) SQL(_ migrator.Dialect) string { + return "resource_history key_path backfill code migration" +} + +func (m *ResourceHistoryKeyPathBackfillMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) error { + rows, err := getResourceHistoryRows(sess, mg, resourceHistoryRow{}) + if err != nil { + return err + } + + for len(rows) > 0 { + if err := updateResourceHistoryKeyPath(sess, rows); err != nil { + return err + } + + rows, err = getResourceHistoryRows(sess, mg, rows[len(rows)-1]) + if err != nil { + return err + } + } + + return nil +} + +func updateResourceHistoryKeyPath(sess *xorm.Session, rows []resourceHistoryRow) error { + if len(rows) == 0 { + return nil + } + + updates := []resourceHistoryRow{} + + for _, row := range rows { + if row.KeyPath == "" { + row.KeyPath = parseKeyPath(row) + updates = append(updates, row) + } + } + + if len(updates) == 0 { + return nil + } + + guids := "" + setCases := "CASE" + for _, row := range updates { + guids += fmt.Sprintf("'%s',", row.GUID) + setCases += fmt.Sprintf(" WHEN guid = '%s' THEN '%s'", row.GUID, row.KeyPath) + } + + guids = strings.TrimRight(guids, ",") + setCases += " ELSE key_path END " + + // the query will look like this + // UPDATE resource_history + // SET key_path = CASE + // WHEN guid = '1402de51-669b-4206-8a6c-005a00eee6e3' then 'unified/data/folder.grafana.app/folders/default/cf6lylpvls000c/1998492888241012800~created~' + // WHEN guid = '8842cc56-f22b-45e1-82b1-99759cd443b3' then 'unified/data/dashboard.grafana.app/dashboards/default/adzvfhp/1998492902577144677~created~cf6lylpvls000c' + // ELSE key_path END + // WHERE guid IN ('1402de51-669b-4206-8a6c-005a00eee6e3', '8842cc56-f22b-45e1-82b1-99759cd443b3') + // AND key_path = ''; + sql := fmt.Sprintf(` + UPDATE resource_history + SET key_path = %s + WHERE guid IN (%s) + AND key_path = ''; + `, setCases, guids) + + if _, err := sess.Exec(sql); err != nil { + return err + } + + return nil +} + +func parseKeyPath(row resourceHistoryRow) string { + var action string + switch row.Action { + case 1: + action = "created" + case 2: + action = "updated" + case 3: + action = "deleted" + } + return fmt.Sprintf("unified/data/%s/%s/%s/%s/%d~%s~%s", row.Group, row.Resource, row.Namespace, row.Name, snowflakeFromRv(row.ResourceVersion), action, row.Folder) +} + +func snowflakeFromRv(rv int64) int64 { + return (((rv / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (rv % 1000) +} + +type resourceHistoryRow struct { + GUID string `xorm:"guid"` + Group string `xorm:"group"` + Resource string `xorm:"resource"` + Namespace string `xorm:"namespace"` + Name string `xorm:"name"` + ResourceVersion int64 `xorm:"resource_version"` + Action int64 `xorm:"action"` + Folder string `xorm:"folder"` + KeyPath string `xorm:"key_path"` +} + +func getResourceHistoryRows(sess *xorm.Session, mg *migrator.Migrator, continueRow resourceHistoryRow) ([]resourceHistoryRow, error) { + var rows []resourceHistoryRow + cols := fmt.Sprintf( + "%s, %s, %s, %s, %s, %s, %s, %s, %s", + mg.Dialect.Quote("guid"), + mg.Dialect.Quote("group"), + mg.Dialect.Quote("resource"), + mg.Dialect.Quote("namespace"), + mg.Dialect.Quote("name"), + mg.Dialect.Quote("resource_version"), + mg.Dialect.Quote("action"), + mg.Dialect.Quote("folder"), + mg.Dialect.Quote("key_path")) + sql := fmt.Sprintf(` + SELECT %s + FROM resource_history + WHERE (resource_version > %d OR (resource_version = %d AND guid > '%s')) + AND key_path = '' + ORDER BY resource_version ASC, guid ASC + LIMIT 1000; + `, cols, continueRow.ResourceVersion, continueRow.ResourceVersion, continueRow.GUID) + if err := sess.SQL(sql).Find(&rows); err != nil { + return nil, err + } + + return rows, nil +} diff --git a/pkg/storage/unified/sql/rvmanager/rv_manager.go b/pkg/storage/unified/sql/rvmanager/rv_manager.go index b10685f22ad..42e1f9fb74c 100644 --- a/pkg/storage/unified/sql/rvmanager/rv_manager.go +++ b/pkg/storage/unified/sql/rvmanager/rv_manager.go @@ -307,7 +307,7 @@ func (m *ResourceVersionManager) execBatch(ctx context.Context, group, resource // Allocate the RVs for i, guid := range guids { guidToRV[guid] = rv - guidToSnowflakeRV[guid] = SnowflakeFromRv(rv) + guidToSnowflakeRV[guid] = SnowflakeFromRV(rv) rvs[i] = rv rv++ } @@ -364,12 +364,20 @@ func (m *ResourceVersionManager) execBatch(ctx context.Context, group, resource } } -// takes a unix microsecond rv and transforms into a snowflake format. The timestamp is converted from microsecond to +// takes a unix microsecond RV and transforms into a snowflake format. The timestamp is converted from microsecond to // millisecond (the integer division) and the remainder is saved in the stepbits section. machine id is always 0 -func SnowflakeFromRv(rv int64) int64 { +func SnowflakeFromRV(rv int64) int64 { return (((rv / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (rv % 1000) } +// It is generally not possible to convert from a snowflakeID to a microsecond RV due to the loss in precision +// (snowflake ID stores timestamp in milliseconds). However, this implementation stores the microsecond fraction +// in the step bits (see SnowflakeFromRV), allowing us to compute the microsecond timestamp. +func RVFromSnowflake(snowflakeID int64) int64 { + microSecFraction := snowflakeID & ((1 << snowflake.StepBits) - 1) + return ((snowflakeID>>(snowflake.NodeBits+snowflake.StepBits))+snowflake.Epoch)*1000 + microSecFraction +} + // helper utility to compare two RVs. The first RV must be in snowflake format. Will convert rv2 to snowflake and retry // if comparison fails func IsRvEqual(rv1, rv2 int64) bool { @@ -377,7 +385,7 @@ func IsRvEqual(rv1, rv2 int64) bool { return true } - return rv1 == SnowflakeFromRv(rv2) + return rv1 == SnowflakeFromRV(rv2) } // Lock locks the resource version for the given key diff --git a/pkg/storage/unified/sql/rvmanager/rv_manager_test.go b/pkg/storage/unified/sql/rvmanager/rv_manager_test.go index 9a2e105aa2f..9da98d967d0 100644 --- a/pkg/storage/unified/sql/rvmanager/rv_manager_test.go +++ b/pkg/storage/unified/sql/rvmanager/rv_manager_test.go @@ -63,3 +63,13 @@ func TestResourceVersionManager(t *testing.T) { require.Equal(t, rv, int64(200)) }) } + +func TestSnowflakeFromRVRoundtrips(t *testing.T) { + // 2026-01-12 19:33:58.806211 +0000 UTC + offset := int64(1768246438806211) // in microseconds + + for n := range int64(100) { + ts := offset + n + require.Equal(t, ts, RVFromSnowflake(SnowflakeFromRV(ts))) + } +} diff --git a/pkg/storage/unified/sql/server.go b/pkg/storage/unified/sql/server.go index f4a1ee3ce77..b96d559ba55 100644 --- a/pkg/storage/unified/sql/server.go +++ b/pkg/storage/unified/sql/server.go @@ -99,6 +99,9 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) { return nil, err } + isHA := isHighAvailabilityEnabled(opts.Cfg.SectionWithEnvOverrides("database"), + opts.Cfg.SectionWithEnvOverrides("resource_api")) + if opts.Cfg.EnableSQLKVBackend { sqlkv, err := resource.NewSQLKV(eDB) if err != nil { @@ -106,9 +109,10 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) { } kvBackendOpts := resource.KVBackendOptions{ - KvStore: sqlkv, - Tracer: opts.Tracer, - Reg: opts.Reg, + KvStore: sqlkv, + Tracer: opts.Tracer, + Reg: opts.Reg, + UseChannelNotifier: !isHA, } ctx := context.Background() @@ -140,9 +144,6 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) { serverOptions.Backend = kvBackend serverOptions.Diagnostics = kvBackend } else { - isHA := isHighAvailabilityEnabled(opts.Cfg.SectionWithEnvOverrides("database"), - opts.Cfg.SectionWithEnvOverrides("resource_api")) - backend, err := NewBackend(BackendOptions{ DBProvider: eDB, Reg: opts.Reg, diff --git a/pkg/storage/unified/sql/test/integration_test.go b/pkg/storage/unified/sql/test/integration_test.go index eaf78de0779..f73bb61d679 100644 --- a/pkg/storage/unified/sql/test/integration_test.go +++ b/pkg/storage/unified/sql/test/integration_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + index "github.com/blevesearch/bleve_index_api" "github.com/go-jose/go-jose/v4/jwt" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" @@ -107,16 +108,20 @@ func TestIntegrationSQLStorageAndSQLKVCompatibilityTests(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) t.Cleanup(db.CleanupTestDB) + newKvBackend := func(ctx context.Context) (resource.StorageBackend, sqldb.DB) { + return unitest.NewTestSqlKvBackend(t, ctx, true) + } + t.Run("IsHA (polling notifier)", func(t *testing.T) { unitest.RunSQLStorageBackendCompatibilityTest(t, func(ctx context.Context) (resource.StorageBackend, sqldb.DB) { return newTestBackend(t, true, 0) - }, nil) + }, newKvBackend, nil) }) t.Run("NotHA (in process notifier)", func(t *testing.T) { unitest.RunSQLStorageBackendCompatibilityTest(t, func(ctx context.Context) (resource.StorageBackend, sqldb.DB) { return newTestBackend(t, false, 0) - }, nil) + }, newKvBackend, nil) }) } @@ -125,21 +130,28 @@ func TestIntegrationSearchAndStorage(t *testing.T) { ctx := context.Background() - // Create a new bleve backend - search, err := search.NewBleveBackend(search.BleveOptions{ - FileThreshold: 0, - Root: t.TempDir(), - }, nil) - require.NoError(t, err) - require.NotNil(t, search) - t.Cleanup(search.Stop) + scoringModels := []string{index.TFIDFScoring, index.BM25Scoring} - // Create a new resource backend - storage, _ := newTestBackend(t, false, 0) - require.NotNil(t, storage) + for _, model := range scoringModels { + t.Run(model, func(t *testing.T) { + // Create a new bleve backend + search, err := search.NewBleveBackend(search.BleveOptions{ + FileThreshold: 0, + Root: t.TempDir(), + ScoringModel: model, + }, nil) + require.NoError(t, err) + require.NotNil(t, search) + t.Cleanup(search.Stop) - // Run the shared storage and search tests - unitest.RunTestSearchAndStorage(t, ctx, storage, search) + // Create a new resource backend + storage, _ := newTestBackend(t, false, 0) + require.NotNil(t, storage) + + // Run the shared storage and search tests + unitest.RunTestSearchAndStorage(t, ctx, storage, search) + }) + } } func TestClientServer(t *testing.T) { diff --git a/pkg/storage/unified/testing/storage_backend.go b/pkg/storage/unified/testing/storage_backend.go index 730efe418f6..29279a16e97 100644 --- a/pkg/storage/unified/testing/storage_backend.go +++ b/pkg/storage/unified/testing/storage_backend.go @@ -11,7 +11,6 @@ import ( "testing" "time" - "github.com/bwmarrin/snowflake" "github.com/go-jose/go-jose/v4/jwt" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -24,6 +23,7 @@ import ( "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" sqldb "github.com/grafana/grafana/pkg/storage/unified/sql/db" @@ -44,7 +44,6 @@ const ( TestCreateNewResource = "create new resource" TestGetResourceLastImportTime = "get resource last import time" TestOptimisticLocking = "optimistic locking on concurrent writes" - TestKeyPathGeneration = "key_path generation" ) type NewBackendFunc func(ctx context.Context) resource.StorageBackend @@ -101,42 +100,15 @@ func RunStorageBackendTest(t *testing.T, newBackend NewBackendFunc, opts *TestOp } t.Run(tc.name, func(t *testing.T) { + if db.IsTestDbSQLite() { + t.Skip("Skipping tests on sqlite until channel notifier is implemented") + } + tc.fn(t, newBackend(context.Background()), opts.NSPrefix) }) } } -func RunSQLStorageBackendCompatibilityTest(t *testing.T, newBackend NewBackendWithDBFunc, opts *TestOptions) { - if opts == nil { - opts = &TestOptions{} - } - - if opts.NSPrefix == "" { - opts.NSPrefix = GenerateRandomNSPrefix() - } - - t.Logf("Running tests with namespace prefix: %s", opts.NSPrefix) - - cases := []struct { - name string - fn func(*testing.T, resource.StorageBackend, string, sqldb.DB) - }{ - {TestKeyPathGeneration, runTestIntegrationBackendKeyPathGeneration}, - } - - for _, tc := range cases { - if shouldSkip := opts.SkipTests[tc.name]; shouldSkip { - t.Logf("Skipping test: %s", tc.name) - continue - } - - t.Run(tc.name, func(t *testing.T) { - backend, db := newBackend(context.Background()) - tc.fn(t, backend, opts.NSPrefix, db) - }) - } -} - func runTestIntegrationBackendHappyPath(t *testing.T, backend resource.StorageBackend, nsPrefix string) { ctx := types.WithAuthInfo(context.Background(), authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{ Claims: jwt.Claims{ @@ -1199,7 +1171,7 @@ func runTestIntegrationBackendCreateNewResource(t *testing.T, backend resource.S })) server := newServer(t, backend) - ns := nsPrefix + "-create-resource" + ns := nsPrefix + "-create-rsrce" // create-resource ctx = request.WithNamespace(ctx, ns) request := &resourcepb.CreateRequest{ @@ -1640,7 +1612,7 @@ func (s *sliceBulkRequestIterator) RollbackRequested() bool { func runTestIntegrationBackendOptimisticLocking(t *testing.T, backend resource.StorageBackend, nsPrefix string) { ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second)) - ns := nsPrefix + "-optimistic-locking" + ns := nsPrefix + "-optimis-lock" // optimistic-locking. need to cut down on characters to not exceed namespace character limit (40) t.Run("concurrent updates with same RV - only one succeeds", func(t *testing.T) { // Create initial resource with rv0 (no previous RV) @@ -1759,222 +1731,3 @@ func runTestIntegrationBackendOptimisticLocking(t *testing.T, backend resource.S require.LessOrEqual(t, successes, 1, "at most one create should succeed (errors: %v)", errorMessages) }) } - -func runTestIntegrationBackendKeyPathGeneration(t *testing.T, backend resource.StorageBackend, nsPrefix string, db sqldb.DB) { - ctx := testutil.NewDefaultTestContext(t) - - t.Run("Create resource", func(t *testing.T) { - // Create a test resource - key := &resourcepb.ResourceKey{ - Group: "playlist.grafana.app", - Resource: "playlists", - Namespace: nsPrefix + "-default", - Name: "test-playlist-crud", - } - - // Create the K8s unstructured object - testObj := &unstructured.Unstructured{ - Object: map[string]interface{}{ - "apiVersion": "playlist.grafana.app/v0alpha1", - "kind": "Playlist", - "metadata": map[string]interface{}{ - "name": "test-playlist-crud", - "namespace": nsPrefix + "-default", - "uid": "test-uid-crud-123", - }, - "spec": map[string]interface{}{ - "title": "My Test Playlist", - }, - }, - } - - // Get metadata accessor - metaAccessor, err := utils.MetaAccessor(testObj) - require.NoError(t, err) - - // Serialize to JSON - jsonBytes, err := testObj.MarshalJSON() - require.NoError(t, err) - - // Create WriteEvent - writeEvent := resource.WriteEvent{ - Type: resourcepb.WatchEvent_ADDED, - Key: key, - Value: jsonBytes, - Object: metaAccessor, - PreviousRV: 0, // Always 0 for new resources - GUID: "create-guid-crud-123", - } - - // Create the resource using WriteEvent - createRV, err := backend.WriteEvent(ctx, writeEvent) - require.NoError(t, err) - require.Greater(t, createRV, int64(0)) - - // Verify created resource key_path - verifyKeyPath(t, db, ctx, key, "created", createRV, "") - - t.Run("Update resource", func(t *testing.T) { - // Update the resource - testObj.Object["spec"] = map[string]interface{}{ - "title": "My Updated Playlist", - } - - updatedMetaAccessor, err := utils.MetaAccessor(testObj) - require.NoError(t, err) - - updatedJsonBytes, err := testObj.MarshalJSON() - require.NoError(t, err) - - updateEvent := resource.WriteEvent{ - Type: resourcepb.WatchEvent_MODIFIED, - Key: key, - Value: updatedJsonBytes, - Object: updatedMetaAccessor, - PreviousRV: createRV, - GUID: fmt.Sprintf("update-guid-%d", createRV), - } - - // Update the resource - updateRV, err := backend.WriteEvent(ctx, updateEvent) - require.NoError(t, err) - require.Greater(t, updateRV, createRV) - - // Verify updated resource key_path - verifyKeyPath(t, db, ctx, key, "updated", updateRV, "") - - t.Run("Delete resource", func(t *testing.T) { - deleteEvent := resource.WriteEvent{ - Type: resourcepb.WatchEvent_DELETED, - Key: key, - Value: updatedJsonBytes, // Keep the last known value - Object: updatedMetaAccessor, - PreviousRV: updateRV, - GUID: fmt.Sprintf("delete-guid-%d", updateRV), - } - - // Delete the resource - deleteRV, err := backend.WriteEvent(ctx, deleteEvent) - require.NoError(t, err) - require.Greater(t, deleteRV, updateRV) - - // Verify deleted resource key_path - verifyKeyPath(t, db, ctx, key, "deleted", deleteRV, "") - }) - }) - }) - - t.Run("Resource with folder", func(t *testing.T) { - // Create a resource in a folder - folderKey := &resourcepb.ResourceKey{ - Group: "dashboard.grafana.app", - Resource: "dashboards", - Namespace: nsPrefix + "-default", - Name: "my-dashboard", - } - - // Create dashboard object with folder - dashboardObj := &unstructured.Unstructured{ - Object: map[string]interface{}{ - "apiVersion": "dashboard.grafana.app/v0alpha1", - "kind": "Dashboard", - "metadata": map[string]interface{}{ - "name": "my-dashboard", - "namespace": nsPrefix + "-default", - "uid": "dash-uid-456", - "annotations": map[string]interface{}{ - "grafana.app/folder": "test-folder", - }, - }, - "spec": map[string]interface{}{ - "title": "My Dashboard", - }, - }, - } - - folderMetaAccessor, err := utils.MetaAccessor(dashboardObj) - require.NoError(t, err) - - folderJsonBytes, err := dashboardObj.MarshalJSON() - require.NoError(t, err) - - folderWriteEvent := resource.WriteEvent{ - Type: resourcepb.WatchEvent_ADDED, - Key: folderKey, - Value: folderJsonBytes, - Object: folderMetaAccessor, - PreviousRV: 0, - GUID: "folder-guid-456", - } - - // Create the dashboard in folder - folderRV, err := backend.WriteEvent(ctx, folderWriteEvent) - require.NoError(t, err) - require.Greater(t, folderRV, int64(0)) - - // Verify folder resource key_path includes folder - verifyKeyPath(t, db, ctx, folderKey, "created", folderRV, "test-folder") - }) -} - -// verifyKeyPath is a helper function to verify key_path generation -func verifyKeyPath(t *testing.T, db sqldb.DB, ctx context.Context, key *resourcepb.ResourceKey, action string, resourceVersion int64, expectedFolder string) { - var query string - if db.DriverName() == "postgres" { - query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE namespace = $1 AND name = $2 AND resource_version = $3" - } else { - query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE namespace = ? AND name = ? AND resource_version = ?" - } - rows, err := db.QueryContext(ctx, query, key.Namespace, key.Name, resourceVersion) - require.NoError(t, err) - - require.True(t, rows.Next()) - - var keyPath string - var actualRV int64 - var actualAction int - var actualFolder string - - err = rows.Scan(&keyPath, &actualRV, &actualAction, &actualFolder) - require.NoError(t, err) - err = rows.Close() - require.NoError(t, err) - - // Verify basic key_path format - require.Contains(t, keyPath, "unified/data/") - require.Contains(t, keyPath, key.Group) - require.Contains(t, keyPath, key.Resource) - require.Contains(t, keyPath, key.Namespace) - require.Contains(t, keyPath, key.Name) - - // Verify action suffix - require.Contains(t, keyPath, fmt.Sprintf("~%s~", action)) - - // Verify snowflake calculation - expectedSnowflake := (((resourceVersion / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (resourceVersion % 1000) - require.Contains(t, keyPath, fmt.Sprintf("/%d~", expectedSnowflake), fmt.Sprintf("actual RV: %d", actualRV)) - - // Verify folder if specified - if expectedFolder != "" { - require.Equal(t, expectedFolder, actualFolder) - require.Contains(t, keyPath, expectedFolder) - } - - // Verify action code matches - var expectedActionCode int - switch action { - case "created": - expectedActionCode = 1 - case "updated": - expectedActionCode = 2 - case "deleted": - expectedActionCode = 3 - } - require.Equal(t, expectedActionCode, actualAction) - - t.Logf("Action: %s, RV: %d, Snowflake: %d", action, resourceVersion, expectedSnowflake) - t.Logf("Key_path: %s", keyPath) - if expectedFolder != "" { - t.Logf("Folder: %s", actualFolder) - } -} diff --git a/pkg/storage/unified/testing/storage_backend_sql_compatibility.go b/pkg/storage/unified/testing/storage_backend_sql_compatibility.go new file mode 100644 index 00000000000..64184cbe23e --- /dev/null +++ b/pkg/storage/unified/testing/storage_backend_sql_compatibility.go @@ -0,0 +1,1272 @@ +package test + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + claims "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" + sqldb "github.com/grafana/grafana/pkg/storage/unified/sql/db" + "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" + "github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager" + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" + "github.com/grafana/grafana/pkg/util/testutil" +) + +func NewTestSqlKvBackend(t *testing.T, ctx context.Context, withRvManager bool) (resource.KVBackend, sqldb.DB) { + dbstore := db.InitTestDB(t) + eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) + require.NoError(t, err) + kv, err := resource.NewSQLKV(eDB) + require.NoError(t, err) + db, err := eDB.Init(ctx) + require.NoError(t, err) + + kvOpts := resource.KVBackendOptions{ + KvStore: kv, + } + + if db.DriverName() == "sqlite3" { + kvOpts.UseChannelNotifier = true + } + + if withRvManager { + dialect := sqltemplate.DialectForDriver(db.DriverName()) + rvManager, err := rvmanager.NewResourceVersionManager(rvmanager.ResourceManagerOptions{ + Dialect: dialect, + DB: db, + }) + require.NoError(t, err) + + kvOpts.RvManager = rvManager + } + + backend, err := resource.NewKVStorageBackend(kvOpts) + require.NoError(t, err) + return backend, db +} + +func RunSQLStorageBackendCompatibilityTest(t *testing.T, newSqlBackend, newKvBackend NewBackendWithDBFunc, opts *TestOptions) { + if opts == nil { + opts = &TestOptions{} + } + + if opts.NSPrefix == "" { + opts.NSPrefix = GenerateRandomNSPrefix() + } + + t.Logf("Running tests with namespace prefix: %s", opts.NSPrefix) + + cases := []struct { + name string + fn func(*testing.T, resource.StorageBackend, resource.StorageBackend, string, sqldb.DB) + }{ + {"key_path generation", runTestIntegrationBackendKeyPathGeneration}, + {"sql backend fields compatibility", runTestSQLBackendFieldsCompatibility}, + {"cross backend consistency", runTestCrossBackendConsistency}, + {"concurrent operations stress", runTestConcurrentOperationsStress}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if opts.SkipTests[tc.name] { + t.Skip() + } + + kvbackend, db := newKvBackend(t.Context()) + sqlbackend, _ := newSqlBackend(t.Context()) + + // Skip on SQLite due to concurrency limitations + if db.DriverName() == "sqlite3" { + t.Skip("Skipping concurrent operations stress test on SQLite") + } + + tc.fn(t, sqlbackend, kvbackend, opts.NSPrefix, db) + }) + } +} + +func runTestIntegrationBackendKeyPathGeneration(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) { + ctx := testutil.NewDefaultTestContext(t) + + // Test SQL backend with 3 writes, 3 updates, 3 deletes + t.Run("SQL Backend Operations", func(t *testing.T) { + runKeyPathTest(t, sqlBackend, nsPrefix+"-sql", db, ctx) + }) + + // Test SQL KV backend with 3 writes, 3 updates, 3 deletes + t.Run("SQL KV Backend Operations", func(t *testing.T) { + runKeyPathTest(t, kvBackend, nsPrefix+"-kv", db, ctx) + }) +} + +// runKeyPathTest performs 3 writes, 3 updates, and 3 deletes on a backend then verifies that key_path is properly +// generated across both backends +func runKeyPathTest(t *testing.T, backend resource.StorageBackend, nsPrefix string, db sqldb.DB, ctx context.Context) { + // Create storage server from backend + server, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: backend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + // Track the current resource version for each resource (index 0, 1, 2 for resources 1, 2, 3) + currentRVs := make([]int64, 3) + + // Create 3 resources + for i := 1; i <= 3; i++ { + folder := "" + if i == 2 { + folder = "test-folder" // Resource 2 has folder annotation + } + + opts := PlaylistResourceOptions{ + Name: fmt.Sprintf("test-playlist-%d", i), + Namespace: nsPrefix, + UID: fmt.Sprintf("test-uid-%d", i), + Generation: 1, + Title: fmt.Sprintf("My Test Playlist %d", i), + Folder: folder, + } + + created := createPlaylistResource(t, server, ctx, opts) + currentRVs[i-1] = created.ResourceVersion + + // Verify created resource key_path (with folder for resource 2) + key := createPlaylistKey(nsPrefix, fmt.Sprintf("test-playlist-%d", i)) + if i == 2 { + verifyKeyPath(t, db, ctx, key, "created", created.ResourceVersion, "test-folder") + } else { + verifyKeyPath(t, db, ctx, key, "created", created.ResourceVersion, "") + } + } + + // Update the 3 resources + for i := 1; i <= 3; i++ { + folder := "" + if i == 2 { + folder = "test-folder" // Resource 2 has folder annotation + } + + opts := PlaylistResourceOptions{ + Name: fmt.Sprintf("test-playlist-%d", i), + Namespace: nsPrefix, + UID: fmt.Sprintf("test-uid-%d", i), + Generation: 2, + Title: fmt.Sprintf("My Updated Playlist %d", i), + Folder: folder, + } + + updated := updatePlaylistResource(t, server, ctx, opts, currentRVs[i-1]) + currentRVs[i-1] = updated.ResourceVersion // Update to the latest resource version + + // Verify updated resource key_path (with folder for resource 2) + key := createPlaylistKey(nsPrefix, fmt.Sprintf("test-playlist-%d", i)) + if i == 2 { + verifyKeyPath(t, db, ctx, key, "updated", updated.ResourceVersion, "test-folder") + } else { + verifyKeyPath(t, db, ctx, key, "updated", updated.ResourceVersion, "") + } + } + + // Delete the 3 resources + for i := 1; i <= 3; i++ { + name := fmt.Sprintf("test-playlist-%d", i) + deleted := deletePlaylistResource(t, server, ctx, nsPrefix, name, currentRVs[i-1]) + + // Verify deleted resource key_path (with folder for resource 2) + key := createPlaylistKey(nsPrefix, name) + if i == 2 { + verifyKeyPath(t, db, ctx, key, "deleted", deleted.ResourceVersion, "test-folder") + } else { + verifyKeyPath(t, db, ctx, key, "deleted", deleted.ResourceVersion, "") + } + } +} + +// verifyKeyPath is a helper function to verify key_path generation +func verifyKeyPath(t *testing.T, db sqldb.DB, ctx context.Context, key *resourcepb.ResourceKey, action string, resourceVersion int64, expectedFolder string) { + // For SQL backend (namespace contains "-sql"), resourceVersion is in microsecond format + // but key_path stores snowflake RV, so convert to snowflake + // For KV backend (namespace contains "-kv"), resourceVersion is already in snowflake format + isSqlBackend := strings.Contains(key.Namespace, "-sql") + + var keyPathRV int64 + if isSqlBackend { + // Convert microsecond RV to snowflake for key_path construction + keyPathRV = rvmanager.SnowflakeFromRV(resourceVersion) + } else { + // KV backend already provides snowflake RV + keyPathRV = resourceVersion + } + + // Build the expected key_path using DataKey format: unified/data/group/resource/namespace/name/resourceVersion~action~folder + expectedKeyPath := fmt.Sprintf("unified/data/%s/%s/%s/%s/%d~%s~%s", key.Group, key.Resource, key.Namespace, key.Name, keyPathRV, action, expectedFolder) + + var query string + if db.DriverName() == "postgres" { + query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE key_path = $1" + } else { + query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE key_path = ?" + } + rows, err := db.QueryContext(ctx, query, expectedKeyPath) + require.NoError(t, err) + + require.True(t, rows.Next(), "Resource not found in resource_history table - both SQL and KV backends should write to this table") + + var keyPath string + var actualRV int64 + var actualAction int + var actualFolder string + + err = rows.Scan(&keyPath, &actualRV, &actualAction, &actualFolder) + require.NoError(t, err) + + // Ensure there's exactly one row and no errors + require.False(t, rows.Next()) + require.NoError(t, rows.Err()) + + // Verify basic key_path format + require.Contains(t, keyPath, "unified/data/") + require.Contains(t, keyPath, key.Group) + require.Contains(t, keyPath, key.Resource) + require.Contains(t, keyPath, key.Namespace) + require.Contains(t, keyPath, key.Name) + + // Verify action suffix + require.Contains(t, keyPath, fmt.Sprintf("~%s~", action)) + + // Verify folder if specified + if expectedFolder != "" { + require.Equal(t, expectedFolder, actualFolder) + require.Contains(t, keyPath, expectedFolder) + } + + // Verify action code matches + var expectedActionCode int + switch action { + case "created": + expectedActionCode = 1 + case "updated": + expectedActionCode = 2 + case "deleted": + expectedActionCode = 3 + } + require.Equal(t, expectedActionCode, actualAction) +} + +// runTestSQLBackendFieldsCompatibility tests that KV backend with RvManager populates all SQL backend legacy fields +func runTestSQLBackendFieldsCompatibility(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) { + ctx := testutil.NewDefaultTestContext(t) + + // Create unique namespace for isolation + namespace := nsPrefix + "-fields-test" + + // Test SQL backend with 3 resources through complete lifecycle + t.Run("SQL Backend Operations", func(t *testing.T) { + runSQLBackendFieldsTest(t, sqlBackend, namespace+"-sql", db, ctx) + }) + + // Test KV backend with 3 resources through complete lifecycle + t.Run("KV Backend Operations", func(t *testing.T) { + runSQLBackendFieldsTest(t, kvBackend, namespace+"-kv", db, ctx) + }) +} + +// buildCrossDatabaseQuery converts query placeholders for different database drivers +func buildCrossDatabaseQuery(driverName, baseQuery string) string { + if driverName == "postgres" { + // Convert ? placeholders to $1, $2, etc. for PostgreSQL + placeholderCount := 1 + result := baseQuery + for { + oldResult := result + result = strings.Replace(result, "?", fmt.Sprintf("$%d", placeholderCount), 1) + if result == oldResult { + break + } + placeholderCount++ + } + return result + } + // MySQL and SQLite use ? placeholders + return baseQuery +} + +// runSQLBackendFieldsTest performs complete resource lifecycle testing and verifies all legacy SQL fields +func runSQLBackendFieldsTest(t *testing.T, backend resource.StorageBackend, namespace string, db sqldb.DB, ctx context.Context) { + // Create storage server from backend + server, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: backend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + // Resource definitions with different folder configurations + resources := []struct { + name string + folder string + }{ + {"test-resource-1", ""}, // No folder + {"test-resource-2", "test-folder"}, // With folder + {"test-resource-3", ""}, // No folder + } + + // Track resource versions for each resource + resourceVersions := make([][]int64, len(resources)) // [resourceIndex][versionIndex] + + // Create 3 resources + for i, res := range resources { + // Create the resource using helper function + opts := PlaylistResourceOptions{ + Name: res.name, + Namespace: namespace, + UID: fmt.Sprintf("test-uid-%d", i+1), + Generation: 1, + Title: fmt.Sprintf("Test Playlist %d", i+1), + Folder: res.folder, + } + + created := createPlaylistResource(t, server, ctx, opts) + // Store the resource version + resourceVersions[i] = append(resourceVersions[i], created.ResourceVersion) + } + + // Update 3 resources + for i, res := range resources { + // Update the resource using helper function + opts := PlaylistResourceOptions{ + Name: res.name, + Namespace: namespace, + UID: fmt.Sprintf("test-uid-%d", i+1), + Generation: 2, + Title: fmt.Sprintf("Updated Test Playlist %d", i+1), + Folder: res.folder, + } + + currentRV := resourceVersions[i][len(resourceVersions[i])-1] + updated := updatePlaylistResource(t, server, ctx, opts, currentRV) + // Store the new resource version + resourceVersions[i] = append(resourceVersions[i], updated.ResourceVersion) + } + + // Delete first 2 resources (leave the last one to validate resource table) + for i, res := range resources[:2] { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: res.name, + } + + // Delete the resource using the current resource version + currentRV := resourceVersions[i][len(resourceVersions[i])-1] + deleted, err := server.Delete(ctx, &resourcepb.DeleteRequest{ + Key: key, + ResourceVersion: currentRV, + }) + require.NoError(t, err) + require.Nil(t, deleted.Error) + require.Greater(t, deleted.ResourceVersion, currentRV) + + // Store the delete resource version + resourceVersions[i] = append(resourceVersions[i], deleted.ResourceVersion) + } + + // Verify all legacy SQL fields are populated correctly + verifyResourceHistoryTable(t, db, namespace, resources, resourceVersions) + verifyResourceTable(t, db, namespace, resources, resourceVersions) + verifyResourceVersionTable(t, db, namespace, resources, resourceVersions) +} + +// ResourceHistoryRecord represents a row from the resource_history table +type ResourceHistoryRecord struct { + GUID string + Group string + Resource string + Namespace string + Name string + Value string + Action int + Folder string + PreviousResourceVersion int64 + Generation int + ResourceVersion int64 +} + +// ResourceRecord represents a row from the resource table +type ResourceRecord struct { + GUID string + Group string + Resource string + Namespace string + Name string + Value string + Action int + Folder string + PreviousResourceVersion int64 + ResourceVersion int64 +} + +// ResourceVersionRecord represents a row from the resource_version table +type ResourceVersionRecord struct { + Group string + Resource string + ResourceVersion int64 +} + +// verifyResourceHistoryTable validates all resource_history entries +func verifyResourceHistoryTable(t *testing.T, db sqldb.DB, namespace string, resources []struct{ name, folder string }, resourceVersions [][]int64) { + ctx := t.Context() + query := buildCrossDatabaseQuery(db.DriverName(), ` + SELECT guid, "group", resource, namespace, name, value, action, folder, + previous_resource_version, generation, resource_version + FROM resource_history + WHERE namespace = ? + ORDER BY resource_version ASC + `) + + rows, err := db.QueryContext(ctx, query, namespace) + require.NoError(t, err) + + var records []ResourceHistoryRecord + for rows.Next() { + var record ResourceHistoryRecord + err := rows.Scan( + &record.GUID, &record.Group, &record.Resource, &record.Namespace, &record.Name, + &record.Value, &record.Action, &record.Folder, &record.PreviousResourceVersion, + &record.Generation, &record.ResourceVersion, + ) + require.NoError(t, err) + records = append(records, record) + } + require.NoError(t, rows.Err()) + + // We expect 8 records total: 3 creates + 3 updates + 2 deletes + require.Len(t, records, 8, "Expected 8 resource_history records (3 creates + 3 updates + 2 deletes)") + + // Verify each record - we'll validate in the order they were created (by resource_version) + // The records are already sorted by resource_version ASC, so we just need to verify each one + recordIndex := 0 + for resourceIdx, res := range resources { + // Check create record (action=1, generation=1) + createRecord := records[recordIndex] + verifyResourceHistoryRecord(t, createRecord, namespace, res, resourceIdx, 1, 0, 1, resourceVersions[resourceIdx][0]) + recordIndex++ + } + + for resourceIdx, res := range resources { + // Check update record (action=2, generation=2) + updateRecord := records[recordIndex] + verifyResourceHistoryRecord(t, updateRecord, namespace, res, resourceIdx, 2, resourceVersions[resourceIdx][0], 2, resourceVersions[resourceIdx][1]) + recordIndex++ + } + + for resourceIdx, res := range resources[:2] { + // Check delete record (action=3, generation=0) - only first 2 resources were deleted + deleteRecord := records[recordIndex] + verifyResourceHistoryRecord(t, deleteRecord, namespace, res, resourceIdx, 3, resourceVersions[resourceIdx][1], 0, resourceVersions[resourceIdx][2]) + recordIndex++ + } +} + +// verifyResourceHistoryRecord validates a single resource_history record +func verifyResourceHistoryRecord(t *testing.T, record ResourceHistoryRecord, namespace string, expectedRes struct{ name, folder string }, resourceIdx, expectedAction int, expectedPrevRV int64, expectedGeneration int, expectedRV int64) { + // Validate GUID (should be non-empty) + require.NotEmpty(t, record.GUID, "GUID should not be empty") + + // Validate group/resource/namespace/name + require.Equal(t, "playlist.grafana.app", record.Group) + require.Equal(t, "playlists", record.Resource) + require.Equal(t, namespace, record.Namespace) + require.Equal(t, expectedRes.name, record.Name) + + // Validate value contains expected JSON - server modifies/formats the JSON differently for different operations + // Check for both formats (with and without space after colon) + nameFound := strings.Contains(record.Value, fmt.Sprintf(`"name": "%s"`, expectedRes.name)) || + strings.Contains(record.Value, fmt.Sprintf(`"name":"%s"`, expectedRes.name)) + require.True(t, nameFound, "JSON should contain the expected name field") + + kindFound := strings.Contains(record.Value, `"kind": "Playlist"`) || + strings.Contains(record.Value, `"kind":"Playlist"`) + require.True(t, kindFound, "JSON should contain the expected kind field") + + // Validate action + require.Equal(t, expectedAction, record.Action) + + // Validate folder + if expectedRes.folder == "" { + require.Equal(t, "", record.Folder, "Folder should be empty when no folder annotation") + } else { + require.Equal(t, expectedRes.folder, record.Folder, "Folder should match annotation") + } + + // Validate previous_resource_version + // For KV backend operations, expectedPrevRV is now in snowflake format (returned by KV backend) + // but resource_history table stores microsecond RV, so we need to use IsRvEqual for comparison + if strings.Contains(record.Namespace, "-kv") { + if expectedPrevRV == 0 { + require.Zero(t, record.PreviousResourceVersion) + } else { + require.Equal(t, expectedPrevRV, rvmanager.SnowflakeFromRV(record.PreviousResourceVersion), + "Previous resource version should match (KV backend snowflake format)") + } + } else { + require.Equal(t, expectedPrevRV, record.PreviousResourceVersion) + } + + // Validate generation: 1 for create, 2 for update, 0 for delete + require.Equal(t, expectedGeneration, record.Generation) + + // Validate resource_version + // For KV backend operations, expectedRV is now in snowflake format (returned by KV backend) + // but resource_history table stores microsecond RV, so we need to use IsRvEqual for comparison + if strings.Contains(record.Namespace, "-kv") { + require.True(t, rvmanager.IsRvEqual(expectedRV, record.ResourceVersion), + "Resource version should match (KV backend snowflake format)") + } else { + require.Equal(t, expectedRV, record.ResourceVersion) + } +} + +// verifyResourceTable validates the resource table (latest state only) +func verifyResourceTable(t *testing.T, db sqldb.DB, namespace string, resources []struct{ name, folder string }, resourceVersions [][]int64) { + ctx := t.Context() + query := buildCrossDatabaseQuery(db.DriverName(), ` + SELECT guid, "group", resource, namespace, name, value, action, folder, + previous_resource_version, resource_version + FROM resource + WHERE namespace = ? + ORDER BY name ASC + `) + + rows, err := db.QueryContext(ctx, query, namespace) + require.NoError(t, err) + + var records []ResourceRecord + for rows.Next() { + var record ResourceRecord + err := rows.Scan( + &record.GUID, &record.Group, &record.Resource, &record.Namespace, &record.Name, + &record.Value, &record.Action, &record.Folder, &record.PreviousResourceVersion, + &record.ResourceVersion, + ) + require.NoError(t, err) + records = append(records, record) + } + require.NoError(t, rows.Err()) + + // We expect 1 record since only 2 resources were deleted (the 3rd remains) + require.Len(t, records, 1, "Expected 1 resource record since only 2 resources were deleted") + + // Validate the remaining record (should be the 3rd resource after update) + record := records[0] + require.Equal(t, "playlist.grafana.app", record.Group) + require.Equal(t, "playlists", record.Resource) + require.Equal(t, "test-resource-3", record.Name) + + // Should be an update action (2) - resource table stores latest action + require.Equal(t, 2, record.Action) + + // Validate value contains expected JSON + nameFound := strings.Contains(record.Value, fmt.Sprintf(`"name": "%s"`, "test-resource-3")) || + strings.Contains(record.Value, fmt.Sprintf(`"name":"%s"`, "test-resource-3")) + require.True(t, nameFound, "JSON should contain the expected name field") + + kindFound := strings.Contains(record.Value, `"kind": "Playlist"`) || + strings.Contains(record.Value, `"kind":"Playlist"`) + require.True(t, kindFound, "JSON should contain the expected kind field") + + // Folder should be empty (3rd resource has no folder annotation) + require.Equal(t, "", record.Folder, "3rd resource should have no folder") + + // GUID should be non-empty + require.NotEmpty(t, record.GUID, "GUID should not be empty") + + // Resource version should match the expected version for test-resource-3 (updated version) + expectedRV := resourceVersions[2][1] // test-resource-3's update version + if strings.Contains(namespace, "-kv") { + require.True(t, rvmanager.IsRvEqual(expectedRV, record.ResourceVersion), + "Resource version should match (KV backend snowflake format)") + } else { + require.Equal(t, expectedRV, record.ResourceVersion) + } +} + +// verifyResourceVersionTable validates the resource_version table +func verifyResourceVersionTable(t *testing.T, db sqldb.DB, namespace string, resources []struct{ name, folder string }, resourceVersions [][]int64) { + ctx := t.Context() + query := buildCrossDatabaseQuery(db.DriverName(), ` + SELECT "group", resource, resource_version + FROM resource_version + WHERE "group" = ? AND resource = ? + `) + + // Check that we have exactly one entry for playlist.grafana.app/playlists + rows, err := db.QueryContext(ctx, query, "playlist.grafana.app", "playlists") + require.NoError(t, err) + + var records []ResourceVersionRecord + for rows.Next() { + var record ResourceVersionRecord + err := rows.Scan(&record.Group, &record.Resource, &record.ResourceVersion) + require.NoError(t, err) + records = append(records, record) + } + require.NoError(t, rows.Err()) + + // We expect exactly 1 record for the group+resource combination + require.Len(t, records, 1, "Expected 1 resource_version record for playlist.grafana.app/playlists") + + record := records[0] + require.Equal(t, "playlist.grafana.app", record.Group) + require.Equal(t, "playlists", record.Resource) + + // Find the highest resource version across all resources + var maxRV int64 + for _, rvs := range resourceVersions { + for _, rv := range rvs { + if rv > maxRV { + maxRV = rv + } + } + } + + // The resource_version table should contain the latest RV for the group+resource + // It might be slightly higher due to RV manager operations, so check it's at least our max + // For KV backend, maxRV is in snowflake format but record.ResourceVersion is in microsecond format + // Use IsRvEqual for proper comparison between different RV formats + isKvBackend := strings.Contains(namespace, "-kv") + recordResourceVersion := record.ResourceVersion + if isKvBackend { + recordResourceVersion = rvmanager.SnowflakeFromRV(record.ResourceVersion) + } + + require.Less(t, recordResourceVersion, int64(9223372036854775807), "resource_version should be reasonable") + require.Greater(t, recordResourceVersion, maxRV, "resource_version should be at least the latest RV we tracked") +} + +// runTestCrossBackendConsistency tests basic consistency between SQL and KV backends (lightweight) +func runTestCrossBackendConsistency(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) { + ctx := testutil.NewDefaultTestContext(t) + + // Create storage servers from both backends + sqlServer, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: sqlBackend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + kvServer, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: kvBackend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + // Create isolated namespaces for each test phase + sqlNamespace := nsPrefix + "-concurrent-sql" + kvNamespace := nsPrefix + "-concurrent-kv" + + t.Run("Write to SQL, Read from Both", func(t *testing.T) { + runWriteToOneReadFromBoth(t, sqlServer, kvServer, sqlNamespace+"-writeSQL", ctx, "sql") + }) + + t.Run("Write to KV, Read from Both", func(t *testing.T) { + runWriteToOneReadFromBoth(t, kvServer, sqlServer, kvNamespace+"-writeKV", ctx, "kv") + }) + + t.Run("Resource Version Consistency", func(t *testing.T) { + runResourceVersionConsistencyTest(t, sqlServer, kvServer, nsPrefix+"-rv-consistency", ctx) + }) +} + +// runTestConcurrentOperationsStress tests heavy concurrent operations between SQL and KV backends +func runTestConcurrentOperationsStress(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) { + ctx := testutil.NewDefaultTestContext(t) + + // Create storage servers from both backends + sqlServer, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: sqlBackend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + kvServer, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: kvBackend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + // Create isolated namespace for mixed operations + mixedNamespace := nsPrefix + "-concurrent-mixed" + + // do a single create using the sql backend to initialize the resource_version table + // without this, both backend may try to insert the same group+resource to the resource_version which breaks the + // tests + initNamespace := mixedNamespace + "-init" + initOpts := PlaylistResourceOptions{ + Name: "init-resource", + Namespace: initNamespace, + UID: "init-uid", + Generation: 1, + Title: "Init Resource", + Folder: "", + } + createPlaylistResource(t, sqlServer, ctx, initOpts) + + // Heavy Mixed Concurrent Operations + t.Run("Mixed Concurrent Operations", func(t *testing.T) { + runMixedConcurrentOperations(t, sqlServer, kvServer, mixedNamespace, ctx) + }) +} + +// runWriteToOneReadFromBoth writes resources to one backend then reads from both to verify consistency +func runWriteToOneReadFromBoth(t *testing.T, writeServer, readServer resource.ResourceServer, namespace string, ctx context.Context, writerBackend string) { + // Create 5 test resources + resourceNames := []string{ + fmt.Sprintf("resource-%s-1", writerBackend), + fmt.Sprintf("resource-%s-2", writerBackend), + fmt.Sprintf("resource-%s-3", writerBackend), + fmt.Sprintf("resource-%s-4", writerBackend), + fmt.Sprintf("resource-%s-5", writerBackend), + } + + createdResourceVersions := make([]int64, len(resourceNames)) + + // Write all resources to the write backend + for i, resourceName := range resourceNames { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: resourceName, + } + + resourceJSON := fmt.Sprintf(`{ + "apiVersion": "playlist.grafana.app/v0alpha1", + "kind": "Playlist", + "metadata": { + "name": "%s", + "namespace": "%s", + "uid": "test-uid-%d", + "generation": 1 + }, + "spec": { + "title": "Concurrent Test Playlist %d" + } + }`, resourceName, namespace, i+1, i+1) + + created, err := writeServer.Create(ctx, &resourcepb.CreateRequest{ + Key: key, + Value: []byte(resourceJSON), + }) + require.NoError(t, err) + require.Nil(t, created.Error) + require.Greater(t, created.ResourceVersion, int64(0)) + createdResourceVersions[i] = created.ResourceVersion + } + + // Add a small delay to ensure data propagates + time.Sleep(10 * time.Millisecond) + + // Read from both backends and compare payloads + for _, resourceName := range resourceNames { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: resourceName, + } + + // Read from write backend + writeResp, err := writeServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err, "Failed to read %s from write backend", resourceName) + require.Nil(t, writeResp.Error, "Read error from write backend %s: %s", resourceName, writeResp.Error) + require.Greater(t, writeResp.ResourceVersion, int64(0), "Invalid resource version for %s on write backend", resourceName) + + // Read from read backend + readResp, err := readServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err, "Failed to read %s from read backend", resourceName) + require.Nil(t, readResp.Error, "Read error from read backend %s: %s", resourceName, readResp.Error) + require.Greater(t, readResp.ResourceVersion, int64(0), "Invalid resource version for %s on read backend", resourceName) + + // Validate that both backends return identical payload content + require.JSONEq(t, string(writeResp.Value), string(readResp.Value), + "Payload mismatch for resource %s between write and read backends.\nWrite backend: %s\nRead backend: %s", + resourceName, string(writeResp.Value), string(readResp.Value)) + + // Validate that both backends return equivalent resource versions using rvmanager compatibility check + // Note: rvmanager.IsRvEqual expects snowflake format as first parameter, so we check both orderings + require.True(t, rvmanager.IsRvEqual(writeResp.ResourceVersion, readResp.ResourceVersion) || rvmanager.IsRvEqual(readResp.ResourceVersion, writeResp.ResourceVersion), + "Resource version mismatch for resource %s between backends.\nWrite backend (%s): %d\nRead backend (%s): %d", + resourceName, writerBackend, writeResp.ResourceVersion, getOtherBackendName(writerBackend), readResp.ResourceVersion) + + t.Logf("✓ Resource %s: payload and resource version (%d) consistency verified between %s (write) and %s (read) backends", + resourceName, writeResp.ResourceVersion, writerBackend, getOtherBackendName(writerBackend)) + } + + // Verify List consistency between backends + verifyListConsistencyBetweenServers(t, writeServer, readServer, namespace, len(resourceNames)) +} + +// getOtherBackendName returns the complementary backend name +func getOtherBackendName(backend string) string { + if backend == "sql" { + return "kv" + } + return "sql" +} + +// runMixedConcurrentOperations runs different operations simultaneously on both backends +func runMixedConcurrentOperations(t *testing.T, sqlServer, kvServer resource.ResourceServer, namespace string, ctx context.Context) { + var wg sync.WaitGroup + errors := make(chan error, 20) + startBarrier := make(chan struct{}) + + // Use higher operation counts to ensure concurrency + opCounts := BackendOperationCounts{ + Creates: 25, + Updates: 15, + Deletes: 10, + } + + // SQL backend operations + wg.Go(func() { + <-startBarrier // Wait for signal to start + if err := runBackendOperationsWithCounts(ctx, sqlServer, namespace+"-sql", "sql", opCounts); err != nil { + errors <- fmt.Errorf("SQL backend operations failed: %w", err) + } + }) + + // KV backend operations + wg.Go(func() { + <-startBarrier // Wait for signal to start + if err := runBackendOperationsWithCounts(ctx, kvServer, namespace+"-kv", "kv", opCounts); err != nil { + errors <- fmt.Errorf("KV backend operations failed: %w", err) + } + }) + + // Start both goroutines simultaneously + close(startBarrier) + + // Wait for operations to complete with timeout + done := make(chan bool) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + // Operations completed + case <-time.After(10 * time.Second): + t.Fatal("Timeout waiting for mixed concurrent operations") + } + + // Check for errors + close(errors) + for err := range errors { + t.Error(err) + } + + // Allow some time for data propagation + time.Sleep(50 * time.Millisecond) + + // Calculate expected remaining resources based on operation counts + expectedRemaining := opCounts.Creates - opCounts.Deletes // Creates - Deletes = Remaining + + // Verify consistency of resources created by SQL backend operations + // Note: Skip resource version checking since these are separate operations on different backends + verifyListConsistencyBetweenServersWithRVCheck(t, sqlServer, kvServer, namespace+"-sql", expectedRemaining, false) + + // Verify consistency of resources created by KV backend operations + // Note: Skip resource version checking since these are separate operations on different backends + verifyListConsistencyBetweenServersWithRVCheck(t, sqlServer, kvServer, namespace+"-kv", expectedRemaining, false) +} + +// BackendOperationCounts defines how many operations of each type to perform +type BackendOperationCounts struct { + Creates int + Updates int + Deletes int +} + +// runBackendOperationsWithCounts performs configurable create, update, delete operations on a backend +func runBackendOperationsWithCounts(ctx context.Context, server resource.ResourceServer, namespace, backendType string, counts BackendOperationCounts) error { + // Create resources + resourceVersions := make([]int64, counts.Creates) + for i := 1; i <= counts.Creates; i++ { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: fmt.Sprintf("resource-%s-%d", backendType, i), + } + + resourceJSON := fmt.Sprintf(`{ + "apiVersion": "playlist.grafana.app/v0alpha1", + "kind": "Playlist", + "metadata": { + "name": "resource-%s-%d", + "namespace": "%s", + "uid": "test-uid-%s-%d", + "generation": 1 + }, + "spec": { + "title": "Mixed Test Playlist %s %d" + } + }`, backendType, i, namespace, backendType, i, backendType, i) + + created, err := server.Create(ctx, &resourcepb.CreateRequest{ + Key: key, + Value: []byte(resourceJSON), + }) + if err != nil { + return fmt.Errorf("failed to create resource %d: %w", i, err) + } + if created.Error != nil { + return fmt.Errorf("create error for resource %d: %s", i, created.Error.Message) + } + resourceVersions[i-1] = created.ResourceVersion + } + + // Update resources (only update as many as we have, limited by creates and updates count) + updateCount := counts.Updates + if updateCount > counts.Creates { + updateCount = counts.Creates // Can't update more resources than we created + } + for i := 1; i <= updateCount; i++ { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: fmt.Sprintf("resource-%s-%d", backendType, i), + } + + updatedJSON := fmt.Sprintf(`{ + "apiVersion": "playlist.grafana.app/v0alpha1", + "kind": "Playlist", + "metadata": { + "name": "resource-%s-%d", + "namespace": "%s", + "uid": "test-uid-%s-%d", + "generation": 2 + }, + "spec": { + "title": "Updated Mixed Test Playlist %s %d" + } + }`, backendType, i, namespace, backendType, i, backendType, i) + + updated, err := server.Update(ctx, &resourcepb.UpdateRequest{ + Key: key, + Value: []byte(updatedJSON), + ResourceVersion: resourceVersions[i-1], + }) + if err != nil { + return fmt.Errorf("failed to update resource %d: %w", i, err) + } + if updated.Error != nil { + return fmt.Errorf("update error for resource %d: %s", i, updated.Error.Message) + } + resourceVersions[i-1] = updated.ResourceVersion + } + + // Delete resources (only delete as many as we have, limited by creates and deletes count) + deleteCount := counts.Deletes + if deleteCount > updateCount { + deleteCount = updateCount // Can only delete resources that were updated (have latest RV) + } + for i := 1; i <= deleteCount; i++ { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: fmt.Sprintf("resource-%s-%d", backendType, i), + } + + deleted, err := server.Delete(ctx, &resourcepb.DeleteRequest{ + Key: key, + ResourceVersion: resourceVersions[i-1], // Use the resource version from updates + }) + if err != nil { + return fmt.Errorf("failed to delete resource %d: %w", i, err) + } + if deleted.Error != nil { + return fmt.Errorf("delete error for resource %d: %s", i, deleted.Error.Message) + } + } + + return nil +} + +// runResourceVersionConsistencyTest verifies resource version handling across backends +func runResourceVersionConsistencyTest(t *testing.T, sqlServer, kvServer resource.ResourceServer, namespace string, ctx context.Context) { + // Create a resource on SQL backend + opts := PlaylistResourceOptions{ + Name: "rv-test-resource", + Namespace: namespace, + UID: "test-uid-rv", + Generation: 1, + Title: "RV Test Playlist", + Folder: "", // No folder + } + + createPlaylistResource(t, sqlServer, ctx, opts) + + // Allow data to propagate + time.Sleep(10 * time.Millisecond) + + // Read from KV backend to get the same resource + key := createPlaylistKey(namespace, "rv-test-resource") + kvRead, err := kvServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err) + require.Nil(t, kvRead.Error) + // Note: Resource versions may differ between backends, but content should be the same + require.Greater(t, kvRead.ResourceVersion, int64(0), "KV backend should return a valid resource version") + + // Read from SQL backend to compare content + sqlReadInitial, err := sqlServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err) + require.Nil(t, sqlReadInitial.Error) + require.JSONEq(t, string(sqlReadInitial.Value), string(kvRead.Value), "Both backends should return the same initial content") + + // Update via KV backend + updateOpts := PlaylistResourceOptions{ + Name: "rv-test-resource", + Namespace: namespace, + UID: "test-uid-rv", + Generation: 2, + Title: "Updated RV Test Playlist", + Folder: "", // No folder + } + + updatePlaylistResource(t, kvServer, ctx, updateOpts, kvRead.ResourceVersion) + + // Allow data to propagate + time.Sleep(10 * time.Millisecond) + + // Read from SQL backend to verify consistency + sqlRead, err := sqlServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err) + require.Nil(t, sqlRead.Error) + // Note: Resource versions may differ, but content should be consistent + require.Greater(t, sqlRead.ResourceVersion, int64(0), "SQL backend should return a valid resource version") + + // Verify both backends return the same content - we need to read from KV again to get the Value + kvReadAfterUpdate, err := kvServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err) + require.Nil(t, kvReadAfterUpdate.Error) + require.JSONEq(t, string(kvReadAfterUpdate.Value), string(sqlRead.Value), "Both backends should return the same updated content") +} + +// verifyListConsistencyBetweenServers verifies that both servers return consistent list results +func verifyListConsistencyBetweenServers(t *testing.T, server1, server2 resource.ResourceServer, namespace string, expectedCount int) { + verifyListConsistencyBetweenServersWithRVCheck(t, server1, server2, namespace, expectedCount, true) +} + +// verifyListConsistencyBetweenServersWithRVCheck verifies list consistency with optional resource version checking +func verifyListConsistencyBetweenServersWithRVCheck(t *testing.T, server1, server2 resource.ResourceServer, namespace string, expectedCount int, checkResourceVersions bool) { + ctx := testutil.NewDefaultTestContext(t) + + // Get lists from both servers + list1, err := server1.List(ctx, &resourcepb.ListRequest{ + Options: &resourcepb.ListOptions{ + Key: &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + }, + }, + }) + require.NoError(t, err) + require.Nil(t, list1.Error) + + list2, err := server2.List(ctx, &resourcepb.ListRequest{ + Options: &resourcepb.ListOptions{ + Key: &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + }, + }, + }) + require.NoError(t, err) + require.Nil(t, list2.Error) + + // Create maps for easier comparison by extracting names from JSON + items1 := make(map[string]*resourcepb.ResourceWrapper) + for _, item := range list1.Items { + itemNamespace := extractResourceNamespaceFromJSON(t, item.Value) + if itemNamespace == namespace { // Only compare items from our exact namespace + name := extractResourceNameFromJSON(t, item.Value) + items1[name] = item + } + } + + items2 := make(map[string]*resourcepb.ResourceWrapper) + for _, item := range list2.Items { + itemNamespace := extractResourceNamespaceFromJSON(t, item.Value) + if itemNamespace == namespace { // Only compare items from our exact namespace + name := extractResourceNameFromJSON(t, item.Value) + items2[name] = item + } + } + + // Verify counts match after filtering by namespace + require.Equal(t, expectedCount, len(items1), "Server 1 should return expected count after filtering") + require.Equal(t, expectedCount, len(items2), "Server 2 should return expected count after filtering") + require.Equal(t, len(items1), len(items2), "Both servers should return same count after filtering") + + // Verify all items exist in both lists with same content and resource version + for name, item1 := range items1 { + item2, exists := items2[name] + require.True(t, exists, "Item %s should exist in both lists", name) + require.Greater(t, item1.ResourceVersion, int64(0), "Item1 should have valid resource version for %s", name) + require.Greater(t, item2.ResourceVersion, int64(0), "Item2 should have valid resource version for %s", name) + require.JSONEq(t, string(item1.Value), string(item2.Value), "Content should match for %s", name) + + // Validate that both backends return equivalent resource versions using rvmanager compatibility check + if checkResourceVersions { + require.True(t, rvmanager.IsRvEqual(item1.ResourceVersion, item2.ResourceVersion) || rvmanager.IsRvEqual(item2.ResourceVersion, item1.ResourceVersion), + "Resource version mismatch for item %s between backends. Item1: %d, Item2: %d", name, item1.ResourceVersion, item2.ResourceVersion) + } + } +} + +// extractResourceNameFromJSON extracts the resource name from JSON metadata +func extractResourceNameFromJSON(t *testing.T, jsonData []byte) string { + var obj map[string]interface{} + err := json.Unmarshal(jsonData, &obj) + require.NoError(t, err, "Failed to unmarshal JSON") + + metadata, ok := obj["metadata"].(map[string]interface{}) + require.True(t, ok, "metadata field not found or not an object") + + name, ok := metadata["name"].(string) + require.True(t, ok, "name field not found or not a string") + + return name +} + +// extractResourceNamespaceFromJSON extracts the resource namespace from JSON metadata +func extractResourceNamespaceFromJSON(t *testing.T, jsonData []byte) string { + var obj map[string]interface{} + err := json.Unmarshal(jsonData, &obj) + require.NoError(t, err, "Failed to unmarshal JSON") + + metadata, ok := obj["metadata"].(map[string]interface{}) + require.True(t, ok, "metadata field not found or not an object") + + namespace, ok := metadata["namespace"].(string) + require.True(t, ok, "namespace field not found or not a string") + + return namespace +} + +// PlaylistResourceOptions defines options for creating test playlist resources +type PlaylistResourceOptions struct { + Name string + Namespace string + UID string + Generation int + Title string + Folder string // optional - empty string means no folder +} + +// createPlaylistJSON creates standardized JSON for playlist resources +func createPlaylistJSON(opts PlaylistResourceOptions) []byte { + folderAnnotation := "" + if opts.Folder != "" { + folderAnnotation = fmt.Sprintf(`, + "annotations": { + "grafana.app/folder": "%s" + }`, opts.Folder) + } + + jsonStr := fmt.Sprintf(`{ + "apiVersion": "playlist.grafana.app/v0alpha1", + "kind": "Playlist", + "metadata": { + "name": "%s", + "namespace": "%s", + "uid": "%s", + "generation": %d%s + }, + "spec": { + "title": "%s" + } + }`, opts.Name, opts.Namespace, opts.UID, opts.Generation, folderAnnotation, opts.Title) + + return []byte(jsonStr) +} + +// createPlaylistKey creates standardized ResourceKey for playlist resources +func createPlaylistKey(namespace, name string) *resourcepb.ResourceKey { + return &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: name, + } +} + +// createPlaylistResource creates a playlist resource using the server with consistent error handling +func createPlaylistResource(t *testing.T, server resource.ResourceServer, ctx context.Context, opts PlaylistResourceOptions) *resourcepb.CreateResponse { + t.Helper() + key := createPlaylistKey(opts.Namespace, opts.Name) + value := createPlaylistJSON(opts) + + created, err := server.Create(ctx, &resourcepb.CreateRequest{ + Key: key, + Value: value, + }) + require.NoError(t, err) + require.Nil(t, created.Error) + require.Greater(t, created.ResourceVersion, int64(0)) + + return created +} + +// updatePlaylistResource updates a playlist resource using the server with consistent error handling +func updatePlaylistResource(t *testing.T, server resource.ResourceServer, ctx context.Context, opts PlaylistResourceOptions, resourceVersion int64) *resourcepb.UpdateResponse { + t.Helper() + key := createPlaylistKey(opts.Namespace, opts.Name) + value := createPlaylistJSON(opts) + + updated, err := server.Update(ctx, &resourcepb.UpdateRequest{ + Key: key, + Value: value, + ResourceVersion: resourceVersion, + }) + require.NoError(t, err) + require.Nil(t, updated.Error) + require.Greater(t, updated.ResourceVersion, int64(0)) // Just check it's positive, not necessarily greater than input + + return updated +} + +// deletePlaylistResource deletes a playlist resource using the server with consistent error handling +func deletePlaylistResource(t *testing.T, server resource.ResourceServer, ctx context.Context, namespace, name string, resourceVersion int64) *resourcepb.DeleteResponse { + t.Helper() + key := createPlaylistKey(namespace, name) + + deleted, err := server.Delete(ctx, &resourcepb.DeleteRequest{ + Key: key, + ResourceVersion: resourceVersion, + }) + require.NoError(t, err) + require.Nil(t, deleted.Error) + require.Greater(t, deleted.ResourceVersion, int64(0)) + + return deleted +} diff --git a/pkg/storage/unified/testing/storage_backend_test.go b/pkg/storage/unified/testing/storage_backend_test.go index 70e3b15aa7b..96b9d5716db 100644 --- a/pkg/storage/unified/testing/storage_backend_test.go +++ b/pkg/storage/unified/testing/storage_backend_test.go @@ -7,11 +7,8 @@ import ( badger "github.com/dgraph-io/badger/v4" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" - sqldb "github.com/grafana/grafana/pkg/storage/unified/sql/db" - "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" + "github.com/grafana/grafana/pkg/util/testutil" ) func TestBadgerKVStorageBackend(t *testing.T) { @@ -40,49 +37,32 @@ func TestBadgerKVStorageBackend(t *testing.T) { }) } -func TestSQLKVStorageBackend(t *testing.T) { - newBackendFunc := func(ctx context.Context) (resource.StorageBackend, sqldb.DB) { - dbstore := db.InitTestDB(t) - eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) - require.NoError(t, err) - kv, err := resource.NewSQLKV(eDB) - require.NoError(t, err) - kvOpts := resource.KVBackendOptions{ - KvStore: kv, - } - backend, err := resource.NewKVStorageBackend(kvOpts) - require.NoError(t, err) - db, err := eDB.Init(ctx) - require.NoError(t, err) - return backend, db +func TestIntegrationSQLKVStorageBackend(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + skipTests := map[string]bool{ + TestBlobSupport: true, + TestListModifiedSince: true, + TestGetResourceLastImportTime: true, } - RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend { - backend, _ := newBackendFunc(ctx) - return backend - }, &TestOptions{ - NSPrefix: "sqlkvstorage-test", - SkipTests: map[string]bool{ - TestHappyPath: true, - TestWatchWriteEvents: true, - TestList: true, - TestBlobSupport: true, - TestGetResourceStats: true, - TestListHistory: true, - TestListHistoryErrorReporting: true, - TestListModifiedSince: true, - TestListTrash: true, - TestCreateNewResource: true, - TestGetResourceLastImportTime: true, - TestOptimisticLocking: true, - TestKeyPathGeneration: true, - }, + t.Run("Without RvManager", func(t *testing.T) { + RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend { + backend, _ := NewTestSqlKvBackend(t, ctx, false) + return backend + }, &TestOptions{ + NSPrefix: "sqlkvstoragetest", + SkipTests: skipTests, + }) }) - RunSQLStorageBackendCompatibilityTest(t, newBackendFunc, &TestOptions{ - NSPrefix: "sqlkvstorage-compatibility-test", - SkipTests: map[string]bool{ - TestKeyPathGeneration: true, - }, + t.Run("With RvManager", func(t *testing.T) { + RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend { + backend, _ := NewTestSqlKvBackend(t, ctx, true) + return backend + }, &TestOptions{ + NSPrefix: "sqlkvstoragetest-rvmanager", + SkipTests: skipTests, + }) }) } diff --git a/pkg/tests/api/elasticsearch/elasticsearch_test.go b/pkg/tests/api/elasticsearch/elasticsearch_test.go index 09277c944f0..651dd74e9e2 100644 --- a/pkg/tests/api/elasticsearch/elasticsearch_test.go +++ b/pkg/tests/api/elasticsearch/elasticsearch_test.go @@ -24,6 +24,24 @@ func TestMain(m *testing.M) { testsuite.Run(m) } +// mockElasticsearchHandler returns a handler that mocks Elasticsearch endpoints. +// It responds to GET / with cluster info (required for datasource initialization) +// and returns 401 Unauthorized for all other requests. +func mockElasticsearchHandler(onRequest func(r *http.Request)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":{"build_flavor":"default","number":"8.0.0"}}`)) + default: + if onRequest != nil { + onRequest(r) + } + w.WriteHeader(http.StatusUnauthorized) + } + } +} + func TestIntegrationElasticsearch(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) @@ -35,9 +53,8 @@ func TestIntegrationElasticsearch(t *testing.T) { ctx := context.Background() var outgoingRequest *http.Request - outgoingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + outgoingServer := httptest.NewServer(mockElasticsearchHandler(func(r *http.Request) { outgoingRequest = r - w.WriteHeader(http.StatusUnauthorized) })) t.Cleanup(outgoingServer.Close) diff --git a/pkg/tests/api/plugins/data/expectedListResp.json b/pkg/tests/api/plugins/data/expectedListResp.json index 24f705eccd1..83debc4c410 100644 --- a/pkg/tests/api/plugins/data/expectedListResp.json +++ b/pkg/tests/api/plugins/data/expectedListResp.json @@ -209,7 +209,7 @@ "path": "public/plugins/grafana-azure-monitor-datasource/img/azure_monitor_cpu.png" } ], - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": [ "azure", @@ -589,7 +589,7 @@ "hasUpdate": false, "defaultNavUrl": "/plugins/datagrid/", "category": "", - "state": "beta", + "state": "deprecated", "signature": "internal", "signatureType": "", "signatureOrg": "", @@ -639,7 +639,7 @@ ] }, "dependencies": { - "grafanaDependency": "", + "grafanaDependency": "\u003e=11.6.0", "grafanaVersion": "*", "plugins": [], "extensions": { @@ -880,7 +880,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -934,7 +934,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": [ "grafana", @@ -1000,7 +1000,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -1217,7 +1217,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -1325,7 +1325,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -1375,7 +1375,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -1425,7 +1425,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -1575,7 +1575,7 @@ }, "build": {}, "screenshots": null, - "version": "", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -1629,7 +1629,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": [ "grafana", @@ -1734,7 +1734,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -2042,7 +2042,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -2092,7 +2092,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -2445,7 +2445,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, diff --git a/pkg/tests/apis/alerting/notifications/receivers/imported_test.go b/pkg/tests/apis/alerting/notifications/receivers/imported_test.go index f265834b229..56d19aa0aaf 100644 --- a/pkg/tests/apis/alerting/notifications/receivers/imported_test.go +++ b/pkg/tests/apis/alerting/notifications/receivers/imported_test.go @@ -10,10 +10,10 @@ import ( "github.com/grafana/alerting/notify" "github.com/grafana/alerting/receivers/schema" + "github.com/grafana/grafana-app-sdk/resource" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/api/errors" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/alertingnotifications/v0alpha1" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -21,7 +21,6 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/tests/api/alerting" "github.com/grafana/grafana/pkg/tests/apis" - test_common "github.com/grafana/grafana/pkg/tests/apis/alerting/notifications/common" "github.com/grafana/grafana/pkg/tests/testinfra" ) @@ -34,7 +33,8 @@ func TestIntegrationReadImported_Snapshot(t *testing.T) { }, }) - receiverClient := test_common.NewReceiverClient(t, helper.Org1.Admin) + receiverClient, err := v0alpha1.NewReceiverClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) cliCfg := helper.Org1.Admin.NewRestConfig() alertingApi := alerting.NewAlertingLegacyAPIClient(helper.GetEnv().Server.HTTPServer.Listener.Addr().String(), cliCfg.Username, cliCfg.Password) @@ -58,9 +58,9 @@ func TestIntegrationReadImported_Snapshot(t *testing.T) { response := alertingApi.ConvertPrometheusPostAlertmanagerConfig(t, amConfig, headers) require.Equal(t, "success", response.Status) - receiversRaw, err := receiverClient.Client.List(ctx, v1.ListOptions{}) + receiversRaw, err := receiverClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) - raw, err := receiversRaw.MarshalJSON() + raw, err := json.Marshal(receiversRaw) require.NoError(t, err) expectedBytes, err := os.ReadFile(path.Join("test-data", "imported-expected-snapshot.json")) @@ -74,7 +74,7 @@ func TestIntegrationReadImported_Snapshot(t *testing.T) { require.NoError(t, err) } - receivers, err := receiverClient.List(ctx, v1.ListOptions{}) + receivers, err := receiverClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) t.Run("secure fields should be properly masked", func(t *testing.T) { for _, receiver := range receivers.Items { @@ -114,14 +114,14 @@ func TestIntegrationReadImported_Snapshot(t *testing.T) { toUpdate := receivers.Items[1] toUpdate.Spec.Title = "another title" - _, err = receiverClient.Update(ctx, &toUpdate, v1.UpdateOptions{}) + _, err = receiverClient.Update(ctx, &toUpdate, resource.UpdateOptions{}) require.Truef(t, errors.IsBadRequest(err), "Expected BadRequest but got %s", err) }) t.Run("should not be able to delete", func(t *testing.T) { toDelete := receivers.Items[1] - err = receiverClient.Delete(ctx, toDelete.Name, v1.DeleteOptions{}) + err = receiverClient.Delete(ctx, resource.Identifier{Namespace: apis.DefaultNamespace, Name: toDelete.Name}, resource.DeleteOptions{}) require.Truef(t, errors.IsBadRequest(err), "Expected BadRequest but got %s", err) }) } diff --git a/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go b/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go index 5497c8f1685..52fc1fe1a21 100644 --- a/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go +++ b/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go @@ -15,12 +15,12 @@ import ( "github.com/grafana/alerting/notify/notifytest" "github.com/grafana/alerting/receivers/line" "github.com/grafana/alerting/receivers/schema" + "github.com/grafana/grafana-app-sdk/resource" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/api/errors" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/types" "github.com/grafana/alerting/notify" @@ -65,7 +65,8 @@ func TestIntegrationResourceIdentifier(t *testing.T) { ctx := context.Background() helper := getTestHelper(t) - client := test_common.NewReceiverClient(t, helper.Org1.Admin) + client, err := v0alpha1.NewReceiverClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) newResource := &v0alpha1.Receiver{ ObjectMeta: v1.ObjectMeta{ Namespace: "default", @@ -77,42 +78,42 @@ func TestIntegrationResourceIdentifier(t *testing.T) { } t.Run("create should fail if object name is specified", func(t *testing.T) { - resource := newResource.Copy().(*v0alpha1.Receiver) - resource.Name = "new-receiver" - _, err := client.Create(ctx, resource, v1.CreateOptions{}) + receiver := newResource.Copy().(*v0alpha1.Receiver) + receiver.Name = "new-receiver" + _, err := client.Create(ctx, receiver, resource.CreateOptions{}) require.Truef(t, errors.IsBadRequest(err), "Expected BadRequest but got %s", err) }) - var resourceID string + var resourceID resource.Identifier t.Run("create should succeed and provide resource name", func(t *testing.T) { - actual, err := client.Create(ctx, newResource, v1.CreateOptions{}) + actual, err := client.Create(ctx, newResource, resource.CreateOptions{}) require.NoError(t, err) require.NotEmptyf(t, actual.Name, "Resource name should not be empty") require.NotEmptyf(t, actual.UID, "Resource UID should not be empty") - resourceID = actual.Name + resourceID = actual.GetStaticMetadata().Identifier() }) t.Run("resource should be available by the identifier", func(t *testing.T) { - actual, err := client.Get(ctx, resourceID, v1.GetOptions{}) + actual, err := client.Get(ctx, resourceID) require.NoError(t, err) require.NotEmptyf(t, actual.Name, "Resource name should not be empty") require.Equal(t, newResource.Spec, actual.Spec) }) t.Run("update should rename receiver if name in the specification changes", func(t *testing.T) { - existing, err := client.Get(ctx, resourceID, v1.GetOptions{}) + existing, err := client.Get(ctx, resourceID) require.NoError(t, err) updated := existing.Copy().(*v0alpha1.Receiver) updated.Spec.Title = "another-newReceiver" - actual, err := client.Update(ctx, updated, v1.UpdateOptions{}) + actual, err := client.Update(ctx, updated, resource.UpdateOptions{}) require.NoError(t, err) require.Equal(t, updated.Spec, actual.Spec) require.NotEqualf(t, updated.Name, actual.Name, "Update should change the resource name but it didn't") require.NotEqualf(t, updated.ResourceVersion, actual.ResourceVersion, "Update should change the resource version but it didn't") - resource, err := client.Get(ctx, actual.Name, v1.GetOptions{}) + resource, err := client.Get(ctx, actual.GetStaticMetadata().Identifier()) require.NoError(t, err) require.Equal(t, actual.Spec, resource.Spec) require.Equal(t, actual.Name, resource.Name) @@ -140,7 +141,8 @@ func TestIntegrationResourcePermissions(t *testing.T) { admin := org1.Admin viewer := org1.Viewer editor := org1.Editor - adminClient := test_common.NewReceiverClient(t, admin) + adminClient, err := v0alpha1.NewReceiverClientFromGenerator(admin.GetClientRegistry()) + require.NoError(t, err) writeACMetadata := []string{"canWrite", "canDelete"} allACMetadata := []string{"canWrite", "canDelete", "canReadSecrets", "canAdmin", "canModifyProtected"} @@ -292,8 +294,10 @@ func TestIntegrationResourcePermissions(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - createClient := test_common.NewReceiverClient(t, tc.creatingUser) - client := test_common.NewReceiverClient(t, tc.testUser) + createClient, err := v0alpha1.NewReceiverClientFromGenerator(tc.creatingUser.GetClientRegistry()) + require.NoError(t, err) + client, err := v0alpha1.NewReceiverClientFromGenerator(tc.testUser.GetClientRegistry()) + require.NoError(t, err) var created = &v0alpha1.Receiver{ ObjectMeta: v1.ObjectMeta{ @@ -308,12 +312,12 @@ func TestIntegrationResourcePermissions(t *testing.T) { require.NoError(t, err) // Create receiver with creatingUser - created, err = createClient.Create(ctx, created, v1.CreateOptions{}) + created, err = createClient.Create(ctx, created, resource.CreateOptions{}) require.NoErrorf(t, err, "Payload %s", string(d)) require.NotNil(t, created) defer func() { - _ = adminClient.Delete(ctx, created.Name, v1.DeleteOptions{}) + _ = adminClient.Delete(ctx, created.GetStaticMetadata().Identifier(), resource.DeleteOptions{}) }() // Assign resource permissions @@ -338,7 +342,7 @@ func TestIntegrationResourcePermissions(t *testing.T) { // Obtain expected responses using admin client as source of truth. expectedGetWithMetadata, expectedListWithMetadata := func() (*v0alpha1.Receiver, *v0alpha1.Receiver) { - expectedGet, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) + expectedGet, err := adminClient.Get(ctx, created.GetStaticMetadata().Identifier()) require.NoError(t, err) require.NotNil(t, expectedGet) @@ -352,7 +356,7 @@ func TestIntegrationResourcePermissions(t *testing.T) { expectedGetWithMetadata.SetAccessControl(ac) } - expectedList, err := adminClient.List(ctx, v1.ListOptions{}) + expectedList, err := adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) expectedListWithMetadata := extractReceiverFromList(expectedList, created.Name) require.NotNil(t, expectedListWithMetadata) @@ -368,26 +372,26 @@ func TestIntegrationResourcePermissions(t *testing.T) { }() t.Run("should be able to list receivers", func(t *testing.T) { - list, err := client.List(ctx, v1.ListOptions{}) + list, err := client.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) listedReceiver := extractReceiverFromList(list, created.Name) assert.Equalf(t, expectedListWithMetadata, listedReceiver, "Expected %v but got %v", expectedListWithMetadata, listedReceiver) }) t.Run("should be able to read receiver by resource identifier", func(t *testing.T) { - got, err := client.Get(ctx, expectedGetWithMetadata.Name, v1.GetOptions{}) + got, err := client.Get(ctx, expectedGetWithMetadata.GetStaticMetadata().Identifier()) require.NoError(t, err) assert.Equalf(t, expectedGetWithMetadata, got, "Expected %v but got %v", expectedGetWithMetadata, got) }) } else { t.Run("list receivers should be empty", func(t *testing.T) { - list, err := client.List(ctx, v1.ListOptions{}) + list, err := client.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) require.Emptyf(t, list.Items, "Expected no receivers but got %v", list.Items) }) t.Run("should be forbidden to read receiver by name", func(t *testing.T) { - _, err := client.Get(ctx, created.Name, v1.GetOptions{}) + _, err := client.Get(ctx, created.GetStaticMetadata().Identifier()) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) } @@ -559,10 +563,12 @@ func TestIntegrationAccessControl(t *testing.T) { }, } - adminClient := test_common.NewReceiverClient(t, helper.Org1.Admin) + adminClient, err := v0alpha1.NewReceiverClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) for _, tc := range testCases { t.Run(fmt.Sprintf("user '%s'", tc.user.Identity.GetLogin()), func(t *testing.T) { - client := test_common.NewReceiverClient(t, tc.user) + client, err := v0alpha1.NewReceiverClientFromGenerator(tc.user.GetClientRegistry()) + require.NoError(t, err) var expected = &v0alpha1.Receiver{ ObjectMeta: v1.ObjectMeta{ @@ -580,29 +586,29 @@ func TestIntegrationAccessControl(t *testing.T) { newReceiver.Spec.Title = fmt.Sprintf("receiver-2-%s", tc.user.Identity.GetLogin()) if tc.canCreate { t.Run("should be able to create receiver", func(t *testing.T) { - actual, err := client.Create(ctx, newReceiver, v1.CreateOptions{}) + actual, err := client.Create(ctx, newReceiver, resource.CreateOptions{}) require.NoErrorf(t, err, "Payload %s", string(d)) require.Equal(t, newReceiver.Spec, actual.Spec) t.Run("should fail if already exists", func(t *testing.T) { - _, err := client.Create(ctx, newReceiver, v1.CreateOptions{}) + _, err := client.Create(ctx, newReceiver, resource.CreateOptions{}) require.Truef(t, errors.IsConflict(err), "expected bad request but got %s", err) }) // Cleanup. - require.NoError(t, adminClient.Delete(ctx, actual.Name, v1.DeleteOptions{})) + require.NoError(t, adminClient.Delete(ctx, actual.GetStaticMetadata().Identifier(), resource.DeleteOptions{})) }) } else { t.Run("should be forbidden to create", func(t *testing.T) { - _, err := client.Create(ctx, newReceiver, v1.CreateOptions{}) + _, err := client.Create(ctx, newReceiver, resource.CreateOptions{}) require.Truef(t, errors.IsForbidden(err), "Payload %s", string(d)) }) } // create resource to proceed with other tests. We don't use the one created above because the user will always // have admin permissions on it. - expected, err = adminClient.Create(ctx, expected, v1.CreateOptions{}) + expected, err = adminClient.Create(ctx, expected, resource.CreateOptions{}) require.NoErrorf(t, err, "Payload %s", string(d)) require.NotNil(t, expected) @@ -627,34 +633,34 @@ func TestIntegrationAccessControl(t *testing.T) { expectedWithMetadata.SetAccessControl("canAdmin") } t.Run("should be able to list receivers", func(t *testing.T) { - list, err := client.List(ctx, v1.ListOptions{}) + list, err := client.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) require.Len(t, list.Items, 2) // default + created }) t.Run("should be able to read receiver by resource identifier", func(t *testing.T) { - got, err := client.Get(ctx, expected.Name, v1.GetOptions{}) + got, err := client.Get(ctx, expected.GetStaticMetadata().Identifier()) require.NoError(t, err) require.Equal(t, expectedWithMetadata, got) t.Run("should get NotFound if resource does not exist", func(t *testing.T) { - _, err := client.Get(ctx, "Notfound", v1.GetOptions{}) + _, err := client.Get(ctx, resource.Identifier{Namespace: apis.DefaultNamespace, Name: "Notfound"}) require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err) }) }) } else { t.Run("list receivers should be empty", func(t *testing.T) { - list, err := client.List(ctx, v1.ListOptions{}) + list, err := client.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) require.Emptyf(t, list.Items, "Expected no receivers but got %v", list.Items) }) t.Run("should be forbidden to read receiver by name", func(t *testing.T) { - _, err := client.Get(ctx, expected.Name, v1.GetOptions{}) + _, err := client.Get(ctx, expected.GetStaticMetadata().Identifier()) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) t.Run("should get forbidden even if name does not exist", func(t *testing.T) { - _, err := client.Get(ctx, "Notfound", v1.GetOptions{}) + _, err := client.Get(ctx, resource.Identifier{Namespace: apis.DefaultNamespace, Name: "Notfound"}) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) }) @@ -668,7 +674,7 @@ func TestIntegrationAccessControl(t *testing.T) { if tc.canUpdate { t.Run("should be able to update receiver", func(t *testing.T) { - updated, err := client.Update(ctx, updatedExpected, v1.UpdateOptions{}) + updated, err := client.Update(ctx, updatedExpected, resource.UpdateOptions{}) require.NoErrorf(t, err, "Payload %s", string(d)) expected = updated @@ -676,7 +682,7 @@ func TestIntegrationAccessControl(t *testing.T) { t.Run("should get NotFound if name does not exist", func(t *testing.T) { up := updatedExpected.Copy().(*v0alpha1.Receiver) up.Name = "notFound" - _, err := client.Update(ctx, up, v1.UpdateOptions{}) + _, err := client.Update(ctx, up, resource.UpdateOptions{}) require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err) }) }) @@ -686,7 +692,7 @@ func TestIntegrationAccessControl(t *testing.T) { createIntegration(t, "webhook"), } - expected, err = adminClient.Update(ctx, updatedExpected, v1.UpdateOptions{}) + expected, err = adminClient.Update(ctx, updatedExpected, resource.UpdateOptions{}) require.NoErrorf(t, err, "Payload %s", string(d)) require.NotNil(t, expected) @@ -695,60 +701,62 @@ func TestIntegrationAccessControl(t *testing.T) { if tc.canUpdateProtected { t.Run("should be able to update protected fields of the receiver", func(t *testing.T) { - updated, err := client.Update(ctx, updatedProtected, v1.UpdateOptions{}) + updated, err := client.Update(ctx, updatedProtected, resource.UpdateOptions{}) require.NoErrorf(t, err, "Payload %s", string(d)) require.NotNil(t, updated) expected = updated }) } else { t.Run("should be forbidden to edit protected fields of the receiver", func(t *testing.T) { - _, err := client.Update(ctx, updatedProtected, v1.UpdateOptions{}) + _, err := client.Update(ctx, updatedProtected, resource.UpdateOptions{}) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) } } else { t.Run("should be forbidden to update receiver", func(t *testing.T) { - _, err := client.Update(ctx, updatedExpected, v1.UpdateOptions{}) + _, err := client.Update(ctx, updatedExpected, resource.UpdateOptions{}) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) t.Run("should get forbidden even if resource does not exist", func(t *testing.T) { up := updatedExpected.Copy().(*v0alpha1.Receiver) up.Name = "notFound" - _, err := client.Update(ctx, up, v1.UpdateOptions{}) + _, err := client.Update(ctx, up, resource.UpdateOptions{ + ResourceVersion: up.ResourceVersion, + }) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) }) require.Falsef(t, tc.canUpdateProtected, "Invalid combination of assertions. CanUpdateProtected should be false") } - deleteOptions := v1.DeleteOptions{Preconditions: &v1.Preconditions{ResourceVersion: util.Pointer(expected.ResourceVersion)}} + deleteOptions := resource.DeleteOptions{Preconditions: resource.DeleteOptionsPreconditions{ResourceVersion: expected.ResourceVersion}} if tc.canDelete { t.Run("should be able to delete receiver", func(t *testing.T) { - err := client.Delete(ctx, expected.Name, deleteOptions) + err := client.Delete(ctx, expected.GetStaticMetadata().Identifier(), deleteOptions) require.NoError(t, err) t.Run("should get NotFound if name does not exist", func(t *testing.T) { - err := client.Delete(ctx, "notfound", v1.DeleteOptions{}) + err := client.Delete(ctx, resource.Identifier{Namespace: apis.DefaultNamespace, Name: "notfound"}, resource.DeleteOptions{}) require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err) }) }) } else { t.Run("should be forbidden to delete receiver", func(t *testing.T) { - err := client.Delete(ctx, expected.Name, deleteOptions) + err := client.Delete(ctx, expected.GetStaticMetadata().Identifier(), deleteOptions) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) t.Run("should be forbidden even if resource does not exist", func(t *testing.T) { - err := client.Delete(ctx, "notfound", v1.DeleteOptions{}) + err := client.Delete(ctx, resource.Identifier{Namespace: apis.DefaultNamespace, Name: "notfound"}, resource.DeleteOptions{}) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) }) - require.NoError(t, adminClient.Delete(ctx, expected.Name, v1.DeleteOptions{})) + require.NoError(t, adminClient.Delete(ctx, expected.GetStaticMetadata().Identifier(), resource.DeleteOptions{})) } if tc.canRead { t.Run("should get empty list if no receivers", func(t *testing.T) { - list, err := client.List(ctx, v1.ListOptions{}) + list, err := client.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) require.Len(t, list.Items, 1) }) @@ -766,7 +774,8 @@ func TestIntegrationInUseMetadata(t *testing.T) { cliCfg := helper.Org1.Admin.NewRestConfig() legacyCli := alerting.NewAlertingLegacyAPIClient(helper.GetEnv().Server.HTTPServer.Listener.Addr().String(), cliCfg.Username, cliCfg.Password) - adminClient := test_common.NewReceiverClient(t, helper.Org1.Admin) + adminClient, err := v0alpha1.NewReceiverClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) // Prepare environment and create notification policy and rule that use receiver alertmanagerRaw, err := testData.ReadFile(path.Join("test-data", "notification-settings.json")) require.NoError(t, err) @@ -813,7 +822,7 @@ func TestIntegrationInUseMetadata(t *testing.T) { requestReceivers := func(t *testing.T, title string) (v0alpha1.Receiver, v0alpha1.Receiver) { t.Helper() - receivers, err := adminClient.List(ctx, v1.ListOptions{}) + receivers, err := adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) require.Len(t, receivers.Items, 2) idx := slices.IndexFunc(receivers.Items, func(interval v0alpha1.Receiver) bool { @@ -821,7 +830,7 @@ func TestIntegrationInUseMetadata(t *testing.T) { }) receiverListed := receivers.Items[idx] - receiverGet, err := adminClient.Get(ctx, receiverListed.Name, v1.GetOptions{}) + receiverGet, err := adminClient.Get(ctx, receiverListed.GetStaticMetadata().Identifier()) require.NoError(t, err) return receiverListed, *receiverGet @@ -846,8 +855,9 @@ func TestIntegrationInUseMetadata(t *testing.T) { amConfig.AlertmanagerConfig.Route.Routes = amConfig.AlertmanagerConfig.Route.Routes[:1] v1Route, err := routingtree.ConvertToK8sResource(helper.Org1.AdminServiceAccount.OrgId, *amConfig.AlertmanagerConfig.Route, "", func(int64) string { return "default" }) require.NoError(t, err) - routeAdminClient := test_common.NewRoutingTreeClient(t, helper.Org1.Admin) - _, err = routeAdminClient.Update(ctx, v1Route, v1.UpdateOptions{}) + routeAdminClient, err := v0alpha1.NewRoutingTreeClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) + _, err = routeAdminClient.Update(ctx, v1Route, resource.UpdateOptions{}) require.NoError(t, err) receiverListed, receiverGet = requestReceivers(t, "user-defined") @@ -868,7 +878,7 @@ func TestIntegrationInUseMetadata(t *testing.T) { amConfig.AlertmanagerConfig.Route.Routes = nil v1route, err := routingtree.ConvertToK8sResource(1, *amConfig.AlertmanagerConfig.Route, "", func(int64) string { return "default" }) require.NoError(t, err) - _, err = routeAdminClient.Update(ctx, v1route, v1.UpdateOptions{}) + _, err = routeAdminClient.Update(ctx, v1route, resource.UpdateOptions{}) require.NoError(t, err) // Remove the remaining rules. @@ -892,7 +902,8 @@ func TestIntegrationProvisioning(t *testing.T) { org := helper.Org1 admin := org.Admin - adminClient := test_common.NewReceiverClient(t, helper.Org1.Admin) + adminClient, err := v0alpha1.NewReceiverClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) env := helper.GetEnv() ac := acimpl.ProvideAccessControl(env.FeatureToggles) db, err := store.ProvideDBStore(env.Cfg, env.FeatureToggles, env.SQLStore, &foldertest.FakeService{}, &dashboards.FakeDashboardService{}, ac, bus.ProvideBus(tracing.InitializeTracerForTest())) @@ -908,7 +919,7 @@ func TestIntegrationProvisioning(t *testing.T) { createIntegration(t, "email"), }, }, - }, v1.CreateOptions{}) + }, resource.CreateOptions{}) require.NoError(t, err) require.Equal(t, "none", created.GetProvenanceStatus()) @@ -917,23 +928,23 @@ func TestIntegrationProvisioning(t *testing.T) { UID: *created.Spec.Integrations[0].Uid, }, admin.Identity.GetOrgID(), "API")) - got, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) + got, err := adminClient.Get(ctx, created.GetStaticMetadata().Identifier()) require.NoError(t, err) require.Equal(t, "API", got.GetProvenanceStatus()) }) t.Run("should not let update if provisioned", func(t *testing.T) { - got, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) + got, err := adminClient.Get(ctx, created.GetStaticMetadata().Identifier()) require.NoError(t, err) updated := got.Copy().(*v0alpha1.Receiver) updated.Spec.Integrations = append(updated.Spec.Integrations, createIntegration(t, "email")) - _, err = adminClient.Update(ctx, updated, v1.UpdateOptions{}) + _, err = adminClient.Update(ctx, updated, resource.UpdateOptions{}) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) t.Run("should not let delete if provisioned", func(t *testing.T) { - err := adminClient.Delete(ctx, created.Name, v1.DeleteOptions{}) + err := adminClient.Delete(ctx, created.GetStaticMetadata().Identifier(), resource.DeleteOptions{}) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) } @@ -944,7 +955,10 @@ func TestIntegrationOptimisticConcurrency(t *testing.T) { ctx := context.Background() helper := getTestHelper(t) - adminClient := test_common.NewReceiverClient(t, helper.Org1.Admin) + adminClient, err := v0alpha1.NewReceiverClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) + oldClient := test_common.NewReceiverClient(t, helper.Org1.Admin) // TODO replace with regular client once Delete works + receiver := v0alpha1.Receiver{ ObjectMeta: v1.ObjectMeta{ Namespace: "default", @@ -955,21 +969,22 @@ func TestIntegrationOptimisticConcurrency(t *testing.T) { }, } - created, err := adminClient.Create(ctx, &receiver, v1.CreateOptions{}) + created, err := adminClient.Create(ctx, &receiver, resource.CreateOptions{}) require.NoError(t, err) require.NotNil(t, created) require.NotEmpty(t, created.ResourceVersion) - t.Run("should forbid if version does not match", func(t *testing.T) { + t.Run("should conflict if version does not match", func(t *testing.T) { updated := created.Copy().(*v0alpha1.Receiver) - updated.ResourceVersion = "test" - _, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + _, err := adminClient.Update(ctx, updated, resource.UpdateOptions{ + ResourceVersion: "test", + }) require.Truef(t, errors.IsConflict(err), "should get Forbidden error but got %s", err) }) t.Run("should update if version matches", func(t *testing.T) { updated := created.Copy().(*v0alpha1.Receiver) updated.Spec.Integrations = append(updated.Spec.Integrations, createIntegration(t, "email")) - actualUpdated, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + actualUpdated, err := adminClient.Update(ctx, updated, resource.UpdateOptions{}) require.NoError(t, err) for i, integration := range actualUpdated.Spec.Integrations { updated.Spec.Integrations[i].Uid = integration.Uid @@ -981,25 +996,25 @@ func TestIntegrationOptimisticConcurrency(t *testing.T) { updated := created.Copy().(*v0alpha1.Receiver) updated.ResourceVersion = "" updated.Spec.Integrations = append(updated.Spec.Integrations, createIntegration(t, "webhook")) - _, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + _, err := oldClient.Update(ctx, updated, v1.UpdateOptions{}) require.Truef(t, errors.IsConflict(err), "should get Forbidden error but got %s", err) // TODO Change that? K8s returns 400 instead. }) t.Run("should fail to delete if version does not match", func(t *testing.T) { - actual, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) + actual, err := adminClient.Get(ctx, created.GetStaticMetadata().Identifier()) require.NoError(t, err) - err = adminClient.Delete(ctx, actual.Name, v1.DeleteOptions{ + err = oldClient.Delete(ctx, actual.Name, v1.DeleteOptions{ Preconditions: &v1.Preconditions{ ResourceVersion: util.Pointer("something"), }, }) - require.Truef(t, errors.IsConflict(err), "should get Forbidden error but got %s", err) + require.Truef(t, errors.IsConflict(err), "should get conflict error but got %s", err) }) t.Run("should succeed if version matches", func(t *testing.T) { - actual, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) + actual, err := adminClient.Get(ctx, created.GetStaticMetadata().Identifier()) require.NoError(t, err) - err = adminClient.Delete(ctx, actual.Name, v1.DeleteOptions{ + err = oldClient.Delete(ctx, actual.Name, v1.DeleteOptions{ Preconditions: &v1.Preconditions{ ResourceVersion: util.Pointer(actual.ResourceVersion), }, @@ -1007,10 +1022,10 @@ func TestIntegrationOptimisticConcurrency(t *testing.T) { require.NoError(t, err) }) t.Run("should succeed if version is empty", func(t *testing.T) { - actual, err := adminClient.Create(ctx, &receiver, v1.CreateOptions{}) + actual, err := adminClient.Create(ctx, &receiver, resource.CreateOptions{}) require.NoError(t, err) - err = adminClient.Delete(ctx, actual.Name, v1.DeleteOptions{ + err = oldClient.Delete(ctx, actual.Name, v1.DeleteOptions{ Preconditions: &v1.Preconditions{ ResourceVersion: util.Pointer(actual.ResourceVersion), }, @@ -1025,7 +1040,8 @@ func TestIntegrationPatch(t *testing.T) { ctx := context.Background() helper := getTestHelper(t) - adminClient := test_common.NewReceiverClient(t, helper.Org1.Admin) + adminClient, err := v0alpha1.NewReceiverClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) receiver := v0alpha1.Receiver{ ObjectMeta: v1.ObjectMeta{ Namespace: "default", @@ -1040,40 +1056,40 @@ func TestIntegrationPatch(t *testing.T) { }, } - current, err := adminClient.Create(ctx, &receiver, v1.CreateOptions{}) + current, err := adminClient.Create(ctx, &receiver, resource.CreateOptions{}) require.NoError(t, err) require.NotNil(t, current) t.Run("should patch with json patch", func(t *testing.T) { - current, err := adminClient.Get(ctx, current.Name, v1.GetOptions{}) + current, err := adminClient.Get(ctx, current.GetStaticMetadata().Identifier()) require.NoError(t, err) index := slices.IndexFunc(current.Spec.Integrations, func(t v0alpha1.ReceiverIntegration) bool { return t.Type == "webhook" }) - patch := []map[string]any{ + patch := []resource.PatchOperation{ { - "op": "remove", - "path": fmt.Sprintf("/spec/integrations/%d/settings/username", index), + Operation: "remove", + Path: fmt.Sprintf("/spec/integrations/%d/settings/username", index), }, { - "op": "remove", - "path": fmt.Sprintf("/spec/integrations/%d/secureFields/password", index), + Operation: "remove", + Path: fmt.Sprintf("/spec/integrations/%d/secureFields/password", index), }, { - "op": "replace", - "path": fmt.Sprintf("/spec/integrations/%d/settings/authorization_scheme", index), - "value": "bearer", + Operation: "replace", + Path: fmt.Sprintf("/spec/integrations/%d/settings/authorization_scheme", index), + Value: "bearer", }, { - "op": "add", - "path": fmt.Sprintf("/spec/integrations/%d/settings/authorization_credentials", index), - "value": "authz-token", + Operation: "add", + Path: fmt.Sprintf("/spec/integrations/%d/settings/authorization_credentials", index), + Value: "authz-token", }, { - "op": "remove", - "path": fmt.Sprintf("/spec/integrations/%d/secureFields/authorization_credentials", index), + Operation: "remove", + Path: fmt.Sprintf("/spec/integrations/%d/secureFields/authorization_credentials", index), }, } @@ -1084,10 +1100,7 @@ func TestIntegrationPatch(t *testing.T) { delete(expected.SecureFields, "password") expected.SecureFields["authorization_credentials"] = true - patchData, err := json.Marshal(patch) - require.NoError(t, err) - - result, err := adminClient.Patch(ctx, current.Name, types.JSONPatchType, patchData, v1.PatchOptions{}) + result, err := adminClient.Patch(ctx, current.GetStaticMetadata().Identifier(), resource.PatchRequest{Operations: patch}, resource.PatchOptions{}) require.NoError(t, err) require.EqualValues(t, expected, result.Spec.Integrations[index]) @@ -1127,7 +1140,8 @@ func TestIntegrationReferentialIntegrity(t *testing.T) { cliCfg := helper.Org1.Admin.NewRestConfig() legacyCli := alerting.NewAlertingLegacyAPIClient(helper.GetEnv().Server.HTTPServer.Listener.Addr().String(), cliCfg.Username, cliCfg.Password) - adminClient := test_common.NewReceiverClient(t, helper.Org1.Admin) + adminClient, err := v0alpha1.NewReceiverClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) // Prepare environment and create notification policy and rule that use time receiver alertmanagerRaw, err := testData.ReadFile(path.Join("test-data", "notification-settings.json")) require.NoError(t, err) @@ -1146,7 +1160,7 @@ func TestIntegrationReferentialIntegrity(t *testing.T) { _, status, data := legacyCli.PostRulesGroupWithStatus(t, folderUID, &ruleGroup, false) require.Equalf(t, http.StatusAccepted, status, "Failed to post Rule: %s", data) - receivers, err := adminClient.List(ctx, v1.ListOptions{}) + receivers, err := adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) require.Len(t, receivers.Items, 2) idx := slices.IndexFunc(receivers.Items, func(interval v0alpha1.Receiver) bool { @@ -1164,7 +1178,7 @@ func TestIntegrationReferentialIntegrity(t *testing.T) { expectedTitle := renamed.Spec.Title + "-new" renamed.Spec.Title = expectedTitle - actual, err := adminClient.Update(ctx, renamed, v1.UpdateOptions{}) + actual, err := adminClient.Update(ctx, renamed, resource.UpdateOptions{}) require.NoError(t, err) updatedRuleGroup, status := legacyCli.GetRulesGroup(t, folderUID, ruleGroup.Name) @@ -1178,7 +1192,7 @@ func TestIntegrationReferentialIntegrity(t *testing.T) { assert.Equalf(t, expectedTitle, route.Receiver, "time receiver in routes should have been renamed but it did not") } - actual, err = adminClient.Get(ctx, actual.Name, v1.GetOptions{}) + actual, err = adminClient.Get(ctx, actual.GetStaticMetadata().Identifier()) require.NoError(t, err) receiver = *actual @@ -1194,20 +1208,20 @@ func TestIntegrationReferentialIntegrity(t *testing.T) { t.Cleanup(func() { require.NoError(t, db.DeleteProvenance(ctx, ¤tRoute, orgID)) }) - actual, err := adminClient.Update(ctx, renamed, v1.UpdateOptions{}) + actual, err := adminClient.Update(ctx, renamed, resource.UpdateOptions{}) require.Errorf(t, err, "Expected error but got successful result: %v", actual) require.Truef(t, errors.IsConflict(err), "Expected Conflict, got: %s", err) }) t.Run("provisioned rules", func(t *testing.T) { ruleUid := currentRuleGroup.Rules[0].GrafanaManagedAlert.UID - resource := &ngmodels.AlertRule{UID: ruleUid} - require.NoError(t, db.SetProvenance(ctx, resource, orgID, "API")) + rule := &ngmodels.AlertRule{UID: ruleUid} + require.NoError(t, db.SetProvenance(ctx, rule, orgID, "API")) t.Cleanup(func() { - require.NoError(t, db.DeleteProvenance(ctx, resource, orgID)) + require.NoError(t, db.DeleteProvenance(ctx, rule, orgID)) }) - actual, err := adminClient.Update(ctx, renamed, v1.UpdateOptions{}) + actual, err := adminClient.Update(ctx, renamed, resource.UpdateOptions{}) require.Errorf(t, err, "Expected error but got successful result: %v", actual) require.Truef(t, errors.IsConflict(err), "Expected Conflict, got: %s", err) }) @@ -1216,7 +1230,7 @@ func TestIntegrationReferentialIntegrity(t *testing.T) { t.Run("Delete", func(t *testing.T) { t.Run("should fail to delete if receiver is used in rule and routes", func(t *testing.T) { - err := adminClient.Delete(ctx, receiver.Name, v1.DeleteOptions{}) + err := adminClient.Delete(ctx, receiver.GetStaticMetadata().Identifier(), resource.DeleteOptions{}) require.Truef(t, errors.IsConflict(err), "Expected Conflict, got: %s", err) }) @@ -1225,7 +1239,7 @@ func TestIntegrationReferentialIntegrity(t *testing.T) { route.Routes[0].Receiver = "" legacyCli.UpdateRoute(t, route, true) - err = adminClient.Delete(ctx, receiver.Name, v1.DeleteOptions{}) + err = adminClient.Delete(ctx, receiver.GetStaticMetadata().Identifier(), resource.DeleteOptions{}) require.Truef(t, errors.IsConflict(err), "Expected Conflict, got: %s", err) }) }) @@ -1237,10 +1251,11 @@ func TestIntegrationCRUD(t *testing.T) { ctx := context.Background() helper := getTestHelper(t) - adminClient := test_common.NewReceiverClient(t, helper.Org1.Admin) + adminClient, err := v0alpha1.NewReceiverClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) var defaultReceiver *v0alpha1.Receiver t.Run("should list the default receiver", func(t *testing.T) { - items, err := adminClient.List(ctx, v1.ListOptions{}) + items, err := adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) assert.Len(t, items.Items, 1) defaultReceiver = &items.Items[0] @@ -1249,7 +1264,7 @@ func TestIntegrationCRUD(t *testing.T) { assert.NotEmpty(t, defaultReceiver.Name) assert.NotEmpty(t, defaultReceiver.ResourceVersion) - defaultReceiver, err = adminClient.Get(ctx, defaultReceiver.Name, v1.GetOptions{}) + defaultReceiver, err = adminClient.Get(ctx, defaultReceiver.GetStaticMetadata().Identifier()) require.NoError(t, err) assert.NotEmpty(t, defaultReceiver.UID) assert.NotEmpty(t, defaultReceiver.Name) @@ -1262,7 +1277,7 @@ func TestIntegrationCRUD(t *testing.T) { newDefault := defaultReceiver.Copy().(*v0alpha1.Receiver) newDefault.Spec.Integrations = append(newDefault.Spec.Integrations, createIntegration(t, line.Type)) - updatedReceiver, err := adminClient.Update(ctx, newDefault, v1.UpdateOptions{}) + updatedReceiver, err := adminClient.Update(ctx, newDefault, resource.UpdateOptions{}) require.NoError(t, err) expected := newDefault.Copy().(*v0alpha1.Receiver) @@ -1290,12 +1305,12 @@ func TestIntegrationCRUD(t *testing.T) { Integrations: []v0alpha1.ReceiverIntegration{}, }, } - _, err := adminClient.Create(ctx, newReceiver, v1.CreateOptions{}) + _, err := adminClient.Create(ctx, newReceiver, resource.CreateOptions{}) require.Truef(t, errors.IsConflict(err), "Expected Conflict, got: %s", err) }) t.Run("should not let delete default receiver", func(t *testing.T) { - err := adminClient.Delete(ctx, defaultReceiver.Name, v1.DeleteOptions{}) + err := adminClient.Delete(ctx, defaultReceiver.GetStaticMetadata().Identifier(), resource.DeleteOptions{}) require.Truef(t, errors.IsConflict(err), "Expected Conflict, got: %s", err) }) @@ -1317,7 +1332,7 @@ func TestIntegrationCRUD(t *testing.T) { Title: "all-receivers", Integrations: integrations, }, - }, v1.CreateOptions{}) + }, resource.CreateOptions{}) require.NoError(t, err) require.Len(t, receiver.Spec.Integrations, len(integrations)) @@ -1342,7 +1357,7 @@ func TestIntegrationCRUD(t *testing.T) { }) t.Run("should be able read what it is created", func(t *testing.T) { - get, err := adminClient.Get(ctx, receiver.Name, v1.GetOptions{}) + get, err := adminClient.Get(ctx, receiver.GetStaticMetadata().Identifier()) require.NoError(t, err) require.Equal(t, receiver, get) t.Run("should return secrets in secureFields but not settings", func(t *testing.T) { @@ -1394,7 +1409,7 @@ func TestIntegrationCRUD(t *testing.T) { Title: fmt.Sprintf("invalid-%s", key), Integrations: []v0alpha1.ReceiverIntegration{integration}, }, - }, v1.CreateOptions{}) + }, resource.CreateOptions{}) require.Errorf(t, err, "Expected error but got successful result: %v", receiver) require.Truef(t, errors.IsBadRequest(err), "Expected BadRequest, got: %s", err) }) @@ -1408,7 +1423,8 @@ func TestIntegrationReceiverListSelector(t *testing.T) { ctx := context.Background() helper := getTestHelper(t) - adminClient := test_common.NewReceiverClient(t, helper.Org1.Admin) + adminClient, err := v0alpha1.NewReceiverClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) recv1 := &v0alpha1.Receiver{ ObjectMeta: v1.ObjectMeta{ Namespace: "default", @@ -1420,7 +1436,7 @@ func TestIntegrationReceiverListSelector(t *testing.T) { }, }, } - recv1, err := adminClient.Create(ctx, recv1, v1.CreateOptions{}) + recv1, err = adminClient.Create(ctx, recv1, resource.CreateOptions{}) require.NoError(t, err) recv2 := &v0alpha1.Receiver{ @@ -1434,7 +1450,7 @@ func TestIntegrationReceiverListSelector(t *testing.T) { }, }, } - recv2, err = adminClient.Create(ctx, recv2, v1.CreateOptions{}) + recv2, err = adminClient.Create(ctx, recv2, resource.CreateOptions{}) require.NoError(t, err) env := helper.GetEnv() @@ -1444,18 +1460,20 @@ func TestIntegrationReceiverListSelector(t *testing.T) { require.NoError(t, db.SetProvenance(ctx, &definitions.EmbeddedContactPoint{ UID: *recv2.Spec.Integrations[0].Uid, }, helper.Org1.Admin.Identity.GetOrgID(), "API")) - recv2, err = adminClient.Get(ctx, recv2.Name, v1.GetOptions{}) + recv2, err = adminClient.Get(ctx, recv2.GetStaticMetadata().Identifier()) require.NoError(t, err) - receivers, err := adminClient.List(ctx, v1.ListOptions{}) + receivers, err := adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) require.Len(t, receivers.Items, 3) // Includes default. t.Run("should filter by receiver name", func(t *testing.T) { t.Skip("disabled until app installer supports it") // TODO revisit when custom field selectors are supported - list, err := adminClient.List(ctx, v1.ListOptions{ - FieldSelector: "spec.title=" + recv1.Spec.Title, + list, err := adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{ + FieldSelectors: []string{ + "spec.title=" + recv1.Spec.Title, + }, }) require.NoError(t, err) require.Len(t, list.Items, 1) @@ -1463,8 +1481,10 @@ func TestIntegrationReceiverListSelector(t *testing.T) { }) t.Run("should filter by metadata name", func(t *testing.T) { - list, err := adminClient.List(ctx, v1.ListOptions{ - FieldSelector: "metadata.name=" + recv2.Name, + list, err := adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{ + FieldSelectors: []string{ + "metadata.name=" + recv2.Name, + }, }) require.NoError(t, err) require.Len(t, list.Items, 1) @@ -1473,8 +1493,10 @@ func TestIntegrationReceiverListSelector(t *testing.T) { t.Run("should filter by multiple filters", func(t *testing.T) { t.Skip("disabled until app installer supports it") // TODO revisit when custom field selectors are supported - list, err := adminClient.List(ctx, v1.ListOptions{ - FieldSelector: fmt.Sprintf("metadata.name=%s,spec.title=%s", recv2.Name, recv2.Spec.Title), + list, err := adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{ + FieldSelectors: []string{ + fmt.Sprintf("metadata.name=%s,spec.title=%s", recv2.Name, recv2.Spec.Title), + }, }) require.NoError(t, err) require.Len(t, list.Items, 1) @@ -1482,8 +1504,10 @@ func TestIntegrationReceiverListSelector(t *testing.T) { }) t.Run("should be empty when filter does not match", func(t *testing.T) { - list, err := adminClient.List(ctx, v1.ListOptions{ - FieldSelector: fmt.Sprintf("metadata.name=%s", "unknown"), + list, err := adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{ + FieldSelectors: []string{ + fmt.Sprintf("metadata.name=%s", "unknown"), + }, }) require.NoError(t, err) require.Empty(t, list.Items) @@ -1497,7 +1521,8 @@ func persistInitialConfig(t *testing.T, amConfig definitions.PostableUserConfig) helper := getTestHelper(t) - receiverClient := test_common.NewReceiverClient(t, helper.Org1.Admin) + receiverClient, err := v0alpha1.NewReceiverClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) for _, receiver := range amConfig.AlertmanagerConfig.Receivers { if receiver.Name == "grafana-default-email" { continue @@ -1523,7 +1548,7 @@ func persistInitialConfig(t *testing.T, amConfig definitions.PostableUserConfig) }) } - created, err := receiverClient.Create(ctx, &toCreate, v1.CreateOptions{}) + created, err := receiverClient.Create(ctx, &toCreate, resource.CreateOptions{}) require.NoError(t, err) for i, integration := range created.Spec.Integrations { @@ -1533,10 +1558,11 @@ func persistInitialConfig(t *testing.T, amConfig definitions.PostableUserConfig) nsMapper := func(_ int64) string { return "default" } - routeClient := test_common.NewRoutingTreeClient(t, helper.Org1.Admin) + routeClient, err := v0alpha1.NewRoutingTreeClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) v1route, err := routingtree.ConvertToK8sResource(helper.Org1.AdminServiceAccount.OrgId, *amConfig.AlertmanagerConfig.Route, "", nsMapper) require.NoError(t, err) - _, err = routeClient.Update(ctx, v1route, v1.UpdateOptions{}) + _, err = routeClient.Update(ctx, v1route, resource.UpdateOptions{}) require.NoError(t, err) } diff --git a/pkg/tests/apis/alerting/notifications/receivers/test-data/imported-expected-snapshot.json b/pkg/tests/apis/alerting/notifications/receivers/test-data/imported-expected-snapshot.json index f092d8980f7..60d8333e7b8 100644 --- a/pkg/tests/apis/alerting/notifications/receivers/test-data/imported-expected-snapshot.json +++ b/pkg/tests/apis/alerting/notifications/receivers/test-data/imported-expected-snapshot.json @@ -1,10 +1,14 @@ { + "kind": "ReceiverList", "apiVersion": "notifications.alerting.grafana.app/v0alpha1", + "metadata": {}, "items": [ { - "apiVersion": "notifications.alerting.grafana.app/v0alpha1", - "kind": "Receiver", "metadata": { + "name": "Z3JhZmFuYS1kZWZhdWx0LWVtYWls", + "namespace": "default", + "uid": "zyXFk301pvwNz4HRPrTMKPMFO2934cPB7H1ZXmyM1TUX", + "resourceVersion": "a82b34036bdabbc4", "annotations": { "grafana.com/access/canAdmin": "true", "grafana.com/access/canDelete": "true", @@ -15,53 +19,29 @@ "grafana.com/inUse/routes": "1", "grafana.com/inUse/rules": "0", "grafana.com/provenance": "none" - }, - "name": "Z3JhZmFuYS1kZWZhdWx0LWVtYWls", - "namespace": "default", - "resourceVersion": "a82b34036bdabbc4", - "uid": "zyXFk301pvwNz4HRPrTMKPMFO2934cPB7H1ZXmyM1TUX" + } }, "spec": { + "title": "grafana-default-email", "integrations": [ { + "uid": "", + "type": "email", + "version": "v1", "disableResolveMessage": false, "settings": { "addresses": "\u003cexample@email.com\u003e" - }, - "type": "email", - "uid": "", - "version": "v1" + } } - ], - "title": "grafana-default-email" + ] } }, { - "apiVersion": "notifications.alerting.grafana.app/v0alpha1", - "kind": "Receiver", "metadata": { - "annotations": { - "grafana.com/access/canModifyProtected": "true", - "grafana.com/access/canReadSecrets": "true", - "grafana.com/canUse": "false", - "grafana.com/inUse/routes": "0", - "grafana.com/inUse/rules": "0", - "grafana.com/provenance": "converted_prometheus" - }, "name": "Z3JhZmFuYS1kZWZhdWx0LWVtYWlsdGVzdC1jcmVhdGUtZ2V0LWNvbmZpZw", "namespace": "default", + "uid": "JzW6DIlcxj4sRN8A2ULcwTXAmm0Vs0Z68aEBqXSvxK0X", "resourceVersion": "b2823b50ffa1eff6", - "uid": "JzW6DIlcxj4sRN8A2ULcwTXAmm0Vs0Z68aEBqXSvxK0X" - }, - "spec": { - "integrations": [], - "title": "grafana-default-emailtest-create-get-config" - } - }, - { - "apiVersion": "notifications.alerting.grafana.app/v0alpha1", - "kind": "Receiver", - "metadata": { "annotations": { "grafana.com/access/canModifyProtected": "true", "grafana.com/access/canReadSecrets": "true", @@ -69,19 +49,36 @@ "grafana.com/inUse/routes": "0", "grafana.com/inUse/rules": "0", "grafana.com/provenance": "converted_prometheus" - }, - "name": "ZGlzY29yZA", - "namespace": "default", - "resourceVersion": "06e437697f62ac59", - "uid": "8cH8Ql2S6VhPEVUhwlQEKYWyPbRJS7YKj2lEXdrehH8X" + } }, "spec": { + "title": "grafana-default-emailtest-create-get-config", + "integrations": [] + } + }, + { + "metadata": { + "name": "ZGlzY29yZA", + "namespace": "default", + "uid": "8cH8Ql2S6VhPEVUhwlQEKYWyPbRJS7YKj2lEXdrehH8X", + "resourceVersion": "06e437697f62ac59", + "annotations": { + "grafana.com/access/canModifyProtected": "true", + "grafana.com/access/canReadSecrets": "true", + "grafana.com/canUse": "false", + "grafana.com/inUse/routes": "0", + "grafana.com/inUse/rules": "0", + "grafana.com/provenance": "converted_prometheus" + } + }, + "spec": { + "title": "discord", "integrations": [ { + "uid": "", + "type": "discord", + "version": "v0mimir1", "disableResolveMessage": false, - "secureFields": { - "webhook_url": true - }, "settings": { "http_config": { "enable_http2": true, @@ -95,18 +92,19 @@ "send_resolved": true, "title": "{{ template \"discord.default.title\" . }}" }, - "type": "discord", - "uid": "", - "version": "v0mimir1" + "secureFields": { + "webhook_url": true + } } - ], - "title": "discord" + ] } }, { - "apiVersion": "notifications.alerting.grafana.app/v0alpha1", - "kind": "Receiver", "metadata": { + "name": "ZW1haWw", + "namespace": "default", + "uid": "bhlvlN758xmnwVrHVPX0c5XvFHepenUbOXP0fuE6eUMX", + "resourceVersion": "9b3ffed277cee189", "annotations": { "grafana.com/access/canModifyProtected": "true", "grafana.com/access/canReadSecrets": "true", @@ -114,19 +112,16 @@ "grafana.com/inUse/routes": "0", "grafana.com/inUse/rules": "0", "grafana.com/provenance": "converted_prometheus" - }, - "name": "ZW1haWw", - "namespace": "default", - "resourceVersion": "9b3ffed277cee189", - "uid": "bhlvlN758xmnwVrHVPX0c5XvFHepenUbOXP0fuE6eUMX" + } }, "spec": { + "title": "email", "integrations": [ { + "uid": "", + "type": "email", + "version": "v0mimir1", "disableResolveMessage": false, - "secureFields": { - "auth_password": true - }, "settings": { "auth_username": "alertmanager", "from": "alertmanager@example.com", @@ -144,18 +139,19 @@ }, "to": "team@example.com" }, - "type": "email", - "uid": "", - "version": "v0mimir1" + "secureFields": { + "auth_password": true + } } - ], - "title": "email" + ] } }, { - "apiVersion": "notifications.alerting.grafana.app/v0alpha1", - "kind": "Receiver", "metadata": { + "name": "amlyYQ", + "namespace": "default", + "uid": "7Pu4xcRXbvw4XEX279SoqyO8Ibo8cMl0vAJyYTsJ0NEX", + "resourceVersion": "deae9d34f8554205", "annotations": { "grafana.com/access/canModifyProtected": "true", "grafana.com/access/canReadSecrets": "true", @@ -163,19 +159,16 @@ "grafana.com/inUse/routes": "0", "grafana.com/inUse/rules": "0", "grafana.com/provenance": "converted_prometheus" - }, - "name": "amlyYQ", - "namespace": "default", - "resourceVersion": "deae9d34f8554205", - "uid": "7Pu4xcRXbvw4XEX279SoqyO8Ibo8cMl0vAJyYTsJ0NEX" + } }, "spec": { + "title": "jira", "integrations": [ { + "uid": "", + "type": "jira", + "version": "v0mimir1", "disableResolveMessage": false, - "secureFields": { - "http_config.basic_auth.password": true - }, "settings": { "api_url": "http://localhost/jira", "custom_fields": { @@ -203,18 +196,19 @@ "send_resolved": true, "summary": "{{ template \"jira.default.summary\" . }}" }, - "type": "jira", - "uid": "", - "version": "v0mimir1" + "secureFields": { + "http_config.basic_auth.password": true + } } - ], - "title": "jira" + ] } }, { - "apiVersion": "notifications.alerting.grafana.app/v0alpha1", - "kind": "Receiver", "metadata": { + "name": "bXN0ZWFtcw", + "namespace": "default", + "uid": "z7xTMDjrk1HAHXPEx78tQb63LXYA6ivXLOtz2Z09ucIX", + "resourceVersion": "95c8d082d65466a3", "annotations": { "grafana.com/access/canModifyProtected": "true", "grafana.com/access/canReadSecrets": "true", @@ -222,19 +216,16 @@ "grafana.com/inUse/routes": "0", "grafana.com/inUse/rules": "0", "grafana.com/provenance": "converted_prometheus" - }, - "name": "bXN0ZWFtcw", - "namespace": "default", - "resourceVersion": "95c8d082d65466a3", - "uid": "z7xTMDjrk1HAHXPEx78tQb63LXYA6ivXLOtz2Z09ucIX" + } }, "spec": { + "title": "msteams", "integrations": [ { + "uid": "", + "type": "teams", + "version": "v0mimir1", "disableResolveMessage": false, - "secureFields": { - "webhook_url": true - }, "settings": { "http_config": { "enable_http2": true, @@ -249,18 +240,19 @@ "text": "{{ template \"msteams.default.text\" . }}", "title": "{{ template \"msteams.default.title\" . }}" }, - "type": "teams", - "uid": "", - "version": "v0mimir1" + "secureFields": { + "webhook_url": true + } } - ], - "title": "msteams" + ] } }, { - "apiVersion": "notifications.alerting.grafana.app/v0alpha1", - "kind": "Receiver", "metadata": { + "name": "b3BzZ2VuaWU", + "namespace": "default", + "uid": "XmkZ214Dj030hvynYiwNLq8i6uRCjUYXMXjE5m19OKAX", + "resourceVersion": "8ee2957ba150ba16", "annotations": { "grafana.com/access/canModifyProtected": "true", "grafana.com/access/canReadSecrets": "true", @@ -268,19 +260,16 @@ "grafana.com/inUse/routes": "0", "grafana.com/inUse/rules": "0", "grafana.com/provenance": "converted_prometheus" - }, - "name": "b3BzZ2VuaWU", - "namespace": "default", - "resourceVersion": "8ee2957ba150ba16", - "uid": "XmkZ214Dj030hvynYiwNLq8i6uRCjUYXMXjE5m19OKAX" + } }, "spec": { + "title": "opsgenie", "integrations": [ { + "uid": "", + "type": "opsgenie", + "version": "v0mimir1", "disableResolveMessage": false, - "secureFields": { - "api_key": true - }, "settings": { "actions": "test actions", "api_url": "http://localhost/opsgenie/", @@ -311,18 +300,19 @@ "tags": "test-tags", "update_alerts": true }, - "type": "opsgenie", - "uid": "", - "version": "v0mimir1" + "secureFields": { + "api_key": true + } } - ], - "title": "opsgenie" + ] } }, { - "apiVersion": "notifications.alerting.grafana.app/v0alpha1", - "kind": "Receiver", "metadata": { + "name": "cGFnZXJkdXR5", + "namespace": "default", + "uid": "QNitkUCkwzrIc7WVCCJGGDyvXLyo9csSUVqfyStyctQX", + "resourceVersion": "fe673d5dcd67ccf0", "annotations": { "grafana.com/access/canModifyProtected": "true", "grafana.com/access/canReadSecrets": "true", @@ -330,20 +320,16 @@ "grafana.com/inUse/routes": "1", "grafana.com/inUse/rules": "0", "grafana.com/provenance": "converted_prometheus" - }, - "name": "cGFnZXJkdXR5", - "namespace": "default", - "resourceVersion": "fe673d5dcd67ccf0", - "uid": "QNitkUCkwzrIc7WVCCJGGDyvXLyo9csSUVqfyStyctQX" + } }, "spec": { + "title": "pagerduty", "integrations": [ { + "uid": "", + "type": "pagerduty", + "version": "v0mimir1", "disableResolveMessage": false, - "secureFields": { - "routing_key": true, - "service_key": true - }, "settings": { "class": "test class", "client": "Alertmanager", @@ -383,18 +369,20 @@ "source": "test source", "url": "http://localhost/pagerduty" }, - "type": "pagerduty", - "uid": "", - "version": "v0mimir1" + "secureFields": { + "routing_key": true, + "service_key": true + } } - ], - "title": "pagerduty" + ] } }, { - "apiVersion": "notifications.alerting.grafana.app/v0alpha1", - "kind": "Receiver", "metadata": { + "name": "cHVzaG92ZXI", + "namespace": "default", + "uid": "t2TJSktI6vyGfdbLOKmxH4eBqgcIGsAuW8Qm9m0HRycX", + "resourceVersion": "6ae076725ab463e0", "annotations": { "grafana.com/access/canModifyProtected": "true", "grafana.com/access/canReadSecrets": "true", @@ -402,21 +390,16 @@ "grafana.com/inUse/routes": "0", "grafana.com/inUse/rules": "0", "grafana.com/provenance": "converted_prometheus" - }, - "name": "cHVzaG92ZXI", - "namespace": "default", - "resourceVersion": "6ae076725ab463e0", - "uid": "t2TJSktI6vyGfdbLOKmxH4eBqgcIGsAuW8Qm9m0HRycX" + } }, "spec": { + "title": "pushover", "integrations": [ { + "uid": "", + "type": "pushover", + "version": "v0mimir1", "disableResolveMessage": false, - "secureFields": { - "http_config.authorization.credentials": true, - "token": true, - "user_key": true - }, "settings": { "expire": "1h0m0s", "http_config": { @@ -437,18 +420,21 @@ "title": "{{ template \"pushover.default.title\" . }}", "url": "http://localhost/pushover" }, - "type": "pushover", - "uid": "", - "version": "v0mimir1" + "secureFields": { + "http_config.authorization.credentials": true, + "token": true, + "user_key": true + } } - ], - "title": "pushover" + ] } }, { - "apiVersion": "notifications.alerting.grafana.app/v0alpha1", - "kind": "Receiver", "metadata": { + "name": "c2xhY2s", + "namespace": "default", + "uid": "xSB0hnoc9j1CnLCHR3VgeVGXdVXILM0p2dM64bbHN9oX", + "resourceVersion": "ec0e343029ff5d8b", "annotations": { "grafana.com/access/canModifyProtected": "true", "grafana.com/access/canReadSecrets": "true", @@ -456,19 +442,16 @@ "grafana.com/inUse/routes": "0", "grafana.com/inUse/rules": "0", "grafana.com/provenance": "converted_prometheus" - }, - "name": "c2xhY2s", - "namespace": "default", - "resourceVersion": "ec0e343029ff5d8b", - "uid": "xSB0hnoc9j1CnLCHR3VgeVGXdVXILM0p2dM64bbHN9oX" + } }, "spec": { + "title": "slack", "integrations": [ { + "uid": "", + "type": "slack", + "version": "v0mimir1", "disableResolveMessage": false, - "secureFields": { - "api_url": true - }, "settings": { "actions": [ { @@ -522,18 +505,19 @@ "title_link": "http://localhost", "username": "Alerting Team" }, - "type": "slack", - "uid": "", - "version": "v0mimir1" + "secureFields": { + "api_url": true + } } - ], - "title": "slack" + ] } }, { - "apiVersion": "notifications.alerting.grafana.app/v0alpha1", - "kind": "Receiver", "metadata": { + "name": "c25z", + "namespace": "default", + "uid": "vSP8NtFr23hnqZqLxRgzUKfr1wOemOvZm1S6MYkfRI4X", + "resourceVersion": "77d734ad4c196d36", "annotations": { "grafana.com/access/canModifyProtected": "true", "grafana.com/access/canReadSecrets": "true", @@ -541,19 +525,16 @@ "grafana.com/inUse/routes": "0", "grafana.com/inUse/rules": "0", "grafana.com/provenance": "converted_prometheus" - }, - "name": "c25z", - "namespace": "default", - "resourceVersion": "77d734ad4c196d36", - "uid": "vSP8NtFr23hnqZqLxRgzUKfr1wOemOvZm1S6MYkfRI4X" + } }, "spec": { + "title": "sns", "integrations": [ { + "uid": "", + "type": "sns", + "version": "v0mimir1", "disableResolveMessage": false, - "secureFields": { - "sigv4.SecretKey": true - }, "settings": { "attributes": { "key1": "value1" @@ -577,18 +558,19 @@ "subject": "{{ template \"sns.default.subject\" . }}", "topic_arn": "arn:aws:sns:us-east-1:123456789012:alerts" }, - "type": "sns", - "uid": "", - "version": "v0mimir1" + "secureFields": { + "sigv4.SecretKey": true + } } - ], - "title": "sns" + ] } }, { - "apiVersion": "notifications.alerting.grafana.app/v0alpha1", - "kind": "Receiver", "metadata": { + "name": "dGVsZWdyYW0", + "namespace": "default", + "uid": "XLWjtmYcjP5PiqBCwZXX3YKHV1G8niRtpCakIpcHqoYX", + "resourceVersion": "d9850878a33e302e", "annotations": { "grafana.com/access/canModifyProtected": "true", "grafana.com/access/canReadSecrets": "true", @@ -596,19 +578,16 @@ "grafana.com/inUse/routes": "0", "grafana.com/inUse/rules": "0", "grafana.com/provenance": "converted_prometheus" - }, - "name": "dGVsZWdyYW0", - "namespace": "default", - "resourceVersion": "d9850878a33e302e", - "uid": "XLWjtmYcjP5PiqBCwZXX3YKHV1G8niRtpCakIpcHqoYX" + } }, "spec": { + "title": "telegram", "integrations": [ { + "uid": "", + "type": "telegram", + "version": "v0mimir1", "disableResolveMessage": false, - "secureFields": { - "token": true - }, "settings": { "api_url": "http://localhost/telegram-default", "chat": -1001234567890, @@ -624,18 +603,19 @@ "parse_mode": "MarkdownV2", "send_resolved": true }, - "type": "telegram", - "uid": "", - "version": "v0mimir1" + "secureFields": { + "token": true + } } - ], - "title": "telegram" + ] } }, { - "apiVersion": "notifications.alerting.grafana.app/v0alpha1", - "kind": "Receiver", "metadata": { + "name": "dmljdG9yb3Bz", + "namespace": "default", + "uid": "EWiwQ6TIW0GpEo46WusW7Nvg0HuD4QAbHf0JZ2OSOhEX", + "resourceVersion": "1e6886531440afc2", "annotations": { "grafana.com/access/canModifyProtected": "true", "grafana.com/access/canReadSecrets": "true", @@ -643,19 +623,16 @@ "grafana.com/inUse/routes": "0", "grafana.com/inUse/rules": "0", "grafana.com/provenance": "converted_prometheus" - }, - "name": "dmljdG9yb3Bz", - "namespace": "default", - "resourceVersion": "1e6886531440afc2", - "uid": "EWiwQ6TIW0GpEo46WusW7Nvg0HuD4QAbHf0JZ2OSOhEX" + } }, "spec": { + "title": "victorops", "integrations": [ { + "uid": "", + "type": "victorops", + "version": "v0mimir1", "disableResolveMessage": false, - "secureFields": { - "api_key": true - }, "settings": { "api_url": "http://localhost/victorops-default/", "entity_display_name": "{{ template \"victorops.default.entity_display_name\" . }}", @@ -674,18 +651,19 @@ "send_resolved": true, "state_message": "{{ template \"victorops.default.state_message\" . }}" }, - "type": "victorops", - "uid": "", - "version": "v0mimir1" + "secureFields": { + "api_key": true + } } - ], - "title": "victorops" + ] } }, { - "apiVersion": "notifications.alerting.grafana.app/v0alpha1", - "kind": "Receiver", "metadata": { + "name": "d2ViZXg", + "namespace": "default", + "uid": "wDNufI44UXHWq4ERRYenZ7XgXVV3Tjxaokz9IjMRZ54X", + "resourceVersion": "08fc955a08dfe9c0", "annotations": { "grafana.com/access/canModifyProtected": "true", "grafana.com/access/canReadSecrets": "true", @@ -693,19 +671,16 @@ "grafana.com/inUse/routes": "0", "grafana.com/inUse/rules": "0", "grafana.com/provenance": "converted_prometheus" - }, - "name": "d2ViZXg", - "namespace": "default", - "resourceVersion": "08fc955a08dfe9c0", - "uid": "wDNufI44UXHWq4ERRYenZ7XgXVV3Tjxaokz9IjMRZ54X" + } }, "spec": { + "title": "webex", "integrations": [ { + "uid": "", + "type": "webex", + "version": "v0mimir1", "disableResolveMessage": false, - "secureFields": { - "http_config.authorization.credentials": true - }, "settings": { "api_url": "http://localhost/webes-default", "http_config": { @@ -723,18 +698,19 @@ "room_id": "Y2lzY29zcGFyazovL3VzL1JPT00v12345678", "send_resolved": true }, - "type": "webex", - "uid": "", - "version": "v0mimir1" + "secureFields": { + "http_config.authorization.credentials": true + } } - ], - "title": "webex" + ] } }, { - "apiVersion": "notifications.alerting.grafana.app/v0alpha1", - "kind": "Receiver", "metadata": { + "name": "d2ViaG9vaw", + "namespace": "default", + "uid": "aKzigXATPp6HOh20yTrlTcuF2Y9IrPHridGIcWrJygsX", + "resourceVersion": "494392f899a7b410", "annotations": { "grafana.com/access/canModifyProtected": "true", "grafana.com/access/canReadSecrets": "true", @@ -742,19 +718,16 @@ "grafana.com/inUse/routes": "1", "grafana.com/inUse/rules": "0", "grafana.com/provenance": "converted_prometheus" - }, - "name": "d2ViaG9vaw", - "namespace": "default", - "resourceVersion": "494392f899a7b410", - "uid": "aKzigXATPp6HOh20yTrlTcuF2Y9IrPHridGIcWrJygsX" + } }, "spec": { + "title": "webhook", "integrations": [ { + "uid": "", + "type": "webhook", + "version": "v0mimir1", "disableResolveMessage": false, - "secureFields": { - "url": true - }, "settings": { "http_config": { "enable_http2": true, @@ -769,18 +742,19 @@ "timeout": "0s", "url_file": "" }, - "type": "webhook", - "uid": "", - "version": "v0mimir1" + "secureFields": { + "url": true + } } - ], - "title": "webhook" + ] } }, { - "apiVersion": "notifications.alerting.grafana.app/v0alpha1", - "kind": "Receiver", "metadata": { + "name": "d2VjaGF0", + "namespace": "default", + "uid": "jkXCvNrNVw7XX5nmYFyrGiA4ckAvJ282u2scW8KZq7IX", + "resourceVersion": "135913515cbc156b", "annotations": { "grafana.com/access/canModifyProtected": "true", "grafana.com/access/canReadSecrets": "true", @@ -788,19 +762,16 @@ "grafana.com/inUse/routes": "0", "grafana.com/inUse/rules": "0", "grafana.com/provenance": "converted_prometheus" - }, - "name": "d2VjaGF0", - "namespace": "default", - "resourceVersion": "135913515cbc156b", - "uid": "jkXCvNrNVw7XX5nmYFyrGiA4ckAvJ282u2scW8KZq7IX" + } }, "spec": { + "title": "wechat", "integrations": [ { + "uid": "", + "type": "wechat", + "version": "v0mimir1", "disableResolveMessage": false, - "secureFields": { - "api_secret": true - }, "settings": { "agent_id": "1000002", "api_url": "http://localhost/wechat/", @@ -820,15 +791,12 @@ "to_tag": "tag1", "to_user": "user1" }, - "type": "wechat", - "uid": "", - "version": "v0mimir1" + "secureFields": { + "api_secret": true + } } - ], - "title": "wechat" + ] } } - ], - "kind": "ReceiverList", - "metadata": {} -} + ] +} \ No newline at end of file diff --git a/pkg/tests/apis/alerting/notifications/routingtree/routing_tree_test.go b/pkg/tests/apis/alerting/notifications/routingtree/routing_tree_test.go index 00bae878f6d..bbbcd3ecfc0 100644 --- a/pkg/tests/apis/alerting/notifications/routingtree/routing_tree_test.go +++ b/pkg/tests/apis/alerting/notifications/routingtree/routing_tree_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/grafana/grafana-app-sdk/resource" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/common/model" @@ -39,6 +40,11 @@ import ( "github.com/grafana/grafana/pkg/util/testutil" ) +var defaultTreeIdentifier = resource.Identifier{ + Namespace: apis.DefaultNamespace, + Name: v0alpha1.UserDefinedRoutingTreeName, +} + func TestMain(m *testing.M) { testsuite.Run(m) } @@ -52,7 +58,8 @@ func TestIntegrationNotAllowedMethods(t *testing.T) { ctx := context.Background() helper := getTestHelper(t) - client := common.NewRoutingTreeClient(t, helper.Org1.Admin) + client, err := v0alpha1.NewRoutingTreeClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) route := &v0alpha1.RoutingTree{ ObjectMeta: v1.ObjectMeta{ @@ -60,11 +67,7 @@ func TestIntegrationNotAllowedMethods(t *testing.T) { }, Spec: v0alpha1.RoutingTreeSpec{}, } - _, err := client.Create(ctx, route, v1.CreateOptions{}) - assert.Error(t, err) - require.Truef(t, errors.IsMethodNotSupported(err), "Expected MethodNotSupported but got %s", err) - - err = client.Client.DeleteCollection(ctx, v1.DeleteOptions{}, v1.ListOptions{}) + _, err = client.Create(ctx, route, resource.CreateOptions{}) assert.Error(t, err) require.Truef(t, errors.IsMethodNotSupported(err), "Expected MethodNotSupported but got %s", err) } @@ -154,50 +157,52 @@ func TestIntegrationAccessControl(t *testing.T) { } admin := org1.Admin - adminClient := common.NewRoutingTreeClient(t, admin) + adminClient, err := v0alpha1.NewRoutingTreeClientFromGenerator(admin.GetClientRegistry()) + require.NoError(t, err) for _, tc := range testCases { t.Run(fmt.Sprintf("user '%s'", tc.user.Identity.GetLogin()), func(t *testing.T) { - client := common.NewRoutingTreeClient(t, tc.user) + client, err := v0alpha1.NewRoutingTreeClientFromGenerator(tc.user.GetClientRegistry()) + require.NoError(t, err) if tc.canRead { t.Run("should be able to list routing trees", func(t *testing.T) { - list, err := client.List(ctx, v1.ListOptions{}) + list, err := client.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) require.Len(t, list.Items, 1) require.Equal(t, v0alpha1.UserDefinedRoutingTreeName, list.Items[0].Name) }) t.Run("should be able to read routing trees by resource identifier", func(t *testing.T) { - _, err := client.Get(ctx, v0alpha1.UserDefinedRoutingTreeName, v1.GetOptions{}) + _, err := client.Get(ctx, defaultTreeIdentifier) require.NoError(t, err) t.Run("should get NotFound if resource does not exist", func(t *testing.T) { - _, err := client.Get(ctx, "Notfound", v1.GetOptions{}) + _, err := client.Get(ctx, resource.Identifier{Namespace: apis.DefaultNamespace, Name: "Notfound"}) require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err) }) }) } else { t.Run("should be forbidden to list routing trees", func(t *testing.T) { - _, err := client.List(ctx, v1.ListOptions{}) + _, err := client.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.Error(t, err) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) t.Run("should be forbidden to read routing tree by name", func(t *testing.T) { - _, err := client.Get(ctx, v0alpha1.UserDefinedRoutingTreeName, v1.GetOptions{}) + _, err := client.Get(ctx, defaultTreeIdentifier) require.Error(t, err) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) t.Run("should get forbidden even if name does not exist", func(t *testing.T) { - _, err := client.Get(ctx, "Notfound", v1.GetOptions{}) + _, err := client.Get(ctx, resource.Identifier{Namespace: apis.DefaultNamespace, Name: "Notfound"}) require.Error(t, err) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) }) } - current, err := adminClient.Get(ctx, v0alpha1.UserDefinedRoutingTreeName, v1.GetOptions{}) + current, err := adminClient.Get(ctx, defaultTreeIdentifier) require.NoError(t, err) expected := current.Copy().(*v0alpha1.RoutingTree) expected.Spec.Routes = []v0alpha1.RoutingTreeRoute{ @@ -217,7 +222,7 @@ func TestIntegrationAccessControl(t *testing.T) { if tc.canUpdate { t.Run("should be able to update routing tree", func(t *testing.T) { - updated, err := client.Update(ctx, expected, v1.UpdateOptions{}) + updated, err := client.Update(ctx, expected, resource.UpdateOptions{}) require.NoErrorf(t, err, "Payload %s", string(d)) expected = updated @@ -225,21 +230,23 @@ func TestIntegrationAccessControl(t *testing.T) { t.Run("should get NotFound if name does not exist", func(t *testing.T) { up := expected.Copy().(*v0alpha1.RoutingTree) up.Name = "notFound" - _, err := client.Update(ctx, up, v1.UpdateOptions{}) + _, err := client.Update(ctx, up, resource.UpdateOptions{}) require.Error(t, err) require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err) }) }) } else { t.Run("should be forbidden to update routing tree", func(t *testing.T) { - _, err := client.Update(ctx, expected, v1.UpdateOptions{}) + _, err := client.Update(ctx, expected, resource.UpdateOptions{}) require.Error(t, err) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) t.Run("should get forbidden even if resource does not exist", func(t *testing.T) { up := expected.Copy().(*v0alpha1.RoutingTree) up.Name = "notFound" - _, err := client.Update(ctx, up, v1.UpdateOptions{}) + _, err := client.Update(ctx, up, resource.UpdateOptions{ + ResourceVersion: up.ResourceVersion, + }) require.Error(t, err) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) @@ -248,32 +255,32 @@ func TestIntegrationAccessControl(t *testing.T) { if tc.canUpdate { t.Run("should be able to reset routing tree", func(t *testing.T) { - err := client.Delete(ctx, expected.Name, v1.DeleteOptions{}) + err := client.Delete(ctx, expected.GetStaticMetadata().Identifier(), resource.DeleteOptions{}) require.NoError(t, err) t.Run("should get NotFound if name does not exist", func(t *testing.T) { - err := client.Delete(ctx, "notfound", v1.DeleteOptions{}) + err := client.Delete(ctx, resource.Identifier{Namespace: apis.DefaultNamespace, Name: "notfound"}, resource.DeleteOptions{}) require.Error(t, err) require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err) }) }) } else { t.Run("should be forbidden to reset routing tree", func(t *testing.T) { - err := client.Delete(ctx, expected.Name, v1.DeleteOptions{}) + err := client.Delete(ctx, expected.GetStaticMetadata().Identifier(), resource.DeleteOptions{}) require.Error(t, err) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) t.Run("should be forbidden even if resource does not exist", func(t *testing.T) { - err := client.Delete(ctx, "notfound", v1.DeleteOptions{}) + err := client.Delete(ctx, resource.Identifier{Namespace: apis.DefaultNamespace, Name: "notfound"}, resource.DeleteOptions{}) require.Error(t, err) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) }) - require.NoError(t, adminClient.Delete(ctx, expected.Name, v1.DeleteOptions{})) + require.NoError(t, adminClient.Delete(ctx, expected.GetStaticMetadata().Identifier(), resource.DeleteOptions{})) } }) - err := adminClient.Delete(ctx, v0alpha1.UserDefinedRoutingTreeName, v1.DeleteOptions{}) + err := adminClient.Delete(ctx, defaultTreeIdentifier, resource.DeleteOptions{}) require.NoError(t, err) } } @@ -287,21 +294,22 @@ func TestIntegrationProvisioning(t *testing.T) { org := helper.Org1 admin := org.Admin - adminClient := common.NewRoutingTreeClient(t, admin) + adminClient, err := v0alpha1.NewRoutingTreeClientFromGenerator(admin.GetClientRegistry()) + require.NoError(t, err) env := helper.GetEnv() ac := acimpl.ProvideAccessControl(env.FeatureToggles) db, err := store.ProvideDBStore(env.Cfg, env.FeatureToggles, env.SQLStore, &foldertest.FakeService{}, &dashboards.FakeDashboardService{}, ac, bus.ProvideBus(tracing.InitializeTracerForTest())) require.NoError(t, err) - current, err := adminClient.Get(ctx, v0alpha1.UserDefinedRoutingTreeName, v1.GetOptions{}) + current, err := adminClient.Get(ctx, defaultTreeIdentifier) require.NoError(t, err) require.Equal(t, "none", current.GetProvenanceStatus()) t.Run("should provide provenance status", func(t *testing.T) { require.NoError(t, db.SetProvenance(ctx, &definitions.Route{}, admin.Identity.GetOrgID(), "API")) - got, err := adminClient.Get(ctx, current.Name, v1.GetOptions{}) + got, err := adminClient.Get(ctx, current.GetStaticMetadata().Identifier()) require.NoError(t, err) require.Equal(t, "API", got.GetProvenanceStatus()) }) @@ -319,13 +327,13 @@ func TestIntegrationProvisioning(t *testing.T) { }, } - _, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + _, err := adminClient.Update(ctx, updated, resource.UpdateOptions{}) require.Error(t, err) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) t.Run("should not let delete if provisioned", func(t *testing.T) { - err := adminClient.Delete(ctx, current.Name, v1.DeleteOptions{}) + err := adminClient.Delete(ctx, current.GetStaticMetadata().Identifier(), resource.DeleteOptions{}) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) } @@ -336,35 +344,37 @@ func TestIntegrationOptimisticConcurrency(t *testing.T) { ctx := context.Background() helper := getTestHelper(t) - adminClient := common.NewRoutingTreeClient(t, helper.Org1.Admin) + adminClient, err := v0alpha1.NewRoutingTreeClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) - current, err := adminClient.Get(ctx, v0alpha1.UserDefinedRoutingTreeName, v1.GetOptions{}) + current, err := adminClient.Get(ctx, defaultTreeIdentifier) require.NoError(t, err) require.NotEmpty(t, current.ResourceVersion) t.Run("should forbid if version does not match", func(t *testing.T) { updated := current.Copy().(*v0alpha1.RoutingTree) - updated.ResourceVersion = "test" - _, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + _, err := adminClient.Update(ctx, updated, resource.UpdateOptions{ + ResourceVersion: "test", + }) require.Error(t, err) require.Truef(t, errors.IsConflict(err), "should get Forbidden error but got %s", err) }) t.Run("should update if version matches", func(t *testing.T) { updated := current.Copy().(*v0alpha1.RoutingTree) updated.Spec.Defaults.GroupBy = append(updated.Spec.Defaults.GroupBy, "data") - actualUpdated, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + actualUpdated, err := adminClient.Update(ctx, updated, resource.UpdateOptions{}) require.NoError(t, err) require.EqualValues(t, updated.Spec, actualUpdated.Spec) require.NotEqual(t, updated.ResourceVersion, actualUpdated.ResourceVersion) }) t.Run("should update if version is empty", func(t *testing.T) { - current, err = adminClient.Get(ctx, v0alpha1.UserDefinedRoutingTreeName, v1.GetOptions{}) + current, err = adminClient.Get(ctx, defaultTreeIdentifier) require.NoError(t, err) updated := current.Copy().(*v0alpha1.RoutingTree) updated.ResourceVersion = "" updated.Spec.Routes = append(updated.Spec.Routes, v0alpha1.RoutingTreeRoute{Continue: true}) - actualUpdated, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + actualUpdated, err := adminClient.Update(ctx, updated, resource.UpdateOptions{}) require.NoError(t, err) require.EqualValues(t, updated.Spec, actualUpdated.Spec) require.NotEqual(t, current.ResourceVersion, actualUpdated.ResourceVersion) @@ -380,20 +390,22 @@ func TestIntegrationDataConsistency(t *testing.T) { cliCfg := helper.Org1.Admin.NewRestConfig() legacyCli := alerting.NewAlertingLegacyAPIClient(helper.GetEnv().Server.HTTPServer.Listener.Addr().String(), cliCfg.Username, cliCfg.Password) - client := common.NewRoutingTreeClient(t, helper.Org1.Admin) + client, err := v0alpha1.NewRoutingTreeClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) receiver := "grafana-default-email" timeInterval := "test-time-interval" createRoute := func(t *testing.T, route definitions.Route) { t.Helper() - routeClient := common.NewRoutingTreeClient(t, helper.Org1.Admin) + routeClient, err := v0alpha1.NewRoutingTreeClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) v1Route, err := routingtree.ConvertToK8sResource(helper.Org1.Admin.Identity.GetOrgID(), route, "", func(int64) string { return "default" }) require.NoError(t, err) - _, err = routeClient.Update(ctx, v1Route, v1.UpdateOptions{}) + _, err = routeClient.Update(ctx, v1Route, resource.UpdateOptions{}) require.NoError(t, err) } - _, err := common.NewTimeIntervalClient(t, helper.Org1.Admin).Create(ctx, &v0alpha1.TimeInterval{ + _, err = common.NewTimeIntervalClient(t, helper.Org1.Admin).Create(ctx, &v0alpha1.TimeInterval{ ObjectMeta: v1.ObjectMeta{ Namespace: "default", }, @@ -435,7 +447,7 @@ func TestIntegrationDataConsistency(t *testing.T) { }, } createRoute(t, route) - tree, err := client.Get(ctx, v0alpha1.UserDefinedRoutingTreeName, v1.GetOptions{}) + tree, err := client.Get(ctx, defaultTreeIdentifier) require.NoError(t, err) expected := []v0alpha1.RoutingTreeMatcher{ { @@ -503,9 +515,9 @@ func TestIntegrationDataConsistency(t *testing.T) { ensureMatcher(t, labels.MatchNotEqual, "matchers", "v"), } - tree, err := client.Get(ctx, v0alpha1.UserDefinedRoutingTreeName, v1.GetOptions{}) + tree, err := client.Get(ctx, defaultTreeIdentifier) require.NoError(t, err) - _, err = client.Update(ctx, tree, v1.UpdateOptions{}) + _, err = client.Update(ctx, tree, resource.UpdateOptions{}) require.NoError(t, err) cfg, _, _ = legacyCli.GetAlertmanagerConfigWithStatus(t) @@ -542,7 +554,7 @@ func TestIntegrationDataConsistency(t *testing.T) { createRoute(t, route) t.Run("correctly reads all fields", func(t *testing.T) { - tree, err := client.Get(ctx, v0alpha1.UserDefinedRoutingTreeName, v1.GetOptions{}) + tree, err := client.Get(ctx, defaultTreeIdentifier) require.NoError(t, err) assert.Equal(t, v0alpha1.RoutingTreeRouteDefaults{ Receiver: receiver, @@ -589,10 +601,10 @@ func TestIntegrationDataConsistency(t *testing.T) { t.Run("correctly save all fields", func(t *testing.T) { before, status, body := legacyCli.GetAlertmanagerConfigWithStatus(t) require.Equalf(t, http.StatusOK, status, body) - tree, err := client.Get(ctx, v0alpha1.UserDefinedRoutingTreeName, v1.GetOptions{}) + tree, err := client.Get(ctx, defaultTreeIdentifier) tree.Spec.Defaults.GroupBy = []string{"test-123", "test-456", "test-789"} require.NoError(t, err) - _, err = client.Update(ctx, tree, v1.UpdateOptions{}) + _, err = client.Update(ctx, tree, resource.UpdateOptions{}) require.NoError(t, err) before.AlertmanagerConfig.Route.GroupByStr = []string{"test-123", "test-456", "test-789"} @@ -640,7 +652,7 @@ func TestIntegrationDataConsistency(t *testing.T) { } createRoute(t, route) - tree, err := client.Get(ctx, v0alpha1.UserDefinedRoutingTreeName, v1.GetOptions{}) + tree, err := client.Get(ctx, defaultTreeIdentifier) require.NoError(t, err) assert.Equal(t, "foo🙂", tree.Spec.Routes[0].GroupBy[0]) expected := []v0alpha1.RoutingTreeMatcher{ @@ -666,7 +678,8 @@ func TestIntegrationExtraConfigsConflicts(t *testing.T) { cliCfg := helper.Org1.Admin.NewRestConfig() legacyCli := alerting.NewAlertingLegacyAPIClient(helper.GetEnv().Server.HTTPServer.Listener.Addr().String(), cliCfg.Username, cliCfg.Password) - client := common.NewRoutingTreeClient(t, helper.Org1.Admin) + client, err := v0alpha1.NewRoutingTreeClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) // Now upload a new extra config testAlertmanagerConfigYAML := ` @@ -691,7 +704,7 @@ receivers: }, headers) require.Equal(t, "success", response.Status) - current, err := client.Get(ctx, v0alpha1.UserDefinedRoutingTreeName, v1.GetOptions{}) + current, err := client.Get(ctx, defaultTreeIdentifier) require.NoError(t, err) updated := current.Copy().(*v0alpha1.RoutingTree) updated.Spec.Routes = append(updated.Spec.Routes, v0alpha1.RoutingTreeRoute{ @@ -704,7 +717,7 @@ receivers: }, }) - _, err = client.Update(ctx, updated, v1.UpdateOptions{}) + _, err = client.Update(ctx, updated, resource.UpdateOptions{}) require.Error(t, err) require.Truef(t, errors.IsBadRequest(err), "Should get BadRequest error but got: %s", err) @@ -712,6 +725,6 @@ receivers: legacyCli.ConvertPrometheusDeleteAlertmanagerConfig(t, headers) // and try again - _, err = client.Update(ctx, updated, v1.UpdateOptions{}) + _, err = client.Update(ctx, updated, resource.UpdateOptions{}) require.NoError(t, err) } diff --git a/pkg/tests/apis/alerting/notifications/templategroup/imported_test.go b/pkg/tests/apis/alerting/notifications/templategroup/imported_test.go index 9a3e88f3e46..1c7dbe6cb4d 100644 --- a/pkg/tests/apis/alerting/notifications/templategroup/imported_test.go +++ b/pkg/tests/apis/alerting/notifications/templategroup/imported_test.go @@ -6,6 +6,7 @@ import ( "path" "testing" + "github.com/grafana/grafana-app-sdk/resource" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.yaml.in/yaml/v3" @@ -18,7 +19,6 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/tests/api/alerting" "github.com/grafana/grafana/pkg/tests/apis" - "github.com/grafana/grafana/pkg/tests/apis/alerting/notifications/common" "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/grafana/grafana/pkg/util/testutil" ) @@ -35,7 +35,8 @@ func TestIntegrationImportedTemplates(t *testing.T) { }, }) - client := common.NewTemplateGroupClient(t, helper.Org1.Admin) + client, err := v0alpha1.NewTemplateGroupClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) cliCfg := helper.Org1.Admin.NewRestConfig() alertingApi := alerting.NewAlertingLegacyAPIClient(helper.GetEnv().Server.HTTPServer.Listener.Addr().String(), cliCfg.Username, cliCfg.Password) @@ -57,7 +58,7 @@ func TestIntegrationImportedTemplates(t *testing.T) { response := alertingApi.ConvertPrometheusPostAlertmanagerConfig(t, amConfig, headers) require.Equal(t, "success", response.Status) - templates, err := client.List(context.Background(), metav1.ListOptions{}) + templates, err := client.List(context.Background(), apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) require.Len(t, templates.Items, 3) @@ -90,12 +91,12 @@ func TestIntegrationImportedTemplates(t *testing.T) { t.Run("should not be able to update", func(t *testing.T) { tpl := templates.Items[1] tpl.Spec.Content = "new content" - _, err := client.Update(context.Background(), &tpl, metav1.UpdateOptions{}) + _, err := client.Update(context.Background(), &tpl, resource.UpdateOptions{}) require.Truef(t, errors.IsBadRequest(err), "expected bad request but got %s", err) }) t.Run("should not be able to delete", func(t *testing.T) { - err := client.Delete(context.Background(), templates.Items[1].Name, metav1.DeleteOptions{}) + err := client.Delete(context.Background(), templates.Items[1].GetStaticMetadata().Identifier(), resource.DeleteOptions{}) require.Truef(t, errors.IsBadRequest(err), "expected bad request but got %s", err) }) @@ -108,14 +109,14 @@ func TestIntegrationImportedTemplates(t *testing.T) { } tpl.Spec.Kind = v0alpha1.TemplateGroupTemplateKindGrafana - created, err := client.Create(context.Background(), &tpl, metav1.CreateOptions{}) + created, err := client.Create(context.Background(), &tpl, resource.CreateOptions{}) require.NoError(t, err) assert.NotEqual(t, templates.Items[1].Name, created.Name) }) t.Run("sort by kind and then name", func(t *testing.T) { - templates, err := client.List(context.Background(), metav1.ListOptions{}) + templates, err := client.List(context.Background(), apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) require.Len(t, templates.Items, 4) diff --git a/pkg/tests/apis/alerting/notifications/templategroup/templates_group_test.go b/pkg/tests/apis/alerting/notifications/templategroup/templates_group_test.go index 146bccd5f11..ea21d66fc14 100644 --- a/pkg/tests/apis/alerting/notifications/templategroup/templates_group_test.go +++ b/pkg/tests/apis/alerting/notifications/templategroup/templates_group_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/grafana/alerting/templates" + "github.com/grafana/grafana-app-sdk/resource" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/api/errors" @@ -45,7 +46,8 @@ func TestIntegrationResourceIdentifier(t *testing.T) { ctx := context.Background() helper := getTestHelper(t) - client := common.NewTemplateGroupClient(t, helper.Org1.Admin) + client, err := v0alpha1.NewTemplateGroupClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) newTemplate := &v0alpha1.TemplateGroup{ ObjectMeta: v1.ObjectMeta{ @@ -61,23 +63,23 @@ func TestIntegrationResourceIdentifier(t *testing.T) { t.Run("create should fail if object name is specified", func(t *testing.T) { template := newTemplate.Copy().(*v0alpha1.TemplateGroup) template.Name = "new-templateGroup" - _, err := client.Create(ctx, template, v1.CreateOptions{}) + _, err := client.Create(ctx, template, resource.CreateOptions{}) assert.Error(t, err) require.Truef(t, errors.IsBadRequest(err), "Expected BadRequest but got %s", err) }) - var resourceID string + var resourceID resource.Identifier t.Run("create should succeed and provide resource name", func(t *testing.T) { - actual, err := client.Create(ctx, newTemplate, v1.CreateOptions{}) + actual, err := client.Create(ctx, newTemplate, resource.CreateOptions{}) require.NoError(t, err) require.NotEmptyf(t, actual.Name, "Resource name should not be empty") require.NotEmptyf(t, actual.UID, "Resource UID should not be empty") - resourceID = actual.Name + resourceID = actual.GetStaticMetadata().Identifier() }) var existingTemplateGroup *v0alpha1.TemplateGroup t.Run("resource should be available by the identifier", func(t *testing.T) { - actual, err := client.Get(ctx, resourceID, v1.GetOptions{}) + actual, err := client.Get(ctx, resourceID) require.NoError(t, err) require.NotEmptyf(t, actual.Name, "Resource name should not be empty") require.Equal(t, newTemplate.Spec, actual.Spec) @@ -90,12 +92,12 @@ func TestIntegrationResourceIdentifier(t *testing.T) { } updated := existingTemplateGroup.Copy().(*v0alpha1.TemplateGroup) updated.Spec.Title = "another-templateGroup" - actual, err := client.Update(ctx, updated, v1.UpdateOptions{}) + actual, err := client.Update(ctx, updated, resource.UpdateOptions{}) require.NoError(t, err) require.Equal(t, updated.Spec, actual.Spec) require.NotEqualf(t, updated.Name, actual.Name, "Update should change the resource name but it didn't") - resource, err := client.Get(ctx, actual.Name, v1.GetOptions{}) + resource, err := client.Get(ctx, actual.GetStaticMetadata().Identifier()) require.NoError(t, err) require.Equal(t, actual, resource) @@ -104,7 +106,7 @@ func TestIntegrationResourceIdentifier(t *testing.T) { var defaultTemplateGroup *v0alpha1.TemplateGroup t.Run("default template should be available by the identifier", func(t *testing.T) { - actual, err := client.Get(ctx, templates.DefaultTemplateName, v1.GetOptions{}) + actual, err := client.Get(ctx, resource.Identifier{Namespace: apis.DefaultNamespace, Name: templates.DefaultTemplateName}) require.NoError(t, err) require.NotEmptyf(t, actual.Name, "Resource name should not be empty") @@ -122,7 +124,7 @@ func TestIntegrationResourceIdentifier(t *testing.T) { t.Run("create with reserved default title should work", func(t *testing.T) { template := newTemplate.Copy().(*v0alpha1.TemplateGroup) template.Spec.Title = defaultTemplateGroup.Spec.Title - actual, err := client.Create(ctx, template, v1.CreateOptions{}) + actual, err := client.Create(ctx, template, resource.CreateOptions{}) require.NoError(t, err) require.NotEmptyf(t, actual.Name, "Resource name should not be empty") require.NotEmptyf(t, actual.UID, "Resource UID should not be empty") @@ -130,7 +132,7 @@ func TestIntegrationResourceIdentifier(t *testing.T) { }) t.Run("default template should not be available by calculated UID", func(t *testing.T) { - actual, err := client.Get(ctx, newTemplateWithOverlappingName.Name, v1.GetOptions{}) + actual, err := client.Get(ctx, newTemplateWithOverlappingName.GetStaticMetadata().Identifier()) require.NoError(t, err) require.NotEmptyf(t, actual.Name, "Resource name should not be empty") @@ -215,11 +217,13 @@ func TestIntegrationAccessControl(t *testing.T) { }, } - adminClient := common.NewTemplateGroupClient(t, org1.Admin) + adminClient, err := v0alpha1.NewTemplateGroupClientFromGenerator(org1.Admin.GetClientRegistry()) + require.NoError(t, err) for _, tc := range testCases { t.Run(fmt.Sprintf("user '%s'", tc.user.Identity.GetLogin()), func(t *testing.T) { - client := common.NewTemplateGroupClient(t, tc.user) + client, err := v0alpha1.NewTemplateGroupClientFromGenerator(tc.user.GetClientRegistry()) + require.NoError(t, err) var expected = &v0alpha1.TemplateGroup{ ObjectMeta: v1.ObjectMeta{ @@ -237,12 +241,12 @@ func TestIntegrationAccessControl(t *testing.T) { if tc.canCreate { t.Run("should be able to create template group", func(t *testing.T) { - actual, err := client.Create(ctx, expected, v1.CreateOptions{}) + actual, err := client.Create(ctx, expected, resource.CreateOptions{}) require.NoErrorf(t, err, "Payload %s", string(d)) require.Equal(t, expected.Spec, actual.Spec) t.Run("should fail if already exists", func(t *testing.T) { - _, err := client.Create(ctx, actual, v1.CreateOptions{}) + _, err := client.Create(ctx, actual, resource.CreateOptions{}) require.Truef(t, errors.IsBadRequest(err), "expected bad request but got %s", err) }) @@ -250,45 +254,45 @@ func TestIntegrationAccessControl(t *testing.T) { }) } else { t.Run("should be forbidden to create", func(t *testing.T) { - _, err := client.Create(ctx, expected, v1.CreateOptions{}) + _, err := client.Create(ctx, expected, resource.CreateOptions{}) require.Truef(t, errors.IsForbidden(err), "Payload %s", string(d)) }) // create resource to proceed with other tests - expected, err = adminClient.Create(ctx, expected, v1.CreateOptions{}) + expected, err = adminClient.Create(ctx, expected, resource.CreateOptions{}) require.NoErrorf(t, err, "Payload %s", string(d)) require.NotNil(t, expected) } if tc.canRead { t.Run("should be able to list template groups", func(t *testing.T) { - list, err := client.List(ctx, v1.ListOptions{}) + list, err := client.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) require.Len(t, list.Items, 2) // Includes default template. }) t.Run("should be able to read template group by resource identifier", func(t *testing.T) { - got, err := client.Get(ctx, expected.Name, v1.GetOptions{}) + got, err := client.Get(ctx, expected.GetStaticMetadata().Identifier()) require.NoError(t, err) - require.Equal(t, expected, got) + require.Equal(t, expected.Spec, got.Spec) t.Run("should get NotFound if resource does not exist", func(t *testing.T) { - _, err := client.Get(ctx, "Notfound", v1.GetOptions{}) + _, err := client.Get(ctx, resource.Identifier{Namespace: apis.DefaultNamespace, Name: "Notfound"}) require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err) }) }) } else { t.Run("should be forbidden to list template groups", func(t *testing.T) { - _, err := client.List(ctx, v1.ListOptions{}) + _, err := client.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) t.Run("should be forbidden to read template group by name", func(t *testing.T) { - _, err := client.Get(ctx, expected.Name, v1.GetOptions{}) + _, err := client.Get(ctx, expected.GetStaticMetadata().Identifier()) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) t.Run("should get forbidden even if name does not exist", func(t *testing.T) { - _, err := client.Get(ctx, "Notfound", v1.GetOptions{}) + _, err := client.Get(ctx, resource.Identifier{Namespace: apis.DefaultNamespace, Name: "Notfound"}) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) }) @@ -302,7 +306,7 @@ func TestIntegrationAccessControl(t *testing.T) { if tc.canUpdate { t.Run("should be able to update template group", func(t *testing.T) { - updated, err := client.Update(ctx, updatedExpected, v1.UpdateOptions{}) + updated, err := client.Update(ctx, updatedExpected, resource.UpdateOptions{}) require.NoErrorf(t, err, "Payload %s", string(d)) expected = updated @@ -310,52 +314,54 @@ func TestIntegrationAccessControl(t *testing.T) { t.Run("should get NotFound if name does not exist", func(t *testing.T) { up := updatedExpected.Copy().(*v0alpha1.TemplateGroup) up.Name = "notFound" - _, err := client.Update(ctx, up, v1.UpdateOptions{}) + _, err := client.Update(ctx, up, resource.UpdateOptions{}) require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err) }) }) } else { t.Run("should be forbidden to update template group", func(t *testing.T) { - _, err := client.Update(ctx, updatedExpected, v1.UpdateOptions{}) + _, err := client.Update(ctx, updatedExpected, resource.UpdateOptions{}) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) t.Run("should get forbidden even if resource does not exist", func(t *testing.T) { up := updatedExpected.Copy().(*v0alpha1.TemplateGroup) up.Name = "notFound" - _, err := client.Update(ctx, up, v1.UpdateOptions{}) + _, err := client.Update(ctx, up, resource.UpdateOptions{ + ResourceVersion: up.ResourceVersion, + }) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) }) } deleteOptions := v1.DeleteOptions{Preconditions: &v1.Preconditions{ResourceVersion: util.Pointer(expected.ResourceVersion)}} - + oldClient := common.NewTemplateGroupClient(t, tc.user) // TODO replace with normal client once delete is fixed if tc.canDelete { t.Run("should be able to delete template group", func(t *testing.T) { - err := client.Delete(ctx, expected.Name, deleteOptions) + err := oldClient.Delete(ctx, expected.GetStaticMetadata().Identifier().Name, deleteOptions) require.NoError(t, err) t.Run("should get NotFound if name does not exist", func(t *testing.T) { - err := client.Delete(ctx, "notfound", v1.DeleteOptions{}) + err := oldClient.Delete(ctx, "notfound", v1.DeleteOptions{}) require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err) }) }) } else { t.Run("should be forbidden to delete template group", func(t *testing.T) { - err := client.Delete(ctx, expected.Name, deleteOptions) + err := oldClient.Delete(ctx, expected.GetStaticMetadata().Identifier().Name, deleteOptions) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) t.Run("should be forbidden even if resource does not exist", func(t *testing.T) { - err := client.Delete(ctx, "notfound", v1.DeleteOptions{}) + err := oldClient.Delete(ctx, "notfound", v1.DeleteOptions{}) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) }) - require.NoError(t, adminClient.Delete(ctx, expected.Name, v1.DeleteOptions{})) + require.NoError(t, adminClient.Delete(ctx, expected.GetStaticMetadata().Identifier(), resource.DeleteOptions{})) } if tc.canRead { t.Run("should get list with just default template if no template groups", func(t *testing.T) { - list, err := client.List(ctx, v1.ListOptions{}) + list, err := client.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) require.Len(t, list.Items, 1) require.Equal(t, templates.DefaultTemplateName, list.Items[0].Name) @@ -374,7 +380,8 @@ func TestIntegrationProvisioning(t *testing.T) { org := helper.Org1 admin := org.Admin - adminClient := common.NewTemplateGroupClient(t, admin) + adminClient, err := v0alpha1.NewTemplateGroupClientFromGenerator(admin.GetClientRegistry()) + require.NoError(t, err) env := helper.GetEnv() ac := acimpl.ProvideAccessControl(env.FeatureToggles) @@ -390,7 +397,7 @@ func TestIntegrationProvisioning(t *testing.T) { Content: `{{ define "test" }} test {{ end }}`, Kind: v0alpha1.TemplateGroupTemplateKindGrafana, }, - }, v1.CreateOptions{}) + }, resource.CreateOptions{}) require.NoError(t, err) require.Equal(t, "none", created.GetProvenanceStatus()) @@ -399,7 +406,7 @@ func TestIntegrationProvisioning(t *testing.T) { Name: created.Spec.Title, }, admin.Identity.GetOrgID(), "API")) - got, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) + got, err := adminClient.Get(ctx, created.GetStaticMetadata().Identifier()) require.NoError(t, err) require.Equal(t, "API", got.GetProvenanceStatus()) }) @@ -407,12 +414,12 @@ func TestIntegrationProvisioning(t *testing.T) { updated := created.Copy().(*v0alpha1.TemplateGroup) updated.Spec.Content = `{{ define "another-test" }} test {{ end }}` - _, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + _, err := adminClient.Update(ctx, updated, resource.UpdateOptions{}) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) t.Run("should not let delete if provisioned", func(t *testing.T) { - err := adminClient.Delete(ctx, created.Name, v1.DeleteOptions{}) + err := adminClient.Delete(ctx, created.GetStaticMetadata().Identifier(), resource.DeleteOptions{}) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) } @@ -423,8 +430,9 @@ func TestIntegrationOptimisticConcurrency(t *testing.T) { ctx := context.Background() helper := getTestHelper(t) - adminClient := common.NewTemplateGroupClient(t, helper.Org1.Admin) - + adminClient, err := v0alpha1.NewTemplateGroupClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) + oldClient := common.NewTemplateGroupClient(t, helper.Org1.Admin) template := v0alpha1.TemplateGroup{ ObjectMeta: v1.ObjectMeta{ Namespace: "default", @@ -436,21 +444,22 @@ func TestIntegrationOptimisticConcurrency(t *testing.T) { }, } - created, err := adminClient.Create(ctx, &template, v1.CreateOptions{}) + created, err := adminClient.Create(ctx, &template, resource.CreateOptions{}) require.NoError(t, err) require.NotNil(t, created) require.NotEmpty(t, created.ResourceVersion) t.Run("should forbid if version does not match", func(t *testing.T) { updated := created.Copy().(*v0alpha1.TemplateGroup) - updated.ResourceVersion = "test" - _, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + _, err := adminClient.Update(ctx, updated, resource.UpdateOptions{ + ResourceVersion: "test", + }) require.Truef(t, errors.IsConflict(err), "should get Forbidden error but got %s", err) }) t.Run("should update if version matches", func(t *testing.T) { updated := created.Copy().(*v0alpha1.TemplateGroup) updated.Spec.Content = `{{ define "test-another" }} test {{ end }}` - actualUpdated, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + actualUpdated, err := adminClient.Update(ctx, updated, resource.UpdateOptions{}) require.NoError(t, err) require.EqualValues(t, updated.Spec, actualUpdated.Spec) require.NotEqual(t, updated.ResourceVersion, actualUpdated.ResourceVersion) @@ -460,16 +469,16 @@ func TestIntegrationOptimisticConcurrency(t *testing.T) { updated.ResourceVersion = "" updated.Spec.Content = `{{ define "test-another-2" }} test {{ end }}` - actualUpdated, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + actualUpdated, err := adminClient.Update(ctx, updated, resource.UpdateOptions{}) require.NoError(t, err) require.EqualValues(t, updated.Spec, actualUpdated.Spec) require.NotEqual(t, created.ResourceVersion, actualUpdated.ResourceVersion) }) t.Run("should fail to delete if version does not match", func(t *testing.T) { - actual, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) + actual, err := adminClient.Get(ctx, created.GetStaticMetadata().Identifier()) require.NoError(t, err) - err = adminClient.Delete(ctx, actual.Name, v1.DeleteOptions{ + err = oldClient.Delete(ctx, actual.GetStaticMetadata().Identifier().Name, v1.DeleteOptions{ Preconditions: &v1.Preconditions{ ResourceVersion: util.Pointer("something"), }, @@ -477,10 +486,10 @@ func TestIntegrationOptimisticConcurrency(t *testing.T) { require.Truef(t, errors.IsConflict(err), "should get Forbidden error but got %s", err) }) t.Run("should succeed if version matches", func(t *testing.T) { - actual, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) + actual, err := adminClient.Get(ctx, created.GetStaticMetadata().Identifier()) require.NoError(t, err) - err = adminClient.Delete(ctx, actual.Name, v1.DeleteOptions{ + err = oldClient.Delete(ctx, actual.GetStaticMetadata().Identifier().Name, v1.DeleteOptions{ Preconditions: &v1.Preconditions{ ResourceVersion: util.Pointer(actual.ResourceVersion), }, @@ -488,10 +497,10 @@ func TestIntegrationOptimisticConcurrency(t *testing.T) { require.NoError(t, err) }) t.Run("should succeed if version is empty", func(t *testing.T) { - actual, err := adminClient.Create(ctx, &template, v1.CreateOptions{}) + actual, err := adminClient.Create(ctx, &template, resource.CreateOptions{}) require.NoError(t, err) - err = adminClient.Delete(ctx, actual.Name, v1.DeleteOptions{ + err = oldClient.Delete(ctx, actual.GetStaticMetadata().Identifier().Name, v1.DeleteOptions{ Preconditions: &v1.Preconditions{ ResourceVersion: util.Pointer(actual.ResourceVersion), }, @@ -506,7 +515,8 @@ func TestIntegrationPatch(t *testing.T) { ctx := context.Background() helper := getTestHelper(t) - adminClient := common.NewTemplateGroupClient(t, helper.Org1.Admin) + adminClient, err := v0alpha1.NewTemplateGroupClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) template := v0alpha1.TemplateGroup{ ObjectMeta: v1.ObjectMeta{ @@ -519,8 +529,10 @@ func TestIntegrationPatch(t *testing.T) { }, } - current, err := adminClient.Create(ctx, &template, v1.CreateOptions{}) + current, err := adminClient.Create(ctx, &template, resource.CreateOptions{}) require.NoError(t, err) + oldClient := common.NewTemplateGroupClient(t, helper.Org1.Admin) + require.NotNil(t, current) require.NotEmpty(t, current.ResourceVersion) @@ -531,7 +543,7 @@ func TestIntegrationPatch(t *testing.T) { } }` - result, err := adminClient.Patch(ctx, current.Name, types.MergePatchType, []byte(patch), v1.PatchOptions{}) + result, err := oldClient.Patch(ctx, current.GetStaticMetadata().Identifier().Name, types.MergePatchType, []byte(patch), v1.PatchOptions{}) require.NoError(t, err) require.Equal(t, `{{ define "test-another" }} test {{ end }}`, result.Spec.Content) current = result @@ -540,18 +552,15 @@ func TestIntegrationPatch(t *testing.T) { t.Run("should patch with json patch", func(t *testing.T) { expected := `{{ define "test-json-patch" }} test {{ end }}` - patch := []map[string]interface{}{ + patch := []resource.PatchOperation{ { - "op": "replace", - "path": "/spec/content", - "value": expected, + Operation: "replace", + Path: "/spec/content", + Value: expected, }, } - patchData, err := json.Marshal(patch) - require.NoError(t, err) - - result, err := adminClient.Patch(ctx, current.Name, types.JSONPatchType, patchData, v1.PatchOptions{}) + result, err := adminClient.Patch(ctx, current.GetStaticMetadata().Identifier(), resource.PatchRequest{Operations: patch}, resource.PatchOptions{}) require.NoError(t, err) expectedSpec := current.Spec expectedSpec.Content = expected @@ -565,7 +574,8 @@ func TestIntegrationListSelector(t *testing.T) { ctx := context.Background() helper := getTestHelper(t) - adminClient := common.NewTemplateGroupClient(t, helper.Org1.Admin) + adminClient, err := v0alpha1.NewTemplateGroupClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) template1 := &v0alpha1.TemplateGroup{ ObjectMeta: v1.ObjectMeta{ @@ -577,7 +587,7 @@ func TestIntegrationListSelector(t *testing.T) { Kind: v0alpha1.TemplateGroupTemplateKindGrafana, }, } - template1, err := adminClient.Create(ctx, template1, v1.CreateOptions{}) + template1, err = adminClient.Create(ctx, template1, resource.CreateOptions{}) require.NoError(t, err) template2 := &v0alpha1.TemplateGroup{ @@ -590,7 +600,7 @@ func TestIntegrationListSelector(t *testing.T) { Kind: v0alpha1.TemplateGroupTemplateKindGrafana, }, } - template2, err = adminClient.Create(ctx, template2, v1.CreateOptions{}) + template2, err = adminClient.Create(ctx, template2, resource.CreateOptions{}) require.NoError(t, err) env := helper.GetEnv() ac := acimpl.ProvideAccessControl(env.FeatureToggles) @@ -599,18 +609,18 @@ func TestIntegrationListSelector(t *testing.T) { require.NoError(t, db.SetProvenance(ctx, &definitions.NotificationTemplate{ Name: template2.Spec.Title, }, helper.Org1.Admin.Identity.GetOrgID(), "API")) - template2, err = adminClient.Get(ctx, template2.Name, v1.GetOptions{}) + template2, err = adminClient.Get(ctx, template2.GetStaticMetadata().Identifier()) require.NoError(t, err) - tmpls, err := adminClient.List(ctx, v1.ListOptions{}) + tmpls, err := adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) require.Len(t, tmpls.Items, 3) // Includes default template. t.Run("should filter by template name", func(t *testing.T) { t.Skip("disabled until app installer supports it") // TODO revisit when custom field selectors are supported - list, err := adminClient.List(ctx, v1.ListOptions{ - FieldSelector: "spec.title=" + template1.Spec.Title, + list, err := adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{ + FieldSelectors: []string{"spec.title=" + template1.Spec.Title}, }) require.NoError(t, err) require.Len(t, list.Items, 1) @@ -618,8 +628,8 @@ func TestIntegrationListSelector(t *testing.T) { }) t.Run("should filter by template metadata name", func(t *testing.T) { - list, err := adminClient.List(ctx, v1.ListOptions{ - FieldSelector: "metadata.name=" + template2.Name, + list, err := adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{ + FieldSelectors: []string{"metadata.name=" + template2.Name}, }) require.NoError(t, err) require.Len(t, list.Items, 1) @@ -628,8 +638,8 @@ func TestIntegrationListSelector(t *testing.T) { t.Run("should filter by multiple filters", func(t *testing.T) { t.Skip("disabled until app installer supports it") // TODO revisit when custom field selectors are supported - list, err := adminClient.List(ctx, v1.ListOptions{ - FieldSelector: fmt.Sprintf("metadata.name=%s,spec.title=%s", template2.Name, template2.Spec.Title), + list, err := adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{ + FieldSelectors: []string{fmt.Sprintf("metadata.name=%s,spec.title=%s", template2.Name, template2.Spec.Title)}, }) require.NoError(t, err) require.Len(t, list.Items, 1) @@ -637,8 +647,8 @@ func TestIntegrationListSelector(t *testing.T) { }) t.Run("should be empty when filter does not match", func(t *testing.T) { - list, err := adminClient.List(ctx, v1.ListOptions{ - FieldSelector: fmt.Sprintf("metadata.name=%s", "unknown"), + list, err := adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{ + FieldSelectors: []string{fmt.Sprintf("metadata.name=%s", "unknown")}, }) require.NoError(t, err) require.Empty(t, list.Items) @@ -646,17 +656,17 @@ func TestIntegrationListSelector(t *testing.T) { t.Run("should filter by default template name", func(t *testing.T) { t.Skip("disabled until app installer supports it") // TODO revisit when custom field selectors are supported - list, err := adminClient.List(ctx, v1.ListOptions{ - FieldSelector: "spec.title=" + v0alpha1.DefaultTemplateTitle, + list, err := adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{ + FieldSelectors: []string{"spec.title=" + v0alpha1.DefaultTemplateTitle}, }) require.NoError(t, err) require.Len(t, list.Items, 1) require.Equal(t, templates.DefaultTemplateName, list.Items[0].Name) // Now just non-default templates - list, err = adminClient.List(ctx, v1.ListOptions{ - FieldSelector: "spec.title!=" + v0alpha1.DefaultTemplateTitle, - }) + list, err = adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{ + FieldSelectors: []string{"spec.title!=" + v0alpha1.DefaultTemplateTitle}}, + ) require.NoError(t, err) require.Len(t, list.Items, 2) require.NotEqualf(t, templates.DefaultTemplateName, list.Items[0].Name, "Expected non-default template but got %s", list.Items[0].Name) @@ -669,7 +679,8 @@ func TestIntegrationKinds(t *testing.T) { ctx := context.Background() helper := getTestHelper(t) - client := common.NewTemplateGroupClient(t, helper.Org1.Admin) + client, err := v0alpha1.NewTemplateGroupClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) newTemplate := &v0alpha1.TemplateGroup{ ObjectMeta: v1.ObjectMeta{ @@ -683,17 +694,17 @@ func TestIntegrationKinds(t *testing.T) { } t.Run("should not let create Mimir template", func(t *testing.T) { - _, err := client.Create(ctx, newTemplate, v1.CreateOptions{}) + _, err := client.Create(ctx, newTemplate, resource.CreateOptions{}) require.Truef(t, errors.IsBadRequest(err), "expected bad request but got %s", err) }) t.Run("should not let change kind", func(t *testing.T) { newTemplate.Spec.Kind = v0alpha1.TemplateGroupTemplateKindGrafana - created, err := client.Create(ctx, newTemplate, v1.CreateOptions{}) + created, err := client.Create(ctx, newTemplate, resource.CreateOptions{}) require.NoError(t, err) created.Spec.Kind = v0alpha1.TemplateGroupTemplateKindMimir - _, err = client.Update(ctx, created, v1.UpdateOptions{}) + _, err = client.Update(ctx, created, resource.UpdateOptions{}) require.Truef(t, errors.IsBadRequest(err), "expected bad request but got %s", err) }) } diff --git a/pkg/tests/apis/alerting/notifications/timeinterval/timeinterval_test.go b/pkg/tests/apis/alerting/notifications/timeinterval/timeinterval_test.go index fdac61b048b..a05e108a091 100644 --- a/pkg/tests/apis/alerting/notifications/timeinterval/timeinterval_test.go +++ b/pkg/tests/apis/alerting/notifications/timeinterval/timeinterval_test.go @@ -10,6 +10,7 @@ import ( "slices" "testing" + "github.com/grafana/grafana-app-sdk/resource" "github.com/prometheus/alertmanager/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -57,7 +58,8 @@ func TestIntegrationResourceIdentifier(t *testing.T) { ctx := context.Background() helper := getTestHelper(t) - client := common.NewTimeIntervalClient(t, helper.Org1.Admin) + client, err := v0alpha1.NewTimeIntervalClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) newInterval := &v0alpha1.TimeInterval{ ObjectMeta: v1.ObjectMeta{ @@ -72,22 +74,22 @@ func TestIntegrationResourceIdentifier(t *testing.T) { t.Run("create should fail if object name is specified", func(t *testing.T) { interval := newInterval.Copy().(*v0alpha1.TimeInterval) interval.Name = "time-newInterval" - _, err := client.Create(ctx, interval, v1.CreateOptions{}) + _, err := client.Create(ctx, interval, resource.CreateOptions{}) require.Truef(t, errors.IsBadRequest(err), "Expected BadRequest but got %s", err) }) - var resourceID string + var resourceID resource.Identifier t.Run("create should succeed and provide resource name", func(t *testing.T) { - actual, err := client.Create(ctx, newInterval, v1.CreateOptions{}) + actual, err := client.Create(ctx, newInterval, resource.CreateOptions{}) require.NoError(t, err) require.NotEmptyf(t, actual.Name, "Resource name should not be empty") require.NotEmptyf(t, actual.UID, "Resource UID should not be empty") - resourceID = actual.Name + resourceID = actual.GetStaticMetadata().Identifier() }) var existingInterval *v0alpha1.TimeInterval t.Run("resource should be available by the identifier", func(t *testing.T) { - actual, err := client.Get(ctx, resourceID, v1.GetOptions{}) + actual, err := client.Get(ctx, resourceID) require.NoError(t, err) require.NotEmptyf(t, actual.Name, "Resource name should not be empty") require.Equal(t, newInterval.Spec, actual.Spec) @@ -100,13 +102,13 @@ func TestIntegrationResourceIdentifier(t *testing.T) { } updated := existingInterval.Copy().(*v0alpha1.TimeInterval) updated.Spec.Name = "another-newInterval" - actual, err := client.Update(ctx, updated, v1.UpdateOptions{}) + actual, err := client.Update(ctx, updated, resource.UpdateOptions{}) require.NoError(t, err) require.Equal(t, updated.Spec, actual.Spec) require.NotEqualf(t, updated.Name, actual.Name, "Update should change the resource name but it didn't") require.NotEqualf(t, updated.ResourceVersion, actual.ResourceVersion, "Update should change the resource version but it didn't") - resource, err := client.Get(ctx, actual.Name, v1.GetOptions{}) + resource, err := client.Get(ctx, actual.GetStaticMetadata().Identifier()) require.NoError(t, err) require.Equal(t, actual, resource) }) @@ -189,11 +191,13 @@ func TestIntegrationTimeIntervalAccessControl(t *testing.T) { }, } - adminClient := common.NewTimeIntervalClient(t, helper.Org1.Admin) + adminClient, err := v0alpha1.NewTimeIntervalClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) for _, tc := range testCases { t.Run(fmt.Sprintf("user '%s'", tc.user.Identity.GetLogin()), func(t *testing.T) { - client := common.NewTimeIntervalClient(t, tc.user) + client, err := v0alpha1.NewTimeIntervalClientFromGenerator(tc.user.GetClientRegistry()) + require.NoError(t, err) var expected = &v0alpha1.TimeInterval{ ObjectMeta: v1.ObjectMeta{ Namespace: "default", @@ -209,12 +213,12 @@ func TestIntegrationTimeIntervalAccessControl(t *testing.T) { if tc.canCreate { t.Run("should be able to create time interval", func(t *testing.T) { - actual, err := client.Create(ctx, expected, v1.CreateOptions{}) + actual, err := client.Create(ctx, expected, resource.CreateOptions{}) require.NoErrorf(t, err, "Payload %s", string(d)) require.Equal(t, expected.Spec, actual.Spec) t.Run("should fail if already exists", func(t *testing.T) { - _, err := client.Create(ctx, actual, v1.CreateOptions{}) + _, err := client.Create(ctx, actual, resource.CreateOptions{}) require.Truef(t, errors.IsBadRequest(err), "expected bad request but got %s", err) }) @@ -222,45 +226,45 @@ func TestIntegrationTimeIntervalAccessControl(t *testing.T) { }) } else { t.Run("should be forbidden to create", func(t *testing.T) { - _, err := client.Create(ctx, expected, v1.CreateOptions{}) + _, err := client.Create(ctx, expected, resource.CreateOptions{}) require.Truef(t, errors.IsForbidden(err), "Payload %s", string(d)) }) // create resource to proceed with other tests - expected, err = adminClient.Create(ctx, expected, v1.CreateOptions{}) + expected, err = adminClient.Create(ctx, expected, resource.CreateOptions{}) require.NoErrorf(t, err, "Payload %s", string(d)) require.NotNil(t, expected) } if tc.canRead { t.Run("should be able to list time intervals", func(t *testing.T) { - list, err := client.List(ctx, v1.ListOptions{}) + list, err := client.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) require.Len(t, list.Items, 1) }) t.Run("should be able to read time interval by resource identifier", func(t *testing.T) { - got, err := client.Get(ctx, expected.Name, v1.GetOptions{}) + got, err := client.Get(ctx, expected.GetStaticMetadata().Identifier()) require.NoError(t, err) - require.Equal(t, expected, got) + require.Equal(t, expected.Spec, got.Spec) t.Run("should get NotFound if resource does not exist", func(t *testing.T) { - _, err := client.Get(ctx, "Notfound", v1.GetOptions{}) + _, err := client.Get(ctx, resource.Identifier{Namespace: apis.DefaultNamespace, Name: "Notfound"}) require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err) }) }) } else { t.Run("should be forbidden to list time intervals", func(t *testing.T) { - _, err := client.List(ctx, v1.ListOptions{}) + _, err := client.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) t.Run("should be forbidden to read time interval by name", func(t *testing.T) { - _, err := client.Get(ctx, expected.Name, v1.GetOptions{}) + _, err := client.Get(ctx, expected.GetStaticMetadata().Identifier()) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) t.Run("should get forbidden even if name does not exist", func(t *testing.T) { - _, err := client.Get(ctx, "Notfound", v1.GetOptions{}) + _, err := client.Get(ctx, resource.Identifier{Namespace: apis.DefaultNamespace, Name: "Notfound"}) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) }) @@ -274,7 +278,7 @@ func TestIntegrationTimeIntervalAccessControl(t *testing.T) { if tc.canUpdate { t.Run("should be able to update time interval", func(t *testing.T) { - updated, err := client.Update(ctx, updatedExpected, v1.UpdateOptions{}) + updated, err := client.Update(ctx, updatedExpected, resource.UpdateOptions{}) require.NoErrorf(t, err, "Payload %s", string(d)) expected = updated @@ -282,52 +286,54 @@ func TestIntegrationTimeIntervalAccessControl(t *testing.T) { t.Run("should get NotFound if name does not exist", func(t *testing.T) { up := updatedExpected.Copy().(*v0alpha1.TimeInterval) up.Name = "notFound" - _, err := client.Update(ctx, up, v1.UpdateOptions{}) + _, err := client.Update(ctx, up, resource.UpdateOptions{}) require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err) }) }) } else { t.Run("should be forbidden to update time interval", func(t *testing.T) { - _, err := client.Update(ctx, updatedExpected, v1.UpdateOptions{}) + _, err := client.Update(ctx, updatedExpected, resource.UpdateOptions{}) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) t.Run("should get forbidden even if resource does not exist", func(t *testing.T) { up := updatedExpected.Copy().(*v0alpha1.TimeInterval) up.Name = "notFound" - _, err := client.Update(ctx, up, v1.UpdateOptions{}) + _, err := client.Update(ctx, up, resource.UpdateOptions{ + ResourceVersion: up.ResourceVersion, + }) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) }) } deleteOptions := v1.DeleteOptions{Preconditions: &v1.Preconditions{ResourceVersion: util.Pointer(expected.ResourceVersion)}} - + oldClient := common.NewTimeIntervalClient(t, tc.user) if tc.canDelete { t.Run("should be able to delete time interval", func(t *testing.T) { - err := client.Delete(ctx, expected.Name, deleteOptions) + err := oldClient.Delete(ctx, expected.GetStaticMetadata().Identifier().Name, deleteOptions) require.NoError(t, err) t.Run("should get NotFound if name does not exist", func(t *testing.T) { - err := client.Delete(ctx, "notfound", v1.DeleteOptions{}) + err := oldClient.Delete(ctx, "notfound", v1.DeleteOptions{}) require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err) }) }) } else { t.Run("should be forbidden to delete time interval", func(t *testing.T) { - err := client.Delete(ctx, expected.Name, deleteOptions) + err := oldClient.Delete(ctx, expected.GetStaticMetadata().Identifier().Name, deleteOptions) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) t.Run("should be forbidden even if resource does not exist", func(t *testing.T) { - err := client.Delete(ctx, "notfound", v1.DeleteOptions{}) + err := oldClient.Delete(ctx, "notfound", v1.DeleteOptions{}) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) }) - require.NoError(t, adminClient.Delete(ctx, expected.Name, v1.DeleteOptions{})) + require.NoError(t, adminClient.Delete(ctx, expected.GetStaticMetadata().Identifier(), resource.DeleteOptions{})) } if tc.canRead { t.Run("should get empty list if no mute timings", func(t *testing.T) { - list, err := client.List(ctx, v1.ListOptions{}) + list, err := client.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) require.Len(t, list.Items, 0) }) @@ -345,7 +351,8 @@ func TestIntegrationTimeIntervalProvisioning(t *testing.T) { org := helper.Org1 admin := org.Admin - adminClient := common.NewTimeIntervalClient(t, helper.Org1.Admin) + adminClient, err := v0alpha1.NewTimeIntervalClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) env := helper.GetEnv() ac := acimpl.ProvideAccessControl(env.FeatureToggles) @@ -360,7 +367,7 @@ func TestIntegrationTimeIntervalProvisioning(t *testing.T) { Name: "time-interval-1", TimeIntervals: fakes.IntervalGenerator{}.GenerateMany(2), }, - }, v1.CreateOptions{}) + }, resource.CreateOptions{}) require.NoError(t, err) require.Equal(t, "none", created.GetProvenanceStatus()) @@ -371,7 +378,7 @@ func TestIntegrationTimeIntervalProvisioning(t *testing.T) { }, }, admin.Identity.GetOrgID(), "API")) - got, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) + got, err := adminClient.Get(ctx, created.GetStaticMetadata().Identifier()) require.NoError(t, err) require.Equal(t, "API", got.GetProvenanceStatus()) }) @@ -379,12 +386,12 @@ func TestIntegrationTimeIntervalProvisioning(t *testing.T) { updated := created.Copy().(*v0alpha1.TimeInterval) updated.Spec.TimeIntervals = fakes.IntervalGenerator{}.GenerateMany(2) - _, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + _, err := adminClient.Update(ctx, updated, resource.UpdateOptions{}) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) t.Run("should not let delete if provisioned", func(t *testing.T) { - err := adminClient.Delete(ctx, created.Name, v1.DeleteOptions{}) + err := adminClient.Delete(ctx, created.GetStaticMetadata().Identifier(), resource.DeleteOptions{}) require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) }) } @@ -395,7 +402,9 @@ func TestIntegrationTimeIntervalOptimisticConcurrency(t *testing.T) { ctx := context.Background() helper := getTestHelper(t) - adminClient := common.NewTimeIntervalClient(t, helper.Org1.Admin) + adminClient, err := v0alpha1.NewTimeIntervalClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) + oldClient := common.NewTimeIntervalClient(t, helper.Org1.Admin) interval := v0alpha1.TimeInterval{ ObjectMeta: v1.ObjectMeta{ @@ -407,21 +416,22 @@ func TestIntegrationTimeIntervalOptimisticConcurrency(t *testing.T) { }, } - created, err := adminClient.Create(ctx, &interval, v1.CreateOptions{}) + created, err := adminClient.Create(ctx, &interval, resource.CreateOptions{}) require.NoError(t, err) require.NotNil(t, created) require.NotEmpty(t, created.ResourceVersion) t.Run("should forbid if version does not match", func(t *testing.T) { updated := created.Copy().(*v0alpha1.TimeInterval) - updated.ResourceVersion = "test" - _, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + _, err := adminClient.Update(ctx, updated, resource.UpdateOptions{ + ResourceVersion: "test", + }) require.Truef(t, errors.IsConflict(err), "should get Forbidden error but got %s", err) }) t.Run("should update if version matches", func(t *testing.T) { updated := created.Copy().(*v0alpha1.TimeInterval) updated.Spec.TimeIntervals = fakes.IntervalGenerator{}.GenerateMany(2) - actualUpdated, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + actualUpdated, err := adminClient.Update(ctx, updated, resource.UpdateOptions{}) require.NoError(t, err) require.EqualValues(t, updated.Spec, actualUpdated.Spec) require.NotEqual(t, updated.ResourceVersion, actualUpdated.ResourceVersion) @@ -431,16 +441,16 @@ func TestIntegrationTimeIntervalOptimisticConcurrency(t *testing.T) { updated.ResourceVersion = "" updated.Spec.TimeIntervals = fakes.IntervalGenerator{}.GenerateMany(2) - actualUpdated, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + actualUpdated, err := adminClient.Update(ctx, updated, resource.UpdateOptions{}) require.NoError(t, err) require.EqualValues(t, updated.Spec, actualUpdated.Spec) require.NotEqual(t, created.ResourceVersion, actualUpdated.ResourceVersion) }) t.Run("should fail to delete if version does not match", func(t *testing.T) { - actual, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) + actual, err := adminClient.Get(ctx, created.GetStaticMetadata().Identifier()) require.NoError(t, err) - err = adminClient.Delete(ctx, actual.Name, v1.DeleteOptions{ + err = oldClient.Delete(ctx, actual.GetStaticMetadata().Identifier().Name, v1.DeleteOptions{ Preconditions: &v1.Preconditions{ ResourceVersion: util.Pointer("something"), }, @@ -448,10 +458,10 @@ func TestIntegrationTimeIntervalOptimisticConcurrency(t *testing.T) { require.Truef(t, errors.IsConflict(err), "should get Forbidden error but got %s", err) }) t.Run("should succeed if version matches", func(t *testing.T) { - actual, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) + actual, err := adminClient.Get(ctx, created.GetStaticMetadata().Identifier()) require.NoError(t, err) - err = adminClient.Delete(ctx, actual.Name, v1.DeleteOptions{ + err = oldClient.Delete(ctx, actual.GetStaticMetadata().Identifier().Name, v1.DeleteOptions{ Preconditions: &v1.Preconditions{ ResourceVersion: util.Pointer(actual.ResourceVersion), }, @@ -459,10 +469,10 @@ func TestIntegrationTimeIntervalOptimisticConcurrency(t *testing.T) { require.NoError(t, err) }) t.Run("should succeed if version is empty", func(t *testing.T) { - actual, err := adminClient.Create(ctx, &interval, v1.CreateOptions{}) + actual, err := adminClient.Create(ctx, &interval, resource.CreateOptions{}) require.NoError(t, err) - err = adminClient.Delete(ctx, actual.Name, v1.DeleteOptions{ + err = oldClient.Delete(ctx, actual.GetStaticMetadata().Identifier().Name, v1.DeleteOptions{ Preconditions: &v1.Preconditions{ ResourceVersion: util.Pointer(actual.ResourceVersion), }, @@ -477,7 +487,9 @@ func TestIntegrationTimeIntervalPatch(t *testing.T) { ctx := context.Background() helper := getTestHelper(t) - adminClient := common.NewTimeIntervalClient(t, helper.Org1.Admin) + adminClient, err := v0alpha1.NewTimeIntervalClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) + oldClient := common.NewTimeIntervalClient(t, helper.Org1.Admin) interval := v0alpha1.TimeInterval{ ObjectMeta: v1.ObjectMeta{ @@ -489,7 +501,7 @@ func TestIntegrationTimeIntervalPatch(t *testing.T) { }, } - current, err := adminClient.Create(ctx, &interval, v1.CreateOptions{}) + current, err := adminClient.Create(ctx, &interval, resource.CreateOptions{}) require.NoError(t, err) require.NotNil(t, current) require.NotEmpty(t, current.ResourceVersion) @@ -501,7 +513,7 @@ func TestIntegrationTimeIntervalPatch(t *testing.T) { } }` - result, err := adminClient.Patch(ctx, current.Name, types.MergePatchType, []byte(patch), v1.PatchOptions{}) + result, err := oldClient.Patch(ctx, current.GetStaticMetadata().Identifier().Name, types.MergePatchType, []byte(patch), v1.PatchOptions{}) require.NoError(t, err) require.Empty(t, result.Spec.TimeIntervals) current = result @@ -510,18 +522,15 @@ func TestIntegrationTimeIntervalPatch(t *testing.T) { t.Run("should patch with json patch", func(t *testing.T) { expected := fakes.IntervalGenerator{}.Generate() - patch := []map[string]interface{}{ + patch := []resource.PatchOperation{ { - "op": "add", - "path": "/spec/time_intervals/-", - "value": expected, + Operation: "add", + Path: "/spec/time_intervals/-", + Value: expected, }, } - patchData, err := json.Marshal(patch) - require.NoError(t, err) - - result, err := adminClient.Patch(ctx, current.Name, types.JSONPatchType, patchData, v1.PatchOptions{}) + result, err := adminClient.Patch(ctx, current.GetStaticMetadata().Identifier(), resource.PatchRequest{Operations: patch}, resource.PatchOptions{}) require.NoError(t, err) expectedSpec := v0alpha1.TimeIntervalSpec{ Name: current.Spec.Name, @@ -540,7 +549,8 @@ func TestIntegrationTimeIntervalListSelector(t *testing.T) { ctx := context.Background() helper := getTestHelper(t) - adminClient := common.NewTimeIntervalClient(t, helper.Org1.Admin) + adminClient, err := v0alpha1.NewTimeIntervalClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) interval1 := &v0alpha1.TimeInterval{ ObjectMeta: v1.ObjectMeta{ @@ -551,7 +561,7 @@ func TestIntegrationTimeIntervalListSelector(t *testing.T) { TimeIntervals: fakes.IntervalGenerator{}.GenerateMany(2), }, } - interval1, err := adminClient.Create(ctx, interval1, v1.CreateOptions{}) + interval1, err = adminClient.Create(ctx, interval1, resource.CreateOptions{}) require.NoError(t, err) interval2 := &v0alpha1.TimeInterval{ @@ -563,7 +573,7 @@ func TestIntegrationTimeIntervalListSelector(t *testing.T) { TimeIntervals: fakes.IntervalGenerator{}.GenerateMany(2), }, } - interval2, err = adminClient.Create(ctx, interval2, v1.CreateOptions{}) + interval2, err = adminClient.Create(ctx, interval2, resource.CreateOptions{}) require.NoError(t, err) env := helper.GetEnv() ac := acimpl.ProvideAccessControl(env.FeatureToggles) @@ -574,18 +584,18 @@ func TestIntegrationTimeIntervalListSelector(t *testing.T) { Name: interval2.Spec.Name, }, }, helper.Org1.Admin.Identity.GetOrgID(), "API")) - interval2, err = adminClient.Get(ctx, interval2.Name, v1.GetOptions{}) + interval2, err = adminClient.Get(ctx, interval2.GetStaticMetadata().Identifier()) require.NoError(t, err) - intervals, err := adminClient.List(ctx, v1.ListOptions{}) + intervals, err := adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) require.Len(t, intervals.Items, 2) t.Run("should filter by interval name", func(t *testing.T) { t.Skip("disabled until app installer supports it") // TODO revisit when custom field selectors are supported - list, err := adminClient.List(ctx, v1.ListOptions{ - FieldSelector: "spec.name=" + interval1.Spec.Name, + list, err := adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{ + FieldSelectors: []string{"spec.name=" + interval1.Spec.Name}, }) require.NoError(t, err) require.Len(t, list.Items, 1) @@ -593,8 +603,8 @@ func TestIntegrationTimeIntervalListSelector(t *testing.T) { }) t.Run("should filter by interval metadata name", func(t *testing.T) { - list, err := adminClient.List(ctx, v1.ListOptions{ - FieldSelector: "metadata.name=" + interval2.Name, + list, err := adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{ + FieldSelectors: []string{"metadata.name=" + interval2.Name}, }) require.NoError(t, err) require.Len(t, list.Items, 1) @@ -603,8 +613,8 @@ func TestIntegrationTimeIntervalListSelector(t *testing.T) { t.Run("should filter by multiple filters", func(t *testing.T) { t.Skip("disabled until app installer supports it") - list, err := adminClient.List(ctx, v1.ListOptions{ - FieldSelector: fmt.Sprintf("metadata.name=%s,spec.name=%s", interval2.Name, interval2.Spec.Name), + list, err := adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{ + FieldSelectors: []string{fmt.Sprintf("metadata.name=%s", interval2.Name), fmt.Sprintf("spec.name=%s", interval2.Spec.Name)}, }) require.NoError(t, err) require.Len(t, list.Items, 1) @@ -612,8 +622,8 @@ func TestIntegrationTimeIntervalListSelector(t *testing.T) { }) t.Run("should be empty when filter does not match", func(t *testing.T) { - list, err := adminClient.List(ctx, v1.ListOptions{ - FieldSelector: fmt.Sprintf("metadata.name=%s", "unknown"), + list, err := adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{ + FieldSelectors: []string{fmt.Sprintf("metadata.name=%s", "unknown")}, }) require.NoError(t, err) require.Empty(t, list.Items) @@ -647,18 +657,20 @@ func TestIntegrationTimeIntervalReferentialIntegrity(t *testing.T) { }) } - adminClient := common.NewTimeIntervalClient(t, helper.Org1.Admin) + adminClient, err := v0alpha1.NewTimeIntervalClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) v1intervals, err := timeinterval.ConvertToK8sResources(orgID, mtis, func(int64) string { return "default" }, nil) require.NoError(t, err) for _, interval := range v1intervals.Items { - _, err := adminClient.Create(ctx, &interval, v1.CreateOptions{}) + _, err := adminClient.Create(ctx, &interval, resource.CreateOptions{}) require.NoError(t, err) } - routeClient := common.NewRoutingTreeClient(t, helper.Org1.Admin) + routeClient, err := v0alpha1.NewRoutingTreeClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) v1route, err := routingtree.ConvertToK8sResource(helper.Org1.Admin.Identity.GetOrgID(), *amConfig.AlertmanagerConfig.Route, "", func(int64) string { return "default" }) require.NoError(t, err) - _, err = routeClient.Update(ctx, v1route, v1.UpdateOptions{}) + _, err = routeClient.Update(ctx, v1route, resource.UpdateOptions{}) require.NoError(t, err) postGroupRaw, err := testData.ReadFile(path.Join("test-data", "rulegroup-1.json")) @@ -675,7 +687,7 @@ func TestIntegrationTimeIntervalReferentialIntegrity(t *testing.T) { currentRuleGroup, status := legacyCli.GetRulesGroup(t, folderUID, ruleGroup.Name) require.Equal(t, http.StatusAccepted, status) - intervals, err := adminClient.List(ctx, v1.ListOptions{}) + intervals, err := adminClient.List(ctx, apis.DefaultNamespace, resource.ListOptions{}) require.NoError(t, err) require.Len(t, intervals.Items, 3) intervalIdx := slices.IndexFunc(intervals.Items, func(interval v0alpha1.TimeInterval) bool { @@ -700,7 +712,7 @@ func TestIntegrationTimeIntervalReferentialIntegrity(t *testing.T) { renamed := interval.Copy().(*v0alpha1.TimeInterval) renamed.Spec.Name += "-new" - actual, err := adminClient.Update(ctx, renamed, v1.UpdateOptions{}) + actual, err := adminClient.Update(ctx, renamed, resource.UpdateOptions{}) require.NoError(t, err) updatedRuleGroup, status := legacyCli.GetRulesGroup(t, folderUID, ruleGroup.Name) @@ -732,20 +744,20 @@ func TestIntegrationTimeIntervalReferentialIntegrity(t *testing.T) { t.Cleanup(func() { require.NoError(t, db.DeleteProvenance(ctx, ¤tRoute, orgID)) }) - actual, err := adminClient.Update(ctx, renamed, v1.UpdateOptions{}) + actual, err := adminClient.Update(ctx, renamed, resource.UpdateOptions{}) require.Errorf(t, err, "Expected error but got successful result: %v", actual) require.Truef(t, errors.IsConflict(err), "Expected Conflict, got: %s", err) }) t.Run("provisioned rules", func(t *testing.T) { ruleUid := currentRuleGroup.Rules[0].GrafanaManagedAlert.UID - resource := &ngmodels.AlertRule{UID: ruleUid} - require.NoError(t, db.SetProvenance(ctx, resource, orgID, "API")) + rule := &ngmodels.AlertRule{UID: ruleUid} + require.NoError(t, db.SetProvenance(ctx, rule, orgID, "API")) t.Cleanup(func() { - require.NoError(t, db.DeleteProvenance(ctx, resource, orgID)) + require.NoError(t, db.DeleteProvenance(ctx, rule, orgID)) }) - actual, err := adminClient.Update(ctx, renamed, v1.UpdateOptions{}) + actual, err := adminClient.Update(ctx, renamed, resource.UpdateOptions{}) require.Errorf(t, err, "Expected error but got successful result: %v", actual) require.Truef(t, errors.IsConflict(err), "Expected Conflict, got: %s", err) }) @@ -754,7 +766,7 @@ func TestIntegrationTimeIntervalReferentialIntegrity(t *testing.T) { t.Run("Delete", func(t *testing.T) { t.Run("should fail to delete if time interval is used in rule and routes", func(t *testing.T) { - err := adminClient.Delete(ctx, interval.Name, v1.DeleteOptions{}) + err := adminClient.Delete(ctx, interval.GetStaticMetadata().Identifier(), resource.DeleteOptions{}) require.Truef(t, errors.IsConflict(err), "Expected Conflict, got: %s", err) }) @@ -763,7 +775,7 @@ func TestIntegrationTimeIntervalReferentialIntegrity(t *testing.T) { route.Routes[0].MuteTimeIntervals = nil legacyCli.UpdateRoute(t, route, true) - err = adminClient.Delete(ctx, interval.Name, v1.DeleteOptions{}) + err = adminClient.Delete(ctx, interval.GetStaticMetadata().Identifier(), resource.DeleteOptions{}) require.Truef(t, errors.IsConflict(err), "Expected Conflict, got: %s", err) }) @@ -773,7 +785,7 @@ func TestIntegrationTimeIntervalReferentialIntegrity(t *testing.T) { }) intervalToDelete := intervals.Items[idx] - err = adminClient.Delete(ctx, intervalToDelete.Name, v1.DeleteOptions{}) + err = adminClient.Delete(ctx, intervalToDelete.GetStaticMetadata().Identifier(), resource.DeleteOptions{}) require.Truef(t, errors.IsConflict(err), "Expected Conflict, got: %s", err) }) }) @@ -785,7 +797,8 @@ func TestIntegrationTimeIntervalValidation(t *testing.T) { ctx := context.Background() helper := getTestHelper(t) - adminClient := common.NewTimeIntervalClient(t, helper.Org1.Admin) + adminClient, err := v0alpha1.NewTimeIntervalClientFromGenerator(helper.Org1.Admin.GetClientRegistry()) + require.NoError(t, err) testCases := []struct { name string @@ -819,7 +832,7 @@ func TestIntegrationTimeIntervalValidation(t *testing.T) { }, Spec: tc.interval, } - _, err := adminClient.Create(ctx, i, v1.CreateOptions{}) + _, err := adminClient.Create(ctx, i, resource.CreateOptions{}) require.Error(t, err) require.Truef(t, errors.IsBadRequest(err), "Expected BadRequest, got: %s", err) }) diff --git a/pkg/tests/apis/config_test.go b/pkg/tests/apis/config_test.go deleted file mode 100644 index 665d79a5e50..00000000000 --- a/pkg/tests/apis/config_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package apis - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/tests/testinfra" - "github.com/grafana/grafana/pkg/util/testutil" -) - -const pluginsDiscoveryJSON = `[ -{ - "version": "v0alpha1", - "freshness": "Current", - "resources": [ - { - "resource": "metas", - "responseKind": { - "group": "", - "kind": "Meta", - "version": "" - }, - "scope": "Namespaced", - "singularResource": "meta", - "subresources": [ - { - "responseKind": { - "group": "", - "kind": "Meta", - "version": "" - }, - "subresource": "status", - "verbs": [ - "get", - "patch", - "update" - ] - } - ], - "verbs": [ - "get", - "list" - ] - }, - { - "resource": "plugins", - "responseKind": { - "group": "", - "kind": "Plugin", - "version": "" - }, - "scope": "Namespaced", - "singularResource": "plugin", - "subresources": [ - { - "responseKind": { - "group": "", - "kind": "Plugin", - "version": "" - }, - "subresource": "status", - "verbs": [ - "get", - "patch", - "update" - ] - } - ], - "verbs": [ - "create", - "delete", - "deletecollection", - "get", - "list", - "patch", - "update", - "watch" - ] - } - ] -} -]` - -func setupHelper(t *testing.T, openFeatureAPIEnabled bool) *K8sTestHelper { - t.Helper() - helper := NewK8sTestHelper(t, testinfra.GrafanaOpts{ - AppModeProduction: true, - DisableAnonymous: true, - APIServerRuntimeConfig: "plugins.grafana.app/v0alpha1=true", - OpenFeatureAPIEnabled: openFeatureAPIEnabled, - }) - t.Cleanup(func() { helper.Shutdown() }) - return helper -} - -func TestIntegrationAPIServerRuntimeConfig(t *testing.T) { - testutil.SkipIntegrationTestInShortMode(t) - - t.Run("discovery with openfeature api enabled", func(t *testing.T) { - helper := setupHelper(t, true) - disco, err := helper.GetGroupVersionInfoJSON("features.grafana.app") - require.NoError(t, err) - require.JSONEq(t, `[ - { - "freshness": "Current", - "resources": [ - { - "resource": "noop", - "responseKind": { - "group": "", - "kind": "Status", - "version": "" - }, - "scope": "Namespaced", - "singularResource": "noop", - "verbs": [ - "get" - ] - } - ], - "version": "v0alpha1" - } - ]`, disco) - - // plugins should still be discoverable - disco, err = helper.GetGroupVersionInfoJSON("plugins.grafana.app") - require.NoError(t, err) - require.JSONEq(t, pluginsDiscoveryJSON, disco) - require.NoError(t, err) - }) - - t.Run("discovery with openfeature api false", func(t *testing.T) { - helper := setupHelper(t, false) - _, err := helper.GetGroupVersionInfoJSON("features.grafana.app") - require.Error(t, err, "expected error when openfeature api is disabled") - - // plugins should still be discoverable - disco, err := helper.GetGroupVersionInfoJSON("plugins.grafana.app") - require.NoError(t, err) - require.JSONEq(t, pluginsDiscoveryJSON, disco) - require.NoError(t, err) - }) -} diff --git a/pkg/tests/apis/dashboard/integration/api_validation_test.go b/pkg/tests/apis/dashboard/integration/api_validation_test.go index ee40bf00b47..96a364cb8bc 100644 --- a/pkg/tests/apis/dashboard/integration/api_validation_test.go +++ b/pkg/tests/apis/dashboard/integration/api_validation_test.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/stretchr/testify/require" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -132,6 +133,94 @@ func TestIntegrationDashboardAPIValidation(t *testing.T) { } } +func TestIntegrationDashboardAPIZanzana(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + DisableDataMigrations: true, + AppModeProduction: true, + DisableAnonymous: true, + DisableAuthZClientCache: true, + DisableZanzanaCache: true, + DisableZanzanaServerCheckQueryCache: true, + ZanzanaReconciliationInterval: 1 * time.Second, + APIServerStorageType: "unified", + DBMaxConns: 10, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": { + DualWriterMode: rest.Mode5, + }, + "folders.folder.grafana.app": { + DualWriterMode: rest.Mode5, + }, + }, + EnableFeatureToggles: []string{ + "zanzana", + "zanzanaNoLegacyClient", + "kubernetesAuthzZanzanaSync", + }, + UnifiedStorageEnableSearch: true, + }) + + t.Cleanup(func() { + helper.Shutdown() + }) + + org1Ctx := createTestContext(t, helper, helper.Org1, rest.Mode5) + org2Ctx := createTestContext(t, helper, helper.OrgB, rest.Mode5) + + t.Run("Dashboard permission tests", func(t *testing.T) { + runDashboardPermissionTests(t, org1Ctx, true) + }) + + t.Run("Authorization tests for all identity types", func(t *testing.T) { + runAuthorizationTests(t, org1Ctx) + }) + t.Run("Dashboard HTTP API test", func(t *testing.T) { + runDashboardHttpTest(t, org1Ctx, org2Ctx) + }) + + t.Run("Cross-organization tests", func(t *testing.T) { + runCrossOrgTests(t, org1Ctx, org2Ctx) + }) +} + +// list tests will go very slowly if the cache is disabled - allow the cache solely for Lists +func TestIntegrationDashboardAPIZanzanaList(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + DisableDataMigrations: true, + AppModeProduction: true, + DisableAnonymous: true, + APIServerStorageType: "unified", + DBMaxConns: 4, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": { + DualWriterMode: rest.Mode5, + }, + "folders.folder.grafana.app": { + DualWriterMode: rest.Mode5, + }, + }, + EnableFeatureToggles: []string{ + "zanzana", + "zanzanaNoLegacyClient", + "kubernetesAuthzZanzanaSync", + }, + UnifiedStorageEnableSearch: true, + ZanzanaReconciliationInterval: 100 * time.Millisecond, + }) + + t.Cleanup(func() { + helper.Shutdown() + }) + + org1Ctx := createTestContext(t, helper, helper.Org1, rest.Mode5) + + runDashboardListTests(t, org1Ctx) +} + // TestIntegrationDashboardAPI tests the dashboard K8s API func TestIntegrationDashboardAPI(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) @@ -211,11 +300,11 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { t.Run("reject dashboard with existing UID", func(t *testing.T) { // Create a dashboard with a specific UID specificUID := "existing-uid-dash" - createdDash, err := createDashboard(t, adminClient, "Dashboard with Specific UID", nil, &specificUID) + createdDash, err := createDashboard(t, adminClient, "Dashboard with Specific UID", nil, &specificUID, ctx.Helper) require.NoError(t, err) // Try to create another dashboard with the same UID - _, err = createDashboard(t, adminClient, "Another Dashboard with Same UID", nil, &specificUID) + _, err = createDashboard(t, adminClient, "Another Dashboard with Same UID", nil, &specificUID, ctx.Helper) require.Error(t, err) // Clean up @@ -227,14 +316,14 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { t.Run("reject dashboard with too long UID", func(t *testing.T) { // Create a dashboard with a long UID (over 40 chars) longUID := "this-uid-is-way-too-long-for-a-dashboard-uid-12345678901234567890" - _, err := createDashboard(t, adminClient, "Dashboard with Long UID", nil, &longUID) + _, err := createDashboard(t, adminClient, "Dashboard with Long UID", nil, &longUID, ctx.Helper) require.Error(t, err) }) // Test creating dashboard with invalid UID characters t.Run("reject dashboard with invalid UID characters", func(t *testing.T) { invalidUID := "invalid/uid/with/slashes" - _, err := createDashboard(t, adminClient, "Dashboard with Invalid UID", nil, &invalidUID) + _, err := createDashboard(t, adminClient, "Dashboard with Invalid UID", nil, &invalidUID, ctx.Helper) require.Error(t, err) }) }) @@ -243,21 +332,21 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { t.Run("Dashboard title validations", func(t *testing.T) { // Test empty title t.Run("reject dashboard with empty title", func(t *testing.T) { - _, err := createDashboard(t, adminClient, "", nil, nil) + _, err := createDashboard(t, adminClient, "", nil, nil, ctx.Helper) require.Error(t, err) }) // Test long title t.Run("reject dashboard with excessively long title", func(t *testing.T) { veryLongTitle := strings.Repeat("a", 10000) - _, err := createDashboard(t, adminClient, veryLongTitle, nil, nil) + _, err := createDashboard(t, adminClient, veryLongTitle, nil, nil, ctx.Helper) require.Error(t, err) }) // Test updating dashboard with empty title t.Run("reject dashboard update with empty title", func(t *testing.T) { // First create a valid dashboard - dash, err := createDashboard(t, adminClient, "Valid Dashboard Title", nil, nil) + dash, err := createDashboard(t, adminClient, "Valid Dashboard Title", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -273,7 +362,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { // Test updating dashboard with excessively long title t.Run("reject dashboard update with excessively long title", func(t *testing.T) { // First create a valid dashboard - dash, err := createDashboard(t, adminClient, "Valid Dashboard Title", nil, nil) + dash, err := createDashboard(t, adminClient, "Valid Dashboard Title", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -291,7 +380,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { t.Run("Dashboard message validations", func(t *testing.T) { // Test long message t.Run("reject dashboard with excessively long update message", func(t *testing.T) { - dash, err := createDashboard(t, adminClient, "Regular dashboard", nil, nil) + dash, err := createDashboard(t, adminClient, "Regular dashboard", nil, nil, ctx.Helper) require.NoError(t, err) veryLongMessage := strings.Repeat("a", 600) @@ -304,18 +393,78 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { }) }) + t.Run("Dashboard tag validations", func(t *testing.T) { + t.Run("reject dashboard with tag over 50 characters on creation", func(t *testing.T) { + dashObj := createDashboardObject(t, "Dashboard with Long Tag", "", 0) + meta, _ := utils.MetaAccessor(dashObj) + spec, _ := meta.GetSpec() + specMap := spec.(map[string]interface{}) + specMap["tags"] = []string{"this-is-a-very-long-tag-that-exceeds-fifty-characters-limit"} + _ = meta.SetSpec(specMap) + _, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "tag too long") + }) + + t.Run("reject dashboard update with tag over 50 characters", func(t *testing.T) { + dash, err := createDashboard(t, adminClient, "Valid Dashboard", nil, nil, ctx.Helper) + require.NoError(t, err) + require.NotNil(t, dash) + meta, _ := utils.MetaAccessor(dash) + spec, _ := meta.GetSpec() + specMap := spec.(map[string]interface{}) + specMap["tags"] = []string{"this-is-a-very-long-tag-that-exceeds-fifty-characters-limit"} + _ = meta.SetSpec(specMap) + _, err = adminClient.Resource.Update(context.Background(), dash, v1.UpdateOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "tag too long") + err = adminClient.Resource.Delete(context.Background(), dash.GetName(), v1.DeleteOptions{}) + require.NoError(t, err) + }) + + t.Run("accept dashboard with tag at 50 characters", func(t *testing.T) { + dashObj := createDashboardObject(t, "Dashboard with Valid Tag", "", 0) + meta, _ := utils.MetaAccessor(dashObj) + spec, _ := meta.GetSpec() + specMap := spec.(map[string]interface{}) + specMap["tags"] = []string{"this-tag-is-exactly-fifty-characters-long-12345"} + _ = meta.SetSpec(specMap) + createdDash, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, createdDash) + err = adminClient.Resource.Delete(context.Background(), createdDash.GetName(), v1.DeleteOptions{}) + require.NoError(t, err) + }) + + t.Run("reject dashboard with multiple tags where one exceeds limit", func(t *testing.T) { + dashObj := createDashboardObject(t, "Dashboard with Mixed Tags", "", 0) + meta, _ := utils.MetaAccessor(dashObj) + spec, _ := meta.GetSpec() + specMap := spec.(map[string]interface{}) + specMap["tags"] = []string{ + "valid-tag", + "another-valid-tag", + "this-is-a-very-long-tag-that-exceeds-fifty-characters-limit", + } + _ = meta.SetSpec(specMap) + _, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "tag too long") + }) + }) + t.Run("Dashboard folder validations", func(t *testing.T) { // Test non-existent folder UID t.Run("reject dashboard with non-existent folder UID", func(t *testing.T) { nonExistentFolderUID := "non-existent-folder-uid" - _, err := createDashboard(t, adminClient, "Dashboard in Non-existent Folder", &nonExistentFolderUID, nil) + _, err := createDashboard(t, adminClient, "Dashboard in Non-existent Folder", &nonExistentFolderUID, nil, ctx.Helper) ctx.Helper.EnsureStatusError(err, http.StatusNotFound, "folders.folder.grafana.app \"non-existent-folder-uid\" not found") }) t.Run("allow moving folder to general folder", func(t *testing.T) { folder1 := createFolderObject(t, "folder1", "default", "") folder1UID := folder1.GetName() - dash, err := createDashboard(t, adminClient, "Dashboard in a Folder", &folder1UID, nil) + dash, err := createDashboard(t, adminClient, "Dashboard in a Folder", &folder1UID, nil, ctx.Helper) require.NoError(t, err) generalFolderUID := "" @@ -437,7 +586,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { // Test version increment on update t.Run("version increments on dashboard update", func(t *testing.T) { // Create a dashboard with admin - dash, err := createDashboard(t, adminClient, "Dashboard for Version Test", nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard for Version Test", nil, nil, ctx.Helper) require.NoError(t, err, "Failed to create dashboard for version test") dashUID := dash.GetName() @@ -464,7 +613,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { // Test generation conflict when updating concurrently t.Run("reject update with version conflict", func(t *testing.T) { // Create a dashboard with admin - dash, err := createDashboard(t, adminClient, "Dashboard for Version Conflict Test", nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard for Version Conflict Test", nil, nil, ctx.Helper) require.NoError(t, err, "Failed to create dashboard for version conflict test") dashUID := dash.GetName() @@ -517,7 +666,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { t.Run("dashboard version history available, even for UIDs ending in hyphen", func(t *testing.T) { dashboardUID := "test-dashboard-" - dash, err := createDashboard(t, adminClient, "Dashboard with uid ending in hyphen", nil, &dashboardUID) + dash, err := createDashboard(t, adminClient, "Dashboard with uid ending in hyphen", nil, &dashboardUID, ctx.Helper) require.NoError(t, err) updatedDash, err := updateDashboard(t, adminClient, dash, "Updated dashboard with uid ending in hyphen", nil) @@ -564,7 +713,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Create a dashboard with admin - dash, err := createDashboard(t, adminClient, "Dashboard for Provisioning Test", nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard for Provisioning Test", nil, nil, ctx.Helper) require.NoError(t, err, "Failed to create dashboard for provisioning test") dashUID := dash.GetName() @@ -689,7 +838,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { // Create a dashboard with a specific UID to make it easier to manage specificUID := "size-limit-test-dash" - dash, err := createDashboard(t, adminClient, "Dashboard Exceeding Size Limit", nil, &specificUID) + dash, err := createDashboard(t, adminClient, "Dashboard Exceeding Size Limit", nil, &specificUID, ctx.Helper) require.NoError(t, err) meta, _ := utils.MetaAccessor(dash) @@ -877,11 +1026,11 @@ func runQuotaTests(t *testing.T, ctx TestContext) { require.NoError(t, err, "Failed to update quota") // Create first dashboard - should succeed - dash1, err := createDashboard(t, adminClient, fmt.Sprintf("Quota Test Dashboard 1 (%s)", tc.name), nil, nil) + dash1, err := createDashboard(t, adminClient, fmt.Sprintf("Quota Test Dashboard 1 (%s)", tc.name), nil, nil, ctx.Helper) require.NoError(t, err, "Failed to create first dashboard") // Create second dashboard - should fail due to quota - _, err = createDashboard(t, adminClient, fmt.Sprintf("Quota Test Dashboard 2 (%s)", tc.name), nil, nil) + _, err = createDashboard(t, adminClient, fmt.Sprintf("Quota Test Dashboard 2 (%s)", tc.name), nil, nil, ctx.Helper) require.Error(t, err, "Creating second dashboard should fail due to quota") require.Contains(t, err.Error(), "quota", "Error should mention quota") @@ -911,6 +1060,8 @@ func runQuotaTests(t *testing.T, ctx TestContext) { // Helper function to create test context for an organization func createTestContext(t *testing.T, helper *apis.K8sTestHelper, orgUsers apis.OrgUsers, dualWriterMode rest.DualWriterMode) TestContext { + apis.AwaitZanzanaReconcileNext(t, helper) + // Create test folder folderTitle := "Test Folder Org " + strconv.FormatInt(orgUsers.Admin.Identity.GetOrgID(), 10) testFolder, err := createFolder(t, helper, orgUsers.Admin, folderTitle) @@ -1013,6 +1164,8 @@ func createFolder(t *testing.T, helper *apis.K8sTestHelper, user apis.User, titl return nil, err } + apis.AwaitZanzanaReconcileNext(t, helper) + meta, _ := utils.MetaAccessor(createdFolder) // Create a folder struct to return (for compatibility with existing code) @@ -1087,7 +1240,7 @@ func markDashboardObjectAsProvisioned(t *testing.T, dashboard *unstructured.Unst } // Create a dashboard -func createDashboard(t *testing.T, client *apis.K8sResourceClient, title string, folderUID *string, uid *string) (*unstructured.Unstructured, error) { +func createDashboard(t *testing.T, client *apis.K8sResourceClient, title string, folderUID *string, uid *string, helper *apis.K8sTestHelper) (*unstructured.Unstructured, error) { t.Helper() var folderUIDStr string @@ -1111,6 +1264,8 @@ func createDashboard(t *testing.T, client *apis.K8sResourceClient, title string, return nil, err } + apis.AwaitZanzanaReconcileNext(t, helper) + // Fetch the generated object to ensure we're not running into any caching or UID mismatch issues databaseDash, err := client.Resource.Get(context.Background(), createdDash.GetName(), v1.GetOptions{}) if err != nil { @@ -1254,11 +1409,13 @@ func runAuthorizationTests(t *testing.T, ctx TestContext) { {name: "in folder", folderUID: ctx.TestFolder.UID}, } + apis.AwaitZanzanaReconcileNext(t, ctx.Helper) + for _, loc := range locations { t.Run(loc.name, func(t *testing.T) { if roleCapabilities.canCreate { // Test can create dashboard - dash, err := createDashboard(t, identity.DashboardClient, identity.Name+" Dashboard "+loc.name, &loc.folderUID, nil) + dash, err := createDashboard(t, identity.DashboardClient, identity.Name+" Dashboard "+loc.name, &loc.folderUID, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -1274,7 +1431,7 @@ func runAuthorizationTests(t *testing.T, ctx TestContext) { require.NoError(t, err) } else { // Test cannot create dashboard - _, err := createDashboard(t, identity.DashboardClient, identity.Name+" Dashboard "+loc.name, nil, nil) + _, err := createDashboard(t, identity.DashboardClient, identity.Name+" Dashboard "+loc.name, nil, nil, ctx.Helper) require.Error(t, err) } }) @@ -1284,7 +1441,7 @@ func runAuthorizationTests(t *testing.T, ctx TestContext) { // Test dashboard updates t.Run("dashboard update", func(t *testing.T) { // Create a dashboard with admin - dash, err := createDashboard(t, adminClient, "Dashboard to Update by "+identity.Name, nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard to Update by "+identity.Name, nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -1311,7 +1468,7 @@ func runAuthorizationTests(t *testing.T, ctx TestContext) { // Test dashboard deletion permissions t.Run("dashboard deletion", func(t *testing.T) { // Create a dashboard with admin - dash, err := createDashboard(t, adminClient, "Dashboard for deletion test by "+identity.Name, nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard for deletion test by "+identity.Name, nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -1331,7 +1488,7 @@ func runAuthorizationTests(t *testing.T, ctx TestContext) { // Test dashboard viewing for all roles t.Run("dashboard viewing", func(t *testing.T) { // Create a dashboard with admin - dash, err := createDashboard(t, adminClient, "Dashboard for "+identity.Name+" to view", nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard for "+identity.Name+" to view", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -1363,7 +1520,7 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo // Test custom dashboard permissions t.Run("Dashboard with custom permissions", func(t *testing.T) { // Create a dashboard with admin - dash, err := createDashboard(t, adminClient, "Dashboard with Custom Permissions", nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard with Custom Permissions", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -1394,12 +1551,12 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo // Test dashboard-specific permission overrides (new test case) t.Run("Dashboard-specific permission overrides", func(t *testing.T) { // Create multiple dashboards with admin - dash1, err := createDashboard(t, adminClient, "Dashboard with No Custom Permissions", nil, nil) + dash1, err := createDashboard(t, adminClient, "Dashboard with No Custom Permissions", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash1) dash1UID := dash1.GetName() - dash2, err := createDashboard(t, adminClient, "Dashboard with Viewer Edit Permission", nil, nil) + dash2, err := createDashboard(t, adminClient, "Dashboard with Viewer Edit Permission", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash2) dash2UID := dash2.GetName() @@ -1443,7 +1600,7 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo setResourceUserPermission(t, ctx, ctx.AdminUser, false, folderUID, addUserPermission(t, nil, ctx.ViewerUser, ResourcePermissionLevelEdit)) // Create a dashboard in the folder with admin - dash, err := createDashboard(t, adminClient, "Dashboard in Custom Permission Folder", &folderUID, nil) + dash, err := createDashboard(t, adminClient, "Dashboard in Custom Permission Folder", &folderUID, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -1462,7 +1619,7 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo require.Equal(t, "Updated by Viewer with Folder Permission", meta.FindTitle("")) // User should be able to create a dashboard in the folder - dashViewer, err := createDashboard(t, viewerClient, "Dashboard created by Viewer in Custom Permission Folder", &folderUID, nil) + dashViewer, err := createDashboard(t, viewerClient, "Dashboard created by Viewer in Custom Permission Folder", &folderUID, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dashViewer) @@ -1509,7 +1666,7 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo setResourceUserPermission(t, ctx, ctx.AdminUser, false, folder2UID, addUserPermission(t, nil, ctx.ViewerUser, ResourcePermissionLevelEdit)) // Have the viewer create a dashboard in folder2 - viewerDash, err := createDashboard(t, viewerClient, "Dashboard created by Viewer in Edit Permission Folder", &folder2UID, nil) + viewerDash, err := createDashboard(t, viewerClient, "Dashboard created by Viewer in Edit Permission Folder", &folder2UID, nil, ctx.Helper) require.NoError(t, err, "Viewer should be able to create dashboard in folder with edit permissions") require.NotNil(t, viewerDash) dashUID := viewerDash.GetName() @@ -1544,7 +1701,7 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo // Test creator permissions (new test case) t.Run("Creator of dashboard gets admin permission", func(t *testing.T) { // Create a dashboard as an editor user (not admin) - editorCreatedDash, err := createDashboard(t, editorClient, "Dashboard Created by Editor", nil, nil) + editorCreatedDash, err := createDashboard(t, editorClient, "Dashboard Created by Editor", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, editorCreatedDash) dashUID := editorCreatedDash.GetName() @@ -1575,7 +1732,7 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo t.Run("Admin can override creator permissions", func(t *testing.T) { t.Skip("Have to double check if that's actually the case") // Create a dashboard as an editor user (not admin) - editorCreatedDash, err := createDashboard(t, editorClient, "Dashboard Created by Editor for Permission Test", nil, nil) + editorCreatedDash, err := createDashboard(t, editorClient, "Dashboard Created by Editor for Permission Test", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, editorCreatedDash) dashUID := editorCreatedDash.GetName() @@ -1614,7 +1771,7 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo otherOrgClient := getResourceClient(t, ctx.Helper, ctx.Helper.OrgB.Viewer, getDashboardGVR()) // Create a dashboard with admin in the current org - dash, err := createDashboard(t, adminClient, "Dashboard for Cross-Org Permissions Test", nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard for Cross-Org Permissions Test", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) org1DashUID := dash.GetName() @@ -1703,11 +1860,11 @@ func runCrossOrgTests(t *testing.T, org1Ctx, org2Ctx TestContext) { dashTitle := "Cross-Org Dashboard" // Create in org1 - dash1, err := createDashboard(t, org1SuperAdminClient, dashTitle, nil, &uid) + dash1, err := createDashboard(t, org1SuperAdminClient, dashTitle, nil, &uid, org1Ctx.Helper) require.NoError(t, err, "Failed to create dashboard in org1") // Create in org2 with same UID - should succeed (UIDs only need to be unique within an org) - dash2, err := createDashboard(t, org2SuperAdminClient, dashTitle, nil, &uid) + dash2, err := createDashboard(t, org2SuperAdminClient, dashTitle, nil, &uid, org2Ctx.Helper) require.NoError(t, err, "Failed to create dashboard with same UID in org2") // Verify both dashboards were created @@ -1793,12 +1950,12 @@ func runCrossOrgTests(t *testing.T, org1Ctx, org2Ctx TestContext) { // Test cross-organization access t.Run("Cross-organization access", func(t *testing.T) { // Create dashboards in both orgs - org1Dashboard, err := createDashboard(t, org1SuperAdminClient, "Org1 Dashboard", nil, nil) + org1Dashboard, err := createDashboard(t, org1SuperAdminClient, "Org1 Dashboard", nil, nil, org1Ctx.Helper) require.NoError(t, err) require.NotNil(t, org1Dashboard) org1DashUID := org1Dashboard.GetName() - org2Dashboard, err := createDashboard(t, org2SuperAdminClient, "Org2 Dashboard", nil, nil) + org2Dashboard, err := createDashboard(t, org2SuperAdminClient, "Org2 Dashboard", nil, nil, org2Ctx.Helper) require.NoError(t, err) require.NotNil(t, org2Dashboard) org2DashUID := org2Dashboard.GetName() @@ -1957,6 +2114,8 @@ func setResourceUserPermission(t *testing.T, ctx TestContext, actingUser apis.Us // Check response status code require.Equal(t, http.StatusOK, resp.Response.StatusCode, "Failed to set permissions for %s", resourceUID) + + apis.AwaitZanzanaReconcileNext(t, ctx.Helper) } // Test creating a dashboard via HTTP and deleting it @@ -2033,6 +2192,7 @@ func runDashboardHttpTest(t *testing.T, ctx TestContext, foreignOrgCtx TestConte for _, userTC := range userTestCases { testName := fmt.Sprintf("%s by %s", locTC.name, userTC.name) t.Run(testName, func(t *testing.T) { + apis.AwaitZanzanaReconcileNext(t, ctx.Helper) // Create a unique dashboard UID - ensure it's 40 chars max dashboardUID := fmt.Sprintf("test-%s-%s-%s", "POST", @@ -2078,6 +2238,8 @@ func runDashboardHttpTest(t *testing.T, ctx TestContext, foreignOrgCtx TestConte ContentType: "application/json", }, &struct{}{}) + apis.AwaitZanzanaReconcileNext(t, ctx.Helper) + // Check if the creation was successful or failed as expected adminClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, getDashboardGVR()) @@ -2421,7 +2583,7 @@ func runDashboardListTests(t *testing.T, ctx TestContext) { // Create all test resources (folders, dashboards) in one loop for i, fc := range folderConfigs { // Create root dashboard - rootDash, err := createDashboard(t, adminClient, fmt.Sprintf("Root Dashboard - %s", fc.name), nil, nil) + rootDash, err := createDashboard(t, adminClient, fmt.Sprintf("Root Dashboard - %s", fc.name), nil, nil, ctx.Helper) require.NoError(t, err) rootDashboards[i] = rootDash fc.permissions(t, ctx, rootDash.GetName(), true) @@ -2433,7 +2595,7 @@ func runDashboardListTests(t *testing.T, ctx TestContext) { fc.permissions(t, ctx, folder.UID, false) // Create dashboard in folder - folderDash, err := createDashboard(t, adminClient, fmt.Sprintf("Dashboard in %s folder", fc.name), &folder.UID, nil) + folderDash, err := createDashboard(t, adminClient, fmt.Sprintf("Dashboard in %s folder", fc.name), &folder.UID, nil, ctx.Helper) require.NoError(t, err) folderDashboards[i] = folderDash } @@ -2594,10 +2756,10 @@ func runDashboardTrashTests(t *testing.T, ctx TestContext) { t.Run("regular dashboards appear in trash but provisioned ones do not", func(t *testing.T) { // create two dashboards, one that is provisioned and one that is not - regularDash, err := createDashboard(t, adminClient, "Regular Dashboard for Trash Comparison", nil, nil) + regularDash, err := createDashboard(t, adminClient, "Regular Dashboard for Trash Comparison", nil, nil, ctx.Helper) require.NoError(t, err) regularDashUID := regularDash.GetName() - provisionedDash, err := createDashboard(t, adminClient, "Provisioned Dashboard for Trash Comparison", nil, nil) + provisionedDash, err := createDashboard(t, adminClient, "Provisioned Dashboard for Trash Comparison", nil, nil, ctx.Helper) require.NoError(t, err) provisionedDashUID := provisionedDash.GetName() meta, err := utils.MetaAccessor(provisionedDash) @@ -2626,7 +2788,7 @@ func runDashboardTrashTests(t *testing.T, ctx TestContext) { }) t.Run("permission checks - admin can see everything, users can see their own deleted items", func(t *testing.T) { - dash, err := createDashboard(t, editorClient, "Dashboard for Trash Test", nil, nil) + dash, err := createDashboard(t, editorClient, "Dashboard for Trash Test", nil, nil, ctx.Helper) require.NoError(t, err) dashUID := dash.GetName() err = editorClient.Resource.Delete(context.Background(), dashUID, v1.DeleteOptions{}) diff --git a/pkg/tests/apis/dashboard/search_test.go b/pkg/tests/apis/dashboard/search_test.go index 3fe35c51b3a..df03e6a9670 100644 --- a/pkg/tests/apis/dashboard/search_test.go +++ b/pkg/tests/apis/dashboard/search_test.go @@ -4,10 +4,15 @@ import ( "context" "encoding/json" "fmt" + "io/fs" + "math" "net/http" + "os" + "path/filepath" "strings" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" @@ -16,12 +21,187 @@ import ( dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/grafana/grafana/pkg/util/testutil" ) +func TestIntegrationSearchDevDashboards(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + ctx := context.Background() + + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + DisableDataMigrations: true, + AppModeProduction: true, + DisableAnonymous: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode5}, + "folders.folder.grafana.app": {DualWriterMode: rest.Mode5}, + }, + UnifiedStorageEnableSearch: true, + }) + defer helper.Shutdown() + + // Create devenv dashboards from legacy API + cfg := dynamic.ConfigFor(helper.Org1.Admin.NewRestConfig()) + cfg.GroupVersion = &dashboardV0.GroupVersion + adminClient, err := k8srest.RESTClientFor(cfg) + require.NoError(t, err) + adminClient.Get() + + fileCount := 0 + devenv := "../../../../devenv/dev-dashboards/panel-timeseries" + err = filepath.WalkDir(devenv, func(p string, d fs.DirEntry, e error) error { + require.NoError(t, err) + if d.IsDir() || filepath.Ext(d.Name()) != ".json" { + return nil + } + + // use the filename as UID + uid := strings.TrimSuffix(d.Name(), ".json") + if len(uid) > 40 { + uid = uid[:40] // avoid uid too long, max 40 characters + } + + // nolint:gosec + data, err := os.ReadFile(p) + require.NoError(t, err) + + cmd := dashboards.SaveDashboardCommand{ + Dashboard: &simplejson.Json{}, + Overwrite: true, + } + err = cmd.Dashboard.FromDB(data) + require.NoError(t, err) + cmd.Dashboard.Set("id", nil) + cmd.Dashboard.Set("uid", uid) + data, err = json.Marshal(cmd) + require.NoError(t, err) + + var statusCode int + result := adminClient.Post().AbsPath("api", "dashboards", "db"). + Body(data). + SetHeader("Content-type", "application/json"). + Do(ctx). + StatusCode(&statusCode) + require.NoError(t, result.Error(), "file: [%d] %s [status:%d]", fileCount, d.Name(), statusCode) + require.Equal(t, int(http.StatusOK), statusCode) + fileCount++ + return nil + }) + require.NoError(t, err) + require.Equal(t, 16, fileCount, "file count from %s", devenv) + + // Helper to call search + callSearch := func(user apis.User, params map[string]string) dashboardV0.SearchResults { + require.NotNil(t, user) + ns := user.Identity.GetNamespace() + cfg := dynamic.ConfigFor(user.NewRestConfig()) + cfg.GroupVersion = &dashboardV0.GroupVersion + restClient, err := k8srest.RESTClientFor(cfg) + require.NoError(t, err) + + var statusCode int + req := restClient.Get().AbsPath("apis", "dashboard.grafana.app", "v0alpha1", "namespaces", ns, "search"). + //Param("explain", "true") // helpful to understand which field made things match + Param("limit", "1000"). + Param("type", "dashboard") // Only search dashboards + + for k, v := range params { + req = req.Param(k, v) + } + res := req.Do(ctx).StatusCode(&statusCode) + require.NoError(t, res.Error()) + require.Equal(t, int(http.StatusOK), statusCode) + var sr dashboardV0.SearchResults + raw, err := res.Raw() + require.NoError(t, err) + require.NoError(t, json.Unmarshal(raw, &sr)) + + // Normalize scores and query cost for snapshot comparison + sr.QueryCost = 0 // this depends on the hardware + sr.MaxScore = roundTo(sr.MaxScore, 3) + for i := range sr.Hits { + sr.Hits[i].Score = roundTo(sr.Hits[i].Score, 3) // 0.6250571494814442 -> 0.625 + } + return sr + } + + // Compare a results to snapshots + testCases := []struct { + name string + user apis.User + params map[string]string + }{ + { + name: "all", + user: helper.Org1.Admin, + }, + { + name: "query-single-word", + user: helper.Org1.Admin, + params: map[string]string{ + "query": "stacking", + }, + }, + { + name: "query-multiple-words", + user: helper.Org1.Admin, + params: map[string]string{ + "query": "graph softMin", // must match ALL terms + }, + }, + { + name: "with-text-panel", + user: helper.Org1.Admin, + params: map[string]string{ + "field": "panel_types", // return panel types + "panelType": "text", + }, + }, + { + name: "title-ngram-prefix", + user: helper.Org1.Admin, + params: map[string]string{ + "query": "zer", // should match "Zero Decimals Y Ticks" + }, + }, + { + name: "title-ngram-middle-word", + user: helper.Org1.Admin, + params: map[string]string{ + "query": "decim", // should match "Zero Decimals Y Ticks" + }, + }, + } + for i, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + res := callSearch(tc.user, tc.params) + jj, err := json.MarshalIndent(res, "", " ") + require.NoError(t, err) + + fname := fmt.Sprintf("testdata/searchV0/t%02d-%s.json", i, tc.name) + // nolint:gosec + snapshot, err := os.ReadFile(fname) + if err != nil { + assert.Failf(t, "Failed to read snapshot", "file: %s", fname) + err = os.WriteFile(fname, jj, 0o644) + require.NoErrorf(t, err, "Failed to write snapshot file %s", fname) + return + } + + if !assert.JSONEq(t, string(snapshot), string(jj)) { + err = os.WriteFile(fname, jj, 0o644) + require.NoErrorf(t, err, "Failed to write snapshot file %s", fname) + } + }) + } +} + func TestIntegrationSearchPermissionFiltering(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) @@ -285,3 +465,11 @@ func setFolderPermissions(t *testing.T, helper *apis.K8sTestHelper, actingUser a require.Equal(t, http.StatusOK, resp.Response.StatusCode, "Failed to set permissions for folder %s", folderUID) } + +// roundTo rounds a float64 to a specified number of decimal places. +func roundTo(n float64, decimals uint32) float64 { + // Calculate the power of 10 for the desired number of decimals + scale := math.Pow(10, float64(decimals)) + // Multiply, round to the nearest integer, and then divide back + return math.Round(n*scale) / scale +} diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t00-all.json b/pkg/tests/apis/dashboard/testdata/searchV0/t00-all.json new file mode 100644 index 00000000000..35b7cff0302 --- /dev/null +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t00-all.json @@ -0,0 +1,165 @@ +{ + "totalHits": 16, + "hits": [ + { + "resource": "dashboards", + "name": "timeseries", + "title": "Panel Tests - Graph NG", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-by-value-color-schemes", + "title": "Panel Tests - Graph NG - By value color schemes", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-nulls", + "title": "Panel Tests - Graph NG - Discrete panels", + "tags": [ + "gdev", + "panel-tests", + "graph-ng", + "timeseries", + "trend", + "state-timeline", + "transform" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-gradient-area", + "title": "Panel Tests - Graph NG - Gradient Area Fills", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-soft-limits", + "title": "Panel Tests - Graph NG - softMin/softMax", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-yaxis-ticks", + "title": "Panel Tests - Graph NG - Y axis ticks", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-hue-gradients", + "title": "Panel Tests - GraphNG - Hue Gradients", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-time", + "title": "Panel Tests - GraphNG - Time Axis", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-thresholds", + "title": "Panel Tests - GraphNG Thresholds", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-shared-tooltip-cursor-positio", + "title": "Panel Tests - shared tooltips cursor positioning", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-bars-high-density", + "title": "Panel Tests - TimeSeries - bars high density (stroke + fill)", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-out-of-rage", + "title": "Panel Tests - Timeseries - Out of range", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-stacking", + "title": "Panel Tests - TimeSeries - stacking", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-formats", + "title": "Panel Tests - Timeseries - Supported input formats" + }, + { + "resource": "dashboards", + "name": "timeseries-stacking2", + "title": "TimeSeries \u0026 BarChart Stacking", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-y-ticks-zero-decimals", + "title": "Zero Decimals Y Ticks", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + } + ], + "maxScore": 1 +} \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t01-query-single-word.json b/pkg/tests/apis/dashboard/testdata/searchV0/t01-query-single-word.json new file mode 100644 index 00000000000..02eed11383a --- /dev/null +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t01-query-single-word.json @@ -0,0 +1,28 @@ +{ + "totalHits": 2, + "hits": [ + { + "resource": "dashboards", + "name": "timeseries-stacking", + "title": "Panel Tests - TimeSeries - stacking", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ], + "score": 0.284 + }, + { + "resource": "dashboards", + "name": "timeseries-stacking2", + "title": "TimeSeries \u0026 BarChart Stacking", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ], + "score": 0.269 + } + ], + "maxScore": 0.284 +} \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t02-query-multiple-words.json b/pkg/tests/apis/dashboard/testdata/searchV0/t02-query-multiple-words.json new file mode 100644 index 00000000000..270801994c0 --- /dev/null +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t02-query-multiple-words.json @@ -0,0 +1,17 @@ +{ + "totalHits": 1, + "hits": [ + { + "resource": "dashboards", + "name": "timeseries-soft-limits", + "title": "Panel Tests - Graph NG - softMin/softMax", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ], + "score": 0.024 + } + ], + "maxScore": 0.024 +} \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t03-with-text-panel.json b/pkg/tests/apis/dashboard/testdata/searchV0/t03-with-text-panel.json new file mode 100644 index 00000000000..b38cfb14b40 --- /dev/null +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t03-with-text-panel.json @@ -0,0 +1,18 @@ +{ + "totalHits": 1, + "hits": [ + { + "resource": "dashboards", + "name": "timeseries-formats", + "title": "Panel Tests - Timeseries - Supported input formats", + "field": { + "panel_types": [ + "table", + "text", + "timeseries" + ] + } + } + ], + "maxScore": 1.778 +} \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t04-title-ngram-prefix.json b/pkg/tests/apis/dashboard/testdata/searchV0/t04-title-ngram-prefix.json new file mode 100644 index 00000000000..8059db130a0 --- /dev/null +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t04-title-ngram-prefix.json @@ -0,0 +1,17 @@ +{ + "totalHits": 1, + "hits": [ + { + "resource": "dashboards", + "name": "timeseries-y-ticks-zero-decimals", + "title": "Zero Decimals Y Ticks", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ], + "score": 0.35 + } + ], + "maxScore": 0.35 +} \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t05-title-ngram-middle-word.json b/pkg/tests/apis/dashboard/testdata/searchV0/t05-title-ngram-middle-word.json new file mode 100644 index 00000000000..8059db130a0 --- /dev/null +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t05-title-ngram-middle-word.json @@ -0,0 +1,17 @@ +{ + "totalHits": 1, + "hits": [ + { + "resource": "dashboards", + "name": "timeseries-y-ticks-zero-decimals", + "title": "Zero Decimals Y Ticks", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ], + "score": 0.35 + } + ], + "maxScore": 0.35 +} \ No newline at end of file diff --git a/pkg/tests/apis/datasource/testdata_test.go b/pkg/tests/apis/datasource/testdata_test.go index 9a94bea5dce..85450ec6536 100644 --- a/pkg/tests/apis/datasource/testdata_test.go +++ b/pkg/tests/apis/datasource/testdata_test.go @@ -62,7 +62,7 @@ func TestIntegrationTestDatasource(t *testing.T) { t.Run("Admin configs", func(t *testing.T) { client := helper.Org1.Admin.ResourceClient(t, schema.GroupVersionResource{ - Group: "testdata.datasource.grafana.app", + Group: "grafana-testdata-datasource.datasource.grafana.app", Version: "v0alpha1", Resource: "datasources", }).Namespace("default") @@ -92,7 +92,7 @@ func TestIntegrationTestDatasource(t *testing.T) { t.Run("Call subresources", func(t *testing.T) { client := helper.Org1.Admin.ResourceClient(t, schema.GroupVersionResource{ - Group: "testdata.datasource.grafana.app", + Group: "grafana-testdata-datasource.datasource.grafana.app", Version: "v0alpha1", Resource: "datasources", }).Namespace("default") @@ -128,7 +128,7 @@ func TestIntegrationTestDatasource(t *testing.T) { raw := apis.DoRequest[any](helper, apis.RequestParams{ User: helper.Org1.Admin, Method: "GET", - Path: "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/default/datasources/test/resource", + Path: "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/default/datasources/test/resource", }, nil) // endpoint is disabled currently because it has not been // sufficiently tested. diff --git a/pkg/tests/apis/features/features_test.go b/pkg/tests/apis/features/features_test.go index ab7bfea64d4..0a8c9564492 100644 --- a/pkg/tests/apis/features/features_test.go +++ b/pkg/tests/apis/features/features_test.go @@ -44,6 +44,6 @@ func TestIntegrationFeatures(t *testing.T) { "value": true, "key":"`+flag+`", "reason":"static provider evaluation result", - "variant":"enabled"}`, string(rsp.Body)) + "variant":"default"}`, string(rsp.Body)) }) } diff --git a/pkg/tests/apis/folder/folder_tree_test.go b/pkg/tests/apis/folder/folder_tree_test.go index 613d021b236..227dec47d73 100644 --- a/pkg/tests/apis/folder/folder_tree_test.go +++ b/pkg/tests/apis/folder/folder_tree_test.go @@ -36,10 +36,12 @@ func TestIntegrationFolderTreeZanzana(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) runIntegrationFolderTree(t, testinfra.GrafanaOpts{ - DisableDataMigrations: true, - AppModeProduction: true, - DisableAnonymous: true, - APIServerStorageType: "unified", + DisableDataMigrations: true, + AppModeProduction: true, + DisableAnonymous: true, + DisableAuthZClientCache: true, + DisableZanzanaServerCheckQueryCache: true, + APIServerStorageType: "unified", UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ "dashboards.dashboard.grafana.app": { DualWriterMode: grafanarest.Mode5, diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go index ce7dd5f6795..cdcc798c93a 100644 --- a/pkg/tests/apis/folder/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -2054,9 +2054,7 @@ func TestIntegrationDeleteFolderWithProvisionedDashboards(t *testing.T) { DualWriterMode: modeDw, }, }, - EnableFeatureToggles: []string{ - featuremgmt.FlagUnifiedStorageSearch, - }, + UnifiedStorageEnableSearch: true, } setupProvisioningDir(t, &ops) @@ -2163,9 +2161,7 @@ func TestIntegrationProvisionedFolderPropagatesLabelsAndAnnotations(t *testing.T DualWriterMode: mode3, }, }, - EnableFeatureToggles: []string{ - featuremgmt.FlagUnifiedStorageSearch, - }, + UnifiedStorageEnableSearch: true, } setupProvisioningDir(t, &ops) diff --git a/pkg/tests/apis/helper.go b/pkg/tests/apis/helper.go index 379e3ac9734..0f2cb95cc50 100644 --- a/pkg/tests/apis/helper.go +++ b/pkg/tests/apis/helper.go @@ -14,6 +14,7 @@ import ( "testing" "time" + appsdk_k8s "github.com/grafana/grafana-app-sdk/k8s" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/api/errors" @@ -27,6 +28,8 @@ import ( "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" + githubConnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/configprovider" @@ -56,6 +59,8 @@ import ( const ( Org1 = "Org1" Org2 = "OrgB" + + DefaultNamespace = "default" ) var ( @@ -207,6 +212,10 @@ func (c *K8sTestHelper) GetEnv() server.TestEnv { return c.env } +func (c *K8sTestHelper) SetGithubConnectionFactory(f githubConnection.GithubFactory) { + c.env.GithubConnectionFactory = f +} + func (c *K8sTestHelper) GetListenerAddress() string { return c.listenerAddress } @@ -440,6 +449,11 @@ func (c *User) RESTClient(t *testing.T, gv *schema.GroupVersion) *rest.RESTClien return client } +func (c *User) GetClientRegistry() *appsdk_k8s.ClientRegistry { + restConfig := c.NewRestConfig() + return appsdk_k8s.NewClientRegistry(*restConfig, appsdk_k8s.DefaultClientConfig()) +} + type RequestParams struct { User User Method string // GET, POST, PATCH, etc diff --git a/pkg/tests/apis/iam/team_bindings_integration_test.go b/pkg/tests/apis/iam/team_bindings_integration_test.go index 1b355296486..40258edaf45 100644 --- a/pkg/tests/apis/iam/team_bindings_integration_test.go +++ b/pkg/tests/apis/iam/team_bindings_integration_test.go @@ -67,7 +67,7 @@ func TestIntegrationTeamBindings(t *testing.T) { doTeamBindingCRUDTestsUsingTheNewAPIs(t, helper, team, user) if mode < 3 { - doTeamBindingCRUDTestsUsingTheLegacyAPIs(t, helper, mode) + doTeamBindingCRUDTestsUsingTheLegacyAPIs(t, helper) } }) } @@ -84,13 +84,15 @@ func doTeamBindingCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHel }) // Create the team binding - toCreate := helper.LoadYAMLOrJSONFile("testdata/teambinding-test-create-v0.yaml") - toCreate.Object["spec"].(map[string]interface{})["subject"].(map[string]interface{})["name"] = user.GetName() - toCreate.Object["spec"].(map[string]interface{})["teamRef"].(map[string]interface{})["name"] = team.GetName() + toCreate := createTeamBindingObject(helper, user.GetName(), team.GetName()) created, err := teamBindingClient.Resource.Create(ctx, toCreate, metav1.CreateOptions{}) require.NoError(t, err) require.NotNil(t, created) + defer func() { + _ = teamBindingClient.Resource.Delete(ctx, created.GetName(), metav1.DeleteOptions{}) + }() + createdSpec := created.Object["spec"].(map[string]interface{}) require.Equal(t, user.GetName(), createdSpec["subject"].(map[string]interface{})["name"]) require.Equal(t, team.GetName(), createdSpec["teamRef"].(map[string]interface{})["name"]) @@ -115,6 +117,7 @@ func doTeamBindingCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHel // Update the team binding toUpdate := toCreate.DeepCopy() toUpdate.Object["spec"].(map[string]interface{})["permission"] = "member" + toUpdate.Object["metadata"].(map[string]interface{})["name"] = createdUID updated, err := teamBindingClient.Resource.Update(ctx, toUpdate, metav1.UpdateOptions{}) require.NoError(t, err) require.NotNil(t, updated) @@ -164,9 +167,7 @@ func doTeamBindingCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHel GVR: gvrTeamBindings, }) - toCreate := helper.LoadYAMLOrJSONFile("testdata/teambinding-test-create-v0.yaml") - toCreate.Object["spec"].(map[string]interface{})["subject"].(map[string]interface{})["name"] = user.GetName() - toCreate.Object["spec"].(map[string]interface{})["teamRef"].(map[string]interface{})["name"] = team.GetName() + toCreate := createTeamBindingObject(helper, user.GetName(), team.GetName()) _, err := teamBindingClient.Resource.Create(ctx, toCreate, metav1.CreateOptions{}) require.Error(t, err) @@ -185,9 +186,7 @@ func doTeamBindingCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHel GVR: gvrTeamBindings, }) - toCreate := helper.LoadYAMLOrJSONFile("testdata/teambinding-test-create-v0.yaml") - toCreate.Object["spec"].(map[string]interface{})["subject"].(map[string]interface{})["name"] = "" - toCreate.Object["spec"].(map[string]interface{})["teamRef"].(map[string]interface{})["name"] = team.GetName() + toCreate := createTeamBindingObject(helper, "", team.GetName()) _, err := teamBindingClient.Resource.Create(ctx, toCreate, metav1.CreateOptions{}) require.Error(t, err) @@ -205,9 +204,7 @@ func doTeamBindingCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHel GVR: gvrTeamBindings, }) - toCreate := helper.LoadYAMLOrJSONFile("testdata/teambinding-test-create-v0.yaml") - toCreate.Object["spec"].(map[string]interface{})["subject"].(map[string]interface{})["name"] = user.GetName() - toCreate.Object["spec"].(map[string]interface{})["teamRef"].(map[string]interface{})["name"] = "" + toCreate := createTeamBindingObject(helper, user.GetName(), "") _, err := teamBindingClient.Resource.Create(ctx, toCreate, metav1.CreateOptions{}) require.Error(t, err) @@ -225,9 +222,7 @@ func doTeamBindingCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHel GVR: gvrTeamBindings, }) - toCreate := helper.LoadYAMLOrJSONFile("testdata/teambinding-test-create-v0.yaml") - toCreate.Object["spec"].(map[string]interface{})["subject"].(map[string]interface{})["name"] = user.GetName() - toCreate.Object["spec"].(map[string]interface{})["teamRef"].(map[string]interface{})["name"] = team.GetName() + toCreate := createTeamBindingObject(helper, user.GetName(), team.GetName()) toCreate.Object["spec"].(map[string]interface{})["permission"] = "invalid" _, err := teamBindingClient.Resource.Create(ctx, toCreate, metav1.CreateOptions{}) @@ -245,17 +240,31 @@ func doTeamBindingCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHel } { t.Run(fmt.Sprintf("with basic role_%s", u.Identity.GetOrgRole()), func(t *testing.T) { ctx := context.Background() + + // Create the team binding using admin + adminClient := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()), + GVR: gvrTeamBindings, + }) + + toCreate := createTeamBindingObject(helper, user.GetName(), team.GetName()) + created, err := adminClient.Resource.Create(ctx, toCreate, metav1.CreateOptions{}) + require.NoError(t, err) + + defer func() { + _ = adminClient.Resource.Delete(ctx, created.GetName(), metav1.DeleteOptions{}) + }() + teamBindingClient := helper.GetResourceClient(apis.ResourceClientArgs{ User: u, Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()), GVR: gvrTeamBindings, }) - toUpdate := helper.LoadYAMLOrJSONFile("testdata/teambinding-test-create-v0.yaml") - toUpdate.Object["spec"].(map[string]interface{})["subject"].(map[string]interface{})["name"] = user.GetName() - toUpdate.Object["spec"].(map[string]interface{})["teamRef"].(map[string]interface{})["name"] = team.GetName() + toUpdate := created.DeepCopy() toUpdate.Object["spec"].(map[string]interface{})["permission"] = "member" - _, err := teamBindingClient.Resource.Update(ctx, toUpdate, metav1.UpdateOptions{}) + _, err = teamBindingClient.Resource.Update(ctx, toUpdate, metav1.UpdateOptions{}) require.Error(t, err) var statusErr *errors.StatusError @@ -273,10 +282,8 @@ func doTeamBindingCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHel GVR: gvrTeamBindings, }) - toUpdate := helper.LoadYAMLOrJSONFile("testdata/teambinding-test-create-v0.yaml") + toUpdate := createTeamBindingObject(helper, user.GetName(), team.GetName()) toUpdate.Object["metadata"].(map[string]interface{})["name"] = "invalid-team-binding-name" - toUpdate.Object["spec"].(map[string]interface{})["subject"].(map[string]interface{})["name"] = user.GetName() - toUpdate.Object["spec"].(map[string]interface{})["teamRef"].(map[string]interface{})["name"] = team.GetName() _, err := teamBindingClient.Resource.Update(ctx, toUpdate, metav1.UpdateOptions{}) require.Error(t, err) var statusErr *errors.StatusError @@ -293,15 +300,18 @@ func doTeamBindingCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHel GVR: gvrTeamBindings, }) - // Create the team binding if it doesn't already exist - toCreate := helper.LoadYAMLOrJSONFile("testdata/teambinding-test-create-v0.yaml") - toCreate.Object["spec"].(map[string]interface{})["subject"].(map[string]interface{})["name"] = user.GetName() - toCreate.Object["spec"].(map[string]interface{})["teamRef"].(map[string]interface{})["name"] = team.GetName() - _, _ = teamBindingClient.Resource.Create(ctx, toCreate, metav1.CreateOptions{}) + toCreate := createTeamBindingObject(helper, user.GetName(), team.GetName()) + created, err := teamBindingClient.Resource.Create(ctx, toCreate, metav1.CreateOptions{}) + require.NoError(t, err) + + defer func() { + _ = teamBindingClient.Resource.Delete(ctx, created.GetName(), metav1.DeleteOptions{}) + }() toUpdate := toCreate.DeepCopy() toUpdate.Object["spec"].(map[string]interface{})["teamRef"].(map[string]interface{})["name"] = "test-team-2" - _, err := teamBindingClient.Resource.Update(ctx, toUpdate, metav1.UpdateOptions{}) + toUpdate.Object["metadata"].(map[string]interface{})["name"] = created.GetName() + _, err = teamBindingClient.Resource.Update(ctx, toUpdate, metav1.UpdateOptions{}) require.Error(t, err) var statusErr *errors.StatusError require.ErrorAs(t, err, &statusErr) @@ -317,16 +327,19 @@ func doTeamBindingCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHel GVR: gvrTeamBindings, }) - // Create the team binding if it doesn't already exist - toCreate := helper.LoadYAMLOrJSONFile("testdata/teambinding-test-create-v0.yaml") - toCreate.Object["spec"].(map[string]interface{})["subject"].(map[string]interface{})["name"] = user.GetName() - toCreate.Object["spec"].(map[string]interface{})["teamRef"].(map[string]interface{})["name"] = team.GetName() - _, _ = teamBindingClient.Resource.Create(ctx, toCreate, metav1.CreateOptions{}) + toCreate := createTeamBindingObject(helper, user.GetName(), team.GetName()) + created, err := teamBindingClient.Resource.Create(ctx, toCreate, metav1.CreateOptions{}) + require.NoError(t, err) + + defer func() { + _ = teamBindingClient.Resource.Delete(ctx, created.GetName(), metav1.DeleteOptions{}) + }() toUpdate := toCreate.DeepCopy() + toUpdate.Object["metadata"].(map[string]interface{})["name"] = created.GetName() toUpdate.Object["spec"].(map[string]interface{})["subject"].(map[string]interface{})["name"] = "test-user-2" - _, err := teamBindingClient.Resource.Update(ctx, toUpdate, metav1.UpdateOptions{}) + _, err = teamBindingClient.Resource.Update(ctx, toUpdate, metav1.UpdateOptions{}) require.Error(t, err) var statusErr *errors.StatusError require.ErrorAs(t, err, &statusErr) @@ -342,15 +355,18 @@ func doTeamBindingCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHel GVR: gvrTeamBindings, }) - // Create the team binding if it doesn't already exist - toCreate := helper.LoadYAMLOrJSONFile("testdata/teambinding-test-create-v0.yaml") - toCreate.Object["spec"].(map[string]interface{})["subject"].(map[string]interface{})["name"] = user.GetName() - toCreate.Object["spec"].(map[string]interface{})["teamRef"].(map[string]interface{})["name"] = team.GetName() - _, _ = teamBindingClient.Resource.Create(ctx, toCreate, metav1.CreateOptions{}) + toCreate := createTeamBindingObject(helper, user.GetName(), team.GetName()) + created, err := teamBindingClient.Resource.Create(ctx, toCreate, metav1.CreateOptions{}) + require.NoError(t, err) + + defer func() { + _ = teamBindingClient.Resource.Delete(ctx, created.GetName(), metav1.DeleteOptions{}) + }() toUpdate := toCreate.DeepCopy() toUpdate.Object["spec"].(map[string]interface{})["external"] = true - _, err := teamBindingClient.Resource.Update(ctx, toUpdate, metav1.UpdateOptions{}) + toUpdate.Object["metadata"].(map[string]interface{})["name"] = created.GetName() + _, err = teamBindingClient.Resource.Update(ctx, toUpdate, metav1.UpdateOptions{}) require.Error(t, err) var statusErr *errors.StatusError require.ErrorAs(t, err, &statusErr) @@ -366,17 +382,18 @@ func doTeamBindingCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHel GVR: gvrTeamBindings, }) - // Create the team binding if it doesn't already exist - toCreate := helper.LoadYAMLOrJSONFile("testdata/teambinding-test-create-v0.yaml") - toCreate.Object["spec"].(map[string]interface{})["subject"].(map[string]interface{})["name"] = user.GetName() - toCreate.Object["spec"].(map[string]interface{})["teamRef"].(map[string]interface{})["name"] = team.GetName() - _, _ = teamBindingClient.Resource.Create(ctx, toCreate, metav1.CreateOptions{}) + toCreate := createTeamBindingObject(helper, user.GetName(), team.GetName()) + created, err := teamBindingClient.Resource.Create(ctx, toCreate, metav1.CreateOptions{}) + require.NoError(t, err) - toUpdate := helper.LoadYAMLOrJSONFile("testdata/teambinding-test-create-v0.yaml") - toUpdate.Object["spec"].(map[string]interface{})["subject"].(map[string]interface{})["name"] = user.GetName() - toUpdate.Object["spec"].(map[string]interface{})["teamRef"].(map[string]interface{})["name"] = team.GetName() + defer func() { + _ = teamBindingClient.Resource.Delete(ctx, created.GetName(), metav1.DeleteOptions{}) + }() + + toUpdate := createTeamBindingObject(helper, user.GetName(), team.GetName()) toUpdate.Object["spec"].(map[string]interface{})["permission"] = "invalid" - _, err := teamBindingClient.Resource.Update(ctx, toUpdate, metav1.UpdateOptions{}) + toUpdate.Object["metadata"].(map[string]interface{})["name"] = created.GetName() + _, err = teamBindingClient.Resource.Update(ctx, toUpdate, metav1.UpdateOptions{}) require.Error(t, err) var statusErr *errors.StatusError require.ErrorAs(t, err, &statusErr) @@ -385,7 +402,7 @@ func doTeamBindingCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHel }) } -func doTeamBindingCRUDTestsUsingTheLegacyAPIs(t *testing.T, helper *apis.K8sTestHelper, mode rest.DualWriterMode) { +func doTeamBindingCRUDTestsUsingTheLegacyAPIs(t *testing.T, helper *apis.K8sTestHelper) { t.Run("should create team binding using legacy APIs and get it using the new APIs", func(t *testing.T) { ctx := context.Background() @@ -499,3 +516,10 @@ func doTeamBindingCRUDTestsUsingTheLegacyAPIs(t *testing.T, helper *apis.K8sTest require.Equal(t, teamBindingName, teamBinding.GetName()) }) } + +func createTeamBindingObject(helper *apis.K8sTestHelper, userName, teamName string) *unstructured.Unstructured { + obj := helper.LoadYAMLOrJSONFile("testdata/teambinding-test-create-v0.yaml") + obj.Object["spec"].(map[string]interface{})["subject"].(map[string]interface{})["name"] = userName + obj.Object["spec"].(map[string]interface{})["teamRef"].(map[string]interface{})["name"] = teamName + return obj +} diff --git a/pkg/tests/apis/iam/testdata/teambinding-test-create-v0.yaml b/pkg/tests/apis/iam/testdata/teambinding-test-create-v0.yaml index da04e7785b1..2ac36c1b6a6 100644 --- a/pkg/tests/apis/iam/testdata/teambinding-test-create-v0.yaml +++ b/pkg/tests/apis/iam/testdata/teambinding-test-create-v0.yaml @@ -1,7 +1,7 @@ apiVersion: iam.grafana.app/v0alpha1 kind: TeamBinding metadata: - name: test-team-binding-1 + generateName: test-team-binding- spec: subject: name: "" diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json index 4634143bd45..3ed64961b1f 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json @@ -1830,6 +1830,22 @@ "type": "string" } }, + { + "name": "panelType", + "in": "query", + "description": "find dashboards using panels of a given plugin type", + "schema": { + "type": "string" + } + }, + { + "name": "dataSourceType", + "in": "query", + "description": "find dashboards using datasources of a given plugin type", + "schema": { + "type": "string" + } + }, { "name": "permission", "in": "query", diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json index b89d431883b..244a8e591b1 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json @@ -1788,11 +1788,11 @@ "default": false }, "valuesFormat": { + "type": "string", "enum": [ "csv", "json" - ], - "type": "string" + ] } }, "additionalProperties": false @@ -2242,6 +2242,10 @@ "description": "This can be used by data sources that return and explicit naming structure for values and labels\nWhen this property is configured, this value is used rather than the default naming strategy.", "type": "string" }, + "fieldMinMax": { + "description": "Calculate min max per field", + "type": "boolean" + }, "filterable": { "description": "True if data source field supports ad-hoc filters", "type": "boolean" @@ -2273,6 +2277,9 @@ "description": "Alternative to empty string", "type": "string" }, + "nullValueMode": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardNullValueMode" + }, "path": { "description": "An explicit path to the field in the datasource. When the frame meta includes a path,\nThis will default to `${frame.meta.path}/${field.name}\n\nWhen defined, this value can be used as an identifier within the datasource scope, and\nmay be used to update the results", "type": "string" @@ -2281,7 +2288,7 @@ "$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardThresholdsConfig" }, "unit": { - "description": "Unit a field should use. The unit you select is applied to all fields except time.\nYou can use the units ID availables in Grafana or a custom unit.\nAvailable units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts\nAs custom unit, you can use the following formats:\n`suffix:` for custom unit that should go after value.\n`prefix:` for custom unit that should go before value.\n`time:` For custom date time formats type for example `time:YYYY-MM-DD`.\n`si:` for custom SI units. For example: `si: mF`. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character.\n`count:` for a custom count unit.\n`currency:` for custom a currency unit.", + "description": "Unit a field should use. The unit you select is applied to all fields except time.\nYou can use the units ID availables in Grafana or a custom unit.\nAvailable units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts\nAs custom unit, you can use the following formats:\n`suffix:\u003csuffix\u003e` for custom unit that should go after value.\n`prefix:\u003cprefix\u003e` for custom unit that should go before value.\n`time:\u003cformat\u003e` For custom date time formats type for example `time:YYYY-MM-DD`.\n`si:\u003cbase scale\u003e\u003cunit characters\u003e` for custom SI units. For example: `si: mF`. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character.\n`count:\u003cunit\u003e` for a custom count unit.\n`currency:\u003cunit\u003e` for custom a currency unit.", "type": "string" }, "writeable": { @@ -2774,6 +2781,15 @@ }, "additionalProperties": false }, + "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardNullValueMode": { + "description": "How null values should be handled", + "type": "string", + "enum": [ + "null", + "connected", + "null as zero" + ] + }, "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardPanelKind": { "type": "object", "required": [ @@ -3797,7 +3813,7 @@ ], "properties": { "options": { - "description": "Map with : ValueMappingResult. For example: { \"10\": { text: \"Perfection!\", color: \"green\" } }", + "description": "Map with \u003cvalue_to_match\u003e: ValueMappingResult. For example: { \"10\": { text: \"Perfection!\", color: \"green\" } }", "type": "object", "additionalProperties": { "$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardValueMappingResult" @@ -4221,7 +4237,7 @@ } }, "io.k8s.apimachinery.pkg.apis.meta.v1.FieldsV1": { - "description": "FieldsV1 stores a set of fields in a data structure like a Trie, in JSON format.\n\nEach key is either a '.' representing the field itself, and will always map to an empty set, or a string representing a sub-field or item. The string will follow one of these four formats: 'f:', where is the name of a field in a struct, or key in a map 'v:', where is the exact json formatted value of a list item 'i:', where is position of a item in a list 'k:', where is a map of a list item's key fields to their unique values If a key maps to an empty Fields value, the field that key represents is part of the set.\n\nThe exact format is defined in sigs.k8s.io/structured-merge-diff", + "description": "FieldsV1 stores a set of fields in a data structure like a Trie, in JSON format.\n\nEach key is either a '.' representing the field itself, and will always map to an empty set, or a string representing a sub-field or item. The string will follow one of these four formats: 'f:\u003cname\u003e', where \u003cname\u003e is the name of a field in a struct, or key in a map 'v:\u003cvalue\u003e', where \u003cvalue\u003e is the exact json formatted value of a list item 'i:\u003cindex\u003e', where \u003cindex\u003e is position of a item in a list 'k:\u003ckeys\u003e', where \u003ckeys\u003e is a map of a list item's key fields to their unique values If a key maps to an empty Fields value, the field that key represents is part of the set.\n\nThe exact format is defined in sigs.k8s.io/structured-merge-diff", "type": "object" }, "io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta": { @@ -4575,4 +4591,4 @@ } } } -} +} \ No newline at end of file diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2beta1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2beta1.json index 7f396bc20d4..8588ee9707a 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2beta1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2beta1.json @@ -2261,6 +2261,10 @@ "description": "This can be used by data sources that return and explicit naming structure for values and labels\nWhen this property is configured, this value is used rather than the default naming strategy.", "type": "string" }, + "fieldMinMax": { + "description": "Calculate min max per field", + "type": "boolean" + }, "filterable": { "description": "True if data source field supports ad-hoc filters", "type": "boolean" @@ -2292,6 +2296,9 @@ "description": "Alternative to empty string", "type": "string" }, + "nullValueMode": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2beta1.DashboardNullValueMode" + }, "path": { "description": "An explicit path to the field in the datasource. When the frame meta includes a path,\nThis will default to `${frame.meta.path}/${field.name}\n\nWhen defined, this value can be used as an identifier within the datasource scope, and\nmay be used to update the results", "type": "string" @@ -2300,7 +2307,7 @@ "$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2beta1.DashboardThresholdsConfig" }, "unit": { - "description": "Unit a field should use. The unit you select is applied to all fields except time.\nYou can use the units ID availables in Grafana or a custom unit.\nAvailable units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts\nAs custom unit, you can use the following formats:\n`suffix:` for custom unit that should go after value.\n`prefix:` for custom unit that should go before value.\n`time:` For custom date time formats type for example `time:YYYY-MM-DD`.\n`si:` for custom SI units. For example: `si: mF`. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character.\n`count:` for a custom count unit.\n`currency:` for custom a currency unit.", + "description": "Unit a field should use. The unit you select is applied to all fields except time.\nYou can use the units ID availables in Grafana or a custom unit.\nAvailable units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts\nAs custom unit, you can use the following formats:\n`suffix:\u003csuffix\u003e` for custom unit that should go after value.\n`prefix:\u003cprefix\u003e` for custom unit that should go before value.\n`time:\u003cformat\u003e` For custom date time formats type for example `time:YYYY-MM-DD`.\n`si:\u003cbase scale\u003e\u003cunit characters\u003e` for custom SI units. For example: `si: mF`. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character.\n`count:\u003cunit\u003e` for a custom count unit.\n`currency:\u003cunit\u003e` for custom a currency unit.", "type": "string" }, "writeable": { @@ -2803,6 +2810,15 @@ }, "additionalProperties": false }, + "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2beta1.DashboardNullValueMode": { + "description": "How null values should be handled", + "type": "string", + "enum": [ + "null", + "connected", + "null as zero" + ] + }, "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2beta1.DashboardPanelKind": { "type": "object", "required": [ @@ -3823,7 +3839,7 @@ ], "properties": { "options": { - "description": "Map with : ValueMappingResult. For example: { \"10\": { text: \"Perfection!\", color: \"green\" } }", + "description": "Map with \u003cvalue_to_match\u003e: ValueMappingResult. For example: { \"10\": { text: \"Perfection!\", color: \"green\" } }", "type": "object", "additionalProperties": { "$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2beta1.DashboardValueMappingResult" @@ -4252,7 +4268,7 @@ } }, "io.k8s.apimachinery.pkg.apis.meta.v1.FieldsV1": { - "description": "FieldsV1 stores a set of fields in a data structure like a Trie, in JSON format.\n\nEach key is either a '.' representing the field itself, and will always map to an empty set, or a string representing a sub-field or item. The string will follow one of these four formats: 'f:', where is the name of a field in a struct, or key in a map 'v:', where is the exact json formatted value of a list item 'i:', where is position of a item in a list 'k:', where is a map of a list item's key fields to their unique values If a key maps to an empty Fields value, the field that key represents is part of the set.\n\nThe exact format is defined in sigs.k8s.io/structured-merge-diff", + "description": "FieldsV1 stores a set of fields in a data structure like a Trie, in JSON format.\n\nEach key is either a '.' representing the field itself, and will always map to an empty set, or a string representing a sub-field or item. The string will follow one of these four formats: 'f:\u003cname\u003e', where \u003cname\u003e is the name of a field in a struct, or key in a map 'v:\u003cvalue\u003e', where \u003cvalue\u003e is the exact json formatted value of a list item 'i:\u003cindex\u003e', where \u003cindex\u003e is position of a item in a list 'k:\u003ckeys\u003e', where \u003ckeys\u003e is a map of a list item's key fields to their unique values If a key maps to an empty Fields value, the field that key represents is part of the set.\n\nThe exact format is defined in sigs.k8s.io/structured-merge-diff", "type": "object" }, "io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta": { @@ -4606,4 +4622,4 @@ } } } -} +} \ No newline at end of file diff --git a/pkg/tests/apis/openapi_snapshots/testdata.datasource.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/grafana-testdata-datasource.datasource.grafana.app-v0alpha1.json similarity index 96% rename from pkg/tests/apis/openapi_snapshots/testdata.datasource.grafana.app-v0alpha1.json rename to pkg/tests/apis/openapi_snapshots/grafana-testdata-datasource.datasource.grafana.app-v0alpha1.json index 4249cf16db0..a6d02c218c6 100644 --- a/pkg/tests/apis/openapi_snapshots/testdata.datasource.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/grafana-testdata-datasource.datasource.grafana.app-v0alpha1.json @@ -2,10 +2,10 @@ "openapi": "3.0.0", "info": { "description": "Generates test data in different forms", - "title": "testdata.datasource.grafana.app/v0alpha1" + "title": "grafana-testdata-datasource.datasource.grafana.app/v0alpha1" }, "paths": { - "/apis/testdata.datasource.grafana.app/v0alpha1/": { + "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/": { "get": { "tags": [ "API Discovery" @@ -36,7 +36,7 @@ } } }, - "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/connections/{name}/query": { + "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/connections/{name}/query": { "post": { "tags": [ "Connections (deprecated)" @@ -68,7 +68,7 @@ "deprecated": true, "x-kubernetes-action": "connect", "x-kubernetes-group-version-kind": { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "version": "v0alpha1", "kind": "QueryDataResponse" } @@ -96,7 +96,7 @@ } ] }, - "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources": { + "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources": { "get": { "tags": [ "DataSource" @@ -137,7 +137,7 @@ }, "x-kubernetes-action": "list", "x-kubernetes-group-version-kind": { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "version": "v0alpha1", "kind": "DataSource" } @@ -254,7 +254,7 @@ } ] }, - "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}": { + "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}": { "get": { "tags": [ "DataSource" @@ -285,7 +285,7 @@ }, "x-kubernetes-action": "get", "x-kubernetes-group-version-kind": { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "version": "v0alpha1", "kind": "DataSource" } @@ -322,7 +322,7 @@ } ] }, - "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/health": { + "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/health": { "get": { "tags": [ "DataSource" @@ -343,7 +343,7 @@ }, "x-kubernetes-action": "connect", "x-kubernetes-group-version-kind": { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "version": "v0alpha1", "kind": "HealthCheckResult" } @@ -371,7 +371,7 @@ } ] }, - "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/query": { + "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/query": { "post": { "tags": [ "DataSource" @@ -401,7 +401,7 @@ }, "x-kubernetes-action": "connect", "x-kubernetes-group-version-kind": { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "version": "v0alpha1", "kind": "QueryDataResponse" } @@ -429,7 +429,7 @@ } ] }, - "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource": { + "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource": { "get": { "tags": [ "DataSource" @@ -450,7 +450,7 @@ }, "x-kubernetes-action": "connect", "x-kubernetes-group-version-kind": { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "version": "v0alpha1", "kind": "Status" } @@ -478,7 +478,7 @@ } ] }, - "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/queryconvert/{name}": { + "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/queryconvert/{name}": { "post": { "tags": [ "QueryDataRequest" @@ -499,7 +499,7 @@ }, "x-kubernetes-action": "connect", "x-kubernetes-group-version-kind": { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "version": "v0alpha1", "kind": "QueryDataRequest" } @@ -620,7 +620,7 @@ "apiVersion": { "type": "string", "enum": [ - "testdata.datasource.grafana.app/v0alpha1" + "grafana-testdata-datasource.datasource.grafana.app/v0alpha1" ] }, "kind": { @@ -660,7 +660,7 @@ }, "x-kubernetes-group-version-kind": [ { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "kind": "DataSource", "version": "v0alpha1" } @@ -703,7 +703,7 @@ }, "x-kubernetes-group-version-kind": [ { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "kind": "DataSourceList", "version": "v0alpha1" } @@ -744,7 +744,7 @@ }, "x-kubernetes-group-version-kind": [ { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "kind": "HealthCheckResult", "version": "v0alpha1" } @@ -833,7 +833,7 @@ }, "x-kubernetes-group-version-kind": [ { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "kind": "QueryDataResponse", "version": "v0alpha1" } diff --git a/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json index dab9f3cd8b1..f03b3c3369b 100644 --- a/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json @@ -87,7 +87,7 @@ "tags": [ "ExternalGroupMapping" ], - "description": "list or watch objects of kind ExternalGroupMapping", + "description": "list objects of kind ExternalGroupMapping", "operationId": "listExternalGroupMapping", "parameters": [ { @@ -8690,32 +8690,6 @@ "description": "Time is a wrapper around time.Time which supports correct marshaling to YAML and JSON. Wrappers are provided for many of the factory methods that the time package offers.", "type": "string", "format": "date-time" - }, - "io.k8s.apimachinery.pkg.apis.meta.v1.WatchEvent": { - "description": "Event represents a single event to a watched resource.", - "type": "object", - "required": [ - "type", - "object" - ], - "properties": { - "object": { - "description": "Object is:\n * If Type is Added or Modified: the new state of the object.\n * If Type is Deleted: the state of the object immediately before deletion.\n * If Type is Error: *Status is recommended; other types may make sense\n depending on context.", - "allOf": [ - { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.runtime.RawExtension" - } - ] - }, - "type": { - "type": "string", - "default": "" - } - } - }, - "io.k8s.apimachinery.pkg.runtime.RawExtension": { - "description": "RawExtension is used to hold extensions in external versions.\n\nTo use this, make a field which has RawExtension as its type in your external, versioned struct, and Object in your internal struct. You also need to register your various plugin types.\n\n// Internal package:\n\n\ttype MyAPIObject struct {\n\t\truntime.TypeMeta `json:\",inline\"`\n\t\tMyPlugin runtime.Object `json:\"myPlugin\"`\n\t}\n\n\ttype PluginA struct {\n\t\tAOption string `json:\"aOption\"`\n\t}\n\n// External package:\n\n\ttype MyAPIObject struct {\n\t\truntime.TypeMeta `json:\",inline\"`\n\t\tMyPlugin runtime.RawExtension `json:\"myPlugin\"`\n\t}\n\n\ttype PluginA struct {\n\t\tAOption string `json:\"aOption\"`\n\t}\n\n// On the wire, the JSON will look something like this:\n\n\t{\n\t\t\"kind\":\"MyAPIObject\",\n\t\t\"apiVersion\":\"v1\",\n\t\t\"myPlugin\": {\n\t\t\t\"kind\":\"PluginA\",\n\t\t\t\"aOption\":\"foo\",\n\t\t},\n\t}\n\nSo what happens? Decode first uses json or yaml to unmarshal the serialized data into your external MyAPIObject. That causes the raw JSON to be stored, but not unpacked. The next step is to copy (using pkg/conversion) into the internal struct. The runtime package's DefaultScheme has conversion functions installed which will unpack the JSON stored in RawExtension, turning it into the correct object type, and storing it in the Object. (TODO: In the case where the object is of an unknown type, a runtime.Unknown object will be created and stored.)", - "type": "object" } } } diff --git a/pkg/tests/apis/openapi_snapshots/investigations.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/logsdrilldown.grafana.app-v1beta1.json similarity index 61% rename from pkg/tests/apis/openapi_snapshots/investigations.grafana.app-v0alpha1.json rename to pkg/tests/apis/openapi_snapshots/logsdrilldown.grafana.app-v1beta1.json index 017323b2a1e..de166b1984d 100644 --- a/pkg/tests/apis/openapi_snapshots/investigations.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/logsdrilldown.grafana.app-v1beta1.json @@ -1,10 +1,10 @@ { "openapi": "3.0.0", "info": { - "title": "investigations.grafana.app/v0alpha1" + "title": "logsdrilldown.grafana.app/v1beta1" }, "paths": { - "/apis/investigations.grafana.app/v0alpha1/": { + "/apis/logsdrilldown.grafana.app/v1beta1/": { "get": { "tags": [ "API Discovery" @@ -35,13 +35,13 @@ } } }, - "/apis/investigations.grafana.app/v0alpha1/namespaces/{namespace}/investigationindexes": { + "/apis/logsdrilldown.grafana.app/v1beta1/namespaces/{namespace}/logsdrilldowndefaultcolumns": { "get": { "tags": [ - "InvestigationIndex" + "LogsDrilldownDefaultColumns" ], - "description": "list or watch objects of kind InvestigationIndex", - "operationId": "listInvestigationIndex", + "description": "list or watch objects of kind LogsDrilldownDefaultColumns", + "operationId": "listLogsDrilldownDefaultColumns", "parameters": [ { "name": "allowWatchBookmarks", @@ -140,27 +140,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList" } }, "application/json;stream=watch": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList" } }, "application/vnd.kubernetes.protobuf;stream=watch": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList" } } } @@ -168,17 +168,17 @@ }, "x-kubernetes-action": "list", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "InvestigationIndex" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "post": { "tags": [ - "InvestigationIndex" + "LogsDrilldownDefaultColumns" ], - "description": "create an InvestigationIndex", - "operationId": "createInvestigationIndex", + "description": "create LogsDrilldownDefaultColumns", + "operationId": "createLogsDrilldownDefaultColumns", "parameters": [ { "name": "dryRun", @@ -212,17 +212,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } }, @@ -234,17 +234,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -254,17 +254,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -274,17 +274,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -292,17 +292,17 @@ }, "x-kubernetes-action": "post", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "InvestigationIndex" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "delete": { "tags": [ - "InvestigationIndex" + "LogsDrilldownDefaultColumns" ], - "description": "delete collection of InvestigationIndex", - "operationId": "deletecollectionInvestigationIndex", + "description": "delete collection of LogsDrilldownDefaultColumns", + "operationId": "deletecollectionLogsDrilldownDefaultColumns", "parameters": [ { "name": "continue", @@ -446,9 +446,9 @@ }, "x-kubernetes-action": "deletecollection", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "InvestigationIndex" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "parameters": [ @@ -473,30 +473,30 @@ } ] }, - "/apis/investigations.grafana.app/v0alpha1/namespaces/{namespace}/investigationindexes/{name}": { + "/apis/logsdrilldown.grafana.app/v1beta1/namespaces/{namespace}/logsdrilldowndefaultcolumns/{name}": { "get": { "tags": [ - "InvestigationIndex" + "LogsDrilldownDefaultColumns" ], - "description": "read the specified InvestigationIndex", - "operationId": "getInvestigationIndex", + "description": "read the specified LogsDrilldownDefaultColumns", + "operationId": "getLogsDrilldownDefaultColumns", "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -504,17 +504,17 @@ }, "x-kubernetes-action": "get", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "InvestigationIndex" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "put": { "tags": [ - "InvestigationIndex" + "LogsDrilldownDefaultColumns" ], - "description": "replace the specified InvestigationIndex", - "operationId": "replaceInvestigationIndex", + "description": "replace the specified LogsDrilldownDefaultColumns", + "operationId": "replaceLogsDrilldownDefaultColumns", "parameters": [ { "name": "dryRun", @@ -548,17 +548,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } }, @@ -570,17 +570,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -590,17 +590,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -608,17 +608,17 @@ }, "x-kubernetes-action": "put", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "InvestigationIndex" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "delete": { "tags": [ - "InvestigationIndex" + "LogsDrilldownDefaultColumns" ], - "description": "delete an InvestigationIndex", - "operationId": "deleteInvestigationIndex", + "description": "delete LogsDrilldownDefaultColumns", + "operationId": "deleteLogsDrilldownDefaultColumns", "parameters": [ { "name": "dryRun", @@ -710,17 +710,17 @@ }, "x-kubernetes-action": "delete", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "InvestigationIndex" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "patch": { "tags": [ - "InvestigationIndex" + "LogsDrilldownDefaultColumns" ], - "description": "partially update the specified InvestigationIndex", - "operationId": "updateInvestigationIndex", + "description": "partially update the specified LogsDrilldownDefaultColumns", + "operationId": "updateLogsDrilldownDefaultColumns", "parameters": [ { "name": "dryRun", @@ -790,17 +790,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -810,17 +810,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -828,16 +828,16 @@ }, "x-kubernetes-action": "patch", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "InvestigationIndex" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "parameters": [ { "name": "name", "in": "path", - "description": "name of the InvestigationIndex", + "description": "name of the LogsDrilldownDefaultColumns", "required": true, "schema": { "type": "string", @@ -865,468 +865,30 @@ } ] }, - "/apis/investigations.grafana.app/v0alpha1/namespaces/{namespace}/investigations": { + "/apis/logsdrilldown.grafana.app/v1beta1/namespaces/{namespace}/logsdrilldowndefaultcolumns/{name}/status": { "get": { "tags": [ - "Investigation" - ], - "description": "list or watch objects of kind Investigation", - "operationId": "listInvestigation", - "parameters": [ - { - "name": "allowWatchBookmarks", - "in": "query", - "description": "allowWatchBookmarks requests watch events with type \"BOOKMARK\". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "continue", - "in": "query", - "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "labelSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "limit", - "in": "query", - "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "resourceVersion", - "in": "query", - "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "resourceVersionMatch", - "in": "query", - "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "sendInitialEvents", - "in": "query", - "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "timeoutSeconds", - "in": "query", - "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "watch", - "in": "query", - "description": "Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - } + "LogsDrilldownDefaultColumns" ], + "description": "read status of the specified LogsDrilldownDefaultColumns", + "operationId": "getLogsDrilldownDefaultColumnsStatus", "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationList" - } - }, - "application/json;stream=watch": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationList" - } - }, - "application/vnd.kubernetes.protobuf;stream=watch": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationList" - } - } - } - } - }, - "x-kubernetes-action": "list", - "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "Investigation" - } - }, - "post": { - "tags": [ - "Investigation" - ], - "description": "create an Investigation", - "operationId": "createInvestigation", - "parameters": [ - { - "name": "dryRun", - "in": "query", - "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldManager", - "in": "query", - "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldValidation", - "in": "query", - "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", - "schema": { - "type": "string", - "uniqueItems": true - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - } - } - }, - "201": { - "description": "Created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - } - } - }, - "202": { - "description": "Accepted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - } - } - } - }, - "x-kubernetes-action": "post", - "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "Investigation" - } - }, - "delete": { - "tags": [ - "Investigation" - ], - "description": "delete collection of Investigation", - "operationId": "deletecollectionInvestigation", - "parameters": [ - { - "name": "continue", - "in": "query", - "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "dryRun", - "in": "query", - "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "gracePeriodSeconds", - "in": "query", - "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "ignoreStoreReadErrorWithClusterBreakingPotential", - "in": "query", - "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "labelSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "limit", - "in": "query", - "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "orphanDependents", - "in": "query", - "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "propagationPolicy", - "in": "query", - "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "resourceVersion", - "in": "query", - "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "resourceVersionMatch", - "in": "query", - "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "sendInitialEvents", - "in": "query", - "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "timeoutSeconds", - "in": "query", - "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", - "schema": { - "type": "integer", - "uniqueItems": true - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - } - } - } - }, - "x-kubernetes-action": "deletecollection", - "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "Investigation" - } - }, - "parameters": [ - { - "name": "namespace", - "in": "path", - "description": "object name and auth scope, such as for teams and projects", - "required": true, - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "pretty", - "in": "query", - "description": "If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget).", - "schema": { - "type": "string", - "uniqueItems": true - } - } - ] - }, - "/apis/investigations.grafana.app/v0alpha1/namespaces/{namespace}/investigations/{name}": { - "get": { - "tags": [ - "Investigation" - ], - "description": "read the specified Investigation", - "operationId": "getInvestigation", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -1334,17 +896,17 @@ }, "x-kubernetes-action": "get", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "Investigation" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "put": { "tags": [ - "Investigation" + "LogsDrilldownDefaultColumns" ], - "description": "replace the specified Investigation", - "operationId": "replaceInvestigation", + "description": "replace status of the specified LogsDrilldownDefaultColumns", + "operationId": "replaceLogsDrilldownDefaultColumnsStatus", "parameters": [ { "name": "dryRun", @@ -1378,17 +940,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } }, @@ -1400,17 +962,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -1420,17 +982,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -1438,119 +1000,17 @@ }, "x-kubernetes-action": "put", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "Investigation" - } - }, - "delete": { - "tags": [ - "Investigation" - ], - "description": "delete an Investigation", - "operationId": "deleteInvestigation", - "parameters": [ - { - "name": "dryRun", - "in": "query", - "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "gracePeriodSeconds", - "in": "query", - "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "ignoreStoreReadErrorWithClusterBreakingPotential", - "in": "query", - "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "orphanDependents", - "in": "query", - "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "propagationPolicy", - "in": "query", - "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", - "schema": { - "type": "string", - "uniqueItems": true - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - } - } - }, - "202": { - "description": "Accepted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - } - } - } - }, - "x-kubernetes-action": "delete", - "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "Investigation" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "patch": { "tags": [ - "Investigation" + "LogsDrilldownDefaultColumns" ], - "description": "partially update the specified Investigation", - "operationId": "updateInvestigation", + "description": "partially update status of the specified LogsDrilldownDefaultColumns", + "operationId": "updateLogsDrilldownDefaultColumnsStatus", "parameters": [ { "name": "dryRun", @@ -1620,17 +1080,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -1640,17 +1100,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -1658,16 +1118,16 @@ }, "x-kubernetes-action": "patch", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "Investigation" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "parameters": [ { "name": "name", "in": "path", - "description": "name of the Investigation", + "description": "name of the LogsDrilldownDefaultColumns", "required": true, "schema": { "type": "string", @@ -1698,12 +1158,13 @@ }, "components": { "schemas": { - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation": { + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns": { "type": "object", "required": [ + "kind", + "apiVersion", "metadata", - "spec", - "status" + "spec" ], "properties": { "apiVersion": { @@ -1723,269 +1184,21 @@ ] }, "spec": { - "description": "Spec is the spec of the Investigation", - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationSpec" - } - ] + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsSpec" }, "status": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationStatus" - } - ] + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsStatus" } }, "x-kubernetes-group-version-kind": [ { - "group": "investigations.grafana.app", - "kind": "Investigation", - "version": "v0alpha1" + "group": "logsdrilldown.grafana.app", + "kind": "LogsDrilldownDefaultColumns", + "version": "v1beta1" } ] }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationCollectable": { - "description": "Collectable represents an item collected during investigation", - "type": "object", - "required": [ - "id", - "createdAt", - "title", - "origin", - "type", - "queries", - "timeRange", - "datasource", - "url", - "note", - "noteUpdatedAt", - "fieldConfig" - ], - "properties": { - "createdAt": { - "type": "string", - "default": "" - }, - "datasource": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationDatasourceRef" - } - ] - }, - "fieldConfig": { - "type": "string", - "default": "" - }, - "id": { - "type": "string", - "default": "" - }, - "logoPath": { - "type": "string" - }, - "note": { - "type": "string", - "default": "" - }, - "noteUpdatedAt": { - "type": "string", - "default": "" - }, - "origin": { - "type": "string", - "default": "" - }, - "queries": { - "type": "array", - "items": { - "type": "string", - "default": "" - }, - "x-kubernetes-list-type": "atomic" - }, - "timeRange": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationTimeRange" - } - ] - }, - "title": { - "type": "string", - "default": "" - }, - "type": { - "type": "string", - "default": "" - }, - "url": { - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationDatasourceRef": { - "description": "DatasourceRef is a reference to a datasource", - "type": "object", - "required": [ - "uid" - ], - "properties": { - "uid": { - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex": { - "type": "object", - "required": [ - "metadata", - "spec", - "status" - ], - "properties": { - "apiVersion": { - "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - "type": "string" - }, - "kind": { - "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - "type": "string" - }, - "metadata": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" - } - ] - }, - "spec": { - "description": "Spec is the spec of the InvestigationIndex", - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexSpec" - } - ] - }, - "status": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexStatus" - } - ] - } - }, - "x-kubernetes-group-version-kind": [ - { - "group": "investigations.grafana.app", - "kind": "InvestigationIndex", - "version": "v0alpha1" - } - ] - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexCollectableSummary": { - "type": "object", - "required": [ - "id", - "title", - "logoPath", - "origin" - ], - "properties": { - "id": { - "type": "string", - "default": "" - }, - "logoPath": { - "type": "string", - "default": "" - }, - "origin": { - "type": "string", - "default": "" - }, - "title": { - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexInvestigationSummary": { - "description": "Type definition for investigation summaries", - "type": "object", - "required": [ - "title", - "createdByProfile", - "hasCustomName", - "isFavorite", - "overviewNote", - "overviewNoteUpdatedAt", - "viewMode", - "collectableSummaries" - ], - "properties": { - "collectableSummaries": { - "type": "array", - "items": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexCollectableSummary" - } - ] - }, - "x-kubernetes-list-type": "atomic" - }, - "createdByProfile": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexPerson" - } - ] - }, - "hasCustomName": { - "type": "boolean", - "default": false - }, - "isFavorite": { - "type": "boolean", - "default": false - }, - "overviewNote": { - "type": "string", - "default": "" - }, - "overviewNoteUpdatedAt": { - "type": "string", - "default": "" - }, - "title": { - "type": "string", - "default": "" - }, - "viewMode": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexViewMode" - } - ] - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexList": { + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList": { "type": "object", "required": [ "metadata", @@ -2002,7 +1215,7 @@ "default": {}, "allOf": [ { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } ] } @@ -2022,122 +1235,60 @@ }, "x-kubernetes-group-version-kind": [ { - "group": "investigations.grafana.app", - "kind": "InvestigationIndexList", - "version": "v0alpha1" + "group": "logsdrilldown.grafana.app", + "kind": "LogsDrilldownDefaultColumnsList", + "version": "v1beta1" } ] }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexPerson": { - "description": "Person represents a user profile with basic information", + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel": { "type": "object", "required": [ - "uid", - "name", - "gravatarUrl" + "key", + "value" ], "properties": { - "gravatarUrl": { - "description": "URL to user's Gravatar image", - "type": "string", - "default": "" + "key": { + "type": "string" }, - "name": { - "description": "Display name of the user", - "type": "string", - "default": "" - }, - "uid": { - "description": "Unique identifier for the user", - "type": "string", - "default": "" + "value": { + "type": "string" } + }, + "additionalProperties": false + }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsLabels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel" } }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexSpec": { + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord": { "type": "object", "required": [ - "title", - "owner", - "investigationSummaries" + "columns", + "labels" ], "properties": { - "investigationSummaries": { - "description": "Array of investigation summaries", + "columns": { "type": "array", "items": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexInvestigationSummary" - } - ] - }, - "x-kubernetes-list-type": "atomic" - }, - "owner": { - "description": "The Person who owns this investigation index", - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexPerson" - } - ] - }, - "title": { - "description": "Title of the index, e.g. 'Favorites' or 'My Investigations'", - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexStatus": { - "type": "object", - "properties": { - "additionalFields": { - "description": "additionalFields is reserved for future use", - "type": "object", - "additionalProperties": { - "type": "object" + "type": "string" } }, - "operatorStates": { - "description": "operatorStates is a map of operator ID to operator state evaluations. Any operator which consumes this kind SHOULD add its state evaluation information to this field.", - "type": "object", - "additionalProperties": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexstatusOperatorState" - } - ] - } + "labels": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsLabels" } + }, + "additionalProperties": false + }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords": { + "type": "array", + "items": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord" } }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexViewMode": { - "type": "object", - "required": [ - "mode", - "showComments", - "showTooltips" - ], - "properties": { - "mode": { - "type": "string", - "default": "" - }, - "showComments": { - "type": "boolean", - "default": false - }, - "showTooltips": { - "type": "boolean", - "default": false - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexstatusOperatorState": { + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsOperatorState": { "type": "object", "required": [ "lastEvaluation", @@ -2152,273 +1303,58 @@ "description": "details contains any extra information that is operator-specific", "type": "object", "additionalProperties": { - "type": "object" + "type": "object", + "additionalProperties": {} } }, "lastEvaluation": { "description": "lastEvaluation is the ResourceVersion last evaluated", - "type": "string", - "default": "" + "type": "string" }, "state": { - "description": "state describes the state of the lastEvaluation. It is limited to three possible states for machine evaluation.", + "description": "state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.", "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationList": { - "type": "object", - "required": [ - "metadata", - "items" - ], - "properties": { - "apiVersion": { - "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - "type": "string" - }, - "items": { - "type": "array", - "items": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - ] - } - }, - "kind": { - "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - "type": "string" - }, - "metadata": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta" - } + "enum": [ + "success", + "in_progress", + "failed" ] } }, - "x-kubernetes-group-version-kind": [ - { - "group": "investigations.grafana.app", - "kind": "InvestigationList", - "version": "v0alpha1" - } - ] + "additionalProperties": false }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationPerson": { - "description": "Person represents a user profile with basic information", + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsSpec": { "type": "object", "required": [ - "uid", - "name", - "gravatarUrl" + "records" ], "properties": { - "gravatarUrl": { - "description": "URL to user's Gravatar image", - "type": "string", - "default": "" - }, - "name": { - "description": "Display name of the user", - "type": "string", - "default": "" - }, - "uid": { - "description": "Unique identifier for the user", - "type": "string", - "default": "" + "records": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords" } - } + }, + "additionalProperties": false }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationSpec": { - "description": "spec is the schema of our resource", - "type": "object", - "required": [ - "title", - "createdByProfile", - "hasCustomName", - "isFavorite", - "overviewNote", - "overviewNoteUpdatedAt", - "collectables", - "viewMode" - ], - "properties": { - "collectables": { - "type": "array", - "items": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationCollectable" - } - ] - }, - "x-kubernetes-list-type": "atomic" - }, - "createdByProfile": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationPerson" - } - ] - }, - "hasCustomName": { - "type": "boolean", - "default": false - }, - "isFavorite": { - "type": "boolean", - "default": false - }, - "overviewNote": { - "type": "string", - "default": "" - }, - "overviewNoteUpdatedAt": { - "type": "string", - "default": "" - }, - "title": { - "type": "string", - "default": "" - }, - "viewMode": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationViewMode" - } - ] - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationStatus": { + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsStatus": { "type": "object", "properties": { "additionalFields": { "description": "additionalFields is reserved for future use", "type": "object", "additionalProperties": { - "type": "object" + "type": "object", + "additionalProperties": {} } }, "operatorStates": { - "description": "operatorStates is a map of operator ID to operator state evaluations. Any operator which consumes this kind SHOULD add its state evaluation information to this field.", + "description": "operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.", "type": "object", "additionalProperties": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationstatusOperatorState" - } - ] + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsOperatorState" } } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationTimeRange": { - "description": "TimeRange represents a time range with both absolute and relative values", - "type": "object", - "required": [ - "from", - "to", - "raw" - ], - "properties": { - "from": { - "type": "string", - "default": "" - }, - "raw": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationV0alpha1TimeRangeRaw" - } - ] - }, - "to": { - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationV0alpha1TimeRangeRaw": { - "type": "object", - "required": [ - "from", - "to" - ], - "properties": { - "from": { - "type": "string", - "default": "" - }, - "to": { - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationViewMode": { - "type": "object", - "required": [ - "mode", - "showComments", - "showTooltips" - ], - "properties": { - "mode": { - "type": "string", - "default": "" - }, - "showComments": { - "type": "boolean", - "default": false - }, - "showTooltips": { - "type": "boolean", - "default": false - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationstatusOperatorState": { - "type": "object", - "required": [ - "lastEvaluation", - "state" - ], - "properties": { - "descriptiveState": { - "description": "descriptiveState is an optional more descriptive state field which has no requirements on format", - "type": "string" - }, - "details": { - "description": "details contains any extra information that is operator-specific", - "type": "object", - "additionalProperties": { - "type": "object" - } - }, - "lastEvaluation": { - "description": "lastEvaluation is the ResourceVersion last evaluated", - "type": "string", - "default": "" - }, - "state": { - "description": "state describes the state of the lastEvaluation. It is limited to three possible states for machine evaluation.", - "type": "string", - "default": "" - } - } + }, + "additionalProperties": false }, "io.k8s.apimachinery.pkg.apis.meta.v1.APIResource": { "description": "APIResource specifies the name of a resource and whether it is namespaced.", 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 d4764b742f1..fc8efbaabbb 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -866,6 +866,80 @@ } ] }, + "/apis/provisioning.grafana.app/v0alpha1/namespaces/{namespace}/connections/{name}/repositories": { + "get": { + "tags": [ + "Connection" + ], + "summary": "List external repositories", + "description": "List repositories available from the external git provider through this connection", + "operationId": "getConnectionRepositories", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "description": "ExternalRepositoryList lists repositories from an external git provider", + "type": "object", + "required": [ + "items" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "items": { + "type": "array", + "items": { + "default": {} + }, + "x-kubernetes-list-type": "atomic" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "default": {} + } + } + } + } + } + } + }, + "x-kubernetes-action": "connect", + "x-kubernetes-group-version-kind": { + "group": "provisioning.grafana.app", + "version": "v0alpha1", + "kind": "ExternalRepositoryList" + } + }, + "parameters": [ + { + "name": "name", + "in": "path", + "description": "name of the ExternalRepositoryList", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "namespace", + "in": "path", + "description": "object name and auth scope, such as for teams and projects", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + } + ] + }, "/apis/provisioning.grafana.app/v0alpha1/namespaces/{namespace}/connections/{name}/status": { "get": { "tags": [ @@ -4485,7 +4559,7 @@ } ] }, - "webhook": { + "token": { "description": "Token is the reference of the token used to act as the Connection. This value is stored securely and cannot be read back", "default": {}, "allOf": [ @@ -4645,6 +4719,73 @@ } } }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ExternalRepository": { + "type": "object", + "required": [ + "name", + "url" + ], + "properties": { + "name": { + "description": "Name of the repository", + "type": "string", + "default": "" + }, + "owner": { + "description": "Owner is the user, organization, or workspace that owns the repository For GitHub: organization or user For GitLab: namespace (user or group) For Bitbucket: workspace For pure Git: empty", + "type": "string" + }, + "url": { + "description": "URL of the repository", + "type": "string", + "default": "" + } + } + }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ExternalRepositoryList": { + "description": "ExternalRepositoryList lists repositories from an external git provider", + "type": "object", + "required": [ + "items" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "items": { + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ExternalRepository" + } + ] + }, + "x-kubernetes-list-type": "atomic" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta" + } + ] + } + }, + "x-kubernetes-group-version-kind": [ + { + "group": "provisioning.grafana.app", + "kind": "ExternalRepositoryList", + "version": "v0alpha1" + } + ] + }, "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.FileItem": { "type": "object", "required": [ diff --git a/pkg/tests/apis/openapi_test.go b/pkg/tests/apis/openapi_test.go index f9d5cdb70e5..d73463a7daf 100644 --- a/pkg/tests/apis/openapi_test.go +++ b/pkg/tests/apis/openapi_test.go @@ -30,7 +30,6 @@ func TestIntegrationOpenAPIs(t *testing.T) { EnableFeatureToggles: []string{ featuremgmt.FlagQueryService, // Query Library featuremgmt.FlagProvisioning, - featuremgmt.FlagInvestigationsBackend, featuremgmt.FlagGrafanaAdvisor, featuremgmt.FlagKubernetesAlertingRules, featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // all datasources @@ -97,9 +96,6 @@ func TestIntegrationOpenAPIs(t *testing.T) { }, { Group: "iam.grafana.app", Version: "v0alpha1", - }, { - Group: "investigations.grafana.app", - Version: "v0alpha1", }, { Group: "advisor.grafana.app", Version: "v0alpha1", @@ -128,11 +124,11 @@ func TestIntegrationOpenAPIs(t *testing.T) { Group: "shorturl.grafana.app", Version: "v1beta1", }, { - Group: "testdata.datasource.grafana.app", + Group: "grafana-testdata-datasource.datasource.grafana.app", Version: "v0alpha1", }, { Group: "logsdrilldown.grafana.app", - Version: "v1alpha1", + Version: "v1beta1", }} for _, gv := range groups { VerifyOpenAPISnapshots(t, dir, gv, h) diff --git a/pkg/tests/apis/preferences/preferences_test.go b/pkg/tests/apis/preferences/preferences_test.go index 5de09e16fa8..63ab74e0eb8 100644 --- a/pkg/tests/apis/preferences/preferences_test.go +++ b/pkg/tests/apis/preferences/preferences_test.go @@ -67,7 +67,7 @@ func TestIntegrationPreferences(t *testing.T) { Path: fmt.Sprintf("/api/teams/%d/preferences", helper.Org1.Staff.ID), Body: []byte(`{ "weekStart": "sunday", - "timezone": "africa" + "timezone": "Africa/Johannesburg" }`), }, &raw) require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "create preference for user") @@ -79,7 +79,7 @@ func TestIntegrationPreferences(t *testing.T) { Path: "/api/org/preferences", Body: []byte(`{ "weekStart": "sunday", - "timezone": "africa", + "timezone": "Africa/Accra", "theme": "dark" }`), }, &raw) @@ -144,7 +144,7 @@ func TestIntegrationPreferences(t *testing.T) { jj, _ = json.Marshal(bootdata.Result.User) require.JSONEq(t, `{ - "timezone":"africa", + "timezone":"Africa/Johannesburg", "weekStart":"saturday", "theme":"dark", "language":"en-US", `+ // FROM global default! @@ -157,10 +157,10 @@ func TestIntegrationPreferences(t *testing.T) { Path: "/apis/preferences.grafana.app/v1alpha1/namespaces/default/preferences/merged", }, &preferences.Preferences{}) require.Equal(t, http.StatusOK, merged.Response.StatusCode, "get merged preferences") - require.Equal(t, "saturday", *merged.Result.Spec.WeekStart) // from user - require.Equal(t, "africa", *merged.Result.Spec.Timezone) // from team - require.Equal(t, "dark", *merged.Result.Spec.Theme) // from org - require.Equal(t, "en-US", *merged.Result.Spec.Language) // settings.ini - require.Equal(t, "dd/mm/yyyy", *merged.Result.Spec.RegionalFormat) // from user update + require.Equal(t, "saturday", *merged.Result.Spec.WeekStart) // from user + require.Equal(t, "Africa/Johannesburg", *merged.Result.Spec.Timezone) // from team + require.Equal(t, "dark", *merged.Result.Spec.Theme) // from org + require.Equal(t, "en-US", *merged.Result.Spec.Language) // settings.ini + require.Equal(t, "dd/mm/yyyy", *merged.Result.Spec.RegionalFormat) // from user update }) } diff --git a/pkg/tests/apis/provisioning/connection_repositories_test.go b/pkg/tests/apis/provisioning/connection_repositories_test.go new file mode 100644 index 00000000000..4def16377e3 --- /dev/null +++ b/pkg/tests/apis/provisioning/connection_repositories_test.go @@ -0,0 +1,170 @@ +package provisioning + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/util/testutil" +) + +func TestIntegrationProvisioning_ConnectionRepositories(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) + + // Create a connection for testing + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection-repositories-test", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "123456", + "installationID": "454545", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + _, err := helper.CreateGithubConnection(t, ctx, connection) + require.NoError(t, err) + + t.Run("endpoint returns not implemented", func(t *testing.T) { + var statusCode int + result := helper.AdminREST.Get(). + Namespace("default"). + Resource("connections"). + Name("connection-repositories-test"). + SubResource("repositories"). + Do(ctx). + StatusCode(&statusCode) + + require.Error(t, result.Error(), "should return error for not implemented endpoint") + require.Equal(t, http.StatusMethodNotAllowed, statusCode, "should return 405 Method Not Allowed") + require.True(t, apierrors.IsMethodNotSupported(result.Error()), "error should be MethodNotSupported") + }) + + t.Run("admin can access endpoint (gets not implemented)", func(t *testing.T) { + var statusCode int + result := helper.AdminREST.Get(). + Namespace("default"). + Resource("connections"). + Name("connection-repositories-test"). + SubResource("repositories"). + Do(ctx).StatusCode(&statusCode) + + // Endpoint exists but returns not implemented + require.Error(t, result.Error(), "should return error") + require.True(t, apierrors.IsMethodNotSupported(result.Error()), "error should be MethodNotSupported") + // Status code should be 405 (Method Not Allowed) for method not supported + require.Equal(t, http.StatusMethodNotAllowed, statusCode) + }) + + t.Run("editor cannot access endpoint", func(t *testing.T) { + var statusCode int + result := helper.EditorREST.Get(). + Namespace("default"). + Resource("connections"). + Name("connection-repositories-test"). + SubResource("repositories"). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "editor should not be able to access repositories endpoint") + require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden") + require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden") + }) + + t.Run("viewer cannot access endpoint", func(t *testing.T) { + var statusCode int + result := helper.ViewerREST.Get(). + Namespace("default"). + Resource("connections"). + Name("connection-repositories-test"). + SubResource("repositories"). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "viewer should not be able to access repositories endpoint") + require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden") + require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden") + }) + + t.Run("non-GET methods are rejected", func(t *testing.T) { + configBytes, _ := json.Marshal(map[string]any{}) + + var statusCode int + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("connections"). + Name("connection-repositories-test"). + SubResource("repositories"). + Body(configBytes). + SetHeader("Content-Type", "application/json"). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "POST should not be allowed") + require.True(t, apierrors.IsMethodNotSupported(result.Error()), "error should be MethodNotSupported") + }) +} + +func TestIntegrationProvisioning_ConnectionRepositoriesResponseType(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) + + // Create a connection for testing + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection-repositories-test", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "123456", + "installationID": "454545", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + _, err := helper.CreateGithubConnection(t, ctx, connection) + require.NoError(t, err) + + t.Run("verify ExternalRepositoryList type exists in API", func(t *testing.T) { + // Verify the type is registered and can be instantiated + list := &provisioning.ExternalRepositoryList{} + require.NotNil(t, list) + // Verify it has the expected structure (Items is a slice, nil by default is fine) + require.IsType(t, []provisioning.ExternalRepository{}, list.Items) + // Can create items + list.Items = []provisioning.ExternalRepository{ + {Name: "test", Owner: "owner", URL: "https://example.com/repo"}, + } + require.Len(t, list.Items, 1) + require.Equal(t, "test", list.Items[0].Name) + }) +} diff --git a/pkg/tests/apis/provisioning/connection_status_auth_test.go b/pkg/tests/apis/provisioning/connection_status_auth_test.go index fbddd85999a..0deaa3eeefe 100644 --- a/pkg/tests/apis/provisioning/connection_status_auth_test.go +++ b/pkg/tests/apis/provisioning/connection_status_auth_test.go @@ -2,12 +2,12 @@ package provisioning import ( "context" + "encoding/base64" "net/http" "testing" "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" "github.com/grafana/grafana/pkg/util/testutil" @@ -18,7 +18,7 @@ func TestIntegrationProvisioning_ConnectionStatusAuthorization(t *testing.T) { helper := runGrafana(t) ctx := context.Background() - createOptions := metav1.CreateOptions{FieldValidation: "Strict"} + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) // Create a connection for testing connection := &unstructured.Unstructured{Object: map[string]any{ @@ -37,13 +37,12 @@ func TestIntegrationProvisioning_ConnectionStatusAuthorization(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} - - _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) - require.NoError(t, err, "failed to create connection") + _, err := helper.CreateGithubConnection(t, ctx, connection) + require.NoError(t, err) t.Run("admin can GET connection status", func(t *testing.T) { var statusCode int diff --git a/pkg/tests/apis/provisioning/connection_test.go b/pkg/tests/apis/provisioning/connection_test.go index ea28ac88359..95d03e0a03e 100644 --- a/pkg/tests/apis/provisioning/connection_test.go +++ b/pkg/tests/apis/provisioning/connection_test.go @@ -2,23 +2,79 @@ package provisioning import ( "context" + "encoding/base64" + "encoding/json" "errors" + "fmt" + "net/http" "testing" + "time" + "github.com/golang-jwt/jwt/v4" + "github.com/google/go-github/v70/github" + githubConnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" + "github.com/grafana/grafana/pkg/extensions" "github.com/grafana/grafana/pkg/util/testutil" + ghmock "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + clientset "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned" ) +//nolint:gosec // Test RSA private key (generated for testing purposes only) +const testPrivateKeyPEM = `-----BEGIN RSA PRIVATE KEY----- +MIIEoQIBAAKCAQBn1MuM5hIfH6d3TNStI1ofWv/gcjQ4joi9cFijEwVLuPYkF1nD +KkSbaMGFUWiOTaB/H9fxmd/V2u04NlBY3av6m5T/sHfVSiEWAEUblh3cA34HVCmD +cqyyVty5HLGJJlSs2C7W2x7yUc9ImzyDBsyjpKOXuojJ9wN9a17D2cYU5WkXjoDC +4BHid61jn9WBTtPZXSgOdirwahNzxZQSIP7DA9T8yiZwIWPp5YesgsAPyQLCFPgM +s77xz/CEUnEYQ35zI/k/mQrwKdQ/ZP8xLwQohUID0BIxE7G5quL069RuuCZWZkoF +oPiZbp7HSryz1+19jD3rFT7eHGUYvAyCnXmXAgMBAAECggEADSs4Bc7ITZo+Kytb +bfol3AQ2n8jcRrANN7mgBE7NRSVYUouDnvUlbnCC2t3QXPwLdxQa11GkygLSQ2bg +GeVDgq1o4GUJTcvxFlFCcpU/hEANI/DQsxNAQ/4wUGoLOlHaO3HPvwBblHA70gGe +Ux/xpG+lMAFAiB0EHEwZ4M0mClBEOQv3NzaFTWuBHtIMS8eid7M1q5qz9+rCgZSL +KBBHo0OvUbajG4CWl8SM6LUYapASGg+U17E+4xA3npwpIdsk+CbtX+vvX324n4kn +0EkrJqCjv8M1KiCKAP+UxwP00ywxOg4PN+x+dHI/I7xBvEKe/x6BltVSdGA+PlUK +02wagQKBgQDF7gdQLFIagPH7X7dBP6qEGxj/Ck9Qdz3S1gotPkVeq+1/UtQijYZ1 +j44up/0yB2B9P4kW091n+iWcyfoU5UwBua9dHvCZP3QH05LR1ZscUHxLGjDPBASt +l2xSq0hqqNWBspb1M0eCY0Yxi65iDkj3xsI2iN35BEb1FlWdR5KGvwKBgQCGS0ce +wASWbZIPU2UoKGOQkIJU6QmLy0KZbfYkpyfE8IxGttYVEQ8puNvDDNZWHNf+LP85 +c8iV6SfnWiLmu1XkG2YmJFBCCAWgJ8Mq2XQD8E+a/xcaW3NqlcC5+I2czX367j3r +69wZSxRbzR+DCfOiIkrekJImwN183ZYy2cBbKQKBgFj86IrSMmO6H5Ft+j06u5ZD +fJyF7Rz3T3NwSgkHWzbyQ4ggHEIgsRg/36P4YSzSBj6phyAdRwkNfUWdxXMJmH+a +FU7frzqnPaqbJAJ1cBRt10QI1XLtkpDdaJVObvONTtjOC3LYiEkGCzQRYeiyFXpZ +AU51gJ8JnkFotjtNR4KPAoGAehVREDlLcl0lnN0ZZspgyPk2Im6/iOA9KTH3xBZZ +ZwWu4FIyiHA7spgk4Ep5R0ttZ9oMI3SIcw/EgONGOy8uw/HMiPwWIhEc3B2JpRiO +CU6bb7JalFFyuQBudiHoyxVcY5PVovWF31CLr3DoJr4TR9+Y5H/U/XnzYCIo+w1N +exECgYBFAGKYTIeGAvhIvD5TphLpbCyeVLBIq5hRyrdRY+6Iwqdr5PGvLPKwin5+ ++4CDhWPW4spq8MYPCRiMrvRSctKt/7FhVGL2vE/0VY3TcLk14qLC+2+0lnPVgnYn +u5/wOyuHp1cIBnjeN41/pluOWFBHI9xLW3ExLtmYMiecJ8VdRA== +-----END RSA PRIVATE KEY-----` + +//nolint:gosec // Test RSA public key (generated for testing purposes only) +const testPublicKeyPem = `-----BEGIN PUBLIC KEY----- +MIIBITANBgkqhkiG9w0BAQEFAAOCAQ4AMIIBCQKCAQBn1MuM5hIfH6d3TNStI1of +Wv/gcjQ4joi9cFijEwVLuPYkF1nDKkSbaMGFUWiOTaB/H9fxmd/V2u04NlBY3av6 +m5T/sHfVSiEWAEUblh3cA34HVCmDcqyyVty5HLGJJlSs2C7W2x7yUc9ImzyDBsyj +pKOXuojJ9wN9a17D2cYU5WkXjoDC4BHid61jn9WBTtPZXSgOdirwahNzxZQSIP7D +A9T8yiZwIWPp5YesgsAPyQLCFPgMs77xz/CEUnEYQ35zI/k/mQrwKdQ/ZP8xLwQo +hUID0BIxE7G5quL069RuuCZWZkoFoPiZbp7HSryz1+19jD3rFT7eHGUYvAyCnXmX +AgMBAAE= +-----END PUBLIC KEY-----` + func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) helper := runGrafana(t) - createOptions := metav1.CreateOptions{FieldValidation: "Strict"} ctx := context.Background() + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) + + decryptService := helper.GetEnv().DecryptService + require.NotNil(t, decryptService, "decrypt service not wired properly") t.Run("should perform CRUDL requests on connection", func(t *testing.T) { connection := &unstructured.Unstructured{Object: map[string]any{ @@ -37,12 +93,12 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} // CREATE - _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + _, err := helper.CreateGithubConnection(t, ctx, connection) require.NoError(t, err, "failed to create resource") // READ @@ -60,6 +116,22 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) { require.Contains(t, output.Object, "secure", "object should contain secure") assert.Contains(t, output.Object["secure"], "privateKey", "secure should contain PrivateKey") + // Verifying token + assert.Contains(t, output.Object["secure"], "token", "token should be created") + secretName, found, err := unstructured.NestedString(output.Object, "secure", "token", "name") + require.NoError(t, err, "error getting secret name") + require.True(t, found, "secret name should exist: %v", output.Object) + decrypted, err := decryptService.Decrypt(ctx, "provisioning.grafana.app", output.GetNamespace(), secretName) + require.NoError(t, err, "decryption error") + require.Len(t, decrypted, 1) + + val := decrypted[secretName].Value() + require.NotNil(t, val) + k := val.DangerouslyExposeAndConsumeValue() + valid, err := verifyToken(t, "123456", testPublicKeyPem, k) + require.NoError(t, err, "error verifying token: %s", k) + require.True(t, valid, "token should be valid: %s", k) + // LIST list, err := helper.Connections.Resource.List(ctx, metav1.ListOptions{}) require.NoError(t, err, "failed to list resource") @@ -77,25 +149,41 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) { "spec": map[string]any{ "type": "github", "github": map[string]any{ - "appID": "456789", - "installationID": "454545", + "appID": "123456", + "installationID": "454546", }, }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} - res, err := helper.Connections.Resource.Update(ctx, updatedConnection, metav1.UpdateOptions{}) + res, err := helper.UpdateGithubConnection(t, ctx, updatedConnection) require.NoError(t, err, "failed to update resource") spec = res.Object["spec"].(map[string]any) require.Contains(t, spec, "github") githubInfo = spec["github"].(map[string]any) - assert.Equal(t, "456789", githubInfo["appID"], "appID should be updated") + assert.Equal(t, "454546", githubInfo["installationID"], "installationID should be updated") - // DELETE - require.NoError(t, helper.Connections.Resource.Delete(ctx, "connection", metav1.DeleteOptions{}), "failed to delete resource") + // DELETE - Retry delete to handle resource version conflicts + // The controller may have updated the resource after our update, changing the resource version + require.Eventually(t, func() bool { + err := helper.Connections.Resource.Delete(ctx, "connection", metav1.DeleteOptions{}) + if err != nil { + if k8serrors.IsConflict(err) { + // Resource version conflict - retry + return false + } + if k8serrors.IsNotFound(err) { + // Already deleted - success + return true + } + // Other error - fail the test + require.NoError(t, err, "failed to delete resource") + } + return true + }, 5*time.Second, 100*time.Millisecond, "should successfully delete resource") list, err = helper.Connections.Resource.List(ctx, metav1.ListOptions{}) require.NoError(t, err, "failed to list resources") assert.Equal(t, 0, len(list.Items), "should have no connections") @@ -118,7 +206,7 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} @@ -151,9 +239,12 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) { } func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + helper := runGrafana(t) createOptions := metav1.CreateOptions{FieldValidation: "Strict"} ctx := context.Background() + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) t.Run("should fail when type is empty", func(t *testing.T) { connection := &unstructured.Unstructured{Object: map[string]any{ @@ -168,13 +259,13 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) require.Error(t, err, "failed to create resource") - assert.Contains(t, err.Error(), "type must be specified") + assert.Contains(t, err.Error(), "connection type \"\" is not supported") }) t.Run("should fail when type is invalid", func(t *testing.T) { @@ -190,13 +281,57 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) require.Error(t, err, "failed to create resource") - assert.Contains(t, err.Error(), "spec.type: Unsupported value: \"some-invalid-type\"") + assert.Contains(t, err.Error(), "connection type \"some-invalid-type\" is not supported") + }) + + t.Run("should fail when type is 'git'", func(t *testing.T) { + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "git", + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "connection type \"git\" is not supported") + }) + + t.Run("should fail when type is 'local'", func(t *testing.T) { + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "local", + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "connection type \"local\" is not supported") }) t.Run("should fail when type is github but 'github' field is not there", func(t *testing.T) { @@ -212,13 +347,13 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) require.Error(t, err, "failed to create resource") - assert.Contains(t, err.Error(), "github info must be specified for GitHub connection") + assert.Contains(t, err.Error(), "invalid github connection") }) t.Run("should fail when type is github but private key is not there", func(t *testing.T) { @@ -242,7 +377,7 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { assert.Contains(t, err.Error(), "privateKey must be specified for GitHub connection") }) - t.Run("should fail when type is github but a client Secret is specified", func(t *testing.T) { + t.Run("should fail when type is github but a client Secret is also specified", func(t *testing.T) { connection := &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "provisioning.grafana.app/v0alpha1", "kind": "Connection", @@ -259,7 +394,7 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, "clientSecret": map[string]any{ "create": "someSecret", @@ -271,6 +406,100 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { assert.Contains(t, err.Error(), "clientSecret is forbidden in GitHub connection") }) + t.Run("should fail when type is github and github API is unavailable", func(t *testing.T) { + connectionFactory := helper.GetEnv().GithubConnectionFactory.(*githubConnection.Factory) + connectionFactory.Client = ghmock.NewMockedHTTPClient( + ghmock.WithRequestMatchHandler( + ghmock.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusServiceUnavailable, + }, + Message: "Service unavailable", + })) + }), + ), + ) + helper.SetGithubConnectionFactory(connectionFactory) + + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "123456", + "installationID": "454545", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "spec.token: Internal error: github is unavailable") + }) + + t.Run("should fail when type is github and returned app ID doesn't match given one", func(t *testing.T) { + var appID int64 = 123455 + appSlug := "appSlug" + connectionFactory := helper.GetEnv().GithubConnectionFactory.(*githubConnection.Factory) + connectionFactory.Client = ghmock.NewMockedHTTPClient( + ghmock.WithRequestMatch( + ghmock.GetApp, github.App{ + ID: &appID, + Slug: &appSlug, + }, + ), + ) + helper.SetGithubConnectionFactory(connectionFactory) + + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "123456", + "installationID": "454545", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "spec.appID: Invalid value: \"123456\": appID mismatch") + }) +} + +func TestIntegrationProvisioning_ConnectionEnterpriseValidation(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + if !extensions.IsEnterprise { + t.Skip("Skipping integration test when not enterprise") + } + + helper := runGrafana(t) + createOptions := metav1.CreateOptions{FieldValidation: "Strict"} + ctx := context.Background() + t.Run("should fail when type is bitbucket but 'bitbucket' field is not there", func(t *testing.T) { connection := &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "provisioning.grafana.app/v0alpha1", @@ -290,7 +519,7 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { }} _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) require.Error(t, err, "failed to create resource") - assert.Contains(t, err.Error(), "bitbucket info must be specified in Bitbucket connection") + assert.Contains(t, err.Error(), "invalid bitbucket connection") }) t.Run("should fail when type is bitbucket but client secret is not there", func(t *testing.T) { @@ -360,7 +589,7 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { }} _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) require.Error(t, err, "failed to create resource") - assert.Contains(t, err.Error(), "gitlab info must be specified in Gitlab connection") + assert.Contains(t, err.Error(), "invalid gitlab connection") }) t.Run("should fail when type is gitlab but client secret is not there", func(t *testing.T) { @@ -411,3 +640,345 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { assert.Contains(t, err.Error(), "privateKey is forbidden in Gitlab connection") }) } + +func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + namespace := "default" + + // Create typed client from REST config + restConfig := helper.Org1.Admin.NewRestConfig() + provisioningClient, err := clientset.NewForConfig(restConfig) + require.NoError(t, err) + connClient := provisioningClient.ProvisioningV0alpha1().Connections(namespace) + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) + + t.Run("health check gets updated after initial creation", func(t *testing.T) { + // Create a connection using unstructured (like other connection tests) + connUnstructured := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "test-connection-health", + "namespace": namespace, + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "12345", + "installationID": "67890", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + + createdUnstructured, err := helper.CreateGithubConnection(t, ctx, connUnstructured) + require.NoError(t, err) + require.NotNil(t, createdUnstructured) + + connName := createdUnstructured.GetName() + + t.Cleanup(func() { + _ = helper.Connections.Resource.Delete(ctx, connName, metav1.DeleteOptions{}) + }) + + // Wait for initial reconciliation - controller should update status + require.Eventually(t, func() bool { + updated, err := connClient.Get(ctx, connName, metav1.GetOptions{}) + if err != nil { + return false + } + return updated.Status.ObservedGeneration == updated.Generation && + updated.Status.Health.Checked > 0 && + updated.Status.State == provisioning.ConnectionStateConnected && + updated.Status.Health.Healthy + }, 10*time.Second, 500*time.Millisecond, "connection should be initially reconciled with health status") + + // Verify initial health check was set + initial, err := connClient.Get(ctx, connName, metav1.GetOptions{}) + require.NoError(t, err) + assert.True(t, initial.Status.Health.Healthy, "connection should be healthy") + assert.Equal(t, provisioning.ConnectionStateConnected, initial.Status.State, "connection should be connected") + assert.Greater(t, initial.Status.Health.Checked, int64(0), "health check timestamp should be set") + assert.Equal(t, initial.Generation, initial.Status.ObservedGeneration, "observed generation should match") + }) + + t.Run("health check updates when spec changes", func(t *testing.T) { + // Create a connection using unstructured + connUnstructured := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "test-connection-spec-change", + "namespace": namespace, + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "11111", + "installationID": "22222", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + + createdUnstructured, err := helper.CreateGithubConnection(t, ctx, connUnstructured) + require.NoError(t, err) + require.NotNil(t, createdUnstructured) + + connName := createdUnstructured.GetName() + + t.Cleanup(func() { + _ = helper.Connections.Resource.Delete(ctx, connName, metav1.DeleteOptions{}) + }) + + // Wait for initial reconciliation + var initialHealthChecked int64 + require.Eventually(t, func() bool { + updated, err := connClient.Get(ctx, connName, metav1.GetOptions{}) + if err != nil { + return false + } + if updated.Status.ObservedGeneration == updated.Generation { + initialHealthChecked = updated.Status.Health.Checked + return true + } + return false + }, 10*time.Second, 500*time.Millisecond, "connection should be initially reconciled") + + // Get the latest version before updating to avoid conflicts with controller updates + latestUnstructured, err := helper.Connections.Resource.Get(ctx, connName, metav1.GetOptions{}) + require.NoError(t, err) + + // Update the connection spec using the latest version + updatedUnstructured := latestUnstructured.DeepCopy() + githubSpec := updatedUnstructured.Object["spec"].(map[string]any)["github"].(map[string]any) + githubSpec["appID"] = "99999" + _, err = helper.UpdateGithubConnection(t, ctx, updatedUnstructured) + require.NoError(t, err) + + // Wait for reconciliation after spec change + require.Eventually(t, func() bool { + reconciled, err := connClient.Get(ctx, connName, metav1.GetOptions{}) + if err != nil { + return false + } + return reconciled.Status.ObservedGeneration == reconciled.Generation && + reconciled.Status.Health.Checked > initialHealthChecked + }, 10*time.Second, 500*time.Millisecond, "connection should be reconciled after spec change") + + // Verify health check was updated + final, err := connClient.Get(ctx, connName, metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, final.Generation, final.Status.ObservedGeneration, "observed generation should match generation") + assert.Greater(t, final.Status.Health.Checked, initialHealthChecked, "health check should be updated after spec change") + assert.True(t, final.Status.Health.Healthy, "connection should remain healthy") + }) +} + +func TestIntegrationProvisioning_RepositoryFieldSelectorByConnection(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + createOptions := metav1.CreateOptions{FieldValidation: "Strict"} + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) + + // Create a connection first + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "test-conn-for-field-selector", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "123456", + "installationID": "789012", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + + _, err := helper.CreateGithubConnection(t, ctx, connection) + require.NoError(t, err, "failed to create connection") + + t.Cleanup(func() { + // Clean up repositories first + _ = helper.Repositories.Resource.Delete(ctx, "repo-with-connection", metav1.DeleteOptions{}) + _ = helper.Repositories.Resource.Delete(ctx, "repo-without-connection", metav1.DeleteOptions{}) + _ = helper.Repositories.Resource.Delete(ctx, "repo-with-different-connection", metav1.DeleteOptions{}) + // Then clean up the connection + _ = helper.Connections.Resource.Delete(ctx, "test-conn-for-field-selector", metav1.DeleteOptions{}) + }) + + // Create a repository WITH the connection + repoWithConnection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Repository", + "metadata": map[string]any{ + "name": "repo-with-connection", + "namespace": "default", + }, + "spec": map[string]any{ + "title": "Repo With Connection", + "type": "local", + "sync": map[string]any{ + "enabled": false, + "target": "folder", + }, + "local": map[string]any{ + "path": helper.ProvisioningPath, + }, + "connection": map[string]any{ + "name": "test-conn-for-field-selector", + }, + }, + }} + + _, err = helper.Repositories.Resource.Create(ctx, repoWithConnection, createOptions) + require.NoError(t, err, "failed to create repository with connection") + + // Create a repository WITHOUT the connection + repoWithoutConnection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Repository", + "metadata": map[string]any{ + "name": "repo-without-connection", + "namespace": "default", + }, + "spec": map[string]any{ + "title": "Repo Without Connection", + "type": "local", + "sync": map[string]any{ + "enabled": false, + "target": "folder", + }, + "local": map[string]any{ + "path": helper.ProvisioningPath, + }, + }, + }} + + _, err = helper.Repositories.Resource.Create(ctx, repoWithoutConnection, createOptions) + require.NoError(t, err, "failed to create repository without connection") + + // Create a repository with a DIFFERENT connection name (non-existent) + repoWithDifferentConnection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Repository", + "metadata": map[string]any{ + "name": "repo-with-different-connection", + "namespace": "default", + }, + "spec": map[string]any{ + "title": "Repo With Different Connection", + "type": "local", + "sync": map[string]any{ + "enabled": false, + "target": "folder", + }, + "local": map[string]any{ + "path": helper.ProvisioningPath, + }, + "connection": map[string]any{ + "name": "some-other-connection", + }, + }, + }} + + _, err = helper.Repositories.Resource.Create(ctx, repoWithDifferentConnection, createOptions) + require.NoError(t, err, "failed to create repository with different connection") + + t.Run("filter repositories by spec.connection.name", func(t *testing.T) { + // List repositories with field selector for the specific connection + list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{ + FieldSelector: "spec.connection.name=test-conn-for-field-selector", + }) + require.NoError(t, err, "failed to list repositories with field selector") + + // Should only return the repository with the matching connection + assert.Len(t, list.Items, 1, "should return exactly one repository") + assert.Equal(t, "repo-with-connection", list.Items[0].GetName(), "should return the correct repository") + }) + + t.Run("filter repositories by non-existent connection returns empty", func(t *testing.T) { + // List repositories with field selector for a non-existent connection + list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{ + FieldSelector: "spec.connection.name=non-existent-connection", + }) + require.NoError(t, err, "failed to list repositories with field selector") + + // Should return empty list + assert.Len(t, list.Items, 0, "should return no repositories for non-existent connection") + }) + + t.Run("filter repositories by empty connection name", func(t *testing.T) { + // List repositories with field selector for empty connection (repos without connection) + list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{ + FieldSelector: "spec.connection.name=", + }) + require.NoError(t, err, "failed to list repositories with empty connection field selector") + + // Should return the repository without a connection + assert.Len(t, list.Items, 1, "should return exactly one repository without connection") + assert.Equal(t, "repo-without-connection", list.Items[0].GetName(), "should return the repository without connection") + }) + + t.Run("list all repositories without field selector", func(t *testing.T) { + // List all repositories without field selector + list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err, "failed to list all repositories") + + // Should return all three repositories + assert.Len(t, list.Items, 3, "should return all three repositories") + + names := make([]string, len(list.Items)) + for i, item := range list.Items { + names[i] = item.GetName() + } + assert.Contains(t, names, "repo-with-connection") + assert.Contains(t, names, "repo-without-connection") + assert.Contains(t, names, "repo-with-different-connection") + }) +} + +func verifyToken(t *testing.T, appID, publicKey, token string) (bool, error) { + t.Helper() + + // Parse the private key + key, err := jwt.ParseRSAPublicKeyFromPEM([]byte(publicKey)) + if err != nil { + return false, err + } + + parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (any, error) { + return key, nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()})) + if err != nil { + return false, err + } + + claims, ok := parsedToken.Claims.(jwt.MapClaims) + if !ok || !parsedToken.Valid { + return false, fmt.Errorf("invalid token") + } + + return claims.VerifyIssuer(appID, true), nil +} diff --git a/pkg/tests/apis/provisioning/helper_test.go b/pkg/tests/apis/provisioning/helper_test.go index 791ac4b8a20..34b57afe3c6 100644 --- a/pkg/tests/apis/provisioning/helper_test.go +++ b/pkg/tests/apis/provisioning/helper_test.go @@ -10,11 +10,14 @@ import ( "os" "path" "path/filepath" + "strconv" "strings" "testing" "text/template" "time" + "github.com/google/go-github/v70/github" + "github.com/grafana/grafana/pkg/extensions" ghmock "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -30,6 +33,7 @@ import ( 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/apps/provisioning/pkg/apis/provisioning/v0alpha1" + githubConnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -699,13 +703,18 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper // (instance is needed for export jobs, folder for most operations) ProvisioningAllowedTargets: []string{"folder", "instance"}, } + + if extensions.IsEnterprise { + opts.ProvisioningRepositoryTypes = []string{"local", "github", "gitlab", "bitbucket"} + } + for _, o := range options { o(&opts) } helper := apis.NewK8sTestHelper(t, opts) - // FIXME: keeping this line here to keep the dependency around until we have tests which use this again. - helper.GetEnv().GitHubFactory.Client = ghmock.NewMockedHTTPClient() + // FIXME: keeping these lines here to keep the dependency around until we have tests which use this again. + helper.GetEnv().GithubRepoFactory.Client = ghmock.NewMockedHTTPClient() repositories := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Admin, @@ -973,6 +982,79 @@ func (h *provisioningTestHelper) CleanupAllRepos(t *testing.T) { }, waitTimeoutDefault, waitIntervalDefault, "repositories should be cleaned up between subtests") } +func (h *provisioningTestHelper) CreateGithubConnection( + t *testing.T, + ctx context.Context, + connection *unstructured.Unstructured, +) (*unstructured.Unstructured, error) { + t.Helper() + + err := h.setGithubClient(t, connection) + if err != nil { + return nil, err + } + + return h.Connections.Resource.Create(ctx, connection, metav1.CreateOptions{FieldValidation: "Strict"}) +} + +func (h *provisioningTestHelper) UpdateGithubConnection( + t *testing.T, + ctx context.Context, + connection *unstructured.Unstructured, +) (*unstructured.Unstructured, error) { + t.Helper() + + err := h.setGithubClient(t, connection) + if err != nil { + return nil, err + } + + return h.Connections.Resource.Update(ctx, connection, metav1.UpdateOptions{FieldValidation: "Strict"}) +} + +func (h *provisioningTestHelper) setGithubClient(t *testing.T, connection *unstructured.Unstructured) error { + t.Helper() + + objectSpec := connection.Object["spec"].(map[string]interface{}) + githubObj := objectSpec["github"].(map[string]interface{}) + appID := githubObj["appID"].(string) + id, err := strconv.ParseInt(appID, 10, 64) + if err != nil { + return err + } + + appSlug := "someSlug" + connectionFactory := h.GetEnv().GithubConnectionFactory.(*githubConnection.Factory) + connectionFactory.Client = ghmock.NewMockedHTTPClient( + ghmock.WithRequestMatchHandler( + ghmock.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + app := github.App{ + ID: &id, + Slug: &appSlug, + } + _, _ = w.Write(ghmock.MustMarshal(app)) + }), + ), + ghmock.WithRequestMatchHandler( + ghmock.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("installation_id") + idInt, _ := strconv.ParseInt(id, 10, 64) + w.WriteHeader(http.StatusOK) + installation := github.Installation{ + ID: &idInt, + } + _, _ = w.Write(ghmock.MustMarshal(installation)) + }), + ), + ) + h.SetGithubConnectionFactory(connectionFactory) + + return nil +} + func postHelper(t *testing.T, helper apis.K8sTestHelper, path string, body interface{}, user apis.User) (map[string]interface{}, int, error) { return requestHelper(t, helper, http.MethodPost, path, body, user) } diff --git a/pkg/tests/apis/provisioning/repository_test.go b/pkg/tests/apis/provisioning/repository_test.go index f2447e71d23..0f3509602b7 100644 --- a/pkg/tests/apis/provisioning/repository_test.go +++ b/pkg/tests/apis/provisioning/repository_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/extensions" provisioningAPIServer "github.com/grafana/grafana/pkg/registry/apis/provisioning" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -149,10 +150,19 @@ func TestIntegrationProvisioning_CreatingAndGetting(t *testing.T) { } } - assert.ElementsMatch(collect, []provisioning.RepositoryType{ - provisioning.LocalRepositoryType, - provisioning.GitHubRepositoryType, - }, settings.AvailableRepositoryTypes) + if extensions.IsEnterprise { + assert.ElementsMatch(collect, []provisioning.RepositoryType{ + provisioning.LocalRepositoryType, + provisioning.GitHubRepositoryType, + provisioning.BitbucketRepositoryType, + provisioning.GitLabRepositoryType, + }, settings.AvailableRepositoryTypes) + } else { + assert.ElementsMatch(collect, []provisioning.RepositoryType{ + provisioning.LocalRepositoryType, + provisioning.GitHubRepositoryType, + }, settings.AvailableRepositoryTypes) + } }, time.Second*10, time.Millisecond*100, "Expected settings to match") }) diff --git a/pkg/tests/apis/zanzana_reconcile.go b/pkg/tests/apis/zanzana_reconcile.go index f8a5673fed7..d63d46491fe 100644 --- a/pkg/tests/apis/zanzana_reconcile.go +++ b/pkg/tests/apis/zanzana_reconcile.go @@ -18,7 +18,10 @@ import ( const zanzanaReconcileLastSuccessMetric = "grafana_zanzana_reconcile_last_success_timestamp_seconds" -// AwaitZanzanaReconcileNext waits for the next Zanzana reconciliation cycle to complete. +// AwaitZanzanaReconcileNext waits for a Zanzana reconciliation cycle whose last-success timestamp +// has been incremented from its current value. This ensures a reconciliation has occurred after +// this function is called. +// // It is a no-op unless the `zanzana` feature toggle is enabled for the running test env. func AwaitZanzanaReconcileNext(t *testing.T, helper *K8sTestHelper) { t.Helper() @@ -31,18 +34,14 @@ func AwaitZanzanaReconcileNext(t *testing.T, helper *K8sTestHelper) { return } - prev, ok := getZanzanaReconcileLastSuccessTimestampSeconds(t, helper) - if !ok { - prev = 0 - } - + baselineTimestamp, _ := getZanzanaReconcileLastSuccessTimestampSeconds(t, helper) require.EventuallyWithT(t, func(c *assert.CollectT) { ts, ok := getZanzanaReconcileLastSuccessTimestampSeconds(t, helper) assert.True(c, ok, "expected to find %s in /metrics", zanzanaReconcileLastSuccessMetric) if !ok { return } - assert.Greater(c, ts, prev, "expected %s (%v) > %v", zanzanaReconcileLastSuccessMetric, ts, prev) + assert.Greater(c, ts, baselineTimestamp, "expected %s (%v) > baseline (%v)", zanzanaReconcileLastSuccessMetric, ts, baselineTimestamp) }, 30*time.Second, 50*time.Millisecond) } diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index ebf82f17864..81e243f42b5 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -320,8 +320,9 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { require.NoError(t, err) _, err = openFeatureSect.NewKey("enable_api", strconv.FormatBool(opts.OpenFeatureAPIEnabled)) require.NoError(t, err) - if !opts.OpenFeatureAPIEnabled { - _, err = openFeatureSect.NewKey("provider", "static") // in practice, APIEnabled being false goes with features-service type, but trying to make tests work + + if opts.OpenFeatureAPIEnabled { + _, err = openFeatureSect.NewKey("provider", "static") require.NoError(t, err) _, err = openFeatureSect.NewKey("targetingKey", "grafana") require.NoError(t, err) @@ -369,6 +370,39 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { require.NoError(t, err) } + if opts.DisableZanzanaServerCheckQueryCache { + zanzanaServerSect, err := cfg.NewSection("zanzana.server") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("check_cache_limit", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("cache_controller_enabled", "false") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("cache_controller_ttl", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("check_query_cache_enabled", "false") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("check_query_cache_ttl", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("check_iterator_cache_enabled", "false") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("check_iterator_cache_max_results", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("check_iterator_cache_ttl", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("list_objects_iterator_cache_enabled", "false") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("list_objects_iterator_cache_max_results", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("list_objects_iterator_cache_ttl", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("shared_iterator_enabled", "false") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("shared_iterator_limit", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("shared_iterator_ttl", "0") + require.NoError(t, err) + } + analyticsSect, err := cfg.NewSection("analytics") require.NoError(t, err) _, err = analyticsSect.NewKey("intercom_secret", "intercom_secret_at_config") @@ -556,6 +590,8 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { require.NoError(t, err) _, err = section.NewKey("enableMigration", fmt.Sprintf("%t", v.EnableMigration)) require.NoError(t, err) + _, err = section.NewKey("autoMigrationThreshold", fmt.Sprintf("%d", v.AutoMigrationThreshold)) + require.NoError(t, err) } } if opts.UnifiedStorageEnableSearch { @@ -586,6 +622,12 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { _, err = provisioningSect.NewKey("allowed_targets", strings.Join(opts.ProvisioningAllowedTargets, "|")) require.NoError(t, err) } + if len(opts.ProvisioningRepositoryTypes) > 0 { + provisioningSect, err := getOrCreateSection("provisioning") + require.NoError(t, err) + _, err = provisioningSect.NewKey("repository_types", strings.Join(opts.ProvisioningRepositoryTypes, "|")) + require.NoError(t, err) + } if opts.EnableSCIM { scimSection, err := getOrCreateSection("auth.scim") require.NoError(t, err) @@ -638,9 +680,14 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { require.NoError(t, err) _, err = dbSection.NewKey("query_retries", fmt.Sprintf("%d", queryRetries)) require.NoError(t, err) - _, err = dbSection.NewKey("max_open_conn", "2") + maxConns := opts.DBMaxConns + if maxConns <= 0 { + maxConns = 2 + } + + _, err = dbSection.NewKey("max_open_conn", fmt.Sprintf("%d", maxConns)) require.NoError(t, err) - _, err = dbSection.NewKey("max_idle_conn", "2") + _, err = dbSection.NewKey("max_idle_conn", fmt.Sprintf("%d", maxConns)) require.NoError(t, err) cfgPath := filepath.Join(cfgDir, "test.ini") @@ -690,6 +737,7 @@ type GrafanaOpts struct { UnifiedStorageMaxPageSizeBytes int PermittedProvisioningPaths string ProvisioningAllowedTargets []string + ProvisioningRepositoryTypes []string GrafanaComSSOAPIToken string LicensePath string EnableRecordingRules bool @@ -703,6 +751,10 @@ type GrafanaOpts struct { DisableAuthZClientCache bool ZanzanaReconciliationInterval time.Duration DisableZanzanaCache bool + DisableZanzanaServerCheckQueryCache bool + + // If set to 0, the default (2) is used. + DBMaxConns int // Allow creating grafana dir beforehand Dir string diff --git a/pkg/tsdb/cloud-monitoring/cloudmonitoring.go b/pkg/tsdb/cloud-monitoring/cloudmonitoring.go index c88feb30a1a..37401de02c4 100644 --- a/pkg/tsdb/cloud-monitoring/cloudmonitoring.go +++ b/pkg/tsdb/cloud-monitoring/cloudmonitoring.go @@ -92,7 +92,7 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque }, nil } - url := fmt.Sprintf("%v/v3/projects/%v/metricDescriptors", dsInfo.services[cloudMonitor].url, defaultProject) + url := fmt.Sprintf("%s/v3/projects/%s/metricDescriptors", dsInfo.services[cloudMonitor].url, defaultProject) request, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err @@ -139,6 +139,7 @@ type datasourceInfo struct { defaultProject string clientEmail string tokenUri string + universeDomain string services map[string]datasourceService privateKey string usingImpersonation bool @@ -150,6 +151,7 @@ type datasourceJSONData struct { DefaultProject string `json:"defaultProject"` ClientEmail string `json:"clientEmail"` TokenURI string `json:"tokenUri"` + UniverseDomain string `json:"universeDomain"` UsingImpersonation bool `json:"usingImpersonation"` ServiceAccountToImpersonate string `json:"serviceAccountToImpersonate"` } @@ -179,6 +181,7 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst defaultProject: jsonData.DefaultProject, clientEmail: jsonData.ClientEmail, tokenUri: jsonData.TokenURI, + universeDomain: jsonData.UniverseDomain, usingImpersonation: jsonData.UsingImpersonation, serviceAccountToImpersonate: jsonData.ServiceAccountToImpersonate, services: map[string]datasourceService{}, @@ -194,13 +197,13 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst return nil, err } - for name, info := range routes { + for name := range routes { client, err := newHTTPClient(dsInfo, opts, &httpClientProvider, name) if err != nil { return nil, err } dsInfo.services[name] = datasourceService{ - url: info.url, + url: buildURL(name, dsInfo.universeDomain), client: client, } } diff --git a/pkg/tsdb/cloud-monitoring/httpclient.go b/pkg/tsdb/cloud-monitoring/httpclient.go index 5a57e5f4ac8..aa0ed084194 100644 --- a/pkg/tsdb/cloud-monitoring/httpclient.go +++ b/pkg/tsdb/cloud-monitoring/httpclient.go @@ -23,12 +23,12 @@ type routeInfo struct { var routes = map[string]routeInfo{ cloudMonitor: { method: "GET", - url: "https://monitoring.googleapis.com", + url: "https://monitoring.", scopes: []string{cloudMonitorScope}, }, resourceManager: { method: "GET", - url: "https://cloudresourcemanager.googleapis.com", + url: "https://cloudresourcemanager.", scopes: []string{resourceManagerScope}, }, } @@ -68,6 +68,13 @@ func getMiddleware(model *datasourceInfo, routePath string) (httpclient.Middlewa return tokenprovider.AuthMiddleware(provider), nil } +func buildURL(route string, universeDomain string) string { + if universeDomain == "" { + universeDomain = "googleapis.com" + } + return routes[route].url + universeDomain +} + func newHTTPClient(model *datasourceInfo, opts httpclient.Options, clientProvider *httpclient.Provider, route string) (*http.Client, error) { m, err := getMiddleware(model, route) if err != nil { diff --git a/pkg/tsdb/cloud-monitoring/resource_handler_test.go b/pkg/tsdb/cloud-monitoring/resource_handler_test.go index 5f315ef9a59..64742233453 100644 --- a/pkg/tsdb/cloud-monitoring/resource_handler_test.go +++ b/pkg/tsdb/cloud-monitoring/resource_handler_test.go @@ -111,7 +111,7 @@ func Test_setRequestVariables(t *testing.T) { im: &fakeInstance{ services: map[string]datasourceService{ cloudMonitor: { - url: routes[cloudMonitor].url, + url: buildURL(cloudMonitor, "googleapis.com"), client: &http.Client{}, }, }, diff --git a/pkg/tsdb/elasticsearch/aggregation_factory.go b/pkg/tsdb/elasticsearch/aggregation_factory.go index cc3e597e50b..3f702b745b4 100644 --- a/pkg/tsdb/elasticsearch/aggregation_factory.go +++ b/pkg/tsdb/elasticsearch/aggregation_factory.go @@ -3,8 +3,8 @@ package elasticsearch import ( "regexp" - "github.com/grafana/grafana/pkg/components/simplejson" es "github.com/grafana/grafana/pkg/tsdb/elasticsearch/client" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) // addDateHistogramAgg adds a date histogram aggregation to the aggregation builder diff --git a/pkg/tsdb/elasticsearch/client/client.go b/pkg/tsdb/elasticsearch/client/client.go index fbb3e09f092..12e1a8f5df4 100644 --- a/pkg/tsdb/elasticsearch/client/client.go +++ b/pkg/tsdb/elasticsearch/client/client.go @@ -16,7 +16,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" - "github.com/grafana/grafana/pkg/services/featuremgmt" ) // Used in logging to mark a stage @@ -35,6 +34,7 @@ type DatasourceInfo struct { Interval string MaxConcurrentShardRequests int64 IncludeFrozen bool + ClusterInfo ClusterInfo } type ConfiguredFields struct { @@ -159,7 +159,7 @@ func (c *baseClientImpl) ExecuteMultisearch(r *MultiSearchRequest) (*MultiSearch resSpan.End() }() - improvedParsingEnabled := isFeatureEnabled(c.ctx, featuremgmt.FlagElasticsearchImprovedParsing) + improvedParsingEnabled := isFeatureEnabled(c.ctx, "elasticsearchImprovedParsing") msr, err := c.parser.parseMultiSearchResponse(res.Body, improvedParsingEnabled) if err != nil { return nil, err @@ -197,7 +197,11 @@ func (c *baseClientImpl) createMultiSearchRequests(searchRequests []*SearchReque func (c *baseClientImpl) getMultiSearchQueryParameters() string { var qs []string - qs = append(qs, fmt.Sprintf("max_concurrent_shard_requests=%d", c.ds.MaxConcurrentShardRequests)) + // if the build flavor is not serverless, we can use the max concurrent shard requests + // this is because serverless clusters do not support max concurrent shard requests + if !c.ds.ClusterInfo.IsServerless() && c.ds.MaxConcurrentShardRequests > 0 { + qs = append(qs, fmt.Sprintf("max_concurrent_shard_requests=%d", c.ds.MaxConcurrentShardRequests)) + } if c.ds.IncludeFrozen { qs = append(qs, "ignore_throttled=false") diff --git a/pkg/tsdb/elasticsearch/client/client_test.go b/pkg/tsdb/elasticsearch/client/client_test.go index 8f257873232..b8afb048c00 100644 --- a/pkg/tsdb/elasticsearch/client/client_test.go +++ b/pkg/tsdb/elasticsearch/client/client_test.go @@ -15,7 +15,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) func TestClient_ExecuteMultisearch(t *testing.T) { diff --git a/pkg/tsdb/elasticsearch/client/cluster_info.go b/pkg/tsdb/elasticsearch/client/cluster_info.go new file mode 100644 index 00000000000..eb89189804f --- /dev/null +++ b/pkg/tsdb/elasticsearch/client/cluster_info.go @@ -0,0 +1,51 @@ +package es + +import ( + "encoding/json" + "fmt" + "net/http" +) + +type VersionInfo struct { + BuildFlavor string `json:"build_flavor"` +} + +// ClusterInfo represents Elasticsearch cluster information returned from the root endpoint. +// It is used to determine cluster capabilities and configuration like whether the cluster is serverless. +type ClusterInfo struct { + Version VersionInfo `json:"version"` +} + +const ( + BuildFlavorServerless = "serverless" +) + +// GetClusterInfo fetches cluster information from the Elasticsearch root endpoint. +// It returns the cluster build flavor which is used to determine if the cluster is serverless. +func GetClusterInfo(httpCli *http.Client, url string) (clusterInfo ClusterInfo, err error) { + resp, err := httpCli.Get(url) + if err != nil { + return ClusterInfo{}, fmt.Errorf("error getting ES cluster info: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return ClusterInfo{}, fmt.Errorf("unexpected status code %d getting ES cluster info", resp.StatusCode) + } + + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("error closing response body: %w", closeErr) + } + }() + + err = json.NewDecoder(resp.Body).Decode(&clusterInfo) + if err != nil { + return ClusterInfo{}, fmt.Errorf("error decoding ES cluster info: %w", err) + } + + return clusterInfo, nil +} + +func (ci ClusterInfo) IsServerless() bool { + return ci.Version.BuildFlavor == BuildFlavorServerless +} diff --git a/pkg/tsdb/elasticsearch/client/cluster_info_test.go b/pkg/tsdb/elasticsearch/client/cluster_info_test.go new file mode 100644 index 00000000000..0fdcc46e813 --- /dev/null +++ b/pkg/tsdb/elasticsearch/client/cluster_info_test.go @@ -0,0 +1,188 @@ +package es + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetClusterInfo(t *testing.T) { + t.Run("Should successfully get cluster info", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + _, err := rw.Write([]byte(`{ + "name": "test-cluster", + "cluster_name": "elasticsearch", + "cluster_uuid": "abc123", + "version": { + "number": "8.0.0", + "build_flavor": "default", + "build_type": "tar", + "build_hash": "abc123", + "build_date": "2023-01-01T00:00:00.000Z", + "build_snapshot": false, + "lucene_version": "9.0.0" + } + }`)) + require.NoError(t, err) + })) + + t.Cleanup(func() { + ts.Close() + }) + + clusterInfo, err := GetClusterInfo(ts.Client(), ts.URL) + + require.NoError(t, err) + require.NotNil(t, clusterInfo) + assert.Equal(t, "default", clusterInfo.Version.BuildFlavor) + }) + + t.Run("Should successfully get serverless cluster info", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + _, err := rw.Write([]byte(`{ + "name": "serverless-cluster", + "cluster_name": "elasticsearch", + "cluster_uuid": "def456", + "version": { + "number": "8.11.0", + "build_flavor": "serverless", + "build_type": "docker", + "build_hash": "def456", + "build_date": "2023-11-01T00:00:00.000Z", + "build_snapshot": false, + "lucene_version": "9.8.0" + } + }`)) + require.NoError(t, err) + })) + + t.Cleanup(func() { + ts.Close() + }) + + clusterInfo, err := GetClusterInfo(ts.Client(), ts.URL) + + require.NoError(t, err) + require.NotNil(t, clusterInfo) + assert.Equal(t, "serverless", clusterInfo.Version.BuildFlavor) + assert.True(t, clusterInfo.IsServerless()) + }) + + t.Run("Should return error when HTTP request fails", func(t *testing.T) { + clusterInfo, err := GetClusterInfo(http.DefaultClient, "http://invalid-url-that-does-not-exist.local:9999") + + require.Error(t, err) + require.Equal(t, ClusterInfo{}, clusterInfo) + assert.Contains(t, err.Error(), "error getting ES cluster info") + }) + + t.Run("Should return error when response body is invalid JSON", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + _, err := rw.Write([]byte(`{"invalid json`)) + require.NoError(t, err) + })) + + t.Cleanup(func() { + ts.Close() + }) + + clusterInfo, err := GetClusterInfo(ts.Client(), ts.URL) + + require.Error(t, err) + require.Equal(t, ClusterInfo{}, clusterInfo) + assert.Contains(t, err.Error(), "error decoding ES cluster info") + }) + + t.Run("Should handle empty version object", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + _, err := rw.Write([]byte(`{ + "name": "test-cluster", + "version": {} + }`)) + require.NoError(t, err) + })) + + t.Cleanup(func() { + ts.Close() + }) + + clusterInfo, err := GetClusterInfo(ts.Client(), ts.URL) + + require.NoError(t, err) + require.Equal(t, ClusterInfo{}, clusterInfo) + assert.Equal(t, "", clusterInfo.Version.BuildFlavor) + assert.False(t, clusterInfo.IsServerless()) + }) + + t.Run("Should handle HTTP error status codes", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.WriteHeader(http.StatusUnauthorized) + _, err := rw.Write([]byte(`{"error": "Unauthorized"}`)) + require.NoError(t, err) + })) + + t.Cleanup(func() { + ts.Close() + }) + + clusterInfo, err := GetClusterInfo(ts.Client(), ts.URL) + + require.Error(t, err) + require.Equal(t, ClusterInfo{}, clusterInfo) + assert.Contains(t, err.Error(), "unexpected status code 401 getting ES cluster info") + }) +} + +func TestClusterInfo_IsServerless(t *testing.T) { + t.Run("Should return true when build_flavor is serverless", func(t *testing.T) { + clusterInfo := ClusterInfo{ + Version: VersionInfo{ + BuildFlavor: BuildFlavorServerless, + }, + } + + assert.True(t, clusterInfo.IsServerless()) + }) + + t.Run("Should return false when build_flavor is default", func(t *testing.T) { + clusterInfo := ClusterInfo{ + Version: VersionInfo{ + BuildFlavor: "default", + }, + } + + assert.False(t, clusterInfo.IsServerless()) + }) + + t.Run("Should return false when build_flavor is empty", func(t *testing.T) { + clusterInfo := ClusterInfo{ + Version: VersionInfo{ + BuildFlavor: "", + }, + } + + assert.False(t, clusterInfo.IsServerless()) + }) + + t.Run("Should return false when build_flavor is unknown value", func(t *testing.T) { + clusterInfo := ClusterInfo{ + Version: VersionInfo{ + BuildFlavor: "unknown", + }, + } + + assert.False(t, clusterInfo.IsServerless()) + }) + + t.Run("should return false when cluster info is empty", func(t *testing.T) { + clusterInfo := ClusterInfo{} + assert.False(t, clusterInfo.IsServerless()) + }) +} diff --git a/pkg/tsdb/elasticsearch/client/search_request_test.go b/pkg/tsdb/elasticsearch/client/search_request_test.go index 80113b4996e..7e2c592dddb 100644 --- a/pkg/tsdb/elasticsearch/client/search_request_test.go +++ b/pkg/tsdb/elasticsearch/client/search_request_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) func TestSearchRequest(t *testing.T) { diff --git a/pkg/tsdb/elasticsearch/data_query_processor.go b/pkg/tsdb/elasticsearch/data_query_processor.go index 288d6ce30de..4dc0afb109a 100644 --- a/pkg/tsdb/elasticsearch/data_query_processor.go +++ b/pkg/tsdb/elasticsearch/data_query_processor.go @@ -6,8 +6,8 @@ import ( "strconv" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/components/simplejson" es "github.com/grafana/grafana/pkg/tsdb/elasticsearch/client" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) // processQuery processes a single query and adds it to the multi-search request builder diff --git a/pkg/tsdb/elasticsearch/data_query_settings.go b/pkg/tsdb/elasticsearch/data_query_settings.go index fe286ccaeda..519eb6dc96d 100644 --- a/pkg/tsdb/elasticsearch/data_query_settings.go +++ b/pkg/tsdb/elasticsearch/data_query_settings.go @@ -3,7 +3,7 @@ package elasticsearch import ( "strconv" - "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) // setFloatPath converts a string value at the specified path to float64 diff --git a/pkg/tsdb/elasticsearch/elasticsearch.go b/pkg/tsdb/elasticsearch/elasticsearch.go index 40073bf1740..0432bbcee20 100644 --- a/pkg/tsdb/elasticsearch/elasticsearch.go +++ b/pkg/tsdb/elasticsearch/elasticsearch.go @@ -88,6 +88,14 @@ func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.Ins httpCliOpts.SigV4.Service = "es" } + apiKeyAuth, ok := jsonData["apiKeyAuth"].(bool) + if ok && apiKeyAuth { + apiKey := settings.DecryptedSecureJSONData["apiKey"] + if apiKey != "" { + httpCliOpts.Header.Add("Authorization", "ApiKey "+apiKey) + } + } + httpCli, err := httpClientProvider.New(httpCliOpts) if err != nil { return nil, err @@ -151,6 +159,11 @@ func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.Ins includeFrozen = false } + clusterInfo, err := es.GetClusterInfo(httpCli, settings.URL) + if err != nil { + return nil, err + } + configuredFields := es.ConfiguredFields{ TimeField: timeField, LogLevelField: logLevelField, @@ -166,6 +179,7 @@ func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.Ins ConfiguredFields: configuredFields, Interval: interval, IncludeFrozen: includeFrozen, + ClusterInfo: clusterInfo, } return model, nil } diff --git a/pkg/tsdb/elasticsearch/elasticsearch_test.go b/pkg/tsdb/elasticsearch/elasticsearch_test.go index 8ab3cabc7e5..35ec1f814ce 100644 --- a/pkg/tsdb/elasticsearch/elasticsearch_test.go +++ b/pkg/tsdb/elasticsearch/elasticsearch_test.go @@ -3,6 +3,8 @@ package elasticsearch import ( "context" "encoding/json" + "net/http" + "net/http/httptest" "testing" "github.com/grafana/grafana-plugin-sdk-go/backend" @@ -18,8 +20,26 @@ type datasourceInfo struct { Interval string `json:"interval"` } +// mockElasticsearchServer creates a test HTTP server that mocks Elasticsearch cluster info endpoint +func mockElasticsearchServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + // Return a mock Elasticsearch cluster info response + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "version": map[string]interface{}{ + "build_flavor": "serverless", + "number": "8.0.0", + }, + }) + })) +} + func TestNewInstanceSettings(t *testing.T) { t.Run("fields exist", func(t *testing.T) { + server := mockElasticsearchServer() + defer server.Close() + dsInfo := datasourceInfo{ TimeField: "@timestamp", MaxConcurrentShardRequests: 5, @@ -28,6 +48,7 @@ func TestNewInstanceSettings(t *testing.T) { require.NoError(t, err) dsSettings := backend.DataSourceInstanceSettings{ + URL: server.URL, JSONData: json.RawMessage(settingsJSON), } @@ -37,6 +58,9 @@ func TestNewInstanceSettings(t *testing.T) { t.Run("timeField", func(t *testing.T) { t.Run("is nil", func(t *testing.T) { + server := mockElasticsearchServer() + defer server.Close() + dsInfo := datasourceInfo{ MaxConcurrentShardRequests: 5, Interval: "Daily", @@ -46,6 +70,7 @@ func TestNewInstanceSettings(t *testing.T) { require.NoError(t, err) dsSettings := backend.DataSourceInstanceSettings{ + URL: server.URL, JSONData: json.RawMessage(settingsJSON), } @@ -54,6 +79,9 @@ func TestNewInstanceSettings(t *testing.T) { }) t.Run("is empty", func(t *testing.T) { + server := mockElasticsearchServer() + defer server.Close() + dsInfo := datasourceInfo{ MaxConcurrentShardRequests: 5, Interval: "Daily", @@ -64,6 +92,7 @@ func TestNewInstanceSettings(t *testing.T) { require.NoError(t, err) dsSettings := backend.DataSourceInstanceSettings{ + URL: server.URL, JSONData: json.RawMessage(settingsJSON), } @@ -74,6 +103,9 @@ func TestNewInstanceSettings(t *testing.T) { t.Run("maxConcurrentShardRequests", func(t *testing.T) { t.Run("no maxConcurrentShardRequests", func(t *testing.T) { + server := mockElasticsearchServer() + defer server.Close() + dsInfo := datasourceInfo{ TimeField: "@timestamp", } @@ -81,6 +113,7 @@ func TestNewInstanceSettings(t *testing.T) { require.NoError(t, err) dsSettings := backend.DataSourceInstanceSettings{ + URL: server.URL, JSONData: json.RawMessage(settingsJSON), } @@ -90,6 +123,9 @@ func TestNewInstanceSettings(t *testing.T) { }) t.Run("string maxConcurrentShardRequests", func(t *testing.T) { + server := mockElasticsearchServer() + defer server.Close() + dsInfo := datasourceInfo{ TimeField: "@timestamp", MaxConcurrentShardRequests: "10", @@ -98,6 +134,7 @@ func TestNewInstanceSettings(t *testing.T) { require.NoError(t, err) dsSettings := backend.DataSourceInstanceSettings{ + URL: server.URL, JSONData: json.RawMessage(settingsJSON), } @@ -107,6 +144,9 @@ func TestNewInstanceSettings(t *testing.T) { }) t.Run("number maxConcurrentShardRequests", func(t *testing.T) { + server := mockElasticsearchServer() + defer server.Close() + dsInfo := datasourceInfo{ TimeField: "@timestamp", MaxConcurrentShardRequests: 10, @@ -115,6 +155,7 @@ func TestNewInstanceSettings(t *testing.T) { require.NoError(t, err) dsSettings := backend.DataSourceInstanceSettings{ + URL: server.URL, JSONData: json.RawMessage(settingsJSON), } @@ -124,6 +165,9 @@ func TestNewInstanceSettings(t *testing.T) { }) t.Run("zero maxConcurrentShardRequests", func(t *testing.T) { + server := mockElasticsearchServer() + defer server.Close() + dsInfo := datasourceInfo{ TimeField: "@timestamp", MaxConcurrentShardRequests: 0, @@ -132,6 +176,7 @@ func TestNewInstanceSettings(t *testing.T) { require.NoError(t, err) dsSettings := backend.DataSourceInstanceSettings{ + URL: server.URL, JSONData: json.RawMessage(settingsJSON), } @@ -141,6 +186,9 @@ func TestNewInstanceSettings(t *testing.T) { }) t.Run("negative maxConcurrentShardRequests", func(t *testing.T) { + server := mockElasticsearchServer() + defer server.Close() + dsInfo := datasourceInfo{ TimeField: "@timestamp", MaxConcurrentShardRequests: -10, @@ -149,6 +197,7 @@ func TestNewInstanceSettings(t *testing.T) { require.NoError(t, err) dsSettings := backend.DataSourceInstanceSettings{ + URL: server.URL, JSONData: json.RawMessage(settingsJSON), } @@ -158,6 +207,9 @@ func TestNewInstanceSettings(t *testing.T) { }) t.Run("float maxConcurrentShardRequests", func(t *testing.T) { + server := mockElasticsearchServer() + defer server.Close() + dsInfo := datasourceInfo{ TimeField: "@timestamp", MaxConcurrentShardRequests: 10.5, @@ -166,6 +218,7 @@ func TestNewInstanceSettings(t *testing.T) { require.NoError(t, err) dsSettings := backend.DataSourceInstanceSettings{ + URL: server.URL, JSONData: json.RawMessage(settingsJSON), } @@ -175,6 +228,9 @@ func TestNewInstanceSettings(t *testing.T) { }) t.Run("invalid maxConcurrentShardRequests", func(t *testing.T) { + server := mockElasticsearchServer() + defer server.Close() + dsInfo := datasourceInfo{ TimeField: "@timestamp", MaxConcurrentShardRequests: "invalid", @@ -183,6 +239,7 @@ func TestNewInstanceSettings(t *testing.T) { require.NoError(t, err) dsSettings := backend.DataSourceInstanceSettings{ + URL: server.URL, JSONData: json.RawMessage(settingsJSON), } diff --git a/pkg/tsdb/elasticsearch/healthcheck.go b/pkg/tsdb/elasticsearch/healthcheck.go index 928945691de..cb5d0a866db 100644 --- a/pkg/tsdb/elasticsearch/healthcheck.go +++ b/pkg/tsdb/elasticsearch/healthcheck.go @@ -28,7 +28,6 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque Message: "Health check failed: Failed to get data source info", }, nil } - healthStatusUrl, err := url.Parse(ds.URL) if err != nil { logger.Error("Failed to parse data source URL", "error", err) @@ -38,6 +37,14 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque }, nil } + // If the cluster is serverless, return a healthy result + if ds.ClusterInfo.IsServerless() { + return &backend.CheckHealthResult{ + Status: backend.HealthStatusOk, + Message: "Elasticsearch Serverless data source is healthy.", + }, nil + } + // check that ES is healthy healthStatusUrl.Path = path.Join(healthStatusUrl.Path, "_cluster/health") healthStatusUrl.RawQuery = "wait_for_status=yellow" diff --git a/pkg/tsdb/elasticsearch/metrics_response_processor.go b/pkg/tsdb/elasticsearch/metrics_response_processor.go index 1e60a732d64..619180ccf90 100644 --- a/pkg/tsdb/elasticsearch/metrics_response_processor.go +++ b/pkg/tsdb/elasticsearch/metrics_response_processor.go @@ -9,7 +9,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) // metricsResponseProcessor handles processing of metrics query responses diff --git a/pkg/tsdb/elasticsearch/models.go b/pkg/tsdb/elasticsearch/models.go index adb18554339..8df08182588 100644 --- a/pkg/tsdb/elasticsearch/models.go +++ b/pkg/tsdb/elasticsearch/models.go @@ -4,7 +4,7 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) // Query represents the time series query model of the datasource diff --git a/pkg/tsdb/elasticsearch/parse_query.go b/pkg/tsdb/elasticsearch/parse_query.go index e1bfa189ab9..4d7b0cf7d5e 100644 --- a/pkg/tsdb/elasticsearch/parse_query.go +++ b/pkg/tsdb/elasticsearch/parse_query.go @@ -6,7 +6,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) func parseQuery(tsdbQuery []backend.DataQuery, logger log.Logger) ([]*Query, error) { diff --git a/pkg/tsdb/elasticsearch/raw_dsl_aggregation_parser.go b/pkg/tsdb/elasticsearch/raw_dsl_aggregation_parser.go index b092763b57d..a569c92e7db 100644 --- a/pkg/tsdb/elasticsearch/raw_dsl_aggregation_parser.go +++ b/pkg/tsdb/elasticsearch/raw_dsl_aggregation_parser.go @@ -5,7 +5,7 @@ import ( "fmt" "strconv" - "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) // AggregationParser parses raw Elasticsearch DSL aggregations diff --git a/pkg/tsdb/elasticsearch/response_parser.go b/pkg/tsdb/elasticsearch/response_parser.go index d05ca92e19b..2c0c5d33810 100644 --- a/pkg/tsdb/elasticsearch/response_parser.go +++ b/pkg/tsdb/elasticsearch/response_parser.go @@ -15,9 +15,9 @@ import ( "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/components/simplejson" es "github.com/grafana/grafana/pkg/tsdb/elasticsearch/client" "github.com/grafana/grafana/pkg/tsdb/elasticsearch/instrumentation" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) const ( diff --git a/pkg/tsdb/elasticsearch/response_utils.go b/pkg/tsdb/elasticsearch/response_utils.go index c101633d0c2..5dfd1f38360 100644 --- a/pkg/tsdb/elasticsearch/response_utils.go +++ b/pkg/tsdb/elasticsearch/response_utils.go @@ -7,8 +7,8 @@ import ( "strings" "time" - "github.com/grafana/grafana/pkg/components/simplejson" es "github.com/grafana/grafana/pkg/tsdb/elasticsearch/client" + "github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson" ) // flatten flattens multi-level objects to single level objects. It uses dot notation to join keys. diff --git a/pkg/tsdb/elasticsearch/simplejson/simplejson.go b/pkg/tsdb/elasticsearch/simplejson/simplejson.go new file mode 100644 index 00000000000..d7759ac3c2b --- /dev/null +++ b/pkg/tsdb/elasticsearch/simplejson/simplejson.go @@ -0,0 +1,582 @@ +// Package simplejson provides a wrapper for arbitrary JSON objects that adds methods to access properties. +// Use of this package in place of types and the standard library's encoding/json package is strongly discouraged. +// +// Don't lint for stale code, since it's a copied library and we might as well keep the whole thing. +// nolint:unused +package simplejson + +import ( + "bytes" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "log" +) + +// returns the current implementation version +func Version() string { + return "0.5.0" +} + +type Json struct { + data any +} + +func (j *Json) FromDB(data []byte) error { + j.data = make(map[string]any) + + dec := json.NewDecoder(bytes.NewBuffer(data)) + dec.UseNumber() + return dec.Decode(&j.data) +} + +func (j *Json) ToDB() ([]byte, error) { + if j == nil || j.data == nil { + return nil, nil + } + + return j.Encode() +} + +func (j *Json) Scan(val any) error { + switch v := val.(type) { + case []byte: + if len(v) == 0 { + return nil + } + return json.Unmarshal(v, &j) + case string: + if len(v) == 0 { + return nil + } + return json.Unmarshal([]byte(v), &j) + default: + return fmt.Errorf("unsupported type: %T", v) + } +} + +func (j *Json) Value() (driver.Value, error) { + return j.ToDB() +} + +// DeepCopyInto creates a copy by serializing JSON +func (j *Json) DeepCopyInto(out *Json) { + b, err := j.Encode() + if err == nil { + _ = out.UnmarshalJSON(b) + } +} + +// DeepCopy will make a deep copy of the JSON object +func (j *Json) DeepCopy() *Json { + if j == nil { + return nil + } + out := new(Json) + j.DeepCopyInto(out) + return out +} + +// NewJson returns a pointer to a new `Json` object +// after unmarshaling `body` bytes +func NewJson(body []byte) (*Json, error) { + j := new(Json) + err := j.UnmarshalJSON(body) + if err != nil { + return nil, err + } + return j, nil +} + +// MustJson returns a pointer to a new `Json` object, panicking if `body` cannot be parsed. +func MustJson(body []byte) *Json { + j, err := NewJson(body) + + if err != nil { + panic(fmt.Sprintf("could not unmarshal JSON: %q", err)) + } + + return j +} + +// New returns a pointer to a new, empty `Json` object +func New() *Json { + return &Json{ + data: make(map[string]any), + } +} + +// NewFromAny returns a pointer to a new `Json` object with provided data. +func NewFromAny(data any) *Json { + return &Json{data: data} +} + +// Interface returns the underlying data +func (j *Json) Interface() any { + return j.data +} + +// Encode returns its marshaled data as `[]byte` +func (j *Json) Encode() ([]byte, error) { + return j.MarshalJSON() +} + +// EncodePretty returns its marshaled data as `[]byte` with indentation +func (j *Json) EncodePretty() ([]byte, error) { + return json.MarshalIndent(&j.data, "", " ") +} + +// Implements the json.Marshaler interface. +func (j *Json) MarshalJSON() ([]byte, error) { + return json.Marshal(&j.data) +} + +// Set modifies `Json` map by `key` and `value` +// Useful for changing single key/value in a `Json` object easily. +func (j *Json) Set(key string, val any) { + m, err := j.Map() + if err != nil { + return + } + m[key] = val +} + +// SetPath modifies `Json`, recursively checking/creating map keys for the supplied path, +// and then finally writing in the value +func (j *Json) SetPath(branch []string, val any) { + if len(branch) == 0 { + j.data = val + return + } + + // in order to insert our branch, we need map[string]any + if _, ok := (j.data).(map[string]any); !ok { + // have to replace with something suitable + j.data = make(map[string]any) + } + curr := j.data.(map[string]any) + + for i := 0; i < len(branch)-1; i++ { + b := branch[i] + // key exists? + if _, ok := curr[b]; !ok { + n := make(map[string]any) + curr[b] = n + curr = n + continue + } + + // make sure the value is the right sort of thing + if _, ok := curr[b].(map[string]any); !ok { + // have to replace with something suitable + n := make(map[string]any) + curr[b] = n + } + + curr = curr[b].(map[string]any) + } + + // add remaining k/v + curr[branch[len(branch)-1]] = val +} + +// Del modifies `Json` map by deleting `key` if it is present. +func (j *Json) Del(key string) { + m, err := j.Map() + if err != nil { + return + } + delete(m, key) +} + +// Get returns a pointer to a new `Json` object +// for `key` in its `map` representation +// +// useful for chaining operations (to traverse a nested JSON): +// +// js.Get("top_level").Get("dict").Get("value").Int() +func (j *Json) Get(key string) *Json { + m, err := j.Map() + if err == nil { + if val, ok := m[key]; ok { + return &Json{val} + } + } + return &Json{nil} +} + +// GetPath searches for the item as specified by the branch +// without the need to deep dive using Get()'s. +// +// js.GetPath("top_level", "dict") +func (j *Json) GetPath(branch ...string) *Json { + jin := j + for _, p := range branch { + jin = jin.Get(p) + } + return jin +} + +// GetIndex returns a pointer to a new `Json` object +// for `index` in its `array` representation +// +// this is the analog to Get when accessing elements of +// a json array instead of a json object: +// +// js.Get("top_level").Get("array").GetIndex(1).Get("key").Int() +func (j *Json) GetIndex(index int) *Json { + a, err := j.Array() + if err == nil { + if len(a) > index { + return &Json{a[index]} + } + } + return &Json{nil} +} + +// CheckGetIndex returns a pointer to a new `Json` object +// for `index` in its `array` representation, and a `bool` +// indicating success or failure +// +// useful for chained operations when success is important: +// +// if data, ok := js.Get("top_level").CheckGetIndex(0); ok { +// log.Println(data) +// } +func (j *Json) CheckGetIndex(index int) (*Json, bool) { + a, err := j.Array() + if err == nil { + if len(a) > index { + return &Json{a[index]}, true + } + } + return nil, false +} + +// SetIndex modifies `Json` array by `index` and `value` +// for `index` in its `array` representation +func (j *Json) SetIndex(index int, val any) { + a, err := j.Array() + if err == nil { + if len(a) > index { + a[index] = val + } + } +} + +// CheckGet returns a pointer to a new `Json` object and +// a `bool` identifying success or failure +// +// useful for chained operations when success is important: +// +// if data, ok := js.Get("top_level").CheckGet("inner"); ok { +// log.Println(data) +// } +func (j *Json) CheckGet(key string) (*Json, bool) { + m, err := j.Map() + if err == nil { + if val, ok := m[key]; ok { + return &Json{val}, true + } + } + return nil, false +} + +// Map type asserts to `map` +func (j *Json) Map() (map[string]any, error) { + if m, ok := (j.data).(map[string]any); ok { + return m, nil + } + return nil, errors.New("type assertion to map[string]any failed") +} + +// Array type asserts to an `array` +func (j *Json) Array() ([]any, error) { + if a, ok := (j.data).([]any); ok { + return a, nil + } + return nil, errors.New("type assertion to []any failed") +} + +// Bool type asserts to `bool` +func (j *Json) Bool() (bool, error) { + if s, ok := (j.data).(bool); ok { + return s, nil + } + return false, errors.New("type assertion to bool failed") +} + +// String type asserts to `string` +func (j *Json) String() (string, error) { + if s, ok := (j.data).(string); ok { + return s, nil + } + return "", errors.New("type assertion to string failed") +} + +// Bytes type asserts to `[]byte` +func (j *Json) Bytes() ([]byte, error) { + if s, ok := (j.data).(string); ok { + return []byte(s), nil + } + return nil, errors.New("type assertion to []byte failed") +} + +// StringArray type asserts to an `array` of `string` +func (j *Json) StringArray() ([]string, error) { + arr, err := j.Array() + if err != nil { + return nil, err + } + retArr := make([]string, 0, len(arr)) + for _, a := range arr { + if a == nil { + retArr = append(retArr, "") + continue + } + s, ok := a.(string) + if !ok { + return nil, err + } + retArr = append(retArr, s) + } + return retArr, nil +} + +// MustArray guarantees the return of a `[]any` (with optional default) +// +// useful when you want to iterate over array values in a succinct manner: +// +// for i, v := range js.Get("results").MustArray() { +// fmt.Println(i, v) +// } +func (j *Json) MustArray(args ...[]any) []any { + var def []any + + switch len(args) { + case 0: + case 1: + def = args[0] + default: + log.Panicf("MustArray() received too many arguments %d", len(args)) + } + + a, err := j.Array() + if err == nil { + return a + } + + return def +} + +// MustMap guarantees the return of a `map[string]any` (with optional default) +// +// useful when you want to iterate over map values in a succinct manner: +// +// for k, v := range js.Get("dictionary").MustMap() { +// fmt.Println(k, v) +// } +func (j *Json) MustMap(args ...map[string]any) map[string]any { + var def map[string]any + + switch len(args) { + case 0: + case 1: + def = args[0] + default: + log.Panicf("MustMap() received too many arguments %d", len(args)) + } + + a, err := j.Map() + if err == nil { + return a + } + + return def +} + +// MustString guarantees the return of a `string` (with optional default) +// +// useful when you explicitly want a `string` in a single value return context: +// +// myFunc(js.Get("param1").MustString(), js.Get("optional_param").MustString("my_default")) +func (j *Json) MustString(args ...string) string { + var def string + + switch len(args) { + case 0: + case 1: + def = args[0] + default: + log.Panicf("MustString() received too many arguments %d", len(args)) + } + + s, err := j.String() + if err == nil { + return s + } + + return def +} + +// MustStringArray guarantees the return of a `[]string` (with optional default) +// +// useful when you want to iterate over array values in a succinct manner: +// +// for i, s := range js.Get("results").MustStringArray() { +// fmt.Println(i, s) +// } +func (j *Json) MustStringArray(args ...[]string) []string { + var def []string + + switch len(args) { + case 0: + case 1: + def = args[0] + default: + log.Panicf("MustStringArray() received too many arguments %d", len(args)) + } + + a, err := j.StringArray() + if err == nil { + return a + } + + return def +} + +// MustInt guarantees the return of an `int` (with optional default) +// +// useful when you explicitly want an `int` in a single value return context: +// +// myFunc(js.Get("param1").MustInt(), js.Get("optional_param").MustInt(5150)) +func (j *Json) MustInt(args ...int) int { + var def int + + switch len(args) { + case 0: + case 1: + def = args[0] + default: + log.Panicf("MustInt() received too many arguments %d", len(args)) + } + + i, err := j.Int() + if err == nil { + return i + } + + return def +} + +// MustFloat64 guarantees the return of a `float64` (with optional default) +// +// useful when you explicitly want a `float64` in a single value return context: +// +// myFunc(js.Get("param1").MustFloat64(), js.Get("optional_param").MustFloat64(5.150)) +func (j *Json) MustFloat64(args ...float64) float64 { + var def float64 + + switch len(args) { + case 0: + case 1: + def = args[0] + default: + log.Panicf("MustFloat64() received too many arguments %d", len(args)) + } + + f, err := j.Float64() + if err == nil { + return f + } + + return def +} + +// MustBool guarantees the return of a `bool` (with optional default) +// +// useful when you explicitly want a `bool` in a single value return context: +// +// myFunc(js.Get("param1").MustBool(), js.Get("optional_param").MustBool(true)) +func (j *Json) MustBool(args ...bool) bool { + var def bool + + switch len(args) { + case 0: + case 1: + def = args[0] + default: + log.Panicf("MustBool() received too many arguments %d", len(args)) + } + + b, err := j.Bool() + if err == nil { + return b + } + + return def +} + +// MustInt64 guarantees the return of an `int64` (with optional default) +// +// useful when you explicitly want an `int64` in a single value return context: +// +// myFunc(js.Get("param1").MustInt64(), js.Get("optional_param").MustInt64(5150)) +func (j *Json) MustInt64(args ...int64) int64 { + var def int64 + + switch len(args) { + case 0: + case 1: + def = args[0] + default: + log.Panicf("MustInt64() received too many arguments %d", len(args)) + } + + i, err := j.Int64() + if err == nil { + return i + } + + return def +} + +// MustUInt64 guarantees the return of an `uint64` (with optional default) +// +// useful when you explicitly want an `uint64` in a single value return context: +// +// myFunc(js.Get("param1").MustUint64(), js.Get("optional_param").MustUint64(5150)) +func (j *Json) MustUint64(args ...uint64) uint64 { + var def uint64 + + switch len(args) { + case 0: + case 1: + def = args[0] + default: + log.Panicf("MustUint64() received too many arguments %d", len(args)) + } + + i, err := j.Uint64() + if err == nil { + return i + } + + return def +} + +// MarshalYAML implements yaml.Marshaller. +func (j *Json) MarshalYAML() (any, error) { + return j.data, nil +} + +// UnmarshalYAML implements yaml.Unmarshaller. +func (j *Json) UnmarshalYAML(unmarshal func(any) error) error { + var data any + if err := unmarshal(&data); err != nil { + return err + } + j.data = data + return nil +} diff --git a/pkg/tsdb/elasticsearch/simplejson/simplejson_go11.go b/pkg/tsdb/elasticsearch/simplejson/simplejson_go11.go new file mode 100644 index 00000000000..88748985576 --- /dev/null +++ b/pkg/tsdb/elasticsearch/simplejson/simplejson_go11.go @@ -0,0 +1,90 @@ +package simplejson + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "reflect" + "strconv" +) + +// Implements the json.Unmarshaler interface. +func (j *Json) UnmarshalJSON(p []byte) error { + dec := json.NewDecoder(bytes.NewBuffer(p)) + dec.UseNumber() + return dec.Decode(&j.data) +} + +// NewFromReader returns a *Json by decoding from an io.Reader +func NewFromReader(r io.Reader) (*Json, error) { + j := new(Json) + dec := json.NewDecoder(r) + dec.UseNumber() + err := dec.Decode(&j.data) + return j, err +} + +// Float64 coerces into a float64 +func (j *Json) Float64() (float64, error) { + switch n := j.data.(type) { + case json.Number: + return n.Float64() + case float32, float64: + return reflect.ValueOf(j.data).Float(), nil + case int, int8, int16, int32, int64: + return float64(reflect.ValueOf(j.data).Int()), nil + case uint, uint8, uint16, uint32, uint64: + return float64(reflect.ValueOf(j.data).Uint()), nil + } + return 0, errors.New("invalid value type") +} + +// Int coerces into an int +func (j *Json) Int() (int, error) { + switch n := j.data.(type) { + case json.Number: + i, err := n.Int64() + if err != nil { + return 0, err + } + return int(i), nil + case float32, float64: + return int(reflect.ValueOf(j.data).Float()), nil + case int, int8, int16, int32, int64: + return int(reflect.ValueOf(j.data).Int()), nil + case uint, uint8, uint16, uint32, uint64: + return int(reflect.ValueOf(j.data).Uint()), nil + } + return 0, errors.New("invalid value type") +} + +// Int64 coerces into an int64 +func (j *Json) Int64() (int64, error) { + switch n := j.data.(type) { + case json.Number: + return n.Int64() + case float32, float64: + return int64(reflect.ValueOf(j.data).Float()), nil + case int, int8, int16, int32, int64: + return reflect.ValueOf(j.data).Int(), nil + case uint, uint8, uint16, uint32, uint64: + return int64(reflect.ValueOf(j.data).Uint()), nil + } + return 0, errors.New("invalid value type") +} + +// Uint64 coerces into an uint64 +func (j *Json) Uint64() (uint64, error) { + switch n := j.data.(type) { + case json.Number: + return strconv.ParseUint(n.String(), 10, 64) + case float32, float64: + return uint64(reflect.ValueOf(j.data).Float()), nil + case int, int8, int16, int32, int64: + return uint64(reflect.ValueOf(j.data).Int()), nil + case uint, uint8, uint16, uint32, uint64: + return reflect.ValueOf(j.data).Uint(), nil + } + return 0, errors.New("invalid value type") +} diff --git a/pkg/tsdb/elasticsearch/simplejson/simplejson_test.go b/pkg/tsdb/elasticsearch/simplejson/simplejson_test.go new file mode 100644 index 00000000000..efc786bc745 --- /dev/null +++ b/pkg/tsdb/elasticsearch/simplejson/simplejson_test.go @@ -0,0 +1,274 @@ +package simplejson + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSimplejson(t *testing.T) { + var ok bool + var err error + + js, err := NewJson([]byte(`{ + "test": { + "string_array": ["asdf", "ghjk", "zxcv"], + "string_array_null": ["abc", null, "efg"], + "array": [1, "2", 3], + "arraywithsubs": [{"subkeyone": 1}, + {"subkeytwo": 2, "subkeythree": 3}], + "int": 10, + "float": 5.150, + "string": "simplejson", + "bool": true, + "sub_obj": {"a": 1} + } + }`)) + + assert.NotEqual(t, nil, js) + assert.Equal(t, nil, err) + + _, ok = js.CheckGet("test") + assert.Equal(t, true, ok) + + _, ok = js.CheckGet("missing_key") + assert.Equal(t, false, ok) + + aws := js.Get("test").Get("arraywithsubs") + assert.NotEqual(t, nil, aws) + var awsval int + awsval, _ = aws.GetIndex(0).Get("subkeyone").Int() + assert.Equal(t, 1, awsval) + awsval, _ = aws.GetIndex(1).Get("subkeytwo").Int() + assert.Equal(t, 2, awsval) + awsval, _ = aws.GetIndex(1).Get("subkeythree").Int() + assert.Equal(t, 3, awsval) + + arr := js.Get("test").Get("array") + assert.NotEqual(t, nil, arr) + val, ok := arr.CheckGetIndex(0) + assert.Equal(t, ok, true) + valInt, _ := val.Int() + assert.Equal(t, valInt, 1) + val, ok = arr.CheckGetIndex(1) + assert.Equal(t, ok, true) + valStr, _ := val.String() + assert.Equal(t, valStr, "2") + val, ok = arr.CheckGetIndex(2) + assert.Equal(t, ok, true) + valInt, _ = val.Int() + assert.Equal(t, valInt, 3) + _, ok = arr.CheckGetIndex(3) + assert.Equal(t, ok, false) + + i, _ := js.Get("test").Get("int").Int() + assert.Equal(t, 10, i) + + f, _ := js.Get("test").Get("float").Float64() + assert.Equal(t, 5.150, f) + + s, _ := js.Get("test").Get("string").String() + assert.Equal(t, "simplejson", s) + + b, _ := js.Get("test").Get("bool").Bool() + assert.Equal(t, true, b) + + mi := js.Get("test").Get("int").MustInt() + assert.Equal(t, 10, mi) + + mi2 := js.Get("test").Get("missing_int").MustInt(5150) + assert.Equal(t, 5150, mi2) + + ms := js.Get("test").Get("string").MustString() + assert.Equal(t, "simplejson", ms) + + ms2 := js.Get("test").Get("missing_string").MustString("fyea") + assert.Equal(t, "fyea", ms2) + + ma2 := js.Get("test").Get("missing_array").MustArray([]any{"1", 2, "3"}) + assert.Equal(t, ma2, []any{"1", 2, "3"}) + + msa := js.Get("test").Get("string_array").MustStringArray() + assert.Equal(t, msa[0], "asdf") + assert.Equal(t, msa[1], "ghjk") + assert.Equal(t, msa[2], "zxcv") + + msa2 := js.Get("test").Get("string_array").MustStringArray([]string{"1", "2", "3"}) + assert.Equal(t, msa2[0], "asdf") + assert.Equal(t, msa2[1], "ghjk") + assert.Equal(t, msa2[2], "zxcv") + + msa3 := js.Get("test").Get("missing_array").MustStringArray([]string{"1", "2", "3"}) + assert.Equal(t, msa3, []string{"1", "2", "3"}) + + mm2 := js.Get("test").Get("missing_map").MustMap(map[string]any{"found": false}) + assert.Equal(t, mm2, map[string]any{"found": false}) + + strs, err := js.Get("test").Get("string_array").StringArray() + assert.Equal(t, err, nil) + assert.Equal(t, strs[0], "asdf") + assert.Equal(t, strs[1], "ghjk") + assert.Equal(t, strs[2], "zxcv") + + strs2, err := js.Get("test").Get("string_array_null").StringArray() + assert.Equal(t, err, nil) + assert.Equal(t, strs2[0], "abc") + assert.Equal(t, strs2[1], "") + assert.Equal(t, strs2[2], "efg") + + gp, _ := js.GetPath("test", "string").String() + assert.Equal(t, "simplejson", gp) + + gp2, _ := js.GetPath("test", "int").Int() + assert.Equal(t, 10, gp2) + + assert.Equal(t, js.Get("test").Get("bool").MustBool(), true) + + js.Set("float2", 300.0) + assert.Equal(t, js.Get("float2").MustFloat64(), 300.0) + + js.Set("test2", "setTest") + assert.Equal(t, "setTest", js.Get("test2").MustString()) + + js.Del("test2") + assert.NotEqual(t, "setTest", js.Get("test2").MustString()) + + js.Get("test").Get("sub_obj").Set("a", 2) + assert.Equal(t, 2, js.Get("test").Get("sub_obj").Get("a").MustInt()) + + js.GetPath("test", "sub_obj").Set("a", 3) + assert.Equal(t, 3, js.GetPath("test", "sub_obj", "a").MustInt()) +} + +func TestStdlibInterfaces(t *testing.T) { + val := new(struct { + Name string `json:"name"` + Params *Json `json:"params"` + }) + val2 := new(struct { + Name string `json:"name"` + Params *Json `json:"params"` + }) + + raw := `{"name":"myobject","params":{"string":"simplejson"}}` + + assert.Equal(t, nil, json.Unmarshal([]byte(raw), val)) + + assert.Equal(t, "myobject", val.Name) + assert.NotEqual(t, nil, val.Params.data) + s, _ := val.Params.Get("string").String() + assert.Equal(t, "simplejson", s) + + p, err := json.Marshal(val) + assert.Equal(t, nil, err) + assert.Equal(t, nil, json.Unmarshal(p, val2)) + assert.Equal(t, val, val2) // stable +} + +func TestSet(t *testing.T) { + js, err := NewJson([]byte(`{}`)) + assert.Equal(t, nil, err) + + js.Set("baz", "bing") + + s, err := js.GetPath("baz").String() + assert.Equal(t, nil, err) + assert.Equal(t, "bing", s) +} + +func TestReplace(t *testing.T) { + js, err := NewJson([]byte(`{}`)) + assert.Equal(t, nil, err) + + err = js.UnmarshalJSON([]byte(`{"baz":"bing"}`)) + assert.Equal(t, nil, err) + + s, err := js.GetPath("baz").String() + assert.Equal(t, nil, err) + assert.Equal(t, "bing", s) +} + +func TestSetPath(t *testing.T) { + js, err := NewJson([]byte(`{}`)) + assert.Equal(t, nil, err) + + js.SetPath([]string{"foo", "bar"}, "baz") + + s, err := js.GetPath("foo", "bar").String() + assert.Equal(t, nil, err) + assert.Equal(t, "baz", s) +} + +func TestSetPathNoPath(t *testing.T) { + js, err := NewJson([]byte(`{"some":"data","some_number":1.0,"some_bool":false}`)) + assert.Equal(t, nil, err) + + f := js.GetPath("some_number").MustFloat64(99.0) + assert.Equal(t, f, 1.0) + + js.SetPath([]string{}, map[string]any{"foo": "bar"}) + + s, err := js.GetPath("foo").String() + assert.Equal(t, nil, err) + assert.Equal(t, "bar", s) + + f = js.GetPath("some_number").MustFloat64(99.0) + assert.Equal(t, f, 99.0) +} + +func TestPathWillAugmentExisting(t *testing.T) { + js, err := NewJson([]byte(`{"this":{"a":"aa","b":"bb","c":"cc"}}`)) + assert.Equal(t, nil, err) + + js.SetPath([]string{"this", "d"}, "dd") + + cases := []struct { + path []string + outcome string + }{ + { + path: []string{"this", "a"}, + outcome: "aa", + }, + { + path: []string{"this", "b"}, + outcome: "bb", + }, + { + path: []string{"this", "c"}, + outcome: "cc", + }, + { + path: []string{"this", "d"}, + outcome: "dd", + }, + } + + for _, tc := range cases { + s, err := js.GetPath(tc.path...).String() + assert.Equal(t, nil, err) + assert.Equal(t, tc.outcome, s) + } +} + +func TestPathWillOverwriteExisting(t *testing.T) { + // notice how "a" is 0.1 - but then we'll try to set at path a, foo + js, err := NewJson([]byte(`{"this":{"a":0.1,"b":"bb","c":"cc"}}`)) + assert.Equal(t, nil, err) + + js.SetPath([]string{"this", "a", "foo"}, "bar") + + s, err := js.GetPath("this", "a", "foo").String() + assert.Equal(t, nil, err) + assert.Equal(t, "bar", s) +} + +func TestMustJson(t *testing.T) { + js := MustJson([]byte(`{"foo": "bar"}`)) + assert.Equal(t, js.Get("foo").MustString(), "bar") + + assert.PanicsWithValue(t, "could not unmarshal JSON: \"unexpected EOF\"", func() { + MustJson([]byte(`{`)) + }) +} diff --git a/pkg/tsdb/elasticsearch/standalone/datasource.go b/pkg/tsdb/elasticsearch/standalone/datasource.go new file mode 100644 index 00000000000..6b9b8ac3f82 --- /dev/null +++ b/pkg/tsdb/elasticsearch/standalone/datasource.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + elasticsearch "github.com/grafana/grafana/pkg/tsdb/elasticsearch" +) + +var ( + _ backend.QueryDataHandler = (*Datasource)(nil) + _ backend.CheckHealthHandler = (*Datasource)(nil) + _ backend.CallResourceHandler = (*Datasource)(nil) +) + +func NewDatasource(context.Context, backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return &Datasource{ + Service: elasticsearch.ProvideService(httpclient.NewProvider()), + }, nil +} + +type Datasource struct { + Service *elasticsearch.Service +} + +func contextualMiddlewares(ctx context.Context) context.Context { + cfg := backend.GrafanaConfigFromContext(ctx) + responseLimitMiddleware := httpclient.ResponseLimitMiddleware(cfg.ResponseLimit()) + ctx = httpclient.WithContextualMiddleware(ctx, responseLimitMiddleware) + return ctx +} + +func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + ctx = contextualMiddlewares(ctx) + return d.Service.QueryData(ctx, req) +} + +func (d *Datasource) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + ctx = contextualMiddlewares(ctx) + return d.Service.CallResource(ctx, req, sender) +} + +func (d *Datasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + ctx = contextualMiddlewares(ctx) + return d.Service.CheckHealth(ctx, req) +} diff --git a/pkg/tsdb/elasticsearch/standalone/main.go b/pkg/tsdb/elasticsearch/standalone/main.go new file mode 100644 index 00000000000..22bd4169339 --- /dev/null +++ b/pkg/tsdb/elasticsearch/standalone/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "os" + + "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" +) + +func main() { + // Start listening to requests sent from Grafana. This call is blocking so + // it won't finish until Grafana shuts down the process or the plugin choose + // to exit by itself using os.Exit. Manage automatically manages life cycle + // of datasource instances. It accepts datasource instance factory as first + // argument. This factory will be automatically called on incoming request + // from Grafana to create different instances of SampleDatasource (per datasource + // ID). When datasource configuration changed Dispose method will be called and + // new datasource instance created using NewSampleDatasource factory. + if err := datasource.Manage("elasticsearch", NewDatasource, datasource.ManageOpts{}); err != nil { + log.DefaultLogger.Error(err.Error()) + os.Exit(1) + } +} diff --git a/pkg/util/testutil/context_test.go b/pkg/util/testutil/context_test.go index 4d7ecf670f6..ca5c5abee4f 100644 --- a/pkg/util/testutil/context_test.go +++ b/pkg/util/testutil/context_test.go @@ -15,7 +15,10 @@ import ( func TestMain(m *testing.M) { // make sure we don't leak goroutines after tests in this package have // finished, which means we haven't leaked contexts either - goleak.VerifyTestMain(m) + // (Except for goroutines running specific functions. If possible we should fix this.) + goleak.VerifyTestMain(m, + goleak.IgnoreTopFunction("github.com/open-feature/go-sdk/openfeature.(*eventExecutor).startEventListener.func1.1"), + ) } func TestTestContextFunc(t *testing.T) { diff --git a/playwright.config.ts b/playwright.config.ts index 27ec8b97f13..d90602f0a46 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -179,6 +179,10 @@ export default defineConfig({ name: 'cloud-plugins', testDir: path.join(testDirRoot, '/cloud-plugins-suite'), }), + withAuth({ + name: 'alerting', + testDir: path.join(testDirRoot, '/alerting-suite'), + }), withAuth({ name: 'dashboard-new-layouts', testDir: path.join(testDirRoot, '/dashboard-new-layouts'), diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index 9f09c0135d7..4cb15addb0a 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -6002,6 +6002,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "type": "string", + "format": "date-time" + }, "email": { "type": "string" }, @@ -6152,11 +6156,8 @@ ] }, "timezone": { - "type": "string", - "enum": [ - "utc", - "browser" - ] + "description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string", + "type": "string" }, "weekStart": { "type": "string" @@ -6990,6 +6991,9 @@ "ReportOptions": { "type": "object", "properties": { + "csvEncoding": { + "type": "string" + }, "layout": { "type": "string" }, @@ -8654,11 +8658,8 @@ ] }, "timezone": { - "type": "string", - "enum": [ - "utc", - "browser" - ] + "description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string", + "type": "string" }, "weekStart": { "type": "string" @@ -8906,6 +8907,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "type": "string", + "format": "date-time" + }, "email": { "type": "string" }, diff --git a/public/api-merged.json b/public/api-merged.json index 642583d2600..8dc5868bb41 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -4024,12 +4024,14 @@ }, "/dashboards/uid/{uid}/restore": { "post": { + "description": "This API will be removed when /apis/dashboards.grafana.app/v1 is released.\nYou can restore a dashboard by reading it from history, then creating it again.", "tags": [ "dashboards", "versions" ], "summary": "Restore a dashboard to a given dashboard version using UID.", "operationId": "restoreDashboardVersionByUID", + "deprecated": true, "parameters": [ { "name": "Body", @@ -6167,10 +6169,16 @@ }, { "type": "string", - "description": "A comma separated list of folder ID(s) to filter the elements by.", + "description": "A comma separated list of folder ID(s) to filter the elements by.\nDeprecated: Use FolderFilterUIDs instead.", "name": "folderFilter", "in": "query" }, + { + "type": "string", + "description": "A comma separated list of folder UID(s) to filter the elements by.", + "name": "folderFilterUIDs", + "in": "query" + }, { "type": "integer", "format": "int64", @@ -18483,6 +18491,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "type": "string", + "format": "date-time" + }, "email": { "type": "string" }, @@ -18729,11 +18741,8 @@ ] }, "timezone": { - "type": "string", - "enum": [ - "utc", - "browser" - ] + "description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string", + "type": "string" }, "weekStart": { "type": "string" @@ -20504,6 +20513,9 @@ "ReportOptions": { "type": "object", "properties": { + "csvEncoding": { + "type": "string" + }, "layout": { "type": "string" }, @@ -23117,11 +23129,8 @@ ] }, "timezone": { - "type": "string", - "enum": [ - "utc", - "browser" - ] + "description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string", + "type": "string" }, "weekStart": { "type": "string" @@ -23407,6 +23416,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "type": "string", + "format": "date-time" + }, "email": { "type": "string" }, diff --git a/public/app/api/clients/collections/v1alpha1/index.ts b/public/app/api/clients/collections/v1alpha1/index.ts index cd2102f6b29..c23fee00241 100644 --- a/public/app/api/clients/collections/v1alpha1/index.ts +++ b/public/app/api/clients/collections/v1alpha1/index.ts @@ -1,7 +1,7 @@ import { generatedAPI } from '@grafana/api-clients/rtkq/collections/v1alpha1'; import { t } from '@grafana/i18n'; -import { notifyApp } from 'app/core/actions'; import { createSuccessNotification, createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; export const collectionsAPIv1alpha1 = generatedAPI.enhanceEndpoints({ endpoints: { diff --git a/public/app/api/clients/playlist/v0alpha1/index.ts b/public/app/api/clients/playlist/v0alpha1/index.ts index 8f264203f8d..67a5973190e 100644 --- a/public/app/api/clients/playlist/v0alpha1/index.ts +++ b/public/app/api/clients/playlist/v0alpha1/index.ts @@ -1,8 +1,8 @@ import { generatedAPI, type Playlist, type PlaylistSpec } from '@grafana/api-clients/rtkq/playlist/v0alpha1'; import { getBackendSrv } from '@grafana/runtime'; -import { notifyApp } from '../../../../core/actions'; import { createSuccessNotification } from '../../../../core/copy/appNotification'; +import { notifyApp } from '../../../../core/reducers/appNotification'; import { contextSrv } from '../../../../core/services/context_srv'; import { handleError } from '../../../utils'; diff --git a/public/app/api/clients/provisioning/v0alpha1/index.ts b/public/app/api/clients/provisioning/v0alpha1/index.ts index 7f41904cf94..d1ef01c9b1c 100644 --- a/public/app/api/clients/provisioning/v0alpha1/index.ts +++ b/public/app/api/clients/provisioning/v0alpha1/index.ts @@ -11,14 +11,35 @@ import { t } from '@grafana/i18n'; import { isFetchError } from '@grafana/runtime'; import { clearFolders } from 'app/features/browse-dashboards/state/slice'; import { getState } from 'app/store/store'; +import { ThunkDispatch } from 'app/types/store'; -import { notifyApp } from '../../../../core/actions'; import { createSuccessNotification, createErrorNotification } from '../../../../core/copy/appNotification'; +import { notifyApp } from '../../../../core/reducers/appNotification'; import { PAGE_SIZE } from '../../../../features/browse-dashboards/api/services'; import { refetchChildren } from '../../../../features/browse-dashboards/state/actions'; import { handleError } from '../../../utils'; import { createOnCacheEntryAdded } from '../utils/createOnCacheEntryAdded'; +const handleProvisioningFormError = (e: unknown, dispatch: ThunkDispatch, title: string) => { + if (typeof e === 'object' && e && 'error' in e && isFetchError(e.error)) { + if (e.error.data.kind === 'Status' && e.error.data.status === 'Failure') { + const statusError: Status = e.error.data; + dispatch(notifyApp(createErrorNotification(title, new Error(statusError.message || 'Unknown error')))); + return; + } + + if (Array.isArray(e.error.data.errors) && e.error.data.errors.length) { + const nonFieldErrors = e.error.data.errors.filter((err: ErrorDetails) => !err.field); + if (nonFieldErrors.length > 0) { + dispatch(notifyApp(createErrorNotification(title))); + } + return; + } + } + + handleError(e, dispatch, title); +}; + export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({ endpoints: { listJob: { @@ -37,6 +58,17 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({ }), onCacheEntryAdded: createOnCacheEntryAdded('repositories'), }, + listConnection: { + providesTags: (result) => + result + ? [ + { type: 'Connection', id: 'LIST' }, + ...result.items + .map((connection) => ({ type: 'Connection' as const, id: connection.metadata?.name })) + .filter(Boolean), + ] + : [{ type: 'Connection', id: 'LIST' }], + }, deleteRepository: { onQueryStarted: async (_, { queryFulfilled, dispatch }) => { try { @@ -104,34 +136,7 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({ try { await queryFulfilled; } catch (e) { - // Handle special cases first - if (typeof e === 'object' && e && 'error' in e && isFetchError(e.error)) { - // Handle Status error responses (Kubernetes style) - if (e.error.data.kind === 'Status' && e.error.data.status === 'Failure') { - const statusError: Status = e.error.data; - dispatch( - notifyApp( - createErrorNotification( - 'Error validating repository', - new Error(statusError.message || 'Unknown error') - ) - ) - ); - return; - } - // Handle TestResults error responses with field errors - if (Array.isArray(e.error.data.errors) && e.error.data.errors.length) { - const nonFieldErrors = e.error.data.errors.filter((err: ErrorDetails) => !err.field); - // Only show notification if there are errors that don't have a field, field errors are handled by the form - if (nonFieldErrors.length > 0) { - dispatch(notifyApp(createErrorNotification('Error validating repository'))); - } - return; - } - } - - // For all other cases, use handleError - handleError(e, dispatch, 'Error validating repository'); + handleProvisioningFormError(e, dispatch, 'Error validating repository'); } }, }, @@ -240,6 +245,70 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({ } }, }, + createConnection: { + onQueryStarted: async (_, { queryFulfilled, dispatch }) => { + try { + await queryFulfilled; + dispatch( + notifyApp( + createSuccessNotification(t('provisioning.connection-form.alert-connection-saved', 'Connection saved')) + ) + ); + } catch (e) { + handleProvisioningFormError( + e, + dispatch, + t('provisioning.connection-form.error-save-connection', 'Failed to save connection') + ); + } + }, + }, + replaceConnection: { + onQueryStarted: async (_, { queryFulfilled, dispatch }) => { + try { + await queryFulfilled; + dispatch( + notifyApp( + createSuccessNotification( + t('provisioning.connection-form.alert-connection-updated', 'Connection updated') + ) + ) + ); + } catch (e) { + handleProvisioningFormError( + e, + dispatch, + t('provisioning.connection-form.error-save-connection', 'Failed to save connection') + ); + } + }, + }, + deleteConnection: { + invalidatesTags: (result, error) => (error ? [] : [{ type: 'Connection', id: 'LIST' }]), + onQueryStarted: async (_, { queryFulfilled, dispatch }) => { + try { + await queryFulfilled; + dispatch( + notifyApp( + createSuccessNotification( + t('provisioning.connection-form.alert-connection-deleted', 'Connection deleted') + ) + ) + ); + } catch (e) { + if (e instanceof Error) { + dispatch( + notifyApp( + createErrorNotification( + t('provisioning.connection-form.error-delete-connection', 'Failed to delete connection'), + e + ) + ) + ); + } + } + }, + }, }, }); diff --git a/public/app/api/clients/scope/v0alpha1/baseAPI.ts b/public/app/api/clients/scope/v0alpha1/baseAPI.ts new file mode 100644 index 00000000000..bdec3014c1e --- /dev/null +++ b/public/app/api/clients/scope/v0alpha1/baseAPI.ts @@ -0,0 +1,16 @@ +import { createApi } from '@reduxjs/toolkit/query/react'; + +import { getAPIBaseURL } from '@grafana/api-clients'; +import { createBaseQuery } from '@grafana/api-clients/rtkq'; + +export const API_GROUP = 'scope.grafana.app' as const; +export const API_VERSION = 'v0alpha1' as const; +export const BASE_URL = getAPIBaseURL(API_GROUP, API_VERSION); + +export const api = createApi({ + reducerPath: 'scopeAPIv0alpha1', + baseQuery: createBaseQuery({ + baseURL: BASE_URL, + }), + endpoints: () => ({}), +}); diff --git a/public/app/api/clients/scope/v0alpha1/endpoints.gen.ts b/public/app/api/clients/scope/v0alpha1/endpoints.gen.ts new file mode 100644 index 00000000000..0bd43fc2b05 --- /dev/null +++ b/public/app/api/clients/scope/v0alpha1/endpoints.gen.ts @@ -0,0 +1,1727 @@ +import { api } from './baseAPI'; +export const addTagTypes = [ + 'API Discovery', + 'FindScopeDashboardBindingsResults', + 'FindScopeNavigationsResults', + 'FindScopeNodeChildrenResults', + 'ScopeDashboardBinding', + 'ScopeNavigation', + 'ScopeNode', + 'Scope', +] as const; +const injectedRtkApi = api + .enhanceEndpoints({ + addTagTypes, + }) + .injectEndpoints({ + endpoints: (build) => ({ + getApiResources: build.query({ + query: () => ({ url: `/` }), + providesTags: ['API Discovery'], + }), + getFindScopeDashboardBindingsResults: build.query< + GetFindScopeDashboardBindingsResultsApiResponse, + GetFindScopeDashboardBindingsResultsApiArg + >({ + query: (queryArg) => ({ + url: `/find/scope_dashboard_bindings`, + params: { + scope: queryArg.scope, + }, + }), + providesTags: ['FindScopeDashboardBindingsResults'], + }), + getFindScopeNavigationsResults: build.query< + GetFindScopeNavigationsResultsApiResponse, + GetFindScopeNavigationsResultsApiArg + >({ + query: (queryArg) => ({ + url: `/find/scope_navigations`, + params: { + scope: queryArg.scope, + }, + }), + providesTags: ['FindScopeNavigationsResults'], + }), + getFindScopeNodeChildrenResults: build.query< + GetFindScopeNodeChildrenResultsApiResponse, + GetFindScopeNodeChildrenResultsApiArg + >({ + query: (queryArg) => ({ + url: `/find/scope_node_children`, + params: { + parent: queryArg.parent, + query: queryArg.query, + names: queryArg.names, + limit: queryArg.limit, + }, + }), + providesTags: ['FindScopeNodeChildrenResults'], + }), + listScopeDashboardBinding: build.query({ + query: (queryArg) => ({ + url: `/scopedashboardbindings`, + params: { + pretty: queryArg.pretty, + allowWatchBookmarks: queryArg.allowWatchBookmarks, + continue: queryArg['continue'], + fieldSelector: queryArg.fieldSelector, + labelSelector: queryArg.labelSelector, + limit: queryArg.limit, + resourceVersion: queryArg.resourceVersion, + resourceVersionMatch: queryArg.resourceVersionMatch, + sendInitialEvents: queryArg.sendInitialEvents, + timeoutSeconds: queryArg.timeoutSeconds, + watch: queryArg.watch, + }, + }), + providesTags: ['ScopeDashboardBinding'], + }), + createScopeDashboardBinding: build.mutation< + CreateScopeDashboardBindingApiResponse, + CreateScopeDashboardBindingApiArg + >({ + query: (queryArg) => ({ + url: `/scopedashboardbindings`, + method: 'POST', + body: queryArg.scopeDashboardBinding, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['ScopeDashboardBinding'], + }), + deletecollectionScopeDashboardBinding: build.mutation< + DeletecollectionScopeDashboardBindingApiResponse, + DeletecollectionScopeDashboardBindingApiArg + >({ + query: (queryArg) => ({ + url: `/scopedashboardbindings`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + continue: queryArg['continue'], + dryRun: queryArg.dryRun, + fieldSelector: queryArg.fieldSelector, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + labelSelector: queryArg.labelSelector, + limit: queryArg.limit, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + resourceVersion: queryArg.resourceVersion, + resourceVersionMatch: queryArg.resourceVersionMatch, + sendInitialEvents: queryArg.sendInitialEvents, + timeoutSeconds: queryArg.timeoutSeconds, + }, + }), + invalidatesTags: ['ScopeDashboardBinding'], + }), + getScopeDashboardBinding: build.query({ + query: (queryArg) => ({ + url: `/scopedashboardbindings/${queryArg.name}`, + params: { + pretty: queryArg.pretty, + }, + }), + providesTags: ['ScopeDashboardBinding'], + }), + replaceScopeDashboardBinding: build.mutation< + ReplaceScopeDashboardBindingApiResponse, + ReplaceScopeDashboardBindingApiArg + >({ + query: (queryArg) => ({ + url: `/scopedashboardbindings/${queryArg.name}`, + method: 'PUT', + body: queryArg.scopeDashboardBinding, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['ScopeDashboardBinding'], + }), + deleteScopeDashboardBinding: build.mutation< + DeleteScopeDashboardBindingApiResponse, + DeleteScopeDashboardBindingApiArg + >({ + query: (queryArg) => ({ + url: `/scopedashboardbindings/${queryArg.name}`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + }, + }), + invalidatesTags: ['ScopeDashboardBinding'], + }), + updateScopeDashboardBinding: build.mutation< + UpdateScopeDashboardBindingApiResponse, + UpdateScopeDashboardBindingApiArg + >({ + query: (queryArg) => ({ + url: `/scopedashboardbindings/${queryArg.name}`, + method: 'PATCH', + body: queryArg.patch, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + force: queryArg.force, + }, + }), + invalidatesTags: ['ScopeDashboardBinding'], + }), + getScopeDashboardBindingStatus: build.query< + GetScopeDashboardBindingStatusApiResponse, + GetScopeDashboardBindingStatusApiArg + >({ + query: (queryArg) => ({ + url: `/scopedashboardbindings/${queryArg.name}/status`, + params: { + pretty: queryArg.pretty, + }, + }), + providesTags: ['ScopeDashboardBinding'], + }), + replaceScopeDashboardBindingStatus: build.mutation< + ReplaceScopeDashboardBindingStatusApiResponse, + ReplaceScopeDashboardBindingStatusApiArg + >({ + query: (queryArg) => ({ + url: `/scopedashboardbindings/${queryArg.name}/status`, + method: 'PUT', + body: queryArg.scopeDashboardBinding, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['ScopeDashboardBinding'], + }), + updateScopeDashboardBindingStatus: build.mutation< + UpdateScopeDashboardBindingStatusApiResponse, + UpdateScopeDashboardBindingStatusApiArg + >({ + query: (queryArg) => ({ + url: `/scopedashboardbindings/${queryArg.name}/status`, + method: 'PATCH', + body: queryArg.patch, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + force: queryArg.force, + }, + }), + invalidatesTags: ['ScopeDashboardBinding'], + }), + listScopeNavigation: build.query({ + query: (queryArg) => ({ + url: `/scopenavigations`, + params: { + pretty: queryArg.pretty, + allowWatchBookmarks: queryArg.allowWatchBookmarks, + continue: queryArg['continue'], + fieldSelector: queryArg.fieldSelector, + labelSelector: queryArg.labelSelector, + limit: queryArg.limit, + resourceVersion: queryArg.resourceVersion, + resourceVersionMatch: queryArg.resourceVersionMatch, + sendInitialEvents: queryArg.sendInitialEvents, + timeoutSeconds: queryArg.timeoutSeconds, + watch: queryArg.watch, + }, + }), + providesTags: ['ScopeNavigation'], + }), + createScopeNavigation: build.mutation({ + query: (queryArg) => ({ + url: `/scopenavigations`, + method: 'POST', + body: queryArg.scopeNavigation, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['ScopeNavigation'], + }), + deletecollectionScopeNavigation: build.mutation< + DeletecollectionScopeNavigationApiResponse, + DeletecollectionScopeNavigationApiArg + >({ + query: (queryArg) => ({ + url: `/scopenavigations`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + continue: queryArg['continue'], + dryRun: queryArg.dryRun, + fieldSelector: queryArg.fieldSelector, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + labelSelector: queryArg.labelSelector, + limit: queryArg.limit, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + resourceVersion: queryArg.resourceVersion, + resourceVersionMatch: queryArg.resourceVersionMatch, + sendInitialEvents: queryArg.sendInitialEvents, + timeoutSeconds: queryArg.timeoutSeconds, + }, + }), + invalidatesTags: ['ScopeNavigation'], + }), + getScopeNavigation: build.query({ + query: (queryArg) => ({ + url: `/scopenavigations/${queryArg.name}`, + params: { + pretty: queryArg.pretty, + }, + }), + providesTags: ['ScopeNavigation'], + }), + replaceScopeNavigation: build.mutation({ + query: (queryArg) => ({ + url: `/scopenavigations/${queryArg.name}`, + method: 'PUT', + body: queryArg.scopeNavigation, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['ScopeNavigation'], + }), + deleteScopeNavigation: build.mutation({ + query: (queryArg) => ({ + url: `/scopenavigations/${queryArg.name}`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + }, + }), + invalidatesTags: ['ScopeNavigation'], + }), + updateScopeNavigation: build.mutation({ + query: (queryArg) => ({ + url: `/scopenavigations/${queryArg.name}`, + method: 'PATCH', + body: queryArg.patch, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + force: queryArg.force, + }, + }), + invalidatesTags: ['ScopeNavigation'], + }), + getScopeNavigationStatus: build.query({ + query: (queryArg) => ({ + url: `/scopenavigations/${queryArg.name}/status`, + params: { + pretty: queryArg.pretty, + }, + }), + providesTags: ['ScopeNavigation'], + }), + replaceScopeNavigationStatus: build.mutation< + ReplaceScopeNavigationStatusApiResponse, + ReplaceScopeNavigationStatusApiArg + >({ + query: (queryArg) => ({ + url: `/scopenavigations/${queryArg.name}/status`, + method: 'PUT', + body: queryArg.scopeNavigation, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['ScopeNavigation'], + }), + updateScopeNavigationStatus: build.mutation< + UpdateScopeNavigationStatusApiResponse, + UpdateScopeNavigationStatusApiArg + >({ + query: (queryArg) => ({ + url: `/scopenavigations/${queryArg.name}/status`, + method: 'PATCH', + body: queryArg.patch, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + force: queryArg.force, + }, + }), + invalidatesTags: ['ScopeNavigation'], + }), + listScopeNode: build.query({ + query: (queryArg) => ({ + url: `/scopenodes`, + params: { + pretty: queryArg.pretty, + allowWatchBookmarks: queryArg.allowWatchBookmarks, + continue: queryArg['continue'], + fieldSelector: queryArg.fieldSelector, + labelSelector: queryArg.labelSelector, + limit: queryArg.limit, + resourceVersion: queryArg.resourceVersion, + resourceVersionMatch: queryArg.resourceVersionMatch, + sendInitialEvents: queryArg.sendInitialEvents, + timeoutSeconds: queryArg.timeoutSeconds, + watch: queryArg.watch, + }, + }), + providesTags: ['ScopeNode'], + }), + createScopeNode: build.mutation({ + query: (queryArg) => ({ + url: `/scopenodes`, + method: 'POST', + body: queryArg.scopeNode, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['ScopeNode'], + }), + deletecollectionScopeNode: build.mutation({ + query: (queryArg) => ({ + url: `/scopenodes`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + continue: queryArg['continue'], + dryRun: queryArg.dryRun, + fieldSelector: queryArg.fieldSelector, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + labelSelector: queryArg.labelSelector, + limit: queryArg.limit, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + resourceVersion: queryArg.resourceVersion, + resourceVersionMatch: queryArg.resourceVersionMatch, + sendInitialEvents: queryArg.sendInitialEvents, + timeoutSeconds: queryArg.timeoutSeconds, + }, + }), + invalidatesTags: ['ScopeNode'], + }), + getScopeNode: build.query({ + query: (queryArg) => ({ + url: `/scopenodes/${queryArg.name}`, + params: { + pretty: queryArg.pretty, + }, + }), + providesTags: ['ScopeNode'], + }), + replaceScopeNode: build.mutation({ + query: (queryArg) => ({ + url: `/scopenodes/${queryArg.name}`, + method: 'PUT', + body: queryArg.scopeNode, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['ScopeNode'], + }), + deleteScopeNode: build.mutation({ + query: (queryArg) => ({ + url: `/scopenodes/${queryArg.name}`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + }, + }), + invalidatesTags: ['ScopeNode'], + }), + updateScopeNode: build.mutation({ + query: (queryArg) => ({ + url: `/scopenodes/${queryArg.name}`, + method: 'PATCH', + body: queryArg.patch, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + force: queryArg.force, + }, + }), + invalidatesTags: ['ScopeNode'], + }), + listScope: build.query({ + query: (queryArg) => ({ + url: `/scopes`, + params: { + pretty: queryArg.pretty, + allowWatchBookmarks: queryArg.allowWatchBookmarks, + continue: queryArg['continue'], + fieldSelector: queryArg.fieldSelector, + labelSelector: queryArg.labelSelector, + limit: queryArg.limit, + resourceVersion: queryArg.resourceVersion, + resourceVersionMatch: queryArg.resourceVersionMatch, + sendInitialEvents: queryArg.sendInitialEvents, + timeoutSeconds: queryArg.timeoutSeconds, + watch: queryArg.watch, + }, + }), + providesTags: ['Scope'], + }), + createScope: build.mutation({ + query: (queryArg) => ({ + url: `/scopes`, + method: 'POST', + body: queryArg.scope, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['Scope'], + }), + deletecollectionScope: build.mutation({ + query: (queryArg) => ({ + url: `/scopes`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + continue: queryArg['continue'], + dryRun: queryArg.dryRun, + fieldSelector: queryArg.fieldSelector, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + labelSelector: queryArg.labelSelector, + limit: queryArg.limit, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + resourceVersion: queryArg.resourceVersion, + resourceVersionMatch: queryArg.resourceVersionMatch, + sendInitialEvents: queryArg.sendInitialEvents, + timeoutSeconds: queryArg.timeoutSeconds, + }, + }), + invalidatesTags: ['Scope'], + }), + getScope: build.query({ + query: (queryArg) => ({ + url: `/scopes/${queryArg.name}`, + params: { + pretty: queryArg.pretty, + }, + }), + providesTags: ['Scope'], + }), + replaceScope: build.mutation({ + query: (queryArg) => ({ + url: `/scopes/${queryArg.name}`, + method: 'PUT', + body: queryArg.scope, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['Scope'], + }), + deleteScope: build.mutation({ + query: (queryArg) => ({ + url: `/scopes/${queryArg.name}`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + }, + }), + invalidatesTags: ['Scope'], + }), + updateScope: build.mutation({ + query: (queryArg) => ({ + url: `/scopes/${queryArg.name}`, + method: 'PATCH', + body: queryArg.patch, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + force: queryArg.force, + }, + }), + invalidatesTags: ['Scope'], + }), + }), + overrideExisting: false, + }); +export { injectedRtkApi as generatedAPI }; +export type GetApiResourcesApiResponse = /** status 200 OK */ ApiResourceList; +export type GetApiResourcesApiArg = void; +export type GetFindScopeDashboardBindingsResultsApiResponse = /** status 200 OK */ FindScopeDashboardBindingsResults; +export type GetFindScopeDashboardBindingsResultsApiArg = { + /** name of the FindScopeDashboardBindingsResults */ + name: string; + /** A scope name (id) to match against, this parameter may be repeated */ + scope?: string[]; +}; +export type GetFindScopeNavigationsResultsApiResponse = /** status 200 OK */ FindScopeNavigationsResults; +export type GetFindScopeNavigationsResultsApiArg = { + /** name of the FindScopeNavigationsResults */ + name: string; + /** A scope name (id) to match against, this parameter may be repeated */ + scope?: string[]; +}; +export type GetFindScopeNodeChildrenResultsApiResponse = /** status 200 OK */ FindScopeNodeChildrenResults; +export type GetFindScopeNodeChildrenResultsApiArg = { + /** The parent scope node */ + parent?: string; + query?: string; + names?: string[]; + limit?: number; +}; +export type ListScopeDashboardBindingApiResponse = /** status 200 OK */ ScopeDashboardBindingList; +export type ListScopeDashboardBindingApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** allowWatchBookmarks requests watch events with type "BOOKMARK". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored. */ + allowWatchBookmarks?: boolean; + /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". + + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ + continue?: string; + /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ + fieldSelector?: string; + /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ + labelSelector?: string; + /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. + + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ + limit?: number; + /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersion?: string; + /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersionMatch?: string; + /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. + + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan + is interpreted as "data at least as new as the provided `resourceVersion`" + and the bookmark event is send when the state is synced + to a `resourceVersion` at least as fresh as the one provided by the ListOptions. + If `resourceVersion` is unset, this is interpreted as "consistent read" and the + bookmark event is send when the state is synced at least to the moment + when request started being processed. + - `resourceVersionMatch` set to any other value or unset + Invalid error is returned. + + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ + sendInitialEvents?: boolean; + /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ + timeoutSeconds?: number; + /** Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion. */ + watch?: boolean; +}; +export type CreateScopeDashboardBindingApiResponse = /** status 200 OK */ + | ScopeDashboardBinding + | /** status 201 Created */ ScopeDashboardBinding + | /** status 202 Accepted */ ScopeDashboardBinding; +export type CreateScopeDashboardBindingApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + scopeDashboardBinding: ScopeDashboardBinding; +}; +export type DeletecollectionScopeDashboardBindingApiResponse = /** status 200 OK */ Status; +export type DeletecollectionScopeDashboardBindingApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". + + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ + continue?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ + fieldSelector?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ + labelSelector?: string; + /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. + + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ + limit?: number; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; + /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersion?: string; + /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersionMatch?: string; + /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. + + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan + is interpreted as "data at least as new as the provided `resourceVersion`" + and the bookmark event is send when the state is synced + to a `resourceVersion` at least as fresh as the one provided by the ListOptions. + If `resourceVersion` is unset, this is interpreted as "consistent read" and the + bookmark event is send when the state is synced at least to the moment + when request started being processed. + - `resourceVersionMatch` set to any other value or unset + Invalid error is returned. + + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ + sendInitialEvents?: boolean; + /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ + timeoutSeconds?: number; +}; +export type GetScopeDashboardBindingApiResponse = /** status 200 OK */ ScopeDashboardBinding; +export type GetScopeDashboardBindingApiArg = { + /** name of the ScopeDashboardBinding */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; +}; +export type ReplaceScopeDashboardBindingApiResponse = /** status 200 OK */ + | ScopeDashboardBinding + | /** status 201 Created */ ScopeDashboardBinding; +export type ReplaceScopeDashboardBindingApiArg = { + /** name of the ScopeDashboardBinding */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + scopeDashboardBinding: ScopeDashboardBinding; +}; +export type DeleteScopeDashboardBindingApiResponse = /** status 200 OK */ Status | /** status 202 Accepted */ Status; +export type DeleteScopeDashboardBindingApiArg = { + /** name of the ScopeDashboardBinding */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; +}; +export type UpdateScopeDashboardBindingApiResponse = /** status 200 OK */ + | ScopeDashboardBinding + | /** status 201 Created */ ScopeDashboardBinding; +export type UpdateScopeDashboardBindingApiArg = { + /** name of the ScopeDashboardBinding */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ + force?: boolean; + patch: Patch; +}; +export type GetScopeDashboardBindingStatusApiResponse = /** status 200 OK */ ScopeDashboardBinding; +export type GetScopeDashboardBindingStatusApiArg = { + /** name of the ScopeDashboardBinding */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; +}; +export type ReplaceScopeDashboardBindingStatusApiResponse = /** status 200 OK */ + | ScopeDashboardBinding + | /** status 201 Created */ ScopeDashboardBinding; +export type ReplaceScopeDashboardBindingStatusApiArg = { + /** name of the ScopeDashboardBinding */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + scopeDashboardBinding: ScopeDashboardBinding; +}; +export type UpdateScopeDashboardBindingStatusApiResponse = /** status 200 OK */ + | ScopeDashboardBinding + | /** status 201 Created */ ScopeDashboardBinding; +export type UpdateScopeDashboardBindingStatusApiArg = { + /** name of the ScopeDashboardBinding */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ + force?: boolean; + patch: Patch; +}; +export type ListScopeNavigationApiResponse = /** status 200 OK */ ScopeNavigationList; +export type ListScopeNavigationApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** allowWatchBookmarks requests watch events with type "BOOKMARK". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored. */ + allowWatchBookmarks?: boolean; + /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". + + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ + continue?: string; + /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ + fieldSelector?: string; + /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ + labelSelector?: string; + /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. + + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ + limit?: number; + /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersion?: string; + /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersionMatch?: string; + /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. + + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan + is interpreted as "data at least as new as the provided `resourceVersion`" + and the bookmark event is send when the state is synced + to a `resourceVersion` at least as fresh as the one provided by the ListOptions. + If `resourceVersion` is unset, this is interpreted as "consistent read" and the + bookmark event is send when the state is synced at least to the moment + when request started being processed. + - `resourceVersionMatch` set to any other value or unset + Invalid error is returned. + + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ + sendInitialEvents?: boolean; + /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ + timeoutSeconds?: number; + /** Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion. */ + watch?: boolean; +}; +export type CreateScopeNavigationApiResponse = /** status 200 OK */ + | ScopeNavigation + | /** status 201 Created */ ScopeNavigation + | /** status 202 Accepted */ ScopeNavigation; +export type CreateScopeNavigationApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + scopeNavigation: ScopeNavigation; +}; +export type DeletecollectionScopeNavigationApiResponse = /** status 200 OK */ Status; +export type DeletecollectionScopeNavigationApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". + + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ + continue?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ + fieldSelector?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ + labelSelector?: string; + /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. + + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ + limit?: number; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; + /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersion?: string; + /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersionMatch?: string; + /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. + + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan + is interpreted as "data at least as new as the provided `resourceVersion`" + and the bookmark event is send when the state is synced + to a `resourceVersion` at least as fresh as the one provided by the ListOptions. + If `resourceVersion` is unset, this is interpreted as "consistent read" and the + bookmark event is send when the state is synced at least to the moment + when request started being processed. + - `resourceVersionMatch` set to any other value or unset + Invalid error is returned. + + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ + sendInitialEvents?: boolean; + /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ + timeoutSeconds?: number; +}; +export type GetScopeNavigationApiResponse = /** status 200 OK */ ScopeNavigation; +export type GetScopeNavigationApiArg = { + /** name of the ScopeNavigation */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; +}; +export type ReplaceScopeNavigationApiResponse = /** status 200 OK */ + | ScopeNavigation + | /** status 201 Created */ ScopeNavigation; +export type ReplaceScopeNavigationApiArg = { + /** name of the ScopeNavigation */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + scopeNavigation: ScopeNavigation; +}; +export type DeleteScopeNavigationApiResponse = /** status 200 OK */ Status | /** status 202 Accepted */ Status; +export type DeleteScopeNavigationApiArg = { + /** name of the ScopeNavigation */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; +}; +export type UpdateScopeNavigationApiResponse = /** status 200 OK */ + | ScopeNavigation + | /** status 201 Created */ ScopeNavigation; +export type UpdateScopeNavigationApiArg = { + /** name of the ScopeNavigation */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ + force?: boolean; + patch: Patch; +}; +export type GetScopeNavigationStatusApiResponse = /** status 200 OK */ ScopeNavigation; +export type GetScopeNavigationStatusApiArg = { + /** name of the ScopeNavigation */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; +}; +export type ReplaceScopeNavigationStatusApiResponse = /** status 200 OK */ + | ScopeNavigation + | /** status 201 Created */ ScopeNavigation; +export type ReplaceScopeNavigationStatusApiArg = { + /** name of the ScopeNavigation */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + scopeNavigation: ScopeNavigation; +}; +export type UpdateScopeNavigationStatusApiResponse = /** status 200 OK */ + | ScopeNavigation + | /** status 201 Created */ ScopeNavigation; +export type UpdateScopeNavigationStatusApiArg = { + /** name of the ScopeNavigation */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ + force?: boolean; + patch: Patch; +}; +export type ListScopeNodeApiResponse = /** status 200 OK */ ScopeNodeList; +export type ListScopeNodeApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** allowWatchBookmarks requests watch events with type "BOOKMARK". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored. */ + allowWatchBookmarks?: boolean; + /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". + + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ + continue?: string; + /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ + fieldSelector?: string; + /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ + labelSelector?: string; + /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. + + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ + limit?: number; + /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersion?: string; + /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersionMatch?: string; + /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. + + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan + is interpreted as "data at least as new as the provided `resourceVersion`" + and the bookmark event is send when the state is synced + to a `resourceVersion` at least as fresh as the one provided by the ListOptions. + If `resourceVersion` is unset, this is interpreted as "consistent read" and the + bookmark event is send when the state is synced at least to the moment + when request started being processed. + - `resourceVersionMatch` set to any other value or unset + Invalid error is returned. + + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ + sendInitialEvents?: boolean; + /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ + timeoutSeconds?: number; + /** Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion. */ + watch?: boolean; +}; +export type CreateScopeNodeApiResponse = /** status 200 OK */ + | ScopeNode + | /** status 201 Created */ ScopeNode + | /** status 202 Accepted */ ScopeNode; +export type CreateScopeNodeApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + scopeNode: ScopeNode; +}; +export type DeletecollectionScopeNodeApiResponse = /** status 200 OK */ Status; +export type DeletecollectionScopeNodeApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". + + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ + continue?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ + fieldSelector?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ + labelSelector?: string; + /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. + + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ + limit?: number; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; + /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersion?: string; + /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersionMatch?: string; + /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. + + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan + is interpreted as "data at least as new as the provided `resourceVersion`" + and the bookmark event is send when the state is synced + to a `resourceVersion` at least as fresh as the one provided by the ListOptions. + If `resourceVersion` is unset, this is interpreted as "consistent read" and the + bookmark event is send when the state is synced at least to the moment + when request started being processed. + - `resourceVersionMatch` set to any other value or unset + Invalid error is returned. + + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ + sendInitialEvents?: boolean; + /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ + timeoutSeconds?: number; +}; +export type GetScopeNodeApiResponse = /** status 200 OK */ ScopeNode; +export type GetScopeNodeApiArg = { + /** name of the ScopeNode */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; +}; +export type ReplaceScopeNodeApiResponse = /** status 200 OK */ ScopeNode | /** status 201 Created */ ScopeNode; +export type ReplaceScopeNodeApiArg = { + /** name of the ScopeNode */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + scopeNode: ScopeNode; +}; +export type DeleteScopeNodeApiResponse = /** status 200 OK */ Status | /** status 202 Accepted */ Status; +export type DeleteScopeNodeApiArg = { + /** name of the ScopeNode */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; +}; +export type UpdateScopeNodeApiResponse = /** status 200 OK */ ScopeNode | /** status 201 Created */ ScopeNode; +export type UpdateScopeNodeApiArg = { + /** name of the ScopeNode */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ + force?: boolean; + patch: Patch; +}; +export type ListScopeApiResponse = /** status 200 OK */ ScopeList; +export type ListScopeApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** allowWatchBookmarks requests watch events with type "BOOKMARK". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored. */ + allowWatchBookmarks?: boolean; + /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". + + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ + continue?: string; + /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ + fieldSelector?: string; + /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ + labelSelector?: string; + /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. + + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ + limit?: number; + /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersion?: string; + /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersionMatch?: string; + /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. + + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan + is interpreted as "data at least as new as the provided `resourceVersion`" + and the bookmark event is send when the state is synced + to a `resourceVersion` at least as fresh as the one provided by the ListOptions. + If `resourceVersion` is unset, this is interpreted as "consistent read" and the + bookmark event is send when the state is synced at least to the moment + when request started being processed. + - `resourceVersionMatch` set to any other value or unset + Invalid error is returned. + + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ + sendInitialEvents?: boolean; + /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ + timeoutSeconds?: number; + /** Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion. */ + watch?: boolean; +}; +export type CreateScopeApiResponse = /** status 200 OK */ + | Scope + | /** status 201 Created */ Scope + | /** status 202 Accepted */ Scope; +export type CreateScopeApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + scope: Scope; +}; +export type DeletecollectionScopeApiResponse = /** status 200 OK */ Status; +export type DeletecollectionScopeApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". + + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ + continue?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ + fieldSelector?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ + labelSelector?: string; + /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. + + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ + limit?: number; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; + /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersion?: string; + /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersionMatch?: string; + /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. + + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan + is interpreted as "data at least as new as the provided `resourceVersion`" + and the bookmark event is send when the state is synced + to a `resourceVersion` at least as fresh as the one provided by the ListOptions. + If `resourceVersion` is unset, this is interpreted as "consistent read" and the + bookmark event is send when the state is synced at least to the moment + when request started being processed. + - `resourceVersionMatch` set to any other value or unset + Invalid error is returned. + + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ + sendInitialEvents?: boolean; + /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ + timeoutSeconds?: number; +}; +export type GetScopeApiResponse = /** status 200 OK */ Scope; +export type GetScopeApiArg = { + /** name of the Scope */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; +}; +export type ReplaceScopeApiResponse = /** status 200 OK */ Scope | /** status 201 Created */ Scope; +export type ReplaceScopeApiArg = { + /** name of the Scope */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + scope: Scope; +}; +export type DeleteScopeApiResponse = /** status 200 OK */ Status | /** status 202 Accepted */ Status; +export type DeleteScopeApiArg = { + /** name of the Scope */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; +}; +export type UpdateScopeApiResponse = /** status 200 OK */ Scope | /** status 201 Created */ Scope; +export type UpdateScopeApiArg = { + /** name of the Scope */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ + force?: boolean; + patch: Patch; +}; +export type ApiResource = { + /** categories is a list of the grouped resources this resource belongs to (e.g. 'all') */ + categories?: string[]; + /** group is the preferred group of the resource. Empty implies the group of the containing resource list. For subresources, this may have a different value, for example: Scale". */ + group?: string; + /** kind is the kind for the resource (e.g. 'Foo' is the kind for a resource 'foo') */ + kind: string; + /** name is the plural name of the resource. */ + name: string; + /** namespaced indicates if a resource is namespaced or not. */ + namespaced: boolean; + /** shortNames is a list of suggested short names of the resource. */ + shortNames?: string[]; + /** singularName is the singular name of the resource. This allows clients to handle plural and singular opaquely. The singularName is more correct for reporting status on a single item and both singular and plural are allowed from the kubectl CLI interface. */ + singularName: string; + /** The hash value of the storage version, the version this resource is converted to when written to the data store. Value must be treated as opaque by clients. Only equality comparison on the value is valid. This is an alpha feature and may change or be removed in the future. The field is populated by the apiserver only if the StorageVersionHash feature gate is enabled. This field will remain optional even if it graduates. */ + storageVersionHash?: string; + /** verbs is a list of supported kube verbs (this includes get, list, watch, create, update, patch, delete, deletecollection, and proxy) */ + verbs: string[]; + /** version is the preferred version of the resource. Empty implies the version of the containing resource list For subresources, this may have a different value, for example: v1 (while inside a v1beta1 version of the core resource's group)". */ + version?: string; +}; +export type ApiResourceList = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** groupVersion is the group and version this APIResourceList is for. */ + groupVersion: string; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** resources contains the name of the resources and if they are namespaced. */ + resources: ApiResource[]; +}; +export type Time = string; +export type FieldsV1 = object; +export type ManagedFieldsEntry = { + /** APIVersion defines the version of this resource that this field set applies to. The format is "group/version" just like the top-level APIVersion field. It is necessary to track the version of a field set because it cannot be automatically converted. */ + apiVersion?: string; + /** FieldsType is the discriminator for the different fields format and version. There is currently only one possible value: "FieldsV1" */ + fieldsType?: string; + /** FieldsV1 holds the first JSON version format as described in the "FieldsV1" type. */ + fieldsV1?: FieldsV1; + /** Manager is an identifier of the workflow managing these fields. */ + manager?: string; + /** Operation is the type of operation which lead to this ManagedFieldsEntry being created. The only valid values for this field are 'Apply' and 'Update'. */ + operation?: string; + /** Subresource is the name of the subresource used to update that object, or empty string if the object was updated through the main resource. The value of this field is used to distinguish between managers, even if they share the same name. For example, a status update will be distinct from a regular update using the same manager name. Note that the APIVersion field is not related to the Subresource field and it always corresponds to the version of the main resource. */ + subresource?: string; + /** Time is the timestamp of when the ManagedFields entry was added. The timestamp will also be updated if a field is added, the manager changes any of the owned fields value or removes a field. The timestamp does not update when a field is removed from the entry because another manager took it over. */ + time?: Time; +}; +export type OwnerReference = { + /** API version of the referent. */ + apiVersion: string; + /** If true, AND if the owner has the "foregroundDeletion" finalizer, then the owner cannot be deleted from the key-value store until this reference is removed. See https://kubernetes.io/docs/concepts/architecture/garbage-collection/#foreground-deletion for how the garbage collector interacts with this field and enforces the foreground deletion. Defaults to false. To set this field, a user needs "delete" permission of the owner, otherwise 422 (Unprocessable Entity) will be returned. */ + blockOwnerDeletion?: boolean; + /** If true, this reference points to the managing controller. */ + controller?: boolean; + /** Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind: string; + /** Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names */ + name: string; + /** UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid: string; +}; +export type ObjectMeta = { + /** Annotations is an unstructured key value map stored with a resource that may be set by external tools to store and retrieve arbitrary metadata. They are not queryable and should be preserved when modifying objects. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations */ + annotations?: { + [key: string]: string; + }; + /** CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC. + + Populated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata */ + creationTimestamp?: Time; + /** Number of seconds allowed for this object to gracefully terminate before it will be removed from the system. Only set when deletionTimestamp is also set. May only be shortened. Read-only. */ + deletionGracePeriodSeconds?: number; + /** DeletionTimestamp is RFC 3339 date and time at which this resource will be deleted. This field is set by the server when a graceful deletion is requested by the user, and is not directly settable by a client. The resource is expected to be deleted (no longer visible from resource lists, and not reachable by name) after the time in this field, once the finalizers list is empty. As long as the finalizers list contains items, deletion is blocked. Once the deletionTimestamp is set, this value may not be unset or be set further into the future, although it may be shortened or the resource may be deleted prior to this time. For example, a user may request that a pod is deleted in 30 seconds. The Kubelet will react by sending a graceful termination signal to the containers in the pod. After that 30 seconds, the Kubelet will send a hard termination signal (SIGKILL) to the container and after cleanup, remove the pod from the API. In the presence of network partitions, this object may still exist after this timestamp, until an administrator or automated process can determine the resource is fully terminated. If not set, graceful deletion of the object has not been requested. + + Populated by the system when a graceful deletion is requested. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata */ + deletionTimestamp?: Time; + /** Must be empty before the object is deleted from the registry. Each entry is an identifier for the responsible component that will remove the entry from the list. If the deletionTimestamp of the object is non-nil, entries in this list can only be removed. Finalizers may be processed and removed in any order. Order is NOT enforced because it introduces significant risk of stuck finalizers. finalizers is a shared field, any actor with permission can reorder it. If the finalizer list is processed in order, then this can lead to a situation in which the component responsible for the first finalizer in the list is waiting for a signal (field value, external system, or other) produced by a component responsible for a finalizer later in the list, resulting in a deadlock. Without enforced ordering finalizers are free to order amongst themselves and are not vulnerable to ordering changes in the list. */ + finalizers?: string[]; + /** GenerateName is an optional prefix, used by the server, to generate a unique name ONLY IF the Name field has not been provided. If this field is used, the name returned to the client will be different than the name passed. This value will also be combined with a unique suffix. The provided value has the same validation rules as the Name field, and may be truncated by the length of the suffix required to make the value unique on the server. + + If this field is specified and the generated name exists, the server will return a 409. + + Applied only if Name is not specified. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency */ + generateName?: string; + /** A sequence number representing a specific generation of the desired state. Populated by the system. Read-only. */ + generation?: number; + /** Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels */ + labels?: { + [key: string]: string; + }; + /** ManagedFields maps workflow-id and version to the set of fields that are managed by that workflow. This is mostly for internal housekeeping, and users typically shouldn't need to set or understand this field. A workflow can be the user's name, a controller's name, or the name of a specific apply path like "ci-cd". The set of fields is always in the version that the workflow used when modifying the object. */ + managedFields?: ManagedFieldsEntry[]; + /** Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names */ + name?: string; + /** Namespace defines the space within which each name must be unique. An empty namespace is equivalent to the "default" namespace, but "default" is the canonical representation. Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty. + + Must be a DNS_LABEL. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces */ + namespace?: string; + /** List of objects depended by this object. If ALL objects in the list have been deleted, this object will be garbage collected. If this object is managed by a controller, then an entry in this list will point to this controller, with the controller field set to true. There cannot be more than one managing controller. */ + ownerReferences?: OwnerReference[]; + /** An opaque value that represents the internal version of this object that can be used by clients to determine when objects have changed. May be used for optimistic concurrency, change detection, and the watch operation on a resource or set of resources. Clients must treat these values as opaque and passed unmodified back to the server. They may only be valid for a particular resource or set of resources. + + Populated by the system. Read-only. Value must be treated as opaque by clients and . More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency */ + resourceVersion?: string; + /** Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. */ + selfLink?: string; + /** UID is the unique in time and space value for this object. It is typically generated by the server on successful creation of a resource and is not allowed to change on PUT operations. + + Populated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid?: string; +}; +export type ScopeDashboardBindingSpec = { + dashboard: string; + scope: string; +}; +export type Condition = { + /** lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. */ + lastTransitionTime: Time; + /** message is a human readable message indicating details about the transition. This may be an empty string. */ + message: string; + /** observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. */ + observedGeneration?: number; + /** reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. */ + reason: string; + /** status of the condition, one of True, False, Unknown. */ + status: string; + /** type of condition in CamelCase or in foo.example.com/CamelCase. */ + type: string; +}; +export type ScopeDashboardBindingStatus = { + /** DashboardTitle should be populated and update from the dashboard */ + dashboardTitle: string; + /** DashboardTitleConditions is a list of conditions that are used to determine if the dashboard title is valid. */ + dashboardTitleConditions?: Condition[]; + /** Groups is used for the grouping of dashboards that are suggested based on a scope. The source of truth for this information has not been determined yet. */ + groups?: string[]; + /** DashboardTitleConditions is a list of conditions that are used to determine if the list of groups is valid. */ + groupsConditions?: Condition[]; +}; +export type ScopeDashboardBinding = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata?: ObjectMeta; + spec?: ScopeDashboardBindingSpec; + status?: ScopeDashboardBindingStatus; +}; +export type FindScopeDashboardBindingsResults = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + items?: ScopeDashboardBinding[]; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + message?: string; +}; +export type ScopeNavigationSpec = { + /** Makes the subscope not selectable, only serving as a way to build the tree. */ + disableSubScopeSelection?: boolean; + + /** Preload the subscope children, as soon as the ScopeNavigation is loaded. */ + preLoadSubScopeChildren?: boolean; + scope: string; + /** Used to navigate to a sub-scope of the main scope. URL will not be used if this is set. */ + subScope?: string; + url: string; +}; +export type ScopeNavigationStatus = { + /** Groups is used for the grouping of dashboards that are suggested based on a scope. The source of truth for this information has not been determined yet. */ + groups?: string[]; + /** GroupsConditions is a list of conditions that are used to determine if the list of groups is valid. */ + groupsConditions?: Condition[]; + /** Title should be populated and update from the dashboard */ + title: string; + /** TitleConditions is a list of conditions that are used to determine if the title is valid. */ + titleConditions?: Condition[]; +}; +export type ScopeNavigation = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata?: ObjectMeta; + spec?: ScopeNavigationSpec; + status?: ScopeNavigationStatus; +}; +export type FindScopeNavigationsResults = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + items?: ScopeNavigation[]; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + message?: string; +}; +export type ScopeNodeSpec = { + description?: string; + disableMultiSelect: boolean; + /** scope (later more things) */ + linkId?: string; + /** Possible enum values: + - `"scope"` */ + linkType?: 'scope'; + nodeType: string; + parentName?: string; + /** Redirect to a specific path when this node is selected. */ + redirectPath?: string; + /** Displays next to the title to provide more context. */ + subTitle?: string; + title: string; +}; +export type ScopeNode = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata?: ObjectMeta; + spec?: ScopeNodeSpec; +}; +export type ListMeta = { + /** continue may be set if the user set a limit on the number of items returned, and indicates that the server has more data available. The value is opaque and may be used to issue another request to the endpoint that served this list to retrieve the next set of available objects. Continuing a consistent list may not be possible if the server configuration has changed or more than a few minutes have passed. The resourceVersion field returned when using this continue value will be identical to the value in the first response, unless you have received this token from an error message. */ + continue?: string; + /** remainingItemCount is the number of subsequent items in the list which are not included in this list response. If the list request contained label or field selectors, then the number of remaining items is unknown and the field will be left unset and omitted during serialization. If the list is complete (either because it is not chunking or because this is the last chunk), then there are no more remaining items and this field will be left unset and omitted during serialization. Servers older than v1.15 do not set this field. The intended use of the remainingItemCount is *estimating* the size of a collection. Clients should not rely on the remainingItemCount to be set or to be exact. */ + remainingItemCount?: number; + /** String that identifies the server's internal version of this object that can be used by clients to determine when objects have changed. Value must be treated as opaque by clients and passed unmodified back to the server. Populated by the system. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency */ + resourceVersion?: string; + /** Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. */ + selfLink?: string; +}; +export type FindScopeNodeChildrenResults = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + items?: ScopeNode[]; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata?: ListMeta; +}; +export type ScopeDashboardBindingList = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + items?: ScopeDashboardBinding[]; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata?: ListMeta; +}; +export type StatusCause = { + /** The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional. + + Examples: + "name" - the field "name" on the current resource + "items[0].name" - the field "name" on the first array entry in "items" */ + field?: string; + /** A human-readable description of the cause of the error. This field may be presented as-is to a reader. */ + message?: string; + /** A machine-readable description of the cause of the error. If this value is empty there is no information available. */ + reason?: string; +}; +export type StatusDetails = { + /** The Causes array includes more details associated with the StatusReason failure. Not all StatusReasons may provide detailed causes. */ + causes?: StatusCause[]; + /** The group attribute of the resource associated with the status StatusReason. */ + group?: string; + /** The kind attribute of the resource associated with the status StatusReason. On some operations may differ from the requested resource Kind. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described). */ + name?: string; + /** If specified, the time in seconds before the operation should be retried. Some errors may indicate the client must take an alternate action - for those errors this field may indicate how long to wait before taking the alternate action. */ + retryAfterSeconds?: number; + /** UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid?: string; +}; +export type Status = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** Suggested HTTP return code for this status, 0 if not set. */ + code?: number; + /** Extended data associated with the reason. Each reason may define its own extended details. This field is optional and the data returned is not guaranteed to conform to any schema except that defined by the reason type. */ + details?: StatusDetails; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** A human-readable description of the status of this operation. */ + message?: string; + /** Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + metadata?: ListMeta; + /** A machine-readable description of why this operation is in the "Failure" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it. */ + reason?: string; + /** Status of the operation. One of: "Success" or "Failure". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status */ + status?: string; +}; +export type Patch = object; +export type ScopeNavigationList = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + items?: ScopeNavigation[]; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata?: ListMeta; +}; +export type ScopeNodeList = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + items?: ScopeNode[]; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata?: ListMeta; +}; +export type ScopeFilter = { + key: string; + /** Possible enum values: + - `"equals"` + - `"not-equals"` + - `"not-one-of"` + - `"one-of"` + - `"regex-match"` + - `"regex-not-match"` */ + operator: 'equals' | 'not-equals' | 'not-one-of' | 'one-of' | 'regex-match' | 'regex-not-match'; + value: string; + /** Values is used for operators that require multiple values (e.g. one-of and not-one-of). */ + values?: string[]; +}; +export type ScopeSpec = { + /** Provides a default path for the scope. This refers to a list of nodes in the selector. This is used to display the title next to the selected scope and expand the selector to the proper path. This will override whichever is selected from in the selector. The path is a list of node ids, starting at the direct parent of the selected node towards the root. */ + defaultPath?: string[]; + filters?: ScopeFilter[]; + title: string; +}; +export type Scope = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata?: ObjectMeta; + spec?: ScopeSpec; +}; +export type ScopeList = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + items?: Scope[]; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata?: ListMeta; +}; diff --git a/public/app/api/clients/scope/v0alpha1/index.ts b/public/app/api/clients/scope/v0alpha1/index.ts new file mode 100644 index 00000000000..9b1365f667e --- /dev/null +++ b/public/app/api/clients/scope/v0alpha1/index.ts @@ -0,0 +1,3 @@ +import { generatedAPI } from './endpoints.gen'; + +export const scopeAPIv0alpha1 = generatedAPI; diff --git a/public/app/api/clients/scope/v0alpha1/sync-from-enterprise.sh b/public/app/api/clients/scope/v0alpha1/sync-from-enterprise.sh new file mode 100755 index 00000000000..2e5cfa2c762 --- /dev/null +++ b/public/app/api/clients/scope/v0alpha1/sync-from-enterprise.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Syncs the scope API client from Enterprise to OSS. +# +# This script: +# 1. Regenerates the Enterprise API client from the OpenAPI spec +# 2. Copies the generated endpoints.gen.ts to OSS +# +# Prerequisites: +# - The OpenAPI spec must exist at data/openapi/scope.grafana.app-v0alpha1.json +# (generated by running TestIntegrationOpenAPIs in pkg/extensions/apiserver/tests/) +# +# Usage: ./sync-from-enterprise.sh + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GRAFANA_ROOT="$(cd "$SCRIPT_DIR/../../../../.." && pwd)" + +# Source and destination directories for the generated API client +ENTERPRISE_SCOPE_API_DIR="$GRAFANA_ROOT/public/app/extensions/api/clients/scope/v0alpha1" +OSS_SCOPE_API_DIR="$SCRIPT_DIR" + +cd "$GRAFANA_ROOT" + +# Check if OpenAPI spec exists +if [ ! -f "data/openapi/scope.grafana.app-v0alpha1.json" ]; then + echo "Error: OpenAPI spec not found at data/openapi/scope.grafana.app-v0alpha1.json" + echo "Run TestIntegrationOpenAPIs in pkg/extensions/apiserver/tests/ to generate it." + exit 1 +fi + +echo "Step 1: Generating Enterprise API client from OpenAPI spec..." +yarn workspace @grafana/api-clients process-specs && npx rtk-query-codegen-openapi ./local/generate-enterprise-apis.ts + +if [ ! -f "$ENTERPRISE_SCOPE_API_DIR/endpoints.gen.ts" ]; then + echo "Error: Enterprise endpoints.gen.ts not found after generation" + exit 1 +fi + +echo "Step 2: Copying endpoints.gen.ts from Enterprise to OSS..." +cp "$ENTERPRISE_SCOPE_API_DIR/endpoints.gen.ts" "$OSS_SCOPE_API_DIR/endpoints.gen.ts" + +echo "Done! Scope API client synced from Enterprise." diff --git a/public/app/api/utils.ts b/public/app/api/utils.ts index 3866bbe977d..9efa6940650 100644 --- a/public/app/api/utils.ts +++ b/public/app/api/utils.ts @@ -1,8 +1,8 @@ import { normalizeError } from '@grafana/api-clients'; import { ThunkDispatch } from 'app/types/store'; -import { notifyApp } from '../core/actions'; import { createErrorNotification } from '../core/copy/appNotification'; +import { notifyApp } from '../core/reducers/appNotification'; /** * Handle an error from a k8s API call diff --git a/public/app/core/actions/index.ts b/public/app/core/actions/index.ts deleted file mode 100644 index d73c489b33e..00000000000 --- a/public/app/core/actions/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { hideAppNotification, notifyApp } from '../reducers/appNotification'; -import { updateNavIndex, updateConfigurationSubtitle } from '../reducers/navModel'; - -export { updateNavIndex, updateConfigurationSubtitle, notifyApp, hideAppNotification }; diff --git a/public/app/core/components/AppChrome/AppChromeService.tsx b/public/app/core/components/AppChrome/AppChromeService.tsx index e340e8276b8..131b2f48ea2 100644 --- a/public/app/core/components/AppChrome/AppChromeService.tsx +++ b/public/app/core/components/AppChrome/AppChromeService.tsx @@ -10,11 +10,8 @@ import { isShallowEqual } from 'app/core/utils/isShallowEqual'; import { KioskMode } from 'app/types/dashboard'; import { RouteDescriptor } from '../../navigation/types'; -import { buildBreadcrumbs } from '../Breadcrumbs/utils'; -import { logDuplicateUnifiedHistoryEntryEvent } from './History/eventsTracking'; import { ReturnToPreviousProps } from './ReturnToPrevious/ReturnToPrevious'; -import { HistoryEntry } from './types'; export interface AppChromeState { chromeless?: boolean; @@ -34,7 +31,6 @@ export interface AppChromeState { export const DOCKED_LOCAL_STORAGE_KEY = 'grafana.navigation.docked'; export const DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY = 'grafana.navigation.open'; -export const HISTORY_LOCAL_STORAGE_KEY = 'grafana.navigation.history'; export class AppChromeService { searchBarStorageKey = 'SearchBar_Hidden'; @@ -88,8 +84,6 @@ export class AppChromeService { newState.chromeless = newState.kioskMode === KioskMode.Full || this.currentRoute?.chromeless; if (!this.ignoreStateUpdate(newState, current)) { - config.featureToggles.unifiedHistory && - store.setObject(HISTORY_LOCAL_STORAGE_KEY, this.getUpdatedHistory(newState)); this.state.next(newState); } } @@ -118,40 +112,6 @@ export class AppChromeService { window.sessionStorage.removeItem('returnToPrevious'); }; - private getUpdatedHistory(newState: AppChromeState): HistoryEntry[] { - const breadcrumbs = buildBreadcrumbs(newState.sectionNav.node, newState.pageNav, { text: 'Home', url: '/' }); - const newPageNav = newState.pageNav || newState.sectionNav.node; - - let entries = store.getObject(HISTORY_LOCAL_STORAGE_KEY, []); - const clickedHistory = store.getObject('CLICKING_HISTORY'); - if (clickedHistory) { - store.setObject('CLICKING_HISTORY', false); - return entries; - } - if (!newPageNav) { - return entries; - } - - const lastEntry = entries[0]; - const newEntry = { name: newPageNav.text, views: [], breadcrumbs, time: Date.now(), url: window.location.href }; - const isSamePath = lastEntry && newEntry.url.split('?')[0] === lastEntry.url.split('?')[0]; - - // To avoid adding an entry with the same path twice, we always use the latest one - if (isSamePath) { - entries[0] = newEntry; - } else { - if (lastEntry && lastEntry.name === newEntry.name) { - logDuplicateUnifiedHistoryEntryEvent({ - entryName: newEntry.name, - lastEntryURL: lastEntry.url, - newEntryURL: newEntry.url, - }); - } - entries = [newEntry, ...entries]; - } - - return entries; - } private ignoreStateUpdate(newState: AppChromeState, current: AppChromeState) { if (isShallowEqual(newState, current)) { return true; diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx index c6c8ee37239..3efee947d5b 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx @@ -26,7 +26,7 @@ const mockDifferentComponent = { } as ExtensionInfo; const mockPluginMeta = { - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', addedComponents: [mockComponent, mockDifferentComponent], addedLinks: [], }; @@ -187,7 +187,7 @@ describe('ExtensionSidebarProvider', () => { it('should only include permitted plugins in available components', () => { const permittedPluginMeta = { - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', addedComponents: [mockComponent], addedLinks: [], }; @@ -256,7 +256,7 @@ describe('ExtensionSidebarProvider', () => { // Call it directly with the test event subscriberFn( new OpenExtensionSidebarEvent({ - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', componentTitle: 'Test Component', props: { testProp: 'test value' }, }) @@ -266,7 +266,7 @@ describe('ExtensionSidebarProvider', () => { expect(screen.getByTestId('is-open')).toHaveTextContent('true'); expect(screen.getByTestId('props')).toHaveTextContent('{"testProp":"test value"}'); const expectedComponentId = JSON.stringify({ - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', componentTitle: 'Test Component', }); expect(screen.getByTestId('docked-component-id')).toHaveTextContent(expectedComponentId); @@ -381,7 +381,7 @@ describe('ExtensionSidebarProvider', () => { subscriberFn( new ToggleExtensionSidebarEvent({ - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', componentTitle: 'Test Component', props: { testProp: 'test value' }, }) @@ -392,7 +392,7 @@ describe('ExtensionSidebarProvider', () => { expect(screen.getByTestId('is-open')).toHaveTextContent('true'); expect(screen.getByTestId('props')).toHaveTextContent('{"testProp":"test value"}'); const expectedComponentId = JSON.stringify({ - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', componentTitle: 'Test Component', }); expect(screen.getByTestId('docked-component-id')).toHaveTextContent(expectedComponentId); diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx index 51d73d2e8e5..07f716d1a7d 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx @@ -11,7 +11,6 @@ import { DEFAULT_EXTENSION_SIDEBAR_WIDTH, MAX_EXTENSION_SIDEBAR_WIDTH } from './ export const EXTENSION_SIDEBAR_DOCKED_LOCAL_STORAGE_KEY = 'grafana.navigation.extensionSidebarDocked'; export const EXTENSION_SIDEBAR_WIDTH_LOCAL_STORAGE_KEY = 'grafana.navigation.extensionSidebarWidth'; const PERMITTED_EXTENSION_SIDEBAR_PLUGINS = [ - 'grafana-investigations-app', 'grafana-assistant-app', 'grafana-dash-app', // The docs plugin ID is transitioning from grafana-grafanadocsplugin-app to grafana-pathfinder-app. diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx index 8fdb8b5eccb..cb717322af7 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx @@ -44,7 +44,7 @@ const mockComponent = { }; const mockPluginMeta = { - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', addedComponents: [mockComponent], }; @@ -109,7 +109,7 @@ describe('ExtensionToolbarItem', () => { it('should render a dropdown menu when multiple components are available', async () => { const multipleComponentsMeta = { - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', addedComponents: [ { ...mockComponent, title: 'Component 1' }, { ...mockComponent, title: 'Component 2' }, @@ -141,7 +141,7 @@ describe('ExtensionToolbarItem', () => { it('should show menu items when clicking the dropdown button', async () => { const multipleComponentsMeta = { - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', addedComponents: [ { ...mockComponent, title: 'Component 1' }, { ...mockComponent, title: 'Component 2' }, @@ -165,7 +165,7 @@ describe('ExtensionToolbarItem', () => { it('should toggle the sidebar when clicking a menu item', async () => { const multipleComponentsMeta = { - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', addedComponents: [ { ...mockComponent, title: 'Component 1' }, { ...mockComponent, title: 'Component 2' }, @@ -192,7 +192,7 @@ describe('ExtensionToolbarItem', () => { it('should close the sidebar when clicking an active menu item', async () => { const multipleComponentsMeta = { - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', addedComponents: [ { ...mockComponent, title: 'Component 1' }, { ...mockComponent, title: 'Component 2' }, @@ -218,13 +218,13 @@ describe('ExtensionToolbarItem', () => { it('should render individual buttons when multiple plugins are available', async () => { const plugin1Meta = { - pluginId: 'grafana-investigations-app', - addedComponents: [{ ...mockComponent, title: 'Investigations' }], + pluginId: 'grafana-assistant-app', + addedComponents: [{ ...mockComponent, title: 'Assistant' }], }; const plugin2Meta = { - pluginId: 'grafana-assistant-app', - addedComponents: [{ ...mockComponent, title: 'Assistant' }], + pluginId: 'grafana-dash-app', + addedComponents: [{ ...mockComponent, title: 'Dash' }], }; (usePluginLinks as jest.Mock).mockReturnValue({ @@ -249,7 +249,7 @@ describe('ExtensionToolbarItem', () => { expect(buttons).toHaveLength(2); // Each button should have the correct title - expect(buttons[0]).toHaveAttribute('aria-label', 'Open Investigations'); - expect(buttons[1]).toHaveAttribute('aria-label', 'Open Assistant'); + expect(buttons[0]).toHaveAttribute('aria-label', 'Open Assistant'); + expect(buttons[1]).toHaveAttribute('aria-label', 'Open Dash'); }); }); diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx index 3145909e7f8..d819b0fa499 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx @@ -17,8 +17,6 @@ function getPluginIcon(pluginId?: string): string { case 'grafana-grafanadocsplugin-app': case 'grafana-pathfinder-app': return 'book'; - case 'grafana-investigations-app': - return 'eye'; default: return 'ai-sparkle'; } diff --git a/public/app/core/components/AppChrome/History/HistoryContainer.tsx b/public/app/core/components/AppChrome/History/HistoryContainer.tsx deleted file mode 100644 index 88cbde02856..00000000000 --- a/public/app/core/components/AppChrome/History/HistoryContainer.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { css } from '@emotion/css'; -import { useEffect } from 'react'; -import { useToggle } from 'react-use'; - -import { GrafanaTheme2, store } from '@grafana/data'; -import { t } from '@grafana/i18n'; -import { Drawer, ToolbarButton, useStyles2 } from '@grafana/ui'; -import { appEvents } from 'app/core/app_events'; -import { RecordHistoryEntryEvent } from 'app/types/events'; - -import { HISTORY_LOCAL_STORAGE_KEY } from '../AppChromeService'; -import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator'; -import { HistoryEntry } from '../types'; - -import { HistoryWrapper } from './HistoryWrapper'; -import { logUnifiedHistoryDrawerInteractionEvent } from './eventsTracking'; - -export function HistoryContainer() { - const [showHistoryDrawer, onToggleShowHistoryDrawer] = useToggle(false); - const styles = useStyles2(getStyles); - - useEffect(() => { - const sub = appEvents.subscribe(RecordHistoryEntryEvent, (ev) => { - const clickedHistory = store.getObject('CLICKING_HISTORY'); - if (clickedHistory) { - store.setObject('CLICKING_HISTORY', false); - return; - } - const history = store.getObject(HISTORY_LOCAL_STORAGE_KEY, []); - let lastEntry = history[0]; - const newUrl = ev.payload.url; - const lastUrl = lastEntry.views[0]?.url; - if (lastUrl !== newUrl) { - lastEntry.views = [ - { - name: ev.payload.name, - description: ev.payload.description, - url: newUrl, - time: Date.now(), - }, - ...lastEntry.views, - ]; - store.setObject(HISTORY_LOCAL_STORAGE_KEY, [...history]); - } - return () => { - sub.unsubscribe(); - }; - }); - }, []); - - return ( - <> - { - onToggleShowHistoryDrawer(); - logUnifiedHistoryDrawerInteractionEvent({ type: 'open' }); - }} - iconOnly - icon="history" - aria-label={t('nav.history-container.drawer-tittle', 'History')} - /> - - {showHistoryDrawer && ( - { - onToggleShowHistoryDrawer(); - logUnifiedHistoryDrawerInteractionEvent({ type: 'close' }); - }} - size="sm" - > - onToggleShowHistoryDrawer(false)} /> - - )} - - ); -} - -const getStyles = (theme: GrafanaTheme2) => { - return { - separator: css({ - [theme.breakpoints.down('sm')]: { - display: 'none', - }, - }), - }; -}; diff --git a/public/app/core/components/AppChrome/History/HistoryWrapper.tsx b/public/app/core/components/AppChrome/History/HistoryWrapper.tsx deleted file mode 100644 index d25c86d6f3d..00000000000 --- a/public/app/core/components/AppChrome/History/HistoryWrapper.tsx +++ /dev/null @@ -1,291 +0,0 @@ -import { css, cx } from '@emotion/css'; -import moment from 'moment'; -import { useState } from 'react'; - -import { FieldType, GrafanaTheme2, store } from '@grafana/data'; -import { t } from '@grafana/i18n'; -import { Box, Button, Card, Icon, IconButton, Space, Sparkline, Stack, Text, useStyles2, useTheme2 } from '@grafana/ui'; -import { formatDate } from 'app/core/internationalization/dates'; - -import { HISTORY_LOCAL_STORAGE_KEY } from '../AppChromeService'; -import { HistoryEntry } from '../types'; - -import { logClickUnifiedHistoryEntryEvent, logUnifiedHistoryShowMoreEvent } from './eventsTracking'; - -export function HistoryWrapper({ onClose }: { onClose: () => void }) { - const history = store.getObject(HISTORY_LOCAL_STORAGE_KEY, []).filter((entry) => { - return moment(entry.time).isAfter(moment().subtract(2, 'day').startOf('day')); - }); - const [numItemsToShow, setNumItemsToShow] = useState(5); - - const selectedTime = history.find((entry) => { - return entry.url === window.location.href || entry.views.some((view) => view.url === window.location.href); - })?.time; - - const hist = history.slice(0, numItemsToShow).reduce((acc: { [key: string]: HistoryEntry[] }, entry) => { - const date = moment(entry.time); - let key = ''; - if (date.isSame(moment(), 'day')) { - key = t('nav.history-wrapper.today', 'Today'); - } else if (date.isSame(moment().subtract(1, 'day'), 'day')) { - key = t('nav.history-wrapper.yesterday', 'Yesterday'); - } else { - key = date.format('YYYY-MM-DD'); - } - acc[key] = [...(acc[key] || []), entry]; - return acc; - }, {}); - const styles = useStyles2(getStyles); - return ( - - - {Object.keys(hist).map((entries, date) => { - return ( - - - {entries} - -
        - {hist[entries].map((entry, index) => { - return ( - onClose()} - /> - ); - })} -
        -
        - ); - })} -
        - {history.length > numItemsToShow && ( - - - - )} -
        - ); -} -interface ItemProps { - entry: HistoryEntry; - isSelected: boolean; - onClick: () => void; -} - -function HistoryEntryAppView({ entry, isSelected, onClick }: ItemProps) { - const styles = useStyles2(getStyles); - const theme = useTheme2(); - const [isExpanded, setIsExpanded] = useState(isSelected && entry.views.length > 0); - - const { breadcrumbs, views, time, url, sparklineData } = entry; - const expandedLabel = isExpanded - ? t('nav.history-wrapper.collapse', 'Collapse') - : t('nav.history-wrapper.expand', 'Expand'); - const entryIconLabel = isExpanded - ? t('nav.history-wrapper.icon-selected', 'Selected Entry') - : t('nav.history-wrapper.icon-unselected', 'Normal Entry'); - const selectedViewTime = - isSelected && - entry.views.find((entry) => { - return entry.url === window.location.href; - })?.time; - - return ( - - - - {views.length > 0 ? ( - setIsExpanded(!isExpanded)} - aria-label={expandedLabel} - className={styles.iconButton} - /> - ) : ( - - )} - - { - store.setObject('CLICKING_HISTORY', true); - onClick(); - logClickUnifiedHistoryEntryEvent({ entryURL: url }); - }} - href={url} - isCompact={true} - className={isSelected ? styles.card : cx(styles.card, styles.cardSelected)} - > - -
        - {breadcrumbs.map((breadcrumb, index) => ( - - {breadcrumb.text}{' '} - {index !== breadcrumbs.length - 1 - ? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings - '> ' - : ''} - - ))} -
        - - {formatDate(time, { timeStyle: 'short' })} - - {sparklineData && ( - - )} -
        -
        -
        - {isExpanded && ( -
        - {views.map((view, index) => { - return ( - { - store.setObject('CLICKING_HISTORY', true); - onClick(); - logClickUnifiedHistoryEntryEvent({ entryURL: view.url, subEntry: 'timeRange' }); - }} - isCompact={true} - className={view.time === selectedViewTime ? undefined : styles.subCard} - > - - {view.name} - {view.description && ( - - {view.description} - - )} - - - ); - })} -
        - )} -
        -
        - ); -} -const getStyles = (theme: GrafanaTheme2) => { - return { - card: css({ - label: 'card', - background: 'none', - margin: theme.spacing(0.5, 0), - }), - cardSelected: css({ - label: 'card-selected', - background: 'none', - }), - subCard: css({ - label: 'subcard', - background: 'none', - margin: 0, - }), - iconButton: css({ - label: 'expand-button', - margin: 0, - }), - iconButtonCircle: css({ - label: 'blue-circle-icon', - margin: 0, - background: theme.colors.background.primary, - fill: theme.colors.primary.main, - cursor: 'default', - '&:hover:before': { - background: 'none', - }, - //Need this to place the icon on the line, otherwise the line will appear on top of the icon - zIndex: 0, - }), - iconButtonDot: css({ - label: 'blue-dot-icon', - margin: 0, - color: theme.colors.primary.main, - border: theme.shape.radius.circle, - cursor: 'default', - '&:hover:before': { - background: 'none', - }, - //Need this to place the icon on the line, otherwise the line will appear on top of the icon - zIndex: 0, - }), - expanded: css({ - label: 'expanded', - display: 'flex', - flexDirection: 'column', - marginLeft: theme.spacing(6), - gap: theme.spacing(1), - position: 'relative', - '&:before': { - content: '""', - position: 'absolute', - left: 0, - top: 0, - height: '100%', - width: '1px', - background: theme.colors.border.weak, - }, - }), - timeline: css({ - label: 'timeline', - position: 'relative', - height: '100%', - width: '100%', - paddingLeft: theme.spacing(2), - '&:before': { - content: '""', - position: 'absolute', - left: theme.spacing(5.75), - top: 0, - height: '100%', - width: '1px', - borderLeft: `1px dashed ${theme.colors.border.strong}`, - }, - }), - }; -}; diff --git a/public/app/core/components/AppChrome/History/eventsTracking.ts b/public/app/core/components/AppChrome/History/eventsTracking.ts deleted file mode 100644 index 5d49bd1c978..00000000000 --- a/public/app/core/components/AppChrome/History/eventsTracking.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { reportInteraction } from '@grafana/runtime'; - -const UNIFIED_HISTORY_ENTRY_CLICKED = 'grafana_unified_history_entry_clicked'; -const UNIFIED_HISTORY_ENTRY_DUPLICATED = 'grafana_unified_history_duplicated_entry_rendered'; -const UNIFIED_HISTORY_DRAWER_INTERACTION = 'grafana_unified_history_drawer_interaction'; -const UNIFIED_HISTORY_DRAWER_SHOW_MORE = 'grafana_unified_history_show_more'; - -//Currently just 'timeRange' is supported -//in short term, we could add 'templateVariables' for example -type subEntryTypes = 'timeRange'; - -//Whether the user opens or closes the `HistoryDrawer` -type UnifiedHistoryDrawerInteraction = 'open' | 'close'; - -interface UnifiedHistoryEntryClicked { - //We will also work with the current URL but we will get this from Rudderstack data - //URL to return to - entryURL: string; - //In the case we want to go back to a specific query param, currently just a specific time range - subEntry?: subEntryTypes; -} - -interface UnifiedHistoryEntryDuplicated { - // Common name of the history entries - entryName: string; - // URL of the last entry - lastEntryURL: string; - // URL of the new entry - newEntryURL: string; -} - -//Event triggered when a user clicks on an entry of the `HistoryDrawer` -export const logClickUnifiedHistoryEntryEvent = ({ entryURL, subEntry }: UnifiedHistoryEntryClicked) => { - reportInteraction(UNIFIED_HISTORY_ENTRY_CLICKED, { - entryURL, - subEntry, - }); -}; - -//Event triggered when history entry name matches the previous one -//so we keep track of duplicated entries and be able to analyze them -export const logDuplicateUnifiedHistoryEntryEvent = ({ - entryName, - lastEntryURL, - newEntryURL, -}: UnifiedHistoryEntryDuplicated) => { - reportInteraction(UNIFIED_HISTORY_ENTRY_DUPLICATED, { - entryName, - lastEntryURL, - newEntryURL, - }); -}; - -//We keep track of users open and closing the drawer -export const logUnifiedHistoryDrawerInteractionEvent = ({ type }: { type: UnifiedHistoryDrawerInteraction }) => { - reportInteraction(UNIFIED_HISTORY_DRAWER_INTERACTION, { - type, - }); -}; - -//We keep track of users clicking on the `Show more` button -export const logUnifiedHistoryShowMoreEvent = () => { - reportInteraction(UNIFIED_HISTORY_DRAWER_SHOW_MORE); -}; diff --git a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx index d5179570ef3..23c1624dc05 100644 --- a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx +++ b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx @@ -6,7 +6,6 @@ import { Components } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; import { ScopesContextValue } from '@grafana/runtime'; import { Icon, Stack, ToolbarButton, useStyles2 } from '@grafana/ui'; -import { config } from 'app/core/config'; import { MEGA_MENU_TOGGLE_ID } from 'app/core/constants'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { useMediaQueryMinWidth } from 'app/core/hooks/useMediaQueryMinWidth'; @@ -19,7 +18,6 @@ import { HomeLink } from '../../Branding/Branding'; import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs'; import { buildBreadcrumbs } from '../../Breadcrumbs/utils'; import { ExtensionToolbarItem } from '../ExtensionSidebar/ExtensionToolbarItem'; -import { HistoryContainer } from '../History/HistoryContainer'; import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator'; import { QuickAdd } from '../QuickAdd/QuickAdd'; @@ -60,7 +58,6 @@ export const SingleTopBar = memo(function SingleTopBar({ const profileNode = useSelector((state) => state.navIndex['profile']); const homeNav = useSelector((state) => state.navIndex)[HOME_NAV_ID]; const breadcrumbs = buildBreadcrumbs(sectionNav, pageNav, homeNav); - const unifiedHistoryEnabled = config.featureToggles.unifiedHistory; const isSmallScreen = !useMediaQueryMinWidth('sm'); const isLargeScreen = useMediaQueryMinWidth('lg'); const topLevelScopes = !showToolbarLevel && isLargeScreen && scopes?.state.enabled; @@ -96,7 +93,6 @@ export const SingleTopBar = memo(function SingleTopBar({ > - {unifiedHistoryEnabled && !isSmallScreen && } {!isSmallScreen && } diff --git a/public/app/core/components/AppChrome/types.ts b/public/app/core/components/AppChrome/types.ts index 6cf72c936f5..de9183423c3 100644 --- a/public/app/core/components/AppChrome/types.ts +++ b/public/app/core/components/AppChrome/types.ts @@ -4,28 +4,3 @@ export interface ToolbarUpdateProps { pageNav?: NavModelItem; actions?: React.ReactNode; } - -export interface HistoryEntryView { - name: string; - description: string; - url: string; - time: number; -} - -export interface HistoryEntrySparkline { - values: number[]; - range: { - min: number; - max: number; - delta: number; - }; -} - -export interface HistoryEntry { - name: string; - time: number; - breadcrumbs: NavModelItem[]; - url: string; - views: HistoryEntryView[]; - sparklineData?: HistoryEntrySparkline; -} diff --git a/public/app/core/components/AppNotifications/AppNotificationList.tsx b/public/app/core/components/AppNotifications/AppNotificationList.tsx index 8d5a1d3161e..e194ada32ed 100644 --- a/public/app/core/components/AppNotifications/AppNotificationList.tsx +++ b/public/app/core/components/AppNotifications/AppNotificationList.tsx @@ -4,10 +4,9 @@ import { useLocation } from 'react-router-dom'; import { AlertErrorPayload, AlertPayload, AppEvents, GrafanaTheme2 } from '@grafana/data'; import { useStyles2, Stack } from '@grafana/ui'; -import { notifyApp, hideAppNotification } from 'app/core/actions'; import { appEvents } from 'app/core/app_events'; import { useGrafana } from 'app/core/context/GrafanaContext'; -import { selectVisible } from 'app/core/reducers/appNotification'; +import { hideAppNotification, notifyApp, selectVisible } from 'app/core/reducers/appNotification'; import { useSelector, useDispatch } from 'app/types/store'; import { diff --git a/public/app/core/components/BouncingLoader/BouncingLoader.tsx b/public/app/core/components/BouncingLoader/BouncingLoader.tsx index 025f0469cdf..bb4f12306bb 100644 --- a/public/app/core/components/BouncingLoader/BouncingLoader.tsx +++ b/public/app/core/components/BouncingLoader/BouncingLoader.tsx @@ -3,7 +3,8 @@ import { css, keyframes } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; import { useStyles2 } from '@grafana/ui'; -import grafanaIconSvg from 'img/grafana_icon.svg'; + +import { Branding } from '../Branding/Branding'; export function BouncingLoader() { const styles = useStyles2(getStyles); @@ -16,7 +17,7 @@ export function BouncingLoader() { aria-label={t('bouncing-loader.label', 'Loading')} >
        - +
        ); diff --git a/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx b/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx index 9ff66c518ee..a6a9041f6c1 100644 --- a/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx +++ b/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx @@ -4,8 +4,8 @@ import * as React from 'react'; import SplitPane, { Split } from 'react-split-pane'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { getDragStyles } from '@grafana/ui'; -import { config } from 'app/core/config'; interface Props { splitOrientation?: Split; diff --git a/public/app/core/config.ts b/public/app/core/config.ts index 6c757c9436a..f725aab5472 100644 --- a/public/app/core/config.ts +++ b/public/app/core/config.ts @@ -1,6 +1,5 @@ import { PluginState } from '@grafana/data'; import { config, GrafanaBootConfig } from '@grafana/runtime'; -export { config, type GrafanaBootConfig as Settings }; let grafanaConfig: GrafanaBootConfig = config; diff --git a/public/app/core/copy/appNotification.ts b/public/app/core/copy/appNotification.ts index 3f1dd8f484f..8dc671d303f 100644 --- a/public/app/core/copy/appNotification.ts +++ b/public/app/core/copy/appNotification.ts @@ -6,7 +6,7 @@ import { dispatch as storeDispatch } from 'app/store/store'; import { AppNotificationSeverity, AppNotification } from 'app/types/appNotifications'; import { useDispatch } from 'app/types/store'; -import { notifyApp } from '../actions'; +import { notifyApp } from '../reducers/appNotification'; const defaultSuccessNotification = { title: '', diff --git a/public/app/core/icons/cached.json b/public/app/core/icons/cached.json index 6e35e64dd0c..9987d867738 100644 --- a/public/app/core/icons/cached.json +++ b/public/app/core/icons/cached.json @@ -29,6 +29,7 @@ "unicons/bookmark", "unicons/book-open", "unicons/brackets-curly", + "unicons/brain", "unicons/bug", "unicons/building", "unicons/calculator-alt", diff --git a/public/app/core/internationalization/dates.ts b/public/app/core/internationalization/dates.ts index 9ef7bbbdb82..c151e505487 100644 --- a/public/app/core/internationalization/dates.ts +++ b/public/app/core/internationalization/dates.ts @@ -2,7 +2,7 @@ import deepEqual from 'fast-deep-equal'; import memoize from 'micro-memoize'; import { getLanguage } from '@grafana/i18n/internal'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; const deepMemoize: typeof memoize = (fn) => memoize(fn, { isEqual: deepEqual }); diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index d22835ac69e..5c817bf74cd 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -3,6 +3,7 @@ import { AnyAction, combineReducers } from 'redux'; import { allReducers as allApiClientReducers } from '@grafana/api-clients/rtkq'; import { generatedAPI as legacyAPI } from '@grafana/api-clients/rtkq/legacy'; +import { scopeAPIv0alpha1 } from 'app/api/clients/scope/v0alpha1'; import sharedReducers from 'app/core/reducers'; import ldapReducers from 'app/features/admin/state/reducers'; import alertingReducers from 'app/features/alerting/state/reducers'; @@ -52,6 +53,7 @@ const rootReducers = { [alertingApi.reducerPath]: alertingApi.reducer, [publicDashboardApi.reducerPath]: publicDashboardApi.reducer, [browseDashboardsAPI.reducerPath]: browseDashboardsAPI.reducer, + [scopeAPIv0alpha1.reducerPath]: scopeAPIv0alpha1.reducer, ...allApiClientReducers, }; diff --git a/public/app/core/services/theme.ts b/public/app/core/services/theme.ts index 542aa1ab743..82bd949904f 100644 --- a/public/app/core/services/theme.ts +++ b/public/app/core/services/theme.ts @@ -1,8 +1,7 @@ import { getThemeById } from '@grafana/data/internal'; -import { ThemeChangedEvent } from '@grafana/runtime'; +import { config, ThemeChangedEvent } from '@grafana/runtime'; import { appEvents } from '../app_events'; -import { config } from '../config'; import { contextSrv } from '../services/context_srv'; import { PreferencesService } from './PreferencesService'; diff --git a/public/app/core/utils/richHistory.ts b/public/app/core/utils/richHistory.ts index e94929939f4..f8944dcd7d6 100644 --- a/public/app/core/utils/richHistory.ts +++ b/public/app/core/utils/richHistory.ts @@ -10,7 +10,6 @@ import { } from '@grafana/data'; import { t } from '@grafana/i18n'; import { getDataSourceSrv } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification, createWarningNotification } from 'app/core/copy/appNotification'; import { dispatch } from 'app/store/store'; import { RichHistoryQuery } from 'app/types/explore'; @@ -23,6 +22,7 @@ import { } from '../history/RichHistoryStorage'; import { createRetentionPeriodBoundary } from '../history/richHistoryLocalStorageUtils'; import { getLocalRichHistoryStorage, getRichHistoryStorage } from '../history/richHistoryStorageProvider'; +import { notifyApp } from '../reducers/appNotification'; import { contextSrv } from '../services/context_srv'; import { diff --git a/public/app/core/utils/shortLinks.ts b/public/app/core/utils/shortLinks.ts index 208f2ae0fcc..e6e260e6ccc 100644 --- a/public/app/core/utils/shortLinks.ts +++ b/public/app/core/utils/shortLinks.ts @@ -5,7 +5,6 @@ import { t } from '@grafana/i18n'; import { getBackendSrv, config, locationService } from '@grafana/runtime'; import { sceneGraph, SceneTimeRangeLike, VizPanel } from '@grafana/scenes'; import { shortURLAPIv1beta1 } from 'app/api/clients/shorturl/v1beta1'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification'; import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl'; @@ -14,6 +13,7 @@ import { dispatch } from 'app/store/store'; import { ShortURL } from '../../../../apps/shorturl/plugin/src/generated/shorturl/v1beta1/shorturl_object_gen'; import { extractErrorMessage } from '../../api/utils'; import { ShareLinkConfiguration } from '../../features/dashboard-scene/sharing/ShareButton/utils'; +import { notifyApp } from '../reducers/appNotification'; import { copyStringToClipboard } from './explore'; diff --git a/public/app/features/admin/Users/OrgUsersTable.tsx b/public/app/features/admin/Users/OrgUsersTable.tsx index 3debbd886a8..a3d93b12bad 100644 --- a/public/app/features/admin/Users/OrgUsersTable.tsx +++ b/public/app/features/admin/Users/OrgUsersTable.tsx @@ -115,12 +115,15 @@ export const OrgUsersTable = ({ { id: 'lastSeenAtAge', header: 'Last active', - cell: ({ cell: { value } }: Cell<'lastSeenAtAge'>) => { + cell: ({ cell: { value }, row: { original } }: Cell<'lastSeenAtAge'>) => { + // If lastSeenAt is before created, user has never logged in + const neverLoggedIn = + original.lastSeenAt && original.created && new Date(original.lastSeenAt) < new Date(original.created); return ( <> {value && ( <> - {value === '10 years' ? ( + {neverLoggedIn ? ( Never diff --git a/public/app/features/admin/Users/UsersTable.tsx b/public/app/features/admin/Users/UsersTable.tsx index a3a1f6be297..b4d34f0d438 100644 --- a/public/app/features/admin/Users/UsersTable.tsx +++ b/public/app/features/admin/Users/UsersTable.tsx @@ -135,12 +135,19 @@ export const UsersTable = ({ content: 'Time since user was seen using Grafana', iconName: 'question-circle', }, - cell: ({ cell: { value } }: Cell<'lastSeenAtAge'>) => { + cell: ({ + cell: { value }, + row: { + original: { lastSeenAt, created }, + }, + }: Cell<'lastSeenAtAge'>) => { + // The user has never logged in if lastSeenAt is before its creation date. + const neverLoggedIn = lastSeenAt && created && new Date(lastSeenAt) < new Date(created); return ( <> {value && ( <> - {value === '10 years' ? ( + {neverLoggedIn ? ( Never diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index 34459b9581e..418327face5 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -1,7 +1,7 @@ import { Navigate } from 'react-router-dom-v5-compat'; +import { config } from '@grafana/runtime'; import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; -import { config } from 'app/core/config'; import { GrafanaRouteComponent, RouteDescriptor } from 'app/core/navigation/types'; import { AccessControlAction } from 'app/types/accessControl'; diff --git a/public/app/features/alerting/state/ThresholdMapper.ts b/public/app/features/alerting/state/ThresholdMapper.ts index 48b9b7c656a..140330b9d06 100644 --- a/public/app/features/alerting/state/ThresholdMapper.ts +++ b/public/app/features/alerting/state/ThresholdMapper.ts @@ -1,4 +1,4 @@ -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; export const hiddenReducerTypes = ['percent_diff', 'percent_diff_abs']; diff --git a/public/app/features/alerting/unified/api/alertmanagerApi.ts b/public/app/features/alerting/unified/api/alertmanagerApi.ts index 6f9a99194cc..b0c41c84acd 100644 --- a/public/app/features/alerting/unified/api/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/api/alertmanagerApi.ts @@ -108,7 +108,9 @@ export const alertmanagerApi = alertingApi.injectEndpoints({ }), grafanaNotifiers: build.query({ - query: () => ({ url: '/api/alert-notifiers' }), + // NOTE: version=2 parameter required for versioned schema (PR #109969) + // This parameter will be removed in future when v2 becomes default + query: () => ({ url: '/api/alert-notifiers?version=2' }), transformResponse: (response: NotifierDTO[]) => { const populateSecureFieldKey = ( option: NotificationChannelOption, @@ -121,11 +123,16 @@ export const alertmanagerApi = alertingApi.injectEndpoints({ ), }); + // Keep versions array intact for version-specific options lookup + // Transform options with secureFieldKey population return response.map((notifier) => ({ ...notifier, - options: notifier.options.map((option) => { - return populateSecureFieldKey(option, ''); - }), + options: (notifier.options || []).map((option) => populateSecureFieldKey(option, '')), + // Also transform options within each version + versions: notifier.versions?.map((version) => ({ + ...version, + options: (version.options || []).map((option) => populateSecureFieldKey(option, '')), + })), })); }, }), diff --git a/public/app/features/alerting/unified/api/prometheusApi.ts b/public/app/features/alerting/unified/api/prometheusApi.ts index 9432da368b6..c03b52eab4e 100644 --- a/public/app/features/alerting/unified/api/prometheusApi.ts +++ b/public/app/features/alerting/unified/api/prometheusApi.ts @@ -46,6 +46,7 @@ export type GrafanaPromRulesOptions = Omit { + describe('when the provenance is file', () => { + it('should render the badge with the correct text', () => { + render(); + + expect(screen.getByText('Provisioned')).toBeInTheDocument(); + expect(screen.queryByText('Imported')).not.toBeInTheDocument(); + }); + + it('should render correct tooltip text', async () => { + const { user } = render(); + + const badge = screen.getByText('Provisioned'); + await user.hover(badge); + + expect( + screen.getByText('This resource has been provisioned via file and cannot be edited through the UI') + ).toBeInTheDocument(); + }); + }); + + describe('when the provenance is ConvertedPrometheus', () => { + it('should render the badge with the correct text', () => { + render(); + + expect(screen.getByText('Imported')).toBeInTheDocument(); + expect(screen.queryByText('Provisioned')).not.toBeInTheDocument(); + }); + + it('should render correct tooltip text', async () => { + const { user } = render(); + + const badge = screen.getByText('Imported'); + await user.hover(badge); + + expect( + screen.getByText('This resource has been provisioned via Prometheus/Mimir and cannot be edited through the UI') + ).toBeInTheDocument(); + }); + }); + + describe('when the provenance is API', () => { + it('should render the badge with the correct text', () => { + render(); + + expect(screen.getByText('Provisioned')).toBeInTheDocument(); + expect(screen.queryByText('Imported')).not.toBeInTheDocument(); + }); + + it('should render correct tooltip text', async () => { + const { user } = render(); + + const badge = screen.getByText('Provisioned'); + await user.hover(badge); + + expect( + screen.getByText('This resource has been provisioned via api and cannot be edited through the UI') + ).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/Provisioning.tsx b/public/app/features/alerting/unified/components/Provisioning.tsx index 73beb8a0865..5a9deb8a6bd 100644 --- a/public/app/features/alerting/unified/components/Provisioning.tsx +++ b/public/app/features/alerting/unified/components/Provisioning.tsx @@ -3,6 +3,8 @@ import { ComponentPropsWithoutRef } from 'react'; import { Trans, t } from '@grafana/i18n'; import { Alert, Badge, Tooltip } from '@grafana/ui'; +import { KnownProvenance } from '../types/knownProvenance'; + export enum ProvisionedResource { ContactPoint = 'contact point', Template = 'template', @@ -36,6 +38,24 @@ export const ProvisioningAlert = ({ resource, ...rest }: ProvisioningAlertProps) ); }; +export const ImportedContactPointAlert = (props: ExtraAlertProps) => { + return ( + + + This contact point contains integrations that were imported from an external Alertmanager and is currently + read-only. The integrations will become editable after the migration process is complete. + + + ); +}; + export const ProvisioningBadge = ({ tooltip, provenance, @@ -46,11 +66,17 @@ export const ProvisioningBadge = ({ */ provenance?: string; }) => { - const badge = ; + const isConvertedPrometheus = provenance === KnownProvenance.ConvertedPrometheus; + const badgeText = isConvertedPrometheus + ? t('alerting.provisioning-badge.badge.text-converted-prometheus', 'Imported') + : t('alerting.provisioning-badge.badge.text-provisioned', 'Provisioned'); + const badgeColor = isConvertedPrometheus ? 'blue' : 'purple'; + const badge = ; if (tooltip) { + const provenanceText = isConvertedPrometheus ? 'Prometheus/Mimir' : provenance; const provenanceTooltip = ( - + This resource has been provisioned via {{ provenance }} and cannot be edited through the UI ); diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.test.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.test.tsx new file mode 100644 index 00000000000..2879bbb57e1 --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from 'test/test-utils'; + +import { AccessControlAction } from 'app/types/accessControl'; + +import { setupMswServer } from '../../mockApi'; +import { grantUserPermissions } from '../../mocks'; +import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; +import { KnownProvenance } from '../../types/knownProvenance'; + +import { ContactPointHeader } from './ContactPointHeader'; +import { ContactPointWithMetadata } from './utils'; + +setupMswServer(); + +const renderWithProvider = (component: React.ReactElement, alertmanagerSourceName?: string) => { + return render( + + {component} + + ); +}; + +describe('ContactPointHeader', () => { + beforeEach(() => { + grantUserPermissions([ + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsWrite, + ]); + }); + + const mockContactPoint: ContactPointWithMetadata = { + id: 'test-contact-point', + name: 'Test Contact Point', + provenance: KnownProvenance.API, + policies: [], + grafana_managed_receiver_configs: [], + }; + + it('shows Provisioned badge when contact point has file provenance via K8s annotations', () => { + const contactPointWithFile = { + ...mockContactPoint, + provenance: KnownProvenance.File, + }; + + renderWithProvider(); + + expect(screen.getByText('Provisioned')).toBeInTheDocument(); + }); + + it('shows correct badge when contact point has converted_prometheus provenance', () => { + const contactPointWithConvertedPrometheus = { + ...mockContactPoint, + provenance: KnownProvenance.ConvertedPrometheus, + }; + + renderWithProvider(); + + expect(screen.getByText('Imported')).toBeInTheDocument(); + }); +}); 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 0fb1403cb35..6e45c1b6512 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx @@ -13,6 +13,7 @@ import { canDeleteEntity, canEditEntity, getAnnotation, + isProvisionedResource, shouldUseK8sApi, } from 'app/features/alerting/unified/utils/k8s/utils'; @@ -31,13 +32,15 @@ interface ContactPointHeaderProps { } export const ContactPointHeader = ({ contactPoint, onDelete }: ContactPointHeaderProps) => { - const { name, id, provisioned, policies = [] } = contactPoint; + const { name, id, provenance, policies = [] } = contactPoint; const styles = useStyles2(getStyles); const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false); const { selectedAlertmanager } = useAlertmanager(); const usingK8sApi = shouldUseK8sApi(selectedAlertmanager!); + const isProvisioned = isProvisionedResource(provenance); + const [exportSupported, exportAllowed] = useAlertmanagerAbility(AlertmanagerAction.ExportContactPoint); const [editSupported, editAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateContactPoint); const [deleteSupported, deleteAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateContactPoint); @@ -70,14 +73,14 @@ export const ContactPointHeader = ({ contactPoint, onDelete }: ContactPointHeade /** Does the current user have permissions to edit the contact point? */ const hasAbilityToEdit = usingK8sApi ? canEditEntity(contactPoint) : editAllowed; /** Can the contact point actually be edited via the UI? */ - const contactPointIsEditable = !provisioned; + const contactPointIsEditable = !isProvisioned; /** 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 = 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; + const contactPointIsDeleteable = !isProvisioned && !numberOfPoliciesPreventingDeletion && !numberOfRules; /** Given the alertmanager, the user's permissions, and the state of the contact point - can it actually be deleted? */ const canBeDeleted = deleteSupported && hasAbilityToDelete && contactPointIsDeleteable; @@ -130,7 +133,7 @@ export const ContactPointHeader = ({ contactPoint, onDelete }: ContactPointHeade const reasonsDeleteIsDisabled = [ !hasAbilityToDelete ? cannotDeleteNoPermissions : '', - provisioned ? cannotDeleteProvisioned : '', + isProvisioned ? cannotDeleteProvisioned : '', numberOfPoliciesPreventingDeletion > 0 ? cannotDeletePolicies : '', numberOfRules ? cannotDeleteRules : '', ].filter(Boolean); @@ -209,15 +212,13 @@ export const ContactPointHeader = ({ contactPoint, onDelete }: ContactPointHeade {referencedByRulesText} )} - {provisioned && ( - - )} + {isProvisioned && } {!isReferencedByAnything && } { }); it('should disable buttons when provisioned', async () => { - const { user } = renderWithProvider(); + const { user } = renderWithProvider( + + ); expect(screen.getByText(/provisioned/i)).toBeInTheDocument(); diff --git a/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap b/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap index 18a5bae9e28..7524d3ba37a 100644 --- a/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap +++ b/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap @@ -50,7 +50,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, }, ], - "provisioned": false, + "provenance": undefined, }, { "grafana_managed_receiver_configs": [ @@ -93,7 +93,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, "name": "lotsa-emails", "policies": [], - "provisioned": false, + "provenance": undefined, }, { "grafana_managed_receiver_configs": [ @@ -129,7 +129,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, "name": "OnCall Conctact point", "policies": [], - "provisioned": false, + "provenance": undefined, }, { "grafana_managed_receiver_configs": [ @@ -178,7 +178,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, }, ], - "provisioned": true, + "provenance": "api", }, { "grafana_managed_receiver_configs": [ @@ -243,7 +243,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, "name": "Slack with multiple channels", "policies": [], - "provisioned": false, + "provenance": undefined, }, ], "error": undefined, @@ -301,7 +301,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag }, }, ], - "provisioned": false, + "provenance": undefined, }, { "grafana_managed_receiver_configs": [ @@ -344,7 +344,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag }, "name": "lotsa-emails", "policies": [], - "provisioned": false, + "provenance": undefined, }, { "grafana_managed_receiver_configs": [ @@ -383,7 +383,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag }, "name": "OnCall Conctact point", "policies": [], - "provisioned": false, + "provenance": undefined, }, { "grafana_managed_receiver_configs": [ @@ -432,7 +432,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag }, }, ], - "provisioned": true, + "provenance": "api", }, { "grafana_managed_receiver_configs": [ @@ -497,7 +497,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag }, "name": "Slack with multiple channels", "policies": [], - "provisioned": false, + "provenance": undefined, }, ], "error": undefined, diff --git a/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx b/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx index 2ac9c04f981..b539a239bd6 100644 --- a/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx @@ -6,10 +6,13 @@ import { disablePlugin } from 'app/features/alerting/unified/mocks/server/config import { setOnCallIntegrations } from 'app/features/alerting/unified/mocks/server/handlers/plugins/configure-plugins'; import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; +import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types/accessControl'; import { setupMswServer } from '../../mockApi'; import { grantUserPermissions } from '../../mocks'; +import { setAlertmanagerConfig } from '../../mocks/server/entities/alertmanagers'; +import { KnownProvenance } from '../../types/knownProvenance'; import { useContactPointsWithStatus } from './useContactPoints'; @@ -69,4 +72,235 @@ describe('useContactPoints', () => { expect(snapshot).toMatchSnapshot(); }); }); + + describe('Provenance handling', () => { + it('should extract provenance when provenance is "api"', async () => { + // Set up alertmanager config with a receiver that has API provenance + const config: AlertManagerCortexConfig = { + template_files: {}, + alertmanager_config: { + receivers: [ + { + name: 'api-provenance-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid-1', + name: 'api-provenance-contact-point', + type: 'email', + disableResolveMessage: false, + settings: { + addresses: 'test@example.com', + }, + secureFields: {}, + provenance: 'api', // This will be used by the K8s mock handler + }, + ], + }, + ], + }, + }; + setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, config); + + const { result } = renderHook( + () => + useContactPointsWithStatus({ + alertmanager: GRAFANA_RULES_SOURCE_NAME, + fetchPolicies: false, + fetchStatuses: false, + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const contactPoint = result.current.contactPoints?.find((cp) => cp.name === 'api-provenance-contact-point'); + expect(contactPoint).toBeDefined(); + expect(contactPoint?.provenance).toBe(KnownProvenance.API); + }); + + it('should extract provenance when provenance is "file"', async () => { + const config: AlertManagerCortexConfig = { + template_files: {}, + alertmanager_config: { + receivers: [ + { + name: 'file-provenance-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid-2', + name: 'file-provenance-contact-point', + type: 'email', + disableResolveMessage: false, + settings: { + addresses: 'test@example.com', + }, + secureFields: {}, + provenance: 'file', + }, + ], + }, + ], + }, + }; + setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, config); + + const { result } = renderHook( + () => + useContactPointsWithStatus({ + alertmanager: GRAFANA_RULES_SOURCE_NAME, + fetchPolicies: false, + fetchStatuses: false, + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const contactPoint = result.current.contactPoints?.find((cp) => cp.name === 'file-provenance-contact-point'); + expect(contactPoint).toBeDefined(); + expect(contactPoint?.provenance).toBe(KnownProvenance.File); + }); + + it('should extract provenance when provenance is "converted_prometheus"', async () => { + const config: AlertManagerCortexConfig = { + template_files: {}, + alertmanager_config: { + receivers: [ + { + name: 'mimir-provenance-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid-3', + name: 'mimir-provenance-contact-point', + type: 'email', + disableResolveMessage: false, + settings: { + addresses: 'test@example.com', + }, + secureFields: {}, + provenance: 'converted_prometheus', + }, + ], + }, + ], + }, + }; + setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, config); + + const { result } = renderHook( + () => + useContactPointsWithStatus({ + alertmanager: GRAFANA_RULES_SOURCE_NAME, + fetchPolicies: false, + fetchStatuses: false, + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const contactPoint = result.current.contactPoints?.find((cp) => cp.name === 'mimir-provenance-contact-point'); + expect(contactPoint).toBeDefined(); + expect(contactPoint?.provenance).toBe(KnownProvenance.ConvertedPrometheus); + }); + + it('should map "none" provenance annotation to undefined', async () => { + const config: AlertManagerCortexConfig = { + template_files: {}, + alertmanager_config: { + receivers: [ + { + name: 'none-provenance-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid-4', + name: 'none-provenance-contact-point', + type: 'email', + disableResolveMessage: false, + settings: { + addresses: 'test@example.com', + }, + secureFields: {}, + // No provenance field - will default to PROVENANCE_NONE in mock handler + }, + ], + }, + ], + }, + }; + setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, config); + + const { result } = renderHook( + () => + useContactPointsWithStatus({ + alertmanager: GRAFANA_RULES_SOURCE_NAME, + fetchPolicies: false, + fetchStatuses: false, + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const contactPoint = result.current.contactPoints?.find((cp) => cp.name === 'none-provenance-contact-point'); + expect(contactPoint).toBeDefined(); + // The mock handler sets PROVENANCE_NONE ('none') when no provenance is found + // parseK8sReceiver converts 'none' to undefined + expect(contactPoint?.provenance).toBeUndefined(); + }); + + it('should handle missing annotations gracefully', async () => { + // This test verifies that when annotations are undefined, provenance is handled correctly + const config: AlertManagerCortexConfig = { + template_files: {}, + alertmanager_config: { + receivers: [ + { + name: 'no-annotations-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid-5', + name: 'no-annotations-contact-point', + type: 'email', + disableResolveMessage: false, + settings: { + addresses: 'test@example.com', + }, + secureFields: {}, + }, + ], + }, + ], + }, + }; + setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, config); + + const { result } = renderHook( + () => + useContactPointsWithStatus({ + alertmanager: GRAFANA_RULES_SOURCE_NAME, + fetchPolicies: false, + fetchStatuses: false, + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const contactPoint = result.current.contactPoints?.find((cp) => cp.name === 'no-annotations-contact-point'); + expect(contactPoint).toBeDefined(); + // When annotations are missing, the mock handler should set provenance to undefined + expect(contactPoint?.provenance).toBeUndefined(); + }); + }); }); diff --git a/public/app/features/alerting/unified/components/contact-points/useContactPoints.ts b/public/app/features/alerting/unified/components/contact-points/useContactPoints.ts index 6627d5f69d2..bf5e8e5fcdf 100644 --- a/public/app/features/alerting/unified/components/contact-points/useContactPoints.ts +++ b/public/app/features/alerting/unified/components/contact-points/useContactPoints.ts @@ -11,7 +11,7 @@ import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver } f import { BaseAlertmanagerArgs, Skippable } from 'app/features/alerting/unified/types/hooks'; import { cloudNotifierTypes } from 'app/features/alerting/unified/utils/cloud-alertmanager-notifier-types'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; -import { isK8sEntityProvisioned, shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils'; +import { shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils'; import { GrafanaManagedContactPoint, Receiver } from 'app/plugins/datasource/alertmanager/types'; import { getAPINamespace } from '../../../../../api/utils'; @@ -21,7 +21,9 @@ import { useAsync } from '../../hooks/useAsync'; import { usePluginBridge } from '../../hooks/usePluginBridge'; import { useProduceNewAlertmanagerConfiguration } from '../../hooks/useProduceNewAlertmanagerConfig'; import { addReceiverAction, deleteReceiverAction, updateReceiverAction } from '../../reducers/alertmanager/receivers'; +import { KnownProvenance } from '../../types/knownProvenance'; import { getIrmIfPresentOrOnCallPluginId } from '../../utils/config'; +import { K8sAnnotations } from '../../utils/k8s/constants'; import { enhanceContactPointsWithMetadata } from './utils'; @@ -78,10 +80,13 @@ const useOnCallIntegrations = ({ skip }: Skippable = {}) => { type K8sReceiver = ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver; const parseK8sReceiver = (item: K8sReceiver): GrafanaManagedContactPoint => { + const metadataProvenance = item.metadata.annotations?.[K8sAnnotations.Provenance]; + const provenance = metadataProvenance === KnownProvenance.None ? undefined : metadataProvenance; + return { id: item.metadata.name || item.metadata.uid || item.spec.title, name: item.spec.title, - provisioned: isK8sEntityProvisioned(item), + provenance: provenance, grafana_managed_receiver_configs: item.spec.integrations, metadata: item.metadata, }; diff --git a/public/app/features/alerting/unified/components/contact-points/useNotificationTemplates.ts b/public/app/features/alerting/unified/components/contact-points/useNotificationTemplates.ts index 91739aeca61..3083b66d300 100644 --- a/public/app/features/alerting/unified/components/contact-points/useNotificationTemplates.ts +++ b/public/app/features/alerting/unified/components/contact-points/useNotificationTemplates.ts @@ -16,7 +16,8 @@ import { deleteNotificationTemplateAction, updateNotificationTemplateAction, } from '../../reducers/alertmanager/notificationTemplates'; -import { K8sAnnotations, PROVENANCE_NONE } from '../../utils/k8s/constants'; +import { KnownProvenance } from '../../types/knownProvenance'; +import { K8sAnnotations } from '../../utils/k8s/constants'; import { getAnnotation, shouldUseK8sApi } from '../../utils/k8s/utils'; import { ensureDefine } from '../../utils/templates'; import { TemplateFormValues } from '../receivers/TemplateForm'; @@ -79,7 +80,7 @@ function templateGroupsToTemplates( function templateGroupToTemplate( templateGroup: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TemplateGroup ): NotificationTemplate { - const provenance = getAnnotation(templateGroup, K8sAnnotations.Provenance) ?? PROVENANCE_NONE; + const provenance = getAnnotation(templateGroup, K8sAnnotations.Provenance) ?? KnownProvenance.None; return { // K8s entities should always have a metadata.name property. The type is marked as optional because it's also used in other places uid: templateGroup.metadata.name ?? templateGroup.spec.title, @@ -96,8 +97,8 @@ function amConfigToTemplates(config: AlertManagerCortexConfig): NotificationTemp uid: title, title, content, - // Undefined, null or empty string should be converted to PROVENANCE_NONE - provenance: (config.template_file_provenances ?? {})[title] || PROVENANCE_NONE, + // Undefined, null or empty string should be converted to KnownProvenance.None + provenance: (config.template_file_provenances ?? {})[title] || KnownProvenance.None, missing: !templates.includes(title), })); } @@ -272,7 +273,7 @@ export function useValidateNotificationTemplate({ } interface NotificationTemplateMetadata { - isProvisioned: boolean; + provenance?: string; } export function useNotificationTemplateMetadata( @@ -280,11 +281,11 @@ export function useNotificationTemplateMetadata( ): NotificationTemplateMetadata { if (!template) { return { - isProvisioned: false, + provenance: KnownProvenance.None, }; } return { - isProvisioned: Boolean(template.provenance) && template.provenance !== PROVENANCE_NONE, + provenance: template.provenance, }; } diff --git a/public/app/features/alerting/unified/components/contact-points/utils.test.ts b/public/app/features/alerting/unified/components/contact-points/utils.test.ts index e8ded92bf74..ebf064ff6b0 100644 --- a/public/app/features/alerting/unified/components/contact-points/utils.test.ts +++ b/public/app/features/alerting/unified/components/contact-points/utils.test.ts @@ -1,8 +1,12 @@ +import { GrafanaManagedContactPoint } from 'app/plugins/datasource/alertmanager/types'; + +import { KnownProvenance } from '../../types/knownProvenance'; import { ReceiverTypes } from '../receivers/grafanaAppReceivers/onCall/onCall'; import { RECEIVER_META_KEY, RECEIVER_PLUGIN_META_KEY } from './constants'; import { ReceiverConfigWithMetadata, + enhanceContactPointsWithMetadata, getReceiverDescription, isAutoGeneratedPolicy, summarizeEmailAddresses, @@ -128,3 +132,110 @@ describe('summarizeEmailAddresses', () => { expect(summarizeEmailAddresses('foo@foo.com\n bar@bar.com ')).toBe(output); }); }); + +describe('enhanceContactPointsWithMetadata', () => { + it('should extract provenance from receiver configs when contact point has no provenance', () => { + const contactPoint: GrafanaManagedContactPoint = { + name: 'test-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid', + name: 'test-contact-point', + type: 'email', + settings: { addresses: 'test@example.com' }, + secureFields: {}, + provenance: KnownProvenance.API, + }, + ], + }; + + const enhanced = enhanceContactPointsWithMetadata({ + contactPoints: [contactPoint], + notifiers: [], + status: [], + }); + + expect(enhanced[0].provenance).toBe(KnownProvenance.API); + }); + + it('should prefer contact point provenance over receiver config provenance', () => { + const contactPoint: GrafanaManagedContactPoint = { + name: 'test-contact-point', + provenance: KnownProvenance.File, // Provenance on contact point (from K8s) + grafana_managed_receiver_configs: [ + { + uid: 'test-uid', + name: 'test-contact-point', + type: 'email', + settings: { addresses: 'test@example.com' }, + secureFields: {}, + provenance: KnownProvenance.API, // Different provenance on receiver config + }, + ], + }; + + const enhanced = enhanceContactPointsWithMetadata({ + contactPoints: [contactPoint], + notifiers: [], + status: [], + }); + + expect(enhanced[0].provenance).toBe(KnownProvenance.File); + }); + + it('should extract provenance from first receiver config that has it', () => { + const contactPoint: GrafanaManagedContactPoint = { + name: 'test-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid-1', + name: 'test-contact-point', + type: 'email', + settings: { addresses: 'test@example.com' }, + secureFields: {}, + // No provenance on first receiver + }, + { + uid: 'test-uid-2', + name: 'test-contact-point', + type: 'slack', + settings: { recipient: '#channel' }, + secureFields: {}, + provenance: KnownProvenance.ConvertedPrometheus, // Provenance on second receiver + }, + ], + }; + + const enhanced = enhanceContactPointsWithMetadata({ + contactPoints: [contactPoint], + notifiers: [], + status: [], + }); + + expect(enhanced[0].provenance).toBe(KnownProvenance.ConvertedPrometheus); + }); + + it('should have undefined provenance when neither contact point nor receiver configs have provenance', () => { + const contactPoint: GrafanaManagedContactPoint = { + name: 'test-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid', + name: 'test-contact-point', + type: 'email', + settings: { addresses: 'test@example.com' }, + secureFields: {}, + // No provenance + }, + ], + }; + + const enhanced = enhanceContactPointsWithMetadata({ + contactPoints: [contactPoint], + notifiers: [], + status: [], + }); + + expect(enhanced[0].provenance).toBeUndefined(); + }); +}); diff --git a/public/app/features/alerting/unified/components/contact-points/utils.ts b/public/app/features/alerting/unified/components/contact-points/utils.ts index d2cc43901c1..d24cfc2b0af 100644 --- a/public/app/features/alerting/unified/components/contact-points/utils.ts +++ b/public/app/features/alerting/unified/components/contact-points/utils.ts @@ -146,9 +146,16 @@ export function enhanceContactPointsWithMetadata({ const id = getContactPointIdentifier(contactPoint); + // Extract provenance from contactPoint first; else, search in its receivers + const contactPointProvenance = + 'provenance' in contactPoint && contactPoint.provenance !== undefined + ? contactPoint.provenance + : receivers.find((receiver) => Boolean(receiver.provenance))?.provenance; + return { ...contactPoint, id, + provenance: contactPointProvenance, policies: alertmanagerConfiguration && usedContactPointsByName && (usedContactPointsByName[contactPoint.name] ?? []), grafana_managed_receiver_configs: receivers.map((receiver, index) => { diff --git a/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx b/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx index 94e290a2087..ce0871ac2cd 100644 --- a/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx +++ b/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx @@ -9,7 +9,7 @@ import { IoK8SApimachineryPkgApisMetaV1ObjectMeta, } from 'app/features/alerting/unified/openapi/timeIntervalsApi.gen'; import { BaseAlertmanagerArgs, Skippable } from 'app/features/alerting/unified/types/hooks'; -import { PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; +import { KnownProvenance } from 'app/features/alerting/unified/types/knownProvenance'; import { isK8sEntityProvisioned, shouldUseK8sApi, @@ -62,7 +62,7 @@ const parseAmTimeInterval: (interval: MuteTimeInterval, provenance: string) => M return { ...interval, id: interval.name, - provisioned: Boolean(provenance && provenance !== PROVENANCE_NONE), + provisioned: Boolean(provenance && provenance !== KnownProvenance.None), }; }; diff --git a/public/app/features/alerting/unified/components/notification-policies/NotificationPoliciesList.tsx b/public/app/features/alerting/unified/components/notification-policies/NotificationPoliciesList.tsx index 448c409b673..5e85c5f575c 100644 --- a/public/app/features/alerting/unified/components/notification-policies/NotificationPoliciesList.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/NotificationPoliciesList.tsx @@ -11,7 +11,7 @@ import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alertin import { FormAmRoute } from 'app/features/alerting/unified/types/amroutes'; import { addUniqueIdentifierToRoute } from 'app/features/alerting/unified/utils/amroutes'; import { getErrorCode, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc'; -import { ObjectMatcher, ROUTES_META_SYMBOL, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; +import { ObjectMatcher, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; import { anyOfRequestState, isError } from '../../hooks/useAsync'; import { useAlertmanager } from '../../state/AlertmanagerContext'; @@ -27,6 +27,7 @@ import { useAddPolicyModal, useAlertGroupsModal, useDeletePolicyModal, useEditPo import { Policy } from './Policy'; import { TIMING_OPTIONS_DEFAULTS } from './timingOptions'; import { + isRouteProvisioned, useAddNotificationPolicy, useDeleteNotificationPolicy, useNotificationPolicyRoute, @@ -99,6 +100,8 @@ export const NotificationPoliciesList = () => { } return; }, [defaultPolicy]); + const routeProvenance = defaultPolicy?.provenance; + const isRootRouteProvisioned = rootRoute ? isRouteProvisioned(rootRoute) : false; // useAsync could also work but it's hard to wait until it's done in the tests // Combining with useEffect gives more predictable results because the condition is in useEffect @@ -244,7 +247,8 @@ export const NotificationPoliciesList = () => { currentRoute={defaults(rootRoute, TIMING_OPTIONS_DEFAULTS)} contactPointsState={contactPointsState.receivers} readOnly={!hasConfigurationAPI} - provisioned={rootRoute[ROUTES_META_SYMBOL]?.provisioned} + provisioned={isRootRouteProvisioned} + provenance={routeProvenance} alertManagerSourceName={selectedAlertmanager} onAddPolicy={openAddModal} onEditPolicy={openEditModal} diff --git a/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx b/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx index a10bca42100..62f9a57ad73 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx @@ -17,6 +17,7 @@ import { import { useAlertmanagerAbilities } from '../../hooks/useAbilities'; import { mockReceiversState } from '../../mocks'; import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; +import { KnownProvenance } from '../../types/knownProvenance'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { @@ -331,6 +332,60 @@ describe('Policy', () => { const customPolicy = screen.getByTestId('am-route-container'); expect(within(customPolicy).getByTestId('matches-all')).toBeInTheDocument(); }); + + it('shows correct badge when policy has file provenance', () => { + const mockRoute: RouteWithID = { + id: 'test-route', + receiver: 'test-receiver', + routes: [], + }; + + renderPolicy( + + ); + + const badge = screen.getByText('Provisioned'); + expect(badge).toBeInTheDocument(); + }); + + it('shows correct badge when policy has converted_prometheus provenance', () => { + const mockRoute: RouteWithID = { + id: 'test-route', + receiver: 'test-receiver', + routes: [], + }; + + renderPolicy( + + ); + + const badge = screen.getByText('Imported'); + expect(badge).toBeInTheDocument(); + }); }); // Doesn't matter which path the routes use, it just needs to match the initialEntries history entry to render the element diff --git a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx index c4b6a0c55b7..d638273e006 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx @@ -61,6 +61,7 @@ interface PolicyComponentProps { contactPointsState?: ReceiversState; readOnly?: boolean; provisioned?: boolean; + provenance?: string; inheritedProperties?: InheritableProperties; routesMatchingFilters?: RoutesMatchingFilters; @@ -89,6 +90,7 @@ const Policy = (props: PolicyComponentProps) => { contactPointsState, readOnly = false, provisioned = false, + provenance, alertManagerSourceName, currentRoute, inheritedProperties, @@ -255,7 +257,7 @@ const Policy = (props: PolicyComponentProps) => { {/* TODO maybe we should move errors to the gutter instead? */} {errors.length > 0 && } - {provisioned && } + {provisioned && } {!isAutoGenerated && !readOnly && ( diff --git a/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.test.tsx b/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.test.tsx index 512f8f2ac66..a57886534ff 100644 --- a/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.test.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.test.tsx @@ -1,9 +1,15 @@ import { MatcherOperator, ROUTES_META_SYMBOL, Route } from 'app/plugins/datasource/alertmanager/types'; import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route } from '../../openapi/routesApi.gen'; +import { KnownProvenance } from '../../types/knownProvenance'; import { ROOT_ROUTE_NAME } from '../../utils/k8s/constants'; -import { createKubernetesRoutingTreeSpec, k8sSubRouteToRoute, routeToK8sSubRoute } from './useNotificationPolicyRoute'; +import { + createKubernetesRoutingTreeSpec, + isRouteProvisioned, + k8sSubRouteToRoute, + routeToK8sSubRoute, +} from './useNotificationPolicyRoute'; test('k8sSubRouteToRoute', () => { const input: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route = { @@ -115,3 +121,86 @@ test('createKubernetesRoutingTreeSpec', () => { expect(tree.metadata.name).toBe(ROOT_ROUTE_NAME); expect(tree).toMatchSnapshot(); }); + +describe('isRouteProvisioned', () => { + it('returns false when route has no provenance', () => { + const route: Route = { + receiver: 'test-receiver', + }; + + expect(isRouteProvisioned(route)).toBeFalsy(); + }); + + it('returns false when route has KnownProvenance.None in metadata', () => { + const route: Route = { + receiver: 'test-receiver', + [ROUTES_META_SYMBOL]: { + provenance: KnownProvenance.None, + }, + }; + + expect(isRouteProvisioned(route)).toBeFalsy(); + }); + + it('returns false when route has KnownProvenance.None at top level', () => { + const route: Route = { + receiver: 'test-receiver', + provenance: KnownProvenance.None, + }; + expect(isRouteProvisioned(route)).toBeFalsy(); + }); + + it('returns true when route has file provenance in metadata', () => { + const route: Route = { + receiver: 'test-receiver', + [ROUTES_META_SYMBOL]: { + provenance: KnownProvenance.File, + }, + }; + + expect(isRouteProvisioned(route)).toBeTruthy(); + }); + + it('returns true when route has api provenance in metadata', () => { + const route: Route = { + receiver: 'test-receiver', + [ROUTES_META_SYMBOL]: { + provenance: KnownProvenance.API, + }, + }; + + expect(isRouteProvisioned(route)).toBeTruthy(); + }); + + it('returns true when route has converted_prometheus provenance in metadata', () => { + const route: Route = { + receiver: 'test-receiver', + [ROUTES_META_SYMBOL]: { + provenance: KnownProvenance.ConvertedPrometheus, + }, + }; + + expect(isRouteProvisioned(route)).toBeTruthy(); + }); + + it('returns true when route has file provenance at top level', () => { + const route: Route = { + receiver: 'test-receiver', + provenance: KnownProvenance.File, + }; + + expect(isRouteProvisioned(route)).toBeTruthy(); + }); + + it('falls back to top-level provenance when metadata provenance is missing', () => { + const route: Route = { + receiver: 'test-receiver', + provenance: KnownProvenance.File, + [ROUTES_META_SYMBOL]: { + provenance: undefined, + }, + }; + + expect(isRouteProvisioned(route)).toBeTruthy(); + }); +}); diff --git a/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.ts b/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.ts index e6a0b7a0cc5..ca9be820463 100644 --- a/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.ts +++ b/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.ts @@ -22,8 +22,8 @@ import { } from '../../reducers/alertmanager/notificationPolicyRoutes'; import { FormAmRoute } from '../../types/amroutes'; import { addUniqueIdentifierToRoute } from '../../utils/amroutes'; -import { PROVENANCE_NONE, ROOT_ROUTE_NAME } from '../../utils/k8s/constants'; -import { isK8sEntityProvisioned, shouldUseK8sApi } from '../../utils/k8s/utils'; +import { K8sAnnotations, ROOT_ROUTE_NAME } from '../../utils/k8s/constants'; +import { getAnnotation, isProvisionedResource, shouldUseK8sApi } from '../../utils/k8s/utils'; import { routeAdapter } from '../../utils/routeAdapter'; import { InsertPosition, @@ -33,6 +33,11 @@ import { omitRouteFromRouteTree, } from '../../utils/routeTree'; +export function isRouteProvisioned(route: Route): boolean { + const provenance = route[ROUTES_META_SYMBOL]?.provenance ?? route.provenance; + return isProvisionedResource(provenance); +} + const k8sRoutesToRoutesMemoized = memoize(k8sRoutesToRoutes, { maxSize: 1 }); const { @@ -82,7 +87,7 @@ const parseAmConfigRoute = memoize((route: Route): Route => { return { ...route, [ROUTES_META_SYMBOL]: { - provisioned: Boolean(route.provenance && route.provenance !== PROVENANCE_NONE), + provenance: route.provenance, }, }; }); @@ -232,10 +237,11 @@ function k8sRoutesToRoutes(routes: ComGithubGrafanaGrafanaPkgApisAlertingNotific ...route.spec.defaults, routes: route.spec.routes?.map(k8sSubRouteToRoute), [ROUTES_META_SYMBOL]: { - provisioned: isK8sEntityProvisioned(route), + provenance: getAnnotation(route, K8sAnnotations.Provenance), resourceVersion: route.metadata.resourceVersion, name: route.metadata.name, }, + provenance: getAnnotation(route, K8sAnnotations.Provenance), }; }); } diff --git a/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx b/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx index ad6ee94d73e..b57242f0e49 100644 --- a/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx @@ -79,6 +79,9 @@ describe('new receiver', () => { // click test await user.click(ui.testContactPoint.get()); + // close the modal + await user.click(screen.getByRole('button', { name: 'Close' })); + // we shouldn't be testing implementation details but when the request is successful // it can't seem to assert on the success toast await user.click(ui.saveContactButton.get()); diff --git a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx index 5a50ab55cd4..92872e56bef 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx @@ -33,6 +33,7 @@ import { AccessControlAction } from 'app/types/accessControl'; import { AITemplateButtonComponent } from '../../enterprise-components/AI/AIGenTemplateButton/addAITemplateButton'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { isProvisionedResource } from '../../utils/k8s/utils'; import { makeAMLink, stringifyErrorLike } from '../../utils/misc'; import { EditorColumnHeader } from '../EditorColumnHeader'; import { ProvisionedResource, ProvisioningAlert } from '../Provisioning'; @@ -122,7 +123,8 @@ export const TemplateForm = ({ originalTemplate, prefill, alertmanager }: Props) // AI feedback state const [aiGeneratedTemplate, setAiGeneratedTemplate] = useState(false); - const { isProvisioned } = useNotificationTemplateMetadata(originalTemplate); + const { provenance } = useNotificationTemplateMetadata(originalTemplate); + const isProvisioned = isProvisionedResource(provenance); const originalTemplatePrefill: TemplateFormValues | undefined = originalTemplate ? { title: originalTemplate.title, content: originalTemplate.content } : undefined; diff --git a/public/app/features/alerting/unified/components/receivers/TemplatesTable.test.tsx b/public/app/features/alerting/unified/components/receivers/TemplatesTable.test.tsx new file mode 100644 index 00000000000..f707d1d6b79 --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/TemplatesTable.test.tsx @@ -0,0 +1,98 @@ +import { render, screen, within } from 'test/test-utils'; + +import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList'; +import { AccessControlAction } from 'app/types/accessControl'; + +import { setupMswServer } from '../../mockApi'; +import { grantUserPermissions } from '../../mocks'; +import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; +import { KnownProvenance } from '../../types/knownProvenance'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { NotificationTemplate } from '../contact-points/useNotificationTemplates'; + +import { TemplatesTable } from './TemplatesTable'; + +const mockTemplates: Array> = [ + { + uid: 'mimir-template', + title: 'mimir-template', + content: '{{ define "mimir-template" }}Template from Mimir{{ end }}', + provenance: KnownProvenance.ConvertedPrometheus, + }, + { + uid: 'file-template', + title: 'file-template', + content: '{{ define "file-template" }}File provisioned template{{ end }}', + provenance: KnownProvenance.File, + }, + { + uid: 'api-template', + title: 'api-template', + content: '{{ define "api-template" }}API provisioned template{{ end }}', + provenance: KnownProvenance.API, + }, + { + uid: 'no-provenance-template', + title: 'no-provenance-template', + content: '{{ define "no-provenance-template" }}No provenance template{{ end }}', + provenance: KnownProvenance.None, + }, + { + uid: 'undefined-provenance-template', + title: 'undefined-provenance-template', + content: '{{ define "undefined-provenance-template" }}Undefined provenance template{{ end }}', + provenance: undefined, + }, +]; + +const renderWithProvider = (templates: Array>) => { + return render( + + + + + ); +}; + +setupMswServer(); + +describe('TemplatesTable', () => { + beforeEach(() => { + grantUserPermissions([ + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsWrite, + AccessControlAction.AlertingNotificationsExternalRead, + AccessControlAction.AlertingNotificationsExternalWrite, + ]); + }); + + it('shows "Imported" badge for templates with converted_prometheus provenance', () => { + const templates = [mockTemplates[0]]; // mimir-template + renderWithProvider(templates); + + const templateRow = screen.getByRole('row', { name: /mimir-template/i }); + const badge = within(templateRow).getByText('Imported'); + expect(badge).toBeInTheDocument(); + }); + + it('shows "Provisioned" badge for templates with other provenance', () => { + // api and file templates + [mockTemplates[1], mockTemplates[2]].forEach((template) => { + renderWithProvider([template]); + + const templateRow = screen.getByRole('row', { name: new RegExp(template.title ?? '', 'i') }); + const badge = within(templateRow).getByText('Provisioned'); + expect(badge).toBeInTheDocument(); + }); + }); + + it('does not show badge for templates with KnownProvenance.None or empty string provenance', () => { + // no-provenance-template and undefined-provenance-template + [mockTemplates[3], mockTemplates[4]].forEach((template) => { + renderWithProvider([template]); + + const templateRow = screen.getByRole('row', { name: new RegExp(template.title ?? '', 'i') }); + expect(within(templateRow).queryByText('Provisioned')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx b/public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx index ea00e22b280..4f71904dd73 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx @@ -10,6 +10,7 @@ import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/d import { Authorize } from '../../components/Authorize'; import { AlertmanagerAction } from '../../hooks/useAbilities'; import { getAlertTableStyles } from '../../styles/table'; +import { isProvisionedResource } from '../../utils/k8s/utils'; import { makeAMLink, stringifyErrorLike } from '../../utils/misc'; import { CollapseToggle } from '../CollapseToggle'; import { DetailsField } from '../DetailsField'; @@ -128,7 +129,8 @@ function TemplateRow({ notificationTemplate, idx, alertManagerName, onDeleteClic const isGrafanaAlertmanager = alertManagerName === GRAFANA_RULES_SOURCE_NAME; const [isExpanded, setIsExpanded] = useState(false); - const { isProvisioned } = useNotificationTemplateMetadata(notificationTemplate); + const { provenance } = useNotificationTemplateMetadata(notificationTemplate); + const isProvisioned = isProvisionedResource(provenance); const { uid, title: name, content: template, missing } = notificationTemplate; const misconfiguredBadgeText = t('alerting.templates.misconfigured-badge-text', 'Misconfigured'); @@ -139,7 +141,7 @@ function TemplateRow({ notificationTemplate, idx, alertManagerName, onDeleteClic setIsExpanded(!isExpanded)} /> - {name} {isProvisioned && }{' '} + {name} {isProvisioned && }{' '} {missing && !isGrafanaAlertmanager && ( ; secureFields: Record; + version?: string; }; type TestReceiverFormValues = { @@ -246,4 +248,241 @@ describe('ChannelSubForm', () => { expect(slackUrl).toBeEnabled(); expect(slackUrl).toHaveValue(''); }); + + describe('version-specific options display', () => { + // Create a mock notifier with different options for v0 and v1 + const legacyOptions = [ + { + element: 'input' as const, + inputType: 'text', + label: 'Legacy URL', + description: 'The legacy endpoint URL', + placeholder: '', + propertyName: 'legacyUrl', + required: true, + secure: false, + showWhen: { field: '', is: '' }, + validationRule: '', + dependsOn: '', + }, + ]; + + const webhookWithVersions: NotifierDTO = { + ...grafanaAlertNotifiers.webhook, + versions: [ + { + version: 'v0mimir1', + label: 'Webhook (Legacy)', + description: 'Legacy webhook from Mimir', + canCreate: false, + options: legacyOptions, + }, + { + version: 'v0mimir2', + label: 'Webhook (Legacy v2)', + description: 'Legacy webhook v2 from Mimir', + canCreate: false, + options: legacyOptions, + }, + { + version: 'v1', + label: 'Webhook', + description: 'Sends HTTP POST request', + canCreate: true, + options: grafanaAlertNotifiers.webhook.options, + }, + ], + }; + + const versionedNotifiers: Notifier[] = [ + { dto: webhookWithVersions, meta: { enabled: true, order: 1 } }, + { dto: grafanaAlertNotifiers.slack, meta: { enabled: true, order: 2 } }, + ]; + + function VersionedTestFormWrapper({ + defaults, + initial, + }: { + defaults: TestChannelValues; + initial?: TestChannelValues; + }) { + const form = useForm({ + defaultValues: { + name: 'test-contact-point', + items: [defaults], + }, + }); + + return ( + + + + + + ); + } + + function renderVersionedForm(defaults: TestChannelValues, initial?: TestChannelValues) { + return render(); + } + + it('should display v1 options when integration has v1 version', () => { + const webhookV1: TestChannelValues = { + __id: 'id-0', + type: 'webhook', + version: 'v1', + settings: { url: 'https://example.com' }, + secureFields: {}, + }; + + renderVersionedForm(webhookV1, webhookV1); + + // Should show v1 URL field (from default options) + expect(ui.settings.webhook.url.get()).toBeInTheDocument(); + // Should NOT show legacy URL field + expect(screen.queryByRole('textbox', { name: /Legacy URL/i })).not.toBeInTheDocument(); + }); + + it('should display v0 options when integration has legacy version', () => { + const webhookV0: TestChannelValues = { + __id: 'id-0', + type: 'webhook', + version: 'v0mimir1', + settings: { legacyUrl: 'https://legacy.example.com' }, + secureFields: {}, + }; + + renderVersionedForm(webhookV0, webhookV0); + + // Should show legacy URL field (from v0 options) + expect(screen.getByRole('textbox', { name: /Legacy URL/i })).toBeInTheDocument(); + // Should NOT show v1 URL field + expect(ui.settings.webhook.url.query()).not.toBeInTheDocument(); + }); + + it('should display "Legacy" badge for v0mimir1 integration', () => { + const webhookV0: TestChannelValues = { + __id: 'id-0', + type: 'webhook', + version: 'v0mimir1', + settings: { legacyUrl: 'https://legacy.example.com' }, + secureFields: {}, + }; + + renderVersionedForm(webhookV0, webhookV0); + + // Should show "Legacy" badge for v0mimir1 integrations + expect(screen.getByText('Legacy')).toBeInTheDocument(); + }); + + it('should display "Legacy v2" badge for v0mimir2 integration', () => { + const webhookV0v2: TestChannelValues = { + __id: 'id-0', + type: 'webhook', + version: 'v0mimir2', + settings: { legacyUrl: 'https://legacy.example.com' }, + secureFields: {}, + }; + + renderVersionedForm(webhookV0v2, webhookV0v2); + + // Should show "Legacy v2" badge for v0mimir2 integrations + expect(screen.getByText('Legacy v2')).toBeInTheDocument(); + }); + + it('should NOT display version badge for v1 integration', () => { + const webhookV1: TestChannelValues = { + __id: 'id-0', + type: 'webhook', + version: 'v1', + settings: { url: 'https://example.com' }, + secureFields: {}, + }; + + renderVersionedForm(webhookV1, webhookV1); + + // Should NOT show version badge for non-legacy v1 integrations + expect(screen.queryByText('v1')).not.toBeInTheDocument(); + }); + + it('should filter out notifiers with canCreate: false from dropdown', () => { + // Create a notifier that only has v0 versions (cannot be created) + const legacyOnlyNotifier: NotifierDTO = { + type: 'wechat', + name: 'WeChat', + heading: 'WeChat settings', + description: 'Sends notifications to WeChat', + options: [], + versions: [ + { + version: 'v0mimir1', + label: 'WeChat (Legacy)', + description: 'Legacy WeChat', + canCreate: false, + options: [], + }, + ], + }; + + const notifiersWithLegacyOnly: Notifier[] = [ + { dto: webhookWithVersions, meta: { enabled: true, order: 1 } }, + { dto: legacyOnlyNotifier, meta: { enabled: true, order: 2 } }, + ]; + + function LegacyOnlyTestWrapper({ defaults }: { defaults: TestChannelValues }) { + const form = useForm({ + defaultValues: { + name: 'test-contact-point', + items: [defaults], + }, + }); + + return ( + + + + + + ); + } + + render( + + ); + + // Webhook should be in dropdown (has v1 with canCreate: true) + expect(ui.typeSelector.get()).toHaveTextContent('Webhook'); + + // WeChat should NOT be in the options (only has v0 with canCreate: false) + // We can't easily check dropdown options without opening it, but the filter should work + }); + }); }); diff --git a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx index c49b5184623..cb1d79025f8 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx @@ -6,7 +6,7 @@ import { Controller, FieldErrors, useFormContext } from 'react-hook-form'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { Alert, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui'; +import { Alert, Badge, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui'; import { NotificationChannelOption } from 'app/features/alerting/unified/types/alerting'; import { @@ -16,6 +16,12 @@ import { GrafanaChannelValues, ReceiverFormValues, } from '../../../types/receiver-form'; +import { + canCreateNotifier, + getLegacyVersionLabel, + getOptionsForVersion, + isLegacyVersion, +} from '../../../utils/notifier-versions'; import { OnCallIntegrationType } from '../grafanaAppReceivers/onCall/useOnCallIntegration'; import { ChannelOptions } from './ChannelOptions'; @@ -62,6 +68,7 @@ export function ChannelSubForm({ const channelFieldPath = `items.${integrationIndex}` as const; const typeFieldPath = `${channelFieldPath}.type` as const; + const versionFieldPath = `${channelFieldPath}.version` as const; const settingsFieldPath = `${channelFieldPath}.settings` as const; const secureFieldsPath = `${channelFieldPath}.secureFields` as const; @@ -104,6 +111,9 @@ export function ChannelSubForm({ setValue(settingsFieldPath, defaultNotifierSettings); setValue(secureFieldsPath, {}); + + // Reset version when changing type - backend will use its default + setValue(versionFieldPath, undefined); } // Restore initial value of an existing oncall integration @@ -123,6 +133,7 @@ export function ChannelSubForm({ setValue, settingsFieldPath, typeFieldPath, + versionFieldPath, secureFieldsPath, getValues, watch, @@ -164,24 +175,30 @@ export function ChannelSubForm({ setValue(`${settingsFieldPath}.${fieldPath}`, undefined); }; - const typeOptions = useMemo( - (): SelectableValue[] => - sortBy(notifiers, ({ dto, meta }) => [meta?.order ?? 0, dto.name]).map( - ({ dto: { name, type }, meta }) => ({ - // @ts-expect-error ReactNode is supported + const typeOptions = useMemo((): SelectableValue[] => { + // Filter out notifiers that can't be created (e.g., v0-only integrations like WeChat) + // These are legacy integrations that only exist in Mimir and can't be created in Grafana + const creatableNotifiers = notifiers.filter(({ dto }) => canCreateNotifier(dto)); + + return sortBy(creatableNotifiers, ({ dto, meta }) => [meta?.order ?? 0, dto.name]).map( + ({ dto: { name, type }, meta }) => { + return { + // ReactNode is supported in Select label, but types don't reflect it + /* eslint-disable @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any */ label: ( {name} {meta?.badge} - ), + ) as any, + /* eslint-enable @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any */ value: type, description: meta?.description, isDisabled: meta ? !meta.enabled : false, - }) - ), - [notifiers] - ); + }; + } + ); + }, [notifiers]); const handleTest = async () => { await trigger(); @@ -198,10 +215,21 @@ export function ChannelSubForm({ // Cloud AM takes no value at all const isParseModeNone = parse_mode === 'None' || !parse_mode; const showTelegramWarning = isTelegram && !isParseModeNone; + + // Check if current integration is a legacy version (canCreate: false) + // Legacy integrations are read-only and cannot be edited + // Read version from existing integration data (stored in receiver config) + const integrationVersion = initialValues?.version || defaultValues.version; + const isLegacy = notifier ? isLegacyVersion(notifier.dto, integrationVersion) : false; + + // Get the correct options based on the integration's version + // This ensures legacy (v0) integrations display the correct schema + const versionedOptions = notifier ? getOptionsForVersion(notifier.dto, integrationVersion) : []; + // if there are mandatory options defined, optional options will be hidden by a collapse // if there aren't mandatory options, all options will be shown without collapse - const mandatoryOptions = notifier?.dto.options.filter((o) => o.required) ?? []; - const optionalOptions = notifier?.dto.options.filter((o) => !o.required) ?? []; + const mandatoryOptions = versionedOptions.filter((o) => o.required); + const optionalOptions = versionedOptions.filter((o) => !o.required); const contactPointTypeInputId = `contact-point-type-${pathPrefix}`; return ( @@ -214,21 +242,35 @@ export function ChannelSubForm({ data-testid={`${pathPrefix}type`} noMargin > - ( - onChange(value?.value)} + /> + )} + /> + {isLegacy && integrationVersion && ( + )} - /> +
        @@ -292,7 +334,7 @@ export function ChannelSubForm({ name: notifier.dto.name, })} > - {notifier.dto.info !== '' && ( + {notifier.dto.info && ( {notifier.dto.info} 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 df68ab185dc..30f26055983 100644 --- a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx @@ -9,7 +9,11 @@ import { } from 'app/features/alerting/unified/components/contact-points/useContactPoints'; import { showManageContactPointPermissions } from 'app/features/alerting/unified/components/contact-points/utils'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; -import { canEditEntity, canModifyProtectedEntity } from 'app/features/alerting/unified/utils/k8s/utils'; +import { + canEditEntity, + canModifyProtectedEntity, + isProvisionedResource, +} from 'app/features/alerting/unified/utils/k8s/utils'; import { GrafanaManagedContactPoint, GrafanaManagedReceiverConfig, @@ -18,12 +22,13 @@ import { import { alertmanagerApi } from '../../../api/alertmanagerApi'; import { GrafanaChannelValues, ReceiverFormValues } from '../../../types/receiver-form'; +import { hasLegacyIntegrations } from '../../../utils/notifier-versions'; import { formChannelValuesToGrafanaChannelConfig, formValuesToGrafanaReceiver, grafanaReceiverToFormValues, } from '../../../utils/receiver-form'; -import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning'; +import { ImportedContactPointAlert, ProvisionedResource, ProvisioningAlert } from '../../Provisioning'; import { ReceiverTypes } from '../grafanaAppReceivers/onCall/onCall'; import { useOnCallIntegration } from '../grafanaAppReceivers/onCall/useOnCallIntegration'; @@ -39,6 +44,8 @@ const defaultChannelValues: GrafanaChannelValues = Object.freeze({ secureFields: {}, disableResolveMessage: false, type: 'email', + // version is intentionally not set here - it will be determined by the notifier's currentVersion + // when the integration is created/type is changed. The backend will use its default if not provided. }); interface Props { @@ -67,7 +74,6 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode } } = useOnCallIntegration(); const { data: grafanaNotifiers = [], isLoading: isLoadingNotifiers } = useGrafanaNotifiersQuery(); - const [testReceivers, setTestReceivers] = useState(); // transform receiver DTO to form values @@ -125,7 +131,8 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode } // 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 hasScopedEditProtectedPermissions = contactPoint ? canModifyProtectedEntity(contactPoint) : true; - const isEditable = !readOnly && hasScopedEditPermissions && !contactPoint?.provisioned; + const isProvisioned = isProvisionedResource(contactPoint?.provenance); + const isEditable = !readOnly && hasScopedEditPermissions && !isProvisioned; const isTestable = !readOnly; const canEditProtectedFields = editMode ? hasScopedEditProtectedPermissions : true; @@ -135,15 +142,20 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode } ); } + // Map notifiers to Notifier[] format for ReceiverForm + // The grafanaNotifiers include version-specific options via the versions array from the backend + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions const notifiers: Notifier[] = grafanaNotifiers.map((n) => { if (n.type === ReceiverTypes.OnCall) { return { - dto: extendOnCallNotifierFeatures(n), + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions + dto: extendOnCallNotifierFeatures(n as any) as any, meta: onCallNotifierMeta, }; } - return { dto: n }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions + return { dto: n as any }; }); return ( @@ -163,7 +175,10 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode } )} - {contactPoint?.provisioned && } + {isProvisioned && hasLegacyIntegrations(contactPoint, grafanaNotifiers) && } + {isProvisioned && !hasLegacyIntegrations(contactPoint, grafanaNotifiers) && ( + + )} contactPointId={contactPoint?.id} diff --git a/public/app/features/alerting/unified/components/receivers/form/fields/TemplateSelector.test.tsx b/public/app/features/alerting/unified/components/receivers/form/fields/TemplateSelector.test.tsx index 6134ace8fbb..866f489e6fa 100644 --- a/public/app/features/alerting/unified/components/receivers/form/fields/TemplateSelector.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/fields/TemplateSelector.test.tsx @@ -7,8 +7,8 @@ import { grantUserPermissions } from 'app/features/alerting/unified/mocks'; import { getAlertmanagerConfig } from 'app/features/alerting/unified/mocks/server/entities/alertmanagers'; import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext'; import { NotificationChannelOption } from 'app/features/alerting/unified/types/alerting'; +import { KnownProvenance } from 'app/features/alerting/unified/types/knownProvenance'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; -import { PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; import { DEFAULT_TEMPLATES } from 'app/features/alerting/unified/utils/template-constants'; import { AccessControlAction } from 'app/types/accessControl'; @@ -68,7 +68,7 @@ describe('getTemplateOptions function', () => { uid: title, title, content, - provenance: PROVENANCE_NONE, + provenance: KnownProvenance.None, }; }); const defaultTemplates = parseTemplates(DEFAULT_TEMPLATES); diff --git a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx index 30da1c9e41d..74ee6cd61d3 100644 --- a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx @@ -162,7 +162,7 @@ export function useCombinedLabels( // This is called by Combobox when the dropdown menu opens const createAsyncValuesLoader = useCallback( (key: string): AsyncOptionsLoader => { - return async (_inputValue: string): Promise>> => { + return async (valueQuery: string): Promise>> => { if (!isKeyAllowed(key) || !key) { return []; } @@ -188,7 +188,10 @@ export function useCombinedLabels( // Combine: existing values first, then unique ops values (Set preserves first occurrence) const combinedValues = [...new Set([...existingValues, ...opsValues])]; - return mapLabelsToOptions(combinedValues); + const valueQueryLowerCase = valueQuery.toLowerCase(); + const filteredValues = combinedValues.filter((value) => value.toLowerCase().includes(valueQueryLowerCase)); + + return mapLabelsToOptions(filteredValues); }; }, [labelsByKeyFromExisingAlerts, labelsPluginInstalled, opsLabelKeysSet, fetchLabelValues] diff --git a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsFieldOpsLabels.test.tsx b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsFieldOpsLabels.test.tsx index 1d1bfb68050..6de58193962 100644 --- a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsFieldOpsLabels.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsFieldOpsLabels.test.tsx @@ -6,24 +6,40 @@ import { clearPluginSettingsCache } from 'app/features/plugins/pluginSettings'; import { mockAlertRuleApi, setupMswServer } from '../../../mockApi'; import { getGrafanaRule } from '../../../mocks'; -import { - defaultLabelValues, - getLabelValuesHandler, - getMockOpsLabels, -} from '../../../mocks/server/handlers/plugins/grafana-labels-app'; +import { getMockOpsLabels } from '../../../mocks/server/handlers/plugins/grafana-labels-app'; import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource'; import { LabelsWithSuggestions } from './LabelsField'; +// Mock getBoundingClientRect for @tanstack/react-virtual to calculate visible items +// The global ResizeObserver mock in jest-setup.ts handles subsequent measurements +Element.prototype.getBoundingClientRect = jest.fn(() => ({ + width: 200, + height: 400, + top: 0, + left: 0, + bottom: 400, + right: 200, + x: 0, + y: 0, + toJSON: () => ({}), +})); + // Existing labels in the form (simulating editing an existing alert rule with ops labels) const existingOpsLabels = getMockOpsLabels(); -const SubFormProviderWrapper = ({ +// Wrapper that provides portal container for Combobox dropdowns +const TestWrapper = ({ children, labels, }: React.PropsWithChildren<{ labels: Array<{ key: string; value: string }> }>) => { const methods = useForm({ defaultValues: { labelsInSubform: labels } }); - return {children}; + return ( + <> + {children} +
        + + ); }; const grafanaRule = getGrafanaRule(undefined, { @@ -64,9 +80,9 @@ describe('LabelsField with ops labels', () => { async function renderLabelsWithOpsLabels(labels = existingOpsLabels) { const view = render( - + - + ); // Wait for the dropdowns to be rendered @@ -221,41 +237,84 @@ describe('LabelsField with ops labels', () => { expect(combobox).toHaveAttribute('aria-expanded', 'true'); }); - // Test that opening the value dropdown requests values for the CORRECT label key - // This verifies the async loader is called with the right key - it('should request correct label values when opening value dropdown', async () => { - const requestedKeys: string[] = []; - - // Add a spy handler that tracks which keys are requested - server.use(getLabelValuesHandler(defaultLabelValues, (key) => requestedKeys.push(key))); - + // Test that opening the value dropdown shows values for the CORRECT label key + // This verifies the async loader is called with the right key and renders the correct options + it('should show correct label values when opening value dropdown', async () => { const { user } = await renderLabelsWithOpsLabels(); // Open the first label's value dropdown (sentMail) + // Expected values: "true", "false" const firstValueDropdown = within(screen.getByTestId('labelsInSubform-value-0')); await user.click(firstValueDropdown.getByRole('combobox')); - // Wait for the API call to be made - await waitFor(() => { - expect(requestedKeys).toContain('sentMail'); - }); + // Wait for sentMail values to appear + const trueOption = await screen.findByRole('option', { name: /true/i }); + expect(trueOption).toBeInTheDocument(); - // Close dropdown - await user.keyboard('{Escape}'); + // Verify we have exactly 2 options for sentMail (true, false) + const firstDropdownOptions = screen.getAllByRole('option'); + expect(firstDropdownOptions).toHaveLength(2); + expect(firstDropdownOptions[0]).toHaveTextContent('true'); + expect(firstDropdownOptions[1]).toHaveTextContent('false'); - // Clear the tracked keys - requestedKeys.length = 0; + // Close dropdown by clicking outside (simulate real user behavior) + await user.click(document.body); // Open the second label's value dropdown (stage) + // Expected values: "production", "staging", "development" const secondValueDropdown = within(screen.getByTestId('labelsInSubform-value-1')); await user.click(secondValueDropdown.getByRole('combobox')); - // Wait for the API call - should request 'stage', NOT 'sentMail' - await waitFor(() => { - expect(requestedKeys).toContain('stage'); - }); + // Wait for stage values to appear + const productionOption = await screen.findByRole('option', { name: /production/i }); + expect(productionOption).toBeInTheDocument(); - // Verify we didn't request the wrong key (the bug from escalation #19378) - expect(requestedKeys).not.toContain('sentMail'); + // Verify we have exactly 3 options for stage (production, staging, development) + // This ensures we're NOT showing sentMail values + const secondDropdownOptions = screen.getAllByRole('option'); + expect(secondDropdownOptions).toHaveLength(3); + expect(secondDropdownOptions[0]).toHaveTextContent('production'); + expect(secondDropdownOptions[1]).toHaveTextContent('staging'); + expect(secondDropdownOptions[2]).toHaveTextContent('development'); + }); + + // Test that typing in the value dropdown filters options (search functionality) + it('should filter value options when typing in the combobox', async () => { + const { user } = await renderLabelsWithOpsLabels(); + + // Add a new label with "stage" key which has multiple values: production, staging, development + const addMoreButton = await screen.findByText('Add more'); + await user.click(addMoreButton); + + // First, set the key to "stage" + const keyDropdown = within(screen.getByTestId('labelsInSubform-key-2')); + await user.type(keyDropdown.getByRole('combobox'), 'stage{enter}'); + + // Wait for the key to be set + const keyInput = screen.getByTestId('labelsInSubform-key-2').querySelector('input'); + await waitFor(() => expect(keyInput).toHaveValue('stage')); + + const valueDropdown = within(screen.getByTestId('labelsInSubform-value-2')); + const combobox = valueDropdown.getByRole('combobox'); + + // Type "stag" which should filter to only "staging" (not "production" or "development") + await user.type(combobox, 'stag'); + + // Wait for the staging option to appear (allows for debounce + async load) + const stagingOption = await screen.findByRole('option', { name: /staging/i }); + expect(stagingOption).toBeInTheDocument(); + + // Verify we have exactly 2 options: + // 1. "stag" - Use custom value (created because user typed custom text) + // 2. "staging" - The filtered match from available values + const allOptions = screen.getAllByRole('option'); + expect(allOptions).toHaveLength(2); + expect(allOptions[0]).toHaveTextContent('stag'); + expect(allOptions[0]).toHaveTextContent('Use custom value'); + expect(allOptions[1]).toHaveTextContent('staging'); + + // Verify that "production" and "development" are NOT shown (they don't match "stag") + expect(screen.queryByRole('option', { name: /^production$/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: /^development$/i })).not.toBeInTheDocument(); }); }); diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx index d19ac4bb619..fd6cbf40fae 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx @@ -36,7 +36,11 @@ import { isExpressionQueryInAlert, } from '../../../rule-editor/formProcessing'; import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; -import { GRAFANA_RULES_SOURCE_NAME, getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource'; +import { + GRAFANA_RULES_SOURCE_NAME, + getDefaultOrFirstCompatibleDataSource, + getRulesDataSources, +} from '../../../utils/datasource'; import { PromOrLokiQuery, isPromOrLokiQuery } from '../../../utils/rule-form'; import { isCloudAlertingRuleByType, @@ -417,7 +421,9 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange, mod ]); const { sectionTitle, helpLabel, helpContent, helpLink } = DESCRIPTIONS[type ?? RuleFormType.grafana]; - + // Only show the data source managed option if there are data sources with manageAlerts enabled + const hasAlertEnabledDataSources = useMemo(() => getRulesDataSources().length > 0, []); + const canSelectDataSourceManaged = onlyOneDSInQueries(queries) && hasAlertEnabledDataSources; if (!type) { return null; } @@ -437,8 +443,6 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange, mod } : undefined; - const canSelectDataSourceManaged = onlyOneDSInQueries(queries); - return ( <> - {mode === 'edit' && ( + {mode === 'edit' && hasAlertEnabledDataSources && ( <> { const provenance = contactPoint.grafana_managed_receiver_configs?.find((integration) => { return integration.provenance; - })?.provenance || PROVENANCE_NONE; + })?.provenance || KnownProvenance.None; return { metadata: { // This isn't exactly accurate, but its the cleanest way to use the same data for AM config and K8S responses diff --git a/public/app/features/alerting/unified/mocks/server/handlers/k8s/templates.k8s.ts b/public/app/features/alerting/unified/mocks/server/handlers/k8s/templates.k8s.ts index f3baa318342..fff0d120629 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/k8s/templates.k8s.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/k8s/templates.k8s.ts @@ -3,8 +3,9 @@ import { HttpResponse, http } from 'msw'; import { getAlertmanagerConfig } from 'app/features/alerting/unified/mocks/server/entities/alertmanagers'; import { ALERTING_API_SERVER_BASE_URL, getK8sResponse } from 'app/features/alerting/unified/mocks/server/utils'; import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TemplateGroup } from 'app/features/alerting/unified/openapi/templatesApi.gen'; +import { KnownProvenance } from 'app/features/alerting/unified/types/knownProvenance'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; -import { PROVENANCE_ANNOTATION, PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; +import { PROVENANCE_ANNOTATION } from 'app/features/alerting/unified/utils/k8s/constants'; const config = getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME); @@ -14,7 +15,7 @@ const mappedTemplates = Object.entries( ).map(([title, template]) => ({ metadata: { name: titleToK8sResourceName(title), // K8s uses unique identifiers for resources - annotations: { [PROVENANCE_ANNOTATION]: config.template_file_provenances?.[title] || PROVENANCE_NONE }, + annotations: { [PROVENANCE_ANNOTATION]: config.template_file_provenances?.[title] || KnownProvenance.None }, }, spec: { title: title, diff --git a/public/app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s.ts b/public/app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s.ts index 84503c2ce13..77f6eef9f50 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s.ts @@ -4,7 +4,8 @@ import { base64UrlEncode } from '@grafana/alerting'; import { filterBySelector } from 'app/features/alerting/unified/mocks/server/handlers/k8s/utils'; import { ALERTING_API_SERVER_BASE_URL, getK8sResponse } from 'app/features/alerting/unified/mocks/server/utils'; import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval } from 'app/features/alerting/unified/openapi/timeIntervalsApi.gen'; -import { K8sAnnotations, PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; +import { KnownProvenance } from 'app/features/alerting/unified/types/knownProvenance'; +import { K8sAnnotations } from 'app/features/alerting/unified/utils/k8s/constants'; /** UID of a time interval that we expect to follow all happy paths within tests/mocks */ export const TIME_INTERVAL_UID_HAPPY_PATH = 'f4eae7a4895fa786'; @@ -21,7 +22,7 @@ const allTimeIntervals = getK8sResponse { ]), }); }); + + it('should not show rule type switch when no data sources have manageAlerts enabled', async () => { + // Setup data source with manageAlerts explicitly disabled + setupDataSources( + mockDataSource( + { + type: 'prometheus', + name: 'Prom-disabled', + uid: 'prometheus-disabled', + isDefault: true, + jsonData: { manageAlerts: false }, + }, + { alerting: true, module: 'core:plugin/prometheus' } + ) + ); + + renderRuleEditor(); + + // Wait for the form to load + await screen.findByRole('textbox', { name: 'name' }); + + // The rule type switch should NOT be visible + expect(screen.queryByText('Rule type')).not.toBeInTheDocument(); + expect(screen.queryByTestId('rule-type-radio-group')).not.toBeInTheDocument(); + }); + + it('should show rule type switch when data sources have manageAlerts enabled', async () => { + // Setup data source with manageAlerts enabled + setupDataSources( + mockDataSource( + { + type: 'prometheus', + name: 'Prom-enabled', + uid: 'prometheus-enabled', + isDefault: true, + jsonData: { manageAlerts: true }, + }, + { alerting: true, module: 'core:plugin/prometheus' } + ) + ); + + renderRuleEditor(); + + // Wait for the form to load + await screen.findByRole('textbox', { name: 'name' }); + + // The rule type section should be visible + expect(await screen.findByText('Rule type')).toBeInTheDocument(); + }); }); diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx index 7985791cfa2..1f33e3476ff 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx @@ -7,10 +7,11 @@ import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime'; import { AccessControlAction } from 'app/types/accessControl'; import { setupMswServer } from '../mockApi'; -import { grantUserPermissions, grantUserRole } from '../mocks'; +import { grantUserPermissions, grantUserRole, mockDataSource } from '../mocks'; import { setGrafanaRuleGroupExportResolver } from '../mocks/server/configure'; import { alertingFactory } from '../mocks/server/db'; import { RulesFilter } from '../search/rulesSearchParser'; +import { setupDataSources } from '../testSetup/datasources'; import RuleListPage, { RuleListActions } from './RuleList.v2'; import { loadDefaultSavedSearch } from './filter/useSavedSearches'; @@ -365,6 +366,51 @@ describe('RuleListActions', () => { expect(ui.exportDrawer.query()).toBeInTheDocument(); }); }); + + describe('Data source options visibility', () => { + it('should not show "New Data source recording rule" option when no data sources have manageAlerts enabled', async () => { + // Set up only data sources with manageAlerts explicitly set to false + // This replaces the default data sources that have manageAlerts defaulting to true + setupDataSources( + mockDataSource({ + name: 'Prometheus-disabled', + uid: 'prometheus-disabled', + type: 'prometheus', + jsonData: { manageAlerts: false }, + }) + ); + + grantUserPermissions([AccessControlAction.AlertingRuleExternalWrite]); + + const { user } = render(); + + await user.click(ui.moreButton.get()); + const menu = await ui.moreMenu.find(); + + expect(ui.menuOptions.newDataSourceRecordingRule.query(menu)).not.toBeInTheDocument(); + }); + + it('should show "New Data source recording rule" option when data sources have manageAlerts enabled', async () => { + // Set up data source with manageAlerts enabled + setupDataSources( + mockDataSource({ + name: 'Prometheus-enabled', + uid: 'prometheus-enabled', + type: 'prometheus', + jsonData: { manageAlerts: true }, + }) + ); + + grantUserPermissions([AccessControlAction.AlertingRuleExternalWrite]); + + const { user } = render(); + + await user.click(ui.moreButton.get()); + const menu = await ui.moreMenu.find(); + + expect(ui.menuOptions.newDataSourceRecordingRule.query(menu)).toBeInTheDocument(); + }); + }); }); describe('RuleListPage v2 - View switching', () => { diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx index 284bd6ad757..b90663200ce 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx @@ -13,6 +13,7 @@ import { useListViewMode } from '../components/rules/Filter/RulesViewModeSelecto import { AIAlertRuleButtonComponent } from '../enterprise-components/AI/AIGenAlertRuleButton/addAIAlertRuleButton'; import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities'; import { useRulesFilter } from '../hooks/useFilteredRules'; +import { getRulesDataSources } from '../utils/datasource'; import { FilterView } from './FilterView'; import { GroupedView } from './GroupedView'; @@ -41,8 +42,11 @@ export function RuleListActions() { const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule); const [exportRulesSupported, exportRulesAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules); + // Check if there are any data sources with manageAlerts enabled + const hasAlertEnabledDataSources = useMemo(() => getRulesDataSources().length > 0, []); + const canCreateGrafanaRules = createGrafanaRuleSupported && createGrafanaRuleAllowed; - const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed; + const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed && hasAlertEnabledDataSources; const canExportRules = exportRulesSupported && exportRulesAllowed; const canCreateRules = canCreateGrafanaRules || canCreateCloudRules; diff --git a/public/app/features/alerting/unified/rule-list/filter/SavedSearchItem.tsx b/public/app/features/alerting/unified/rule-list/filter/SavedSearchItem.tsx index 0fb92622b2d..eb4af39ac12 100644 --- a/public/app/features/alerting/unified/rule-list/filter/SavedSearchItem.tsx +++ b/public/app/features/alerting/unified/rule-list/filter/SavedSearchItem.tsx @@ -121,11 +121,10 @@ export function SavedSearchItem({ {/* Apply button (magnifying glass) */} { expect(frontendFilter.ruleMatches(regularRule)).toBe(true); expect(frontendFilter.ruleMatches(pluginRule)).toBe(true); }); + + it('should include searchFolder in backend filter when namespace is provided', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ namespace: 'my-folder' })); + + expect(backendFilter.searchFolder).toBe('my-folder'); + }); + + it('should skip namespace filtering on frontend when backend filtering is enabled', () => { + const group: PromRuleGroupDTO = { + name: 'Test Group', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + const { frontendFilter } = getGrafanaFilter(getFilter({ namespace: 'staging' })); + // Should return true because namespace filter is null (handled by backend) + expect(frontendFilter.groupMatches(group)).toBe(true); + }); }); describe('when alertingUIUseBackendFilters is disabled', () => { @@ -537,6 +556,12 @@ describe('grafana-managed rules', () => { expect(backendFilter.searchGroupName).toBeUndefined(); }); + it('should not include searchFolder in backend filter', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ namespace: 'my-folder' })); + + expect(backendFilter.searchFolder).toBeUndefined(); + }); + it('should perform groupName filtering on frontend', () => { const group: PromRuleGroupDTO = { name: 'CPU Usage Alerts', @@ -706,8 +731,8 @@ describe('grafana-managed rules', () => { expect(frontendFilter.groupMatches(group)).toBe(true); }); - it('should still apply always-frontend filters (namespace)', () => { - // Namespace filter should still work + it('should skip namespace filtering on frontend', () => { + // Namespace filter should be handled by backend const group: PromRuleGroupDTO = { name: 'Test Group', file: 'production/alerts', @@ -719,7 +744,7 @@ describe('grafana-managed rules', () => { expect(nsFilter.groupMatches(group)).toBe(true); const { frontendFilter: nsFilter2 } = getGrafanaFilter(getFilter({ namespace: 'staging' })); - expect(nsFilter2.groupMatches(group)).toBe(false); + expect(nsFilter2.groupMatches(group)).toBe(true); }); it('should skip dataSourceNames filtering on frontend (handled by backend)', () => { @@ -807,8 +832,8 @@ describe('grafana-managed rules', () => { expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(false); }); - it('should return true for client-side only filters', () => { - expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + it('should return false for namespace filter (handled by backend)', () => { + expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(false); }); it('should return false for plugins filter (handled by backend when feature toggle is enabled)', () => { @@ -862,8 +887,8 @@ describe('grafana-managed rules', () => { expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); - // Should return true for: always-frontend filters only (namespace) - expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + // Should return false for: namespace (handled by backend) + expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(false); // plugins is backend-handled when both feature toggles are enabled expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(false); diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts index e8c4cf3c44a..cca395a9cb2 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts @@ -96,6 +96,7 @@ export function getGrafanaFilter(filterState: Partial) { datasources: ruleFilterConfig.dataSourceNames ? undefined : datasourceUids, ruleMatchers: ruleMatchersBackendFilter, plugins: ruleFilterConfig.plugins ? undefined : normalizedFilterState.plugins, + searchFolder: groupFilterConfig.namespace ? undefined : normalizedFilterState.namespace, }; return { @@ -134,7 +135,7 @@ function buildGrafanaFilterConfigs() { }; const groupFilterConfig: GroupFilterConfig = { - namespace: namespaceFilter, + namespace: useBackendFilters ? null : namespaceFilter, groupName: useBackendFilters ? null : groupNameFilter, }; diff --git a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts index add1097fa0f..e2cb1247ac8 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts @@ -45,6 +45,7 @@ interface GrafanaPromApiFilter { contactPoint?: string; title?: string; searchGroupName?: string; + searchFolder?: string; type?: 'alerting' | 'recording'; dashboardUid?: string; } diff --git a/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts b/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts index 5d6b7c97782..648cfc18190 100644 --- a/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts +++ b/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts @@ -75,6 +75,7 @@ describe('paginationLimits', () => { { contactPoint: 'slack' }, { dataSourceNames: ['prometheus'] }, { labels: ['severity=critical'] }, + { namespace: 'production' }, ])( 'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p', (filterState) => { @@ -84,16 +85,6 @@ describe('paginationLimits', () => { expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); } ); - - it.each>([ - { namespace: 'production' }, - { ruleState: PromAlertingRuleState.Firing, namespace: 'production' }, - ])('should return large limits for both when frontend filters are used: %p', (filterState) => { - const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); - - expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); - expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); - }); }); describe('when alertingUIUseFullyCompatBackendFilters is enabled', () => { @@ -158,6 +149,7 @@ describe('paginationLimits', () => { { contactPoint: 'slack' }, { dataSourceNames: ['prometheus'] }, { labels: ['severity=critical'] }, + { namespace: 'production' }, ])( 'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p', (filterState) => { @@ -167,16 +159,6 @@ describe('paginationLimits', () => { expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); } ); - - it.each>([{ namespace: 'production' }])( - 'should return large limits for both when frontend filters are used: %p', - (filterState) => { - const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); - - expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); - expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); - } - ); }); }); }); diff --git a/public/app/features/alerting/unified/types/alerting.ts b/public/app/features/alerting/unified/types/alerting.ts index c6fe667982a..6436f132391 100644 --- a/public/app/features/alerting/unified/types/alerting.ts +++ b/public/app/features/alerting/unified/types/alerting.ts @@ -80,6 +80,20 @@ export type CloudNotifierType = | 'jira'; export type NotifierType = GrafanaNotifierType | CloudNotifierType; + +/** + * Represents a specific version of a notifier integration + * Used for integration versioning during Single Alert Manager migration + */ +export interface NotifierVersion { + version: string; + label: string; + description: string; + options: NotificationChannelOption[]; + /** Whether this version can be used to create new integrations */ + canCreate?: boolean; +} + export interface NotifierDTO { name: string; description: string; @@ -88,6 +102,23 @@ export interface NotifierDTO { options: NotificationChannelOption[]; info?: string; secure?: boolean; + /** + * Available versions for this notifier from the backend + * Each version contains version-specific options and metadata + */ + versions?: NotifierVersion[]; + /** + * The default version that the backend will use when creating new integrations. + * Returned by the backend from /api/alert-notifiers?version=2 + * + * - "v1" for most notifiers (modern Grafana version) + * - "v0mimir1" for legacy-only notifiers (e.g., WeChat) + * + * Note: Currently not used in the frontend. The backend handles version + * selection automatically. Could be used in the future to display + * version information or validate notifier capabilities. + */ + currentVersion?: string; } export interface NotificationChannelType { diff --git a/public/app/features/alerting/unified/types/knownProvenance.ts b/public/app/features/alerting/unified/types/knownProvenance.ts new file mode 100644 index 00000000000..4bf5fe4bb32 --- /dev/null +++ b/public/app/features/alerting/unified/types/knownProvenance.ts @@ -0,0 +1,6 @@ +export enum KnownProvenance { + None = 'none' /** Provenance value given for entities that were not provisioned */, + API = 'api', + File = 'file', + ConvertedPrometheus = 'converted_prometheus', +} diff --git a/public/app/features/alerting/unified/types/receiver-form.ts b/public/app/features/alerting/unified/types/receiver-form.ts index 09d87d06845..0e5c95f04ae 100644 --- a/public/app/features/alerting/unified/types/receiver-form.ts +++ b/public/app/features/alerting/unified/types/receiver-form.ts @@ -8,6 +8,7 @@ import { ControlledField } from '../hooks/useControlledFieldArray'; export interface ChannelValues { __id: string; // used to correlate form values to original DTOs type: string; + version?: string; // Integration version (e.g. "v0" for Mimir legacy, "v1" for Grafana) settings: Record; secureFields: Record; } diff --git a/public/app/features/alerting/unified/utils/k8s/constants.ts b/public/app/features/alerting/unified/utils/k8s/constants.ts index cf297261733..4fdc2628e10 100644 --- a/public/app/features/alerting/unified/utils/k8s/constants.ts +++ b/public/app/features/alerting/unified/utils/k8s/constants.ts @@ -4,9 +4,6 @@ * */ export const PROVENANCE_ANNOTATION = 'grafana.com/provenance'; -/** Value of {@link PROVENANCE_ANNOTATION} given for entities that were not provisioned */ -export const PROVENANCE_NONE = 'none'; - export enum K8sAnnotations { Provenance = 'grafana.com/provenance', diff --git a/public/app/features/alerting/unified/utils/k8s/utils.test.ts b/public/app/features/alerting/unified/utils/k8s/utils.test.ts index a04a1ea16ec..5b6214846e7 100644 --- a/public/app/features/alerting/unified/utils/k8s/utils.test.ts +++ b/public/app/features/alerting/unified/utils/k8s/utils.test.ts @@ -1,4 +1,6 @@ -import { encodeFieldSelector } from './utils'; +import { KnownProvenance } from '../../types/knownProvenance'; + +import { encodeFieldSelector, isProvisionedResource } from './utils'; describe('encodeFieldSelector', () => { it('should escape backslashes', () => { @@ -25,3 +27,29 @@ describe('encodeFieldSelector', () => { expect(encodeFieldSelector('foo=bar,bar=baz,qux\\foo')).toBe('foo\\=bar\\,bar\\=baz\\,qux\\\\foo'); }); }); + +describe('isProvisionedResource', () => { + it('should return true when provenance is API', () => { + expect(isProvisionedResource(KnownProvenance.API)).toBe(true); + }); + + it('should return true when provenance is File', () => { + expect(isProvisionedResource(KnownProvenance.File)).toBe(true); + }); + + it('should return true when provenance is ConvertedPrometheus', () => { + expect(isProvisionedResource(KnownProvenance.ConvertedPrometheus)).toBe(true); + }); + + it('should return false when provenance is none', () => { + expect(isProvisionedResource(KnownProvenance.None)).toBe(false); + }); + + it('should return false when provenance is undefined', () => { + expect(isProvisionedResource(undefined)).toBe(false); + }); + + it('should return true for any other non-empty string', () => { + expect(isProvisionedResource('custom-provenance')).toBe(true); + }); +}); diff --git a/public/app/features/alerting/unified/utils/k8s/utils.ts b/public/app/features/alerting/unified/utils/k8s/utils.ts index 48ec5685a69..015ba8f17a2 100644 --- a/public/app/features/alerting/unified/utils/k8s/utils.ts +++ b/public/app/features/alerting/unified/utils/k8s/utils.ts @@ -1,6 +1,8 @@ import { IoK8SApimachineryPkgApisMetaV1ObjectMeta } from 'app/features/alerting/unified/openapi/receiversApi.gen'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; -import { K8sAnnotations, PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; +import { K8sAnnotations } from 'app/features/alerting/unified/utils/k8s/constants'; + +import { KnownProvenance } from '../../types/knownProvenance'; /** * Should we call the kubernetes-style API for managing alertmanager entities? @@ -22,7 +24,7 @@ type EntityToCheck = { */ export const isK8sEntityProvisioned = (k8sEntity: EntityToCheck) => { const provenance = getAnnotation(k8sEntity, K8sAnnotations.Provenance); - return Boolean(provenance && provenance !== PROVENANCE_NONE); + return isProvisionedResource(provenance); }; export const ANNOTATION_PREFIX_ACCESS = 'grafana.com/access/'; @@ -59,3 +61,7 @@ export const stringifyFieldSelector = (fieldSelectors: FieldSelector[]): string .map(([key, value, operator = '=']) => `${key}${operator}${encodeFieldSelector(value)}`) .join(','); }; + +export function isProvisionedResource(provenance?: string): boolean { + return Boolean(provenance && provenance !== KnownProvenance.None); +} diff --git a/public/app/features/alerting/unified/utils/notifier-versions.test.ts b/public/app/features/alerting/unified/utils/notifier-versions.test.ts new file mode 100644 index 00000000000..d11ac74665d --- /dev/null +++ b/public/app/features/alerting/unified/utils/notifier-versions.test.ts @@ -0,0 +1,429 @@ +import { GrafanaManagedContactPoint } from 'app/plugins/datasource/alertmanager/types'; + +import { NotificationChannelOption, NotifierDTO, NotifierVersion } from '../types/alerting'; + +import { + canCreateNotifier, + getLegacyVersionLabel, + getOptionsForVersion, + hasLegacyIntegrations, + isLegacyVersion, +} from './notifier-versions'; + +// Helper to create a minimal NotifierDTO for testing +function createNotifier(overrides: Partial = {}): NotifierDTO { + return { + name: 'Test Notifier', + description: 'Test description', + type: 'webhook', + heading: 'Test heading', + options: [ + { + element: 'input', + inputType: 'text', + label: 'Default Option', + description: 'Default option description', + placeholder: '', + propertyName: 'defaultOption', + required: true, + secure: false, + showWhen: { field: '', is: '' }, + validationRule: '', + dependsOn: '', + }, + ], + ...overrides, + }; +} + +// Helper to create a NotifierVersion for testing +function createVersion(overrides: Partial = {}): NotifierVersion { + return { + version: 'v1', + label: 'Test Version', + description: 'Test version description', + options: [ + { + element: 'input', + inputType: 'text', + label: 'Version Option', + description: 'Version option description', + placeholder: '', + propertyName: 'versionOption', + required: true, + secure: false, + showWhen: { field: '', is: '' }, + validationRule: '', + dependsOn: '', + }, + ], + ...overrides, + }; +} + +describe('notifier-versions utilities', () => { + describe('canCreateNotifier', () => { + it('should return true if notifier has no versions array', () => { + const notifier = createNotifier({ versions: undefined }); + expect(canCreateNotifier(notifier)).toBe(true); + }); + + it('should return true if notifier has empty versions array', () => { + const notifier = createNotifier({ versions: [] }); + expect(canCreateNotifier(notifier)).toBe(true); + }); + + it('should return true if at least one version has canCreate: true', () => { + const notifier = createNotifier({ + versions: [ + createVersion({ version: 'v0mimir1', canCreate: false }), + createVersion({ version: 'v1', canCreate: true }), + ], + }); + expect(canCreateNotifier(notifier)).toBe(true); + }); + + it('should return true if at least one version has canCreate: undefined (defaults to true)', () => { + const notifier = createNotifier({ + versions: [ + createVersion({ version: 'v0mimir1', canCreate: false }), + createVersion({ version: 'v1', canCreate: undefined }), + ], + }); + expect(canCreateNotifier(notifier)).toBe(true); + }); + + it('should return false if all versions have canCreate: false', () => { + const notifier = createNotifier({ + versions: [ + createVersion({ version: 'v0mimir1', canCreate: false }), + createVersion({ version: 'v0mimir2', canCreate: false }), + ], + }); + expect(canCreateNotifier(notifier)).toBe(false); + }); + + it('should return false for notifiers like WeChat that only have legacy versions', () => { + const wechatNotifier = createNotifier({ + name: 'WeChat', + type: 'wechat', + versions: [createVersion({ version: 'v0mimir1', canCreate: false })], + }); + expect(canCreateNotifier(wechatNotifier)).toBe(false); + }); + }); + + describe('isLegacyVersion', () => { + it('should return false if no version is specified', () => { + const notifier = createNotifier({ + versions: [createVersion({ version: 'v0mimir1', canCreate: false })], + }); + expect(isLegacyVersion(notifier, undefined)).toBe(false); + expect(isLegacyVersion(notifier, '')).toBe(false); + }); + + it('should return false if notifier has no versions array', () => { + const notifier = createNotifier({ versions: undefined }); + expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(false); + }); + + it('should return false if notifier has empty versions array', () => { + const notifier = createNotifier({ versions: [] }); + expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(false); + }); + + it('should return false if version is not found in versions array', () => { + const notifier = createNotifier({ + versions: [createVersion({ version: 'v1', canCreate: true })], + }); + expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(false); + }); + + it('should return false if version has canCreate: true', () => { + const notifier = createNotifier({ + versions: [createVersion({ version: 'v1', canCreate: true })], + }); + expect(isLegacyVersion(notifier, 'v1')).toBe(false); + }); + + it('should return false if version has canCreate: undefined', () => { + const notifier = createNotifier({ + versions: [createVersion({ version: 'v1', canCreate: undefined })], + }); + expect(isLegacyVersion(notifier, 'v1')).toBe(false); + }); + + it('should return true if version has canCreate: false', () => { + const notifier = createNotifier({ + versions: [ + createVersion({ version: 'v0mimir1', canCreate: false }), + createVersion({ version: 'v1', canCreate: true }), + ], + }); + expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(true); + }); + + it('should correctly identify legacy versions in a mixed notifier', () => { + const notifier = createNotifier({ + versions: [ + createVersion({ version: 'v0mimir1', canCreate: false }), + createVersion({ version: 'v0mimir2', canCreate: false }), + createVersion({ version: 'v1', canCreate: true }), + ], + }); + expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(true); + expect(isLegacyVersion(notifier, 'v0mimir2')).toBe(true); + expect(isLegacyVersion(notifier, 'v1')).toBe(false); + }); + }); + + describe('getOptionsForVersion', () => { + const defaultOptions: NotificationChannelOption[] = [ + { + element: 'input', + inputType: 'text', + label: 'Default URL', + description: 'Default URL description', + placeholder: '', + propertyName: 'url', + required: true, + secure: false, + showWhen: { field: '', is: '' }, + validationRule: '', + dependsOn: '', + }, + ]; + + const v0Options: NotificationChannelOption[] = [ + { + element: 'input', + inputType: 'text', + label: 'Legacy URL', + description: 'Legacy URL description', + placeholder: '', + propertyName: 'legacyUrl', + required: true, + secure: false, + showWhen: { field: '', is: '' }, + validationRule: '', + dependsOn: '', + }, + ]; + + const v1Options: NotificationChannelOption[] = [ + { + element: 'input', + inputType: 'text', + label: 'Modern URL', + description: 'Modern URL description', + placeholder: '', + propertyName: 'modernUrl', + required: true, + secure: false, + showWhen: { field: '', is: '' }, + validationRule: '', + dependsOn: '', + }, + ]; + + it('should return options from default creatable version if no version is specified', () => { + const notifier = createNotifier({ + options: defaultOptions, + versions: [createVersion({ version: 'v1', options: v1Options, canCreate: true })], + }); + // When no version specified, should use options from the default creatable version + expect(getOptionsForVersion(notifier, undefined)).toBe(v1Options); + }); + + it('should return default options if no version is specified and empty string is passed', () => { + const notifier = createNotifier({ + options: defaultOptions, + versions: [createVersion({ version: 'v1', options: v1Options, canCreate: true })], + }); + // Empty string is still a falsy version, so should use default creatable version + expect(getOptionsForVersion(notifier, '')).toBe(v1Options); + }); + + it('should return default options if notifier has no versions array', () => { + const notifier = createNotifier({ + options: defaultOptions, + versions: undefined, + }); + expect(getOptionsForVersion(notifier, 'v1')).toBe(defaultOptions); + }); + + it('should return default options if notifier has empty versions array', () => { + const notifier = createNotifier({ + options: defaultOptions, + versions: [], + }); + expect(getOptionsForVersion(notifier, 'v1')).toBe(defaultOptions); + }); + + it('should return default options if version is not found', () => { + const notifier = createNotifier({ + options: defaultOptions, + versions: [createVersion({ version: 'v1', options: v1Options })], + }); + expect(getOptionsForVersion(notifier, 'v0mimir1')).toBe(defaultOptions); + }); + + it('should return version-specific options when version is found', () => { + const notifier = createNotifier({ + options: defaultOptions, + versions: [ + createVersion({ version: 'v0mimir1', options: v0Options }), + createVersion({ version: 'v1', options: v1Options }), + ], + }); + expect(getOptionsForVersion(notifier, 'v0mimir1')).toBe(v0Options); + expect(getOptionsForVersion(notifier, 'v1')).toBe(v1Options); + }); + + it('should return default options if version found but has no options', () => { + const notifier = createNotifier({ + options: defaultOptions, + versions: [ + { + version: 'v1', + label: 'V1', + description: 'V1 description', + options: undefined as unknown as NotificationChannelOption[], + }, + ], + }); + expect(getOptionsForVersion(notifier, 'v1')).toBe(defaultOptions); + }); + }); + + describe('hasLegacyIntegrations', () => { + // Helper to create a minimal contact point for testing + function createContactPoint(overrides: Partial = {}): GrafanaManagedContactPoint { + return { + name: 'Test Contact Point', + ...overrides, + }; + } + + // Create notifiers with version info for testing + const notifiersWithVersions: NotifierDTO[] = [ + createNotifier({ + type: 'slack', + versions: [ + createVersion({ version: 'v0mimir1', canCreate: false }), + createVersion({ version: 'v1', canCreate: true }), + ], + }), + createNotifier({ + type: 'webhook', + versions: [ + createVersion({ version: 'v0mimir1', canCreate: false }), + createVersion({ version: 'v0mimir2', canCreate: false }), + createVersion({ version: 'v1', canCreate: true }), + ], + }), + ]; + + it('should return false if contact point is undefined', () => { + expect(hasLegacyIntegrations(undefined, notifiersWithVersions)).toBe(false); + }); + + it('should return false if notifiers is undefined', () => { + const contactPoint = createContactPoint({ + grafana_managed_receiver_configs: [{ type: 'slack', settings: {}, version: 'v0mimir1' }], + }); + expect(hasLegacyIntegrations(contactPoint, undefined)).toBe(false); + }); + + it('should return false if contact point has no integrations', () => { + const contactPoint = createContactPoint({ grafana_managed_receiver_configs: undefined }); + expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false); + }); + + it('should return false if contact point has empty integrations array', () => { + const contactPoint = createContactPoint({ grafana_managed_receiver_configs: [] }); + expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false); + }); + + it('should return false if all integrations have v1 version (canCreate: true)', () => { + const contactPoint = createContactPoint({ + grafana_managed_receiver_configs: [ + { type: 'slack', settings: {}, version: 'v1' }, + { type: 'webhook', settings: {}, version: 'v1' }, + ], + }); + expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false); + }); + + it('should return false if all integrations have no version', () => { + const contactPoint = createContactPoint({ + grafana_managed_receiver_configs: [ + { type: 'slack', settings: {} }, + { type: 'webhook', settings: {} }, + ], + }); + expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false); + }); + + it('should return true if any integration has a legacy version (canCreate: false)', () => { + const contactPoint = createContactPoint({ + grafana_managed_receiver_configs: [ + { type: 'slack', settings: {}, version: 'v0mimir1' }, + { type: 'webhook', settings: {}, version: 'v1' }, + ], + }); + expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(true); + }); + + it('should return true if all integrations have legacy versions', () => { + const contactPoint = createContactPoint({ + grafana_managed_receiver_configs: [ + { type: 'slack', settings: {}, version: 'v0mimir1' }, + { type: 'webhook', settings: {}, version: 'v0mimir2' }, + ], + }); + expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(true); + }); + + it('should return false if notifier type is not found in notifiers array', () => { + const contactPoint = createContactPoint({ + grafana_managed_receiver_configs: [{ type: 'unknown', settings: {}, version: 'v0mimir1' }], + }); + expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false); + }); + }); + + describe('getLegacyVersionLabel', () => { + it('should return "Legacy" for undefined version', () => { + expect(getLegacyVersionLabel(undefined)).toBe('Legacy'); + }); + + it('should return "Legacy" for empty string version', () => { + expect(getLegacyVersionLabel('')).toBe('Legacy'); + }); + + it('should return "Legacy" for v0mimir1', () => { + expect(getLegacyVersionLabel('v0mimir1')).toBe('Legacy'); + }); + + it('should return "Legacy v2" for v0mimir2', () => { + expect(getLegacyVersionLabel('v0mimir2')).toBe('Legacy v2'); + }); + + it('should return "Legacy v3" for v0mimir3', () => { + expect(getLegacyVersionLabel('v0mimir3')).toBe('Legacy v3'); + }); + + it('should return "Legacy" for v1 (trailing 1)', () => { + expect(getLegacyVersionLabel('v1')).toBe('Legacy'); + }); + + it('should return "Legacy v2" for v2 (trailing 2)', () => { + expect(getLegacyVersionLabel('v2')).toBe('Legacy v2'); + }); + + it('should return "Legacy" for version strings without trailing number', () => { + expect(getLegacyVersionLabel('legacy')).toBe('Legacy'); + }); + }); +}); diff --git a/public/app/features/alerting/unified/utils/notifier-versions.ts b/public/app/features/alerting/unified/utils/notifier-versions.ts new file mode 100644 index 00000000000..b4e3b7902b1 --- /dev/null +++ b/public/app/features/alerting/unified/utils/notifier-versions.ts @@ -0,0 +1,126 @@ +/** + * Utilities for integration versioning + * + * These utilities help get version-specific options from the backend response + * (via /api/alert-notifiers?version=2) + */ + +import { GrafanaManagedContactPoint } from 'app/plugins/datasource/alertmanager/types'; + +import { NotificationChannelOption, NotifierDTO } from '../types/alerting'; + +/** + * Checks if a notifier can be used to create new integrations. + * A notifier can be created if it has at least one version with canCreate: true, + * or if it has no versions array (legacy behavior). + * + * @param notifier - The notifier DTO to check + * @returns True if the notifier can be used to create new integrations + */ +export function canCreateNotifier(notifier: NotifierDTO): boolean { + // If no versions array, assume it can be created (legacy behavior) + if (!notifier.versions || notifier.versions.length === 0) { + return true; + } + + // Check if any version has canCreate: true (or undefined, which defaults to true) + return notifier.versions.some((v) => v.canCreate !== false); +} + +/** + * Checks if a specific version is legacy (cannot be created). + * A version is legacy if it has canCreate: false in the notifier's versions array. + * + * @param notifier - The notifier DTO containing versions array + * @param version - The version string to check (e.g., 'v0mimir1', 'v1') + * @returns True if the version is legacy (canCreate: false) + */ +export function isLegacyVersion(notifier: NotifierDTO, version?: string): boolean { + // If no version specified or no versions array, it's not legacy + if (!version || !notifier.versions || notifier.versions.length === 0) { + return false; + } + + // Find the matching version and check its canCreate property + const versionData = notifier.versions.find((v) => v.version === version); + + // A version is legacy if canCreate is explicitly false + return versionData?.canCreate === false; +} + +/** + * Gets the options for a specific version of a notifier. + * Used to display the correct form fields based on integration version. + * + * @param notifier - The notifier DTO containing versions array + * @param version - The version to get options for (e.g., 'v0', 'v1') + * @returns The options for the specified version, or default options if version not found + */ +export function getOptionsForVersion(notifier: NotifierDTO, version?: string): NotificationChannelOption[] { + // If no versions array, use default options + if (!notifier.versions || notifier.versions.length === 0) { + return notifier.options; + } + + // If version is specified, find the matching version + if (version) { + const versionData = notifier.versions.find((v) => v.version === version); + // Return version-specific options if found, otherwise fall back to default + return versionData?.options ?? notifier.options; + } + + // If no version specified, find the default creatable version (canCreate !== false) + const defaultVersion = notifier.versions.find((v) => v.canCreate !== false); + return defaultVersion?.options ?? notifier.options; +} + +/** + * Checks if a contact point has any legacy (imported) integrations. + * A contact point has legacy integrations if any of its integrations uses a version + * with canCreate: false in the corresponding notifier's versions array. + * + * @param contactPoint - The contact point to check + * @param notifiers - Array of notifier DTOs to look up version info + * @returns True if the contact point has at least one legacy/imported integration + */ +export function hasLegacyIntegrations(contactPoint?: GrafanaManagedContactPoint, notifiers?: NotifierDTO[]): boolean { + if (!contactPoint?.grafana_managed_receiver_configs || !notifiers) { + return false; + } + + return contactPoint.grafana_managed_receiver_configs.some((config) => { + const notifier = notifiers.find((n) => n.type === config.type); + return notifier ? isLegacyVersion(notifier, config.version) : false; + }); +} + +/** + * Gets a user-friendly label for a legacy version. + * Extracts the version number from the version string and formats it as: + * - "Legacy" for version 1 (e.g., v0mimir1) + * - "Legacy v2" for version 2 (e.g., v0mimir2) + * - etc. + * + * Precondition: This function assumes the version is already known to be legacy + * (i.e., canCreate: false). Use isLegacyVersion() to check before calling this. + * + * @param version - The version string (e.g., 'v0mimir1', 'v0mimir2') + * @returns A user-friendly label like "Legacy" or "Legacy v2" + */ +export function getLegacyVersionLabel(version?: string): string { + if (!version) { + return 'Legacy'; + } + + // Extract trailing number from version string (e.g., v0mimir1 → 1, v0mimir2 → 2) + const match = version.match(/(\d+)$/); + if (match) { + const num = parseInt(match[1], 10); + if (num === 1) { + return 'Legacy'; + } + return `Legacy v${num}`; + } + + return 'Legacy'; +} diff --git a/public/app/features/alerting/unified/utils/receiver-form.ts b/public/app/features/alerting/unified/utils/receiver-form.ts index bea5503ad85..31927a1080a 100644 --- a/public/app/features/alerting/unified/utils/receiver-form.ts +++ b/public/app/features/alerting/unified/utils/receiver-form.ts @@ -185,6 +185,7 @@ function grafanaChannelConfigToFormChannelValues( const values: GrafanaChannelValues = { __id: id, type: channel.type as NotifierType, + version: channel.version, provenance: channel.provenance, settings: { ...channel.settings }, secureFields: { ...channel.secureFields }, @@ -239,6 +240,7 @@ export function formChannelValuesToGrafanaChannelConfig( }), secureFields: secureFieldsFromValues, type: values.type, + version: values.version ?? existing?.version, name, disableResolveMessage: values.disableResolveMessage ?? existing?.disableResolveMessage ?? defaults.disableResolveMessage, diff --git a/public/app/features/annotations/standardAnnotationSupport.ts b/public/app/features/annotations/standardAnnotationSupport.ts index 90d28715c7f..cd258e5b0ee 100644 --- a/public/app/features/annotations/standardAnnotationSupport.ts +++ b/public/app/features/annotations/standardAnnotationSupport.ts @@ -18,7 +18,7 @@ import { standardTransformers, } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; export const standardAnnotationSupport: AnnotationSupport = { /** diff --git a/public/app/features/apiserver/client.test.ts b/public/app/features/apiserver/client.test.ts index 01787b4a18d..02f6463b8f0 100644 --- a/public/app/features/apiserver/client.test.ts +++ b/public/app/features/apiserver/client.test.ts @@ -28,18 +28,17 @@ describe('DatasourceAPIVersions', () => { it('get', async () => { const getMock = jest.fn().mockResolvedValue({ groups: [ - { name: 'testdata.datasource.grafana.app', preferredVersion: { version: 'v1' } }, + { name: 'grafana-testdata-datasource.datasource.grafana.app', preferredVersion: { version: 'v1' } }, { name: 'prometheus.datasource.grafana.app', preferredVersion: { version: 'v2' } }, { name: 'myorg-myplugin.datasource.grafana.app', preferredVersion: { version: 'v3' } }, ], }); getBackendSrv().get = getMock; const apiVersions = new DatasourceAPIVersions(); - expect(await apiVersions.get('testdata')).toBe('v1'); expect(await apiVersions.get('grafana-testdata-datasource')).toBe('v1'); expect(await apiVersions.get('prometheus')).toBe('v2'); expect(await apiVersions.get('graphite')).toBeUndefined(); - expect(await apiVersions.get('myorg-myplugin-datasource')).toBe('v3'); + expect(await apiVersions.get('myorg-myplugin')).toBe('v3'); expect(getMock).toHaveBeenCalledTimes(1); expect(getMock).toHaveBeenCalledWith('/apis'); }); diff --git a/public/app/features/apiserver/client.ts b/public/app/features/apiserver/client.ts index 2907fc9e80b..7966d0fa56b 100644 --- a/public/app/features/apiserver/client.ts +++ b/public/app/features/apiserver/client.ts @@ -162,17 +162,6 @@ export class DatasourceAPIVersions { if (group.name.includes('datasource.grafana.app')) { const id = group.name.split('.')[0]; apiVersions[id] = group.preferredVersion.version; - // workaround for plugins that don't append '-datasource' for the group name - // e.g. org-plugin-datasource uses org-plugin.datasource.grafana.app - if (!id.endsWith('-datasource')) { - if (!id.includes('-')) { - // workaroud for Grafana plugins that don't include the org either - // e.g. testdata uses testdata.datasource.grafana.app - apiVersions[`grafana-${id}-datasource`] = group.preferredVersion.version; - } else { - apiVersions[`${id}-datasource`] = group.preferredVersion.version; - } - } } }); this.apiVersions = apiVersions; diff --git a/public/app/features/auth-config/AuthProvidersListPage.tsx b/public/app/features/auth-config/AuthProvidersListPage.tsx index af05c498ccf..413d78c75a4 100644 --- a/public/app/features/auth-config/AuthProvidersListPage.tsx +++ b/public/app/features/auth-config/AuthProvidersListPage.tsx @@ -3,10 +3,9 @@ import { connect, ConnectedProps } from 'react-redux'; import { GrafanaEdition } from '@grafana/data/internal'; import { Trans } from '@grafana/i18n'; -import { reportInteraction } from '@grafana/runtime'; +import { config, reportInteraction } from '@grafana/runtime'; import { Grid, TextLink, ToolbarButton } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; -import { config } from 'app/core/config'; import { StoreState } from 'app/types/store'; import { isOpenSourceBuildOrUnlicenced } from '../admin/EnterpriseAuthFeaturesCard'; diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index e177fcf9fa2..1a6b8c65011 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -1,11 +1,12 @@ import { css } from '@emotion/css'; -import { memo, useEffect, useMemo } from 'react'; +import { memo, useEffect, useMemo, useRef } from 'react'; import { useLocation, useParams } from 'react-router-dom-v5-compat'; import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans } from '@grafana/i18n'; import { config, reportInteraction } from '@grafana/runtime'; +import { evaluateBooleanFlag } from '@grafana/runtime/internal'; import { LinkButton, FilterInput, useStyles2, Text, Stack } from '@grafana/ui'; import { useGetFolderQueryFacade, useUpdateFolder } from 'app/api/clients/folder/v1beta1/hooks'; import { Page } from 'app/core/components/Page/Page'; @@ -44,6 +45,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record new URLSearchParams(location.search), [location.search]); const { isReadOnlyRepo, repoType } = useGetResourceRepositoryView({ folderName: folderUID }); + const isRecentlyViewedEnabled = !folderUID && evaluateBooleanFlag('recentlyViewedDashboards', false); useEffect(() => { stateManager.initStateFromUrl(folderUID); @@ -73,6 +75,23 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record { + if (!isRecentlyViewedEnabled || hasEmittedExposureEvent.current) { + return; + } + + hasEmittedExposureEvent.current = true; + const isExperimentTreatment = evaluateBooleanFlag('experimentRecentlyViewedDashboards', false); + + reportInteraction('dashboards_browse_list_viewed', { + experiment_dashboard_list_recently_viewed: isExperimentTreatment ? 'treatment' : 'control', + has_recently_viewed_component: isExperimentTreatment, + }); + }, [isRecentlyViewedEnabled]); + const { data: folderDTO } = useGetFolderQueryFacade(folderUID); const [saveFolder] = useUpdateFolder(); const navModel = useMemo(() => { @@ -179,8 +198,8 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record - {/* only show recently viewed dashboards when in root */} - {!folderUID && } + {/* only show recently viewed dashboards when in root and flag is enabled */} + {isRecentlyViewedEnabled && }
        { - if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) { - return []; - } return getRecentlyViewedDashboards(MAX_RECENT); }, []); const { foldersByUid } = useDashboardLocationInfo(recentDashboards.length > 0); const handleClearHistory = () => { + reportInteraction('grafana_recently_viewed_dashboards_clear_history'); store.set(recentDashboardsKey, JSON.stringify([])); retry(); }; - if (!evaluateBooleanFlag('recentlyViewedDashboards', false) || recentDashboards.length === 0) { + const handleSectionToggle = () => { + reportInteraction('grafana_recently_viewed_dashboards_toggle_section', { + expanded: !isOpen, + }); + setIsOpen(!isOpen); + }; + + if (recentDashboards.length === 0) { return null; } @@ -48,7 +53,7 @@ export function RecentlyViewedDashboards() { headerDataTestId="browseDashboardsRecentlyViewedTitle" label={ - setIsOpen(!isOpen)}> + Recently viewed
        {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 ad217e45337..4b58709934f 100644 --- a/public/app/features/canvas/runtime/scene.tsx +++ b/public/app/features/canvas/runtime/scene.tsx @@ -6,7 +6,7 @@ import { BehaviorSubject, ReplaySubject, Subject, Subscription } from 'rxjs'; import Selecto from 'selecto'; import { AppEvents, PanelData, OneClickMode, ActionType } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; +import { config, locationService } from '@grafana/runtime'; import { ColorDimensionConfig, ResourceDimensionConfig, @@ -17,7 +17,6 @@ import { DirectionDimensionConfig, } from '@grafana/schema'; import { Portal } from '@grafana/ui'; -import { config } from 'app/core/config'; import { DimensionContext } from 'app/features/dimensions/context'; import { getColorDimensionFromData, diff --git a/public/app/features/canvas/runtime/sceneAbleManagement.ts b/public/app/features/canvas/runtime/sceneAbleManagement.ts index 9af3e1a74e0..e8e3cb7f3ac 100644 --- a/public/app/features/canvas/runtime/sceneAbleManagement.ts +++ b/public/app/features/canvas/runtime/sceneAbleManagement.ts @@ -2,7 +2,7 @@ import InfiniteViewer from 'infinite-viewer'; import Moveable from 'moveable'; import Selecto from 'selecto'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import { CONNECTION_ANCHOR_DIV_ID } from 'app/plugins/panel/canvas/components/connections/ConnectionAnchors'; import { CONNECTION_VERTEX_ID, diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx index 64f0c774ea8..1f5a25013be 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx @@ -18,6 +18,7 @@ import { NewObjectAddedToCanvasEvent, ObjectRemovedFromCanvasEvent, ObjectsReorderedOnCanvasEvent, + RepeatsUpdatedEvent, } from './shared'; export interface DashboardEditPaneState extends SceneObjectState { @@ -87,6 +88,12 @@ export class DashboardEditPane extends SceneObjectBase { }) ); + this._subs.add( + dashboard.subscribeToEvent(RepeatsUpdatedEvent, () => { + this.forceRender(); + }) + ); + if (this.panelEditAction) { this.performPanelEditAction(this.panelEditAction); this.panelEditAction = undefined; diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx index d368de82c65..616f7c715f1 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx @@ -57,12 +57,10 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index } const instanceName = elementInfo.instanceName === '' ? noTitleText : elementInfo.instanceName; const outlineRename = useOutlineRename(editableElement, isEditing); const isContainer = editableElement.getOutlineChildren ? true : false; - const visibleChildren = useMemo(() => { - const children = editableElement.getOutlineChildren?.(isEditing) ?? []; - return isEditing - ? children - : children.filter((child) => !getEditableElementFor(child)?.getEditableElementInfo().isHidden); - }, [editableElement, isEditing]); + const outlineChildren = editableElement.getOutlineChildren?.(isEditing) ?? []; + const visibleChildren = isEditing + ? outlineChildren + : outlineChildren.filter((child) => !getEditableElementFor(child)?.getEditableElementInfo().isHidden); const onNodeClicked = (e: React.MouseEvent) => { e.stopPropagation(); @@ -258,7 +256,6 @@ function getStyles(theme: GrafanaTheme2) { }), nodeButtonClone: css({ color: theme.colors.text.secondary, - cursor: 'not-allowed', }), outlineInput: css({ border: `1px solid ${theme.components.input.borderColor}`, diff --git a/public/app/features/dashboard-scene/edit-pane/shared.ts b/public/app/features/dashboard-scene/edit-pane/shared.ts index 2a60fb33546..e17c314e1c7 100644 --- a/public/app/features/dashboard-scene/edit-pane/shared.ts +++ b/public/app/features/dashboard-scene/edit-pane/shared.ts @@ -84,6 +84,10 @@ export class ConditionalRenderingChangedEvent extends BusEventWithPayload { + static type = 'repeats-updated'; +} + export interface DashboardEditActionEventPayload { removedObject?: SceneObject; addedObject?: SceneObject; diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 097c0d8d26c..515087010fb 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -40,7 +40,11 @@ import { PanelEditor } from '../panel-edit/PanelEditor'; import { DashboardScene } from '../scene/DashboardScene'; import { buildNewDashboardSaveModel, buildNewDashboardSaveModelV2 } from '../serialization/buildNewDashboardSaveModel'; import { transformSaveModelSchemaV2ToScene } from '../serialization/transformSaveModelSchemaV2ToScene'; -import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; +import { + createV2RowsLayout, + SceneCreationOptions, + transformSaveModelToScene, +} from '../serialization/transformSaveModelToScene'; import { restoreDashboardStateFromLocalStorage } from '../utils/dashboardSessionState'; import { processQueryParamsForDashboardLoad, updateNavModel } from './utils'; @@ -106,6 +110,34 @@ interface DashboardScenePageStateManagerLike { useState: () => DashboardScenePageState; } +/** + * Creates scene creation options with appropriate layout creator + * based on feature flags and dashboard type. + */ +export function getSceneCreationOptions( + loadOptions?: LoadDashboardOptions, + meta?: { isSnapshot?: boolean } +): SceneCreationOptions | undefined { + const isReport = loadOptions?.route === DashboardRoutes.Report; + const isTemplate = loadOptions?.route === DashboardRoutes.Template; + const isSnapshot = meta?.isSnapshot ?? false; + + // Don't use v2 layout for reports or snapshots + if (isReport || isSnapshot || isTemplate) { + return undefined; + } + + // Use v2 layout creator when v2 API is enabled + if (shouldForceV2API()) { + return { + createLayout: createV2RowsLayout, + targetVersion: 'v2', + }; + } + + return undefined; +} + abstract class DashboardScenePageStateManagerBase extends StateManagerBase implements DashboardScenePageStateManagerLike @@ -155,7 +187,7 @@ abstract class DashboardScenePageStateManagerBase private async loadHomeDashboard(): Promise { const rsp = await this.fetchHomeDashboard(); if (rsp) { - return transformSaveModelToScene(rsp); + return transformSaveModelToScene(rsp, undefined, getSceneCreationOptions()); } return null; @@ -441,7 +473,8 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag } if (rsp?.dashboard) { - const scene = transformSaveModelToScene(rsp, options); + const sceneCreationOptions = getSceneCreationOptions(options, rsp.meta); + const scene = transformSaveModelToScene(rsp, options, sceneCreationOptions); // Special handling for Template route - set up edit mode and dirty state if ( @@ -474,7 +507,8 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag throw new DashboardVersionError('v2beta1', 'Using legacy snapshot API to get a V2 dashboard'); } - const scene = transformSaveModelToScene(rsp); + // Snapshots should use default v1 layout + const scene = transformSaveModelToScene(rsp, undefined, getSceneCreationOptions(undefined, { isSnapshot: true })); return scene; } @@ -755,7 +789,8 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag return; } - const scene = transformSaveModelToScene(rsp); + const sceneCreationOptions = getSceneCreationOptions(undefined, rsp.meta); + const scene = transformSaveModelToScene(rsp, undefined, sceneCreationOptions); // we need to call and restore dashboard state on every reload that pulls a new dashboard version if (config.featureToggles.preserveDashboardStateWhenNavigating && Boolean(uid)) { diff --git a/public/app/features/dashboard-scene/pages/utils.ts b/public/app/features/dashboard-scene/pages/utils.ts index 6e786123a87..1c6e36e09f8 100644 --- a/public/app/features/dashboard-scene/pages/utils.ts +++ b/public/app/features/dashboard-scene/pages/utils.ts @@ -1,7 +1,7 @@ import { UrlQueryMap, getTimeZone, getDefaultTimeRange, dateMath } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { getFolderByUidFacade } from 'app/api/clients/folder/v1beta1/hooks'; -import { updateNavIndex } from 'app/core/actions'; +import { updateNavIndex } from 'app/core/reducers/navModel'; import { buildNavModel } from 'app/features/folders/state/navModel'; import { store } from 'app/store/store'; diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx index b863bf2fc78..290e298c88c 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx @@ -71,6 +71,7 @@ export class PanelOptionsPane extends SceneObjectBase { reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.SELECT_PANEL_PLUGIN, plugin_id: pluginId, + from_suggestions: options.fromSuggestions ?? false, }); // clear custom options @@ -236,6 +237,7 @@ function PanelOptionsPaneComponent({ model }: SceneComponentProps )} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx index acc4ff208e6..71c82bcf6ab 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx @@ -26,6 +26,7 @@ export interface Props { editPreview: VizPanel; onChange: (options: VizTypeChangeDetails, panel?: VizPanel) => void; onClose: () => void; + isNewPanel?: boolean; } const getTabs = (): Array<{ label: string; value: VisualizationSelectPaneTab }> => { @@ -42,7 +43,7 @@ const getTabs = (): Array<{ label: string; value: VisualizationSelectPaneTab }> : [allVisualizationsTab, suggestionsTab]; }; -export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose, showBackButton }: Props) { +export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose, showBackButton, isNewPanel }: Props) { const styles = useStyles2(getStyles); const panelModel = useMemo(() => new PanelModelCompatibilityWrapper(panel), [panel]); const filterId = useId(); @@ -83,6 +84,16 @@ export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose [setListMode] ); + const handleBackButtonClick = useCallback(() => { + reportInteraction(INTERACTION_EVENT_NAME, { + item: INTERACTION_ITEM.BACK_BUTTON, + tab: VisualizationSelectPaneTab[listMode], + creator_team: 'grafana_plugins_catalog', + schema_version: '1.0.0', + }); + onClose(); + }, [listMode, onClose]); + return (
        @@ -114,7 +125,7 @@ export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose variant="secondary" icon="arrow-left" data-testid={selectors.components.PanelEditor.toggleVizPicker} - onClick={onClose} + onClick={handleBackButtonClick} > Back @@ -136,6 +147,7 @@ export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose editPreview={editPreview} data={data} searchQuery={searchQuery} + isNewPanel={isNewPanel} /> )} {listMode === VisualizationSelectPaneTab.Visualizations && ( diff --git a/public/app/features/dashboard-scene/panel-edit/interaction.ts b/public/app/features/dashboard-scene/panel-edit/interaction.ts index 4ba515b5223..da147a60807 100644 --- a/public/app/features/dashboard-scene/panel-edit/interaction.ts +++ b/public/app/features/dashboard-scene/panel-edit/interaction.ts @@ -4,4 +4,5 @@ export const INTERACTION_ITEM = { SELECT_PANEL_PLUGIN: 'select_panel_plugin', CHANGE_TAB: 'change_tab', // for ref - PanelVizTypePicker SEARCH: 'search', // for ref - PanelVizTypePicker + BACK_BUTTON: 'back_button', // for ref - PanelVizTypePicker }; diff --git a/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts b/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts index fc7039bb075..7d11d1518be 100644 --- a/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts +++ b/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts @@ -9,8 +9,8 @@ import { sceneGraph, SceneTimeRangeLike, } from '@grafana/scenes'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { contextSrv } from 'app/core/services/context_srv'; import { getMessageFromError } from 'app/core/utils/errors'; import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi'; diff --git a/public/app/features/dashboard-scene/scene/VariableControls.test.tsx b/public/app/features/dashboard-scene/scene/VariableControls.test.tsx new file mode 100644 index 00000000000..c65639edfa6 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/VariableControls.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from '@testing-library/react'; + +import { VariableHide } from '@grafana/data'; +import { SceneGridLayout, SceneVariable, SceneVariableSet, ScopesVariable, TextBoxVariable } from '@grafana/scenes'; + +import { DashboardScene } from './DashboardScene'; +import { VariableControls } from './VariableControls'; +import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; + +jest.mock('@grafana/runtime', () => { + const runtime = jest.requireActual('@grafana/runtime'); + return { + ...runtime, + config: { + ...runtime.config, + featureToggles: { + dashboardNewLayouts: true, + }, + }, + }; +}); + +describe('VariableControls', () => { + it('should not render scopes variable', () => { + const variables = [new ScopesVariable({})]; + const dashboard = buildScene(variables); + dashboard.activate(); + + render(); + + expect(screen.queryByText('__scopes')).not.toBeInTheDocument(); + }); + + it('should not render regular hidden variables', () => { + const hiddenVariable = new TextBoxVariable({ + name: 'HiddenVar', + hide: VariableHide.hideVariable, + }); + const variables = [hiddenVariable]; + const dashboard = buildScene(variables); + dashboard.activate(); + + render(); + + expect(screen.queryByText('HiddenVar')).not.toBeInTheDocument(); + }); + + it('should render regular hidden variables in edit mode', async () => { + const hiddenVariable = new TextBoxVariable({ + name: 'HiddenVar', + hide: VariableHide.hideVariable, + }); + const variables = [hiddenVariable]; + const dashboard = buildScene(variables); + dashboard.activate(); + + dashboard.setState({ isEditing: true }); + render(); + + expect(await screen.findByText('HiddenVar')).toBeInTheDocument(); + }); + + it('should not render variables hidden in controls menu in edit mode', async () => { + const dashboard = buildScene([new TextBoxVariable({ name: 'TextVarControls', hide: VariableHide.inControlsMenu })]); + dashboard.activate(); + + dashboard.setState({ isEditing: true }); + render(); + + expect(screen.queryByText('TextVarControls')).not.toBeInTheDocument(); + }); +}); + +function buildScene(variables: SceneVariable[] = []) { + const dashboard = new DashboardScene({ + $variables: new SceneVariableSet({ variables }), + body: new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + children: [], + }), + }), + }); + return dashboard; +} diff --git a/public/app/features/dashboard-scene/scene/VariableControls.tsx b/public/app/features/dashboard-scene/scene/VariableControls.tsx index a2e6f3daf88..4cd92d34614 100644 --- a/public/app/features/dashboard-scene/scene/VariableControls.tsx +++ b/public/app/features/dashboard-scene/scene/VariableControls.tsx @@ -39,8 +39,9 @@ export function VariableControls({ dashboard }: { dashboard: DashboardScene }) { ? restVariables.filter((v) => v.state.hide !== VariableHide.inControlsMenu) : variables.filter( (v) => + // used for scopes variables, should always be hidden // if we're editing in dynamic dashboards, still shows hidden variable but greyed out - (isEditingNewLayouts && v.state.hide === VariableHide.hideVariable) || + (!v.UNSAFE_renderAsHidden && isEditingNewLayouts && v.state.hide === VariableHide.hideVariable) || v.state.hide !== VariableHide.inControlsMenu ); diff --git a/public/app/features/dashboard-scene/scene/export/exporters.ts b/public/app/features/dashboard-scene/scene/export/exporters.ts index 96492361e8d..0878ee35307 100644 --- a/public/app/features/dashboard-scene/scene/export/exporters.ts +++ b/public/app/features/dashboard-scene/scene/export/exporters.ts @@ -12,9 +12,9 @@ import { LibraryPanelRef, LibraryPanelKind, } 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'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { buildPanelKind } from 'app/features/dashboard/api/ResponseTransformers'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel, GridPos } from 'app/features/dashboard/state/PanelModel'; diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx index 3b7b6cf03d2..3b76452aec0 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx @@ -14,7 +14,7 @@ import { import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup'; -import { DashboardStateChangedEvent } from '../../edit-pane/shared'; +import { DashboardStateChangedEvent, RepeatsUpdatedEvent } from '../../edit-pane/shared'; import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone'; import { getMultiVariableValues } from '../../utils/utils'; import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView'; @@ -147,6 +147,7 @@ export class AutoGridItem extends SceneObjectBase implements this.setState({ repeatedPanels, repeatedConditionalRendering }); this._prevRepeatValues = values; + this.publishEvent(new RepeatsUpdatedEvent(this), true); } public getPanelCount() { diff --git a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx index 2a2c741c425..8eed7b8fdf1 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx @@ -17,7 +17,7 @@ import { import { GRID_COLUMN_COUNT } from 'app/core/constants'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; -import { DashboardStateChangedEvent } from '../../edit-pane/shared'; +import { DashboardStateChangedEvent, RepeatsUpdatedEvent } from '../../edit-pane/shared'; import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone'; import { getMultiVariableValues } from '../../utils/utils'; import { scrollCanvasElementIntoView, scrollIntoView } from '../layouts-shared/scrollCanvasElementIntoView'; @@ -219,6 +219,7 @@ export class DashboardGridItem } this._prevRepeatValues = values; + this.publishEvent(new RepeatsUpdatedEvent(this), true); } public handleVariableName() { diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx index 69f81584a48..266b6cd571b 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx @@ -134,7 +134,7 @@ function TabRepeatSelect({ tab, id }: { tab: TabItem; id?: string }) { Learn more diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.test.tsx b/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.test.tsx index d363a95ddf0..a8c2cbb6ec0 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.test.tsx +++ b/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.test.tsx @@ -7,10 +7,13 @@ import { SceneGridLayout, VizPanel, SceneVariableSet } from '@grafana/scenes'; import { activateFullSceneTree } from '../../utils/test-utils'; import { DashboardScene } from '../DashboardScene'; +import { AutoGridLayoutManager } from '../layout-auto-grid/AutoGridLayoutManager'; import { DashboardGridItem } from '../layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager'; import { RowItem } from '../layout-rows/RowItem'; import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager'; +import { TabItem } from '../layout-tabs/TabItem'; +import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager'; import { LayoutParent } from '../types/LayoutParent'; import { DashboardLayoutSelector } from './DashboardLayoutSelector'; @@ -40,6 +43,27 @@ describe('DashboardLayoutSelector', () => { await user.click(confirmButton); expect(switchLayoutMock).toHaveBeenCalled(); }); + + it('should disable tabs option when a row contains tabs layout and show correct message', async () => { + const scene = buildTestSceneWithNestedTabs(); + const layoutManager = scene.state.body; + + render(); + + const tabsOption = screen.getByLabelText('layout-selection-option-Tabs'); + expect(tabsOption).toBeDisabled(); + expect(screen.getByTitle('Cannot change to tabs because a row already contains tabs')).toBeInTheDocument(); + }); + + it('should not disable tabs option when rows do not contain tabs', async () => { + const scene = buildTestScene(); + const layoutManager = scene.state.body; + + render(); + + const tabsOption = screen.getByLabelText('layout-selection-option-Tabs'); + expect(tabsOption).not.toBeDisabled(); + }); }); const buildTestScene = () => { @@ -70,3 +94,43 @@ const buildTestScene = () => { activateFullSceneTree(scene); return scene; }; + +const buildTestSceneWithNestedTabs = () => { + const scene = new DashboardScene({ + title: 'testScene', + editable: true, + $variables: new SceneVariableSet({ + variables: [], + }), + body: new RowsLayoutManager({ + rows: [ + new RowItem({ + title: 'Row 1', + layout: new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + children: [ + new DashboardGridItem({ + body: new VizPanel({ key: 'panel-1', pluginId: 'text' }), + }), + ], + }), + }), + }), + new RowItem({ + title: 'Row with Tabs', + layout: new TabsLayoutManager({ + tabs: [ + new TabItem({ + title: 'Tab 1', + layout: AutoGridLayoutManager.createEmpty(), + }), + ], + }), + }), + ], + }), + }); + + activateFullSceneTree(scene); + return scene; +}; diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx b/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx index b32555b32f8..ee902d195ad 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx +++ b/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx @@ -11,6 +11,7 @@ import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { isLayoutParent } from '../types/LayoutParent'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; +import { containsTabsLayout } from './findAllGridTypes'; import { layoutRegistry } from './layoutRegistry'; export interface Props { @@ -22,19 +23,26 @@ export function DashboardLayoutSelector({ layoutManager }: Props) { const options = layoutRegistry.list().filter((layout) => layout.isGridLayout === isGridLayout); const [newLayout, setNewLayout] = useState(); - const disableTabs = useMemo(() => { + const disableTabsReason = useMemo(() => { if (config.featureToggles.unlimitedLayoutsNesting) { - return false; + return undefined; } + + // Check parent hierarchy let parent = layoutManager.parent; while (parent) { if (parent instanceof TabsLayoutManager) { - return true; + return 'parent'; } parent = parent.parent; } - return false; + // Check child hierarchy + if (containsTabsLayout(layoutManager)) { + return 'child'; + } + + return undefined; }, [layoutManager]); const onChangeLayout = useCallback((newLayout: LayoutRegistryItem) => setNewLayout(newLayout), []); @@ -59,8 +67,15 @@ export function DashboardLayoutSelector({ layoutManager }: Props) { const radioOptions = options.map((opt) => { let description = opt.description; - if (disableTabs && opt.id === TabsLayoutManager.descriptor.id) { - description = t('dashboard.canvas-actions.disabled-nested-tabs', 'Tabs cannot be nested inside other tabs'); + if (disableTabsReason && opt.id === TabsLayoutManager.descriptor.id) { + if (disableTabsReason === 'parent') { + description = t('dashboard.canvas-actions.disabled-nested-tabs', 'Tabs cannot be nested inside other tabs'); + } else { + description = t( + 'dashboard.canvas-actions.disabled-child-contains-tabs', + 'Cannot change to tabs because a row already contains tabs' + ); + } disabledOptions.push(opt); } diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.test.ts b/public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.test.ts new file mode 100644 index 00000000000..b2c925fb26e --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.test.ts @@ -0,0 +1,93 @@ +import { AutoGridLayoutManager } from '../layout-auto-grid/AutoGridLayoutManager'; +import { RowItem } from '../layout-rows/RowItem'; +import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager'; +import { TabItem } from '../layout-tabs/TabItem'; +import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager'; + +import { containsTabsLayout, findAllGridTypes } from './findAllGridTypes'; + +describe('findAllGridTypes', () => { + it('should return grid type for a grid layout', () => { + const layout = AutoGridLayoutManager.createEmpty(); + expect(findAllGridTypes(layout)).toEqual([AutoGridLayoutManager.descriptor.id]); + }); + + it('should return grid types from tabs', () => { + const layout = new TabsLayoutManager({ + tabs: [ + new TabItem({ layout: AutoGridLayoutManager.createEmpty() }), + new TabItem({ layout: AutoGridLayoutManager.createEmpty() }), + ], + }); + expect(findAllGridTypes(layout)).toEqual([ + AutoGridLayoutManager.descriptor.id, + AutoGridLayoutManager.descriptor.id, + ]); + }); + + it('should return grid types from rows', () => { + const layout = new RowsLayoutManager({ + rows: [ + new RowItem({ layout: AutoGridLayoutManager.createEmpty() }), + new RowItem({ layout: AutoGridLayoutManager.createEmpty() }), + ], + }); + expect(findAllGridTypes(layout)).toEqual([ + AutoGridLayoutManager.descriptor.id, + AutoGridLayoutManager.descriptor.id, + ]); + }); +}); + +describe('containsTabsLayout', () => { + it('should return true when layout is TabsLayoutManager', () => { + const layout = new TabsLayoutManager({ + tabs: [new TabItem({ layout: AutoGridLayoutManager.createEmpty() })], + }); + expect(containsTabsLayout(layout)).toBe(true); + }); + + it('should return false when layout is a grid layout', () => { + const layout = AutoGridLayoutManager.createEmpty(); + expect(containsTabsLayout(layout)).toBe(false); + }); + + it('should return false when layout is RowsLayoutManager with no tabs in rows', () => { + const layout = new RowsLayoutManager({ + rows: [ + new RowItem({ layout: AutoGridLayoutManager.createEmpty() }), + new RowItem({ layout: AutoGridLayoutManager.createEmpty() }), + ], + }); + expect(containsTabsLayout(layout)).toBe(false); + }); + + it('should return true when RowsLayoutManager contains a row with tabs layout', () => { + const layout = new RowsLayoutManager({ + rows: [ + new RowItem({ layout: AutoGridLayoutManager.createEmpty() }), + new RowItem({ + layout: new TabsLayoutManager({ + tabs: [new TabItem({ layout: AutoGridLayoutManager.createEmpty() })], + }), + }), + ], + }); + expect(containsTabsLayout(layout)).toBe(true); + }); + + it('should return true when any row contains tabs layout', () => { + const layout = new RowsLayoutManager({ + rows: [ + new RowItem({ + layout: new TabsLayoutManager({ + tabs: [new TabItem({ layout: AutoGridLayoutManager.createEmpty() })], + }), + }), + new RowItem({ layout: AutoGridLayoutManager.createEmpty() }), + new RowItem({ layout: AutoGridLayoutManager.createEmpty() }), + ], + }); + expect(containsTabsLayout(layout)).toBe(true); + }); +}); diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.ts b/public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.ts index 03dec1482fb..6050e25a94f 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.ts +++ b/public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.ts @@ -15,3 +15,15 @@ export function findAllGridTypes(layout: DashboardLayoutManager): string[] { return []; } + +export function containsTabsLayout(layout: DashboardLayoutManager): boolean { + if (layout instanceof TabsLayoutManager) { + return true; + } + + if (layout instanceof RowsLayoutManager) { + return layout.state.rows.some((row) => containsTabsLayout(row.getLayout())); + } + + return false; +} 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 ad644f92294..3902567b76d 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.test.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.test.ts @@ -1,6 +1,13 @@ -import { defaultDataQueryKind, PanelQueryKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import { + defaultDataQueryKind, + defaultPanelSpec, + PanelKind, + PanelQueryKind, +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants'; +import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; -import { ensureUniqueRefIds, getRuntimePanelDataSource } from './utils'; +import { ensureUniqueRefIds, getPanelDataSource, getRuntimePanelDataSource } from './utils'; describe('getRuntimePanelDataSource', () => { it('should return uid and type when explicit datasource UID is provided', () => { @@ -141,6 +148,159 @@ describe('getRuntimePanelDataSource', () => { }); }); +describe('getPanelDataSource', () => { + const createPanelWithQueries = (queries: PanelQueryKind[]): PanelKind => ({ + kind: 'Panel', + spec: { + ...defaultPanelSpec(), + id: 1, + title: 'Test Panel', + data: { + kind: 'QueryGroup', + spec: { + queries, + queryOptions: {}, + transformations: [], + }, + }, + }, + }); + + const createQuery = (datasourceName: string, group: string, refId = 'A'): PanelQueryKind => ({ + kind: 'PanelQuery', + spec: { + refId, + hidden: false, + query: { + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group, + datasource: { + name: datasourceName, + }, + spec: {}, + }, + }, + }); + + const createQueryWithoutDatasourceName = (group: string, refId = 'A'): PanelQueryKind => ({ + kind: 'PanelQuery', + spec: { + refId, + hidden: false, + query: { + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group, + spec: {}, + }, + }, + }); + + it('should return undefined when panel has no queries', () => { + const panel = createPanelWithQueries([]); + + const result = getPanelDataSource(panel); + + expect(result).toBeUndefined(); + }); + + it('should return undefined for a single query with specific datasource (not mixed)', () => { + const panel = createPanelWithQueries([createQuery('prometheus-uid', 'prometheus')]); + + const result = getPanelDataSource(panel); + + expect(result).toBeUndefined(); + }); + + it('should return undefined for multiple queries with the same datasource', () => { + const panel = createPanelWithQueries([ + createQuery('prometheus-uid', 'prometheus', 'A'), + createQuery('prometheus-uid', 'prometheus', 'B'), + createQuery('prometheus-uid', 'prometheus', 'C'), + ]); + + const result = getPanelDataSource(panel); + + expect(result).toBeUndefined(); + }); + + it('should return mixed datasource when queries use different datasource UIDs', () => { + const panel = createPanelWithQueries([ + createQuery('prometheus-uid', 'prometheus', 'A'), + createQuery('loki-uid', 'loki', 'B'), + ]); + + const result = getPanelDataSource(panel); + + expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME }); + }); + + it('should return mixed datasource when queries use different datasource types', () => { + const panel = createPanelWithQueries([ + createQuery('ds-uid', 'prometheus', 'A'), + createQuery('ds-uid', 'loki', 'B'), + ]); + + const result = getPanelDataSource(panel); + + expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME }); + }); + + it('should return mixed datasource when multiple queries use Dashboard datasource', () => { + const panel = createPanelWithQueries([ + createQuery(SHARED_DASHBOARD_QUERY, 'datasource', 'A'), + createQuery(SHARED_DASHBOARD_QUERY, 'datasource', 'B'), + createQuery(SHARED_DASHBOARD_QUERY, 'datasource', 'C'), + ]); + + const result = getPanelDataSource(panel); + + expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME }); + }); + + it('should return Dashboard datasource when single query uses Dashboard datasource', () => { + const panel = createPanelWithQueries([createQuery(SHARED_DASHBOARD_QUERY, 'datasource')]); + + const result = getPanelDataSource(panel); + + expect(result).toEqual({ type: 'datasource', uid: SHARED_DASHBOARD_QUERY }); + }); + + it('should return mixed when Dashboard datasource is mixed with other datasources', () => { + const panel = createPanelWithQueries([ + createQuery(SHARED_DASHBOARD_QUERY, 'datasource', 'A'), + createQuery('prometheus-uid', 'prometheus', 'B'), + ]); + + const result = getPanelDataSource(panel); + + expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME }); + }); + + it('should return undefined when queries have no explicit datasource name but same type', () => { + const panel = createPanelWithQueries([ + createQueryWithoutDatasourceName('prometheus', 'A'), + createQueryWithoutDatasourceName('prometheus', 'B'), + ]); + + const result = getPanelDataSource(panel); + + expect(result).toBeUndefined(); + }); + + it('should return mixed when queries have no explicit datasource name but different types', () => { + const panel = createPanelWithQueries([ + createQueryWithoutDatasourceName('prometheus', 'A'), + createQueryWithoutDatasourceName('loki', 'B'), + ]); + + const result = getPanelDataSource(panel); + + expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME }); + }); +}); + describe('ensureUniqueRefIds', () => { const createQuery = (refId: string): PanelQueryKind => ({ kind: 'PanelQuery', diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts index 2256206aaee..ee63a5d7f52 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts @@ -23,6 +23,7 @@ import { DataQueryKind, defaultPanelQueryKind, } from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup'; @@ -228,29 +229,45 @@ export function createPanelDataProvider(panelKind: PanelKind): SceneDataProvider * This ensures v2→Scene→v1 conversion produces the same output as the Go backend, * which does NOT add panel-level datasource for non-mixed panels. */ -function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined { - if (!panel.spec.data?.spec.queries?.length) { +export function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined { + const queries = panel.spec.data?.spec.queries; + if (!queries?.length) { return undefined; } - let firstDatasource: DataSourceRef | undefined = undefined; - let isMixedDatasource = false; + // Check if multiple queries use Dashboard datasource - this needs mixed mode + const dashboardDsQueryCount = queries.filter((q) => q.spec.query.datasource?.name === SHARED_DASHBOARD_QUERY).length; + if (dashboardDsQueryCount > 1) { + return { type: 'mixed', uid: MIXED_DATASOURCE_NAME }; + } - panel.spec.data.spec.queries.forEach((query) => { - const queryDs = query.spec.query.datasource?.name + // Get all datasources from queries + const datasources = queries.map((query) => + query.spec.query.datasource?.name ? { uid: query.spec.query.datasource.name, type: query.spec.query.group } - : getRuntimePanelDataSource(query.spec.query); + : getRuntimePanelDataSource(query.spec.query) + ); - if (!firstDatasource) { - firstDatasource = queryDs; - } else if (firstDatasource.uid !== queryDs?.uid || firstDatasource.type !== queryDs?.type) { - isMixedDatasource = true; - } - }); + const firstDatasource = datasources[0]; + + // Check if queries use different datasources + const isMixedDatasource = datasources.some( + (ds) => ds?.uid !== firstDatasource?.uid || ds?.type !== firstDatasource?.type + ); + + if (isMixedDatasource) { + return { type: 'mixed', uid: MIXED_DATASOURCE_NAME }; + } + + // Handle case where all queries use Dashboard datasource - needs to set datasource for proper data fetching + // See DashboardDatasourceBehaviour.tsx for more details + if (firstDatasource?.uid === SHARED_DASHBOARD_QUERY) { + return { type: 'datasource', uid: SHARED_DASHBOARD_QUERY }; + } // Only return mixed datasource - for non-mixed panels, each query already has its own datasource // This matches the Go backend behavior which doesn't add panel.datasource for non-mixed panels - return isMixedDatasource ? { type: 'mixed', uid: MIXED_DATASOURCE_NAME } : undefined; + return undefined; } /** diff --git a/public/app/features/dashboard-scene/serialization/serialization-test-utils.ts b/public/app/features/dashboard-scene/serialization/serialization-test-utils.ts index 178c89e1da4..af7d6021fb5 100644 --- a/public/app/features/dashboard-scene/serialization/serialization-test-utils.ts +++ b/public/app/features/dashboard-scene/serialization/serialization-test-utils.ts @@ -1,9 +1,40 @@ +import { readdirSync, statSync } from 'fs'; +import path from 'path'; + import { Spec as DashboardV2Spec, GridLayoutItemKind, RowsLayoutRowKind, } from '@grafana/schema/dist/esm/schema/dashboard/v2'; +/** + * Recursively gets all JSON files from a directory. + * Returns an array of objects containing the full file path and relative path from the base directory. + */ +export function getFilesRecursively( + dir: string, + baseDir: string = dir +): Array<{ filePath: string; relativePath: string }> { + const files: Array<{ filePath: string; relativePath: string }> = []; + const entries = readdirSync(dir); + + for (const entry of entries) { + const fullPath = path.join(dir, entry); + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + files.push(...getFilesRecursively(fullPath, baseDir)); + } else if (entry.endsWith('.json')) { + files.push({ + filePath: fullPath, + relativePath: path.relative(baseDir, fullPath), + }); + } + } + + return files; +} + /** * Normalizes backend output to match frontend behavior. * The backend sets repeat properties on library panel grid items from the library panel definition, @@ -94,3 +125,40 @@ export function normalizeBackendOutputForFrontendComparison( return normalized; } + +/** + * Recursively removes empty arrays from an object. + * This normalizes the difference between frontend (which preserves empty arrays) + * and Go backend (which omits empty arrays due to `omitempty`). + */ +export function removeEmptyArrays(value: T): T { + if (Array.isArray(value)) { + // Recursively process array items, but don't remove the array itself here + // (parent will handle removal if this array becomes empty after processing) + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return value.map((item) => removeEmptyArrays(item)) as T; + } + + if (value !== null && typeof value === 'object') { + const result: Record = {}; + for (const key of Object.keys(value)) { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const v = (value as Record)[key]; + if (Array.isArray(v)) { + // Only include non-empty arrays + if (v.length > 0) { + result[key] = removeEmptyArrays(v); + } + // Skip empty arrays (don't add to result) + } else if (v !== null && typeof v === 'object') { + result[key] = removeEmptyArrays(v); + } else { + result[key] = v; + } + } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return result as T; + } + + return value; +} diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts index 0bcea1e0ecd..bfb5095f3cf 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts @@ -27,6 +27,7 @@ import { createPanelSaveModel } from 'app/features/dashboard/state/__fixtures__/ import { SHARED_DASHBOARD_QUERY, DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/constants'; import { DashboardDataDTO } from 'app/types/dashboard'; +import { getSceneCreationOptions } from '../pages/DashboardScenePageStateManager'; import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet'; import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; @@ -822,10 +823,14 @@ describe('transformSaveModelToScene', () => { }); it('Should convert legacy rows to new rows', () => { - const scene = transformSaveModelToScene({ - dashboard: repeatingRowsAndPanelsDashboardJson as DashboardDataDTO, - meta: {}, - }); + const scene = transformSaveModelToScene( + { + dashboard: repeatingRowsAndPanelsDashboardJson as DashboardDataDTO, + meta: {}, + }, + undefined, + getSceneCreationOptions() + ); const layout = scene.state.body as RowsLayoutManager; const row1 = layout.state.rows[0]; @@ -857,10 +862,14 @@ describe('transformSaveModelToScene', () => { }); it('Should convert legacy rows to new rows with free panels before first row', () => { - const scene = transformSaveModelToScene({ - dashboard: rowsAfterFreePanels as DashboardDataDTO, - meta: {}, - }); + const scene = transformSaveModelToScene( + { + dashboard: rowsAfterFreePanels as DashboardDataDTO, + meta: {}, + }, + undefined, + getSceneCreationOptions() + ); const layout = scene.state.body as RowsLayoutManager; const row1 = layout.state.rows[0]; diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index 3da62d6cdf4..8f80dcc526e 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -29,11 +29,11 @@ import { } from 'app/features/dashboard/services/DashboardProfiler'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; -import { DashboardDTO, DashboardDataDTO, DashboardRoutes } from 'app/types/dashboard'; +import { DashboardDTO, DashboardDataDTO } from 'app/types/dashboard'; import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior'; import { dashboardAnalyticsInitializer } from '../behaviors/DashboardAnalyticsInitializerBehavior'; -import { LoadDashboardOptions, shouldForceV2API } from '../pages/DashboardScenePageStateManager'; +import { LoadDashboardOptions } from '../pages/DashboardScenePageStateManager'; import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; import { DashboardControls } from '../scene/DashboardControls'; @@ -76,11 +76,53 @@ export interface SaveModelToSceneOptions { isEmbedded?: boolean; } -export function transformSaveModelToScene(rsp: DashboardDTO, options?: LoadDashboardOptions): DashboardScene { +type LayoutCreator = (panels: PanelModel[], preload?: boolean) => DashboardLayoutManager; + +export interface SceneCreationOptions { + /** + * When provided, this function is used to create the dashboard body/layout instead of the default v1 behavior. + * This allows callers to inject v2 layout strategy. + */ + createLayout?: LayoutCreator; + /** + * Determines how the dashboard scene is serialized. + * @default 'v1' + */ + targetVersion?: 'v1' | 'v2'; +} + +// Rows as SceneGridRow within the grid. +const createDefaultGridLayout: LayoutCreator = (panels, preload) => { + return new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + isLazy: getIsLazy(preload), + children: createSceneObjectsForPanels(panels), + }), + }); +}; + +/** + * V2 layout creator - uses RowsLayoutManager when dashboard has rows. + * This creates a layout that can be properly serialized to v2 format. + */ +export const createV2RowsLayout: LayoutCreator = (panels, preload) => { + const hasRows = panels.some((p) => p.type === 'row'); + if (hasRows) { + return createRowsFromPanels(panels); + } + // Fall back to default grid layout when no rows + return createDefaultGridLayout(panels, preload); +}; + +export function transformSaveModelToScene( + rsp: DashboardDTO, + options?: LoadDashboardOptions, + sceneOptions?: SceneCreationOptions +): DashboardScene { // Just to have migrations run const oldModel = new DashboardModel(rsp.dashboard, rsp.meta); - const scene = createDashboardSceneFromDashboardModel(oldModel, rsp.dashboard, options); + const scene = createDashboardSceneFromDashboardModel(oldModel, rsp.dashboard, options, sceneOptions); // TODO: refactor createDashboardSceneFromDashboardModel to work on Dashboard schema model const apiVersion = config.featureToggles.kubernetesDashboards @@ -92,7 +134,7 @@ export function transformSaveModelToScene(rsp: DashboardDTO, options?: LoadDashb return scene; } -export function createRowsFromPanels(oldPanels: PanelModel[]): RowsLayoutManager { +function createRowsFromPanels(oldPanels: PanelModel[]): RowsLayoutManager { const rowItems: RowItem[] = []; let currentLegacyRow: PanelModel | null = null; @@ -143,7 +185,7 @@ export function createRowsFromPanels(oldPanels: PanelModel[]): RowsLayoutManager }); } -export function createSceneObjectsForPanels(oldPanels: PanelModel[]): SceneGridItemLike[] { +function createSceneObjectsForPanels(oldPanels: PanelModel[]): SceneGridItemLike[] { // collects all panels and rows const panels: SceneGridItemLike[] = []; @@ -259,14 +301,14 @@ function createRowItemFromLegacyRow(row: PanelModel, panels: DashboardGridItem[] export function createDashboardSceneFromDashboardModel( oldModel: DashboardModel, dto: DashboardDataDTO, - options?: LoadDashboardOptions + options?: LoadDashboardOptions, + sceneOptions?: SceneCreationOptions ) { let variables: SceneVariableSet | undefined; let annotationLayers: SceneDataLayerProvider[] = []; let alertStatesLayer: AlertStatesDataLayer | undefined; const uid = oldModel.uid; - const isReport = options?.route === DashboardRoutes.Report; - const serializerVersion = shouldForceV2API() && !oldModel.meta.isSnapshot && !isReport ? 'v2' : 'v1'; + const targetVersion = sceneOptions?.targetVersion ?? 'v1'; if (oldModel.meta.isSnapshot) { variables = createVariablesForSnapshot(oldModel); @@ -354,9 +396,11 @@ export function createDashboardSceneFromDashboardModel( let body: DashboardLayoutManager; - if (serializerVersion === 'v2' && oldModel.panels.some((p) => p.type === 'row')) { - body = createRowsFromPanels(oldModel.panels); + if (sceneOptions?.createLayout) { + // Use injected layout creator (allows callers to specify v2 or custom layout strategy) + body = sceneOptions.createLayout(oldModel.panels, dto.preload); } else { + // Default v1 layout: DefaultGridLayoutManager body = new DefaultGridLayoutManager({ grid: new SceneGridLayout({ isLazy: getIsLazy(dto.preload), @@ -404,7 +448,7 @@ export function createDashboardSceneFromDashboardModel( hideTimeControls: oldModel.timepicker.hidden, }), }, - serializerVersion + targetVersion ); // Enable panel profiling for this dashboard using the composed SceneRenderProfiler diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts index 9dbf8e36f0a..afd8dd97c03 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts @@ -1,7 +1,13 @@ -import { readdirSync, readFileSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import path from 'path'; -import { normalizeBackendOutputForFrontendComparison } from './serialization-test-utils'; +import { getSceneCreationOptions } from '../pages/DashboardScenePageStateManager'; + +import { + getFilesRecursively, + normalizeBackendOutputForFrontendComparison, + removeEmptyArrays, +} from './serialization-test-utils'; import { transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene'; import { transformSaveModelToScene } from './transformSaveModelToScene'; import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2'; @@ -171,19 +177,32 @@ describe('V1 to V2 Dashboard Transformation Comparison', () => { 'migrated_dashboards_output' ); - const jsonInputs = readdirSync(inputDir); const LATEST_API_VERSION = 'dashboard.grafana.app/v2beta1'; - // Filter to only process v1beta1 input files - const v1beta1Inputs = jsonInputs.filter((inputFile) => inputFile.startsWith('v1beta1.')); + // Get v0alpha1 and v1beta1 input files recursively from all subdirectories + const v1beta1Inputs = getFilesRecursively(inputDir).filter(({ relativePath }) => { + const fileName = path.basename(relativePath); + return fileName.startsWith('v1beta1.') && fileName.endsWith('.json'); + }); - v1beta1Inputs.forEach((inputFile) => { - it(`compare ${inputFile} from v1beta1 to v2beta1 backend and frontend conversions`, async () => { - const jsonInput = JSON.parse(readFileSync(path.join(inputDir, inputFile), 'utf8')); + v1beta1Inputs.forEach(({ filePath: inputFilePath, relativePath }) => { + // Calculate output file path for this input + const relativeDir = path.dirname(relativePath); + const fileName = path.basename(relativePath); + const outputFileName = fileName.replace('.json', `.${LATEST_API_VERSION.split('/')[1]}.json`); + const outputFilePath = + relativeDir === '.' ? path.join(outputDir, outputFileName) : path.join(outputDir, relativeDir, outputFileName); - // Find the corresponding v2beta1 output file - const outputFileName = inputFile.replace('.json', `.${LATEST_API_VERSION.split('/')[1]}.json`); - const outputFilePath = path.join(outputDir, outputFileName); + // Include output file name in test description for clarity + const outputRelativePath = relativeDir === '.' ? outputFileName : path.join(relativeDir, outputFileName); + + it(`compare ${relativePath} → ${outputRelativePath}`, async () => { + // Skip if output file doesn't exist + if (!existsSync(outputFilePath)) { + return; + } + + const jsonInput = JSON.parse(readFileSync(inputFilePath, 'utf8')); // Load the backend output const backendOutput = JSON.parse(readFileSync(outputFilePath, 'utf8')); @@ -200,29 +219,34 @@ describe('V1 to V2 Dashboard Transformation Comparison', () => { }); const backendOutputAfterLoadedByScene = transformSceneToSaveModelSchemaV2(sceneBackend, false); - // Transform using frontend path: v1beta1 -> Scene -> v2beta1 - // Extract the spec from v1beta1 format and use it as the dashboard data - // Remove snapshot field to prevent isSnapshot() from returning true - const dashboardSpec = { ...jsonInput.spec }; + // Determine how to extract the dashboard spec: + // - Files with apiVersion field are API-wrapped (spec contains dashboard) + // - Files without apiVersion are raw dashboard JSON (entire file is the spec) + const hasApiVersion = jsonInput.apiVersion !== undefined; + const dashboardSpec = hasApiVersion ? { ...jsonInput.spec } : { ...jsonInput }; delete dashboardSpec.snapshot; // Wrap in DashboardDTO structure that transformSaveModelToScene expects - const scene = transformSaveModelToScene({ - dashboard: dashboardSpec, - meta: { - isNew: false, - isFolder: false, - canSave: true, - canEdit: true, - canDelete: false, - canShare: false, - canStar: false, - canAdmin: false, - isSnapshot: false, - provisioned: false, - version: 1, + const scene = transformSaveModelToScene( + { + dashboard: dashboardSpec, + meta: { + isNew: false, + isFolder: false, + canSave: true, + canEdit: true, + canDelete: false, + canShare: false, + canStar: false, + canAdmin: false, + isSnapshot: false, + provisioned: false, + version: 1, + }, }, - }); + undefined, + getSceneCreationOptions() + ); const frontendOutput = transformSceneToSaveModelSchemaV2(scene, false); @@ -232,30 +256,39 @@ describe('V1 to V2 Dashboard Transformation Comparison', () => { // Normalize backend output to account for differences in library panel repeat handling // Backend sets repeat from library panel definition, frontend only sets it when explicit on instance - // For migrated dashboards, panels are in the root level, not in spec.panels - const inputPanels = jsonInput.panels || jsonInput.spec?.panels || []; - const normalizedBackendOutput = normalizeBackendOutputForFrontendComparison( - backendOutputAfterLoadedByScene, - inputPanels + // Get input panels from appropriate location based on file format + const inputPanels = hasApiVersion ? jsonInput.spec?.panels || [] : jsonInput.panels || []; + const normalizedBackendOutput = removeEmptyArrays( + normalizeBackendOutputForFrontendComparison(backendOutputAfterLoadedByScene, inputPanels) ); + // Also normalize frontend output to remove schema gap fields and empty arrays + // (Go backend omits empty arrays due to omitempty, frontend preserves them) + const normalizedFrontendOutput = removeEmptyArrays(frontendOutput); + // Compare only the spec structures - this is the core transformation - expect(normalizedBackendOutput).toEqual(frontendOutput); + expect(normalizedBackendOutput).toEqual(normalizedFrontendOutput); }); }); // Test migrated dashboards (from migration pipeline output) - const migratedJsonInputs = readdirSync(migratedInput); + const migratedJsonInputs = getFilesRecursively(migratedInput).filter(({ relativePath }) => { + return relativePath.endsWith('.json'); + }); - migratedJsonInputs.forEach((inputFile) => { - it(`compare migrated ${inputFile} from v1beta1 to v2beta1 backend and frontend conversions`, async () => { + migratedJsonInputs.forEach(({ filePath: inputFilePath, relativePath }) => { + // Calculate output file path for this input + const relativeDir = path.dirname(relativePath); + const fileName = path.basename(relativePath); + const outputFileName = `v1beta1-mig-${fileName.replace('.json', '')}.${LATEST_API_VERSION.split('/')[1]}.json`; + const outputFilePath = + relativeDir === '.' + ? path.join(migratedOutput, outputFileName) + : path.join(migratedOutput, relativeDir, outputFileName); + + it(`compare migrated ${relativePath} → ${outputFileName}`, async () => { // Read the raw dashboard JSON from migration output (latest_version directory) - const jsonInput = JSON.parse(readFileSync(path.join(migratedInput, inputFile), 'utf8')); - - // Find the corresponding v2beta1 output file in migrated_dashboards_output - // The backend test prefixes these with "v1beta1-mig-" - const outputFileName = `v1beta1-mig-${inputFile.replace('.json', '')}.${LATEST_API_VERSION.split('/')[1]}.json`; - const outputFilePath = path.join(migratedOutput, outputFileName); + const jsonInput = JSON.parse(readFileSync(inputFilePath, 'utf8')); // Load the backend output const backendOutput = JSON.parse(readFileSync(outputFilePath, 'utf8')); @@ -279,22 +312,26 @@ describe('V1 to V2 Dashboard Transformation Comparison', () => { delete dashboardSpec.snapshot; // Wrap in DashboardDTO structure that transformSaveModelToScene expects - const scene = transformSaveModelToScene({ - dashboard: dashboardSpec, - meta: { - isNew: false, - isFolder: false, - canSave: true, - canEdit: true, - canDelete: false, - canShare: false, - canStar: false, - canAdmin: false, - isSnapshot: false, - provisioned: false, - version: 1, + const scene = transformSaveModelToScene( + { + dashboard: dashboardSpec, + meta: { + isNew: false, + isFolder: false, + canSave: true, + canEdit: true, + canDelete: false, + canShare: false, + canStar: false, + canAdmin: false, + isSnapshot: false, + provisioned: false, + version: 1, + }, }, - }); + undefined, + getSceneCreationOptions() + ); const frontendOutput = transformSceneToSaveModelSchemaV2(scene, false); @@ -306,13 +343,16 @@ describe('V1 to V2 Dashboard Transformation Comparison', () => { // Backend sets repeat from library panel definition, frontend only sets it when explicit on instance // For migrated dashboards, panels are in the root level, not in spec.panels const inputPanels = jsonInput.panels || jsonInput.spec?.panels || []; - const normalizedBackendOutput = normalizeBackendOutputForFrontendComparison( - backendOutputAfterLoadedByScene, - inputPanels + const normalizedBackendOutput = removeEmptyArrays( + normalizeBackendOutputForFrontendComparison(backendOutputAfterLoadedByScene, inputPanels) ); + // Also normalize frontend output to remove schema gap fields and empty arrays + // (Go backend omits empty arrays due to omitempty, frontend preserves them) + const normalizedFrontendOutput = removeEmptyArrays(frontendOutput); + // Compare only the spec structures - this is the core transformation - expect(normalizedBackendOutput).toEqual(frontendOutput); + expect(normalizedBackendOutput).toEqual(normalizedFrontendOutput); }); }); }); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelV2ToV1.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelV2ToV1.test.ts index 74208ff8a8f..bca864088ed 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelV2ToV1.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelV2ToV1.test.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, readFileSync, statSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import path from 'path'; import { Dashboard } from '@grafana/schema'; @@ -6,32 +6,13 @@ import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboa import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { DashboardDataDTO } from 'app/types/dashboard'; +import { getSceneCreationOptions } from '../pages/DashboardScenePageStateManager'; + +import { getFilesRecursively } from './serialization-test-utils'; import { transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene'; import { transformSaveModelToScene } from './transformSaveModelToScene'; import { transformSceneToSaveModel } from './transformSceneToSaveModel'; -// Helper function to recursively get all files from a directory -function getFilesRecursively(dir: string, baseDir: string = dir): Array<{ filePath: string; relativePath: string }> { - const files: Array<{ filePath: string; relativePath: string }> = []; - const entries = readdirSync(dir); - - for (const entry of entries) { - const fullPath = path.join(dir, entry); - const stat = statSync(fullPath); - - if (stat.isDirectory()) { - files.push(...getFilesRecursively(fullPath, baseDir)); - } else if (entry.endsWith('.json')) { - files.push({ - filePath: fullPath, - relativePath: path.relative(baseDir, fullPath), - }); - } - } - - return files; -} - // Mock the config to provide datasource information jest.mock('@grafana/runtime', () => { const mockConfig = { @@ -228,22 +209,26 @@ function removeMetadata(spec: Dashboard): Partial { * identical processing. */ function loadAndSerializeV1SaveModel(dashboard: Dashboard): Dashboard { - const scene = transformSaveModelToScene({ - dashboard: dashboard as DashboardDataDTO, - meta: { - isNew: false, - isFolder: false, - canSave: true, - canEdit: true, - canDelete: false, - canShare: false, - canStar: false, - canAdmin: false, - isSnapshot: false, - provisioned: false, - version: 1, + const scene = transformSaveModelToScene( + { + dashboard: dashboard as DashboardDataDTO, + meta: { + isNew: false, + isFolder: false, + canSave: true, + canEdit: true, + canDelete: false, + canShare: false, + canStar: false, + canAdmin: false, + isSnapshot: false, + provisioned: false, + version: 1, + }, }, - }); + undefined, + getSceneCreationOptions() + ); return transformSceneToSaveModel(scene, false); } diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts index 3052e4d8118..4b0e109e864 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts @@ -140,7 +140,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa const dashboard: Dashboard = { ...defaultDashboard, title: state.title, - description: state.description || undefined, + description: state.description, uid: state.uid, id: state.id, editable: state.editable, @@ -781,6 +781,10 @@ export function tabItemToSaveModel( panels: [], }; + if (tab.state.repeatByVariable) { + rowPanel.repeat = tab.state.repeatByVariable; + } + panelsArray.push(rowPanel); // The base Y position for panels in this tab (after the row panel) @@ -912,6 +916,15 @@ function autoGridLayoutToPanels(layout: AutoGridLayoutManager, isSnapshot = fals }, isSnapshot ); + + // Handle repeat properties for AutoGridItem + // AutoGrid always uses horizontal direction, and maxPerRow is derived from maxColumnCount + if (item.state.variableName) { + panel.repeat = item.state.variableName; + panel.repeatDirection = 'h'; + panel.maxPerRow = maxColumnCount; + } + panels.push(panel); // Move to next position diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index 90c7f5e2e61..bd1ebbbe6bf 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -88,7 +88,7 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps const dashboardSchemaV2: DeepPartial = { //dashboard settings title: sceneDash.title, - description: sceneDash.description, + description: sceneDash.description || undefined, cursorSync: getCursorSync(sceneDash), liveNow: getLiveNow(sceneDash), preload: sceneDash.preload ?? defaultDashboardV2Spec().preload, diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsCode.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsCode.tsx index 9ffad2ef066..33a0eb15c4c 100644 --- a/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsCode.tsx +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsCode.tsx @@ -9,8 +9,8 @@ import { Trans, t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; import { SceneComponentProps } from '@grafana/scenes'; import { Button, ClipboardButton, CodeEditor, Label, Spinner, Stack, Switch, useStyles2 } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; import { createSuccessNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { dispatch } from 'app/store/store'; import { ShareExportTab } from '../ShareExportTab'; @@ -25,6 +25,10 @@ export class ExportAsCode extends ShareExportTab { public getTabLabel(): string { return t('export.json.title', 'Export dashboard'); } + + public getSubtitle(): string | undefined { + return t('export.json.info-text', 'Copy or download a file containing the definition of your dashboard'); + } } function ExportAsCodeRenderer({ model }: SceneComponentProps) { @@ -53,12 +57,6 @@ function ExportAsCodeRenderer({ model }: SceneComponentProps) { return (
        -

        - - Copy or download a file containing the definition of your dashboard - -

        - {config.featureToggles.kubernetesDashboards ? ( ; + +const selector = e2eSelectors.pages.ExportDashboardDrawer.ExportAsJson; + +const createDefaultProps = (overrides?: Partial[0]>) => { + const defaultProps: Parameters[0] = { + dashboardJson: { + loading: false, + value: { + json: { title: 'Test Dashboard' } as Dashboard, + hasLibraryPanels: false, + initialSaveModelVersion: 'v1', + }, + } as DashboardJsonState, + isSharingExternally: false, + exportMode: ExportMode.Classic, + isViewingYAML: false, + onExportModeChange: jest.fn(), + onShareExternallyChange: jest.fn(), + onViewYAML: jest.fn(), + }; + + return { ...defaultProps, ...overrides }; +}; + +const createV2DashboardJson = (hasLibraryPanels = false): DashboardJsonState => ({ + loading: false, + value: { + json: { + title: 'Test V2 Dashboard', + spec: { + elements: {}, + }, + } as unknown as DashboardV2Spec, + hasLibraryPanels, + initialSaveModelVersion: 'v2', + }, +}); + +const expandOptions = async () => { + const button = screen.getByRole('button', { expanded: false }); + await userEvent.click(button); +}; + +describe('ResourceExport', () => { + describe('export mode options for v1 dashboard', () => { + it('should show three export mode options in correct order: Classic, V1 Resource, V2 Resource', async () => { + render(); + await expandOptions(); + + const radioGroup = screen.getByRole('radiogroup', { name: /model/i }); + const labels = within(radioGroup) + .getAllByRole('radio') + .map((radio) => radio.parentElement?.textContent?.trim()); + + expect(labels).toHaveLength(3); + expect(labels).toEqual(['Classic', 'V1 Resource', 'V2 Resource']); + }); + + it('should have first option selected by default when exportMode is Classic', async () => { + render(); + await expandOptions(); + + const radioGroup = screen.getByRole('radiogroup', { name: /model/i }); + const radios = within(radioGroup).getAllByRole('radio'); + expect(radios[0]).toBeChecked(); + }); + + it('should call onExportModeChange when export mode is changed', async () => { + const onExportModeChange = jest.fn(); + render(); + await expandOptions(); + + const radioGroup = screen.getByRole('radiogroup', { name: /model/i }); + const radios = within(radioGroup).getAllByRole('radio'); + await userEvent.click(radios[1]); // V1 Resource + expect(onExportModeChange).toHaveBeenCalledWith(ExportMode.V1Resource); + }); + }); + + describe('export mode options for v2 dashboard', () => { + it('should not show export mode options', async () => { + render(); + await expandOptions(); + + expect(screen.queryByRole('radiogroup', { name: /model/i })).not.toBeInTheDocument(); + }); + }); + + describe('format options', () => { + it('should not show format options when export mode is Classic', async () => { + render(); + await expandOptions(); + + expect(screen.getByRole('radiogroup', { name: /model/i })).toBeInTheDocument(); + expect(screen.queryByRole('radiogroup', { name: /format/i })).not.toBeInTheDocument(); + }); + + it.each([ExportMode.V1Resource, ExportMode.V2Resource])( + 'should show format options when export mode is %s', + async (exportMode) => { + render(); + await expandOptions(); + + expect(screen.getByRole('radiogroup', { name: /model/i })).toBeInTheDocument(); + expect(screen.getByRole('radiogroup', { name: /format/i })).toBeInTheDocument(); + } + ); + + it('should have first format option selected when isViewingYAML is false', async () => { + render(); + await expandOptions(); + + const formatGroup = screen.getByRole('radiogroup', { name: /format/i }); + const formatRadios = within(formatGroup).getAllByRole('radio'); + expect(formatRadios[0]).toBeChecked(); // JSON + }); + + it('should have second format option selected when isViewingYAML is true', async () => { + render(); + await expandOptions(); + + const formatGroup = screen.getByRole('radiogroup', { name: /format/i }); + const formatRadios = within(formatGroup).getAllByRole('radio'); + expect(formatRadios[1]).toBeChecked(); // YAML + }); + + it('should call onViewYAML when format is changed', async () => { + const onViewYAML = jest.fn(); + render(); + await expandOptions(); + + const formatGroup = screen.getByRole('radiogroup', { name: /format/i }); + const formatRadios = within(formatGroup).getAllByRole('radio'); + await userEvent.click(formatRadios[1]); // YAML + expect(onViewYAML).toHaveBeenCalled(); + }); + }); + + describe('share externally switch', () => { + it('should show share externally switch for Classic mode', () => { + render(); + + expect(screen.getByTestId(selector.exportExternallyToggle)).toBeInTheDocument(); + }); + + it('should show share externally switch for V2Resource mode with V2 dashboard', () => { + render( + + ); + + expect(screen.getByTestId(selector.exportExternallyToggle)).toBeInTheDocument(); + }); + + it('should call onShareExternallyChange when switch is toggled', async () => { + const onShareExternallyChange = jest.fn(); + render(); + + const switchElement = screen.getByTestId(selector.exportExternallyToggle); + await userEvent.click(switchElement); + expect(onShareExternallyChange).toHaveBeenCalled(); + }); + + it('should reflect isSharingExternally value in switch', () => { + render(); + + expect(screen.getByTestId(selector.exportExternallyToggle)).toBeChecked(); + }); + }); +}); diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx index b58bf8377af..356175cd77f 100644 --- a/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx @@ -4,7 +4,8 @@ import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { Dashboard } from '@grafana/schema'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; -import { Alert, Label, RadioButtonGroup, Stack, Switch } from '@grafana/ui'; +import { Alert, Icon, Label, RadioButtonGroup, Stack, Switch, Box, Tooltip } from '@grafana/ui'; +import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow'; import { DashboardJson } from 'app/features/manage-dashboards/types'; import { ExportableResource } from '../ShareExportTab'; @@ -48,80 +49,90 @@ export function ResourceExport({ const switchExportLabel = exportMode === ExportMode.V2Resource - ? t('export.json.export-remove-ds-refs', 'Remove deployment details') - : t('share-modal.export.share-externally-label', `Export for sharing externally`); + ? t('dashboard-scene.resource-export.share-externally', 'Share dashboard with another instance') + : t('share-modal.export.share-externally-label', 'Export for sharing externally'); + const switchExportTooltip = t( + 'dashboard-scene.resource-export.share-externally-tooltip', + 'Removes all instance-specific metadata and data source references from the resource before export.' + ); const switchExportModeLabel = t('export.json.export-mode', 'Model'); const switchExportFormatLabel = t('export.json.export-format', 'Format'); + const exportResourceOptions = [ + { + label: t('dashboard-scene.resource-export.label.classic', 'Classic'), + value: ExportMode.Classic, + }, + { + label: t('dashboard-scene.resource-export.label.v1-resource', 'V1 Resource'), + value: ExportMode.V1Resource, + }, + { + label: t('dashboard-scene.resource-export.label.v2-resource', 'V2 Resource'), + value: ExportMode.V2Resource, + }, + ]; + return ( - - - {initialSaveModelVersion === 'v1' && ( - - - onExportModeChange(value)} - /> + <> + + + + {initialSaveModelVersion === 'v1' && ( + + + onExportModeChange(value)} + aria-label={switchExportModeLabel} + /> + + )} + + {exportMode !== ExportMode.Classic && ( + + + + + )} - )} - {initialSaveModelVersion === 'v2' && ( - - - onExportModeChange(value)} - /> - - )} - {exportMode !== ExportMode.Classic && ( - - - - - )} - {(isV2Dashboard || - exportMode === ExportMode.Classic || - (initialSaveModelVersion === 'v2' && exportMode === ExportMode.V1Resource)) && ( - - - - - )} - + + + + {(isV2Dashboard || + exportMode === ExportMode.Classic || + (initialSaveModelVersion === 'v2' && exportMode === ExportMode.V1Resource)) && ( + + + + + )} {showV2LibPanelAlert && ( Due to limitations in the new dashboard schema (V2), library panels will be converted to regular panels with @@ -137,6 +149,6 @@ export function ResourceExport({ )} - + ); } diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx index c049579fb38..5c618b18b16 100644 --- a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx @@ -1,11 +1,11 @@ import { render, screen } from '@testing-library/react'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; import { SceneTimeRange, VizPanel } from '@grafana/scenes'; import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction } from 'app/types/accessControl'; -import { config } from '../../../../core/config'; import { grantUserPermissions } from '../../../alerting/unified/mocks'; import { DashboardScene, DashboardSceneState } from '../../scene/DashboardScene'; import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGridLayoutManager'; diff --git a/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.tsx b/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.tsx index 80a880a04c9..9b90ccdb030 100644 --- a/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.tsx @@ -66,7 +66,12 @@ function ShareDrawerRenderer({ model }: SceneComponentProps) { const dashboard = getDashboardSceneFor(model); return ( - + {activeShare && } diff --git a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx index 0e9d6ad49c5..4bf62bbce73 100644 --- a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx @@ -23,6 +23,7 @@ import { DashboardDataDTO } from 'app/types/dashboard'; import { DashboardScene } from '../scene/DashboardScene'; import { makeExportableV1, makeExportableV2 } from '../scene/export/exporters'; +import { createV2RowsLayout, transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; import { transformSceneToSaveModelSchemaV2 } from '../serialization/transformSceneToSaveModelSchemaV2'; import { getVariablesCompatibility } from '../utils/getVariablesCompatibility'; @@ -65,6 +66,10 @@ export class ShareExportTab extends SceneObjectBase impleme return t('share-modal.tab-title.export', 'Export'); } + public getSubtitle(): string | undefined { + return undefined; + } + public onShareExternallyChange = () => { this.setState({ isSharingExternally: !this.state.isSharingExternally, @@ -216,7 +221,27 @@ export class ShareExportTab extends SceneObjectBase impleme } if (exportMode === ExportMode.V2Resource) { - const spec = transformSceneToSaveModelSchemaV2(scene); + let sceneForV2Export = scene; + + // When exporting v1 dashboard as v2, we need to recreate the scene with v2 layout creator + // to ensure rows are properly serialized. The v1 scene uses DefaultGridLayoutManager which + // doesn't know about RowsLayoutManager structure needed for v2 serialization. + if (initialSaveModelVersion === 'v1' && initialSaveModel && isV1ClassicDashboard(initialSaveModel)) { + // Recreate scene with v2 layout creator to properly handle rows + sceneForV2Export = transformSaveModelToScene( + { + dashboard: { ...initialSaveModel, title: initialSaveModel.title ?? '', uid: initialSaveModel.uid ?? '' }, + meta: scene.state.meta, + }, + undefined, + { + createLayout: createV2RowsLayout, + targetVersion: 'v2', + } + ); + } + + const spec = transformSceneToSaveModelSchemaV2(sceneForV2Export); const specCopy = JSON.parse(JSON.stringify(spec)); const statelessSpec = await makeExportableV2(specCopy, isSharingExternally); const exportableV2 = isSharingExternally ? statelessSpec : spec; diff --git a/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx b/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx index 9c4abbc3a6d..0de2fbed8cf 100644 --- a/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx @@ -6,8 +6,8 @@ import { Trans, t } from '@grafana/i18n'; import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectRef, VizPanel } from '@grafana/scenes'; import { Dashboard } from '@grafana/schema'; import { Button, ClipboardButton, Field, Input, Modal, RadioButtonGroup, Stack } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; import { createSuccessNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils'; import { getDashboardSnapshotSrv, SnapshotSharingOptions } from 'app/features/dashboard/services/SnapshotSrv'; import { dispatch } from 'app/store/store'; diff --git a/public/app/features/dashboard-scene/sharing/types.ts b/public/app/features/dashboard-scene/sharing/types.ts index bf344638d86..4424ae16e66 100644 --- a/public/app/features/dashboard-scene/sharing/types.ts +++ b/public/app/features/dashboard-scene/sharing/types.ts @@ -15,5 +15,6 @@ export interface SceneShareTab void; } diff --git a/public/app/features/dashboard/api/ResponseTransformersToBackend.test.ts b/public/app/features/dashboard/api/ResponseTransformersToBackend.test.ts index 2ca8d2a2411..63fb8016294 100644 --- a/public/app/features/dashboard/api/ResponseTransformersToBackend.test.ts +++ b/public/app/features/dashboard/api/ResponseTransformersToBackend.test.ts @@ -2,6 +2,7 @@ import { readdirSync, readFileSync } from 'fs'; import path from 'path'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import { getSceneCreationOptions } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager'; import { normalizeBackendOutputForFrontendComparison } from 'app/features/dashboard-scene/serialization/serialization-test-utils'; import { transformSaveModelSchemaV2ToScene } from 'app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene'; import { transformSaveModelToScene } from 'app/features/dashboard-scene/serialization/transformSaveModelToScene'; @@ -193,22 +194,26 @@ describe('V1 to V2 Dashboard Transformation Comparison (ResponseTransformers)', delete dashboardSpec.snapshot; // Wrap in DashboardDTO structure that transformSaveModelToScene expects - const scene = transformSaveModelToScene({ - dashboard: dashboardSpec, - meta: { - isNew: false, - isFolder: false, - canSave: true, - canEdit: true, - canDelete: false, - canShare: false, - canStar: false, - canAdmin: false, - isSnapshot: false, - provisioned: false, - version: 1, + const scene = transformSaveModelToScene( + { + dashboard: dashboardSpec, + meta: { + isNew: false, + isFolder: false, + canSave: true, + canEdit: true, + canDelete: false, + canShare: false, + canStar: false, + canAdmin: false, + isSnapshot: false, + provisioned: false, + version: 1, + }, }, - }); + undefined, + getSceneCreationOptions() + ); const frontendOutput = transformSceneToSaveModelSchemaV2(scene, false); diff --git a/public/app/features/dashboard/api/publicDashboardApi.ts b/public/app/features/dashboard/api/publicDashboardApi.ts index 59e9f81fef0..e6c9e77a852 100644 --- a/public/app/features/dashboard/api/publicDashboardApi.ts +++ b/public/app/features/dashboard/api/publicDashboardApi.ts @@ -3,8 +3,8 @@ import { createApi } from '@reduxjs/toolkit/query/react'; import { createBaseQuery } from '@grafana/api-clients/rtkq'; import { t } from '@grafana/i18n'; import { config, FetchError, isFetchError } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { PublicDashboard, PublicDashboardSettings, diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index be0d9cfcaea..394471cd770 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -14,7 +14,6 @@ import { ToolbarButtonRow, ConfirmModal, } from '@grafana/ui'; -import { updateNavIndex } from 'app/core/actions'; import { appEvents } from 'app/core/app_events'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator'; @@ -22,7 +21,7 @@ import config from 'app/core/config'; import { useAppNotification } from 'app/core/copy/appNotification'; import { useBusEvent } from 'app/core/hooks/useBusEvent'; import { ID_PREFIX, setStarred } from 'app/core/reducers/navBarTree'; -import { removeNavIndex } from 'app/core/reducers/navModel'; +import { removeNavIndex, updateNavIndex } from 'app/core/reducers/navModel'; import AddPanelButton from 'app/features/dashboard/components/AddPanelButton/AddPanelButton'; import { SaveDashboardDrawer } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx index 7363aff85f8..93119592712 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx @@ -24,6 +24,7 @@ import { appEvents } from 'app/core/app_events'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { Page } from 'app/core/components/Page/Page'; import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { SubMenuItems } from 'app/features/dashboard/components/SubMenu/SubMenuItems'; import { SaveLibraryPanelModal } from 'app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal'; import { PanelModelWithLibraryPanel } from 'app/features/library-panels/types'; @@ -32,7 +33,6 @@ import { updateTimeZoneForSession } from 'app/features/profile/state/reducers'; import { PanelOptionsChangedEvent, ShowModalReactEvent } from 'app/types/events'; import { StoreState } from 'app/types/store'; -import { notifyApp } from '../../../../core/actions'; import { UnlinkModal } from '../../../dashboard-scene/scene/UnlinkModal'; import { isPanelModelLibraryPanel } from '../../../library-panels/guard'; import { getVariablesByKey } from '../../../variables/state/selectors'; diff --git a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx index 4c701cbda15..55df40308cf 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; import { t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; import { Modal, ModalTabsHeader, TabContent, Themeable2, withTheme2 } from '@grafana/ui'; -import { config } from 'app/core/config'; import { contextSrv } from 'app/core/services/context_srv'; import { SharePublicDashboard } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard'; import { isPublicDashboardsEnabled } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils'; diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index b1ae5811f24..abd41458b64 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -8,10 +8,10 @@ import { createTheme } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config, setDataSourceSrv } from '@grafana/runtime'; import { Dashboard } from '@grafana/schema'; -import { notifyApp } from 'app/core/actions'; import { AppChrome } from 'app/core/components/AppChrome/AppChrome'; import { getRouteComponentProps } from 'app/core/navigation/mocks/routeProps'; import { RouteDescriptor } from 'app/core/navigation/types'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { HOME_NAV_ID } from 'app/core/reducers/navModel'; import { DashboardInitPhase, DashboardMeta, DashboardRoutes } from 'app/types/dashboard'; diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 8733fe0311d..4b0b9ed1d3d 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -6,13 +6,13 @@ import { NavModel, NavModelItem, TimeRange, PageLayoutType, locationUtil, Grafan import { selectors } from '@grafana/e2e-selectors'; import { locationService } from '@grafana/runtime'; import { Themeable2, withTheme2 } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; import { ScrollRefElement } from 'app/core/components/NativeScrollbar'; import { Page } from 'app/core/components/Page/Page'; import { GrafanaContext, GrafanaContextType } from 'app/core/context/GrafanaContext'; import { createErrorNotification } from 'app/core/copy/appNotification'; import { getKioskMode } from 'app/core/navigation/kiosk'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { ID_PREFIX } from 'app/core/reducers/navBarTree'; import { getNavModel } from 'app/core/selectors/navModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts index 05c20ee1d9f..40ed686f350 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts @@ -1,8 +1,8 @@ import { PanelModel } from '@grafana/data'; import { t } from '@grafana/i18n'; import { locationService } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { DataSourceInput } from 'app/features/manage-dashboards/state/reducers'; import { DashboardJson } from 'app/features/manage-dashboards/types'; import { dispatch } from 'app/types/store'; diff --git a/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.test.tsx b/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.test.tsx index 48358aa563c..550869201f1 100644 --- a/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.test.tsx +++ b/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.test.tsx @@ -4,7 +4,7 @@ const mockPushMeasurement = jest.fn(); import { PanelLoadTimeMonitor } from './PanelLoadTimeMonitor'; -jest.mock('app/core/config', () => ({ +jest.mock('@grafana/runtime', () => ({ config: { grafanaJavascriptAgent: { enabled: true, diff --git a/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.tsx b/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.tsx index 230add25c6a..2cda7cce69d 100644 --- a/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.tsx +++ b/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { faro } from '@grafana/faro-web-sdk'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import { PanelLogEvents } from 'app/core/log_events'; interface Props { diff --git a/public/app/features/dashboard/dashgrid/panelOptionsLogger.test.ts b/public/app/features/dashboard/dashgrid/panelOptionsLogger.test.ts index 95a43c75cad..7c55a8842a2 100644 --- a/public/app/features/dashboard/dashgrid/panelOptionsLogger.test.ts +++ b/public/app/features/dashboard/dashgrid/panelOptionsLogger.test.ts @@ -12,7 +12,7 @@ jest.mock('@grafana/faro-web-sdk', () => ({ }, })); -jest.mock('app/core/config', () => ({ +jest.mock('@grafana/runtime', () => ({ config: { grafanaJavascriptAgent: { enabled: true, diff --git a/public/app/features/dashboard/dashgrid/panelOptionsLogger.ts b/public/app/features/dashboard/dashgrid/panelOptionsLogger.ts index 0e75d7fdad1..6c994f924cf 100644 --- a/public/app/features/dashboard/dashgrid/panelOptionsLogger.ts +++ b/public/app/features/dashboard/dashgrid/panelOptionsLogger.ts @@ -1,6 +1,6 @@ import { FieldConfigSource } from '@grafana/data'; import { faro } from '@grafana/faro-web-sdk'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import { FIELD_CONFIG_CUSTOM_KEY, FIELD_CONFIG_OVERRIDES_KEY, PanelLogEvents } from 'app/core/log_events'; interface PanelLogInfo { diff --git a/public/app/features/dashboard/routes.ts b/public/app/features/dashboard/routes.ts index 57702d1a35d..8cd75418deb 100644 --- a/public/app/features/dashboard/routes.ts +++ b/public/app/features/dashboard/routes.ts @@ -1,7 +1,7 @@ +import { config } from '@grafana/runtime'; import { DashboardRoutes } from 'app/types/dashboard'; import { SafeDynamicImport } from '../../core/components/DynamicImports/SafeDynamicImport'; -import { config } from '../../core/config'; import { RouteDescriptor } from '../../core/navigation/types'; export const getPublicDashboardRoutes = (): RouteDescriptor[] => { diff --git a/public/app/features/dashboard/services/TimeSrv.ts b/public/app/features/dashboard/services/TimeSrv.ts index fb8f346a267..2c819f9018f 100644 --- a/public/app/features/dashboard/services/TimeSrv.ts +++ b/public/app/features/dashboard/services/TimeSrv.ts @@ -13,10 +13,9 @@ import { dateTimeForTimeZone, } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { locationService } from '@grafana/runtime'; +import { config, locationService } from '@grafana/runtime'; import { sceneGraph } from '@grafana/scenes'; import { appEvents } from 'app/core/app_events'; -import { config } from 'app/core/config'; import { AutoRefreshInterval, contextSrv, ContextSrv } from 'app/core/services/context_srv'; import { getCopiedTimeRange, diff --git a/public/app/features/dashboard/state/DashboardMigrator.test.ts b/public/app/features/dashboard/state/DashboardMigrator.test.ts index acdc306fc87..6c4551f9158 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.test.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.test.ts @@ -2,8 +2,8 @@ import { each, map } from 'lodash'; import { DataLinkBuiltInVars, MappingType, VariableHide } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test'; +import { config } from '@grafana/runtime'; import { FieldConfigSource } from '@grafana/schema'; -import { config } from 'app/core/config'; import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants'; import { mockDataSource } from 'app/features/alerting/unified/mocks'; import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index 0395c6e9201..3d882c4d4c1 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -1,8 +1,8 @@ import { TimeZone } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; import { WeekStart } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; import { createSuccessNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { removeAllPanels } from 'app/features/panel/state/reducers'; diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 86d64138dee..c2a02bca86b 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -1,9 +1,9 @@ import { DataQuery, locationUtil, setWeekStart, DashboardLoadedEvent } from '@grafana/data'; import { t } from '@grafana/i18n'; import { config, isFetchError, locationService } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; import { appEvents } from 'app/core/app_events'; import { createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { backendSrv } from 'app/core/services/backend_srv'; import { KeybindingSrv } from 'app/core/services/keybindingSrv'; import store from 'app/core/store'; diff --git a/public/app/features/dashboard/utils/loadSnapshotData.ts b/public/app/features/dashboard/utils/loadSnapshotData.ts index 2be1206ec12..d44e70e3ffb 100644 --- a/public/app/features/dashboard/utils/loadSnapshotData.ts +++ b/public/app/features/dashboard/utils/loadSnapshotData.ts @@ -6,7 +6,7 @@ import { LoadingState, PanelData, } from '@grafana/data'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import { SnapshotWorker } from '../../query/state/DashboardQueryRunner/SnapshotWorker'; import { getTimeSrv } from '../services/TimeSrv'; diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts index 961c78c8208..fa9db9f7abe 100644 --- a/public/app/features/datasources/state/actions.ts +++ b/public/app/features/datasources/state/actions.ts @@ -17,8 +17,8 @@ import { isFetchError, locationService, } from '@grafana/runtime'; -import { updateNavIndex } from 'app/core/actions'; import { appEvents } from 'app/core/app_events'; +import { updateNavIndex } from 'app/core/reducers/navModel'; import { getBackendSrv } from 'app/core/services/backend_srv'; import { contextSrv } from 'app/core/services/context_srv'; import { DatasourceAPIVersions } from 'app/features/apiserver/client'; diff --git a/public/app/features/datasources/state/buildCategories.test.ts b/public/app/features/datasources/state/buildCategories.test.ts index 3231a029ff7..da64a6b412d 100644 --- a/public/app/features/datasources/state/buildCategories.test.ts +++ b/public/app/features/datasources/state/buildCategories.test.ts @@ -53,7 +53,7 @@ describe('buildCategories', () => { it('should add enterprise phantom plugins', () => { const enterprisePluginsCategory = categories[3]; expect(enterprisePluginsCategory.title).toBe('Enterprise plugins'); - expect(enterprisePluginsCategory.plugins.length).toBe(31); + expect(enterprisePluginsCategory.plugins.length).toBe(32); expect(enterprisePluginsCategory.plugins[0].name).toBe('Adobe Analytics'); expect(enterprisePluginsCategory.plugins[enterprisePluginsCategory.plugins.length - 1].name).toBe('Zendesk'); }); diff --git a/public/app/features/datasources/state/buildCategories.ts b/public/app/features/datasources/state/buildCategories.ts index 7aeeed6abb4..1be01fae638 100644 --- a/public/app/features/datasources/state/buildCategories.ts +++ b/public/app/features/datasources/state/buildCategories.ts @@ -13,6 +13,7 @@ import catchpointSvg from 'img/plugins/catchpoint.svg'; import cloudflareJpg from 'img/plugins/cloudflare.jpg'; import cockroachdbJpg from 'img/plugins/cockroachdb.jpg'; import datadogPng from 'img/plugins/datadog.png'; +import db2Svg from 'img/plugins/db2.svg'; import droneSvg from 'img/plugins/drone.svg'; import dynatracePng from 'img/plugins/dynatrace.png'; import gitlabSvg from 'img/plugins/gitlab.svg'; @@ -418,6 +419,12 @@ function getEnterprisePhantomPlugins(): DataSourcePluginMeta[] { name: 'SolarWinds', imgUrl: solarWindsSvg, }), + getPhantomPlugin({ + id: 'grafana-ibmdb2-datasource', + description: t('datasources.get-enterprise-phantom-plugins.description.ibmdb2-datasource', 'IBM Db2 data source'), + name: 'IBM Db2', + imgUrl: db2Svg, + }), ]; } diff --git a/public/app/features/dimensions/editors/ResourcePickerPopover.tsx b/public/app/features/dimensions/editors/ResourcePickerPopover.tsx index bfb35f9e717..6187974620f 100644 --- a/public/app/features/dimensions/editors/ResourcePickerPopover.tsx +++ b/public/app/features/dimensions/editors/ResourcePickerPopover.tsx @@ -6,9 +6,8 @@ import { useRef, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans } from '@grafana/i18n'; -import { getBackendSrv } from '@grafana/runtime'; +import { config, getBackendSrv } from '@grafana/runtime'; import { Button, useStyles2 } from '@grafana/ui'; -import { config } from 'app/core/config'; import { MediaType, PickerTabType, ResourceFolderName } from '../types'; diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 1ff012f3fee..6d3c0a90739 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -73,7 +73,7 @@ import { contentOutlineTrackUnpinClicked, } from '../ContentOutline/ContentOutlineAnalyticEvents'; import { useContentOutlineContext } from '../ContentOutline/ContentOutlineContext'; -import { getUrlStateFromPaneState } from '../hooks/useStateSync'; +import { getUrlStateFromPaneState } from '../hooks/useStateSync/external.utils'; import { changePanelState } from '../state/explorePane'; import { changeQueries, runQueries } from '../state/query'; @@ -149,9 +149,6 @@ const getDefaultVisualisationType = (): LogsVisualisationType => { if (visualisationType === 'logs') { return 'logs'; } - if (config.featureToggles.logsExploreTableDefaultVisualization) { - return 'table'; - } return 'logs'; }; @@ -447,7 +444,6 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { reportInteraction('grafana_explore_logs_visualisation_changed', { newVisualizationType: visualisation, datasourceType: props.datasourceType ?? 'unknown', - defaultVisualisationType: config.featureToggles.logsExploreTableDefaultVisualization ? 'table' : 'logs', }); }, [panelState?.logs, props.datasourceType, updatePanelState] diff --git a/public/app/features/explore/Logs/LogsTableActionButtons.tsx b/public/app/features/explore/Logs/LogsTableActionButtons.tsx index f60d5eaa072..e9f3acde38d 100644 --- a/public/app/features/explore/Logs/LogsTableActionButtons.tsx +++ b/public/app/features/explore/Logs/LogsTableActionButtons.tsx @@ -12,7 +12,7 @@ import { import { t } from '@grafana/i18n'; import { ClipboardButton, CustomCellRendererProps, IconButton, Modal, useTheme2 } from '@grafana/ui'; import { getLogsPermalinkRange } from 'app/core/utils/shortLinks'; -import { getUrlStateFromPaneState } from 'app/features/explore/hooks/useStateSync'; +import { getUrlStateFromPaneState } from 'app/features/explore/hooks/useStateSync/external.utils'; import { LogsFrame, DATAPLANE_ID_NAME } from 'app/features/logs/logsFrame'; import { getState } from 'app/store/store'; diff --git a/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx b/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx index ebd005f595b..6a00d567a41 100644 --- a/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx +++ b/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx @@ -3,10 +3,9 @@ import { memo, useState } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { applyFieldOverrides, DataFrame, SelectableValue, SplitOpen } from '@grafana/data'; -import { getTemplateSrv, reportInteraction } from '@grafana/runtime'; +import { config, getTemplateSrv, reportInteraction } from '@grafana/runtime'; import { TimeZone } from '@grafana/schema'; import { RadioButtonGroup, Table, AdHocFilterItem, PanelChrome } from '@grafana/ui'; -import { config } from 'app/core/config'; import { PANEL_BORDER } from 'app/core/constants'; import { ExploreItemState, TABLE_RESULTS_STYLE, TABLE_RESULTS_STYLES, TableResultsStyle } from 'app/types/explore'; import { StoreState } from 'app/types/store'; diff --git a/public/app/features/explore/RichHistory/RichHistoryCard.tsx b/public/app/features/explore/RichHistory/RichHistoryCard.tsx index 4c9353254e0..421e4f35294 100644 --- a/public/app/features/explore/RichHistory/RichHistoryCard.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryCard.tsx @@ -8,8 +8,8 @@ import { Trans, t } from '@grafana/i18n'; import { config, reportInteraction, getAppEvents } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { TextArea, Button, IconButton, useStyles2 } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; import { createSuccessNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { copyStringToClipboard } from 'app/core/utils/explore'; import { createUrlFromRichHistory, createQueryText } from 'app/core/utils/richHistory'; import { createAndCopyShortLink } from 'app/core/utils/shortLinks'; diff --git a/public/app/features/explore/RichHistory/RichHistorySettingsTab.tsx b/public/app/features/explore/RichHistory/RichHistorySettingsTab.tsx index 05942398898..696e5d204ae 100644 --- a/public/app/features/explore/RichHistory/RichHistorySettingsTab.tsx +++ b/public/app/features/explore/RichHistory/RichHistorySettingsTab.tsx @@ -4,9 +4,9 @@ import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { getAppEvents } from '@grafana/runtime'; import { useStyles2, Select, Button, Field, InlineField, InlineSwitch, Alert } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; import { createSuccessNotification } from 'app/core/copy/appNotification'; import { MAX_HISTORY_ITEMS } from 'app/core/history/RichHistoryLocalStorage'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { dispatch } from 'app/store/store'; import { supportedFeatures } from '../../../core/history/richHistoryStorageProvider'; diff --git a/public/app/features/explore/Table/TableContainer.tsx b/public/app/features/explore/Table/TableContainer.tsx index 2c1614e539d..7283711e4ce 100644 --- a/public/app/features/explore/Table/TableContainer.tsx +++ b/public/app/features/explore/Table/TableContainer.tsx @@ -13,10 +13,9 @@ import { EventBusSrv, } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { getTemplateSrv, PanelRenderer } from '@grafana/runtime'; +import { config, getTemplateSrv, PanelRenderer } from '@grafana/runtime'; import { TimeZone } from '@grafana/schema'; import { AdHocFilterItem, PanelChrome, withTheme2, Themeable2, PanelContextProvider } from '@grafana/ui'; -import { config } from 'app/core/config'; import { hasDeprecatedParentRowIndex, migrateFromParentRowIndexToNestedFrames, 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 fb735991a3d..037a4a3c0c2 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.test.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.test.tsx @@ -22,7 +22,7 @@ import { PluginExtensionPoints, PluginExtensionTypes, } from '@grafana/data'; -import { usePluginLinks, usePluginComponents } from '@grafana/runtime'; +import { usePluginLinks, usePluginComponents, config } from '@grafana/runtime'; import { DEFAULT_SPAN_FILTERS } from 'app/features/explore/state/constants'; import { TraceViewPluginExtensionContext } from '../types/trace'; @@ -47,13 +47,6 @@ jest.mock('app/core/copy/appNotification', () => ({ })), })); -// Mock config -jest.mock('../../../../../core/config', () => ({ - config: { - feedbackLinksEnabled: false, // Default to false to avoid interference with tests - }, -})); - // Mock navigator.clipboard Object.assign(navigator, { clipboard: { @@ -127,6 +120,7 @@ describe('TracePageHeader test', () => { beforeEach(() => { jest.clearAllMocks(); mockWindowOpen.mockClear(); + config.feedbackLinksEnabled = false; // Default to false to avoid interference with tests }); it('should render the new trace header', () => { @@ -438,9 +432,7 @@ describe('TracePageHeader test', () => { }); it('should render feedback button when feedbackLinksEnabled is true', () => { - // Mock config with feedbackLinksEnabled = true - const mockConfig = require('../../../../../core/config'); - mockConfig.config.feedbackLinksEnabled = true; + config.feedbackLinksEnabled = true; setup(); @@ -453,9 +445,7 @@ describe('TracePageHeader test', () => { 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; + config.feedbackLinksEnabled = true; setup(); @@ -469,9 +459,7 @@ describe('TracePageHeader test', () => { }); it('should render feedback button with correct styling and icon', () => { - // Mock config with feedbackLinksEnabled = true - const mockConfig = require('../../../../../core/config'); - mockConfig.config.feedbackLinksEnabled = true; + config.feedbackLinksEnabled = true; setup(); diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx index f0038ff1593..1e0fedade74 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx @@ -26,7 +26,13 @@ import { PluginExtensionPoints, } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { reportInteraction, renderLimitedComponents, usePluginComponents, usePluginLinks } from '@grafana/runtime'; +import { + reportInteraction, + renderLimitedComponents, + usePluginComponents, + usePluginLinks, + config, +} from '@grafana/runtime'; import { AdHocFiltersComboboxRenderer } from '@grafana/scenes'; import { TimeZone } from '@grafana/schema'; import { @@ -46,7 +52,6 @@ import { } from '@grafana/ui'; import { useAppNotification } from 'app/core/copy/appNotification'; -import { config } from '../../../../../core/config'; import { downloadTraceAsJson } from '../../../../inspector/utils/download'; import { ViewRangeTimeUpdate, TUpdateViewRangeTimeFunction, ViewRange } from '../TraceTimelineViewer/types'; import { getHeaderTags, getTraceName } from '../model/trace-viewer'; diff --git a/public/app/features/explore/TraceView/createSpanLink.test.ts b/public/app/features/explore/TraceView/createSpanLink.test.ts index fceb78b0b49..df2972c4483 100644 --- a/public/app/features/explore/TraceView/createSpanLink.test.ts +++ b/public/app/features/explore/TraceView/createSpanLink.test.ts @@ -200,7 +200,7 @@ describe('createSpanLinkFactory', () => { datasource: 'loki1_uid', queries: [ { - expr: '{cluster="cluster1", hostname="hostname1", service_namespace="namespace1"} | label_format log_line_contains_trace_id=`{{ contains "7946b05c2e2e4e5a" __line__ }}` | log_line_contains_trace_id="true" or trace_id="7946b05c2e2e4e5a" | label_format log_line_contains_span_id=`{{ contains "6605c7b08e715d6c" __line__ }}` | log_line_contains_span_id="true" or span_id="6605c7b08e715d6c"', + expr: '{cluster="cluster1", hostname="hostname1", service_namespace="namespace1"} | logfmt | json | drop __error__, __error_details__ | trace_id="7946b05c2e2e4e5a" | span_id="6605c7b08e715d6c"', refId: '', datasource: { uid: 'loki1_uid' }, }, diff --git a/public/app/features/explore/TraceView/createSpanLink.tsx b/public/app/features/explore/TraceView/createSpanLink.tsx index 4feed49e07d..1b7cbc83054 100644 --- a/public/app/features/explore/TraceView/createSpanLink.tsx +++ b/public/app/features/explore/TraceView/createSpanLink.tsx @@ -446,12 +446,12 @@ function getQueryForLoki( let expr = '{${__tags}}'; if (filterByTraceID && span.traceID) { - expr += - ' | label_format log_line_contains_trace_id=`{{ contains "${__span.traceId}" __line__ }}` | log_line_contains_trace_id="true" or trace_id="${__span.traceId}"'; - } - if (filterBySpanID && span.spanID) { - expr += - ' | label_format log_line_contains_span_id=`{{ contains "${__span.spanId}" __line__ }}` | log_line_contains_span_id="true" or span_id="${__span.spanId}"'; + expr += ' | logfmt | json | drop __error__, __error_details__ | trace_id="${__span.traceId}"'; + if (filterBySpanID && span.spanID) { + expr += ' | span_id="${__span.spanId}"'; + } + } else if (filterBySpanID && span.spanID) { + expr += ' | logfmt | json | drop __error__, __error_details__ | span_id="${__span.spanId}"'; } return { diff --git a/public/app/features/explore/hooks/useKeyboardShortcuts.ts b/public/app/features/explore/hooks/useKeyboardShortcuts.ts index 9ff1e32e5ed..47487690878 100644 --- a/public/app/features/explore/hooks/useKeyboardShortcuts.ts +++ b/public/app/features/explore/hooks/useKeyboardShortcuts.ts @@ -3,9 +3,19 @@ import { Unsubscribable } from 'rxjs'; import { getAppEvents } from '@grafana/runtime'; import { useGrafana } from 'app/core/context/GrafanaContext'; -import { AbsoluteTimeEvent, CopyTimeEvent, PasteTimeEvent, ShiftTimeEvent, ZoomOutEvent } from 'app/types/events'; +import { getState } from 'app/store/store'; +import { + AbsoluteTimeEvent, + CopyTimeEvent, + PasteTimeEvent, + RunQueriesEvent, + ShiftTimeEvent, + ZoomOutEvent, +} from 'app/types/events'; import { useDispatch } from 'app/types/store'; +import { runQueries } from '../state/query'; +import { selectPanesEntries } from '../state/selectors'; import { copyTimeRangeToClipboard, makeAbsoluteTime, @@ -21,8 +31,23 @@ export function useKeyboardShortcuts() { useEffect(() => { keybindings.setupTimeRangeBindings(false); + // Explore-specific: run queries shortcut + keybindings.bind('e r', () => { + getAppEvents().publish(new RunQueriesEvent()); + }); + const tearDown: Unsubscribable[] = []; + tearDown.push( + getAppEvents().subscribe(RunQueriesEvent, () => { + // Read panes at event time to avoid re-subscribing when panes change + const panes = selectPanesEntries(getState()); + panes.forEach(([exploreId]) => { + dispatch(runQueries({ exploreId })); + }); + }) + ); + tearDown.push( getAppEvents().subscribe(AbsoluteTimeEvent, () => { dispatch(makeAbsoluteTime()); @@ -54,6 +79,7 @@ export function useKeyboardShortcuts() { ); return () => { + keybindings.unbind('e r'); tearDown.forEach((u) => u.unsubscribe()); }; }, [dispatch, keybindings]); diff --git a/public/app/features/explore/hooks/useStateSync/index.ts b/public/app/features/explore/hooks/useStateSync/index.ts index 44462fa0d35..264b19e3d56 100644 --- a/public/app/features/explore/hooks/useStateSync/index.ts +++ b/public/app/features/explore/hooks/useStateSync/index.ts @@ -12,8 +12,6 @@ import { syncFromURL } from './synchronizer/fromURL'; import { initializeFromURL } from './synchronizer/init'; import { syncToURL, syncToURLPredicate } from './synchronizer/toURL'; -export { getUrlStateFromPaneState } from './external.utils'; - /** * Bi-directionally syncs URL changes with Explore's state. */ diff --git a/public/app/features/explore/hooks/useStateSync/synchronizer/fromURL.ts b/public/app/features/explore/hooks/useStateSync/synchronizer/fromURL.ts index a7856edc9af..5cc17e4a172 100644 --- a/public/app/features/explore/hooks/useStateSync/synchronizer/fromURL.ts +++ b/public/app/features/explore/hooks/useStateSync/synchronizer/fromURL.ts @@ -11,7 +11,7 @@ import { withUniqueRefIds } from 'app/features/explore/utils/queries'; import { ExploreItemState } from 'app/types/explore'; import { ThunkDispatch } from 'app/types/store'; -import { getUrlStateFromPaneState } from '../index'; +import { getUrlStateFromPaneState } from '../external.utils'; import { urlDiff } from '../internal.utils'; import { ExploreURLV1 } from '../migrators/v1'; diff --git a/public/app/features/explore/hooks/useStateSync/synchronizer/init.ts b/public/app/features/explore/hooks/useStateSync/synchronizer/init.ts index 201355d32b3..097e5549f01 100644 --- a/public/app/features/explore/hooks/useStateSync/synchronizer/init.ts +++ b/public/app/features/explore/hooks/useStateSync/synchronizer/init.ts @@ -11,7 +11,7 @@ import { withUniqueRefIds } from 'app/features/explore/utils/queries'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { ThunkDispatch } from 'app/types/store'; -import { getUrlStateFromPaneState } from '../index'; +import { getUrlStateFromPaneState } from '../external.utils'; import { getDefaultQuery, getPaneDatasource, diff --git a/public/app/features/explore/hooks/useStateSync/synchronizer/toURL.ts b/public/app/features/explore/hooks/useStateSync/synchronizer/toURL.ts index c746d767048..d665592d80d 100644 --- a/public/app/features/explore/hooks/useStateSync/synchronizer/toURL.ts +++ b/public/app/features/explore/hooks/useStateSync/synchronizer/toURL.ts @@ -11,7 +11,7 @@ import { runQueries } from 'app/features/explore/state/query'; import { changeRangeAction } from 'app/features/explore/state/time'; import { ExploreState } from 'app/types/explore'; -import { getUrlStateFromPaneState } from '../index'; +import { getUrlStateFromPaneState } from '../external.utils'; import { InitState } from '../internal.utils'; /* diff --git a/public/app/features/explore/state/correlations.ts b/public/app/features/explore/state/correlations.ts index cc99f6388b7..5adf7868d0b 100644 --- a/public/app/features/explore/state/correlations.ts +++ b/public/app/features/explore/state/correlations.ts @@ -2,8 +2,8 @@ import { Observable } from 'rxjs'; import { DataLinkTransformationConfig } from '@grafana/data'; import { CorrelationData, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { CreateCorrelationParams } from 'app/features/correlations/types'; import { getCorrelationsBySourceUIDs, createCorrelation, generateDefaultLabel } from 'app/features/correlations/utils'; import { store } from 'app/store/store'; diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index 39ad4ea5e73..2f921078135 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -22,6 +22,7 @@ import { import { combinePanelData } from '@grafana/o11y-ds-frontend'; import { config, getDataSourceSrv } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { buildQueryTransaction, ensureQueries, @@ -48,7 +49,6 @@ import { } from 'app/types/explore'; import { createAsyncThunk, StoreState, ThunkDispatch, ThunkResult } from 'app/types/store'; -import { notifyApp } from '../../../core/actions'; import { createErrorNotification } from '../../../core/copy/appNotification'; import { runRequest } from '../../query/state/runRequest'; import { decorateData, decorateWithLogsResult } from '../utils/decorators'; diff --git a/public/app/features/explore/utils/links.ts b/public/app/features/explore/utils/links.ts index 10b2acb3d54..c4644e1a709 100644 --- a/public/app/features/explore/utils/links.ts +++ b/public/app/features/explore/utils/links.ts @@ -30,7 +30,7 @@ import { parseDataplaneLogsFrame } from 'app/features/logs/logsFrame'; import { ExploreItemState } from 'app/types/explore'; import { getLinkSrv } from '../../panel/panellinks/link_srv'; -import { getUrlStateFromPaneState } from '../hooks/useStateSync'; +import { getUrlStateFromPaneState } from '../hooks/useStateSync/external.utils'; type DataLinkFilter = (link: DataLink, scopedVars: ScopedVars) => boolean; diff --git a/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx b/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx index 9e2cfd50b75..433ce7f1d91 100644 --- a/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx +++ b/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, testWithFeatureToggles } from 'test/test-utils'; +import { render, testWithFeatureToggles, userEvent, waitFor } from 'test/test-utils'; import { ExpressionQuery, ExpressionQueryType } from '../../types'; @@ -72,12 +72,12 @@ describe('SqlExpr', () => { const refIds = [{ value: 'A' }]; const query = { refId: 'expr1', type: 'sql', expression: '' } as ExpressionQuery; - await act(async () => { - render(); - }); + render(); // Verify onChange was called - expect(onChange).toHaveBeenCalled(); + await waitFor(() => { + expect(onChange).toHaveBeenCalled(); + }); // Verify essential SQL structure without exact string matching const updatedQuery = onChange.mock.calls[0][0]; @@ -90,19 +90,12 @@ describe('SqlExpr', () => { const existingExpression = 'SELECT 1 AS foo'; const query = { refId: 'expr1', type: 'sql', expression: existingExpression } as ExpressionQuery; - await act(async () => { - render(); - }); - - // Check if onChange was called - if (onChange.mock.calls.length > 0) { - // If called, ensure it didn't change the expression value - const updatedQuery = onChange.mock.calls[0][0]; - expect(updatedQuery.expression).toBe(existingExpression); - } + render(); // The SQLEditor should receive the existing expression - expect(query.expression).toBe(existingExpression); + await waitFor(() => { + expect(query.expression).toBe(existingExpression); + }); }); it('adds alerting format when alerting prop is true', async () => { @@ -110,40 +103,12 @@ describe('SqlExpr', () => { const refIds = [{ value: 'A' }]; const query = { refId: 'expr1', type: 'sql' } as ExpressionQuery; - await act(async () => { - render(); + render(); + + await waitFor(() => { + const updatedQuery = onChange.mock.calls[0][0]; + expect(updatedQuery.format).toBe('alerting'); }); - - const updatedQuery = onChange.mock.calls[0][0]; - expect(updatedQuery.format).toBe('alerting'); - }); -}); - -describe('SqlExpr with GenAI features', () => { - const defaultProps: SqlExprProps = { - onChange: jest.fn(), - refIds: [{ value: 'A' }], - query: { refId: 'expression_1', type: ExpressionQueryType.sql, expression: `SELECT * FROM A LIMIT 10` }, - queries: [], - }; - - it('renders suggestions drawer when isDrawerOpen is true', async () => { - const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions'); - useSQLSuggestions.mockImplementation(() => ({ - isDrawerOpen: true, - suggestions: ['suggestion1', 'suggestion2'], - })); - - const { findByTestId } = render(); - expect(await findByTestId('suggestions-drawer')).toBeInTheDocument(); - }); - - it('renders explanation drawer when isExplanationOpen is true', async () => { - const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations'); - useSQLExplanations.mockImplementation(() => ({ isExplanationOpen: true })); - - const { findByTestId } = render(); - expect(await findByTestId('explanation-drawer')).toBeInTheDocument(); }); }); @@ -166,10 +131,10 @@ describe('Schema Inspector feature toggle', () => { }); }); - it('renders panel open by default', () => { - const { getByText } = render(); + it('renders panel open by default', async () => { + const { findByText } = render(); - expect(getByText('No schema information available')).toBeInTheDocument(); + expect(await findByText('No schema information available')).toBeInTheDocument(); }); it('closes panel and shows reopen button when close button clicked', async () => { @@ -178,7 +143,7 @@ describe('Schema Inspector feature toggle', () => { expect(queryByText('No schema information available')).toBeInTheDocument(); const closeButton = getByText('Schema inspector'); - await act(async () => fireEvent.click(closeButton)); + await userEvent.click(closeButton); expect(queryByText('No schema information available')).not.toBeInTheDocument(); expect(await findByText('Schema inspector')).toBeInTheDocument(); @@ -188,12 +153,12 @@ describe('Schema Inspector feature toggle', () => { const { queryByText, getByText } = render(); const closeButton = getByText('Schema inspector'); - await act(async () => fireEvent.click(closeButton)); + await userEvent.click(closeButton); expect(queryByText('No schema information available')).not.toBeInTheDocument(); const reopenButton = getByText('Schema inspector'); - await act(async () => fireEvent.click(reopenButton)); + await userEvent.click(reopenButton); expect(queryByText('No schema information available')).toBeInTheDocument(); }); @@ -233,3 +198,33 @@ describe('Schema Inspector feature toggle', () => { }); }); }); + +describe('SqlExpr with GenAI features', () => { + const defaultProps: SqlExprProps = { + onChange: jest.fn(), + refIds: [{ value: 'A' }], + query: { refId: 'expression_1', type: ExpressionQueryType.sql, expression: `SELECT * FROM A LIMIT 10` }, + queries: [], + }; + + it('renders suggestions drawer when isDrawerOpen is true', async () => { + // TODO this inline require breaks future tests - do it differently! + const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions'); + useSQLSuggestions.mockImplementation(() => ({ + isDrawerOpen: true, + suggestions: ['suggestion1', 'suggestion2'], + })); + + const { findByTestId } = render(); + expect(await findByTestId('suggestions-drawer')).toBeInTheDocument(); + }); + + it('renders explanation drawer when isExplanationOpen is true', async () => { + // TODO this inline require breaks future tests - do it differently! + const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations'); + useSQLExplanations.mockImplementation(() => ({ isExplanationOpen: true })); + + const { findByTestId } = render(); + expect(await findByTestId('explanation-drawer')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/expressions/components/SqlExpressions/hooks/useSQLSchemas.test.ts b/public/app/features/expressions/components/SqlExpressions/hooks/useSQLSchemas.test.ts new file mode 100644 index 00000000000..bc975dee05f --- /dev/null +++ b/public/app/features/expressions/components/SqlExpressions/hooks/useSQLSchemas.test.ts @@ -0,0 +1,26 @@ +import { DataQuery } from '@grafana/schema'; +import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants'; + +import { isDashboardDatasource } from './useSQLSchemas'; + +describe('isDashboardDatasource', () => { + it('identifies Dashboard datasource queries in a mixed set', () => { + const queries: DataQuery[] = [ + { refId: 'A', datasource: { uid: 'prometheus-uid', type: 'prometheus' } }, + { refId: 'B', datasource: { uid: SHARED_DASHBOARD_QUERY, type: 'datasource' } }, + { refId: 'C', datasource: { uid: 'mysql-uid', type: 'mysql' } }, + ]; + + const backendQueries = queries.filter((q) => !isDashboardDatasource(q)); + + expect(backendQueries.map((q) => q.refId)).toEqual(['A', 'C']); + }); + + it('returns true when query has dashboard datasource uid', () => { + const query: DataQuery = { + refId: 'A', + datasource: { uid: SHARED_DASHBOARD_QUERY, type: 'datasource' }, + }; + expect(isDashboardDatasource(query)).toBe(true); + }); +}); diff --git a/public/app/features/expressions/components/SqlExpressions/hooks/useSQLSchemas.ts b/public/app/features/expressions/components/SqlExpressions/hooks/useSQLSchemas.ts index fd9f5f41a1e..c662e0039fc 100644 --- a/public/app/features/expressions/components/SqlExpressions/hooks/useSQLSchemas.ts +++ b/public/app/features/expressions/components/SqlExpressions/hooks/useSQLSchemas.ts @@ -4,6 +4,11 @@ import { getAPINamespace } from '@grafana/api-clients'; import { getDefaultTimeRange, TimeRange } from '@grafana/data'; import { config, getBackendSrv } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; +import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants'; + +export function isDashboardDatasource(query: DataQuery): boolean { + return query.datasource?.uid === SHARED_DASHBOARD_QUERY; +} export interface SQLSchemaField { name: string; @@ -61,7 +66,10 @@ export function useSQLSchemas({ queries, enabled, timeRange }: UseSQLSchemasOpti setError(null); try { - if (currentQueries.length === 0) { + // Filter out Dashboard datasource queries - they are frontend-only and can't be processed by backend + const backendQueries = currentQueries.filter((q) => !isDashboardDatasource(q)); + + if (backendQueries.length === 0) { setSchemas({ kind: 'SQLSchemaResponse', apiVersion: 'query.grafana.app/v0alpha1', sqlSchemas: {} }); setLoading(false); return; @@ -73,7 +81,7 @@ export function useSQLSchemas({ queries, enabled, timeRange }: UseSQLSchemasOpti const response = await getBackendSrv().post( `/apis/query.grafana.app/v0alpha1/namespaces/${namespace}/sqlschemas/name`, { - queries: currentQueries, + queries: backendQueries, from: currentTimeRange.from.toISOString(), to: currentTimeRange.to.toISOString(), } diff --git a/public/app/features/expressions/types.ts b/public/app/features/expressions/types.ts index 3a4bd936424..ec83f0e50e2 100644 --- a/public/app/features/expressions/types.ts +++ b/public/app/features/expressions/types.ts @@ -1,5 +1,5 @@ import { DataQuery, ReducerID, SelectableValue } from '@grafana/data'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import { EvalFunction } from '../alerting/state/alertDef'; diff --git a/public/app/features/inspector/InspectDataTab.tsx b/public/app/features/inspector/InspectDataTab.tsx index 8aba53622cc..4e979740c9a 100644 --- a/public/app/features/inspector/InspectDataTab.tsx +++ b/public/app/features/inspector/InspectDataTab.tsx @@ -15,9 +15,8 @@ import { } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; -import { getTemplateSrv, reportInteraction } from '@grafana/runtime'; +import { config, getTemplateSrv, reportInteraction } from '@grafana/runtime'; import { Button, Spinner, Table } from '@grafana/ui'; -import { config } from 'app/core/config'; import { GetDataOptions } from 'app/features/query/state/PanelQueryRunner'; import { dataFrameToLogsModel } from '../logs/logsModel'; diff --git a/public/app/features/inspector/styles.ts b/public/app/features/inspector/styles.ts index 86646950382..65a50dd3816 100644 --- a/public/app/features/inspector/styles.ts +++ b/public/app/features/inspector/styles.ts @@ -1,8 +1,8 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { stylesFactory } from '@grafana/ui'; -import { config } from 'app/core/config'; /** @deprecated */ export const getPanelInspectorStyles = stylesFactory(() => { diff --git a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx index 941dca5416d..8645dfb4c4e 100644 --- a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx +++ b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx @@ -340,14 +340,16 @@ describe('LibraryPanelsSearch', () => { await user.click(screen.getAllByRole('button', { name: 'Delete' })[1]); await waitFor(() => - expect(getLibraryPanelsSpy).toHaveBeenCalledWith({ - searchString: '', - folderFilterUIDs: ['wfTJJL5Wz'], - page: 1, - typeFilter: [], - sortDirection: undefined, - perPage: 40, - }) + expect(getLibraryPanelsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + searchString: '', + folderFilterUIDs: ['wfTJJL5Wz'], + page: 1, + typeFilter: [], + sortDirection: undefined, + perPage: 40, + }) + ) ); }); }); diff --git a/public/app/features/live/centrifuge/service.ts b/public/app/features/live/centrifuge/service.ts index 52194e7a120..6a3858945d9 100644 --- a/public/app/features/live/centrifuge/service.ts +++ b/public/app/features/live/centrifuge/service.ts @@ -3,6 +3,7 @@ import { ConnectedContext, ConnectingContext, DisconnectedContext, + ErrorContext, ServerPublicationContext, State, } from 'centrifuge'; @@ -25,6 +26,7 @@ import { StreamingFrameAction, StreamingFrameOptions, BackendDataSourceResponse, + getBackendSrv, } from '@grafana/runtime'; import { StreamingResponseData } from '../data/utils'; @@ -71,6 +73,7 @@ export class CentrifugeService implements CentrifugeSrv { readonly connectionState: BehaviorSubject; readonly connectionBlocker: Promise; private readonly dataStreamSubscriberReadiness: Observable; + private lastAuthCheck = 0; constructor(private deps: CentrifugeSrvDeps) { this.dataStreamSubscriberReadiness = deps.dataStreamSubscriberReadiness.pipe(share(), startWith(true)); @@ -106,6 +109,7 @@ export class CentrifugeService implements CentrifugeSrv { this.centrifuge.on('connecting', this.onDisconnect); this.centrifuge.on('disconnected', this.onDisconnect); this.centrifuge.on('publication', this.onServerSideMessage); + this.centrifuge.on('error', this.onError); } //---------------------------------------------------------- @@ -124,6 +128,27 @@ export class CentrifugeService implements CentrifugeSrv { console.log('Publication from server-side channel', context); }; + private onError = (context: ErrorContext) => { + /** + * This is a workaround to handle the case where the authentication token + * has expired, but we still try to reconnect inside a page with Grafana Live enabled. + * See: https://github.com/grafana/grafana/issues/72792 + */ + if (context.type === 'transport' && context.error?.code === 2) { + const now = Date.now(); + // Check every 5 seconds to avoid hammering the + // API if there is a case like this + if (now - this.lastAuthCheck > 5000) { + this.lastAuthCheck = now; + getBackendSrv() + .get('/api/login/ping') + .catch(() => { + // Just swallow this error - it's non-critical + }); + } + } + }; + /** * Get a channel. If the scope, namespace, or path is invalid, a shutdown * channel will be returned with an error state indicated in its status diff --git a/public/app/features/logs/components/logParser.test.ts b/public/app/features/logs/components/logParser.test.ts index a132f75b642..f03d628877d 100644 --- a/public/app/features/logs/components/logParser.test.ts +++ b/public/app/features/logs/components/logParser.test.ts @@ -1,6 +1,8 @@ import { DataFrameType, Field, FieldType, LogRowModel, MutableDataFrame } from '@grafana/data'; import { mockTimeRange } from '@grafana/plugin-ui'; +import { setTemplateSrv } from '@grafana/runtime'; import { ExploreFieldLinkModel, getFieldLinksForExplore } from 'app/features/explore/utils/links'; +import { TemplateSrv } from 'app/features/templating/template_srv'; import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; import { getAllFields, createLogLineLinks, FieldDef, getDataframeFields } from './logParser'; @@ -466,6 +468,10 @@ describe('logParser', () => { }); describe('getDataframeFields', () => { + beforeEach(() => { + setTemplateSrv(new TemplateSrv()); + }); + it('should add row labels as variables for links', () => { const row = createLogRow({ labels: { service_name: 'checkout', service_namespace: 'prod' }, diff --git a/public/app/features/logs/components/panel/links.test.ts b/public/app/features/logs/components/panel/links.test.ts index bb3da98fc43..545373fc78a 100644 --- a/public/app/features/logs/components/panel/links.test.ts +++ b/public/app/features/logs/components/panel/links.test.ts @@ -1,6 +1,8 @@ import { FieldType, getDefaultTimeRange, LogsSortOrder, toDataFrame } from '@grafana/data'; +import { setTemplateSrv } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; import { getFieldLinksForExplore } from 'app/features/explore/utils/links'; +import { TemplateSrv } from 'app/features/templating/template_srv'; import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; import { createLogLine } from '../mocks/logRow'; @@ -70,6 +72,7 @@ describe('getTempoTraceFromLinks', () => { wrapLogMessage: true, } ); + setTemplateSrv(new TemplateSrv()); }); test('Gets the trace information from a link', () => { diff --git a/public/app/features/logs/components/panel/processing.test.ts b/public/app/features/logs/components/panel/processing.test.ts index 5998ff36d21..748c53b1df4 100644 --- a/public/app/features/logs/components/panel/processing.test.ts +++ b/public/app/features/logs/components/panel/processing.test.ts @@ -190,6 +190,22 @@ describe('preProcessLogs', () => { expect(logListModel.body).not.toBe(entry); }); + test('Prettifies JSON with duplicate keys', () => { + const entry = '{"key": "value", "key": "otherValue"}'; + const logListModel = createLogLine( + { entry }, + { + escape: false, + order: LogsSortOrder.Descending, + timeZone: 'browser', + wrapLogMessage: true, // wrapped + prettifyJSON: true, + } + ); + expect(logListModel.entry).toBe(entry); + expect(logListModel.body).not.toBe(entry); + }); + test('Prettifies and escapes wrapped JSON', () => { const entry = '{"key": "value", "otherKey": "other\\nValue"}'; const logListModel = createLogLine( diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index 9132c2b7e6e..814481e87f2 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -135,7 +135,9 @@ export class LogListModel implements LogRowModel { get body(): string { if (this._body === undefined) { try { - const parsed = parse(this.raw); + const parsed = parse(this.raw, undefined, { + onDuplicateKey: ({ newValue }) => newValue, + }); if (typeof parsed === 'object' && parsed !== null && !(parsed instanceof LosslessNumber)) { this._json = true; } diff --git a/public/app/features/manage-dashboards/state/actions.ts b/public/app/features/manage-dashboards/state/actions.ts index f897c23cfb9..049a3b920cf 100644 --- a/public/app/features/manage-dashboards/state/actions.ts +++ b/public/app/features/manage-dashboards/state/actions.ts @@ -6,8 +6,8 @@ import { PanelQueryKind, AnnotationQueryKind, } from '@grafana/schema/dist/esm/schema/dashboard/v2'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { browseDashboardsAPI, ImportInputs } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { ThunkResult } from 'app/types/store'; diff --git a/public/app/features/manage-dashboards/utils/validation.ts b/public/app/features/manage-dashboards/utils/validation.ts index 3c591b6f6cb..20bcf0cc56f 100644 --- a/public/app/features/manage-dashboards/utils/validation.ts +++ b/public/app/features/manage-dashboards/utils/validation.ts @@ -18,6 +18,10 @@ export const validateDashboardJson = (json: string) => { if (hasInvalidTag) { return t('dashboard.validation.tags-expected-strings', 'tags expected array of strings'); } + const hasTooLongTag = dashboard.tags.some((tag: string) => tag.length > 50); + if (hasTooLongTag) { + return t('dashboard.validation.tag-too-long', 'Dashboard tag too long, max 50 characters'); + } } else { return t('dashboard.validation.tags-expected-array', 'tags expected array'); } diff --git a/public/app/features/org/state/actions.test.ts b/public/app/features/org/state/actions.test.ts index b624430697a..f3429d8ea0b 100644 --- a/public/app/features/org/state/actions.test.ts +++ b/public/app/features/org/state/actions.test.ts @@ -2,7 +2,7 @@ import { thunkTester } from 'test/core/thunk/thunkTester'; import { OrgRole } from '@grafana/data'; import { BackendSrv } from '@grafana/runtime'; -import { updateConfigurationSubtitle } from 'app/core/actions'; +import { updateConfigurationSubtitle } from 'app/core/reducers/navModel'; import { updateOrganization, setUserOrganization, getUserOrganizations } from './actions'; diff --git a/public/app/features/org/state/actions.ts b/public/app/features/org/state/actions.ts index 1d580c5b3ae..672c2b2bdac 100644 --- a/public/app/features/org/state/actions.ts +++ b/public/app/features/org/state/actions.ts @@ -1,5 +1,5 @@ import { getBackendSrv } from '@grafana/runtime'; -import { updateConfigurationSubtitle } from 'app/core/actions'; +import { updateConfigurationSubtitle } from 'app/core/reducers/navModel'; import { ThunkResult } from 'app/types/store'; import { UserOrg } from 'app/types/user'; diff --git a/public/app/features/panel/components/PanelDataErrorView.test.tsx b/public/app/features/panel/components/PanelDataErrorView.test.tsx index 7040b80418f..c5c4f96e982 100644 --- a/public/app/features/panel/components/PanelDataErrorView.test.tsx +++ b/public/app/features/panel/components/PanelDataErrorView.test.tsx @@ -2,8 +2,9 @@ import { render, screen } from '@testing-library/react'; import { defaultsDeep } from 'lodash'; import { Provider } from 'react-redux'; -import { FieldType, getDefaultTimeRange, LoadingState } from '@grafana/data'; -import { PanelDataErrorViewProps } from '@grafana/runtime'; +import { CoreApp, EventBusSrv, FieldType, getDefaultTimeRange, LoadingState } from '@grafana/data'; +import { config, PanelDataErrorViewProps } from '@grafana/runtime'; +import { usePanelContext } from '@grafana/ui'; import { configureStore } from 'app/store/configureStore'; import { PanelDataErrorView } from './PanelDataErrorView'; @@ -16,7 +17,24 @@ jest.mock('app/features/dashboard/services/DashboardSrv', () => ({ }, })); +jest.mock('@grafana/ui', () => ({ + ...jest.requireActual('@grafana/ui'), + usePanelContext: jest.fn(), +})); + +const mockUsePanelContext = jest.mocked(usePanelContext); +const RUN_QUERY_MESSAGE = 'Run a query to visualize it here or go to all visualizations to add other panel types'; +const panelContextRoot = { + app: CoreApp.Dashboard, + eventsScope: 'global', + eventBus: new EventBusSrv(), +}; + describe('PanelDataErrorView', () => { + beforeEach(() => { + mockUsePanelContext.mockReturnValue(panelContextRoot); + }); + it('show No data when there is no data', () => { renderWithProps(); @@ -70,6 +88,45 @@ describe('PanelDataErrorView', () => { expect(screen.getByText('Query returned nothing')).toBeInTheDocument(); }); + + it('should show "Run a query..." message when no query is configured and feature toggle is enabled', () => { + mockUsePanelContext.mockReturnValue(panelContextRoot); + + const originalFeatureToggle = config.featureToggles.newVizSuggestions; + config.featureToggles.newVizSuggestions = true; + + renderWithProps({ + data: { + state: LoadingState.Done, + series: [], + timeRange: getDefaultTimeRange(), + }, + }); + + expect(screen.getByText(RUN_QUERY_MESSAGE)).toBeInTheDocument(); + + config.featureToggles.newVizSuggestions = originalFeatureToggle; + }); + + it('should show "No data" message when feature toggle is disabled even without queries', () => { + mockUsePanelContext.mockReturnValue(panelContextRoot); + + const originalFeatureToggle = config.featureToggles.newVizSuggestions; + config.featureToggles.newVizSuggestions = false; + + renderWithProps({ + data: { + state: LoadingState.Done, + series: [], + timeRange: getDefaultTimeRange(), + }, + }); + + expect(screen.getByText('No data')).toBeInTheDocument(); + expect(screen.queryByText(RUN_QUERY_MESSAGE)).not.toBeInTheDocument(); + + config.featureToggles.newVizSuggestions = originalFeatureToggle; + }); }); function renderWithProps(overrides?: Partial) { diff --git a/public/app/features/panel/components/PanelDataErrorView.tsx b/public/app/features/panel/components/PanelDataErrorView.tsx index 93723b3bff1..74c0d494c3f 100644 --- a/public/app/features/panel/components/PanelDataErrorView.tsx +++ b/public/app/features/panel/components/PanelDataErrorView.tsx @@ -5,14 +5,15 @@ import { FieldType, getPanelDataSummary, GrafanaTheme2, + PanelData, PanelDataSummary, PanelPluginVisualizationSuggestion, } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { t, Trans } from '@grafana/i18n'; -import { PanelDataErrorViewProps, locationService } from '@grafana/runtime'; +import { PanelDataErrorViewProps, locationService, config } from '@grafana/runtime'; import { VizPanel } from '@grafana/scenes'; -import { usePanelContext, useStyles2 } from '@grafana/ui'; +import { Icon, usePanelContext, useStyles2 } from '@grafana/ui'; import { CardButton } from 'app/core/components/CardButton'; import { LS_VISUALIZATION_SELECT_TAB_KEY } from 'app/core/constants'; import store from 'app/core/store'; @@ -24,6 +25,11 @@ import { findVizPanelByKey, getVizPanelKeyForPanelId } from 'app/features/dashbo import { useDispatch } from 'app/types/store'; import { changePanelPlugin } from '../state/actions'; +import { hasData } from '../suggestions/utils'; + +function hasNoQueryConfigured(data: PanelData): boolean { + return !data.request?.targets || data.request.targets.length === 0; +} export function PanelDataErrorView(props: PanelDataErrorViewProps) { const styles = useStyles2(getStyles); @@ -93,8 +99,14 @@ export function PanelDataErrorView(props: PanelDataErrorViewProps) { } }; + const noData = !hasData(props.data); + const noQueryConfigured = hasNoQueryConfigured(props.data); + const showEmptyState = + config.featureToggles.newVizSuggestions && context.app === CoreApp.PanelEditor && noQueryConfigured && noData; + return (
        + {showEmptyState && }
        {message}
        @@ -131,7 +143,17 @@ function getMessageFor( return message; } - if (!data.series || data.series.length === 0 || data.series.every((frame) => frame.length === 0)) { + const noData = !hasData(data); + const noQueryConfigured = hasNoQueryConfigured(data); + + if (config.featureToggles.newVizSuggestions && noQueryConfigured && noData) { + return t( + 'dashboard.new-panel.empty-state-message', + 'Run a query to visualize it here or go to all visualizations to add other panel types' + ); + } + + if (noData) { return fieldConfig?.defaults.noValue ?? t('panel.panel-data-error-view.no-value.default', 'No data'); } @@ -176,5 +198,9 @@ const getStyles = (theme: GrafanaTheme2) => { width: '100%', maxWidth: '600px', }), + emptyStateIcon: css({ + color: theme.colors.text.secondary, + marginBottom: theme.spacing(2), + }), }; }; diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx index 924b5f3b6bf..c29f1b60a21 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @@ -22,6 +22,7 @@ import { getAllSuggestions } from '../../suggestions/getAllSuggestions'; import { hasData } from '../../suggestions/utils'; import { VisualizationSuggestionCard } from './VisualizationSuggestionCard'; +import { VizSuggestionsInteractions, PANEL_STATES, type PanelState } from './interactions'; import { VizTypeChangeDetails } from './types'; export interface Props { @@ -30,6 +31,7 @@ export interface Props { data?: PanelData; panel?: PanelModel; searchQuery?: string; + isNewPanel?: boolean; } const useSuggestions = (data: PanelData | undefined, searchQuery: string | undefined) => { @@ -62,7 +64,7 @@ const useSuggestions = (data: PanelData | undefined, searchQuery: string | undef return { value: filteredValue, loading, error, retry }; }; -export function VisualizationSuggestions({ onChange, editPreview, data, panel, searchQuery }: Props) { +export function VisualizationSuggestions({ onChange, editPreview, data, panel, searchQuery, isNewPanel }: Props) { const styles = useStyles2(getStyles); const { value: result, loading, error, retry } = useSuggestions(data, searchQuery); @@ -75,6 +77,18 @@ export function VisualizationSuggestions({ onChange, editPreview, data, panel, s const isNewVizSuggestionsEnabled = config.featureToggles.newVizSuggestions; const isUnconfiguredPanel = panel?.type === UNCONFIGURED_PANEL_PLUGIN_ID; + const panelState = useMemo((): PanelState => { + if (isUnconfiguredPanel) { + return PANEL_STATES.UNCONFIGURED_PANEL; + } + + if (isNewPanel) { + return PANEL_STATES.NEW_PANEL; + } + + return PANEL_STATES.EXISTING_PANEL; + }, [isUnconfiguredPanel, isNewPanel]); + const suggestionsByVizType = useMemo(() => { const meta = getAllPanelPluginMeta(); const record: Record = {}; @@ -96,22 +110,36 @@ export function VisualizationSuggestions({ onChange, editPreview, data, panel, s }, [suggestions]); const applySuggestion = useCallback( - (suggestion: PanelPluginVisualizationSuggestion, isPreview?: boolean) => { + (suggestion: PanelPluginVisualizationSuggestion, isPreview: boolean, isAutoSelected = false) => { + if (isPreview) { + VizSuggestionsInteractions.suggestionPreviewed({ + pluginId: suggestion.pluginId, + suggestionName: suggestion.name, + panelState, + isAutoSelected, + }); + + setSuggestionHash(suggestion.hash); + } else { + VizSuggestionsInteractions.suggestionAccepted({ + pluginId: suggestion.pluginId, + suggestionName: suggestion.name, + panelState, + }); + } + onChange( { pluginId: suggestion.pluginId, options: suggestion.options, fieldConfig: suggestion.fieldConfig, withModKey: isPreview, + fromSuggestions: true, }, isPreview ? editPreview : undefined ); - - if (isPreview) { - setSuggestionHash(suggestion.hash); - } }, - [onChange, editPreview] + [onChange, editPreview, panelState] ); useEffect(() => { @@ -124,7 +152,7 @@ export function VisualizationSuggestions({ onChange, editPreview, data, panel, s // the previously selected suggestion is no longer present in the list. const newFirstCardHash = suggestions?.[0]?.hash ?? null; if (firstCardHash !== newFirstCardHash || suggestions.every((s) => s.hash !== suggestionHash)) { - applySuggestion(suggestions[0], true); + applySuggestion(suggestions[0], true, true); setFirstCardHash(newFirstCardHash); return; } @@ -243,7 +271,7 @@ export function VisualizationSuggestions({ onChange, editPreview, data, panel, s suggestion={suggestion} width={width} tabIndex={index} - onClick={() => applySuggestion(suggestion)} + onClick={() => applySuggestion(suggestion, false)} />
        ))} diff --git a/public/app/features/panel/components/VizTypePicker/interactions.ts b/public/app/features/panel/components/VizTypePicker/interactions.ts new file mode 100644 index 00000000000..3062919c93a --- /dev/null +++ b/public/app/features/panel/components/VizTypePicker/interactions.ts @@ -0,0 +1,28 @@ +import { reportInteraction } from '@grafana/runtime'; + +export const PANEL_STATES = { + UNCONFIGURED_PANEL: 'unconfigured_panel', + NEW_PANEL: 'new_panel', + EXISTING_PANEL: 'existing_panel', +} as const; + +export type PanelState = (typeof PANEL_STATES)[keyof typeof PANEL_STATES]; + +export const VizSuggestionsInteractions = { + suggestionPreviewed: (properties: { + pluginId: string; + suggestionName: string; + panelState: PanelState; + isAutoSelected?: boolean; + }) => { + reportVizSuggestionsInteraction('suggestion_previewed', properties); + }, + + suggestionAccepted: (properties: { pluginId: string; suggestionName: string; panelState: PanelState }) => { + reportVizSuggestionsInteraction('suggestion_accepted', properties); + }, +}; + +const reportVizSuggestionsInteraction = (name: string, properties?: Record) => { + reportInteraction(`grafana_viz_suggestions_${name}`, properties); +}; diff --git a/public/app/features/panel/components/VizTypePicker/types.ts b/public/app/features/panel/components/VizTypePicker/types.ts index 4dbab692faf..6eac9011184 100644 --- a/public/app/features/panel/components/VizTypePicker/types.ts +++ b/public/app/features/panel/components/VizTypePicker/types.ts @@ -5,4 +5,5 @@ export interface VizTypeChangeDetails { options?: Record; fieldConfig?: FieldConfigSource; withModKey?: boolean; + fromSuggestions?: boolean; } diff --git a/public/app/features/panel/state/util.ts b/public/app/features/panel/state/util.ts index 87f674479a8..4c785e67fe8 100644 --- a/public/app/features/panel/state/util.ts +++ b/public/app/features/panel/state/util.ts @@ -1,5 +1,5 @@ import { PanelPluginMeta, PluginState, unEscapeStringFromRegex } from '@grafana/data'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; export function getAllPanelPluginMeta(): PanelPluginMeta[] { const allPanels = config.panels; diff --git a/public/app/features/panel/suggestions/getAllSuggestions.test.ts b/public/app/features/panel/suggestions/getAllSuggestions.test.ts index 8657824cc27..7631cbf95f9 100644 --- a/public/app/features/panel/suggestions/getAllSuggestions.test.ts +++ b/public/app/features/panel/suggestions/getAllSuggestions.test.ts @@ -11,6 +11,7 @@ import { toDataFrame, VisualizationSuggestionScore, } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { BarGaugeDisplayMode, BigValueColorMode, @@ -20,7 +21,6 @@ import { VizOrientation, } from '@grafana/schema'; import { appEvents } from 'app/core/app_events'; -import { config } from 'app/core/config'; import { clearPanelPluginCache } from 'app/features/plugins/importPanelPlugin'; import { pluginImporter } from 'app/features/plugins/importer/pluginImporter'; diff --git a/public/app/features/panel/suggestions/utils.ts b/public/app/features/panel/suggestions/utils.ts index 07820587be5..eaf56bd99c0 100644 --- a/public/app/features/panel/suggestions/utils.ts +++ b/public/app/features/panel/suggestions/utils.ts @@ -5,7 +5,7 @@ import { VisualizationSuggestion, VisualizationSuggestionScore, } from '@grafana/data'; -import { ReduceDataOptions } from '@grafana/schema'; +import { LegendDisplayMode, ReduceDataOptions, VizLegendOptions } from '@grafana/schema'; /** * @internal @@ -62,3 +62,15 @@ export function defaultNumericVizOptions( export function hasData(data?: PanelData): boolean { return Boolean(data && data.series && data.series.length > 0 && data.series.some((frame) => frame.length > 0)); } + +/** + * @internal + * Hidden legend config for previewing suggestion cards. + * This should only be used in previewModifier. + */ +export const SUGGESTIONS_LEGEND_OPTIONS: VizLegendOptions = { + calcs: [], + displayMode: LegendDisplayMode.Hidden, + placement: 'right', + showLegend: false, +}; diff --git a/public/app/features/plugins/admin/components/PluginDetailsBody.tsx b/public/app/features/plugins/admin/components/PluginDetailsBody.tsx index d93c5a913b5..b6c5b301f68 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsBody.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsBody.tsx @@ -176,7 +176,7 @@ export function PluginDetailsBody({ plugin, queryParams, pageId, info, showDetai export const getStyles = (theme: GrafanaTheme2) => ({ wrap: css({ width: '100%', - height: '50vh', + height: '65vh', }), readme: css({ '& img': { diff --git a/public/app/features/plugins/admin/components/PluginUsage.tsx b/public/app/features/plugins/admin/components/PluginUsage.tsx index 0ad9ebb1738..b156fb39783 100644 --- a/public/app/features/plugins/admin/components/PluginUsage.tsx +++ b/public/app/features/plugins/admin/components/PluginUsage.tsx @@ -62,6 +62,7 @@ export function PluginUsage({ plugin }: Props) { keyboardEvents={of()} onTagSelected={() => {}} trackingSource="PluginDetailsPage_PluginUsage" + onClickItem={() => {}} /> ); }} @@ -74,13 +75,13 @@ export function PluginUsage({ plugin }: Props) { return ; } - if (!config.featureToggles.panelTitleSearch) { + if (!config.featureToggles.unifiedStorageSearchUI) { return ( diff --git a/public/app/features/plugins/admin/hooks/usePluginConfig.tsx b/public/app/features/plugins/admin/hooks/usePluginConfig.tsx index f80af8bd8a6..5f6001e3095 100644 --- a/public/app/features/plugins/admin/hooks/usePluginConfig.tsx +++ b/public/app/features/plugins/admin/hooks/usePluginConfig.tsx @@ -11,7 +11,11 @@ export const usePluginConfig = (plugin?: CatalogPlugin) => { return null; } - const isPluginInstalled = config.pluginAdminExternalManageEnabled ? plugin.isFullyInstalled : plugin.isInstalled; + // On Cloud, check both isFullyInstalled (for multi-instance setup) and isInstalled (fallback for single instance) + // This ensures tabs show even if instance data hasn't fully loaded + const isPluginInstalled = config.pluginAdminExternalManageEnabled + ? plugin.isFullyInstalled || plugin.isInstalled + : plugin.isInstalled; if (isPluginInstalled && !plugin.isDisabled) { return loadPlugin(plugin.id); diff --git a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx index 6cb8ec39af4..be6a6c73973 100644 --- a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx +++ b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx @@ -101,7 +101,7 @@ export const usePluginDetailsTabs = ( } if ( - config.featureToggles.panelTitleSearch && + config.featureToggles.unifiedStorageSearchUI && (pluginConfig.meta.type === PluginType.panel || pluginConfig.meta.type === PluginType.datasource) ) { navModelChildren.push({ diff --git a/public/app/features/plugins/admin/state/actions.ts b/public/app/features/plugins/admin/state/actions.ts index e9cf2d9d40d..34ffc227354 100644 --- a/public/app/features/plugins/admin/state/actions.ts +++ b/public/app/features/plugins/admin/state/actions.ts @@ -3,7 +3,6 @@ import { from, forkJoin, timeout, lastValueFrom, catchError, of } from 'rxjs'; import { PanelPlugin, PluginError } from '@grafana/data'; import { config, getBackendSrv, isFetchError } from '@grafana/runtime'; -import { Settings } from 'app/core/config'; import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin'; import { StoreState, ThunkResult } from 'app/types/store'; @@ -301,7 +300,7 @@ export const loadPanelPlugin = (id: string): ThunkResult> = function updatePanels() { return getBackendSrv() .get('/api/frontend/settings') - .then((settings: Settings) => { + .then((settings) => { config.panels = settings.panels; }); } diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index a529952eff3..03025e484cb 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -4,8 +4,6 @@ const cloudwatchPlugin = async () => await import(/* webpackChunkName: "cloudwatchPlugin" */ 'app/plugins/datasource/cloudwatch/module'); const dashboardDSPlugin = async () => await import(/* webpackChunkName "dashboardDSPlugin" */ 'app/plugins/datasource/dashboard/module'); -const elasticsearchPlugin = async () => - await import(/* webpackChunkName: "elasticsearchPlugin" */ 'app/plugins/datasource/elasticsearch/module'); const grafanaPlugin = async () => await import(/* webpackChunkName: "grafanaPlugin" */ 'app/plugins/datasource/grafana/module'); const influxdbPlugin = async () => @@ -75,7 +73,6 @@ const builtInPlugins: Record Promise getTargetOptions(settings.data?.allowedTargets || ['folder']), [settings.data]); const isGitBased = isGitProvider(type); @@ -104,17 +104,6 @@ export function ConfigForm({ data }: ConfigFormProps) { const localFields = type === 'local' ? getLocalProviderFields(type) : null; const hasTokenInstructions = getHasTokenInstructions(type); - // TODO: this should be removed after 12.2 is released - useEffect(() => { - if (isGitBased && !data?.secure?.token) { - setTokenConfigured(false); - setError('token', { - type: 'manual', - message: `Enter your ${gitFields?.tokenConfig.label ?? 'access token'}`, - }); - } - }, [data, gitFields, setTokenConfigured, setError, isGitBased]); - useEffect(() => { if (request.isSuccess) { const formData = getValues(); @@ -126,11 +115,9 @@ export function ConfigForm({ data }: ConfigFormProps) { }); reset(formData); - setTimeout(() => { - navigate('/admin/provisioning'); - }, 300); + setTimeout(() => navigate(PROVISIONING_URL), 300); } - }, [request.isSuccess, reset, getValues, navigate, repositoryName]); + }, [request.isSuccess, reset, getValues, repositoryName, navigate]); const onSubmit = async (form: RepositoryFormData) => { setIsLoading(true); diff --git a/public/app/features/provisioning/Connection/ConnectionForm.test.tsx b/public/app/features/provisioning/Connection/ConnectionForm.test.tsx new file mode 100644 index 00000000000..4a90ca5fd50 --- /dev/null +++ b/public/app/features/provisioning/Connection/ConnectionForm.test.tsx @@ -0,0 +1,277 @@ +import { QueryStatus } from '@reduxjs/toolkit/query'; +import { render, screen, waitFor } from 'test/test-utils'; + +import { Connection } from 'app/api/clients/provisioning/v0alpha1'; + +import { useCreateOrUpdateConnection } from '../hooks/useCreateOrUpdateConnection'; + +import { ConnectionForm } from './ConnectionForm'; + +jest.mock('../hooks/useCreateOrUpdateConnection', () => ({ + useCreateOrUpdateConnection: jest.fn(), +})); + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), +})); + +const mockSubmitData = jest.fn(); +const mockUseCreateOrUpdateConnection = useCreateOrUpdateConnection as jest.MockedFunction< + typeof useCreateOrUpdateConnection +>; + +type MockRequestState = { + status: QueryStatus; + isLoading: boolean; + isSuccess: boolean; + isError: boolean; + error?: unknown; + reset: jest.Mock; +}; + +const createMockRequestState = (overrides: Partial = {}): MockRequestState => ({ + status: QueryStatus.uninitialized, + isLoading: false, + isSuccess: false, + isError: false, + reset: jest.fn(), + ...overrides, +}); + +const createMockConnection = (overrides: Partial = {}): Connection => ({ + metadata: { name: 'test-connection' }, + spec: { + type: 'github', + url: 'https://github.com/settings/installations/12345678', + github: { + appID: '123456', + installationID: '12345678', + }, + }, + secure: { + privateKey: { name: 'configured' }, + }, + status: { + state: 'connected', + health: { healthy: true }, + observedGeneration: 1, + }, + ...overrides, +}); + +interface SetupOptions { + data?: Connection; + requestState?: Partial; +} + +function setup(options: SetupOptions = {}) { + const { data, requestState = {} } = options; + + mockUseCreateOrUpdateConnection.mockReturnValue([ + mockSubmitData, + createMockRequestState(requestState) as unknown as ReturnType[1], + ]); + + return { + mockSubmitData, + ...render(), + }; +} + +describe('ConnectionForm', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSubmitData.mockResolvedValue(undefined); + }); + + describe('Rendering - Create Mode', () => { + it('should render all form fields', () => { + setup(); + + expect(screen.getByLabelText(/^Provider/)).toBeInTheDocument(); + expect(screen.getByLabelText(/^GitHub App ID/)).toBeInTheDocument(); + expect(screen.getByLabelText(/^GitHub Installation ID/)).toBeInTheDocument(); + expect(screen.getByLabelText(/^Private Key \(PEM\)/)).toBeInTheDocument(); + }); + + it('should render Save button', () => { + setup(); + + expect(screen.getByRole('button', { name: /^save$/i })).toBeInTheDocument(); + }); + + it('should not render Delete button in create mode', () => { + setup(); + + expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument(); + }); + + it('should have Provider field disabled', () => { + setup(); + + expect(screen.getByLabelText(/^Provider/)).toBeDisabled(); + }); + }); + + describe('Rendering - Edit Mode', () => { + it('should populate form fields with existing connection data', () => { + setup({ data: createMockConnection() }); + + expect(screen.getByLabelText(/^GitHub App ID/)).toHaveValue('123456'); + expect(screen.getByLabelText(/^GitHub Installation ID/)).toHaveValue('12345678'); + }); + + it('should render Delete button in edit mode', () => { + setup({ data: createMockConnection() }); + + expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument(); + }); + + it('should show configured state for private key', () => { + setup({ data: createMockConnection() }); + + expect(screen.getByLabelText(/^Private Key \(PEM\)/)).toHaveValue('configured'); + }); + }); + + describe('Form Validation', () => { + it('should show required error and not submit when fields are empty', async () => { + const { user, mockSubmitData } = setup(); + + const saveButton = screen.getByRole('button', { name: /^save$/i }); + await user.click(saveButton); + + await waitFor(() => { + expect(screen.getAllByText('This field is required')).toHaveLength(3); + }); + + expect(mockSubmitData).not.toHaveBeenCalled(); + }); + }); + + describe('Form Submission - Create', () => { + it('should call submitData with correct data on valid submission', async () => { + const { user, mockSubmitData } = setup(); + + await user.type(screen.getByLabelText(/^GitHub App ID/), '123456'); + await user.type(screen.getByLabelText(/^GitHub Installation ID/), '12345678'); + await user.type(screen.getByLabelText(/^Private Key \(PEM\)/), '-----BEGIN RSA PRIVATE KEY-----'); + + const saveButton = screen.getByRole('button', { name: /^save$/i }); + await user.click(saveButton); + + await waitFor(() => { + expect(mockSubmitData).toHaveBeenCalledWith( + { + type: 'github', + github: { + appID: '123456', + installationID: '12345678', + }, + }, + '-----BEGIN RSA PRIVATE KEY-----' + ); + }); + }); + }); + + describe('Form Submission - Edit', () => { + it('should allow submission without changing private key', async () => { + const { user, mockSubmitData } = setup({ data: createMockConnection() }); + + const saveButton = screen.getByRole('button', { name: /^save$/i }); + await user.click(saveButton); + + await waitFor(() => { + expect(mockSubmitData).toHaveBeenCalledWith( + { + type: 'github', + github: { + appID: '123456', + installationID: '12345678', + }, + }, + 'configured' + ); + }); + }); + }); + + describe('Loading State', () => { + it('should disable Save button while loading', () => { + setup({ requestState: { isLoading: true } }); + + const saveButton = screen.getByRole('button', { name: /saving/i }); + expect(saveButton).toBeDisabled(); + }); + + it('should show "Saving..." text while loading', () => { + setup({ requestState: { isLoading: true } }); + + expect(screen.getByText('Saving...')).toBeInTheDocument(); + }); + }); + + describe('Error Handling', () => { + it('should map API error for appID to form field', async () => { + const { user, mockSubmitData } = setup(); + + mockSubmitData.mockRejectedValue({ + status: 400, + data: { errors: [{ field: 'appID', detail: 'Invalid App ID' }] }, + }); + + await user.type(screen.getByLabelText(/^GitHub App ID/), '123456'); + await user.type(screen.getByLabelText(/^GitHub Installation ID/), '12345678'); + await user.type(screen.getByLabelText(/^Private Key \(PEM\)/), '-----BEGIN RSA PRIVATE KEY-----'); + + const saveButton = screen.getByRole('button', { name: /^save$/i }); + await user.click(saveButton); + + await waitFor(() => { + expect(screen.getByText('Invalid App ID')).toBeInTheDocument(); + }); + }); + + it('should map API error for installationID to form field', async () => { + const { user, mockSubmitData } = setup(); + + mockSubmitData.mockRejectedValue({ + status: 400, + data: { errors: [{ field: 'installationID', detail: 'Invalid Installation ID' }] }, + }); + + await user.type(screen.getByLabelText(/^GitHub App ID/), '123456'); + await user.type(screen.getByLabelText(/^GitHub Installation ID/), '12345678'); + await user.type(screen.getByLabelText(/^Private Key \(PEM\)/), '-----BEGIN RSA PRIVATE KEY-----'); + + const saveButton = screen.getByRole('button', { name: /^save$/i }); + await user.click(saveButton); + + await waitFor(() => { + expect(screen.getByText('Invalid Installation ID')).toBeInTheDocument(); + }); + }); + + it('should map API error for privateKey to form field', async () => { + const { user, mockSubmitData } = setup(); + + mockSubmitData.mockRejectedValue({ + status: 400, + data: { errors: [{ field: 'secure.privateKey', detail: 'Invalid Private Key format' }] }, + }); + + await user.type(screen.getByLabelText(/^GitHub App ID/), '123456'); + await user.type(screen.getByLabelText(/^GitHub Installation ID/), '12345678'); + await user.type(screen.getByLabelText(/^Private Key \(PEM\)/), 'invalid-key'); + + const saveButton = screen.getByRole('button', { name: /^save$/i }); + await user.click(saveButton); + + await waitFor(() => { + expect(screen.getByText('Invalid Private Key format')).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/public/app/features/provisioning/Connection/ConnectionForm.tsx b/public/app/features/provisioning/Connection/ConnectionForm.tsx new file mode 100644 index 00000000000..ceab4f7d8f6 --- /dev/null +++ b/public/app/features/provisioning/Connection/ConnectionForm.tsx @@ -0,0 +1,199 @@ +import { useEffect, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom-v5-compat'; + +import { t } from '@grafana/i18n'; +import { isFetchError, reportInteraction } from '@grafana/runtime'; +import { Button, Combobox, Field, Input, SecretTextArea, Stack } from '@grafana/ui'; +import { Connection } from 'app/api/clients/provisioning/v0alpha1'; +import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt'; + +import { CONNECTIONS_URL } from '../constants'; +import { useCreateOrUpdateConnection } from '../hooks/useCreateOrUpdateConnection'; +import { ConnectionFormData } from '../types'; +import { getConnectionFormErrors } from '../utils/getFormErrors'; + +import { DeleteConnectionButton } from './DeleteConnectionButton'; + +interface ConnectionFormProps { + data?: Connection; +} + +const providerOptions = [{ value: 'github', label: 'GitHub' }]; + +export function ConnectionForm({ data }: ConnectionFormProps) { + const connectionName = data?.metadata?.name; + const isEdit = Boolean(connectionName); + const privateKey = data?.secure?.privateKey; + const [privateKeyConfigured, setPrivateKeyConfigured] = useState(Boolean(privateKey)); + const [submitData, request] = useCreateOrUpdateConnection(connectionName); + const navigate = useNavigate(); + + const { + register, + handleSubmit, + reset, + control, + formState: { errors, isDirty }, + setValue, + getValues, + setError, + } = useForm({ + defaultValues: { + type: data?.spec?.type || 'github', + appID: data?.spec?.github?.appID || '', + installationID: data?.spec?.github?.installationID || '', + privateKey: privateKey?.name || '', + }, + }); + + useEffect(() => { + if (request.isSuccess) { + const formData = getValues(); + + reportInteraction('grafana_provisioning_connection_saved', { + connectionName: connectionName ?? 'unknown', + connectionType: formData.type, + }); + + reset(formData); + // use timeout to ensure the form resets before navigating + setTimeout(() => navigate(CONNECTIONS_URL), 300); + } + }, [request.isSuccess, reset, getValues, connectionName, navigate]); + + const onSubmit = async (form: ConnectionFormData) => { + try { + const spec = { + type: form.type, + github: { + appID: form.appID, + installationID: form.installationID, + }, + }; + + await submitData(spec, form.privateKey); + } catch (err) { + if (isFetchError(err)) { + const [field, errorMessage] = getConnectionFormErrors(err.data?.errors); + + if (field && errorMessage) { + setError(field, errorMessage); + return; + } + } + } + }; + + return ( +
        + + + + ( + onChange(option?.value)} + {...field} + /> + )} + /> + + + + + + + + + + + + ( + { + setValue('privateKey', ''); + setPrivateKeyConfigured(false); + }} + rows={8} + grow + /> + )} + /> + + + + + {connectionName && data && } + + + + ); +} diff --git a/public/app/features/provisioning/Connection/ConnectionFormPage.tsx b/public/app/features/provisioning/Connection/ConnectionFormPage.tsx new file mode 100644 index 00000000000..1c98f407429 --- /dev/null +++ b/public/app/features/provisioning/Connection/ConnectionFormPage.tsx @@ -0,0 +1,59 @@ +import { skipToken } from '@reduxjs/toolkit/query/react'; +import { useParams } from 'react-router-dom-v5-compat'; + +import { Trans, t } from '@grafana/i18n'; +import { EmptyState, Text, TextLink } from '@grafana/ui'; +import { useGetConnectionQuery } from 'app/api/clients/provisioning/v0alpha1'; +import { Page } from 'app/core/components/Page/Page'; + +import { CONNECTIONS_URL } from '../constants'; + +import { ConnectionForm } from './ConnectionForm'; + +export default function ConnectionFormPage() { + const { name = '' } = useParams(); + const isCreate = !name; + + const query = useGetConnectionQuery(isCreate ? skipToken : { name }); + + //@ts-expect-error TODO add error types + const notFound = !isCreate && query.isError && query.error?.status === 404; + + const pageTitle = isCreate + ? t('provisioning.connection-form.page-title-create', 'Create connection') + : t('provisioning.connection-form.page-title-edit', 'Edit connection'); + + return ( + + + {notFound ? ( + + + + The connection you are looking for does not exist. + + + + Back to connections + + + ) : ( + + )} + + + ); +} diff --git a/public/app/features/provisioning/Connection/ConnectionList.test.tsx b/public/app/features/provisioning/Connection/ConnectionList.test.tsx new file mode 100644 index 00000000000..7b928ad17cc --- /dev/null +++ b/public/app/features/provisioning/Connection/ConnectionList.test.tsx @@ -0,0 +1,165 @@ +import { render, screen } from 'test/test-utils'; + +import { Connection } from 'app/api/clients/provisioning/v0alpha1'; + +import { ConnectionList } from './ConnectionList'; + +const createMockConnection = (overrides: Partial = {}): Connection => ({ + metadata: { name: 'test-connection' }, + spec: { + type: 'github', + url: 'https://github.com/settings/installations/12345678', + github: { + appID: '123456', + installationID: '12345678', + }, + }, + status: { + state: 'connected', + health: { healthy: true }, + observedGeneration: 1, + }, + ...overrides, +}); + +const mockConnections: Connection[] = [ + createMockConnection({ + metadata: { name: 'github-conn-1' }, + spec: { + type: 'github', + url: 'https://github.com/settings/installations/103343308', + github: { + appID: '123456', + installationID: '103343308', + }, + }, + }), + createMockConnection({ + metadata: { name: 'gitlab-conn-2' }, + spec: { type: 'gitlab', url: 'https://gitlab.com/org2/repo2' }, + }), + createMockConnection({ + metadata: { name: 'another-github' }, + spec: { + type: 'github', + url: 'https://github.com/settings/installations/987654321', + github: { + appID: '654321', + installationID: '987654321', + }, + }, + }), +]; + +function setup(items: Connection[] = mockConnections) { + return render(, { renderWithRouter: true }); +} + +describe('ConnectionList', () => { + describe('Rendering', () => { + it('should render search input with correct placeholder', () => { + setup(); + + expect(screen.getByPlaceholderText('Search connections')).toBeInTheDocument(); + }); + + it('should render all connection items when no filter is applied', () => { + setup(); + + // Verify all 3 connections are displayed by checking for their URL links + expect( + screen.getByRole('link', { name: 'https://github.com/settings/installations/103343308' }) + ).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'https://gitlab.com/org2/repo2' })).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'https://github.com/settings/installations/987654321' }) + ).toBeInTheDocument(); + }); + + it('should render EmptyState when items array is empty', () => { + setup([]); + + expect(screen.getByText('No connections configured')).toBeInTheDocument(); + }); + }); + + describe('Filtering', () => { + it('should filter connections by name', async () => { + const { user } = setup(); + + const searchInput = screen.getByPlaceholderText('Search connections'); + await user.type(searchInput, 'gitlab'); + + // Should show only gitlab connection + expect(screen.getByRole('link', { name: 'https://gitlab.com/org2/repo2' })).toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: 'https://github.com/settings/installations/103343308' }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: 'https://github.com/settings/installations/987654321' }) + ).not.toBeInTheDocument(); + }); + + it('should filter connections by provider type', async () => { + const { user } = setup(); + + const searchInput = screen.getByPlaceholderText('Search connections'); + await user.type(searchInput, 'github'); + + // Should show only github connections + expect( + screen.getByRole('link', { name: 'https://github.com/settings/installations/103343308' }) + ).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'https://gitlab.com/org2/repo2' })).not.toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'https://github.com/settings/installations/987654321' }) + ).toBeInTheDocument(); + }); + + it('should be case-insensitive', async () => { + const { user } = setup(); + + const searchInput = screen.getByPlaceholderText('Search connections'); + await user.type(searchInput, 'GITLAB'); + + expect(screen.getByRole('link', { name: 'https://gitlab.com/org2/repo2' })).toBeInTheDocument(); + }); + + it('should show EmptyState when filter matches nothing', async () => { + const { user } = setup(); + + const searchInput = screen.getByPlaceholderText('Search connections'); + await user.type(searchInput, 'nonexistent'); + + expect(screen.getByText('No results matching your query')).toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: 'https://github.com/settings/installations/103343308' }) + ).not.toBeInTheDocument(); + }); + + it('should clear filter and show all items', async () => { + const { user } = setup(); + + const searchInput = screen.getByPlaceholderText('Search connections'); + await user.type(searchInput, 'gitlab'); + + // Filter applied + expect(screen.getByRole('link', { name: 'https://gitlab.com/org2/repo2' })).toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: 'https://github.com/settings/installations/103343308' }) + ).not.toBeInTheDocument(); + + // Clear the filter + await user.clear(searchInput); + + // All items should be visible again + expect( + screen.getByRole('link', { name: 'https://github.com/settings/installations/103343308' }) + ).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'https://gitlab.com/org2/repo2' })).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'https://github.com/settings/installations/987654321' }) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/provisioning/Connection/ConnectionList.tsx b/public/app/features/provisioning/Connection/ConnectionList.tsx new file mode 100644 index 00000000000..38a7eb99e00 --- /dev/null +++ b/public/app/features/provisioning/Connection/ConnectionList.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; + +import { t } from '@grafana/i18n'; +import { EmptyState, FilterInput, Stack } from '@grafana/ui'; +import { Connection } from 'app/api/clients/provisioning/v0alpha1'; + +import { ConnectionListItem } from './ConnectionListItem'; + +interface Props { + items: Connection[]; +} + +export function ConnectionList({ items }: Props) { + const [query, setQuery] = useState(''); + + const filteredItems = items.filter((item) => { + if (!query) { + return true; + } + const lowerQuery = query.toLowerCase(); + const name = item.metadata?.name?.toLowerCase() ?? ''; + const providerType = item.spec?.type?.toLowerCase() ?? ''; + return name.includes(lowerQuery) || providerType.includes(lowerQuery); + }); + + const isEmpty = items.length === 0; + + return ( + + + + {filteredItems.length ? ( + filteredItems.map((item) => ) + ) : ( + + )} + + + ); +} diff --git a/public/app/features/provisioning/Connection/ConnectionListItem.tsx b/public/app/features/provisioning/Connection/ConnectionListItem.tsx new file mode 100644 index 00000000000..05014e4f98f --- /dev/null +++ b/public/app/features/provisioning/Connection/ConnectionListItem.tsx @@ -0,0 +1,49 @@ +import { Trans } from '@grafana/i18n'; +import { Card, LinkButton, Stack, Text, TextLink } from '@grafana/ui'; +import { Connection } from 'app/api/clients/provisioning/v0alpha1'; + +import { RepoIcon } from '../Shared/RepoIcon'; +import { RepoType } from '../Wizard/types'; +import { CONNECTIONS_URL } from '../constants'; +import { getRepositoryTypeConfigs } from '../utils/repositoryTypes'; + +import { ConnectionStatusBadge } from './ConnectionStatusBadge'; + +interface Props { + connection: Connection; +} + +export function ConnectionListItem({ connection }: Props) { + const { metadata, spec, status } = connection; + const name = metadata?.name ?? ''; + const url = spec?.url; + const providerType: RepoType = spec?.type ?? 'github'; + const repoConfig = getRepositoryTypeConfigs().find((config) => config.type === providerType); + return ( + + + + + + + {repoConfig && {`${repoConfig.label} app connection`}} + {status?.state && } + + + + {url && ( + + + {url} + + + )} + + + + View + + + + ); +} diff --git a/public/app/features/provisioning/Connection/ConnectionStatusBadge.tsx b/public/app/features/provisioning/Connection/ConnectionStatusBadge.tsx new file mode 100644 index 00000000000..8d36d121e30 --- /dev/null +++ b/public/app/features/provisioning/Connection/ConnectionStatusBadge.tsx @@ -0,0 +1,42 @@ +import { t } from '@grafana/i18n'; +import { Badge, IconName } from '@grafana/ui'; +import { ConnectionStatus } from 'app/api/clients/provisioning/v0alpha1'; + +interface Props { + status: ConnectionStatus; +} + +interface BadgeConfig { + color: 'green' | 'red' | 'darkgrey'; + text: string; + icon: IconName; +} + +function getBadgeConfig(status: ConnectionStatus): BadgeConfig { + switch (status.state) { + case 'connected': + return { + color: 'green', + text: t('provisioning.connections.status-connected', 'Connected'), + icon: 'check', + }; + case 'disconnected': + return { + color: 'red', + text: t('provisioning.connections.status-disconnected', 'Disconnected'), + icon: 'times-circle', + }; + default: + return { + color: 'darkgrey', + text: t('provisioning.connections.status-unknown', 'Unknown'), + icon: 'question-circle', + }; + } +} + +export function ConnectionStatusBadge({ status }: Props) { + const config = getBadgeConfig(status); + + return ; +} diff --git a/public/app/features/provisioning/Connection/ConnectionsPage.tsx b/public/app/features/provisioning/Connection/ConnectionsPage.tsx new file mode 100644 index 00000000000..d3ad28a46a2 --- /dev/null +++ b/public/app/features/provisioning/Connection/ConnectionsPage.tsx @@ -0,0 +1,55 @@ +import { t, Trans } from '@grafana/i18n'; +import { Alert, EmptyState, LinkButton, Stack, Text } from '@grafana/ui'; +import { Page } from 'app/core/components/Page/Page'; + +import { CONNECTIONS_URL } from '../constants'; +import { useConnectionList } from '../hooks/useConnectionList'; +import { getErrorMessage } from '../utils/httpUtils'; + +import { ConnectionList } from './ConnectionList'; + +export default function ConnectionsPage() { + const [items, isLoading, error] = useConnectionList(); + const hasNoConnections = !isLoading && !error && items?.length === 0; + + return ( + + Add connection + + } + > + + + {!!error && ( + + {getErrorMessage(error)} + + )} + + {hasNoConnections && ( + + + {t( + 'provisioning.connections.no-connections-message', + 'Add a connection to authenticate with external providers' + )} + + + )} + + {!!items?.length && } + + + + ); +} diff --git a/public/app/features/provisioning/Connection/DeleteConnectionButton.tsx b/public/app/features/provisioning/Connection/DeleteConnectionButton.tsx new file mode 100644 index 00000000000..03260da58d3 --- /dev/null +++ b/public/app/features/provisioning/Connection/DeleteConnectionButton.tsx @@ -0,0 +1,53 @@ +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom-v5-compat'; + +import { t, Trans } from '@grafana/i18n'; +import { reportInteraction } from '@grafana/runtime'; +import { Button } from '@grafana/ui'; +import { Connection, useDeleteConnectionMutation } from 'app/api/clients/provisioning/v0alpha1'; +import { appEvents } from 'app/core/app_events'; +import { ShowConfirmModalEvent } from 'app/types/events'; + +import { CONNECTIONS_URL } from '../constants'; + +interface Props { + name: string; + connection: Connection; +} + +export function DeleteConnectionButton({ name, connection }: Props) { + const navigate = useNavigate(); + const [deleteConnection, deleteRequest] = useDeleteConnectionMutation(); + + const onDelete = useCallback(async () => { + reportInteraction('grafana_provisioning_connection_deleted', { + connectionName: name, + connectionType: connection?.spec?.type ?? 'unknown', + }); + + await deleteConnection({ name }); + navigate(CONNECTIONS_URL); + }, [deleteConnection, name, connection, navigate]); + + const showDeleteModal = useCallback(() => { + appEvents.publish( + new ShowConfirmModalEvent({ + title: t('provisioning.connections.delete-title', 'Delete connection'), + text: t( + 'provisioning.connections.delete-confirm', + 'Are you sure you want to delete this connection? This action cannot be undone.' + ), + yesText: t('provisioning.connections.delete', 'Delete'), + noText: t('provisioning.connections.cancel', 'Cancel'), + yesButtonVariant: 'destructive', + onConfirm: onDelete, + }) + ); + }, [onDelete]); + + return ( + + ); +} diff --git a/public/app/features/provisioning/GettingStarted/features.ts b/public/app/features/provisioning/GettingStarted/features.ts index d3d6e8cfecd..9a1f233fd44 100644 --- a/public/app/features/provisioning/GettingStarted/features.ts +++ b/public/app/features/provisioning/GettingStarted/features.ts @@ -2,7 +2,7 @@ import { FeatureToggles } from '@grafana/data'; import { config } from '@grafana/runtime'; import { RepositoryViewList } from 'app/api/clients/provisioning/v0alpha1'; -export const requiredFeatureToggles: Array = ['provisioning', 'kubernetesDashboards']; +export const requiredFeatureToggles: Array = ['kubernetesDashboards']; /** * Checks if all required feature toggles are enabled diff --git a/public/app/features/provisioning/Repository/DeleteRepositoryButton.tsx b/public/app/features/provisioning/Repository/DeleteRepositoryButton.tsx index 23ea3943268..85e44fb53d5 100644 --- a/public/app/features/provisioning/Repository/DeleteRepositoryButton.tsx +++ b/public/app/features/provisioning/Repository/DeleteRepositoryButton.tsx @@ -1,14 +1,16 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback } from 'react'; import { useNavigate } from 'react-router-dom-v5-compat'; import { t, Trans } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; -import { Button, ConfirmModal, Dropdown, Icon, Menu, Stack } from '@grafana/ui'; +import { Button, Dropdown, Icon, Menu, Stack } from '@grafana/ui'; import { Repository, useDeleteRepositoryMutation, useReplaceRepositoryMutation, } from 'app/api/clients/provisioning/v0alpha1'; +import { appEvents } from 'app/core/app_events'; +import { ShowConfirmModalEvent } from 'app/types/events'; type DeleteAction = 'remove-resources' | 'keep-resources'; @@ -21,110 +23,102 @@ interface Props { export function DeleteRepositoryButton({ name, repository, redirectTo }: Props) { const [deleteRepository, deleteRequest] = useDeleteRepositoryMutation(); const [replaceRepository, replaceRequest] = useReplaceRepositoryMutation(); - const [showModal, setShowModal] = useState(false); - const [selectedAction, setSelectedAction] = useState('remove-resources'); const navigate = useNavigate(); - useEffect(() => { - if (deleteRequest.isSuccess) { - setShowModal(false); + const performDelete = useCallback( + async (deleteAction: DeleteAction) => { + if (deleteAction === 'keep-resources' && repository) { + const updatedRepository = { + ...repository, + metadata: { + ...repository.metadata, + finalizers: ['cleanup', 'release-orphan-resources'], + }, + }; + await replaceRepository({ name, repository: updatedRepository }); + } + + reportInteraction('grafana_provisioning_repository_deleted', { + repositoryName: name, + repositoryType: repository?.spec?.type ?? 'unknown', + deleteAction, + target: repository?.spec?.sync?.target ?? 'unknown', + workflows: repository?.spec?.workflows ?? [], + }); + + await deleteRepository({ name }); + if (redirectTo) { navigate(redirectTo); } - } - }, [deleteRequest.isSuccess, redirectTo, navigate]); + }, + [deleteRepository, replaceRepository, name, repository, redirectTo, navigate] + ); - const onConfirm = useCallback(async () => { - if (selectedAction === 'keep-resources' && repository) { - const updatedRepository = { - ...repository, - metadata: { - ...repository.metadata, - finalizers: ['cleanup', 'release-orphan-resources'], - }, - }; - await replaceRepository({ name, repository: updatedRepository }); - } - - reportInteraction('grafana_provisioning_repository_deleted', { - repositoryName: name, - repositoryType: repository?.spec?.type ?? 'unknown', - deleteAction: selectedAction, - target: repository?.spec?.sync?.target ?? 'unknown', - workflows: repository?.spec?.workflows ?? [], - }); - - deleteRepository({ name }); - }, [deleteRepository, replaceRepository, name, selectedAction, repository]); - - const getConfirmationMessage = () => { - if (selectedAction === 'remove-resources') { - return t( - 'provisioning.delete-repository-button.confirm-delete-with-resources', - 'Are you sure you want to delete the repository configuration and all its resources?' - ); - } - return t( - 'provisioning.delete-repository-button.confirm-delete-keep-resources', - 'Are you sure you want to delete the repository configuration but keep its resources?' + const showDeleteWithResourcesModal = useCallback(() => { + appEvents.publish( + new ShowConfirmModalEvent({ + title: t( + 'provisioning.delete-repository-button.title-delete-repository-and-resources', + 'Delete repository configuration and resources' + ), + text: t( + 'provisioning.delete-repository-button.confirm-delete-with-resources', + 'Are you sure you want to delete the repository configuration and all its resources?' + ), + yesText: t('provisioning.delete-repository-button.button-delete', 'Delete'), + noText: t('provisioning.delete-repository-button.button-cancel', 'Cancel'), + yesButtonVariant: 'destructive', + onConfirm: () => performDelete('remove-resources'), + }) ); - }; + }, [performDelete]); - const getModalTitle = () => { - if (selectedAction === 'remove-resources') { - return t( - 'provisioning.delete-repository-button.title-delete-repository-and-resources', - 'Delete repository configuration and resources' - ); - } - return t( - 'provisioning.delete-repository-button.title-delete-repository-only', - 'Delete repository configuration only' + const showDeleteKeepResourcesModal = useCallback(() => { + appEvents.publish( + new ShowConfirmModalEvent({ + title: t( + 'provisioning.delete-repository-button.title-delete-repository-only', + 'Delete repository configuration only' + ), + text: t( + 'provisioning.delete-repository-button.confirm-delete-keep-resources', + 'Are you sure you want to delete the repository configuration but keep its resources?' + ), + yesText: t('provisioning.delete-repository-button.button-delete', 'Delete'), + noText: t('provisioning.delete-repository-button.button-cancel', 'Cancel'), + yesButtonVariant: 'destructive', + onConfirm: () => performDelete('keep-resources'), + }) ); - }; + }, [performDelete]); const isLoading = deleteRequest.isLoading || replaceRequest.isLoading; return ( - <> - - { - setSelectedAction('remove-resources'); - setShowModal(true); - }} - /> - { - setSelectedAction('keep-resources'); - setShowModal(true); - }} - /> - - } - > - - - setShowModal(false)} - /> - + + + + + } + > + + ); } diff --git a/public/app/features/provisioning/Repository/RepositoryActions.tsx b/public/app/features/provisioning/Repository/RepositoryActions.tsx index a7aa3b71f5a..a55cbc59f90 100644 --- a/public/app/features/provisioning/Repository/RepositoryActions.tsx +++ b/public/app/features/provisioning/Repository/RepositoryActions.tsx @@ -4,7 +4,7 @@ import { Badge, Button, LinkButton, Stack } from '@grafana/ui'; import { Repository } from 'app/api/clients/provisioning/v0alpha1'; import { StatusBadge } from '../Shared/StatusBadge'; -import { PROVISIONING_URL } from '../constants'; +import { CONNECTIONS_URL, PROVISIONING_URL } from '../constants'; import { getRepoHrefForProvider } from '../utils/git'; import { getIsReadOnlyWorkflows } from '../utils/repository'; import { getRepositoryTypeConfig } from '../utils/repositoryTypes'; @@ -34,6 +34,9 @@ export function RepositoryActions({ repository }: RepositoryActionsProps) { )} + + Connections + { + const nameA = a.metadata?.name ?? ''; + const nameB = b.metadata?.name ?? ''; + return collator.compare(nameA, nameB); + }); + + return [sortedItems, query.isLoading, query.error] as const; +} diff --git a/public/app/features/provisioning/hooks/useCreateOrUpdateConnection.ts b/public/app/features/provisioning/hooks/useCreateOrUpdateConnection.ts new file mode 100644 index 00000000000..ffac287d0e5 --- /dev/null +++ b/public/app/features/provisioning/hooks/useCreateOrUpdateConnection.ts @@ -0,0 +1,40 @@ +import { useCallback } from 'react'; + +import { + Connection, + ConnectionSpec, + ConnectionSecure, + useCreateConnectionMutation, + useReplaceConnectionMutation, +} from 'app/api/clients/provisioning/v0alpha1'; + +export function useCreateOrUpdateConnection(name?: string) { + const [create, createRequest] = useCreateConnectionMutation(); + const [update, updateRequest] = useReplaceConnectionMutation(); + + const updateOrCreate = useCallback( + async (data: ConnectionSpec, privateKey?: string) => { + const secure: ConnectionSecure | undefined = privateKey?.length + ? { privateKey: { create: privateKey } } + : undefined; + + const connection: Connection = { + metadata: name ? { name } : { generateName: 'c' }, + spec: data, + secure, + }; + + if (name) { + return update({ + name, + connection, + }); + } + + return create({ connection }); + }, + [create, name, update] + ); + + return [updateOrCreate, name ? updateRequest : createRequest] as const; +} diff --git a/public/app/features/provisioning/types.ts b/public/app/features/provisioning/types.ts index 0c8dfec5dbd..cf32867253f 100644 --- a/public/app/features/provisioning/types.ts +++ b/public/app/features/provisioning/types.ts @@ -5,6 +5,7 @@ import { SelectableValue } from '@grafana/data'; import { BitbucketRepositoryConfig, + ConnectionSpec, GitHubRepositoryConfig, GitLabRepositoryConfig, GitRepositoryConfig, @@ -51,6 +52,16 @@ export type RepositoryFormData = Omit; +// Connection type definition - extracted from API client +export type ConnectionType = ConnectionSpec['type']; + +export type ConnectionFormData = { + type: ConnectionSpec['type']; + appID: string; + installationID: string; + privateKey?: string; +}; + // Section configuration export interface RepositorySection { name: string; diff --git a/public/app/features/provisioning/utils/getFormErrors.ts b/public/app/features/provisioning/utils/getFormErrors.ts index 4b187c131d1..e708b4db41e 100644 --- a/public/app/features/provisioning/utils/getFormErrors.ts +++ b/public/app/features/provisioning/utils/getFormErrors.ts @@ -3,7 +3,7 @@ import { Path } from 'react-hook-form'; import { ErrorDetails } from 'app/api/clients/provisioning/v0alpha1'; import { WizardFormData } from '../Wizard/types'; -import { RepositoryFormData } from '../types'; +import { ConnectionFormData, RepositoryFormData } from '../types'; export type RepositoryField = keyof WizardFormData['repository']; export type RepositoryFormPath = `repository.${RepositoryField}` | 'repository.sync.intervalSeconds'; @@ -89,3 +89,20 @@ export const getConfigFormErrors = (errors?: ErrorDetails[]): ConfigFormErrorTup return mapErrorsToField(errors, fieldMap, { allowPartial: true }); }; + +// Connection form errors +export type ConnectionFormPath = Path; +export type ConnectionFormErrorTuple = GenericFormErrorTuple; + +export const getConnectionFormErrors = (errors?: ErrorDetails[]): ConnectionFormErrorTuple => { + const fieldMap: Record = { + appID: 'appID', + installationID: 'installationID', + 'github.appID': 'appID', + 'github.installationID': 'installationID', + 'secure.privateKey': 'privateKey', + privateKey: 'privateKey', + }; + + return mapErrorsToField(errors, fieldMap, { allowPartial: true }); +}; diff --git a/public/app/features/provisioning/utils/routes.ts b/public/app/features/provisioning/utils/routes.ts index 891ecd090d9..57d62d3987f 100644 --- a/public/app/features/provisioning/utils/routes.ts +++ b/public/app/features/provisioning/utils/routes.ts @@ -1,11 +1,17 @@ +import { config } from '@grafana/runtime'; import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; import { RouteDescriptor } from 'app/core/navigation/types'; import { DashboardRoutes } from 'app/types/dashboard'; import { checkRequiredFeatures } from '../GettingStarted/features'; -import { PROVISIONING_URL, CONNECT_URL, GETTING_STARTED_URL } from '../constants'; +import { CONNECTIONS_URL, CONNECT_URL, GETTING_STARTED_URL, PROVISIONING_URL } from '../constants'; export function getProvisioningRoutes(): RouteDescriptor[] { + const featureToggles = config.featureToggles || {}; + if (!featureToggles.provisioning) { + return []; + } + if (!checkRequiredFeatures()) { return [ { @@ -36,6 +42,26 @@ export function getProvisioningRoutes(): RouteDescriptor[] { ) ), }, + { + path: CONNECTIONS_URL, + component: SafeDynamicImport( + () => import(/* webpackChunkName: "ConnectionsPage"*/ 'app/features/provisioning/Connection/ConnectionsPage') + ), + }, + { + path: `${CONNECTIONS_URL}/:name/edit`, + component: SafeDynamicImport( + () => + import(/* webpackChunkName: "ConnectionFormPage"*/ 'app/features/provisioning/Connection/ConnectionFormPage') + ), + }, + { + path: `${CONNECTIONS_URL}/new`, + component: SafeDynamicImport( + () => + import(/* webpackChunkName: "ConnectionFormPage"*/ 'app/features/provisioning/Connection/ConnectionFormPage') + ), + }, { path: `${CONNECT_URL}/:type`, component: SafeDynamicImport( diff --git a/public/app/features/scopes/ScopesApiClient.test.ts b/public/app/features/scopes/ScopesApiClient.test.ts new file mode 100644 index 00000000000..77d9051999d --- /dev/null +++ b/public/app/features/scopes/ScopesApiClient.test.ts @@ -0,0 +1,687 @@ +import { config } from '@grafana/runtime'; +import { MOCK_NODES, MOCK_SCOPES } from '@grafana/test-utils/unstable'; +import { scopeAPIv0alpha1 } from 'app/api/clients/scope/v0alpha1'; + +import { ScopesApiClient } from './ScopesApiClient'; + +// Helper to create a mock subscription with unsubscribe method +const createMockSubscription = (data: T): Promise & { unsubscribe: jest.Mock } => { + const subscription = Promise.resolve(data) as Promise & { unsubscribe: jest.Mock }; + subscription.unsubscribe = jest.fn(); + return subscription; +}; + +// Mock the RTK Query API and dispatch +jest.mock('app/api/clients/scope/v0alpha1', () => ({ + scopeAPIv0alpha1: { + endpoints: { + getScope: { + initiate: jest.fn(), + }, + getScopeNode: { + initiate: jest.fn(), + }, + getFindScopeNodeChildrenResults: { + initiate: jest.fn(), + }, + getFindScopeDashboardBindingsResults: { + initiate: jest.fn(), + }, + getFindScopeNavigationsResults: { + initiate: jest.fn(), + }, + }, + }, +})); + +jest.mock('app/store/store', () => ({ + dispatch: jest.fn((action) => action), +})); + +describe('ScopesApiClient', () => { + let apiClient: ScopesApiClient; + + beforeEach(() => { + apiClient = new ScopesApiClient(); + config.featureToggles.useMultipleScopeNodesEndpoint = true; + config.featureToggles.useScopeSingleNodeEndpoint = true; + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('fetchScope', () => { + it('should fetch a scope by name', async () => { + // Expected: MOCK_SCOPES contains a scope with name 'grafana' + const expectedScope = MOCK_SCOPES.find((s) => s.metadata.name === 'grafana'); + expect(expectedScope).toBeDefined(); + + const mockSubscription = createMockSubscription({ data: expectedScope }); + (scopeAPIv0alpha1.endpoints.getScope.initiate as jest.Mock).mockReturnValue(mockSubscription); + + const result = await apiClient.fetchScope('grafana'); + + // Validate: result matches the expected scope from MOCK_SCOPES + expect(result).toEqual(expectedScope); + expect(scopeAPIv0alpha1.endpoints.getScope.initiate).toHaveBeenCalledWith( + { name: 'grafana' }, + { subscribe: false } + ); + }); + + it('should return undefined when scope is not found', async () => { + // Expected: No scope with this name exists in MOCK_SCOPES + const nonExistentScopeName = 'non-existent-scope'; + const errorResponse = { + kind: 'Status', + apiVersion: 'v1', + status: 'Failure', + message: `scopes.scope.grafana.app "${nonExistentScopeName}" not found`, + code: 404, + }; + const mockSubscription = createMockSubscription({ data: errorResponse }); + (scopeAPIv0alpha1.endpoints.getScope.initiate as jest.Mock).mockReturnValue(mockSubscription); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const result = await apiClient.fetchScope(nonExistentScopeName); + + // Validate: returns undefined for non-existent scope + expect(result).toBeUndefined(); + expect(consoleErrorSpy).toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); + }); + + describe('fetchMultipleScopes', () => { + it('should fetch multiple scopes in parallel', async () => { + // Expected: Both 'grafana' and 'mimir' exist in MOCK_SCOPES + const scopeNames = ['grafana', 'mimir']; + const expectedScopes = MOCK_SCOPES.filter((s) => scopeNames.includes(s.metadata.name)); + + const mockSubscriptions = expectedScopes.map((scope) => createMockSubscription({ data: scope })); + (scopeAPIv0alpha1.endpoints.getScope.initiate as jest.Mock) + .mockReturnValueOnce(mockSubscriptions[0]) + .mockReturnValueOnce(mockSubscriptions[1]); + + const result = await apiClient.fetchMultipleScopes(scopeNames); + + // Validate: returns both scopes from MOCK_SCOPES + expect(result).toHaveLength(2); + expect(result.map((s) => s.metadata.name)).toContain('grafana'); + expect(result.map((s) => s.metadata.name)).toContain('mimir'); + expect(result).toEqual(expect.arrayContaining(expectedScopes)); + }); + + it('should filter out undefined scopes when some fail', async () => { + // Expected: 'grafana' exists in MOCK_SCOPES, 'non-existent' does not + const scopeNames = ['grafana', 'non-existent']; + const expectedScope = MOCK_SCOPES.find((s) => s.metadata.name === 'grafana'); + const errorResponse = { + kind: 'Status', + apiVersion: 'v1', + status: 'Failure', + message: 'scopes.scope.grafana.app "non-existent" not found', + code: 404, + }; + + const mockSubscriptions = [ + createMockSubscription({ data: expectedScope }), + createMockSubscription({ data: errorResponse }), + ]; + (scopeAPIv0alpha1.endpoints.getScope.initiate as jest.Mock) + .mockReturnValueOnce(mockSubscriptions[0]) + .mockReturnValueOnce(mockSubscriptions[1]); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const result = await apiClient.fetchMultipleScopes(scopeNames); + + // Validate: only returns the existing scope from MOCK_SCOPES, filters out the non-existent one + expect(result).toHaveLength(1); + expect(result[0]).toEqual(expectedScope); + expect(result[0].metadata.name).toBe('grafana'); + // Validate: console.warn is called when some scopes fail + expect(consoleWarnSpy).toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); + + it('should return empty array when no scopes provided', async () => { + const result = await apiClient.fetchMultipleScopes([]); + + // Validate: empty input returns empty array + expect(result).toEqual([]); + }); + }); + + describe('fetchMultipleScopeNodes', () => { + it('should fetch multiple nodes by names', async () => { + // Expected: Both nodes exist in MOCK_NODES + const nodeNames = ['applications-grafana', 'applications-mimir']; + const expectedNodes = MOCK_NODES.filter((n) => nodeNames.includes(n.metadata.name)); + + const mockSubscription = createMockSubscription({ + data: { items: expectedNodes }, + }); + (scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); + + const result = await apiClient.fetchMultipleScopeNodes(nodeNames); + + // Validate: returns the expected nodes from MOCK_NODES + expect(result).toHaveLength(2); + expect(result.map((n) => n.metadata.name)).toContain('applications-grafana'); + expect(result.map((n) => n.metadata.name)).toContain('applications-mimir'); + expect(result).toEqual(expect.arrayContaining(expectedNodes)); + }); + + it('should return empty array when names array is empty', async () => { + const result = await apiClient.fetchMultipleScopeNodes([]); + + expect(result).toEqual([]); + }); + + it('should return empty array when feature toggle is disabled', async () => { + config.featureToggles.useMultipleScopeNodesEndpoint = false; + + const result = await apiClient.fetchMultipleScopeNodes(['applications-grafana']); + + expect(result).toEqual([]); + + // Restore feature toggle + config.featureToggles.useMultipleScopeNodesEndpoint = true; + }); + + it('should handle API errors gracefully', async () => { + // Expected: No node with this name exists in MOCK_NODES + const nonExistentNodeName = 'non-existent-node'; + const mockSubscription = createMockSubscription({ data: { items: [] } }); + (scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const result = await apiClient.fetchMultipleScopeNodes([nonExistentNodeName]); + + // Validate: returns empty array when no matches + expect(result).toEqual([]); + consoleErrorSpy.mockRestore(); + }); + + it('should handle response with no items field', async () => { + // Expected: Node exists in MOCK_NODES + const nodeName = 'applications-grafana'; + const mockSubscription = createMockSubscription({ data: {} }); + (scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); + + const result = await apiClient.fetchMultipleScopeNodes([nodeName]); + + // Validate: returns empty array when items field is missing + expect(result).toEqual([]); + }); + + it('should handle large arrays of node names', async () => { + // Expected: None of these node names exist in MOCK_NODES + const nonExistentNodeNames = Array.from({ length: 10 }, (_, i) => `node-${i}`); + const mockSubscription = createMockSubscription({ data: { items: [] } }); + (scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); + + const result = await apiClient.fetchMultipleScopeNodes(nonExistentNodeNames); + + // Validate: returns empty array when no matches + expect(Array.isArray(result)).toBe(true); + expect(result).toEqual([]); + }); + + it('should pass through node names exactly as provided', async () => { + // Expected: Both nodes exist in MOCK_NODES + const nodeNames = ['applications-grafana', 'applications-mimir']; + const expectedNodes = MOCK_NODES.filter((n) => nodeNames.includes(n.metadata.name)); + const mockSubscription = createMockSubscription({ + data: { items: expectedNodes }, + }); + (scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); + + const result = await apiClient.fetchMultipleScopeNodes(nodeNames); + + // Validate: returns nodes matching the provided names + const resultNames = result.map((n) => n.metadata.name); + expect(resultNames).toEqual(expect.arrayContaining(nodeNames)); + // Verify we got the expected nodes from MOCK_NODES + expectedNodes.forEach((expectedNode) => { + expect(result).toContainEqual(expectedNode); + }); + }); + }); + + describe('fetchScopeNode', () => { + it('should fetch a single scope node by ID', async () => { + // Expected: Node exists in MOCK_NODES + const nodeName = 'applications-grafana'; + const expectedNode = MOCK_NODES.find((n) => n.metadata.name === nodeName); + expect(expectedNode).toBeDefined(); + + const mockSubscription = createMockSubscription({ data: expectedNode }); + (scopeAPIv0alpha1.endpoints.getScopeNode.initiate as jest.Mock).mockReturnValue(mockSubscription); + + const result = await apiClient.fetchScopeNode(nodeName); + + // Validate: result matches the expected node from MOCK_NODES + expect(result).toEqual(expectedNode); + }); + + it('should return undefined when feature toggle is disabled', async () => { + config.featureToggles.useScopeSingleNodeEndpoint = false; + + const result = await apiClient.fetchScopeNode('applications-grafana'); + + expect(result).toBeUndefined(); + + // Restore feature toggle + config.featureToggles.useScopeSingleNodeEndpoint = true; + }); + + it('should return undefined on API error', async () => { + // Expected: No node with this name exists in MOCK_NODES + const nonExistentNodeName = 'non-existent-node'; + const errorResponse = { + kind: 'Status', + apiVersion: 'v1', + status: 'Failure', + message: `scopenodes.scope.grafana.app "${nonExistentNodeName}" not found`, + code: 404, + }; + const mockSubscription = createMockSubscription({ data: errorResponse }); + (scopeAPIv0alpha1.endpoints.getScopeNode.initiate as jest.Mock).mockReturnValue(mockSubscription); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const result = await apiClient.fetchScopeNode(nonExistentNodeName); + + // Validate: returns undefined for non-existent node + expect(result).toBeUndefined(); + consoleErrorSpy.mockRestore(); + }); + }); + + describe('fetchNodes', () => { + it('should fetch nodes with parent filter', async () => { + // Expected: MOCK_NODES contains nodes with parentName 'applications' + const parentName = 'applications'; + const expectedNodes = MOCK_NODES.filter((n) => n.spec.parentName === parentName); + + const mockSubscription = createMockSubscription({ + data: { items: expectedNodes }, + }); + (scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); + + const result = await apiClient.fetchNodes({ parent: parentName }); + + // Validate: returns nodes with matching parentName from MOCK_NODES + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + result.forEach((node) => { + expect(node.spec.parentName).toBe(parentName); + }); + // Verify all returned nodes are from the expected set + result.forEach((node) => { + expect(expectedNodes).toContainEqual(node); + }); + }); + + it('should fetch nodes with query filter', async () => { + // Expected: MOCK_NODES contains nodes with 'Grafana' in title (case-insensitive) + // When query is provided without parent, the API returns nodes matching the query + // In MOCK_NODES, nodes with 'Grafana' in title have parentName 'applications' or 'cloud-applications' + const query = 'Grafana'; + const expectedNodes = MOCK_NODES.filter((n) => n.spec.title.toLowerCase().includes(query.toLowerCase())); + + const mockSubscription = createMockSubscription({ + data: { items: expectedNodes }, + }); + (scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); + + const result = await apiClient.fetchNodes({ query }); + + // Validate: returns nodes matching the query from MOCK_NODES + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + result.forEach((node) => { + expect(node.spec.title.toLowerCase()).toContain('grafana'); + }); + // Verify all returned nodes are from the expected set + result.forEach((node) => { + expect(expectedNodes).toContainEqual(node); + }); + }); + + it('should respect custom limit', async () => { + const limit = 5; + const mockNodes = MOCK_NODES.slice(0, limit); + const mockSubscription = createMockSubscription({ + data: { items: mockNodes }, + }); + (scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); + + const result = await apiClient.fetchNodes({ limit }); + + expect(result.length).toBeLessThanOrEqual(limit); + }); + + it('should throw error for invalid limit (too small)', async () => { + await expect(apiClient.fetchNodes({ limit: 0 })).rejects.toThrow('Limit must be between 1 and 10000'); + }); + + it('should throw error for invalid limit (too large)', async () => { + await expect(apiClient.fetchNodes({ limit: 10001 })).rejects.toThrow('Limit must be between 1 and 10000'); + }); + + it('should use default limit of 1000 when not specified', async () => { + const mockNodes = MOCK_NODES.slice(0, 1000); + const mockSubscription = createMockSubscription({ + data: { items: mockNodes }, + }); + (scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); + + const result = await apiClient.fetchNodes({}); + + expect(Array.isArray(result)).toBe(true); + // Default limit is 1000, so result should not exceed that + expect(result.length).toBeLessThanOrEqual(1000); + }); + + it('should return empty array on API error', async () => { + const mockSubscription = createMockSubscription({ data: { items: [] } }); + (scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const result = await apiClient.fetchNodes({ parent: 'non-existent-parent' }); + + expect(Array.isArray(result)).toBe(true); + consoleErrorSpy.mockRestore(); + }); + + it('should combine parent and query filters', async () => { + // Expected: MOCK_NODES contains nodes with parentName 'applications' and 'Grafana' in title + const parentName = 'applications'; + const query = 'Grafana'; + const expectedNodes = MOCK_NODES.filter( + (n) => n.spec.parentName === parentName && n.spec.title.toLowerCase().includes(query.toLowerCase()) + ); + + const mockSubscription = createMockSubscription({ + data: { items: expectedNodes }, + }); + (scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); + + const result = await apiClient.fetchNodes({ parent: parentName, query }); + + // Validate: returns nodes matching both filters from MOCK_NODES + expect(Array.isArray(result)).toBe(true); + result.forEach((node) => { + expect(node.spec.parentName).toBe(parentName); + expect(node.spec.title.toLowerCase()).toContain('grafana'); + }); + // Verify all returned nodes are from the expected set + result.forEach((node) => { + expect(expectedNodes).toContainEqual(node); + }); + }); + }); + + describe('fetchDashboards', () => { + it('should fetch dashboards for scopes', async () => { + // Expected: MOCK_SCOPE_DASHBOARD_BINDINGS contains bindings for 'grafana' scope + const scopeNames = ['grafana']; + const mockBindings = [ + { + metadata: { name: 'grafana-binding-1' }, + spec: { dashboard: 'dashboard-1', scope: 'grafana' }, + status: { dashboardTitle: 'Dashboard 1' }, + }, + ]; + const mockSubscription = createMockSubscription({ + data: { items: mockBindings }, + }); + (scopeAPIv0alpha1.endpoints.getFindScopeDashboardBindingsResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); + + const result = await apiClient.fetchDashboards(scopeNames); + + // Validate: returns bindings for the requested scope + expect(Array.isArray(result)).toBe(true); + result.forEach((binding) => { + expect(binding.spec.scope).toBe('grafana'); + }); + }); + + it('should fetch dashboards for multiple scopes', async () => { + // Expected: MOCK_SCOPE_DASHBOARD_BINDINGS contains bindings for 'grafana' and 'mimir' scopes + const scopeNames = ['grafana', 'mimir']; + const mockBindings = [ + { + metadata: { name: 'grafana-binding-1' }, + spec: { dashboard: 'dashboard-1', scope: 'grafana' }, + status: { dashboardTitle: 'Dashboard 1' }, + }, + { + metadata: { name: 'mimir-binding-1' }, + spec: { dashboard: 'dashboard-2', scope: 'mimir' }, + status: { dashboardTitle: 'Dashboard 2' }, + }, + ]; + const mockSubscription = createMockSubscription({ + data: { items: mockBindings }, + }); + (scopeAPIv0alpha1.endpoints.getFindScopeDashboardBindingsResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); + + const result = await apiClient.fetchDashboards(scopeNames); + + // Validate: returns bindings for either scope + expect(Array.isArray(result)).toBe(true); + result.forEach((binding) => { + expect(scopeNames).toContain(binding.spec.scope); + }); + }); + + it('should return empty array when no dashboards found', async () => { + const mockSubscription = createMockSubscription({ + data: { items: [] }, + }); + (scopeAPIv0alpha1.endpoints.getFindScopeDashboardBindingsResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); + + const result = await apiClient.fetchDashboards(['non-existent-scope']); + + expect(result).toEqual([]); + }); + + it('should handle API errors gracefully', async () => { + const mockSubscription = createMockSubscription({ + data: { items: [] }, + }); + (scopeAPIv0alpha1.endpoints.getFindScopeDashboardBindingsResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const result = await apiClient.fetchDashboards(['grafana']); + + expect(Array.isArray(result)).toBe(true); + consoleErrorSpy.mockRestore(); + }); + }); + + describe('fetchScopeNavigations', () => { + it('should fetch navigations for scopes', async () => { + // Expected: MSW handler returns MOCK_SUB_SCOPE_MIMIR_ITEMS for 'mimir' scope + const scopeName = 'mimir'; + const mockNavigations = [ + { + metadata: { name: 'mimir-item-1' }, + spec: { scope: 'mimir', url: '/d/mimir-dashboard-1' }, + status: { title: 'Mimir Dashboard 1' }, + }, + ]; + const mockSubscription = createMockSubscription({ + data: { items: mockNavigations }, + }); + (scopeAPIv0alpha1.endpoints.getFindScopeNavigationsResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); + + const result = await apiClient.fetchScopeNavigations([scopeName]); + + // Validate: returns navigations for the requested scope + expect(Array.isArray(result)).toBe(true); + result.forEach((nav) => { + expect(nav.spec.scope).toBe('mimir'); + }); + }); + + it('should fetch navigations for multiple scopes', async () => { + // Expected: Returns navigations for both 'mimir' and 'loki' + const scopeNames = ['mimir', 'loki']; + const mockNavigations = [ + { + metadata: { name: 'mimir-item-1' }, + spec: { scope: 'mimir', url: '/d/mimir-dashboard-1' }, + status: { title: 'Mimir Dashboard 1' }, + }, + { + metadata: { name: 'loki-item-1' }, + spec: { scope: 'loki', url: '/d/loki-dashboard-1' }, + status: { title: 'Loki Dashboard 1' }, + }, + ]; + const mockSubscription = createMockSubscription({ + data: { items: mockNavigations }, + }); + (scopeAPIv0alpha1.endpoints.getFindScopeNavigationsResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); + + const result = await apiClient.fetchScopeNavigations(scopeNames); + + // Validate: returns navigations for both scopes + expect(Array.isArray(result)).toBe(true); + const resultScopeNames = result.map((nav) => nav.spec.scope); + expect(resultScopeNames.length).toBeGreaterThan(0); + result.forEach((nav) => { + expect(scopeNames).toContain(nav.spec.scope); + }); + }); + + it('should return empty array when no navigations found', async () => { + const mockSubscription = createMockSubscription({ + data: { items: [] }, + }); + (scopeAPIv0alpha1.endpoints.getFindScopeNavigationsResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); + + const result = await apiClient.fetchScopeNavigations(['grafana']); + + expect(Array.isArray(result)).toBe(true); + }); + + it('should handle API errors gracefully', async () => { + const mockSubscription = createMockSubscription({ + data: { items: [] }, + }); + (scopeAPIv0alpha1.endpoints.getFindScopeNavigationsResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const result = await apiClient.fetchScopeNavigations(['mimir']); + + expect(Array.isArray(result)).toBe(true); + consoleErrorSpy.mockRestore(); + }); + }); + + describe('performance considerations', () => { + it('should make single batched request with fetchMultipleScopeNodes', async () => { + // This test verifies that the method uses the batched endpoint + const nodeNames = [ + 'applications-grafana', + 'applications-mimir', + 'applications-loki', + 'applications-tempo', + 'applications-cloud', + ]; + const expectedNodes = MOCK_NODES.filter((n) => nodeNames.includes(n.metadata.name)); + const mockSubscription = createMockSubscription({ + data: { items: expectedNodes }, + }); + (scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); + + const result = await apiClient.fetchMultipleScopeNodes(nodeNames); + + expect(Array.isArray(result)).toBe(true); + // Verify it was called once with all names + expect(scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate).toHaveBeenCalledTimes(1); + expect(scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate).toHaveBeenCalledWith( + { names: nodeNames }, + { subscribe: false } + ); + }); + + it('should make N sequential requests with fetchScopeNode (old pattern)', async () => { + // This test demonstrates the old pattern of fetching nodes one by one + // Each call makes a separate API request + const nodeNames = [ + 'applications-grafana', + 'applications-mimir', + 'applications-loki', + 'applications-tempo', + 'applications-cloud', + ]; + const mockNodes = nodeNames.map((name) => MOCK_NODES.find((n) => n.metadata.name === name)).filter(Boolean); + const mockSubscriptions = mockNodes.map((node) => createMockSubscription({ data: node })); + mockSubscriptions.forEach((sub) => { + (scopeAPIv0alpha1.endpoints.getScopeNode.initiate as jest.Mock).mockReturnValueOnce(sub); + }); + + const results = await Promise.all([ + apiClient.fetchScopeNode('applications-grafana'), + apiClient.fetchScopeNode('applications-mimir'), + apiClient.fetchScopeNode('applications-loki'), + apiClient.fetchScopeNode('applications-tempo'), + apiClient.fetchScopeNode('applications-cloud'), + ]); + + expect(results).toHaveLength(5); + expect(results.every((r) => r !== undefined)).toBe(true); + // Verify it was called 5 times (once per node) + expect(scopeAPIv0alpha1.endpoints.getScopeNode.initiate).toHaveBeenCalledTimes(5); + }); + }); +}); diff --git a/public/app/features/scopes/ScopesApiClient.ts b/public/app/features/scopes/ScopesApiClient.ts index 1b2c9f9d1a9..bbfbca130f0 100644 --- a/public/app/features/scopes/ScopesApiClient.ts +++ b/public/app/features/scopes/ScopesApiClient.ts @@ -1,25 +1,95 @@ -import { getAPIBaseURL } from '@grafana/api-clients'; import { Scope, ScopeDashboardBinding, ScopeNode } from '@grafana/data'; -import { getBackendSrv, config } from '@grafana/runtime'; +import { config } from '@grafana/runtime'; +import { scopeAPIv0alpha1 } from 'app/api/clients/scope/v0alpha1'; +import { getMessageFromError } from 'app/core/utils/errors'; +import { dispatch } from 'app/store/store'; import { ScopeNavigation } from './dashboards/types'; -const apiUrl = getAPIBaseURL('scope.grafana.app', 'v0alpha1'); - export class ScopesApiClient { + /** + * Checks if the data is a Kubernetes Status error response. + * @param data The data to check + * @returns true if the data is a Status error, false otherwise + */ + private isStatusError(data: unknown): data is { kind: 'Status'; status: 'Failure'; message?: string; code?: number } { + return ( + data !== null && + typeof data === 'object' && + 'kind' in data && + data.kind === 'Status' && + 'status' in data && + data.status === 'Failure' + ); + } + + /** + * Extracts and validates data from an RTK Query result, checking for error responses. + * @param result The RTK Query result + * @param context Context for error logging (e.g., resource name) + * @returns The data if valid, undefined if it's an error response + */ + private extractDataOrHandleError(result: { data?: T; error?: unknown }, context: string): T | undefined { + if ('data' in result && result.data) { + // Check if the data is actually an error response (Kubernetes Status object) + if (this.isStatusError(result.data)) { + const errorMessage = getMessageFromError(result.data); + console.error(`Failed to fetch %s:`, context, errorMessage); + return undefined; + } + return result.data; + } + + if ('error' in result) { + const errorMessage = getMessageFromError(result.error); + console.error(`Failed to fetch %s:`, context, errorMessage); + } + + return undefined; + } async fetchScope(name: string): Promise { + const subscription = dispatch(scopeAPIv0alpha1.endpoints.getScope.initiate({ name }, { subscribe: false })); try { - return await getBackendSrv().get(apiUrl + `/scopes/${name}`); + const result = await subscription; + return this.extractDataOrHandleError(result, `scope: ${name}`); } catch (err) { - // TODO: maybe some better error handling - console.error(err); + const errorMessage = getMessageFromError(err); + console.error('Failed to fetch scope:', name, errorMessage); return undefined; + } finally { + // Unsubscribe for extra safety, even though with subscribe: false and awaiting, + // the request completes before return, so this is mostly a no-op + subscription.unsubscribe(); } } async fetchMultipleScopes(scopesIds: string[]): Promise { - const scopes = await Promise.all(scopesIds.map((id) => this.fetchScope(id))); - return scopes.filter((scope) => scope !== undefined); + if (scopesIds.length === 0) { + return []; + } + + try { + const scopes = await Promise.all(scopesIds.map((id) => this.fetchScope(id))); + const successfulScopes = scopes.filter((scope) => scope !== undefined); + + if (successfulScopes.length < scopesIds.length) { + const failedCount = scopesIds.length - successfulScopes.length; + console.warn( + 'Failed to fetch', + failedCount, + 'of', + scopesIds.length, + 'scope(s). Requested IDs:', + scopesIds.join(', ') + ); + } + + return successfulScopes; + } catch (err) { + const errorMessage = getMessageFromError(err); + console.error('Failed to fetch multiple scopes:', scopesIds, errorMessage); + return []; + } } async fetchMultipleScopeNodes(names: string[]): Promise { @@ -27,13 +97,31 @@ export class ScopesApiClient { return Promise.resolve([]); } + const subscription = dispatch( + scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate({ names }, { subscribe: false }) + ); try { - const res = await getBackendSrv().get<{ items: ScopeNode[] }>(apiUrl + `/find/scope_node_children`, { - names: names, - }); - return res?.items ?? []; - } catch (err) { + const result = await subscription; + + if ('data' in result && result.data) { + // The generated API returns items compatible with @grafana/data ScopeNode + return result.data.items ?? []; + } + + if ('error' in result) { + const errorMessage = getMessageFromError(result.error); + console.error('Failed to fetch multiple scope nodes:', names, errorMessage); + } + return []; + } catch (err) { + const errorMessage = getMessageFromError(err); + console.error('Failed to fetch multiple scope nodes:', names, errorMessage); + return []; + } finally { + // Unsubscribe for extra safety, even though with subscribe: false and awaiting, + // the request completes before return, so this is mostly a no-op + subscription.unsubscribe(); } } @@ -53,46 +141,128 @@ export class ScopesApiClient { throw new Error('Limit must be between 1 and 10000'); } + const subscription = dispatch( + scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate( + { + parent: options.parent, + query: options.query, + limit, + }, + { subscribe: false, forceRefetch: true } // Froce refetch for search. Revisit this when necessary + ) + ); try { - const nodes = - ( - await getBackendSrv().get<{ items: ScopeNode[] }>(apiUrl + `/find/scope_node_children`, { - parent: options.parent, - query: options.query, - limit, - }) - )?.items ?? []; + const result = await subscription; + + if ('data' in result && result.data) { + // The generated API returns items compatible with @grafana/data ScopeNode + return result.data.items ?? []; + } + + if ('error' in result) { + const errorMessage = getMessageFromError(result.error); + const contextParts: string[] = []; + if (options.parent) { + contextParts.push('parent="' + options.parent + '"'); + } + if (options.query) { + contextParts.push('query="' + options.query + '"'); + } + contextParts.push('limit=' + limit); + const context = contextParts.join(', '); + console.error('Failed to fetch scope nodes:', context, errorMessage); + } - return nodes; - } catch (err) { return []; + } catch (err) { + const errorMessage = getMessageFromError(err); + const contextParts: string[] = []; + if (options.parent) { + contextParts.push('parent="' + options.parent + '"'); + } + if (options.query) { + contextParts.push('query="' + options.query + '"'); + } + contextParts.push('limit=' + limit); + const context = contextParts.join(', '); + console.error('Failed to fetch scope nodes:', context, errorMessage); + return []; + } finally { + // Unsubscribe for extra safety, even though with subscribe: false and awaiting, + // the request completes before return, so this is mostly a no-op + subscription.unsubscribe(); } } public fetchDashboards = async (scopeNames: string[]): Promise => { - try { - const response = await getBackendSrv().get<{ items: ScopeDashboardBinding[] }>( - apiUrl + `/find/scope_dashboard_bindings`, + const subscription = dispatch( + // Note: `name` is required by generated types but ignored by the query builder (codegen bug) + scopeAPIv0alpha1.endpoints.getFindScopeDashboardBindingsResults.initiate( { + name: '', scope: scopeNames, - } - ); + }, + { subscribe: false } + ) + ); + try { + const result = await subscription; + + if ('data' in result && result.data) { + // The generated API returns items compatible with @grafana/data ScopeDashboardBinding + return result.data.items ?? []; + } + + if ('error' in result) { + const errorMessage = getMessageFromError(result.error); + console.error('Failed to fetch dashboards for scopes:', scopeNames, errorMessage); + } - return response?.items ?? []; - } catch (err) { return []; + } catch (err) { + const errorMessage = getMessageFromError(err); + console.error('Failed to fetch dashboards for scopes:', scopeNames, errorMessage); + return []; + } finally { + // Unsubscribe for extra safety, even though with subscribe: false and awaiting, + // the request completes before return, so this is mostly a no-op + subscription.unsubscribe(); } }; public fetchScopeNavigations = async (scopeNames: string[]): Promise => { + const subscription = dispatch( + // Note: `name` is required by generated types but ignored by the query builder (codegen bug) + scopeAPIv0alpha1.endpoints.getFindScopeNavigationsResults.initiate( + { + name: '', + scope: scopeNames, + }, + { subscribe: false } + ) + ); try { - const response = await getBackendSrv().get<{ items: ScopeNavigation[] }>(apiUrl + `/find/scope_navigations`, { - scope: scopeNames, - }); + const result = await subscription; + + if ('data' in result && result.data) { + // The generated API returns items compatible with ScopeNavigation + return result.data.items ?? []; + } + + if ('error' in result) { + const errorMessage = getMessageFromError(result.error); + console.error('Failed to fetch scope navigations for scopes:', scopeNames, errorMessage); + } - return response?.items ?? []; - } catch (err) { return []; + } catch (err) { + const errorMessage = getMessageFromError(err); + console.error('Failed to fetch scope navigations for scopes:', scopeNames, errorMessage); + return []; + } finally { + // Unsubscribe for extra safety, even though with subscribe: false and awaiting, + // the request completes before return, so this is mostly a no-op + subscription.unsubscribe(); } }; @@ -100,11 +270,21 @@ export class ScopesApiClient { if (!config.featureToggles.useScopeSingleNodeEndpoint) { return Promise.resolve(undefined); } + + const subscription = dispatch( + scopeAPIv0alpha1.endpoints.getScopeNode.initiate({ name: scopeNodeId }, { subscribe: false }) + ); try { - const response = await getBackendSrv().get(apiUrl + `/scopenodes/${scopeNodeId}`); - return response; + const result = await subscription; + return this.extractDataOrHandleError(result, `scope node: ${scopeNodeId}`); } catch (err) { + const errorMessage = getMessageFromError(err); + console.error('Failed to fetch scope node:', scopeNodeId, errorMessage); return undefined; + } finally { + // Unsubscribe for extra safety, even though with subscribe: false and awaiting, + // the request completes before return, so this is mostly a no-op + subscription.unsubscribe(); } }; } diff --git a/public/app/features/scopes/ScopesService.test.ts b/public/app/features/scopes/ScopesService.test.ts index fdc0c8bd598..a90de0ef95b 100644 --- a/public/app/features/scopes/ScopesService.test.ts +++ b/public/app/features/scopes/ScopesService.test.ts @@ -1,5 +1,6 @@ import { BehaviorSubject } from 'rxjs'; +import { ScopeSpecFilter } from '@grafana/data'; import { LocationService } from '@grafana/runtime'; import { ScopesService } from './ScopesService'; @@ -16,8 +17,20 @@ describe('ScopesService', () => { let locationService: jest.Mocked; let selectorStateSubscription: | (( - state: { appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }> }, - prevState: { appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }> } + state: { + appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }>; + scopes?: Record< + string, + { metadata: { name: string }; spec: { title: string; defaultPath?: string[]; filters: ScopeSpecFilter[] } } + >; + }, + prevState: { + appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }>; + scopes?: Record< + string, + { metadata: { name: string }; spec: { title: string; defaultPath?: string[]; filters: ScopeSpecFilter[] } } + >; + } ) => void) | undefined; let dashboardsStateSubscription: @@ -274,9 +287,11 @@ describe('ScopesService', () => { selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }], + scopes: {}, }, { appliedScopes: [], + scopes: {}, } ); @@ -298,9 +313,11 @@ describe('ScopesService', () => { selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1', parentNodeId: 'parent1' }], + scopes: {}, }, { appliedScopes: [], + scopes: {}, } ); @@ -320,9 +337,11 @@ describe('ScopesService', () => { selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node2' }], + scopes: {}, }, { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }], + scopes: {}, } ); @@ -344,9 +363,11 @@ describe('ScopesService', () => { selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1' }], + scopes: {}, }, { appliedScopes: [], + scopes: {}, } ); @@ -370,15 +391,171 @@ describe('ScopesService', () => { selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }], + scopes: {}, }, { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }], + scopes: {}, } ); expect(locationService.partial).not.toHaveBeenCalled(); }); + describe('defaultPath support', () => { + it('should extract scope_node from defaultPath when available', () => { + if (!selectorStateSubscription) { + throw new Error('selectorStateSubscription not set'); + } + + selectorStateSubscription( + { + appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'old-node' }], + scopes: { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + defaultPath: ['', 'parent-node', 'correct-node'], + filters: [], + }, + }, + }, + }, + { + appliedScopes: [], + scopes: {}, + } + ); + + // Should use 'correct-node' from defaultPath, not 'old-node' from appliedScopes + expect(locationService.partial).toHaveBeenCalledWith( + { + scopes: ['scope1'], + scope_node: 'correct-node', + scope_parent: null, + }, + true + ); + }); + + it('should fallback to scopeNodeId when defaultPath is not available', () => { + if (!selectorStateSubscription) { + throw new Error('selectorStateSubscription not set'); + } + + selectorStateSubscription( + { + appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'fallback-node' }], + scopes: { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + filters: [], + }, + }, + }, + }, + { + appliedScopes: [], + scopes: {}, + } + ); + + // Should fallback to scopeNodeId from appliedScopes + expect(locationService.partial).toHaveBeenCalledWith( + { + scopes: ['scope1'], + scope_node: 'fallback-node', + scope_parent: null, + }, + true + ); + }); + + it('should handle empty defaultPath gracefully', () => { + if (!selectorStateSubscription) { + throw new Error('selectorStateSubscription not set'); + } + + selectorStateSubscription( + { + appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'fallback-node' }], + scopes: { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + defaultPath: [], + filters: [], + }, + }, + }, + }, + { + appliedScopes: [], + scopes: {}, + } + ); + + // Should fallback to scopeNodeId when defaultPath is empty + expect(locationService.partial).toHaveBeenCalledWith( + { + scopes: ['scope1'], + scope_node: 'fallback-node', + scope_parent: null, + }, + true + ); + }); + + it('should detect changes in defaultPath-derived scopeNodeId', () => { + if (!selectorStateSubscription) { + throw new Error('selectorStateSubscription not set'); + } + + selectorStateSubscription( + { + appliedScopes: [{ scopeId: 'scope1' }], + scopes: { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + defaultPath: ['', 'parent', 'new-node'], + filters: [], + }, + }, + }, + }, + { + appliedScopes: [{ scopeId: 'scope1' }], + scopes: { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + defaultPath: ['', 'parent', 'old-node'], + filters: [], + }, + }, + }, + } + ); + + // Should detect the change in defaultPath-derived scopeNodeId + expect(locationService.partial).toHaveBeenCalledWith( + { + scopes: ['scope1'], + scope_node: 'new-node', + scope_parent: null, + }, + true + ); + }); + }); + it('should write navigation_scope to URL when navigationScope changes', () => { if (!dashboardsStateSubscription) { throw new Error('dashboardsStateSubscription not set'); @@ -622,6 +799,30 @@ describe('ScopesService', () => { true ); }); + + it('should use defaultPath for scope_node when enabling scopes', () => { + selectorService.state.appliedScopes = [{ scopeId: 'scope1', scopeNodeId: 'old-node' }]; + selectorService.state.scopes = { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + defaultPath: ['', 'parent', 'correct-node-from-defaultPath'], + filters: [], + }, + }, + }; + + service.setEnabled(true); + + // Should use defaultPath instead of scopeNodeId from appliedScopes + expect(locationService.partial).toHaveBeenCalledWith( + expect.objectContaining({ + scope_node: 'correct-node-from-defaultPath', + }), + true + ); + }); }); describe('back/forward navigation handling', () => { diff --git a/public/app/features/scopes/ScopesService.ts b/public/app/features/scopes/ScopesService.ts index c92d6328749..8dd40fd953f 100644 --- a/public/app/features/scopes/ScopesService.ts +++ b/public/app/features/scopes/ScopesService.ts @@ -151,12 +151,26 @@ export class ScopesService implements ScopesContextValue { // Update the URL based on change in the scopes state this.subscriptions.push( selectorService.subscribeToState((state, prevState) => { - const oldScopeNodeId = prevState.appliedScopes[0]?.scopeNodeId; - const newScopeNodeId = state.appliedScopes[0]?.scopeNodeId; - const oldScopeNames = prevState.appliedScopes.map((scope) => scope.scopeId); const newScopeNames = state.appliedScopes.map((scope) => scope.scopeId); + // Extract scopeNodeId from defaultPath when available + const getScopeNodeId = (appliedScopes: typeof state.appliedScopes, scopes: typeof state.scopes) => { + const firstScope = appliedScopes[0]; + if (!firstScope) { + return undefined; + } + const scope = scopes[firstScope.scopeId]; + // Prefer defaultPath when available + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) { + return scope.spec.defaultPath[scope.spec.defaultPath.length - 1]; + } + return firstScope.scopeNodeId; + }; + + const oldScopeNodeId = getScopeNodeId(prevState.appliedScopes, prevState.scopes); + const newScopeNodeId = getScopeNodeId(state.appliedScopes, state.scopes); + const scopesChanged = !isEqual(oldScopeNames, newScopeNames); const scopeNodeChanged = oldScopeNodeId !== newScopeNodeId; @@ -230,7 +244,7 @@ export class ScopesService implements ScopesContextValue { if (this.state.enabled !== enabled) { this.updateState({ enabled }); if (enabled) { - const scopeNodeId = this.selectorService.state.appliedScopes[0]?.scopeNodeId; + const scopeNodeId = this.getScopeNodeIdForUrl(); this.locationService.partial( { scopes: this.selectorService.state.appliedScopes.map((s) => s.scopeId), @@ -243,6 +257,29 @@ export class ScopesService implements ScopesContextValue { } }; + /** + * Extracts the scopeNodeId for URL syncing, preferring defaultPath when available. + * When a scope has defaultPath, that is the source of truth for the node ID. + * @private + */ + private getScopeNodeIdForUrl(): string | undefined { + const firstScope = this.selectorService.state.appliedScopes[0]; + if (!firstScope) { + return undefined; + } + + const scope = this.selectorService.state.scopes[firstScope.scopeId]; + + // Prefer scopeNodeId from defaultPath if available (most reliable source) + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) { + // Extract scopeNodeId from the last element of defaultPath + return scope.spec.defaultPath[scope.spec.defaultPath.length - 1]; + } + + // Fallback to next in priority order: scopeNodeId from appliedScopes + return firstScope.scopeNodeId; + } + /** * Returns observable that emits when relevant parts of the selectorService state change. * @private diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts b/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts index 2c5e4b28fc6..97b24dd69ac 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts +++ b/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts @@ -5,7 +5,11 @@ import { config, locationService } from '@grafana/runtime'; import { ScopesApiClient } from '../ScopesApiClient'; // Import mock data for subScope tests -import { navigationWithSubScope, navigationWithSubScope2, navigationWithSubScopeAndGroups } from '../tests/utils/mocks'; +import { + navigationWithSubScope, + navigationWithSubScope2, + navigationWithSubScopeAndGroups, +} from '../tests/utils/mockData'; import { ScopesDashboardsService, filterItemsWithSubScopesInPath } from './ScopesDashboardsService'; import { ScopeNavigation } from './types'; diff --git a/public/app/features/scopes/selector/ScopesInput.tsx b/public/app/features/scopes/selector/ScopesInput.tsx index a180c523e6b..205c3b0356f 100644 --- a/public/app/features/scopes/selector/ScopesInput.tsx +++ b/public/app/features/scopes/selector/ScopesInput.tsx @@ -31,13 +31,33 @@ export function ScopesInput({ onInputClick, onRemoveAllClick, }: ScopesInputProps) { - const scopeNodeId = appliedScopes[0]?.scopeNodeId; + const firstScope = appliedScopes[0]; + const scope = scopes[firstScope?.scopeId]; const styles = useStyles2(getStyles); - const parentNodeIdFromRecentScopes = appliedScopes[0]?.parentNodeId; // This is only set from recent scopes TODO: remove after recent scopes refactor + + // Prefer scopeNodeId from defaultPath if available (most reliable source) + let scopeNodeId: string | undefined; + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) { + // Extract scopeNodeId from the last element of defaultPath + scopeNodeId = scope.spec.defaultPath[scope.spec.defaultPath.length - 1]; + } else { + // Fallback to next in priority order: scopeNodeId from appliedScopes + scopeNodeId = firstScope?.scopeNodeId; + } + const { node: scopeNode, isLoading: scopeNodeLoading } = useScopeNode(scopeNodeId); - // Get parent from scope node if available, otherwise fallback to parent - const parentNodeId = scopeNode?.spec.parentName ?? parentNodeIdFromRecentScopes; + // Prefer parentNodeId from defaultPath if available + let parentNodeId: string | undefined; + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 1) { + // Extract parentNodeId from the second-to-last element of defaultPath + parentNodeId = scope.spec.defaultPath[scope.spec.defaultPath.length - 2]; + } else { + // Fallback to parent from scope node or recent scopes + const parentNodeIdFromRecentScopes = firstScope?.parentNodeId; + parentNodeId = scopeNode?.spec.parentName ?? parentNodeIdFromRecentScopes; + } + const { node: parentNode, isLoading: parentNodeLoading } = useScopeNode(parentNodeId); // Prioritize scope node subtitle over parent node title @@ -99,16 +119,31 @@ export function ScopesInput({ ); } -const getScopesPath = (appliedScopes: SelectedScope[], nodes: NodesMap) => { +const getScopesPath = ( + appliedScopes: SelectedScope[], + nodes: NodesMap, + defaultPath?: string[] +): string[] | undefined => { let nicePath: string[] | undefined; - if (appliedScopes.length > 0 && appliedScopes[0].scopeNodeId) { - let path = getPathOfNode(appliedScopes[0].scopeNodeId, nodes); - // Get reed of empty root section and the actual scope node - path = path.slice(1, -1); + if (appliedScopes.length > 0) { + const firstScope = appliedScopes[0]; - // We may not have all the nodes in path loaded - nicePath = path.map((p) => nodes[p]?.spec.title).filter((p) => p); + // Prefer defaultPath from scope metadata + if (defaultPath && defaultPath.length > 1) { + // Get all nodes except the last one (which is the scope itself) + const pathNodeIds = defaultPath.slice(0, -1); + nicePath = pathNodeIds.map((nodeId) => nodes[nodeId]?.spec.title).filter((title) => title); + } + // Fallback to walking the node tree + else if (firstScope.scopeNodeId) { + let path = getPathOfNode(firstScope.scopeNodeId, nodes); + // Get rid of empty root section and the actual scope node + path = path.slice(1, -1); + + // We may not have all the nodes in path loaded + nicePath = path.map((p) => nodes[p]?.spec.title).filter((p) => p); + } } return nicePath; @@ -127,7 +162,9 @@ function ScopesTooltip({ nodes, scopes, appliedScopes, onRemoveAllClick, disable return t('scopes.selector.input.tooltip', 'Select scope'); } - const nicePath = getScopesPath(appliedScopes, nodes); + const firstScope = appliedScopes[0]; + const scope = scopes[firstScope?.scopeId]; + const nicePath = getScopesPath(appliedScopes, nodes, scope?.spec.defaultPath); const scopeNames = appliedScopes.map((s) => { if (s.scopeNodeId) { return nodes[s.scopeNodeId]?.spec.title || s.scopeNodeId; diff --git a/public/app/features/scopes/selector/ScopesSelectorService.test.ts b/public/app/features/scopes/selector/ScopesSelectorService.test.ts index 58ddb31a92f..e2102db5db7 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.test.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.test.ts @@ -77,7 +77,14 @@ describe('ScopesSelectorService', () => { }), fetchDashboards: jest.fn().mockResolvedValue([]), fetchScopeNavigations: jest.fn().mockResolvedValue([]), - fetchScopeNode: jest.fn().mockResolvedValue(mockNode), + fetchScopeNode: jest.fn().mockImplementation((id: string) => { + // Return undefined for empty string (root node) + if (id === '') { + return Promise.resolve(undefined); + } + return Promise.resolve(mockNode); + }), + fetchMultipleScopeNodes: jest.fn().mockResolvedValue([]), } as unknown as jest.Mocked; dashboardsService = { @@ -435,7 +442,7 @@ describe('ScopesSelectorService', () => { await service.filterNode('', ''); await service.selectScope('test-scope-node'); await service.apply(); - await service.removeAllScopes(); + service.removeAllScopes(); expect(service.state.appliedScopes).toEqual([]); }); @@ -443,7 +450,7 @@ describe('ScopesSelectorService', () => { await service.filterNode('', ''); await service.selectScope('test-scope-node'); await service.apply(); - await service.removeAllScopes(); + service.removeAllScopes(); expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith(undefined, undefined, undefined); }); }); @@ -1236,4 +1243,1076 @@ describe('ScopesSelectorService', () => { expect(locationService.push).toHaveBeenCalledWith('/d/dashboard1'); }); }); + + // Mock data for defaultPath and path helper tests + const regionNode: ScopeNode = { + metadata: { name: 'region-us-west' }, + spec: { + nodeType: 'container', + title: 'US West', + parentName: '', + linkType: undefined, + linkId: undefined, + }, + }; + + const countryNode: ScopeNode = { + metadata: { name: 'country-usa' }, + spec: { + nodeType: 'container', + title: 'USA', + parentName: 'region-us-west', + linkType: undefined, + linkId: undefined, + }, + }; + + const cityNode: ScopeNode = { + metadata: { name: 'city-seattle' }, + spec: { + nodeType: 'container', + title: 'Seattle', + parentName: 'country-usa', + linkType: undefined, + linkId: undefined, + }, + }; + + const datacenterNode: ScopeNode = { + metadata: { name: 'datacenter-sea-1' }, + spec: { + nodeType: 'leaf', + title: 'SEA-1', + parentName: 'city-seattle', + linkType: 'scope', + linkId: 'scope-sea-1', + }, + }; + + const scopeWithDefaultPath: Scope = { + metadata: { name: 'scope-sea-1' }, + spec: { + title: 'Seattle Datacenter 1', + defaultPath: ['region-us-west', 'country-usa', 'city-seattle', 'datacenter-sea-1'], + filters: [], + }, + }; + + const scopeWithoutDefaultPath: Scope = { + metadata: { name: 'scope-no-path' }, + spec: { + title: 'No Path Scope', + filters: [], + }, + }; + + const parentNode: ScopeNode = { + metadata: { name: 'parent' }, + spec: { + nodeType: 'container', + title: 'Parent', + parentName: '', + linkType: undefined, + linkId: undefined, + }, + }; + + const childNode: ScopeNode = { + metadata: { name: 'child' }, + spec: { + nodeType: 'leaf', + title: 'Child', + parentName: 'parent', + linkType: 'scope', + linkId: 'test-scope', + }, + }; + + const grandchildNode: ScopeNode = { + metadata: { name: 'grandchild' }, + spec: { + nodeType: 'leaf', + title: 'Grandchild', + parentName: 'child', + linkType: 'scope', + linkId: 'test-scope-2', + }, + }; + + /* eslint-disable @typescript-eslint/no-explicit-any */ + // Tests for defaultPath functionality + // Note: Tests access protected updateState method via (service as any) casting to set up test state + describe('getScopeNodes', () => { + it('should return cached nodes when available', async () => { + // Pre-populate cache + (service as any).updateState({ + nodes: { + 'region-us-west': regionNode, + 'country-usa': countryNode, + }, + }); + + const result = await service.getScopeNodes(['region-us-west', 'country-usa']); + + expect(result).toEqual([regionNode, countryNode]); + expect(apiClient.fetchMultipleScopeNodes).not.toHaveBeenCalled(); + }); + + it('should fetch only non-cached nodes', async () => { + // Pre-populate cache with one node + (service as any).updateState({ + nodes: { + 'region-us-west': regionNode, + }, + }); + + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([countryNode]); + + const result = await service.getScopeNodes(['region-us-west', 'country-usa']); + + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith(['country-usa']); + expect(result).toEqual([regionNode, countryNode]); + }); + + it('should maintain order of requested nodes', async () => { + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([cityNode, countryNode, regionNode]); + + const result = await service.getScopeNodes(['region-us-west', 'country-usa', 'city-seattle']); + + expect(result).toEqual([regionNode, countryNode, cityNode]); + }); + + it('should update state with fetched nodes', async () => { + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([regionNode, countryNode]); + + await service.getScopeNodes(['region-us-west', 'country-usa']); + + expect(service.state.nodes).toEqual({ + 'region-us-west': regionNode, + 'country-usa': countryNode, + }); + }); + + it('should handle empty array input', async () => { + const result = await service.getScopeNodes([]); + + expect(result).toEqual([]); + expect(apiClient.fetchMultipleScopeNodes).not.toHaveBeenCalled(); + }); + + it('should filter out undefined nodes', async () => { + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([]); + + const result = await service.getScopeNodes(['non-existent-node']); + + expect(result).toEqual([]); + }); + }); + + describe('resolvePathToRoot with defaultPath', () => { + beforeEach(() => { + apiClient.fetchMultipleScopeNodes = jest + .fn() + .mockResolvedValue([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should use defaultPath when scope has it defined', async () => { + // Pre-populate scope cache + (service as any).updateState({ + scopes: { + 'scope-sea-1': scopeWithDefaultPath, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree, 'scope-sea-1'); + + // Should fetch all nodes in defaultPath at once + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith([ + 'region-us-west', + 'country-usa', + 'city-seattle', + 'datacenter-sea-1', + ]); + expect(result.path).toEqual([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should fall back to recursive path walking when no scopeId provided', async () => { + // Setup nodes in cache for recursive walking + (service as any).updateState({ + nodes: { + 'datacenter-sea-1': datacenterNode, + 'city-seattle': cityNode, + 'country-usa': countryNode, + 'region-us-west': regionNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree); + + expect(result.path).toEqual([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should fall back when scope has no defaultPath', async () => { + (service as any).updateState({ + scopes: { + 'scope-no-path': scopeWithoutDefaultPath, + }, + nodes: { + 'datacenter-sea-1': datacenterNode, + 'city-seattle': cityNode, + 'country-usa': countryNode, + 'region-us-west': regionNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree, 'scope-no-path'); + + expect(result.path).toEqual([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should insert path nodes into tree', async () => { + (service as any).updateState({ + scopes: { + 'scope-sea-1': scopeWithDefaultPath, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree, 'scope-sea-1'); + + expect(result.tree.children?.['region-us-west']).toBeDefined(); + expect(result.tree.children?.['region-us-west']?.children?.['country-usa']).toBeDefined(); + expect( + result.tree.children?.['region-us-west']?.children?.['country-usa']?.children?.['city-seattle'] + ).toBeDefined(); + }); + }); + + describe('applyScopes with defaultPath pre-fetching', () => { + it('should pre-fetch all nodes from defaultPath when applying scopes', async () => { + apiClient.fetchMultipleScopes = jest.fn().mockResolvedValue([scopeWithDefaultPath]); + apiClient.fetchMultipleScopeNodes = jest + .fn() + .mockResolvedValue([regionNode, countryNode, cityNode, datacenterNode]); + + await service.changeScopes(['scope-sea-1']); + + // Should batch fetch all nodes in defaultPath + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith([ + 'region-us-west', + 'country-usa', + 'city-seattle', + 'datacenter-sea-1', + ]); + + // All nodes should be in cache + expect(service.state.nodes['region-us-west']).toEqual(regionNode); + expect(service.state.nodes['country-usa']).toEqual(countryNode); + expect(service.state.nodes['city-seattle']).toEqual(cityNode); + expect(service.state.nodes['datacenter-sea-1']).toEqual(datacenterNode); + }); + + it("should only pre-fetch the first scope's defaultPath", async () => { + const scope2: Scope = { + metadata: { name: 'scope-2' }, + spec: { + title: 'Scope 2', + defaultPath: ['region-us-west', 'country-usa', 'city-portland', 'datacenter-pdx-1'], + filters: [], + }, + }; + + const portlandNode: ScopeNode = { + metadata: { name: 'city-portland' }, + spec: { + nodeType: 'container', + title: 'Portland', + parentName: 'country-usa', + linkType: undefined, + linkId: undefined, + }, + }; + + const pdxDatacenterNode: ScopeNode = { + metadata: { name: 'datacenter-pdx-1' }, + spec: { + nodeType: 'leaf', + title: 'PDX-1', + parentName: 'city-portland', + linkType: 'scope', + linkId: 'scope-2', + }, + }; + + apiClient.fetchMultipleScopes = jest.fn().mockResolvedValue([scopeWithDefaultPath, scope2]); + apiClient.fetchMultipleScopeNodes = jest + .fn() + .mockResolvedValue([regionNode, countryNode, cityNode, datacenterNode, portlandNode, pdxDatacenterNode]); + + await service.changeScopes(['scope-sea-1', 'scope-2']); + + // Should only fetch the first scope's defaultPath (not the second scope's) + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith([ + 'region-us-west', + 'country-usa', + 'city-seattle', + 'datacenter-sea-1', + ]); + }); + + it('should not fetch when scopes have no defaultPath', async () => { + apiClient.fetchMultipleScopes = jest.fn().mockResolvedValue([scopeWithoutDefaultPath]); + + await service.changeScopes(['scope-no-path']); + + expect(apiClient.fetchMultipleScopeNodes).not.toHaveBeenCalled(); + }); + + it('should handle empty defaultPath array', async () => { + const scopeWithEmptyPath: Scope = { + metadata: { name: 'scope-empty' }, + spec: { + title: 'Scope Empty', + defaultPath: [], + filters: [], + }, + }; + + apiClient.fetchMultipleScopes = jest.fn().mockResolvedValue([scopeWithEmptyPath]); + + await service.changeScopes(['scope-empty']); + + expect(apiClient.fetchMultipleScopeNodes).not.toHaveBeenCalled(); + }); + }); + + describe('open selector with defaultPath expansion', () => { + beforeEach(() => { + apiClient.fetchNodes = jest.fn().mockImplementation((options) => { + // Return children based on parent + if (options.parent === '') { + return Promise.resolve([regionNode]); + } else if (options.parent === 'region-us-west') { + return Promise.resolve([countryNode]); + } else if (options.parent === 'country-usa') { + return Promise.resolve([cityNode]); + } else if (options.parent === 'city-seattle') { + return Promise.resolve([datacenterNode]); + } + return Promise.resolve([]); + }); + apiClient.fetchMultipleScopeNodes = jest + .fn() + .mockResolvedValue([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should expand to defaultPath when opening selector with applied scope', async () => { + // Apply a scope with defaultPath + (service as any).updateState({ + scopes: { 'scope-sea-1': scopeWithDefaultPath }, + appliedScopes: [{ scopeId: 'scope-sea-1', scopeNodeId: 'datacenter-sea-1' }], + selectedScopes: [{ scopeId: 'scope-sea-1', scopeNodeId: 'datacenter-sea-1' }], + }); + + await service.open(); + + // Should fetch all nodes in the path + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalled(); + + // Tree should be expanded to show the path + expect(service.state.tree.children?.['region-us-west']?.expanded).toBe(true); + expect(service.state.tree.children?.['region-us-west']?.children?.['country-usa']?.expanded).toBe(true); + expect( + service.state.tree.children?.['region-us-west']?.children?.['country-usa']?.children?.['city-seattle']?.expanded + ).toBe(true); + }); + + it('should fall back to parentNodeId when scope has no defaultPath', async () => { + // Pre-populate nodes for fallback behavior + (service as any).updateState({ + scopes: { 'scope-no-path': scopeWithoutDefaultPath }, + nodes: { + 'datacenter-sea-1': datacenterNode, + 'city-seattle': cityNode, + 'country-usa': countryNode, + 'region-us-west': regionNode, + }, + appliedScopes: [{ scopeId: 'scope-no-path', scopeNodeId: 'datacenter-sea-1', parentNodeId: 'city-seattle' }], + selectedScopes: [{ scopeId: 'scope-no-path', scopeNodeId: 'datacenter-sea-1', parentNodeId: 'city-seattle' }], + }); + + await service.open(); + + // Should still expand, but using parentNodeId logic + expect(service.state.opened).toBe(true); + }); + + it('should handle opening selector when scope is not yet loaded', async () => { + (service as any).updateState({ + appliedScopes: [{ scopeId: 'scope-sea-1' }], + selectedScopes: [{ scopeId: 'scope-sea-1' }], + }); + + await service.open(); + + // Should not crash, just open with root nodes + expect(service.state.opened).toBe(true); + }); + }); + + describe('performance improvements', () => { + it('should make only 1 API call for deep hierarchy with defaultPath', async () => { + (service as any).updateState({ + scopes: { 'scope-sea-1': scopeWithDefaultPath }, + }); + + apiClient.fetchMultipleScopeNodes = jest + .fn() + .mockResolvedValue([regionNode, countryNode, cityNode, datacenterNode]); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + await service.resolvePathToRoot('datacenter-sea-1', tree, 'scope-sea-1'); + + // Should make exactly 1 API call + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledTimes(1); + }); + + it('should make N API calls for deep hierarchy without defaultPath (old behavior)', async () => { + // This test documents the old recursive behavior for comparison + apiClient.fetchScopeNode = jest.fn().mockImplementation((id: string) => { + const nodeMap: Record = { + 'datacenter-sea-1': datacenterNode, + 'city-seattle': cityNode, + 'country-usa': countryNode, + 'region-us-west': regionNode, + }; + return Promise.resolve(nodeMap[id]); + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + await service.resolvePathToRoot('datacenter-sea-1', tree); + + // Would make 4 sequential calls in the old implementation + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('datacenter-sea-1'); + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('city-seattle'); + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('country-usa'); + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('region-us-west'); + expect(apiClient.fetchScopeNode).toHaveBeenCalledTimes(4); + }); + }); + + describe('edge cases and error handling', () => { + it('should handle defaultPath with missing nodes gracefully', async () => { + (service as any).updateState({ + scopes: { 'scope-sea-1': scopeWithDefaultPath }, + }); + + // API returns fewer nodes than requested + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([regionNode, countryNode]); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree, 'scope-sea-1'); + + // Should handle partial path gracefully + expect(result.path).toEqual([regionNode, countryNode]); + }); + + it('should handle API errors during batch fetch', async () => { + (service as any).updateState({ + scopes: { 'scope-sea-1': scopeWithDefaultPath }, + }); + + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([]); + + await service.changeScopes(['scope-sea-1']); + + // Should not crash, state should be consistent + expect(service.state.appliedScopes).toEqual([{ scopeId: 'scope-sea-1' }]); + }); + + it('should deduplicate node IDs in defaultPath', async () => { + const scopeWithDuplicates: Scope = { + metadata: { name: 'scope-dupe' }, + spec: { + title: 'Scope with Duplicates', + defaultPath: ['region-us-west', 'country-usa', 'region-us-west', 'country-usa'], + filters: [], + }, + }; + + apiClient.fetchMultipleScopes = jest.fn().mockResolvedValue([scopeWithDuplicates]); + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([regionNode, countryNode]); + + await service.changeScopes(['scope-dupe']); + + // Should only fetch unique nodes + const calledWith = apiClient.fetchMultipleScopeNodes.mock.calls[0][0]; + const uniqueNodes = [...new Set(calledWith)]; + expect(calledWith.length).toBe(uniqueNodes.length); + }); + + it('should handle defaultPath with only root node', async () => { + const scopeWithRootOnly: Scope = { + metadata: { name: 'scope-root' }, + spec: { + title: 'Scope Root Only', + defaultPath: ['region-us-west'], + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'scope-root': scopeWithRootOnly }, + }); + + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([regionNode]); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('region-us-west', tree, 'scope-root'); + + expect(result.path).toEqual([regionNode]); + }); + }); + + describe('backwards compatibility', () => { + it('should work with existing code that does not provide scopeId to resolvePathToRoot', async () => { + (service as any).updateState({ + nodes: { + 'datacenter-sea-1': datacenterNode, + 'city-seattle': cityNode, + 'country-usa': countryNode, + 'region-us-west': regionNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree); + + expect(result.path).toEqual([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should not break when scope metadata is loaded after applying', async () => { + // This simulates the async nature of scope loading + apiClient.fetchMultipleScopes = jest.fn().mockImplementation(async () => { + // Simulate delay + await new Promise((resolve) => setTimeout(resolve, 10)); + return [scopeWithDefaultPath]; + }); + + await service.changeScopes(['scope-sea-1']); + + // Scope should eventually be in state + expect(service.state.scopes['scope-sea-1']).toEqual(scopeWithDefaultPath); + }); + }); + + // Tests for path helper methods + describe('getPathForScope (new helper method)', () => { + it('should prefer defaultPath from scope metadata', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child'], + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + }); + + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([parentNode, childNode]); + + // This tests the new getPathForScope method that should be created + // For now, this is testing the expected behavior through resolvePathToRoot + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('child', tree, 'test-scope'); + + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith(['parent', 'child']); + expect(result.path).toEqual([parentNode, childNode]); + }); + + it('should fall back to scopeNodeId when no defaultPath', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + nodes: { + parent: parentNode, + child: childNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('child', tree, 'test-scope'); + + expect(result.path).toEqual([parentNode, childNode]); + }); + + it('should return empty array when both scopeId and scopeNodeId are undefined', async () => { + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('', tree); + + expect(result.path).toEqual([]); + }); + + it('should handle scope not being in cache yet', async () => { + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + (service as any).updateState({ + nodes: { + parent: parentNode, + child: childNode, + }, + }); + + // Scope not in cache, but scopeNodeId is provided + const result = await service.resolvePathToRoot('child', tree, 'unknown-scope'); + + // Should fall back to node-based path + expect(result.path).toEqual([parentNode, childNode]); + }); + }); + + describe('getNodePath - optimized implementation', () => { + it('should build path from cached nodes without API calls', async () => { + (service as any).updateState({ + nodes: { + parent: parentNode, + child: childNode, + grandchild: grandchildNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('grandchild', tree); + + // Should not make any API calls since all nodes are cached + expect(apiClient.fetchScopeNode).not.toHaveBeenCalled(); + expect(result.path).toEqual([parentNode, childNode, grandchildNode]); + }); + + it('should fetch missing nodes in the path', async () => { + // Only grandchild is cached + (service as any).updateState({ + nodes: { + grandchild: grandchildNode, + }, + }); + + apiClient.fetchScopeNode = jest.fn().mockImplementation((id: string) => { + if (id === 'child') { + return Promise.resolve(childNode); + } + if (id === 'parent') { + return Promise.resolve(parentNode); + } + return Promise.resolve(undefined); + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + await service.resolvePathToRoot('grandchild', tree); + + // Should fetch missing parent nodes + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('child'); + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('parent'); + }); + + it('should handle circular references gracefully', async () => { + const circularNode1: ScopeNode = { + metadata: { name: 'node1' }, + spec: { + nodeType: 'container', + title: 'Node 1', + parentName: 'node2', + }, + }; + + const circularNode2: ScopeNode = { + metadata: { name: 'node2' }, + spec: { + nodeType: 'container', + title: 'Node 2', + parentName: 'node1', + }, + }; + + (service as any).updateState({ + nodes: { + node1: circularNode1, + node2: circularNode2, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + // This should not hang or crash + // Implementation should detect circular references and stop recursion + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const result = await service.resolvePathToRoot('node1', tree); + + expect(result).toBeDefined(); + // When circular reference is detected, it returns partial path (up to the circular point) + expect(result.path.length).toBeGreaterThan(0); + expect(consoleErrorSpy).toHaveBeenCalledWith('Circular reference detected in node path', expect.any(String)); + consoleErrorSpy.mockRestore(); + }); + + it('should stop at root node (empty parentName)', async () => { + (service as any).updateState({ + nodes: { + parent: parentNode, + child: childNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('child', tree); + + expect(result.path).toEqual([parentNode, childNode]); + expect(result.path[0].spec.parentName).toBe(''); + }); + }); + + describe('expandToSelectedScope (new helper method)', () => { + beforeEach(() => { + apiClient.fetchNodes = jest.fn().mockImplementation((options) => { + // Return children based on parent + if (options.parent === '') { + return Promise.resolve([parentNode]); + } else if (options.parent === 'parent') { + return Promise.resolve([childNode]); + } + return Promise.resolve([]); + }); + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([parentNode, childNode]); + }); + + it('should expand tree to show selected scope path', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child'], + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + selectedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + appliedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + }); + + await service.open(); + + // Tree should be expanded to show the path + expect(service.state.tree.children?.['parent']?.expanded).toBe(true); + expect(service.state.tree.children?.['parent']?.children?.['child']).toBeDefined(); + }); + + it('should not expand when no scopes are selected', async () => { + (service as any).updateState({ + selectedScopes: [], + appliedScopes: [], + }); + + await service.open(); + + // Root should have children loaded but not expanded beyond that + expect(service.state.tree.children).toBeDefined(); + }); + + it('should load children of the last node in the path', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child'], + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + selectedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child', parentNodeId: 'parent' }], + appliedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child', parentNodeId: 'parent' }], + }); + + // Mock API to return path nodes + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([parentNode, childNode]); + + // Mock fetchNodes to return children of the last node + apiClient.fetchNodes = jest.fn().mockImplementation((options) => { + if (options.parent === '') { + return Promise.resolve([parentNode]); + } else if (options.parent === 'parent') { + return Promise.resolve([childNode]); + } + return Promise.resolve([]); + }); + + await service.open(); + + // Should have loaded root children (called once during tree initialization) + expect(apiClient.fetchNodes).toHaveBeenCalled(); + // Verify the path nodes were fetched (parent already in cache from fetchNodes, so only child is fetched) + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith(['child']); + }); + + it('should handle errors gracefully when expanding', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child'], + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + selectedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + appliedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + }); + + // Mock API to fail + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + apiClient.fetchMultipleScopeNodes = jest.fn().mockRejectedValue(new Error('API Error')); + + // Should not crash + await expect(service.open()).resolves.not.toThrow(); + expect(service.state.opened).toBe(true); + consoleErrorSpy.mockRestore(); + }); + }); + + describe('integration - full path resolution flow', () => { + it('should resolve path from defaultPath, insert into tree, and expand', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child', 'grandchild'], + filters: [], + }, + }; + + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([parentNode, childNode, grandchildNode]); + apiClient.fetchNodes = jest.fn().mockImplementation((options) => { + // Return children based on parent + if (options.parent === '') { + return Promise.resolve([parentNode]); + } else if (options.parent === 'parent') { + return Promise.resolve([childNode]); + } else if (options.parent === 'child') { + return Promise.resolve([grandchildNode]); + } + return Promise.resolve([]); + }); + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + selectedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'grandchild' }], + appliedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'grandchild' }], + }); + + await service.open(); + + // Path should be resolved (parent already in cache from fetchNodes, so only child and grandchild are fetched) + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith(['child', 'grandchild']); + + // Nodes should be in cache + expect(service.state.nodes['parent']).toEqual(parentNode); + expect(service.state.nodes['child']).toEqual(childNode); + expect(service.state.nodes['grandchild']).toEqual(grandchildNode); + + // Tree should be expanded + expect(service.state.tree.children?.['parent']?.expanded).toBe(true); + expect(service.state.tree.children?.['parent']?.children?.['child']?.expanded).toBe(true); + }); + + it('should use cached nodes and avoid unnecessary API calls', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child'], + filters: [], + }, + }; + + // Pre-populate cache + (service as any).updateState({ + scopes: { 'test-scope': scope }, + nodes: { + parent: parentNode, + child: childNode, + }, + selectedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + appliedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + }); + + apiClient.fetchNodes = jest.fn().mockImplementation((options) => { + // Return children based on parent + if (options.parent === '') { + return Promise.resolve([parentNode]); + } else if (options.parent === 'parent') { + return Promise.resolve([childNode]); + } + return Promise.resolve([]); + }); + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([]); + + await service.open(); + + // Should not fetch nodes that are already cached + expect(apiClient.fetchMultipleScopeNodes).not.toHaveBeenCalled(); + }); + }); + + describe('getScopeNode - caching behavior', () => { + it('should return cached node without API call', async () => { + (service as any).updateState({ + nodes: { + 'test-node': childNode, + }, + }); + + const result = await service.getScopeNode('test-node'); + + expect(result).toEqual(childNode); + expect(apiClient.fetchScopeNode).not.toHaveBeenCalled(); + }); + + it('should fetch and cache node when not in cache', async () => { + apiClient.fetchScopeNode = jest.fn().mockResolvedValue(childNode); + + const result = await service.getScopeNode('test-node'); + + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('test-node'); + expect(result).toEqual(childNode); + // Node is cached using its metadata.name, not the requested ID + expect(service.state.nodes['child']).toEqual(childNode); + }); + + it('should handle API errors gracefully', async () => { + apiClient.fetchScopeNode = jest.fn().mockResolvedValue(undefined); + + const result = await service.getScopeNode('non-existent'); + + expect(result).toBeUndefined(); + expect(service.state.nodes['non-existent']).toBeUndefined(); + }); + }); }); diff --git a/public/app/features/scopes/selector/ScopesSelectorService.ts b/public/app/features/scopes/selector/ScopesSelectorService.ts index abd837c7fb7..b7abee2a2cc 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.ts @@ -19,6 +19,7 @@ import { treeNodeAtPath, } from './scopesTreeUtils'; import { NodesMap, RecentScope, RecentScopeSchema, ScopeSchema, ScopesMap, SelectedScope, TreeNode } from './types'; + export const RECENT_SCOPES_KEY = 'grafana.scopes.recent'; export interface ScopesSelectorServiceState { @@ -101,22 +102,74 @@ export class ScopesSelectorService extends ScopesServiceBase => { + private getNodePath = async (scopeNodeId: string, visited: Set = new Set()): Promise => { + // Protect against circular references + if (visited.has(scopeNodeId)) { + console.error('Circular reference detected in node path', scopeNodeId); + return []; + } + const node = await this.getScopeNode(scopeNodeId); if (!node) { return []; } + + // Add current node to visited set + const newVisited = new Set(visited); + newVisited.add(scopeNodeId); + const parentPath = - node.spec.parentName && node.spec.parentName !== '' ? await this.getNodePath(node.spec.parentName) : []; + node.spec.parentName && node.spec.parentName !== '' + ? await this.getNodePath(node.spec.parentName, newVisited) + : []; return [...parentPath, node]; }; + /** + * Determines the path to a scope node, preferring defaultPath from scope metadata. + * This is the single source of truth for path resolution. + * + * TODO: Consider making this public and exposing via a hook to avoid duplication + * with getScopesPath in ScopesInput.tsx + * + * @param scopeId - The scope ID to get the path for + * @param scopeNodeId - Optional scope node ID to fall back to if no defaultPath + * @returns Promise resolving to array of ScopeNode objects representing the path + */ + private async getPathForScope(scopeId: string, scopeNodeId?: string): Promise { + // 1. Check if scope has defaultPath (preferred method) + const scope = this.state.scopes[scopeId]; + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) { + // Batch fetch all nodes in defaultPath + return await this.getScopeNodes(scope.spec.defaultPath); + } + + // 2. Fall back to calculating path from scopeNodeId + if (scopeNodeId) { + return await this.getNodePath(scopeNodeId); + } + + return []; + } + public resolvePathToRoot = async ( scopeNodeId: string, - tree: TreeNode + tree: TreeNode, + scopeId?: string ): Promise<{ path: ScopeNode[]; tree: TreeNode }> => { - const nodePath = await this.getNodePath(scopeNodeId); + let nodePath: ScopeNode[]; + + // Check if scope has defaultPath for optimized resolution + const scope = scopeId ? this.state.scopes[scopeId] : undefined; + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) { + // Use batch-fetched defaultPath (most efficient) + nodePath = await this.getPathForScope(scopeId!, scopeNodeId); + } else { + // Fall back to node-based path resolution + nodePath = await this.getNodePath(scopeNodeId); + } + const newTree = insertPathNodesIntoTree(tree, nodePath); this.updateState({ tree: newTree }); @@ -207,16 +260,39 @@ export class ScopesSelectorService extends ScopesServiceBase { - // Set parent query only when filtering within existing children - treeNode.children = {}; + // Preserve existing children that have nested structure (from insertPathNodesIntoTree) + const existingChildren = treeNode.children || {}; + const childrenToPreserve: Record = {}; + + // Keep children that have a children property (object, not undefined) + // This includes both empty objects {} (from path insertion) and populated ones + for (const [key, child] of Object.entries(existingChildren)) { + // Preserve if children is an object (not undefined) + if (child.children !== undefined && typeof child.children === 'object') { + childrenToPreserve[key] = child; + } + } + + // Start with preserved children, then add/update with fetched children + treeNode.children = { ...childrenToPreserve }; + for (const node of childNodes) { - treeNode.children[node.metadata.name] = { - expanded: false, - scopeNodeId: node.metadata.name, - // Only set query on tree nodes if parent already has children (filtering vs first expansion). This is used for saerch highlighting. - query: query || '', - children: undefined, - }; + // If this child was preserved, merge with fetched data + if (childrenToPreserve[node.metadata.name]) { + treeNode.children[node.metadata.name] = { + ...childrenToPreserve[node.metadata.name], + // Update query but keep nested children + query: query || '', + }; + } else { + // New child from API + treeNode.children[node.metadata.name] = { + expanded: false, + scopeNodeId: node.metadata.name, + query: query || '', + children: undefined, + }; + } } // Set loaded to true if node is a container treeNode.childrenLoaded = true; @@ -356,16 +432,54 @@ export class ScopesSelectorService extends ScopesServiceBase 0) { + // Deduplicate and filter out already cached nodes + const uniqueNodeIds = [...new Set(firstScope.spec.defaultPath)]; + const nodesToFetch = uniqueNodeIds.filter((nodeId) => !this.state.nodes[nodeId]); - this.addRecentScopes(fetchedScopes, parentNode, scopes[0]?.scopeNodeId); + if (nodesToFetch.length > 0) { + await this.getScopeNodes(nodesToFetch); + } + } + + // Get scopeNode and parentNode, preferring defaultPath as the source of truth + let parentNode: ScopeNode | undefined; + let scopeNodeId: string | undefined; + + if (firstScope?.spec.defaultPath && firstScope.spec.defaultPath.length > 1) { + // Extract from defaultPath (most reliable source) + // defaultPath format: ['', 'parent-id', 'scope-node-id', ...] + scopeNodeId = firstScope.spec.defaultPath[firstScope.spec.defaultPath.length - 1]; + const parentNodeId = firstScope.spec.defaultPath[firstScope.spec.defaultPath.length - 2]; + + scopeNode = scopeNodeId ? this.state.nodes[scopeNodeId] : undefined; + parentNode = parentNodeId && parentNodeId !== '' ? this.state.nodes[parentNodeId] : undefined; + } else { + // Fallback to next in priority order + scopeNodeId = scopes[0]?.scopeNodeId; + scopeNode = scopeNodeId ? this.state.nodes[scopeNodeId] : undefined; + + const parentNodeId = scopes[0]?.parentNodeId ?? scopeNode?.spec.parentName; + parentNode = parentNodeId ? this.state.nodes[parentNodeId] : undefined; + } + + this.addRecentScopes(fetchedScopes, parentNode, scopeNodeId); this.updateState({ scopes: newScopesState, loading: false }); } }; @@ -375,7 +489,7 @@ export class ScopesSelectorService extends ScopesServiceBase { - if (!('url' in s.spec) || typeof s.spec.url !== 'string') { + if (!('url' in s.spec)) { return false; } return isCurrentPath(currentPath, s.spec.url); @@ -386,7 +500,6 @@ export class ScopesSelectorService extends ScopesServiceBase [scopes[0]?.parentNode?.metadata?.name, scopes[0]?.parentNode]) .filter(([key, parentNode]) => parentNode !== undefined && key !== undefined) ); - - return parentNodes; }; /** @@ -499,40 +609,42 @@ export class ScopesSelectorService extends ScopesServiceBase n.metadata.name); - path.unshift(''); - nodeAtPath = treeNodeAtPath(newTree, path); - } catch (error) { - console.error('Failed to resolve path to root', error); - } - } - - // We have resolved to root, which means the parent node should be available - let parentPath = path.slice(0, -1); - let parentNodeAtPath = treeNodeAtPath(newTree, parentPath); - - if (parentNodeAtPath && !parentNodeAtPath.childrenLoaded) { - // This will update the tree with the children - const { newTree: newTreeWithChildren } = await this.loadNodeChildren(parentPath, parentNodeAtPath, ''); - newTree = newTreeWithChildren; - } - - // Expand the nodes to the selected scope - must be done after loading children try { - newTree = expandNodes(newTree, parentPath); + // Get the path for the selected scope, preferring defaultPath from scope metadata + const pathNodes = await this.getPathForScope( + this.state.selectedScopes[0].scopeId, + this.state.selectedScopes[0].scopeNodeId + ); + + if (pathNodes.length > 0) { + // Convert to string path + const stringPath = pathNodes.map((n) => n.metadata.name); + stringPath.unshift(''); // Add root segment + + // Check if nodes are in tree + let nodeAtPath = treeNodeAtPath(newTree, stringPath); + + // If nodes aren't in tree yet, insert them + if (!nodeAtPath) { + newTree = insertPathNodesIntoTree(newTree, pathNodes); + // Update state so loadNodeChildren can see the inserted nodes + this.updateState({ tree: newTree }); + } + + // Load children of the parent node if needed to show all siblings + const parentPath = stringPath.slice(0, -1); + const parentNodeAtPath = treeNodeAtPath(newTree, parentPath); + + if (parentNodeAtPath && !parentNodeAtPath.childrenLoaded) { + const { newTree: newTreeWithChildren } = await this.loadNodeChildren(parentPath, parentNodeAtPath, ''); + newTree = newTreeWithChildren; + } + + // Expand the nodes to show the selected scope + newTree = expandNodes(newTree, parentPath); + } } catch (error) { - console.error('Failed to expand nodes', error); + console.error('Failed to expand to selected scope', error); } } @@ -580,9 +692,14 @@ export class ScopesSelectorService extends ScopesServiceBase !nodesMap[name]); - const nodes = await this.apiClient.fetchMultipleScopeNodes(nodesToFetch); - for (const node of nodes) { - nodesMap[node.metadata.name] = node; + if (nodesToFetch.length > 0) { + const nodes = await this.apiClient.fetchMultipleScopeNodes(nodesToFetch); + // Handle case where API returns undefined or non-array + if (Array.isArray(nodes)) { + for (const node of nodes) { + nodesMap[node.metadata.name] = node; + } + } } const newNodes = { ...this.state.nodes, ...nodesMap }; diff --git a/public/app/features/scopes/selector/scopesTreeUtils.ts b/public/app/features/scopes/selector/scopesTreeUtils.ts index 8824742505d..d4001db68c1 100644 --- a/public/app/features/scopes/selector/scopesTreeUtils.ts +++ b/public/app/features/scopes/selector/scopesTreeUtils.ts @@ -127,17 +127,27 @@ export const insertPathNodesIntoTree = (tree: TreeNode, path: ScopeNode[]) => { if (!childNodeName) { console.warn('Failed to insert full path into tree. Did not find child to' + stringPath[index]); treeNode.childrenLoaded = treeNode.childrenLoaded ?? false; - return treeNode; + return; + } + // Create node if it doesn't exist + if (!treeNode.children[childNodeName]) { + treeNode.children[childNodeName] = { + expanded: false, + scopeNodeId: childNodeName, + query: '', + children: {}, + childrenLoaded: false, + }; + } else { + // Node exists, ensure it has children object for nested insertion + if (treeNode.children[childNodeName].children === undefined) { + treeNode.children[childNodeName] = { + ...treeNode.children[childNodeName], + children: {}, + }; + } } - treeNode.children[childNodeName] = { - expanded: false, - scopeNodeId: childNodeName, - query: '', - children: undefined, - childrenLoaded: false, - }; treeNode.childrenLoaded = treeNode.childrenLoaded ?? false; - return treeNode; }); } return newTree; diff --git a/public/app/features/scopes/tests/dashboardReload.test.ts b/public/app/features/scopes/tests/dashboardReload.test.ts index ced8b1a68d3..67b9a6fd1a5 100644 --- a/public/app/features/scopes/tests/dashboardReload.test.ts +++ b/public/app/features/scopes/tests/dashboardReload.test.ts @@ -1,20 +1,24 @@ -import { config } from '@grafana/runtime'; +import { config, setBackendSrv } from '@grafana/runtime'; +import { setupMockServer } from '@grafana/test-utils/server'; +import { backendSrv } from 'app/core/services/backend_srv'; import { setDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager'; import { enterEditMode, updateMyVar, updateScopes, updateTimeRange } from './utils/actions'; -import { getDatasource, getInstanceSettings, getMock } from './utils/mocks'; +import { getDatasource, getInstanceSettings } from './utils/mocks'; import { renderDashboard, resetScenes } from './utils/render'; jest.mock('@grafana/runtime', () => ({ __esModule: true, ...jest.requireActual('@grafana/runtime'), useChromeHeaderHeight: jest.fn(), - getBackendSrv: () => ({ get: getMock }), getDataSourceSrv: () => ({ get: getDatasource, getInstanceSettings }), usePluginLinks: jest.fn().mockReturnValue({ links: [] }), })); +setBackendSrv(backendSrv); +setupMockServer(); + describe('Dashboard reload', () => { let dashboardReloadSpy: jest.SpyInstance; beforeEach(() => { diff --git a/public/app/features/scopes/tests/dashboardsList.test.ts b/public/app/features/scopes/tests/dashboardsList.test.ts index b4e091bfd6b..db972778705 100644 --- a/public/app/features/scopes/tests/dashboardsList.test.ts +++ b/public/app/features/scopes/tests/dashboardsList.test.ts @@ -1,6 +1,9 @@ import { screen, waitFor } from '@testing-library/react'; -import { config, locationService } from '@grafana/runtime'; +import { config, locationService, setBackendSrv } from '@grafana/runtime'; +import { setupMockServer } from '@grafana/test-utils/server'; +import { MOCK_SUB_SCOPE_MIMIR_ITEMS } from '@grafana/test-utils/unstable'; +import { backendSrv } from 'app/core/services/backend_srv'; import { ScopesApiClient } from '../ScopesApiClient'; import { ScopesService } from '../ScopesService'; @@ -35,26 +38,25 @@ import { dashboardWithRootFolder, dashboardWithRootFolderAndOtherFolder, dashboardWithTwoFolders, - getDatasource, - getInstanceSettings, - getMock, navigationWithSubScope, navigationWithSubScope2, navigationWithSubScopeDifferent, navigationWithSubScopeAndGroups, - subScopeMimirItems, -} from './utils/mocks'; +} from './utils/mockData'; +import { getDatasource, getInstanceSettings } from './utils/mocks'; import { renderDashboard, resetScenes } from './utils/render'; jest.mock('@grafana/runtime', () => ({ __esModule: true, ...jest.requireActual('@grafana/runtime'), useChromeHeaderHeight: jest.fn(), - getBackendSrv: () => ({ get: getMock }), getDataSourceSrv: () => ({ get: getDatasource, getInstanceSettings }), usePluginLinks: jest.fn().mockReturnValue({ links: [] }), })); +setBackendSrv(backendSrv); +setupMockServer(); + describe('Dashboards list', () => { let fetchDashboardsSpy: jest.SpyInstance; let fetchScopeNavigationsSpy: jest.SpyInstance; @@ -539,7 +541,7 @@ describe('Dashboards list', () => { it('Loads subScope items when folder is expanded', async () => { const mockNavigations = [navigationWithSubScope]; - fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(subScopeMimirItems); + fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(MOCK_SUB_SCOPE_MIMIR_ITEMS); await toggleDashboards(); await updateScopes(scopesService, ['grafana']); @@ -571,7 +573,7 @@ describe('Dashboards list', () => { it('Shows loading state while fetching subScope items', async () => { const mockNavigations = [navigationWithSubScope]; - fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(subScopeMimirItems); + fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(MOCK_SUB_SCOPE_MIMIR_ITEMS); await toggleDashboards(); await updateScopes(scopesService, ['grafana']); @@ -591,7 +593,7 @@ describe('Dashboards list', () => { it('Multiple subScope folders with same subScope load same content', async () => { const mockNavigations = [navigationWithSubScope, navigationWithSubScope2]; - fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValue(subScopeMimirItems); + fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValue(MOCK_SUB_SCOPE_MIMIR_ITEMS); await toggleDashboards(); await updateScopes(scopesService, ['grafana']); @@ -676,7 +678,7 @@ describe('Dashboards list', () => { it('Filters search works with loaded subScope content', async () => { const mockNavigations = [navigationWithSubScope]; - fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(subScopeMimirItems); + fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(MOCK_SUB_SCOPE_MIMIR_ITEMS); await toggleDashboards(); await updateScopes(scopesService, ['grafana']); @@ -715,7 +717,7 @@ describe('Dashboards list', () => { it('Does not fetch subScope items if folder is already loaded', async () => { const mockNavigations = [navigationWithSubScope]; - fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(subScopeMimirItems); + fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(MOCK_SUB_SCOPE_MIMIR_ITEMS); await toggleDashboards(); await updateScopes(scopesService, ['grafana']); diff --git a/public/app/features/scopes/tests/selector.test.ts b/public/app/features/scopes/tests/selector.test.ts index f12f848657a..a9a4ab0ac58 100644 --- a/public/app/features/scopes/tests/selector.test.ts +++ b/public/app/features/scopes/tests/selector.test.ts @@ -1,4 +1,7 @@ -import { config, locationService } from '@grafana/runtime'; +import { config, locationService, setBackendSrv } from '@grafana/runtime'; +import { setupMockServer } from '@grafana/test-utils/server'; +import { MOCK_SCOPES } from '@grafana/test-utils/unstable'; +import { backendSrv } from 'app/core/services/backend_srv'; import { getDashboardScenePageStateManager } from '../../dashboard-scene/pages/DashboardScenePageStateManager'; import { ScopesService } from '../ScopesService'; @@ -25,7 +28,7 @@ import { expectResultApplicationsGrafanaSelected, expectScopesSelectorValue, } from './utils/assertions'; -import { getDatasource, getInstanceSettings, getMock, mocksScopes } from './utils/mocks'; +import { getDatasource, getInstanceSettings } from './utils/mocks'; import { renderDashboard, resetScenes } from './utils/render'; import { getListOfScopes } from './utils/selectors'; @@ -33,11 +36,13 @@ jest.mock('@grafana/runtime', () => ({ __esModule: true, ...jest.requireActual('@grafana/runtime'), useChromeHeaderHeight: jest.fn(), - getBackendSrv: () => ({ get: getMock }), getDataSourceSrv: () => ({ get: getDatasource, getInstanceSettings }), usePluginLinks: jest.fn().mockReturnValue({ links: [] }), })); +setBackendSrv(backendSrv); +setupMockServer(); + describe('Selector', () => { let fetchSelectedScopesSpy: jest.SpyInstance; let dashboardReloadSpy: jest.SpyInstance; @@ -67,7 +72,7 @@ describe('Selector', () => { await selectResultCloud(); await applyScopes(); expect(fetchSelectedScopesSpy).toHaveBeenCalled(); - expect(getListOfScopes(scopesService)).toEqual(mocksScopes.filter(({ metadata: { name } }) => name === 'cloud')); + expect(getListOfScopes(scopesService)).toEqual(MOCK_SCOPES.filter(({ metadata: { name } }) => name === 'cloud')); }); it('Does not save the scopes on close', async () => { @@ -105,7 +110,6 @@ describe('Selector', () => { // Lowercase because we don't have any backend that returns the correct case, then it falls back to the value in the URL expectScopesSelectorValue('grafana'); await openSelector(); - //screen.debug(undefined, 100000); expectResultApplicationsGrafanaSelected(); jest.spyOn(locationService, 'getLocation').mockRestore(); @@ -133,11 +137,12 @@ describe('Selector', () => { expectRecentScope('Grafana Applications'); expectRecentScope('Grafana, Mimir Applications'); await selectRecentScope('Grafana Applications'); + await jest.runOnlyPendingTimersAsync(); expectScopesSelectorValue('Grafana'); await openSelector(); - // Close to root node so we can see the recent scopes + // Collapse tree to root level to see recent scopes section await expandResultApplications(); await expandRecentScopes(); @@ -156,8 +161,8 @@ describe('Selector', () => { await applyScopes(); await openSelector(); - // Close to root node so we can try to see the recent scopes - await expandResultApplications(); + // Tree expands to show selected scope, so recent scopes are not visible + // (recent scopes only show at root level with tree collapsed) expectRecentScopeNotPresentInDocument(); }); @@ -175,6 +180,7 @@ describe('Selector', () => { await applyScopes(); // Deselect all scopes + await hoverSelector(); await clearSelector(); // Recent scopes should still be available @@ -197,6 +203,7 @@ describe('Selector', () => { await selectResultApplicationsMimir(); await applyScopes(); + await hoverSelector(); await clearSelector(); // Check recent scopes are updated diff --git a/public/app/features/scopes/tests/tree.test.ts b/public/app/features/scopes/tests/tree.test.ts index f11ec276c0c..d6c4e9cf994 100644 --- a/public/app/features/scopes/tests/tree.test.ts +++ b/public/app/features/scopes/tests/tree.test.ts @@ -1,7 +1,9 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { config, locationService } from '@grafana/runtime'; +import { config, locationService, setBackendSrv } from '@grafana/runtime'; +import { setupMockServer } from '@grafana/test-utils/server'; +import { backendSrv } from 'app/core/services/backend_srv'; import { ScopesService } from '../ScopesService'; @@ -43,18 +45,20 @@ import { expectScopesHeadline, expectScopesSelectorValue, } from './utils/assertions'; -import { getDatasource, getInstanceSettings, getMock } from './utils/mocks'; +import { getDatasource, getInstanceSettings } from './utils/mocks'; import { renderDashboard, resetScenes } from './utils/render'; jest.mock('@grafana/runtime', () => ({ __esModule: true, ...jest.requireActual('@grafana/runtime'), useChromeHeaderHeight: jest.fn(), - getBackendSrv: () => ({ get: getMock }), getDataSourceSrv: () => ({ get: getDatasource, getInstanceSettings }), usePluginLinks: jest.fn().mockReturnValue({ links: [] }), })); +setBackendSrv(backendSrv); +setupMockServer(); + describe('Tree', () => { let fetchNodesSpy: jest.SpyInstance; let fetchScopeSpy: jest.SpyInstance; diff --git a/public/app/features/scopes/tests/utils/actions.ts b/public/app/features/scopes/tests/utils/actions.ts index fc5fde93193..e49acb8670a 100644 --- a/public/app/features/scopes/tests/utils/actions.ts +++ b/public/app/features/scopes/tests/utils/actions.ts @@ -47,7 +47,7 @@ const type = async (selector: () => HTMLInputElement, value: string) => { export const updateScopes = async (service: ScopesService, scopes: string[]) => act(async () => service.changeScopes(scopes)); export const openSelector = async () => click(getSelectorInput); -export const hoverSelector = async () => fireEvent.mouseOver(getSelectorInput()); +export const hoverSelector = async () => userEvent.hover(getSelectorInput()); export const clearSelector = async () => click(getSelectorClear); export const applyScopes = async () => { await click(getSelectorApply); diff --git a/public/app/features/scopes/tests/utils/mockData.ts b/public/app/features/scopes/tests/utils/mockData.ts new file mode 100644 index 00000000000..fd8b94a6c14 --- /dev/null +++ b/public/app/features/scopes/tests/utils/mockData.ts @@ -0,0 +1,92 @@ +import { ScopeDashboardBinding } from '@grafana/data'; + +import { ScopeNavigation } from '../../dashboards/types'; + +// Mock subScope navigation items (specific to these tests) +export const navigationWithSubScope: ScopeNavigation = { + metadata: { name: 'subscope-nav-1' }, + spec: { + scope: 'grafana', + subScope: 'mimir', + url: '/d/subscope-dashboard-1', + }, + status: { + title: 'Mimir Dashboards', + groups: [], // subScope items ignore groups + }, +}; + +export const navigationWithSubScope2: ScopeNavigation = { + metadata: { name: 'subscope-nav-2' }, + spec: { + scope: 'grafana', + subScope: 'mimir', + url: '/d/subscope-dashboard-2', + }, + status: { + title: 'Mimir Overview', + groups: [], + }, +}; + +export const navigationWithSubScopeDifferent: ScopeNavigation = { + metadata: { name: 'subscope-nav-3' }, + spec: { + scope: 'grafana', + subScope: 'loki', + url: '/d/subscope-dashboard-3', + }, + status: { + title: 'Loki Dashboards', + groups: [], + }, +}; + +export const navigationWithSubScopeAndGroups: ScopeNavigation = { + metadata: { name: 'subscope-nav-groups' }, + spec: { + scope: 'grafana', + subScope: 'mimir', + url: '/d/subscope-dashboard-groups', + }, + status: { + title: 'Mimir with Groups', + groups: ['Group1', 'Group2'], // Should be ignored for subScope items + }, +}; + +const generateScopeDashboardBinding = (dashboardTitle: string, groups?: string[], dashboardId?: string) => ({ + metadata: { name: `${dashboardTitle}-name` }, + spec: { + dashboard: `${dashboardId ?? dashboardTitle}-dashboard`, + scope: `${dashboardTitle}-scope`, + }, + status: { + dashboardTitle, + groups, + }, +}); + +export const dashboardWithoutFolder: ScopeDashboardBinding = generateScopeDashboardBinding('Without Folder'); +export const dashboardWithOneFolder: ScopeDashboardBinding = generateScopeDashboardBinding('With one folder', [ + 'Folder 1', +]); +export const dashboardWithTwoFolders: ScopeDashboardBinding = generateScopeDashboardBinding('With two folders', [ + 'Folder 1', + 'Folder 2', +]); +export const alternativeDashboardWithTwoFolders: ScopeDashboardBinding = generateScopeDashboardBinding( + 'Alternative with two folders', + ['Folder 1', 'Folder 2'], + 'With two folders' +); +export const dashboardWithRootFolder: ScopeDashboardBinding = generateScopeDashboardBinding('With root folder', ['']); +export const alternativeDashboardWithRootFolder: ScopeDashboardBinding = generateScopeDashboardBinding( + 'Alternative With root folder', + [''], + 'With root folder' +); +export const dashboardWithRootFolderAndOtherFolder: ScopeDashboardBinding = generateScopeDashboardBinding( + 'With root folder and other folder', + ['', 'Folder 3'] +); diff --git a/public/app/features/scopes/tests/utils/mocks.ts b/public/app/features/scopes/tests/utils/mocks.ts index c1afb8b0de2..45adee30744 100644 --- a/public/app/features/scopes/tests/utils/mocks.ts +++ b/public/app/features/scopes/tests/utils/mocks.ts @@ -1,594 +1,8 @@ -import { Scope, ScopeDashboardBinding, ScopeNode } from '@grafana/data'; import { DataSourceRef } from '@grafana/schema/dist/esm/common/common.gen'; import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager'; -import { ScopeNavigation } from '../../dashboards/types'; - -export const mocksScopes: Scope[] = [ - { - metadata: { name: 'cloud' }, - spec: { - title: 'Cloud', - filters: [{ key: 'cloud', value: '.*', operator: 'regex-match' }], - }, - }, - { - metadata: { name: 'dev' }, - spec: { - title: 'Dev', - filters: [{ key: 'cloud', value: 'dev', operator: 'equals' }], - }, - }, - { - metadata: { name: 'ops' }, - spec: { - title: 'Ops', - filters: [{ key: 'cloud', value: 'ops', operator: 'equals' }], - }, - }, - { - metadata: { name: 'prod' }, - spec: { - title: 'Prod', - filters: [{ key: 'cloud', value: 'prod', operator: 'equals' }], - }, - }, - { - metadata: { name: 'grafana' }, - spec: { - title: 'Grafana', - filters: [{ key: 'app', value: 'grafana', operator: 'equals' }], - }, - }, - { - metadata: { name: 'mimir' }, - spec: { - title: 'Mimir', - filters: [{ key: 'app', value: 'mimir', operator: 'equals' }], - }, - }, - { - metadata: { name: 'loki' }, - spec: { - title: 'Loki', - filters: [{ key: 'app', value: 'loki', operator: 'equals' }], - }, - }, - { - metadata: { name: 'tempo' }, - spec: { - title: 'Tempo', - filters: [{ key: 'app', value: 'tempo', operator: 'equals' }], - }, - }, - { - metadata: { name: 'dev-env' }, - spec: { - title: 'Development', - filters: [{ key: 'environment', value: 'dev', operator: 'equals' }], - }, - }, - { - metadata: { name: 'prod-env' }, - spec: { - title: 'Production', - filters: [{ key: 'environment', value: 'prod', operator: 'equals' }], - }, - }, -] as const; - -const dashboardBindingsGenerator = ( - scopes: string[], - dashboards: Array<{ dashboardTitle: string; dashboardKey?: string; groups?: string[] }> -) => - scopes.reduce((scopeAcc, scopeTitle) => { - const scope = scopeTitle.toLowerCase().replaceAll(' ', '-').replaceAll('/', '-'); - - return [ - ...scopeAcc, - ...dashboards.reduce((acc, { dashboardTitle, groups, dashboardKey }, idx) => { - dashboardKey = dashboardKey ?? dashboardTitle.toLowerCase().replaceAll(' ', '-').replaceAll('/', '-'); - const group = !groups - ? '' - : groups.length === 1 - ? groups[0] === '' - ? '' - : `${groups[0].toLowerCase().replaceAll(' ', '-').replaceAll('/', '-')}-` - : `multiple${idx}-`; - const dashboard = `${group}${dashboardKey}`; - - return [ - ...acc, - { - metadata: { name: `${scope}-${dashboard}` }, - spec: { - dashboard, - scope, - }, - status: { - dashboardTitle, - groups, - }, - }, - ]; - }, []), - ]; - }, []); - -export const mocksScopeDashboardBindings: ScopeDashboardBinding[] = [ - ...dashboardBindingsGenerator( - ['Grafana'], - [ - { dashboardTitle: 'Data Sources', groups: ['General'] }, - { dashboardTitle: 'Usage', groups: ['General'] }, - { dashboardTitle: 'Frontend Errors', groups: ['Observability'] }, - { dashboardTitle: 'Frontend Logs', groups: ['Observability'] }, - { dashboardTitle: 'Backend Errors', groups: ['Observability'] }, - { dashboardTitle: 'Backend Logs', groups: ['Observability'] }, - { dashboardTitle: 'Usage Overview', groups: ['Usage'] }, - { dashboardTitle: 'Data Sources', groups: ['Usage'] }, - { dashboardTitle: 'Stats', groups: ['Usage'] }, - { dashboardTitle: 'Overview', groups: [''] }, - { dashboardTitle: 'Frontend' }, - { dashboardTitle: 'Stats' }, - ] - ), - ...dashboardBindingsGenerator( - ['Loki', 'Tempo', 'Mimir'], - [ - { dashboardTitle: 'Ingester', groups: ['Components', 'Investigations'] }, - { dashboardTitle: 'Distributor', groups: ['Components', 'Investigations'] }, - { dashboardTitle: 'Compacter', groups: ['Components', 'Investigations'] }, - { dashboardTitle: 'Datasource Errors', groups: ['Observability', 'Investigations'] }, - { dashboardTitle: 'Datasource Logs', groups: ['Observability', 'Investigations'] }, - { dashboardTitle: 'Overview' }, - { dashboardTitle: 'Stats', dashboardKey: 'another-stats' }, - ] - ), - ...dashboardBindingsGenerator( - ['Dev', 'Ops', 'Prod'], - [ - { dashboardTitle: 'Overview', groups: ['Cardinality Management'] }, - { dashboardTitle: 'Metrics', groups: ['Cardinality Management'] }, - { dashboardTitle: 'Labels', groups: ['Cardinality Management'] }, - { dashboardTitle: 'Overview', groups: ['Usage Insights'] }, - { dashboardTitle: 'Data Sources', groups: ['Usage Insights'] }, - { dashboardTitle: 'Query Errors', groups: ['Usage Insights'] }, - { dashboardTitle: 'Alertmanager', groups: ['Usage Insights'] }, - { dashboardTitle: 'Metrics Ingestion', groups: ['Usage Insights'] }, - { dashboardTitle: 'Billing/Usage' }, - ] - ), -] as const; - -export const mocksNodes: ScopeNode[] = [ - { - metadata: { name: 'applications' }, - spec: { - nodeType: 'container', - title: 'Applications', - description: 'Application Scopes', - parentName: '', - }, - }, - { - metadata: { name: 'cloud' }, - spec: { - nodeType: 'container', - title: 'Cloud', - description: 'Cloud Scopes', - disableMultiSelect: true, - linkType: 'scope', - linkId: 'cloud', - parentName: '', - }, - }, - { - metadata: { name: 'applications-grafana' }, - spec: { - nodeType: 'leaf', - title: 'Grafana', - description: 'Grafana', - linkType: 'scope', - linkId: 'grafana', - parentName: 'applications', - }, - }, - { - metadata: { name: 'applications-mimir' }, - spec: { - nodeType: 'leaf', - title: 'Mimir', - description: 'Mimir', - linkType: 'scope', - linkId: 'mimir', - parentName: 'applications', - }, - }, - { - metadata: { name: 'applications-loki' }, - spec: { - nodeType: 'leaf', - title: 'Loki', - description: 'Loki', - linkType: 'scope', - linkId: 'loki', - parentName: 'applications', - }, - }, - { - metadata: { name: 'applications-tempo' }, - spec: { - nodeType: 'leaf', - title: 'Tempo', - description: 'Tempo', - linkType: 'scope', - linkId: 'tempo', - parentName: 'applications', - }, - }, - { - metadata: { name: 'applications-cloud' }, - spec: { - nodeType: 'container', - title: 'Cloud', - description: 'Application/Cloud Scopes', - linkType: 'scope', - linkId: 'cloud', - parentName: 'applications', - }, - }, - { - metadata: { name: 'applications-cloud-dev' }, - spec: { - nodeType: 'leaf', - title: 'Dev', - description: 'Dev', - linkType: 'scope', - linkId: 'dev', - parentName: 'applications-cloud', - }, - }, - { - metadata: { name: 'applications-cloud-ops' }, - spec: { - nodeType: 'leaf', - title: 'Ops', - description: 'Ops', - linkType: 'scope', - linkId: 'ops', - parentName: 'applications-cloud', - }, - }, - { - metadata: { name: 'applications-cloud-prod' }, - spec: { - nodeType: 'leaf', - title: 'Prod', - description: 'Prod', - linkType: 'scope', - linkId: 'prod', - parentName: 'applications-cloud', - }, - }, - { - metadata: { name: 'cloud-dev' }, - spec: { - nodeType: 'leaf', - title: 'Dev', - description: 'Dev', - linkType: 'scope', - linkId: 'dev', - parentName: 'cloud', - }, - }, - { - metadata: { name: 'cloud-ops' }, - spec: { - nodeType: 'leaf', - title: 'Ops', - description: 'Ops', - linkType: 'scope', - linkId: 'ops', - parentName: 'cloud', - }, - }, - { - metadata: { name: 'cloud-prod' }, - spec: { - nodeType: 'leaf', - title: 'Prod', - description: 'Prod', - linkType: 'scope', - linkId: 'prod', - parentName: 'cloud', - }, - }, - { - metadata: { name: 'cloud-applications' }, - spec: { - nodeType: 'container', - title: 'Applications', - description: 'Cloud/Application Scopes', - parentName: 'cloud', - }, - }, - { - metadata: { name: 'cloud-applications-grafana' }, - spec: { - nodeType: 'leaf', - title: 'Grafana', - description: 'Grafana', - linkType: 'scope', - linkId: 'grafana', - parentName: 'cloud-applications', - }, - }, - { - metadata: { name: 'cloud-applications-mimir' }, - spec: { - nodeType: 'leaf', - title: 'Mimir', - description: 'Mimir', - linkType: 'scope', - linkId: 'mimir', - parentName: 'cloud-applications', - }, - }, - { - metadata: { name: 'cloud-applications-loki' }, - spec: { - nodeType: 'leaf', - title: 'Loki', - description: 'Loki', - linkType: 'scope', - linkId: 'loki', - parentName: 'cloud-applications', - }, - }, - { - metadata: { name: 'cloud-applications-tempo' }, - spec: { - nodeType: 'leaf', - title: 'Tempo', - description: 'Tempo', - linkType: 'scope', - linkId: 'tempo', - parentName: 'cloud-applications', - }, - }, - { - metadata: { name: 'environments' }, - spec: { - nodeType: 'container', - title: 'Environments', - description: 'Environment Scopes', - disableMultiSelect: true, - parentName: '', - }, - }, - { - metadata: { name: 'environments-dev' }, - spec: { - nodeType: 'container', - title: 'Development', - description: 'Development Environment', - linkType: 'scope', - linkId: 'dev-env', - parentName: 'environments', - }, - }, - { - metadata: { name: 'environments-prod' }, - spec: { - nodeType: 'container', - title: 'Production', - description: 'Production Environment', - linkType: 'scope', - linkId: 'prod-env', - parentName: 'environments', - }, - }, -] as const; - export const dashboardReloadSpy = jest.spyOn(getDashboardScenePageStateManager(), 'reloadDashboard'); -export const getMock = jest - .fn() - .mockImplementation( - (url: string, params: { parent: string; scope: string[]; query?: string } & Record) => { - if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_node_children')) { - return { - items: mocksNodes.filter( - ({ spec: { title, parentName } }) => - parentName === params.parent && title.toLowerCase().includes((params.query ?? '').toLowerCase()) - ), - }; - } - - if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/')) { - const name = url.replace('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/', ''); - - return mocksScopes.find((scope) => scope.metadata.name.toLowerCase() === name.toLowerCase()) ?? {}; - } - - if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopenodes/')) { - const name = url.replace('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopenodes/', ''); - - return mocksNodes.find((node) => node.metadata.name === name); - } - - if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_dashboard_bindings')) { - return { - items: mocksScopeDashboardBindings.filter(({ spec: { scope: bindingScope } }) => - params.scope.includes(bindingScope) - ), - }; - } - - if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_navigations')) { - // Handle subScope fetch requests - if (params.scope && params.scope.includes('mimir')) { - return { - items: subScopeMimirItems, - }; - } - if (params.scope && params.scope.includes('loki')) { - return { - items: subScopeLokiItems, - }; - } - // Return empty for other scopes - return { - items: [], - }; - } - - if (url.startsWith('/api/dashboards/uid/')) { - return {}; - } - - if (url.startsWith('/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/')) { - return { - metadata: { - name: '1', - }, - }; - } - - return {}; - } - ); - -const generateScopeDashboardBinding = (dashboardTitle: string, groups?: string[], dashboardId?: string) => ({ - metadata: { name: `${dashboardTitle}-name` }, - spec: { - dashboard: `${dashboardId ?? dashboardTitle}-dashboard`, - scope: `${dashboardTitle}-scope`, - }, - status: { - dashboardTitle, - groups, - }, -}); - -export const dashboardWithoutFolder: ScopeDashboardBinding = generateScopeDashboardBinding('Without Folder'); -export const dashboardWithOneFolder: ScopeDashboardBinding = generateScopeDashboardBinding('With one folder', [ - 'Folder 1', -]); -export const dashboardWithTwoFolders: ScopeDashboardBinding = generateScopeDashboardBinding('With two folders', [ - 'Folder 1', - 'Folder 2', -]); -export const alternativeDashboardWithTwoFolders: ScopeDashboardBinding = generateScopeDashboardBinding( - 'Alternative with two folders', - ['Folder 1', 'Folder 2'], - 'With two folders' -); -export const dashboardWithRootFolder: ScopeDashboardBinding = generateScopeDashboardBinding('With root folder', ['']); -export const alternativeDashboardWithRootFolder: ScopeDashboardBinding = generateScopeDashboardBinding( - 'Alternative With root folder', - [''], - 'With root folder' -); -export const dashboardWithRootFolderAndOtherFolder: ScopeDashboardBinding = generateScopeDashboardBinding( - 'With root folder and other folder', - ['', 'Folder 3'] -); - -// Mock subScope navigation items -export const navigationWithSubScope: ScopeNavigation = { - metadata: { name: 'subscope-nav-1' }, - spec: { - scope: 'grafana', - subScope: 'mimir', - url: '/d/subscope-dashboard-1', - }, - status: { - title: 'Mimir Dashboards', - groups: [], // subScope items ignore groups - }, -}; - -export const navigationWithSubScope2: ScopeNavigation = { - metadata: { name: 'subscope-nav-2' }, - spec: { - scope: 'grafana', - subScope: 'mimir', - url: '/d/subscope-dashboard-2', - }, - status: { - title: 'Mimir Overview', - groups: [], - }, -}; - -export const navigationWithSubScopeDifferent: ScopeNavigation = { - metadata: { name: 'subscope-nav-3' }, - spec: { - scope: 'grafana', - subScope: 'loki', - url: '/d/subscope-dashboard-3', - }, - status: { - title: 'Loki Dashboards', - groups: [], - }, -}; - -export const navigationWithSubScopeAndGroups: ScopeNavigation = { - metadata: { name: 'subscope-nav-groups' }, - spec: { - scope: 'grafana', - subScope: 'mimir', - url: '/d/subscope-dashboard-groups', - }, - status: { - title: 'Mimir with Groups', - groups: ['Group1', 'Group2'], // Should be ignored for subScope items - }, -}; - -// Mock items that will be loaded when subScope folder is expanded -export const subScopeMimirItems: ScopeNavigation[] = [ - { - metadata: { name: 'mimir-item-1' }, - spec: { - scope: 'mimir', - url: '/d/mimir-dashboard-1', - }, - status: { - title: 'Mimir Dashboard 1', - groups: ['General'], - }, - }, - { - metadata: { name: 'mimir-item-2' }, - spec: { - scope: 'mimir', - url: '/d/mimir-dashboard-2', - }, - status: { - title: 'Mimir Dashboard 2', - groups: ['Observability'], - }, - }, -]; - -export const subScopeLokiItems: ScopeNavigation[] = [ - { - metadata: { name: 'loki-item-1' }, - spec: { - scope: 'loki', - url: '/d/loki-dashboard-1', - }, - status: { - title: 'Loki Dashboard 1', - groups: ['General'], - }, - }, -]; - export const getDatasource = async (ref: DataSourceRef) => { if (ref.uid === '-- Grafana --') { return { diff --git a/public/app/features/scopes/tests/utils/render.tsx b/public/app/features/scopes/tests/utils/render.tsx index ed126b5b2d7..5a44727f563 100644 --- a/public/app/features/scopes/tests/utils/render.tsx +++ b/public/app/features/scopes/tests/utils/render.tsx @@ -12,8 +12,6 @@ import { DashboardDataDTO, DashboardDTO, DashboardMeta } from 'app/types/dashboa import { defaultScopesServices, ScopesContextProvider } from '../../ScopesContextProvider'; -import { getMock } from './mocks'; - const getDashboardDTO: ( overrideDashboard: Partial, overrideMeta: Partial @@ -208,7 +206,6 @@ export async function renderDashboard( export async function resetScenes(spies: jest.SpyInstance[] = []) { await jest.runOnlyPendingTimersAsync(); jest.useRealTimers(); - getMock.mockClear(); spies.forEach((spy) => spy.mockClear()); cleanup(); } diff --git a/public/app/features/scopes/tests/viewMode.test.ts b/public/app/features/scopes/tests/viewMode.test.ts index 90ba082b734..97fa480f3e2 100644 --- a/public/app/features/scopes/tests/viewMode.test.ts +++ b/public/app/features/scopes/tests/viewMode.test.ts @@ -1,4 +1,6 @@ -import { config } from '@grafana/runtime'; +import { config, setBackendSrv } from '@grafana/runtime'; +import { setupMockServer } from '@grafana/test-utils/server'; +import { backendSrv } from 'app/core/services/backend_srv'; import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; import { ScopesService } from '../ScopesService'; @@ -10,18 +12,20 @@ import { expectScopesSelectorClosed, expectScopesSelectorDisabled, } from './utils/assertions'; -import { getDatasource, getInstanceSettings, getMock } from './utils/mocks'; +import { getDatasource, getInstanceSettings } from './utils/mocks'; import { renderDashboard, resetScenes } from './utils/render'; jest.mock('@grafana/runtime', () => ({ __esModule: true, ...jest.requireActual('@grafana/runtime'), useChromeHeaderHeight: jest.fn(), - getBackendSrv: () => ({ get: getMock }), getDataSourceSrv: () => ({ get: getDatasource, getInstanceSettings }), usePluginLinks: jest.fn().mockReturnValue({ links: [] }), })); +setBackendSrv(backendSrv); +setupMockServer(); + describe('View mode', () => { let dashboardScene: DashboardScene; let scopesService: ScopesService; diff --git a/public/app/features/search/service/types.ts b/public/app/features/search/service/types.ts index a670b054979..aaca2595bf0 100644 --- a/public/app/features/search/service/types.ts +++ b/public/app/features/search/service/types.ts @@ -25,7 +25,6 @@ export interface SearchQuery { sort?: string; ds_uid?: string; ds_type?: string; - saved_query_uid?: string; // TODO: not implemented yet tags?: string[]; kind?: string[]; panel_type?: string; diff --git a/public/app/features/search/service/unified.ts b/public/app/features/search/service/unified.ts index 146a54d295d..ab6e599ea93 100644 --- a/public/app/features/search/service/unified.ts +++ b/public/app/features/search/service/unified.ts @@ -1,6 +1,9 @@ import { isEmpty } from 'lodash'; -import { BASE_URL as v0alphaBaseURL } from '@grafana/api-clients/rtkq/dashboard/v0alpha1'; +import { + API_GROUP as DASHBOARD_API_GROUP, + BASE_URL as v0alphaBaseURL, +} from '@grafana/api-clients/rtkq/dashboard/v0alpha1'; import { generatedAPI as legacyUserAPI } from '@grafana/api-clients/rtkq/legacy/user'; import { DataFrame, DataFrameView, getDisplayProcessor, SelectableValue, toDataFrame } from '@grafana/data'; import { t } from '@grafana/i18n'; @@ -85,10 +88,11 @@ export class UnifiedSearcher implements GrafanaSearcher { fieldSelector: `metadata.name=${name}`, }) ); - starsIds = - result.data.items?.[0].spec.resource.find( - (info) => info.group === 'dashboard.grafana.app' && info.kind === 'Dashboard' - )?.names || []; + const items = result.data.items; + starsIds = items?.length + ? items[0].spec.resource.find(({ group, kind }) => group === DASHBOARD_API_GROUP && kind === 'Dashboard') + ?.names || [] + : []; } else { starsIds = await dispatch(legacyUserAPI.endpoints.getStars.initiate()).unwrap(); } @@ -297,6 +301,14 @@ export class UnifiedSearcher implements GrafanaSearcher { uri += '&' + query.kind.map((kind) => `type=${kind}`).join('&'); } + if (query.ds_type?.length) { + uri += '&dataSourceType=' + query.ds_type; + } + + if (query.panel_type?.length) { + uri += '&panelType=' + query.panel_type; + } + if (query.tags?.length) { uri += '&' + query.tags.map((tag) => `tag=${encodeURIComponent(tag)}`).join('&'); } @@ -323,7 +335,7 @@ export class UnifiedSearcher implements GrafanaSearcher { } if (query.deleted) { - uri = `${getAPIBaseURL('dashboard.grafana.app', 'v1beta1')}/dashboards/?labelSelector=grafana.app/get-trash=true`; + uri = `${getAPIBaseURL(DASHBOARD_API_GROUP, 'v1beta1')}/dashboards/?labelSelector=grafana.app/get-trash=true`; } return uri; } diff --git a/public/app/features/teams/hooks.ts b/public/app/features/teams/hooks.ts index 2c563ef3d2f..e0af32b6f3f 100644 --- a/public/app/features/teams/hooks.ts +++ b/public/app/features/teams/hooks.ts @@ -12,8 +12,8 @@ import { useUpdateTeamMutation, UpdateTeamCommand, } from 'app/api/clients/legacy'; -import { updateNavIndex } from 'app/core/actions'; import { addFilteredDisplayName } from 'app/core/components/RolePicker/utils'; +import { updateNavIndex } from 'app/core/reducers/navModel'; import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction, Role } from 'app/types/accessControl'; import { useDispatch } from 'app/types/store'; diff --git a/public/app/features/theme-playground/README.md b/public/app/features/theme-playground/README.md deleted file mode 100644 index c0b370c9734..00000000000 --- a/public/app/features/theme-playground/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Regenerating the schema - -The json schema for the theme options is generated using [typescript-json-schema](https://github.com/YousefED/typescript-json-schema). The schema should be regenerated automatically if the types change. If you need to manually regenerate, run `yarn themes-schema`. diff --git a/public/app/features/theme-playground/ThemePlayground.tsx b/public/app/features/theme-playground/ThemePlayground.tsx index 77df7b5db87..d331f3932bc 100644 --- a/public/app/features/theme-playground/ThemePlayground.tsx +++ b/public/app/features/theme-playground/ThemePlayground.tsx @@ -2,38 +2,75 @@ import { css } from '@emotion/css'; import { useId, useState } from 'react'; import { createTheme, GrafanaTheme2, NewThemeOptions } from '@grafana/data'; -import { experimentalThemeDefinitions } from '@grafana/data/internal'; +import { NewThemeOptionsSchema } from '@grafana/data/internal'; +import aubergine from '@grafana/data/themes/definitions/aubergine.json'; +import debug from '@grafana/data/themes/definitions/debug.json'; +import desertbloom from '@grafana/data/themes/definitions/desertbloom.json'; +import gildedgrove from '@grafana/data/themes/definitions/gildedgrove.json'; +import gloom from '@grafana/data/themes/definitions/gloom.json'; +import mars from '@grafana/data/themes/definitions/mars.json'; +import matrix from '@grafana/data/themes/definitions/matrix.json'; +import sapphiredusk from '@grafana/data/themes/definitions/sapphiredusk.json'; +import synthwave from '@grafana/data/themes/definitions/synthwave.json'; +import tron from '@grafana/data/themes/definitions/tron.json'; +import victorian from '@grafana/data/themes/definitions/victorian.json'; +import zen from '@grafana/data/themes/definitions/zen.json'; +import themeJsonSchema from '@grafana/data/themes/schema.generated.json'; import { t } from '@grafana/i18n'; import { useChromeHeaderHeight } from '@grafana/runtime'; import { CodeEditor, Combobox, Field, Stack, useStyles2 } from '@grafana/ui'; import { ThemeDemo } from '@grafana/ui/internal'; import { Page } from 'app/core/components/Page/Page'; -import { notifyApp } from '../../core/actions'; import { createErrorNotification } from '../../core/copy/appNotification'; +import { notifyApp } from '../../core/reducers/appNotification'; import { HOME_NAV_ID } from '../../core/reducers/navModel'; import { getNavModel } from '../../core/selectors/navModel'; import { ThemeProvider } from '../../core/utils/ConfigProvider'; import { useDispatch, useSelector } from '../../types/store'; -import schema from './schema.generated.json'; - const themeMap: Record = { dark: { name: 'Dark', + id: 'dark', colors: { mode: 'dark', }, }, light: { name: 'Light', + id: 'light', colors: { mode: 'light', }, }, - ...experimentalThemeDefinitions, }; +const experimentalDefinitions: Record = { + aubergine, + debug, + desertbloom, + gildedgrove, + gloom, + mars, + matrix, + sapphiredusk, + synthwave, + tron, + victorian, + zen, +}; + +// Add additional themes +for (const [name, json] of Object.entries(experimentalDefinitions)) { + const result = NewThemeOptionsSchema.safeParse(json); + if (!result.success) { + console.error(`Invalid theme definition for theme ${name}: ${result.error.message}`); + } else { + themeMap[result.data.id] = result.data; + } +} + const themeOptions = Object.entries(themeMap).map(([key, theme]) => ({ label: theme.name, value: key, @@ -59,16 +96,20 @@ export default function ThemePlayground() { const theme = createTheme(themeInput); setTheme(theme); } catch (error) { - dispatch(notifyApp(createErrorNotification(`Failed to create theme: ${error}`))); + dispatch(notifyApp(createErrorNotification('Failed to create theme', `${error}`))); } }; const onEditorBlur = (value: string) => { try { - const themeInput: NewThemeOptions = JSON.parse(value); - updateThemePreview(themeInput); + const themeInput = NewThemeOptionsSchema.safeParse(JSON.parse(value)); + if (!themeInput.success) { + dispatch(notifyApp(createErrorNotification('Failed to parse theme', themeInput.error.issues[0].message))); + } else { + updateThemePreview(themeInput.data); + } } catch (error) { - dispatch(notifyApp(createErrorNotification(`Failed to parse JSON: ${error}`))); + dispatch(notifyApp(createErrorNotification('Failed to parse JSON', `${error}`))); } }; @@ -115,7 +156,7 @@ export default function ThemePlayground() { { uri: 'theme-schema', fileMatch: ['*'], - schema, + schema: themeJsonSchema, }, ], }); diff --git a/public/app/features/theme-playground/schema.generated.json b/public/app/features/theme-playground/schema.generated.json deleted file mode 100644 index 936471ba6ea..00000000000 --- a/public/app/features/theme-playground/schema.generated.json +++ /dev/null @@ -1,551 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "DeepPartial>": { - "properties": { - "action": { - "$ref": "#/definitions/DeepPartial<{selected:string;selectedBorder:string;hover:string;hoverOpacity:number;focus:string;disabledBackground:string;disabledText:string;disabledOpacity:number;}>" - }, - "background": { - "$ref": "#/definitions/DeepPartial<{canvas:string;primary:string;secondary:string;elevated:string;}>" - }, - "border": { - "$ref": "#/definitions/DeepPartial<{weak:string;medium:string;strong:string;}>" - }, - "contrastThreshold": { - "type": "number" - }, - "error": { - "$ref": "#/definitions/DeepPartial" - }, - "gradients": { - "$ref": "#/definitions/DeepPartial<{brandVertical:string;brandHorizontal:string;}>" - }, - "hoverFactor": { - "type": "number" - }, - "info": { - "$ref": "#/definitions/DeepPartial" - }, - "mode": { - "enum": [ - "dark", - "light" - ], - "type": "string" - }, - "primary": { - "$ref": "#/definitions/DeepPartial" - }, - "secondary": { - "$ref": "#/definitions/DeepPartial" - }, - "success": { - "$ref": "#/definitions/DeepPartial" - }, - "text": { - "$ref": "#/definitions/DeepPartial<{primary:string;secondary:string;disabled:string;link:string;maxContrast:string;}>" - }, - "tonalOffset": { - "type": "number" - }, - "warning": { - "$ref": "#/definitions/DeepPartial" - } - }, - "type": "object" - }, - "DeepPartial": { - "properties": { - "border": { - "description": "Used for borders", - "type": "string" - }, - "borderTransparent": { - "description": "Used for weak colored borders like larger alert/banner boxes and smaller badges and tags", - "type": "string" - }, - "contrastText": { - "description": "Text color for text ontop of main", - "type": "string" - }, - "main": { - "description": "Main color", - "type": "string" - }, - "name": { - "description": "color intent (primary, secondary, info, error, etc)", - "type": "string" - }, - "shade": { - "description": "Used for hover", - "type": "string" - }, - "text": { - "description": "Used for text", - "type": "string" - }, - "transparent": { - "description": "Used subtly colored backgrounds", - "type": "string" - } - }, - "type": "object" - }, - "DeepPartial<{brandVertical:string;brandHorizontal:string;}>": { - "properties": { - "brandHorizontal": { - "type": "string" - }, - "brandVertical": { - "type": "string" - } - }, - "type": "object" - }, - "DeepPartial<{canvas:string;primary:string;secondary:string;elevated:string;}>": { - "properties": { - "canvas": { - "description": "Dashboard and body background", - "type": "string" - }, - "elevated": { - "description": "For popovers and menu backgrounds. This is the same color as primary in most light themes but in dark\nthemes it has a brighter shade to help give it contrast against the primary background.", - "type": "string" - }, - "primary": { - "description": "Primary content pane background (panels etc)", - "type": "string" - }, - "secondary": { - "description": "Cards and elements that need to stand out on the primary background", - "type": "string" - } - }, - "type": "object" - }, - "DeepPartial<{primary:string;secondary:string;disabled:string;link:string;maxContrast:string;}>": { - "properties": { - "disabled": { - "type": "string" - }, - "link": { - "type": "string" - }, - "maxContrast": { - "description": "Used for auto white or dark text on colored backgrounds", - "type": "string" - }, - "primary": { - "type": "string" - }, - "secondary": { - "type": "string" - } - }, - "type": "object" - }, - "DeepPartial<{selected:string;selectedBorder:string;hover:string;hoverOpacity:number;focus:string;disabledBackground:string;disabledText:string;disabledOpacity:number;}>": { - "properties": { - "disabledBackground": { - "description": "Used for disabled buttons and inputs", - "type": "string" - }, - "disabledOpacity": { - "description": "Disablerd opacity", - "type": "number" - }, - "disabledText": { - "description": "Disabled text", - "type": "string" - }, - "focus": { - "description": "Used focused menu item / select option", - "type": "string" - }, - "hover": { - "description": "Used for hovered menu item / select option", - "type": "string" - }, - "hoverOpacity": { - "description": "Used for button/colored background hover opacity", - "type": "number" - }, - "selected": { - "description": "Used for selected menu item / select option", - "type": "string" - }, - "selectedBorder": { - "type": "string" - } - }, - "type": "object" - }, - "DeepPartial<{weak:string;medium:string;strong:string;}>": { - "properties": { - "medium": { - "type": "string" - }, - "strong": { - "type": "string" - }, - "weak": { - "type": "string" - } - }, - "type": "object" - }, - "ThemeShapeInput": { - "properties": { - "borderRadius": { - "type": "number" - } - }, - "type": "object" - }, - "ThemeTypographyInput": { - "properties": { - "fontFamily": { - "type": "string" - }, - "fontFamilyMonospace": { - "type": "string" - }, - "fontSize": { - "type": "number" - }, - "fontWeightBold": { - "type": "number" - }, - "fontWeightLight": { - "type": "number" - }, - "fontWeightMedium": { - "type": "number" - }, - "fontWeightRegular": { - "type": "number" - }, - "htmlFontSize": { - "type": "number" - } - }, - "type": "object" - }, - "ThemeVizColor<\"blue\">": { - "properties": { - "aliases": { - "items": { - "type": "string" - }, - "type": "array" - }, - "color": { - "type": "string" - }, - "name": { - "$ref": "#/definitions/ThemeVizColorShadeName_4" - }, - "primary": { - "type": "boolean" - } - }, - "type": "object" - }, - "ThemeVizColor<\"green\">": { - "properties": { - "aliases": { - "items": { - "type": "string" - }, - "type": "array" - }, - "color": { - "type": "string" - }, - "name": { - "$ref": "#/definitions/ThemeVizColorShadeName_3" - }, - "primary": { - "type": "boolean" - } - }, - "type": "object" - }, - "ThemeVizColor<\"orange\">": { - "properties": { - "aliases": { - "items": { - "type": "string" - }, - "type": "array" - }, - "color": { - "type": "string" - }, - "name": { - "$ref": "#/definitions/ThemeVizColorShadeName_1" - }, - "primary": { - "type": "boolean" - } - }, - "type": "object" - }, - "ThemeVizColor<\"purple\">": { - "properties": { - "aliases": { - "items": { - "type": "string" - }, - "type": "array" - }, - "color": { - "type": "string" - }, - "name": { - "$ref": "#/definitions/ThemeVizColorShadeName_5" - }, - "primary": { - "type": "boolean" - } - }, - "type": "object" - }, - "ThemeVizColor<\"red\">": { - "properties": { - "aliases": { - "items": { - "type": "string" - }, - "type": "array" - }, - "color": { - "type": "string" - }, - "name": { - "$ref": "#/definitions/ThemeVizColorShadeName" - }, - "primary": { - "type": "boolean" - } - }, - "type": "object" - }, - "ThemeVizColor<\"yellow\">": { - "properties": { - "aliases": { - "items": { - "type": "string" - }, - "type": "array" - }, - "color": { - "type": "string" - }, - "name": { - "$ref": "#/definitions/ThemeVizColorShadeName_2" - }, - "primary": { - "type": "boolean" - } - }, - "type": "object" - }, - "ThemeVizColorShadeName": { - "enum": [ - "dark-red", - "light-red", - "red", - "semi-dark-red", - "super-light-red" - ], - "type": "string" - }, - "ThemeVizColorShadeName_1": { - "enum": [ - "dark-orange", - "light-orange", - "orange", - "semi-dark-orange", - "super-light-orange" - ], - "type": "string" - }, - "ThemeVizColorShadeName_2": { - "enum": [ - "dark-yellow", - "light-yellow", - "semi-dark-yellow", - "super-light-yellow", - "yellow" - ], - "type": "string" - }, - "ThemeVizColorShadeName_3": { - "enum": [ - "dark-green", - "green", - "light-green", - "semi-dark-green", - "super-light-green" - ], - "type": "string" - }, - "ThemeVizColorShadeName_4": { - "enum": [ - "blue", - "dark-blue", - "light-blue", - "semi-dark-blue", - "super-light-blue" - ], - "type": "string" - }, - "ThemeVizColorShadeName_5": { - "enum": [ - "dark-purple", - "light-purple", - "purple", - "semi-dark-purple", - "super-light-purple" - ], - "type": "string" - }, - "ThemeVizHue": { - "anyOf": [ - { - "properties": { - "name": { - "const": "red", - "type": "string" - }, - "shades": { - "items": { - "$ref": "#/definitions/ThemeVizColor<\"red\">" - }, - "type": "array" - } - }, - "type": "object" - }, - { - "properties": { - "name": { - "const": "orange", - "type": "string" - }, - "shades": { - "items": { - "$ref": "#/definitions/ThemeVizColor<\"orange\">" - }, - "type": "array" - } - }, - "type": "object" - }, - { - "properties": { - "name": { - "const": "yellow", - "type": "string" - }, - "shades": { - "items": { - "$ref": "#/definitions/ThemeVizColor<\"yellow\">" - }, - "type": "array" - } - }, - "type": "object" - }, - { - "properties": { - "name": { - "const": "green", - "type": "string" - }, - "shades": { - "items": { - "$ref": "#/definitions/ThemeVizColor<\"green\">" - }, - "type": "array" - } - }, - "type": "object" - }, - { - "properties": { - "name": { - "const": "blue", - "type": "string" - }, - "shades": { - "items": { - "$ref": "#/definitions/ThemeVizColor<\"blue\">" - }, - "type": "array" - } - }, - "type": "object" - }, - { - "properties": { - "name": { - "const": "purple", - "type": "string" - }, - "shades": { - "items": { - "$ref": "#/definitions/ThemeVizColor<\"purple\">" - }, - "type": "array" - } - }, - "type": "object" - } - ] - } - }, - "properties": { - "colors": { - "$ref": "#/definitions/DeepPartial>" - }, - "name": { - "type": "string" - }, - "shape": { - "$ref": "#/definitions/ThemeShapeInput" - }, - "spacing": { - "properties": { - "gridSize": { - "type": "number" - } - }, - "type": "object" - }, - "typography": { - "$ref": "#/definitions/ThemeTypographyInput" - }, - "visualization": { - "properties": { - "hues": { - "items": { - "$ref": "#/definitions/ThemeVizHue" - }, - "type": "array" - }, - "palette": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "type": "object" - } - }, - "type": "object" -} - diff --git a/public/app/features/users/UsersActionBar.test.tsx b/public/app/features/users/UsersActionBar.test.tsx index ec58494b21e..83681aac2a0 100644 --- a/public/app/features/users/UsersActionBar.test.tsx +++ b/public/app/features/users/UsersActionBar.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import { mockToolkitActionCreator } from 'test/core/redux/mocks'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import { Props, UsersActionBarUnconnected } from './UsersActionBar'; import { searchQueryChanged } from './state/reducers'; diff --git a/public/app/features/variables/interval/actions.test.ts b/public/app/features/variables/interval/actions.test.ts index a16d23b570a..f78ed1ecf41 100644 --- a/public/app/features/variables/interval/actions.test.ts +++ b/public/app/features/variables/interval/actions.test.ts @@ -1,8 +1,8 @@ import { dateTime } from '@grafana/data'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; -import { notifyApp } from '../../../core/actions'; import { getTimeSrv, setTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv'; import { TemplateSrv } from '../../templating/template_srv'; import { variableAdapters } from '../adapters'; diff --git a/public/app/features/variables/state/actions.ts b/public/app/features/variables/state/actions.ts index a92193a0867..95f73bd610e 100644 --- a/public/app/features/variables/state/actions.ts +++ b/public/app/features/variables/state/actions.ts @@ -21,7 +21,7 @@ import { VariableWithOptions, } from '@grafana/data'; import { config, locationService, logWarning } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { contextSrv } from 'app/core/services/context_srv'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; diff --git a/public/app/plugins/datasource/alertmanager/ConfigEditor.tsx b/public/app/plugins/datasource/alertmanager/ConfigEditor.tsx index 24bc89e9b90..be7cc9a197a 100644 --- a/public/app/plugins/datasource/alertmanager/ConfigEditor.tsx +++ b/public/app/plugins/datasource/alertmanager/ConfigEditor.tsx @@ -4,8 +4,8 @@ import { Link } from 'react-router-dom-v5-compat'; import { SIGV4ConnectionConfig } from '@grafana/aws-sdk'; import { DataSourcePluginOptionsEditorProps, SelectableValue } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { Box, DataSourceHttpSettings, InlineField, InlineSwitch, Select, Text } from '@grafana/ui'; -import { config } from 'app/core/config'; import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from './types'; diff --git a/public/app/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index ef56b44c767..1f964e6b664 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -85,6 +85,10 @@ export type GrafanaManagedReceiverConfig = { // SecureSettings?: GrafanaManagedReceiverConfigSettings; settings: GrafanaManagedReceiverConfigSettings; type: string; + /** + * Version of the integration (e.g. "v0" for Mimir legacy, "v1" for Grafana) + */ + version?: string; /** * Name of the _receiver_, which in most cases will be the * same as the contact point's name. This should not be used, and is optional because the @@ -104,7 +108,7 @@ export interface GrafanaManagedContactPoint { /** If parsed from k8s API, we'll have an ID property */ id?: string; metadata?: IoK8SApimachineryPkgApisMetaV1ObjectMeta; - provisioned?: boolean; + provenance?: string; grafana_managed_receiver_configs?: GrafanaManagedReceiverConfig[]; } @@ -144,7 +148,7 @@ export type Route = { provenance?: string; /** this is used to add additional metadata to the routes without interfering with original route definition (symbols aren't iterable) */ [ROUTES_META_SYMBOL]?: { - provisioned?: boolean; + provenance?: string; resourceVersion?: string; name?: string; }; diff --git a/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.test.tsx b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.test.tsx new file mode 100644 index 00000000000..5b365cb98d6 --- /dev/null +++ b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.test.tsx @@ -0,0 +1,247 @@ +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { CoreApp, LoadingState, PanelData } from '@grafana/data'; +import { config, reportInteraction } from '@grafana/runtime'; + +import { AzureQueryType, LogsEditorMode } from '../../dataquery.gen'; +import { selectors } from '../../e2e/selectors'; +import createMockQuery from '../../mocks/query'; +import { AzureMonitorQuery } from '../../types/query'; +import { selectOptionInTest } from '../../utils/testUtils'; + +import { QueryHeader } from './QueryHeader'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), +})); + +describe('Azure Monitor QueryHeader', () => { + const setAzureLogsCheatSheetModalOpen = jest.fn(); + const onRunQuery = jest.fn(); + + const renderComponent = (query: AzureMonitorQuery, props?: Partial>) => { + return render( + + ); + }; + + beforeEach(() => { + config.featureToggles = {}; + }); + + afterEach(() => { + cleanup(); + jest.clearAllMocks(); + }); + + it('renders the service selector', async () => { + const query = createMockQuery(); + + renderComponent(query); + + expect(screen.getByTestId(selectors.components.queryEditor.header.select)).toBeInTheDocument(); + expect(screen.getByLabelText(/Service/i)).toBeInTheDocument(); + }); + + it('changes query type when a new service is selected', async () => { + const query = createMockQuery(); + const onQueryChange = jest.fn(); + + renderComponent(query, { onQueryChange }); + + const serviceSelect = await screen.findByLabelText(/Service/i); + + await selectOptionInTest(serviceSelect, 'Logs'); + + await waitFor(() => { + expect(onQueryChange).toHaveBeenCalled(); + }); + + const lastCall = onQueryChange.mock.calls[onQueryChange.mock.calls.length - 1][0]; + + expect(lastCall).toEqual( + expect.objectContaining({ + queryType: AzureQueryType.LogAnalytics, + }) + ); + }); + + it('initializes logs editor mode to Raw when a raw query exists and builder is enabled', async () => { + config.featureToggles.azureMonitorLogsBuilderEditor = true; + + const query: AzureMonitorQuery = { + ...createMockQuery(), + queryType: AzureQueryType.LogAnalytics, + azureLogAnalytics: { + query: 'SecurityEvent | take 10', + }, + }; + + const onQueryChange = jest.fn(); + + renderComponent(query, { onQueryChange }); + + await waitFor(() => + expect(onQueryChange).toHaveBeenCalledWith( + expect.objectContaining({ + azureLogAnalytics: expect.objectContaining({ + mode: LogsEditorMode.Raw, + }), + }) + ) + ); + }); + + it('renders the logs editor mode radio buttons when builder is enabled', async () => { + config.featureToggles.azureMonitorLogsBuilderEditor = true; + + const query: AzureMonitorQuery = { + ...createMockQuery(), + queryType: AzureQueryType.LogAnalytics, + azureLogAnalytics: { + mode: LogsEditorMode.Builder, + }, + }; + + renderComponent(query); + + expect(screen.getByRole('radiogroup')).toBeInTheDocument(); + + expect(screen.getByLabelText('Builder')).toBeInTheDocument(); + expect(screen.getByLabelText('KQL')).toBeInTheDocument(); + }); + + it('shows the kick start button when in Logs + Raw mode', async () => { + const query: AzureMonitorQuery = { + ...createMockQuery(), + queryType: AzureQueryType.LogAnalytics, + azureLogAnalytics: { + mode: LogsEditorMode.Raw, + }, + }; + + renderComponent(query); + + expect(screen.getByRole('button', { name: /Kick start your query/i })).toBeInTheDocument(); + }); + + it('opens the logs cheat sheet modal and reports interaction when kick start button is clicked', async () => { + const user = userEvent.setup(); + + const query: AzureMonitorQuery = { + ...createMockQuery(), + queryType: AzureQueryType.LogAnalytics, + azureLogAnalytics: { + mode: LogsEditorMode.Raw, + }, + }; + + renderComponent(query); + + await user.click(screen.getByRole('button', { name: /Kick start your query/i })); + + expect(setAzureLogsCheatSheetModalOpen).toHaveBeenCalled(); + expect(reportInteraction).toHaveBeenCalledWith( + 'grafana_azure_logs_query_patterns_opened', + expect.objectContaining({ + version: 'v2', + }) + ); + }); + + it('shows confirmation modal when switching from Raw to Builder with existing KQL', async () => { + const user = userEvent.setup(); + config.featureToggles.azureMonitorLogsBuilderEditor = true; + + const query: AzureMonitorQuery = { + ...createMockQuery(), + queryType: AzureQueryType.LogAnalytics, + azureLogAnalytics: { + mode: LogsEditorMode.Raw, + query: 'SecurityEvent | take 10', + }, + }; + + renderComponent(query); + + await user.click(screen.getByLabelText('Builder')); + + expect(screen.getByText(/Switch editor mode\?/i)).toBeInTheDocument(); + }); + + it('applies mode change when confirming the switch modal', async () => { + const user = userEvent.setup(); + config.featureToggles.azureMonitorLogsBuilderEditor = true; + + const query: AzureMonitorQuery = { + ...createMockQuery(), + queryType: AzureQueryType.LogAnalytics, + azureLogAnalytics: { + mode: LogsEditorMode.Raw, + query: 'SecurityEvent | take 10', + }, + }; + + const onQueryChange = jest.fn(); + + renderComponent(query, { onQueryChange }); + + await user.click(screen.getByLabelText('Builder')); + await user.click(screen.getByText(/Switch to Builder/i)); + + await waitFor(() => + expect(onQueryChange).toHaveBeenCalledWith( + expect.objectContaining({ + azureLogAnalytics: expect.objectContaining({ + mode: LogsEditorMode.Builder, + query: undefined, + }), + }) + ) + ); + }); + + it('renders the Run query button in Builder mode when not in Explore', async () => { + config.featureToggles.azureMonitorLogsBuilderEditor = true; + + const query: AzureMonitorQuery = { + ...createMockQuery(), + queryType: AzureQueryType.LogAnalytics, + azureLogAnalytics: { + mode: LogsEditorMode.Builder, + }, + }; + + renderComponent(query, { app: CoreApp.Dashboard }); + + expect(screen.getByTestId(selectors.components.queryEditor.logsQueryEditor.runQuery.button)).toBeInTheDocument(); + }); + + it('disables the Run query button spinner while loading', async () => { + config.featureToggles.azureMonitorLogsBuilderEditor = true; + + const query: AzureMonitorQuery = { + ...createMockQuery(), + queryType: AzureQueryType.LogAnalytics, + azureLogAnalytics: { + mode: LogsEditorMode.Builder, + }, + }; + + renderComponent(query, { + app: CoreApp.Dashboard, + data: { state: LoadingState.Loading } as PanelData, + }); + + expect(screen.getByTestId(selectors.components.queryEditor.logsQueryEditor.runQuery.button)).toBeInTheDocument(); + }); +}); diff --git a/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.tsx b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.tsx index ab7c817c935..7c371b7df11 100644 --- a/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.tsx @@ -84,12 +84,9 @@ export const QueryHeader = ({ } const goingToBuilder = newMode === LogsEditorMode.Builder; - const goingToRaw = newMode === LogsEditorMode.Raw; - const hasRawKql = !!query.azureLogAnalytics?.query; - const hasBuilderQuery = !!query.azureLogAnalytics?.builderQuery; - if ((goingToBuilder && hasRawKql) || (goingToRaw && hasBuilderQuery)) { + if (goingToBuilder && hasRawKql) { setPendingModeChange(newMode); setShowModeSwitchWarning(true); } else { @@ -103,7 +100,7 @@ export const QueryHeader = ({ azureLogAnalytics: { ...query.azureLogAnalytics, mode, - query: '', + query: mode === LogsEditorMode.Builder ? undefined : query.azureLogAnalytics?.query, builderQuery: mode === LogsEditorMode.Raw ? undefined : query.azureLogAnalytics?.builderQuery, dashboardTime: mode === LogsEditorMode.Builder ? true : undefined, }, @@ -123,10 +120,7 @@ export const QueryHeader = ({ 'components.query-header.body-switching-to-builder', 'Switching to Builder will discard your current KQL query and clear the KQL editor. Are you sure?' ) - : t( - 'components.query-header.body-switching-to-kql', - 'Switching to KQL will discard your current builder settings. Are you sure?' - ) + : null } confirmText={t('components.query-header.confirmText-switch-to', 'Switch to {{newMode}}', { newMode: pendingModeChange === LogsEditorMode.Builder ? 'Builder' : 'KQL', diff --git a/public/app/plugins/datasource/azuremonitor/locales/en-US/grafana-azure-monitor-datasource.json b/public/app/plugins/datasource/azuremonitor/locales/en-US/grafana-azure-monitor-datasource.json index 47d60ee13f7..cea54779bb1 100644 --- a/public/app/plugins/datasource/azuremonitor/locales/en-US/grafana-azure-monitor-datasource.json +++ b/public/app/plugins/datasource/azuremonitor/locales/en-US/grafana-azure-monitor-datasource.json @@ -204,7 +204,6 @@ "query-header": { "aria-label-kick-start": "Azure logs kick start your query button", "body-switching-to-builder": "Switching to Builder will discard your current KQL query and clear the KQL editor. Are you sure?", - "body-switching-to-kql": "Switching to KQL will discard your current builder settings. Are you sure?", "button-kick-start-your-query": "Kick start your query", "button-run-query": "Run query", "confirmText-switch-to": "Switch to {{newMode}}", diff --git a/public/app/plugins/datasource/cloud-monitoring/components/ConfigEditor/ConfigEditor.tsx b/public/app/plugins/datasource/cloud-monitoring/components/ConfigEditor/ConfigEditor.tsx index d866ad49185..e3ddd378078 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/ConfigEditor/ConfigEditor.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/ConfigEditor/ConfigEditor.tsx @@ -1,10 +1,10 @@ import { memo } from 'react'; -import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; +import { DataSourcePluginOptionsEditorProps, updateDatasourcePluginJsonDataOption } from '@grafana/data'; import { ConnectionConfig } from '@grafana/google-sdk'; import { ConfigSection, DataSourceDescription } from '@grafana/plugin-ui'; -import { reportInteraction, config } from '@grafana/runtime'; -import { Divider, SecureSocksProxySettings } from '@grafana/ui'; +import { config, reportInteraction } from '@grafana/runtime'; +import { Divider, Field, Input, SecureSocksProxySettings, Stack } from '@grafana/ui'; import { CloudMonitoringOptions, CloudMonitoringSecureJsonData } from '../../types/types'; @@ -36,14 +36,33 @@ export const ConfigEditor = memo(({ options, onOptionsChange }: Props) => { - + + + + updateDatasourcePluginJsonDataOption( + { options, onOptionsChange }, + 'universeDomain', + event.currentTarget.value + ) + } + placeholder="googleapis.com" + > + + + )} + ); }); diff --git a/public/app/plugins/datasource/cloud-monitoring/types/types.ts b/public/app/plugins/datasource/cloud-monitoring/types/types.ts index 849f72ea69b..f2e17444fb0 100644 --- a/public/app/plugins/datasource/cloud-monitoring/types/types.ts +++ b/public/app/plugins/datasource/cloud-monitoring/types/types.ts @@ -38,6 +38,7 @@ export interface Aggregation { export interface CloudMonitoringOptions extends DataSourceOptions { gceDefaultProject?: string; enableSecureSocksProxy?: boolean; + universeDomain?: string; } export interface CloudMonitoringSecureJsonData extends DataSourceSecureJsonData {} diff --git a/public/app/plugins/datasource/cloud-monitoring/webpack.config.ts b/public/app/plugins/datasource/cloud-monitoring/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/cloud-monitoring/webpack.config.ts +++ b/public/app/plugins/datasource/cloud-monitoring/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/elasticsearch/CHANGELOG.md b/public/app/plugins/datasource/elasticsearch/CHANGELOG.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.test.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.test.tsx index a9d0ad0710c..110e57e33f2 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.test.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.test.tsx @@ -1,8 +1,7 @@ import { render, screen } from '@testing-library/react'; import { select } from 'react-select-event'; -import { DateHistogram } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { DateHistogram } from '../../../../dataquery.gen'; import { useDispatch } from '../../../../hooks/useStatelessReducer'; import { DateHistogramSettingsEditor } from './DateHistogramSettingsEditor'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.tsx index 0dda5b13703..3ca8cb46631 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.tsx @@ -4,9 +4,9 @@ import { GroupBase, OptionsOrGroups } from 'react-select'; import { InternalTimeZones, SelectableValue } from '@grafana/data'; import { InlineField, Input, Select, TimeZonePicker } from '@grafana/ui'; -import { DateHistogram } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; import { calendarIntervals } from '../../../../QueryBuilder'; +import { DateHistogram } from '../../../../dataquery.gen'; import { useDispatch } from '../../../../hooks/useStatelessReducer'; import { useCreatableSelectPersistedBehaviour } from '../../../hooks/useCreatableSelectPersistedBehaviour'; import { changeBucketAggregationSetting } from '../state/actions'; @@ -37,11 +37,11 @@ const hasValue = const isValidNewOption = ( inputValue: string, _: SelectableValue | null, - options: OptionsOrGroups> + options: OptionsOrGroups, GroupBase>> ) => { // TODO: would be extremely nice here to allow only template variables and values that are // valid date histogram's Interval options - const valueExists = (options as Array>).some(hasValue(inputValue)); + const valueExists = options.some(hasValue(inputValue)); // we also don't want users to create "empty" values return !valueExists && inputValue.trim().length > 0; }; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/index.tsx index 6fded5be996..ddfd02b0cc4 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/index.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/index.tsx @@ -3,8 +3,8 @@ import { uniqueId } from 'lodash'; import { useEffect, useRef } from 'react'; import { InlineField, Input, QueryField } from '@grafana/ui'; -import { Filters } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; +import { Filters } from '../../../../../dataquery.gen'; import { useDispatch, useStatelessReducer } from '../../../../../hooks/useStatelessReducer'; import { AddRemove } from '../../../../AddRemove'; import { changeBucketAggregationSetting } from '../../state/actions'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/actions.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/actions.ts index e60a664f066..a5e3cb3d172 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/actions.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/actions.ts @@ -1,6 +1,6 @@ import { createAction } from '@reduxjs/toolkit'; -import { Filter } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; +import { Filter } from '../../../../../../dataquery.gen'; export const addFilter = createAction('@bucketAggregations/filter/add'); export const removeFilter = createAction('@bucketAggregations/filter/remove'); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.test.ts index 56b5ac9555c..eb03fcc96a3 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.test.ts @@ -1,6 +1,5 @@ -import { reducerTester } from 'test/core/redux/reducerTester'; - -import { Filter } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; +import { Filter } from '../../../../../../dataquery.gen'; +import { reducerTester } from '../../../../../reducerTester'; import { addFilter, changeFilter, removeFilter } from './actions'; import { reducer } from './reducer'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.ts index 022de8233b4..b99818d1850 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.ts @@ -1,7 +1,6 @@ import { Action } from 'redux'; -import { Filter } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { Filter } from '../../../../../../dataquery.gen'; import { defaultFilter } from '../utils'; import { addFilter, changeFilter, removeFilter } from './actions'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/utils.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/utils.ts index adf5646381d..3538a497bf4 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/utils.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/utils.ts @@ -1,3 +1,3 @@ -import { Filter } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; +import { Filter } from '../../../../../dataquery.gen'; export const defaultFilter = (): Filter => ({ label: '', query: '*' }); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.test.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.test.tsx index 14862e8f664..9012730c8e2 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.test.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.test.tsx @@ -1,14 +1,7 @@ import { fireEvent, screen } from '@testing-library/react'; import selectEvent from 'react-select-event'; -import { - Average, - Derivative, - ElasticsearchDataQuery, - Terms, - TopMetrics, -} from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { Average, Derivative, ElasticsearchDataQuery, Terms, TopMetrics } from '../../../../dataquery.gen'; import { useDispatch } from '../../../../hooks/useStatelessReducer'; import { renderWithESProvider } from '../../../../test-helpers/render'; import { describeMetric } from '../../../../utils'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.tsx index e1bc64980ab..852b7d8bb84 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.tsx @@ -2,15 +2,9 @@ import { uniqueId } from 'lodash'; import { useRef } from 'react'; import { SelectableValue } from '@grafana/data'; -import { InlineField, Select, Input } from '@grafana/ui'; -import { - Terms, - ExtendedStats, - ExtendedStatMetaType, - Percentiles, - MetricAggregation, -} from 'app/plugins/datasource/elasticsearch/dataquery.gen'; +import { InlineField, Input, Select } from '@grafana/ui'; +import { ExtendedStats, MetricAggregation, Percentiles, Terms } from '../../../../dataquery.gen'; import { useDispatch } from '../../../../hooks/useStatelessReducer'; import { describeMetric } from '../../../../utils'; import { useQuery } from '../../ElasticsearchQueryContext'; @@ -105,7 +99,7 @@ function createOrderByOptionsForExtendedStats(metric: ExtendedStats): Selectable if (!metric.meta) { return []; } - const metaKeys = Object.keys(metric.meta) as ExtendedStatMetaType[]; + const metaKeys = Object.keys(metric.meta); return metaKeys .filter((key) => metric.meta?.[key]) .map((key) => { diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/index.tsx index 9191ca25d1c..63be91fad00 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/index.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/index.tsx @@ -2,8 +2,8 @@ import { uniqueId } from 'lodash'; import { ComponentProps, useRef } from 'react'; import { InlineField, Input } from '@grafana/ui'; -import { BucketAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; +import { BucketAggregation } from '../../../../dataquery.gen'; import { useDispatch } from '../../../../hooks/useStatelessReducer'; import { SettingsEditorContainer } from '../../SettingsEditorContainer'; import { changeBucketAggregationSetting } from '../state/actions'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts index 3e4e5cfea7c..a0f60b799a4 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts @@ -1,7 +1,6 @@ -import { BucketAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { BucketAggregation } from '../../../../dataquery.gen'; import { defaultGeoHashPrecisionString } from '../../../../queryDef'; -import { describeMetric, convertOrderByToMetricId } from '../../../../utils'; +import { convertOrderByToMetricId, describeMetric } from '../../../../utils'; import { useQuery } from '../../ElasticsearchQueryContext'; import { bucketAggregationConfig, orderByOptions, orderOptions } from '../utils'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/actions.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/actions.ts index dfab9ac0279..e3dff091246 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/actions.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/actions.ts @@ -1,10 +1,6 @@ import { createAction } from '@reduxjs/toolkit'; -import { - BucketAggregation, - BucketAggregationType, - BucketAggregationWithField, -} from 'app/plugins/datasource/elasticsearch/dataquery.gen'; +import { BucketAggregation, BucketAggregationType, BucketAggregationWithField } from '../../../../dataquery.gen'; export const addBucketAggregation = createAction('@bucketAggs/add'); export const removeBucketAggregation = createAction('@bucketAggs/remove'); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts index f4a5cc02dde..462f5938b81 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts @@ -1,9 +1,4 @@ -import { - BucketAggregation, - DateHistogram, - ElasticsearchDataQuery, -} from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { BucketAggregation, DateHistogram, ElasticsearchDataQuery } from '../../../../dataquery.gen'; import { defaultBucketAgg } from '../../../../queryDef'; import { reducerTester } from '../../../reducerTester'; import { changeMetricType } from '../../MetricAggregationsEditor/state/actions'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts index 5ba29e656d8..789405c97be 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts @@ -1,7 +1,6 @@ import { Action } from '@reduxjs/toolkit'; -import { BucketAggregation, ElasticsearchDataQuery, Terms } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { BucketAggregation, ElasticsearchDataQuery, Terms } from '../../../../dataquery.gen'; import { defaultBucketAgg } from '../../../../queryDef'; import { removeEmpty } from '../../../../utils'; import { changeMetricType } from '../../MetricAggregationsEditor/state/actions'; @@ -47,11 +46,12 @@ export const createReducer = } /* - TODO: The previous version of the query editor was keeping some of the old bucket aggregation's configurations - in the new selected one (such as field or some settings). - It the future would be nice to have the same behavior but it's hard without a proper definition, - as Elasticsearch will error sometimes if some settings are not compatible. - */ + TODO: The previous version of the query editor was keeping some of the old bucket aggregation's configurations + in the new selected one (such as field or some settings). + It the future would be nice to have the same behavior but it's hard without a proper definition, + as Elasticsearch will error sometimes if some settings are not compatible. + */ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return { id: bucketAgg.id, type: action.payload.newType, diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/EditorTypeSelector.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/EditorTypeSelector.tsx index c9d52ecd49d..5939f413163 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/EditorTypeSelector.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/EditorTypeSelector.tsx @@ -1,29 +1,26 @@ import { SelectableValue } from '@grafana/data'; import { RadioButtonGroup } from '@grafana/ui'; -import { useDispatch } from '../../hooks/useStatelessReducer'; import { EditorType } from '../../types'; -import { useQuery } from './ElasticsearchQueryContext'; -import { changeEditorTypeAndResetQuery } from './state'; - const BASE_OPTIONS: Array> = [ { value: 'builder', label: 'Builder' }, { value: 'code', label: 'Code' }, ]; -export const EditorTypeSelector = () => { - const query = useQuery(); - const dispatch = useDispatch(); - - // Default to 'builder' if editorType is empty - const editorType: EditorType = query.editorType === 'code' ? 'code' : 'builder'; - - const onChange = (newEditorType: EditorType) => { - dispatch(changeEditorTypeAndResetQuery(newEditorType)); - }; +interface Props { + value: EditorType; + onChange: (editorType: EditorType) => void; +} +export const EditorTypeSelector = ({ value, onChange }: Props) => { return ( - fullWidth={false} options={BASE_OPTIONS} value={editorType} onChange={onChange} /> + + data-testid="elasticsearch-editor-type-toggle" + size="sm" + options={BASE_OPTIONS} + value={value} + onChange={onChange} + /> ); }; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/index.tsx index 70d53eddbc4..348a8a630ce 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/index.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/index.tsx @@ -2,10 +2,10 @@ import { css } from '@emotion/css'; import { uniqueId } from 'lodash'; import { Fragment, useEffect } from 'react'; -import { Input, InlineLabel } from '@grafana/ui'; -import { BucketScript, MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; +import { InlineLabel, Input } from '@grafana/ui'; -import { useStatelessReducer, useDispatch } from '../../../../../hooks/useStatelessReducer'; +import { BucketScript, MetricAggregation } from '../../../../../dataquery.gen'; +import { useDispatch, useStatelessReducer } from '../../../../../hooks/useStatelessReducer'; import { AddRemove } from '../../../../AddRemove'; import { MetricPicker } from '../../../../MetricPicker'; import { changeMetricAttribute } from '../../state/actions'; @@ -13,9 +13,9 @@ import { SettingField } from '../SettingField'; import { addPipelineVariable, + changePipelineVariableMetric, removePipelineVariable, renamePipelineVariable, - changePipelineVariableMetric, } from './state/actions'; import { reducer } from './state/reducer'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts index 02fc628ecc3..276ca285c64 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts @@ -1,5 +1,4 @@ -import { PipelineVariable } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { PipelineVariable } from '../../../../../../dataquery.gen'; import { reducerTester } from '../../../../../reducerTester'; import { diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.ts index 406b1f5b590..8d798a5717b 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.ts @@ -1,7 +1,6 @@ import { Action } from '@reduxjs/toolkit'; -import { PipelineVariable } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { PipelineVariable } from '../../../../../../dataquery.gen'; import { defaultPipelineVariable, generatePipelineVariableName } from '../utils'; import { diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/utils.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/utils.ts index e2da781d190..4c3991c69a9 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/utils.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/utils.ts @@ -1,4 +1,4 @@ -import { PipelineVariable } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; +import { PipelineVariable } from '../../../../../dataquery.gen'; export const defaultPipelineVariable = (name: string): PipelineVariable => ({ name, pipelineAgg: '' }); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx index e4a3ad907a9..588b4692f5e 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx @@ -2,11 +2,8 @@ import { uniqueId } from 'lodash'; import { ComponentProps, useState } from 'react'; import { InlineField, Input, TextArea } from '@grafana/ui'; -import { - MetricAggregationWithSettings, - MetricAggregationWithInlineScript, -} from 'app/plugins/datasource/elasticsearch/dataquery.gen'; +import { MetricAggregationWithInlineScript, MetricAggregationWithSettings } from '../../../../dataquery.gen'; import { useDispatch } from '../../../../hooks/useStatelessReducer'; import { getScriptValue } from '../../../../utils'; import { SettingKeyOf } from '../../../types'; @@ -33,9 +30,11 @@ export function SettingField (object: { value: string }) => object.value === value; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/actions.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/actions.ts index 9adff8781b1..b0b52dd39e7 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/actions.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/actions.ts @@ -1,7 +1,6 @@ import { createAction } from '@reduxjs/toolkit'; -import { MetricAggregation, MetricAggregationWithSettings } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { MetricAggregation, MetricAggregationWithSettings } from '../../../../dataquery.gen'; import { MetricAggregationWithMeta } from '../../../../types'; export const addMetric = createAction('@metrics/add'); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts index 9dcbaa9f974..38a4e0f05d1 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts @@ -1,10 +1,4 @@ -import { - MetricAggregation, - ElasticsearchDataQuery, - Derivative, - ExtendedStats, -} from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { Derivative, ElasticsearchDataQuery, ExtendedStats, MetricAggregation } from '../../../../dataquery.gen'; import { defaultMetricAgg } from '../../../../queryDef'; import { reducerTester } from '../../../reducerTester'; import { changeEditorTypeAndResetQuery, initQuery } from '../../state'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts index c0dab7bd4b1..57acf1e87e5 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts @@ -1,7 +1,6 @@ import { Action } from '@reduxjs/toolkit'; -import { ElasticsearchDataQuery, MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; - +import { ElasticsearchDataQuery, MetricAggregation } from '../../../../dataquery.gen'; import { defaultMetricAgg, queryTypeToMetricType } from '../../../../queryDef'; import { removeEmpty } from '../../../../utils'; import { changeEditorTypeAndResetQuery, initQuery } from '../../state'; @@ -57,6 +56,7 @@ export const reducer = ( It the future would be nice to have the same behavior but it's hard without a proper definition, as Elasticsearch will error sometimes if some settings are not compatible. */ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return { id: metric.id, type: action.payload.type, diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/RawQueryEditor.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/RawQueryEditor.tsx index 92a5a8b0b9e..bda3cf85e76 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/RawQueryEditor.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/RawQueryEditor.tsx @@ -10,9 +10,13 @@ interface Props { onRunQuery: () => void; } +// This offset was chosen by testing to match Prometheus behavior +const EDITOR_HEIGHT_OFFSET = 2; + export function RawQueryEditor({ value, onChange, onRunQuery }: Props) { const styles = useStyles2(getStyles); const editorRef = useRef(null); + const containerRef = useRef(null); const handleEditorDidMount = useCallback( (editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => { @@ -22,6 +26,22 @@ export function RawQueryEditor({ value, onChange, onRunQuery }: Props) { editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => { onRunQuery(); }); + + // Make the editor resize itself so that the content fits (grows taller when necessary) + // this code comes from the Prometheus query editor. + // We may wish to consider abstracting it into the grafana/ui repo in the future + const updateElementHeight = () => { + const containerDiv = containerRef.current; + if (containerDiv !== null) { + const pixelHeight = editor.getContentHeight(); + containerDiv.style.height = `${pixelHeight + EDITOR_HEIGHT_OFFSET}px`; + const pixelWidth = containerDiv.clientWidth; + editor.layout({ width: pixelWidth, height: pixelHeight }); + } + }; + + editor.onDidContentSizeChange(updateElementHeight); + updateElementHeight(); }, [onRunQuery] ); @@ -65,7 +85,17 @@ export function RawQueryEditor({ value, onChange, onRunQuery }: Props) { return ( -
        +
        + +
        +
        -
        - ); } @@ -100,7 +118,11 @@ const getStyles = (theme: GrafanaTheme2) => ({ flexDirection: 'column', gap: theme.spacing(1), }), - header: css({ + editorContainer: css({ + width: '100%', + overflow: 'hidden', + }), + footer: css({ display: 'flex', justifyContent: 'flex-end', padding: theme.spacing(0.5, 0), diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.tsx index b54de44bb63..a3731c3c99a 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.tsx @@ -1,16 +1,16 @@ import { css } from '@emotion/css'; -import { useEffect, useId, useState } from 'react'; +import { useCallback, useEffect, useId, useState } from 'react'; import { SemVer } from 'semver'; import { getDefaultTimeRange, GrafanaTheme2, QueryEditorProps } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { Alert, InlineField, InlineLabel, Input, QueryField, useStyles2 } from '@grafana/ui'; +import { Alert, ConfirmModal, InlineField, InlineLabel, Input, QueryField, useStyles2 } from '@grafana/ui'; import { ElasticsearchDataQuery } from '../../dataquery.gen'; import { ElasticDatasource } from '../../datasource'; import { useNextId } from '../../hooks/useNextId'; import { useDispatch } from '../../hooks/useStatelessReducer'; -import { ElasticsearchOptions } from '../../types'; +import { EditorType, ElasticsearchOptions } from '../../types'; import { isSupportedVersion, isTimeSeriesQuery, unsupportedVersionMessage } from '../../utils'; import { BucketAggregationsEditor } from './BucketAggregationsEditor'; @@ -20,7 +20,7 @@ import { MetricAggregationsEditor } from './MetricAggregationsEditor'; import { metricAggregationConfig } from './MetricAggregationsEditor/utils'; import { QueryTypeSelector } from './QueryTypeSelector'; import { RawQueryEditor } from './RawQueryEditor'; -import { changeAliasPattern, changeQuery, changeRawDSLQuery } from './state'; +import { changeAliasPattern, changeEditorTypeAndResetQuery, changeQuery, changeRawDSLQuery } from './state'; export type ElasticQueryEditorProps = QueryEditorProps; @@ -97,31 +97,61 @@ const QueryEditorForm = ({ value, onRunQuery }: Props & { onRunQuery: () => void const inputId = useId(); const styles = useStyles2(getStyles); + const [switchModalOpen, setSwitchModalOpen] = useState(false); + const [pendingEditorType, setPendingEditorType] = useState(null); + const isTimeSeries = isTimeSeriesQuery(value); const isCodeEditor = value.editorType === 'code'; const rawDSLFeatureEnabled = config.featureToggles.elasticsearchRawDSLQuery; + // Default to 'builder' if editorType is empty + const currentEditorType: EditorType = value.editorType === 'code' ? 'code' : 'builder'; + const showBucketAggregationsEditor = value.metrics?.every( (metric) => metricAggregationConfig[metric.type].impliedQueryType === 'metrics' ); + const onEditorTypeChange = useCallback((newEditorType: EditorType) => { + // Show warning modal when switching modes + setPendingEditorType(newEditorType); + setSwitchModalOpen(true); + }, []); + + const confirmEditorTypeChange = useCallback(() => { + if (pendingEditorType) { + dispatch(changeEditorTypeAndResetQuery(pendingEditorType)); + } + setSwitchModalOpen(false); + setPendingEditorType(null); + }, [dispatch, pendingEditorType]); + + const cancelEditorTypeChange = useCallback(() => { + setSwitchModalOpen(false); + setPendingEditorType(null); + }, []); + return ( <> +
        Query type
        -
        - {rawDSLFeatureEnabled && ( -
        - Editor type -
        - + {rawDSLFeatureEnabled && ( +
        +
        -
        - )} + )} +
        {isCodeEditor && rawDSLFeatureEnabled && ( = (state: S, action: A) => S; diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ApiKeyConfig.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ApiKeyConfig.tsx new file mode 100644 index 00000000000..8433160d4f2 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/configuration/ApiKeyConfig.tsx @@ -0,0 +1,22 @@ +import { onUpdateDatasourceSecureJsonDataOption, updateDatasourcePluginResetOption } from '@grafana/data'; +import { InlineField, SecretInput } from '@grafana/ui'; + +import { Props } from './ConfigEditor'; + +export const ApiKeyConfig = (props: Props) => { + const { options } = props; + + return ( + + updateDatasourcePluginResetOption(props, 'apiKey')} + onChange={onUpdateDatasourceSecureJsonDataOption(props, 'apiKey')} + /> + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx index 5961ebef510..57e3f8dc92a 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx @@ -14,14 +14,15 @@ import { import { config } from '@grafana/runtime'; import { Alert, SecureSocksProxySettings, Divider, Stack } from '@grafana/ui'; -import { ElasticsearchOptions } from '../types'; +import { ElasticsearchOptions, ElasticsearchSecureJsonData } from '../types'; +import { ApiKeyConfig } from './ApiKeyConfig'; import { DataLinks } from './DataLinks'; import { ElasticDetails } from './ElasticDetails'; import { LogsConfig } from './LogsConfig'; import { coerceOptions, isValidOptions } from './utils'; -export type Props = DataSourcePluginOptionsEditorProps; +export type Props = DataSourcePluginOptionsEditorProps; export const ConfigEditor = (props: Props) => { const { options, onOptionsChange } = props; @@ -48,6 +49,16 @@ export const ConfigEditor = (props: Props) => { authProps.selectedMethod = options.jsonData.sigV4Auth ? 'custom-sigv4' : authProps.selectedMethod; } + authProps.customMethods = [ + { + id: 'custom-api-key', + label: 'API Key', + description: 'API Key authentication', + component: , + }, + ]; + authProps.selectedMethod = options.jsonData.apiKeyAuth ? 'custom-api-key' : authProps.selectedMethod; + return ( <> {options.access === 'direct' && ( @@ -73,6 +84,7 @@ export const ConfigEditor = (props: Props) => { jsonData: { ...options.jsonData, sigV4Auth: method === 'custom-sigv4', + apiKeyAuth: method === 'custom-api-key', oauthPassThru: method === AuthMethod.OAuthForward, }, }); diff --git a/public/app/plugins/datasource/elasticsearch/jest-setup.js b/public/app/plugins/datasource/elasticsearch/jest-setup.js new file mode 100644 index 00000000000..c85bf9d3a57 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/jest-setup.js @@ -0,0 +1 @@ +import '@grafana/plugin-configs/jest/jest-setup'; diff --git a/public/app/plugins/datasource/elasticsearch/jest.config.js b/public/app/plugins/datasource/elasticsearch/jest.config.js new file mode 100644 index 00000000000..fabef448081 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/jest.config.js @@ -0,0 +1,3 @@ +import defaultConfig from '@grafana/plugin-configs/jest/jest.config.js'; + +export default defaultConfig; diff --git a/public/app/plugins/datasource/elasticsearch/package.json b/public/app/plugins/datasource/elasticsearch/package.json new file mode 100644 index 00000000000..bd1470fa2c6 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/package.json @@ -0,0 +1,62 @@ +{ + "name": "@grafana-plugins/elasticsearch", + "description": "Grafana data source for Elasticsearch", + "private": true, + "version": "12.4.0-pre", + "dependencies": { + "@emotion/css": "11.13.5", + "@grafana/aws-sdk": "0.8.3", + "@grafana/data": "12.4.0-pre", + "@grafana/plugin-ui": "^0.11.1", + "@grafana/runtime": "12.4.0-pre", + "@grafana/schema": "12.4.0-pre", + "@grafana/ui": "12.4.0-pre", + "@reduxjs/toolkit": "2.10.1", + "lodash": "4.17.21", + "lucene": "^2.1.1", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-redux": "9.2.0", + "react-select": "5.10.2", + "react-use": "17.6.0", + "redux": "5.0.1", + "redux-thunk": "3.1.0", + "rxjs": "7.8.2", + "semver": "7.7.3", + "tslib": "2.8.1" + }, + "devDependencies": { + "@grafana/e2e-selectors": "12.4.0-pre", + "@grafana/plugin-configs": "12.4.0-pre", + "@testing-library/dom": "10.4.1", + "@testing-library/jest-dom": "6.6.4", + "@testing-library/react": "16.3.0", + "@testing-library/user-event": "14.6.1", + "@types/jest": "29.5.14", + "@types/lodash": "4.17.20", + "@types/lucene": "^2", + "@types/node": "24.10.1", + "@types/react": "18.3.18", + "@types/react-dom": "18.3.5", + "@types/semver": "7.7.1", + "jest": "29.7.0", + "react-select-event": "5.5.1", + "ts-node": "10.9.2", + "typescript": "5.9.2", + "webpack": "5.101.0" + }, + "peerDependencies": { + "@grafana/runtime": "*" + }, + "resolutions": { + "redux": "^5.0.0" + }, + "scripts": { + "build": "webpack -c ./webpack.config.ts --env production", + "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", + "dev": "webpack -w -c ./webpack.config.ts --env development", + "test": "jest --watch --onlyChanged", + "test:ci": "jest --maxWorkers 4" + }, + "packageManager": "yarn@4.11.0" +} diff --git a/public/app/plugins/datasource/elasticsearch/plugin.json b/public/app/plugins/datasource/elasticsearch/plugin.json index 0e056ffa447..9440fcfed54 100644 --- a/public/app/plugins/datasource/elasticsearch/plugin.json +++ b/public/app/plugins/datasource/elasticsearch/plugin.json @@ -2,6 +2,7 @@ "type": "datasource", "name": "Elasticsearch", "id": "elasticsearch", + "executable": "gpx_elasticsearch", "category": "logging", "info": { "description": "Open source logging & analytics database", @@ -27,7 +28,8 @@ "name": "Documentation", "url": "https://grafana.com/docs/grafana/latest/datasources/elasticsearch/" } - ] + ], + "version": "%VERSION%" }, "alerting": true, "annotations": true, @@ -36,5 +38,9 @@ "backend": true, "queryOptions": { "minInterval": true + }, + "dependencies": { + "grafanaDependency": ">=11.6.0", + "plugins": [] } } diff --git a/public/app/plugins/datasource/elasticsearch/project.json b/public/app/plugins/datasource/elasticsearch/project.json new file mode 100644 index 00000000000..4247352791d --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/project.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "tags": ["scope:plugin", "type:datasource"], + "targets": { + "build": {}, + "dev": {} + } +} diff --git a/public/app/plugins/datasource/elasticsearch/reducers/actions/cleanUp.ts b/public/app/plugins/datasource/elasticsearch/reducers/actions/cleanUp.ts new file mode 100644 index 00000000000..3aa81581e06 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/reducers/actions/cleanUp.ts @@ -0,0 +1,11 @@ +import { createAction } from '@reduxjs/toolkit'; + +import { StoreState } from '../../types/store'; + +export type CleanUpAction = (state: StoreState) => void; + +export interface CleanUpPayload { + cleanupAction: CleanUpAction; +} + +export const cleanUpAction = createAction('core/cleanUpState'); diff --git a/public/app/plugins/datasource/elasticsearch/reducers/root.ts b/public/app/plugins/datasource/elasticsearch/reducers/root.ts new file mode 100644 index 00000000000..5e13826691a --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/reducers/root.ts @@ -0,0 +1,21 @@ +import { ReducersMapObject } from '@reduxjs/toolkit'; +import { Action as AnyAction, combineReducers } from 'redux'; + +const addedReducers = { + defaultReducer: (state = {}) => state, + templating: (state = { lastKey: 'key' }) => state, +}; + +export const addReducer = (newReducers: ReducersMapObject) => { + Object.assign(addedReducers, newReducers); +}; + +export const createRootReducer = () => { + const appReducer = combineReducers({ + ...addedReducers, + }); + + return (state: Parameters[0], action: AnyAction) => { + return appReducer(state, action); + }; +}; diff --git a/public/app/plugins/datasource/elasticsearch/store/configureStore.ts b/public/app/plugins/datasource/elasticsearch/store/configureStore.ts new file mode 100644 index 00000000000..319cccd193d --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/store/configureStore.ts @@ -0,0 +1,47 @@ +import { createListenerMiddleware, configureStore as reduxConfigureStore } from '@reduxjs/toolkit'; +import { setupListeners } from '@reduxjs/toolkit/query'; +import { Middleware } from 'redux'; + +import { addReducer, createRootReducer } from '../reducers/root'; +import { StoreState } from '../types/store'; + +import { setStore } from './store'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function addRootReducer(reducers: any) { + // this is ok now because we add reducers before configureStore is called + // in the future if we want to add reducers during runtime + // we'll have to solve this in a more dynamic way + addReducer(reducers); +} + +const listenerMiddleware = createListenerMiddleware(); +const extraMiddleware: Middleware[] = []; + +export function addExtraMiddleware(middleware: Middleware) { + extraMiddleware.push(middleware); +} + +export function configureStore(initialState?: Partial) { + const store = reduxConfigureStore({ + reducer: createRootReducer(), + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ thunk: true, serializableCheck: false, immutableCheck: false }).concat( + listenerMiddleware.middleware, + ...extraMiddleware + ), + devTools: process.env.NODE_ENV !== 'production', + preloadedState: { + ...initialState, + }, + }); + + // this enables "refetchOnFocus" and "refetchOnReconnect" for RTK Query + setupListeners(store.dispatch); + + setStore(store); + return store; +} + +export type RootState = ReturnType['getState']>; +export type AppDispatch = ReturnType['dispatch']; diff --git a/public/app/plugins/datasource/elasticsearch/store/store.ts b/public/app/plugins/datasource/elasticsearch/store/store.ts new file mode 100644 index 00000000000..aaccaca84f5 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/store/store.ts @@ -0,0 +1,26 @@ +import { Store } from 'redux'; + +import { StoreState } from '../types/store'; + +export let store: Store; + +export function setStore(newStore: Store) { + store = newStore; +} + +export function getState(): StoreState { + if (!store || !store.getState) { + return { defaultReducer: () => ({}), templating: { lastKey: 'key' } }; // used by tests + } + + return store.getState(); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function dispatch(action: any) { + if (!store || !store.getState) { + return; + } + + return store.dispatch(action); +} diff --git a/public/app/plugins/datasource/elasticsearch/tsconfig.json b/public/app/plugins/datasource/elasticsearch/tsconfig.json new file mode 100644 index 00000000000..40352099203 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "types": ["node", "jest", "@testing-library/jest-dom"] + }, + "extends": "@grafana/plugin-configs/tsconfig.json", + "include": ["."] +} diff --git a/public/app/plugins/datasource/elasticsearch/types.ts b/public/app/plugins/datasource/elasticsearch/types.ts index 4645a2a824f..d435f1b4594 100644 --- a/public/app/plugins/datasource/elasticsearch/types.ts +++ b/public/app/plugins/datasource/elasticsearch/types.ts @@ -64,6 +64,11 @@ export interface ElasticsearchOptions extends DataSourceJsonData { sigV4Auth?: boolean; oauthPassThru?: boolean; defaultQueryMode?: QueryType; + apiKeyAuth?: boolean; +} + +export interface ElasticsearchSecureJsonData { + apiKey?: string; } export type QueryType = 'metrics' | 'logs' | 'raw_data' | 'raw_document'; diff --git a/public/app/plugins/datasource/elasticsearch/types/store.ts b/public/app/plugins/datasource/elasticsearch/types/store.ts new file mode 100644 index 00000000000..1ff65f1a7af --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/types/store.ts @@ -0,0 +1,46 @@ +/* eslint-disable no-restricted-imports */ +import { + Action, + addListener as addListenerUntyped, + AsyncThunk, + AsyncThunkOptions, + AsyncThunkPayloadCreator, + createAsyncThunk as createAsyncThunkUntyped, + PayloadAction, + TypedAddListener, +} from '@reduxjs/toolkit'; +import { + TypedUseSelectorHook, + useDispatch as useDispatchUntyped, + useSelector as useSelectorUntyped, +} from 'react-redux'; +import { ThunkDispatch as GenericThunkDispatch, ThunkAction } from 'redux-thunk'; + +import type { createRootReducer } from '../reducers/root'; +import { AppDispatch, RootState } from '../store/configureStore'; +import { dispatch as storeDispatch } from '../store/store'; + +export type StoreState = ReturnType>; + +/* + * Utility type to get strongly types thunks + */ +export type ThunkResult = ThunkAction>; + +export type ThunkDispatch = GenericThunkDispatch; + +// Typed useDispatch & useSelector hooks +export const useDispatch: () => AppDispatch = useDispatchUntyped; +export const useSelector: TypedUseSelectorHook = useSelectorUntyped; + +type DefaultThunkApiConfig = { dispatch: AppDispatch; state: StoreState }; +export const createAsyncThunk = ( + typePrefix: string, + payloadCreator: AsyncThunkPayloadCreator, + options?: AsyncThunkOptions +): AsyncThunk => + createAsyncThunkUntyped(typePrefix, payloadCreator, options); + +// eslint-disable-next-line @typescript-eslint/consistent-type-assertions +export const addListener = addListenerUntyped as TypedAddListener; +export const dispatch: AppDispatch = storeDispatch; diff --git a/public/app/plugins/datasource/elasticsearch/webpack.config.ts b/public/app/plugins/datasource/elasticsearch/webpack.config.ts new file mode 100644 index 00000000000..f64bb95e3c0 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/webpack.config.ts @@ -0,0 +1,9 @@ +import type { Configuration } from 'webpack'; + +import grafanaConfig, { type Env } from '@grafana/plugin-configs/webpack.config.ts'; + +const config = async (env: Env): Promise => { + return await grafanaConfig(env); +}; + +export default config; diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/webpack.config.ts b/public/app/plugins/datasource/grafana-postgresql-datasource/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/webpack.config.ts +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/webpack.config.ts b/public/app/plugins/datasource/grafana-testdata-datasource/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/webpack.config.ts +++ b/public/app/plugins/datasource/grafana-testdata-datasource/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/graphite/webpack.config.ts b/public/app/plugins/datasource/graphite/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/graphite/webpack.config.ts +++ b/public/app/plugins/datasource/graphite/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/influxdb/components/editor/config-v2/ConfigEditor.test.tsx b/public/app/plugins/datasource/influxdb/components/editor/config-v2/ConfigEditor.test.tsx index 9035fc5c7a8..db40627bc0a 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/config-v2/ConfigEditor.test.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/config-v2/ConfigEditor.test.tsx @@ -36,9 +36,4 @@ describe('ConfigEditor', () => { expect(screen.getByTestId('url-auth-section')).toBeInTheDocument(); expect(screen.getByTestId('db-connection-section')).toBeInTheDocument(); }); - - it('shows the informational alert', () => { - render(); - expect(screen.getByText(/You are viewing a new design/i)).toBeInTheDocument(); - }); }); diff --git a/public/app/plugins/datasource/influxdb/components/editor/config-v2/ConfigEditor.tsx b/public/app/plugins/datasource/influxdb/components/editor/config-v2/ConfigEditor.tsx index a6cc7eb3747..c68b7f2c039 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/config-v2/ConfigEditor.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/config-v2/ConfigEditor.tsx @@ -2,13 +2,12 @@ import { css } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Alert, Box, Stack, TextLink, Text, useStyles2 } from '@grafana/ui'; +import { Box, Stack, Text, useStyles2 } from '@grafana/ui'; import { DatabaseConnectionSection } from './DatabaseConnectionSection'; import { LeftSideBar } from './LeftSideBar'; import { UrlAndAuthenticationSection } from './UrlAndAuthenticationSection'; import { CONTAINER_MIN_WIDTH } from './constants'; -import { trackInfluxDBConfigV2FeedbackButtonClicked } from './tracking'; import { Props } from './types'; export const ConfigEditor: React.FC = ({ onOptionsChange, options }: Props) => { @@ -22,22 +21,6 @@ export const ConfigEditor: React.FC = ({ onOptionsChange, options }: Prop
        - - <> - - Share your thoughts - {' '} - to help us make it even better. - - Fields marked with * are required diff --git a/public/app/plugins/datasource/loki/webpack.config.ts b/public/app/plugins/datasource/loki/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/loki/webpack.config.ts +++ b/public/app/plugins/datasource/loki/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/mysql/webpack.config.ts b/public/app/plugins/datasource/mysql/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/mysql/webpack.config.ts +++ b/public/app/plugins/datasource/mysql/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/opentsdb/webpack.config.ts b/public/app/plugins/datasource/opentsdb/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/opentsdb/webpack.config.ts +++ b/public/app/plugins/datasource/opentsdb/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/tempo/datasource.test.ts b/public/app/plugins/datasource/tempo/datasource.test.ts index 6e51e5511b1..9a0abe1cc27 100644 --- a/public/app/plugins/datasource/tempo/datasource.test.ts +++ b/public/app/plugins/datasource/tempo/datasource.test.ts @@ -61,7 +61,6 @@ describe('Tempo data source', () => { describe('runs correctly', () => { const handleStreamingQuery = jest.spyOn(TempoDatasource.prototype, 'handleStreamingQuery'); - const request = jest.spyOn(TempoDatasource.prototype, '_request'); const templateSrv: TemplateSrv = { replace: (s: string) => s } as unknown as TemplateSrv; const range = { @@ -97,7 +96,6 @@ describe('Tempo data source', () => { const ds = new TempoDatasource(defaultSettings, templateSrv); await lastValueFrom(ds.query(traceqlQuery as DataQueryRequest)); expect(handleStreamingQuery).toHaveBeenCalledTimes(1); - expect(request).toHaveBeenCalledTimes(0); }); it('for traceqlSearch queries when live is enabled', async () => { @@ -105,7 +103,6 @@ describe('Tempo data source', () => { const ds = new TempoDatasource(defaultSettings, templateSrv); await lastValueFrom(ds.query(traceqlSearchQuery as DataQueryRequest)); expect(handleStreamingQuery).toHaveBeenCalledTimes(1); - expect(request).toHaveBeenCalledTimes(0); }); it('for traceql queries when live is not enabled', async () => { @@ -113,7 +110,6 @@ describe('Tempo data source', () => { const ds = new TempoDatasource(defaultSettings, templateSrv); await lastValueFrom(ds.query(traceqlQuery as DataQueryRequest)); expect(handleStreamingQuery).toHaveBeenCalledTimes(1); - expect(request).toHaveBeenCalledTimes(1); }); it('for traceqlSearch queries when live is not enabled', async () => { @@ -121,7 +117,6 @@ describe('Tempo data source', () => { const ds = new TempoDatasource(defaultSettings, templateSrv); await lastValueFrom(ds.query(traceqlSearchQuery as DataQueryRequest)); expect(handleStreamingQuery).toHaveBeenCalledTimes(1); - expect(request).toHaveBeenCalledTimes(1); }); }); diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index 4c926300cb3..d439e861023 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -23,14 +23,11 @@ import { SelectableValue, TestDataSourceResponse, TimeRange, - urlUtil, } from '@grafana/data'; import { NodeGraphOptions, SpanBarOptions, TraceToLogsOptions } from '@grafana/o11y-ds-frontend'; import { - BackendSrvRequest, config, DataSourceWithBackend, - getBackendSrv, getDataSourceSrv, getTemplateSrv, reportInteraction, @@ -59,7 +56,6 @@ import { import TempoLanguageProvider from './language_provider'; import { enhanceTraceQlMetricsResponse, - formatTraceQLResponse, transformFromOTLP as transformFromOTEL, transformTrace, } from './resultTransformer'; @@ -419,12 +415,7 @@ export class TempoDatasource extends DataSourceWithBackend, - targets: { [type: string]: TempoQuery[] }, - queryValue: string - ) => { - const startTime = performance.now(); - const tableType = targets.traceqlSearch?.[0]?.tableType ?? targets.traceql?.[0]?.tableType; - - return this._request('/api/search', { - q: queryValue, - limit: options.targets[0].limit ?? DEFAULT_LIMIT, - spss: options.targets[0].spss ?? DEFAULT_SPSS, - start: options.range.from.unix(), - end: options.range.to.unix(), - }).pipe( - map((response) => { - reportTempoQueryMetrics('grafana_traces_traceql_response', options, { - success: true, - streaming: false, - latencyMs: Math.round(performance.now() - startTime), // rounded to nearest millisecond - query: queryValue ?? '', - }); - return { - data: formatTraceQLResponse(response.data.traces, this.instanceSettings, tableType), - }; - }), - catchError((err) => { - reportTempoQueryMetrics('grafana_traces_traceql_response', options, { - success: false, - streaming: false, - latencyMs: Math.round(performance.now() - startTime), // rounded to nearest millisecond - query: queryValue ?? '', - error: getErrorMessage(err.message), - statusCode: err.status, - statusText: err.statusText, - }); - return of({ error: { message: getErrorMessage(err?.data?.message) }, data: [] }); - }) - ); - }; - handleTraceQlMetricsQuery( options: DataQueryRequest, targets: TempoQuery[], @@ -811,7 +755,7 @@ export class TempoDatasource extends DataSourceWithBackend doTempoSearchStreaming( - { ...target, query: this.applyVariables(target, options.scopedVars).query }, + { ...target, query: query }, this, // the datasource options, this.instanceSettings @@ -926,13 +870,6 @@ export class TempoDatasource extends DataSourceWithBackend): Observable> { - const params = data ? urlUtil.serializeParams(data) : ''; - const url = `${this.instanceSettings.url}${apiUrl}${params.length ? `?${params}` : ''}`; - const req = { ...options, url }; - return getBackendSrv().fetch(req); - } - async testDatasource(): Promise { return await super.testDatasource(); } diff --git a/public/app/plugins/datasource/tempo/webpack.config.ts b/public/app/plugins/datasource/tempo/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/tempo/webpack.config.ts +++ b/public/app/plugins/datasource/tempo/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/zipkin/webpack.config.ts b/public/app/plugins/datasource/zipkin/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/zipkin/webpack.config.ts +++ b/public/app/plugins/datasource/zipkin/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx b/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx index 7fe6b348846..8d585476cc9 100644 --- a/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx +++ b/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx @@ -5,7 +5,7 @@ import { useEffectOnce, useToggle } from 'react-use'; import { GrafanaTheme2, PanelProps } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { TimeRangeUpdatedEvent } from '@grafana/runtime'; +import { config, TimeRangeUpdatedEvent } from '@grafana/runtime'; import { Alert, BigValue, @@ -17,7 +17,6 @@ import { ScrollContainer, useStyles2, } from '@grafana/ui'; -import { config } from 'app/core/config'; import alertDef from 'app/features/alerting/state/alertDef'; import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi'; import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails'; diff --git a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx index d8714fd8f4a..2a1ff46fe12 100644 --- a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx +++ b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx @@ -12,10 +12,10 @@ import { PanelProps, VizOrientation, } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { BarGaugeSizing } from '@grafana/schema'; import { BarGauge, DataLinksContextMenu, VizLayout, VizRepeater, VizRepeaterRenderValueProps } from '@grafana/ui'; import { DataLinksContextMenuApi } from '@grafana/ui/internal'; -import { config } from 'app/core/config'; import { BarGaugeLegend } from './BarGaugeLegend'; import { defaultOptions, Options } from './panelcfg.gen'; diff --git a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx index 4f5f7a46b23..1772f8b2d77 100644 --- a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx +++ b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx @@ -5,7 +5,7 @@ import { useMemo, useState } from 'react'; import uPlot from 'uplot'; import { Field, getDisplayProcessor, PanelProps, useDataLinksContext } from '@grafana/data'; -import { PanelDataErrorView } from '@grafana/runtime'; +import { config, PanelDataErrorView } from '@grafana/runtime'; import { DashboardCursorSync, TooltipDisplayMode } from '@grafana/schema'; import { EventBusPlugin, @@ -18,7 +18,6 @@ import { } from '@grafana/ui'; import { AxisProps, ScaleProps, TimeRange2, TooltipHoverMode } from '@grafana/ui/internal'; import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries'; -import { config } from 'app/core/config'; import { TimeSeriesTooltip } from '../timeseries/TimeSeriesTooltip'; import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2'; diff --git a/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx b/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx index be667771d56..8dfd1c48a68 100644 --- a/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx +++ b/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx @@ -5,8 +5,8 @@ import { first } from 'rxjs/operators'; import { SelectableValue } from '@grafana/data'; import { t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; import { ContextMenu, MenuItem, MenuItemProps } from '@grafana/ui'; -import { config } from 'app/core/config'; import { ElementState } from 'app/features/canvas/runtime/element'; import { FrameState } from 'app/features/canvas/runtime/frame'; import { Scene } from 'app/features/canvas/runtime/scene'; diff --git a/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx index 3dfcad29220..93713b261a2 100644 --- a/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx +++ b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx @@ -2,9 +2,9 @@ import { css } from '@emotion/css'; import { useEffect, useMemo, useRef, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { DirectionDimensionConfig, DirectionDimensionMode, ConnectionDirection } from '@grafana/schema'; import { useStyles2 } from '@grafana/ui'; -import { config } from 'app/core/config'; import { Scene } from 'app/features/canvas/runtime/scene'; import { ConnectionCoordinates } from '../../panelcfg.gen'; diff --git a/public/app/plugins/panel/canvas/components/connections/ConnectionSVG2.tsx b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG2.tsx index 6e6093250df..8d0219f09b1 100644 --- a/public/app/plugins/panel/canvas/components/connections/ConnectionSVG2.tsx +++ b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG2.tsx @@ -2,9 +2,9 @@ import { css } from '@emotion/css'; import { useEffect, useMemo, useRef, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { DirectionDimensionConfig, DirectionDimensionMode, ConnectionDirection } from '@grafana/schema'; import { useStyles2 } from '@grafana/ui'; -import { config } from 'app/core/config'; import { Scene } from 'app/features/canvas/runtime/scene'; import { ConnectionCoordinates } from '../../panelcfg.gen'; diff --git a/public/app/plugins/panel/canvas/utils.ts b/public/app/plugins/panel/canvas/utils.ts index 59953e9085a..14f42ad9b7e 100644 --- a/public/app/plugins/panel/canvas/utils.ts +++ b/public/app/plugins/panel/canvas/utils.ts @@ -1,9 +1,10 @@ import { isNumber, isString } from 'lodash'; import { DataFrame, Field, AppEvents, getFieldDisplayName, PluginState, SelectableValue } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { ConnectionDirection } from '@grafana/schema'; import { appEvents } from 'app/core/app_events'; -import { hasAlphaPanels, config } from 'app/core/config'; +import { hasAlphaPanels } from 'app/core/config'; import { CanvasConnection, CanvasElementItem, CanvasElementOptions } from 'app/features/canvas/element'; import { notFoundItem } from 'app/features/canvas/elements/notFound'; import { advancedElementItems, canvasElementRegistry, defaultElementItems } from 'app/features/canvas/registry'; diff --git a/public/app/plugins/panel/dashlist/DashListItem.tsx b/public/app/plugins/panel/dashlist/DashListItem.tsx index 9ef0ca64c3d..cbb093c51f4 100644 --- a/public/app/plugins/panel/dashlist/DashListItem.tsx +++ b/public/app/plugins/panel/dashlist/DashListItem.tsx @@ -1,3 +1,6 @@ +import { truncate } from 'lodash'; + +import { reportInteraction } from '@grafana/runtime'; import { Box, Card, Icon, Link, Stack, Text, useStyles2 } from '@grafana/ui'; import { LocationInfo } from 'app/features/search/service/types'; import { StarToolbarButton } from 'app/features/stars/StarToolbarButton'; @@ -11,10 +14,26 @@ interface Props { showFolderNames: boolean; locationInfo?: LocationInfo; layoutMode: 'list' | 'card'; + order?: number; // for rudderstack analytics to track position in card list onStarChange?: (id: string, isStarred: boolean) => void; } -export function DashListItem({ dashboard, url, showFolderNames, locationInfo, layoutMode, onStarChange }: Props) { +export function DashListItem({ + dashboard, + url, + showFolderNames, + locationInfo, + layoutMode, + order, + onStarChange, +}: Props) { const css = useStyles2(getStyles); + const shortTitle = truncate(dashboard.name, { length: 40, omission: '…' }); + + const onCardLinkClick = () => { + reportInteraction('grafana_recently_viewed_dashboards_click_card', { + cardOrder: order, + }); + }; return ( <> @@ -38,25 +57,35 @@ export function DashListItem({ dashboard, url, showFolderNames, locationInfo, la
        ) : ( - - {dashboard.name} - - - - {showFolderNames && locationInfo && ( - - )} diff --git a/public/app/plugins/panel/dashlist/styles.ts b/public/app/plugins/panel/dashlist/styles.ts index c6346480c22..e4197ef03a1 100644 --- a/public/app/plugins/panel/dashlist/styles.ts +++ b/public/app/plugins/panel/dashlist/styles.ts @@ -32,6 +32,7 @@ export const getStyles = (theme: GrafanaTheme2) => { textDecoration: 'underline', }, height: '100%', + paddingTop: theme.spacing(1.5), '&:hover': { backgroundImage: gradient, @@ -41,5 +42,8 @@ export const getStyles = (theme: GrafanaTheme2) => { dashlistCardIcon: css({ marginRight: theme.spacing(0.5), }), + dashlistCardLink: css({ + paddingTop: theme.spacing(0.5), + }), }; }; diff --git a/public/app/plugins/panel/datagrid/plugin.json b/public/app/plugins/panel/datagrid/plugin.json index 72d430b0b87..4baeb456ea1 100644 --- a/public/app/plugins/panel/datagrid/plugin.json +++ b/public/app/plugins/panel/datagrid/plugin.json @@ -2,7 +2,7 @@ "type": "panel", "name": "Datagrid", "id": "datagrid", - "state": "beta", + "state": "deprecated", "info": { "author": { diff --git a/public/app/plugins/panel/gauge/GaugePanel.tsx b/public/app/plugins/panel/gauge/GaugePanel.tsx index 3ae1988e30a..0dd53f2505b 100644 --- a/public/app/plugins/panel/gauge/GaugePanel.tsx +++ b/public/app/plugins/panel/gauge/GaugePanel.tsx @@ -1,10 +1,10 @@ import { PureComponent, type JSX } from 'react'; import { FieldDisplay, getDisplayProcessor, getFieldDisplayValues, PanelProps } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { BarGaugeSizing, VizOrientation } from '@grafana/schema'; import { DataLinksContextMenu, Gauge, VizRepeater, VizRepeaterRenderValueProps } from '@grafana/ui'; import { DataLinksContextMenuApi } from '@grafana/ui/internal'; -import { config } from 'app/core/config'; import { clearNameForSingleSeries } from '../bargauge/BarGaugePanel'; diff --git a/public/app/plugins/panel/geomap/components/DebugOverlay.tsx b/public/app/plugins/panel/geomap/components/DebugOverlay.tsx index d08d2042f05..9ce3b8f09db 100644 --- a/public/app/plugins/panel/geomap/components/DebugOverlay.tsx +++ b/public/app/plugins/panel/geomap/components/DebugOverlay.tsx @@ -8,7 +8,7 @@ import tinycolor from 'tinycolor2'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans } from '@grafana/i18n'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; interface Props { map: Map; diff --git a/public/app/plugins/panel/geomap/components/MarkersLegend.tsx b/public/app/plugins/panel/geomap/components/MarkersLegend.tsx index 75d9cfe154b..331ceafe3c3 100644 --- a/public/app/plugins/panel/geomap/components/MarkersLegend.tsx +++ b/public/app/plugins/panel/geomap/components/MarkersLegend.tsx @@ -12,11 +12,11 @@ import { GrafanaTheme2, } from '@grafana/data'; import { t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; import { useStyles2, VizLegendItem } from '@grafana/ui'; import { ColorScale } from 'app/core/components/ColorScale/ColorScale'; import { SanitizedSVG } from 'app/core/components/SVG/SanitizedSVG'; import { getThresholdItems } from 'app/core/components/TimelineChart/utils'; -import { config } from 'app/core/config'; import { DimensionSupplier } from 'app/features/dimensions/types'; import { StyleConfigState } from '../style/types'; diff --git a/public/app/plugins/panel/geomap/components/MeasureOverlay.tsx b/public/app/plugins/panel/geomap/components/MeasureOverlay.tsx index 90b29f160e9..e6c4b2968eb 100644 --- a/public/app/plugins/panel/geomap/components/MeasureOverlay.tsx +++ b/public/app/plugins/panel/geomap/components/MeasureOverlay.tsx @@ -5,8 +5,8 @@ import { useMemo, useRef, useState } from 'react'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; import { Button, IconButton, RadioButtonGroup, Select } from '@grafana/ui'; -import { config } from 'app/core/config'; import { MapMeasure, MapMeasureOptions, measures } from '../utils/measure'; diff --git a/public/app/plugins/panel/geomap/layers/registry.ts b/public/app/plugins/panel/geomap/layers/registry.ts index 15e6fbb7861..960da8d7aac 100644 --- a/public/app/plugins/panel/geomap/layers/registry.ts +++ b/public/app/plugins/panel/geomap/layers/registry.ts @@ -9,7 +9,8 @@ import { SelectableValue, PluginState, } from '@grafana/data'; -import { config, hasAlphaPanels } from 'app/core/config'; +import { config } from '@grafana/runtime'; +import { hasAlphaPanels } from 'app/core/config'; import { basemapLayers } from './basemaps'; import { carto } from './basemaps/carto'; diff --git a/public/app/plugins/panel/live/LiveChannelEditor.tsx b/public/app/plugins/panel/live/LiveChannelEditor.tsx index 06af567317e..82f2444ca56 100644 --- a/public/app/plugins/panel/live/LiveChannelEditor.tsx +++ b/public/app/plugins/panel/live/LiveChannelEditor.tsx @@ -10,8 +10,8 @@ import { parseLiveChannelAddress, } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; import { Select, Alert, Label, stylesFactory, Combobox } from '@grafana/ui'; -import { config } from 'app/core/config'; import { discoveryResources, getAPIGroupDiscoveryList, GroupDiscoveryResource } from 'app/features/apiserver/discovery'; import { getManagedChannelInfo } from 'app/features/live/info'; diff --git a/public/app/plugins/panel/piechart/suggestions.ts b/public/app/plugins/panel/piechart/suggestions.ts index c991c628334..559992a25da 100644 --- a/public/app/plugins/panel/piechart/suggestions.ts +++ b/public/app/plugins/panel/piechart/suggestions.ts @@ -7,8 +7,7 @@ import { VisualizationSuggestionsSupplier, } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { LegendDisplayMode } from '@grafana/schema'; -import { defaultNumericVizOptions } from 'app/features/panel/suggestions/utils'; +import { defaultNumericVizOptions, SUGGESTIONS_LEGEND_OPTIONS } from 'app/features/panel/suggestions/utils'; import { PieChartLabels, Options, PieChartType } from './panelcfg.gen'; @@ -16,12 +15,13 @@ const withDefaults = (suggestion: VisualizationSuggestion): Visualizati defaultsDeep(suggestion, { options: { displayLabels: [PieChartLabels.Percent], - legend: { - calcs: [], - displayMode: LegendDisplayMode.Hidden, - placement: 'right', - values: [], - showLegend: false, + }, + cardOptions: { + previewModifier: (s) => { + s.options!.legend = { + ...SUGGESTIONS_LEGEND_OPTIONS, + values: [], + }; }, }, } satisfies VisualizationSuggestion); diff --git a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx index 42c70511977..3b952381554 100644 --- a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx +++ b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx @@ -7,10 +7,9 @@ import { getFieldDisplayValues, PanelProps, } from '@grafana/data'; -import { PanelDataErrorView } from '@grafana/runtime'; +import { config, PanelDataErrorView } from '@grafana/runtime'; import { DataLinksContextMenu, Stack, VizRepeater, VizRepeaterRenderValueProps } from '@grafana/ui'; import { DataLinksContextMenuApi, RadialGauge } from '@grafana/ui/internal'; -import { config } from 'app/core/config'; import { Options } from './panelcfg.gen'; @@ -86,9 +85,6 @@ export function RadialBarPanel({ }); } - const minVizHeight = 60; - const minVizWidth = 60; - if (getValues()[0]?.display?.text === 'No data') { return ; } @@ -105,8 +101,8 @@ export function RadialBarPanel({ itemSpacing={16} renderCounter={renderCounter} orientation={options.orientation} - minVizHeight={minVizHeight} - minVizWidth={minVizWidth} + minVizHeight={options.sizing === 'auto' ? 0 : options.minVizHeight} + minVizWidth={options.sizing === 'auto' ? 0 : options.minVizWidth} getAlignmentFactors={getDisplayValueAlignmentFactors} /> diff --git a/public/app/plugins/panel/radialbar/module.tsx b/public/app/plugins/panel/radialbar/module.tsx index f7afa0ef2c9..b40eb2c594b 100644 --- a/public/app/plugins/panel/radialbar/module.tsx +++ b/public/app/plugins/panel/radialbar/module.tsx @@ -1,5 +1,6 @@ import { PanelPlugin } from '@grafana/data'; import { t } from '@grafana/i18n'; +import { BarGaugeSizing, VizOrientation } from '@grafana/schema'; import { commonOptionsBuilder } from '@grafana/ui'; import { addOrientationOption, addStandardDataReduceOptions } from '../stat/common'; @@ -16,7 +17,7 @@ export const plugin = new PanelPlugin(RadialBarPanel) const category = [t('gauge.category-radial-bar', 'Gauge')]; addStandardDataReduceOptions(builder); - addOrientationOption(builder, category); + commonOptionsBuilder.addTextSizeOptions(builder, { withTitle: true, withValue: true }); builder.addRadio({ @@ -32,6 +33,51 @@ export const plugin = new PanelPlugin(RadialBarPanel) }, }); + addOrientationOption(builder, category); + + builder + .addRadio({ + path: 'sizing', + name: t('gauge.name-gauge-size', 'Gauge size'), + settings: { + options: [ + { value: BarGaugeSizing.Auto, label: t('gauge.gauge-size-options.label-auto', 'Auto') }, + { value: BarGaugeSizing.Manual, label: t('gauge.gauge-size-options.label-manual', 'Manual') }, + ], + }, + category, + defaultValue: defaultOptions.sizing, + showIf: (options: Options) => options.orientation !== VizOrientation.Auto, + }) + .addSliderInput({ + path: 'minVizWidth', + name: t('gauge.name-min-width', 'Min width'), + description: t('gauge.description-min-width', 'Minimum column width (vertical orientation)'), + defaultValue: defaultOptions.minVizWidth, + settings: { + min: 0, + max: 600, + step: 1, + }, + category, + showIf: (options: Options) => + options.sizing === BarGaugeSizing.Manual && options.orientation === VizOrientation.Vertical, + }) + .addSliderInput({ + path: 'minVizHeight', + name: t('gauge.name-min-height', 'Min height'), + description: t('gauge.description-min-height', 'Minimum row height (horizontal orientation)'), + defaultValue: defaultOptions.minVizHeight, + category, + settings: { + min: 0, + max: 600, + step: 1, + }, + showIf: (options: Options) => + options.sizing === BarGaugeSizing.Manual && options.orientation === VizOrientation.Horizontal, + }); + builder.addSliderInput({ path: 'barWidthFactor', name: t('radialbar.config.bar-width', 'Bar width'), diff --git a/public/app/plugins/panel/radialbar/panelcfg.cue b/public/app/plugins/panel/radialbar/panelcfg.cue index 6e5fd7eec21..01e2b5e3b98 100644 --- a/public/app/plugins/panel/radialbar/panelcfg.cue +++ b/public/app/plugins/panel/radialbar/panelcfg.cue @@ -44,6 +44,9 @@ composableKinds: PanelCfg: { endpointMarker?: "point" | "glow" | "none" | *"point" textMode?: "auto" | "value_and_name" | "value" | "name" | "none" | *"auto" effects: GaugePanelEffects | *{} + sizing: common.BarGaugeSizing & (*"auto" | _) + minVizWidth: uint32 | *75 + minVizHeight: uint32 | *75 } @cuetsy(kind="interface") } }] diff --git a/public/app/plugins/panel/radialbar/panelcfg.gen.ts b/public/app/plugins/panel/radialbar/panelcfg.gen.ts index 594747136ef..58b17cbd293 100644 --- a/public/app/plugins/panel/radialbar/panelcfg.gen.ts +++ b/public/app/plugins/panel/radialbar/panelcfg.gen.ts @@ -27,11 +27,14 @@ export interface Options extends common.SingleStatBaseOptions { barWidthFactor: number; effects: GaugePanelEffects; endpointMarker?: ('point' | 'glow' | 'none'); + minVizHeight: number; + minVizWidth: number; segmentCount: number; segmentSpacing: number; shape: ('circle' | 'gauge'); showThresholdLabels: boolean; showThresholdMarkers: boolean; + sizing: common.BarGaugeSizing; sparkline?: boolean; textMode?: ('auto' | 'value_and_name' | 'value' | 'name' | 'none'); } @@ -41,11 +44,14 @@ export const defaultOptions: Partial = { barWidthFactor: 0.5, effects: {}, endpointMarker: 'point', + minVizHeight: 75, + minVizWidth: 75, segmentCount: 1, segmentSpacing: 0.3, shape: 'gauge', showThresholdLabels: false, showThresholdMarkers: true, + sizing: common.BarGaugeSizing.Auto, sparkline: true, textMode: 'auto', }; diff --git a/public/app/plugins/panel/table/suggestions.ts b/public/app/plugins/panel/table/suggestions.ts index 260e73b43eb..bd172f26ad8 100644 --- a/public/app/plugins/panel/table/suggestions.ts +++ b/public/app/plugins/panel/table/suggestions.ts @@ -1,5 +1,5 @@ import { PanelDataSummary, VisualizationSuggestionScore, VisualizationSuggestionsSupplier } from '@grafana/data'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import icnTablePanelSvg from 'app/plugins/panel/table/img/icn-table-panel.svg'; import { Options, FieldConfig } from './panelcfg.gen'; diff --git a/public/app/plugins/panel/text/module.tsx b/public/app/plugins/panel/text/module.tsx index dff26482092..ded19ac3185 100644 --- a/public/app/plugins/panel/text/module.tsx +++ b/public/app/plugins/panel/text/module.tsx @@ -1,6 +1,6 @@ import { PanelPlugin } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import { TextPanel } from './TextPanel'; import { TextPanelEditor } from './TextPanelEditor'; diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index eb3b226a248..a85ef58cdc7 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -10,7 +10,7 @@ import { useDataLinksContext, FieldType, } from '@grafana/data'; -import { PanelDataErrorView } from '@grafana/runtime'; +import { config, PanelDataErrorView } from '@grafana/runtime'; import { TooltipDisplayMode, VizOrientation } from '@grafana/schema'; import { EventBusPlugin, @@ -21,7 +21,6 @@ import { } from '@grafana/ui'; import { FILTER_OUT_OPERATOR, TimeRange2, TooltipHoverMode } from '@grafana/ui/internal'; import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries'; -import { config } from 'app/core/config'; import { TimeSeriesTooltip } from './TimeSeriesTooltip'; import { Options } from './panelcfg.gen'; diff --git a/public/app/plugins/panel/timeseries/suggestions.ts b/public/app/plugins/panel/timeseries/suggestions.ts index b3f6abd74ba..265ed619714 100644 --- a/public/app/plugins/panel/timeseries/suggestions.ts +++ b/public/app/plugins/panel/timeseries/suggestions.ts @@ -10,15 +10,9 @@ import { VisualizationSuggestionsSupplier, } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { - GraphDrawStyle, - GraphFieldConfig, - GraphGradientMode, - LegendDisplayMode, - LineInterpolation, - StackingMode, -} from '@grafana/schema'; +import { GraphDrawStyle, GraphFieldConfig, GraphGradientMode, LineInterpolation, StackingMode } from '@grafana/schema'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { SUGGESTIONS_LEGEND_OPTIONS } from 'app/features/panel/suggestions/utils'; import { Options } from './panelcfg.gen'; @@ -29,14 +23,6 @@ const withDefaults = ( suggestion: VisualizationSuggestion ): VisualizationSuggestion => defaultsDeep(suggestion, { - options: { - legend: { - calcs: [], - displayMode: LegendDisplayMode.Hidden, - placement: 'right', - showLegend: false, - }, - }, fieldConfig: { defaults: { custom: {}, @@ -46,6 +32,7 @@ const withDefaults = ( cardOptions: { previewModifier: (s) => { s.options!.disableKeyboardEvents = true; + s.options!.legend = SUGGESTIONS_LEGEND_OPTIONS; if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) { s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2); } diff --git a/public/app/plugins/panel/trend/module.tsx b/public/app/plugins/panel/trend/module.tsx index 67434a8c542..023c1ea8a0d 100644 --- a/public/app/plugins/panel/trend/module.tsx +++ b/public/app/plugins/panel/trend/module.tsx @@ -1,8 +1,9 @@ import { Field, FieldType, PanelPlugin, VisualizationSuggestionScore } from '@grafana/data'; import { t } from '@grafana/i18n'; import { GraphDrawStyle } from '@grafana/schema'; -import { commonOptionsBuilder, LegendDisplayMode } from '@grafana/ui'; +import { commonOptionsBuilder } from '@grafana/ui'; import { optsWithHideZeros } from '@grafana/ui/internal'; +import { SUGGESTIONS_LEGEND_OPTIONS } from 'app/features/panel/suggestions/utils'; import { defaultGraphConfig, getGraphFieldConfig } from '../timeseries/config'; @@ -49,14 +50,6 @@ export const plugin = new PanelPlugin(TrendPanel) return [ { score: VisualizationSuggestionScore.Good, - options: { - legend: { - calcs: [], - displayMode: LegendDisplayMode.Hidden, - placement: 'right', - showLegend: false, - }, - }, fieldConfig: { defaults: { custom: {}, @@ -65,6 +58,7 @@ export const plugin = new PanelPlugin(TrendPanel) }, cardOptions: { previewModifier: (s) => { + s.options!.legend = SUGGESTIONS_LEGEND_OPTIONS; if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) { s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2); } diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts index 3b7551c2766..afc367a924c 100644 --- a/public/app/store/configureStore.ts +++ b/public/app/store/configureStore.ts @@ -4,6 +4,7 @@ import { Middleware } from 'redux'; import { allMiddleware as allApiClientMiddleware } from '@grafana/api-clients/rtkq'; import { legacyAPI } from 'app/api/clients/legacy'; +import { scopeAPIv0alpha1 } from 'app/api/clients/scope/v0alpha1'; import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi'; import { StoreState } from 'app/types/store'; @@ -40,6 +41,7 @@ export function configureStore(initialState?: Partial) { publicDashboardApi.middleware, browseDashboardsAPI.middleware, legacyAPI.middleware, + scopeAPIv0alpha1.middleware, ...allApiClientMiddleware, ...extraMiddleware ), diff --git a/public/app/types/events.ts b/public/app/types/events.ts index 5c7e4ba151b..95fbecc1e2d 100644 --- a/public/app/types/events.ts +++ b/public/app/types/events.ts @@ -1,6 +1,5 @@ import { AnnotationQuery, BusEventBase, BusEventWithPayload, eventFactory } from '@grafana/data'; import { IconName, ButtonVariant } from '@grafana/ui'; -import { HistoryEntryView } from 'app/core/components/AppChrome/types'; /** * Event Payloads @@ -160,6 +159,10 @@ export class AbsoluteTimeEvent extends BusEventWithPayload { static type = 'remove-panel'; } @@ -217,7 +220,3 @@ export class PanelEditEnteredEvent extends BusEventWithPayload { export class PanelEditExitedEvent extends BusEventWithPayload { static type = 'panel-edit-finished'; } - -export class RecordHistoryEntryEvent extends BusEventWithPayload { - static type = 'record-history-entry'; -} diff --git a/public/app/types/user.ts b/public/app/types/user.ts index e5cd1805e7f..9e575e2ad93 100644 --- a/public/app/types/user.ts +++ b/public/app/types/user.ts @@ -5,6 +5,7 @@ import { Role } from './accessControl'; export interface OrgUser extends WithAccessControlMetadata { avatarUrl: string; email: string; + created?: string; lastSeenAt: string; lastSeenAtAge: string; login: string; @@ -49,6 +50,7 @@ export interface UserDTO extends WithAccessControlMetadata { theme?: string; avatarUrl?: string; orgId?: number; + created?: string; lastSeenAt?: string; lastSeenAtAge?: string; licensedRole?: string; diff --git a/public/img/plugins/db2.svg b/public/img/plugins/db2.svg new file mode 100644 index 00000000000..950f0a73e32 --- /dev/null +++ b/public/img/plugins/db2.svg @@ -0,0 +1,937 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 91e89a580ff..4e8a700bc26 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -3791,7 +3791,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4454,6 +4453,7 @@ }, "no-properties-changed": "Žádné relevantní vlastnosti se nezměnily", "table": { + "notes": "", "updated": "Datum", "updatedBy": "Aktualizoval uživatel", "version": "Verze" @@ -4652,6 +4652,7 @@ }, "canvas-actions": { "add-panel": "Přidat panel", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Seskupit do řádku", @@ -4912,7 +4913,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Hodnoty oddělené čárkou" }, "datasource-options": { "name-filter": "Filtr názvu", @@ -5734,6 +5736,7 @@ "validation": { "invalid-dashboard-id": "Nelze najít platné ID na Grafana.com", "invalid-json": "Neplatný JSON", + "tag-too-long": "", "tags-expected-array": "očekávané pole tagů", "tags-expected-strings": "očekávané pole řetězců tagů" }, @@ -6010,6 +6013,9 @@ }, "custom-variable-form": { "custom-options": "Vlastní možnosti", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Hodnoty oddělené čárkou", "selection-options": "Možnosti výběru" }, @@ -6601,6 +6607,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Nástěnka byla uložena" }, @@ -6624,6 +6635,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6683,8 +6695,11 @@ "tooltip-show-usages": "Zobrazit použití" }, "variable-values-preview": { - "preview-of-values": "Náhled hodnot", - "show-more": "Zobrazit více" + "show-more": "Zobrazit více", + "preview-of-values_one": "", + "preview-of-values_few": "", + "preview-of-values_many": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -9312,7 +9327,8 @@ "tags-input": { "add": "Přidat", "placeholder-new-tag": "Nový štítek (pro přidání stiskněte klávesu Enter)", - "remove": "Odebrat tag: {{name}}" + "remove": "Odebrat tag: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Synchronizovat časy", @@ -10795,18 +10811,6 @@ "help/documentation": "Dokumentace", "help/keyboard-shortcuts": "Klávesové zkratky", "help/support": "Podpora", - "history-container": { - "drawer-tittle": "Historie" - }, - "history-wrapper": { - "collapse": "Sbalit", - "expand": "Rozbalit", - "icon-selected": "Vybraný záznam", - "icon-unselected": "Normální záznam", - "show-more": "Zobrazit více", - "today": "Dnes", - "yesterday": "Včera" - }, "home": { "title": "Domů" }, @@ -11902,7 +11906,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Odstranit", "confirm-delete-keep-resources": "Opravdu chcete odstranit konfiguraci úložiště, ale ponechat jeho zdroje?", "confirm-delete-with-resources": "Opravdu chcete odstranit konfiguraci úložiště a všechny jeho zdroje?", @@ -12170,6 +12220,7 @@ "jobs": "Práce" }, "repository-actions": { + "connections": "", "settings": "Nastavení", "source-code": "Zdrojový kód" }, diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index e0de5a6bf30..1613801e0af 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -3759,7 +3759,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4416,6 +4415,7 @@ }, "no-properties-changed": "Keine relevanten Eigenschaften geändert", "table": { + "notes": "", "updated": "Datum", "updatedBy": "Aktualisiert von", "version": "Version" @@ -4614,6 +4614,7 @@ }, "canvas-actions": { "add-panel": "Panel hinzufügen", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "In Zeile gruppieren", @@ -4874,7 +4875,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Werte werden durch Komma getrennt" }, "datasource-options": { "name-filter": "Namensfilter", @@ -5694,6 +5696,7 @@ "validation": { "invalid-dashboard-id": "Konnte keine gültige Grafana.com-ID finden", "invalid-json": "JSON ungültig", + "tag-too-long": "", "tags-expected-array": "Array der jeweiligen Tags", "tags-expected-strings": "String-Array der jeweiligen Tags" }, @@ -5968,6 +5971,9 @@ }, "custom-variable-form": { "custom-options": "Benutzerdefinierte Optionen", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Werte werden durch Komma getrennt", "selection-options": "Auswahloptionen" }, @@ -6555,6 +6561,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Dashboard gespeichert" }, @@ -6578,6 +6589,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6637,8 +6649,9 @@ "tooltip-show-usages": "Nutzungen anzeigen" }, "variable-values-preview": { - "preview-of-values": "Vorschau der Werte", - "show-more": "Mehr anzeigen" + "show-more": "Mehr anzeigen", + "preview-of-values_one": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -9242,7 +9255,8 @@ "tags-input": { "add": "Hinzufügen", "placeholder-new-tag": "Neues Tag (Eingabetaste zum Hinzufügen)", - "remove": "Tag entfernen: {{name}}" + "remove": "Tag entfernen: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Zeiten synchronisieren", @@ -10709,18 +10723,6 @@ "help/documentation": "Dokumentation", "help/keyboard-shortcuts": "Tastaturbefehle", "help/support": "Support", - "history-container": { - "drawer-tittle": "Verlauf" - }, - "history-wrapper": { - "collapse": "Einklappen", - "expand": "Ausklappen", - "icon-selected": "Ausgewählter Eintrag", - "icon-unselected": "Normaler Eintrag", - "show-more": "Mehr anzeigen", - "today": "Heute", - "yesterday": "Gestern" - }, "home": { "title": "Home" }, @@ -11804,7 +11806,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Löschen", "confirm-delete-keep-resources": "Sind Sie sicher, dass Sie die Repository-Konfiguration löschen, aber ihre Ressourcen behalten möchten?", "confirm-delete-with-resources": "Sind Sie sicher, dass Sie die Repository-Konfiguration und alle ihre Ressourcen löschen möchten?", @@ -12068,6 +12116,7 @@ "jobs": "Aufträge" }, "repository-actions": { + "connections": "", "settings": "Einstellungen", "source-code": "Quellcode" }, diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 2174ff0adbc..c0f6224e69b 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -807,7 +807,8 @@ "label-integration": "Integration", "label-notification-settings": "Notification settings", "label-section": "Optional {{name}} settings", - "test": "Test" + "test": "Test", + "tooltip-legacy-version": "This is a legacy integration (version: {{version}}). It cannot be modified." }, "classic-condition-viewer": { "of": "OF", @@ -2176,11 +2177,14 @@ "provisioning": { "badge-tooltip-provenance": "This resource has been provisioned via {{provenance}} and cannot be edited through the UI", "badge-tooltip-standard": "This resource has been provisioned and cannot be edited through the UI", + "body-imported": "This contact point contains integrations that were imported from an external Alertmanager and is currently read-only. The integrations will become editable after the migration process is complete.", "body-provisioned": "This {{resource}} has been provisioned, that means it was created by config. Please contact your server admin to update this {{resource}}.", + "title-imported": "This contact point was imported and cannot be edited through the UI", "title-provisioned": "This {{resource}} cannot be edited through the UI" }, "provisioning-badge": { "badge": { + "text-converted-prometheus": "Imported", "text-provisioned": "Provisioned" } }, @@ -2677,8 +2681,7 @@ }, "saved-searches": { "actions-aria-label": "Actions", - "apply-aria-label": "Apply search \"{{name}}\"", - "apply-tooltip": "Apply this search", + "apply-tooltip": "Apply search \"{{name}}\"", "button-label": "Saved searches", "cancel": "Cancel", "default-indicator": "Default search", @@ -4614,6 +4617,7 @@ }, "canvas-actions": { "add-panel": "Add panel", + "disabled-child-contains-tabs": "Cannot change to tabs because a row already contains tabs", "disabled-nested-grouping": "Grouping is limited to 2 levels", "disabled-nested-tabs": "Tabs cannot be nested inside other tabs", "group-into-row": "Group into row", @@ -5695,6 +5699,7 @@ "validation": { "invalid-dashboard-id": "Could not find a valid Grafana.com ID", "invalid-json": "Not valid JSON", + "tag-too-long": "Dashboard tag too long, max 50 characters", "tags-expected-array": "tags expected array", "tags-expected-strings": "tags expected array of strings" }, @@ -6378,12 +6383,15 @@ }, "resource-export": { "label": { + "advanced-options": "Advanced options", "classic": "Classic", "json": "JSON", "v1-resource": "V1 Resource", "v2-resource": "V2 Resource", "yaml": "YAML" - } + }, + "share-externally": "Share dashboard with another instance", + "share-externally-tooltip": "Removes all instance-specific metadata and data source references from the resource before export." }, "revert-dashboard-modal": { "body-restore-version": "Are you sure you want to restore the dashboard to version {{version}}? All unsaved changes will be lost.", @@ -6978,6 +6986,7 @@ "drone-datasource": "Drone datasource", "git-lab-integration-and-datasource": "GitLab integration and datasource", "honeycomb-integration-and-datasource": "Honeycomb integration and datasource", + "ibmdb2-datasource": "IBM Db2 data source", "jira-integration-and-datasource": "Jira integration and datasource", "logic-monitor-devices-datasource": "LogicMonitor Devices datasource", "mongo-db-integration-and-data-source": "MongoDB integration and data source", @@ -7836,7 +7845,6 @@ "export-externally-label": "Export the dashboard to use in another instance", "export-format": "Format", "export-mode": "Model", - "export-remove-ds-refs": "Remove deployment details", "info-text": "Copy or download a file containing the definition of your dashboard", "title": "Export dashboard" }, @@ -9253,7 +9261,8 @@ "tags-input": { "add": "Add", "placeholder-new-tag": "New tag (enter key to add)", - "remove": "Remove tag: {{name}}" + "remove": "Remove tag: {{name}}", + "tag-too-long": "Tag too long, max 50 characters" }, "time-sync-button": { "aria-label-sync": "Sync times", @@ -10720,18 +10729,6 @@ "help/documentation": "Documentation", "help/keyboard-shortcuts": "Keyboard shortcuts", "help/support": "Support", - "history-container": { - "drawer-tittle": "History" - }, - "history-wrapper": { - "collapse": "Collapse", - "expand": "Expand", - "icon-selected": "Selected Entry", - "icon-unselected": "Normal Entry", - "show-more": "Show more", - "today": "Today", - "yesterday": "Yesterday" - }, "home": { "title": "Home" }, @@ -11815,7 +11812,53 @@ "free-tier-limit-tooltip": "Free-tier accounts are restricted to one connection", "instance-fully-managed-tooltip": "Configuration is disabled because this instance is fully managed" }, + "connection-form": { + "alert-connection-deleted": "Connection deleted", + "alert-connection-saved": "Connection saved", + "alert-connection-updated": "Connection updated", + "back-to-connections": "Back to connections", + "button-save": "Save", + "button-saving": "Saving...", + "description-app-id": "The ID of your GitHub App", + "description-installation-id": "The installation ID of your GitHub App", + "description-private-key": "The private key for your GitHub App in PEM format", + "description-provider": "Select the provider type", + "error-delete-connection": "Failed to delete connection", + "error-required": "This field is required", + "error-save-connection": "Failed to save connection", + "label-app-id": "GitHub App ID", + "label-installation-id": "GitHub Installation ID", + "label-private-key": "Private Key (PEM)", + "label-provider": "Provider", + "not-found": "Connection not found", + "not-found-description": "The connection you are looking for does not exist.", + "page-subtitle": "Configure a connection to authenticate with external providers", + "page-title-create": "Create connection", + "page-title-edit": "Edit connection", + "placeholder-app-id": "123456", + "placeholder-installation-id": "12345678", + "placeholder-private-key": "-----BEGIN RSA PRIVATE KEY-----..." + }, + "connections": { + "add-connection": "Add connection", + "cancel": "Cancel", + "delete": "Delete", + "delete-confirm": "Are you sure you want to delete this connection? This action cannot be undone.", + "delete-title": "Delete connection", + "error-loading": "Failed to load connections", + "no-connections": "No connections configured", + "no-connections-message": "Add a connection to authenticate with external providers", + "no-results": "No results matching your query", + "page-subtitle": "View and manage your app connections", + "page-title": "Connections", + "search-placeholder": "Search connections", + "status-connected": "Connected", + "status-disconnected": "Disconnected", + "status-unknown": "Unknown", + "view": "View" + }, "delete-repository-button": { + "button-cancel": "Cancel", "button-delete": "Delete", "confirm-delete-keep-resources": "Are you sure you want to delete the repository configuration but keep its resources?", "confirm-delete-with-resources": "Are you sure you want to delete the repository configuration and all its resources?", @@ -12079,6 +12122,7 @@ "jobs": "Jobs" }, "repository-actions": { + "connections": "Connections", "settings": "Settings", "source-code": "Source code" }, diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 5530857665c..e94cd155216 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -3759,7 +3759,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4416,6 +4415,7 @@ }, "no-properties-changed": "No se ha cambiado ninguna propiedad relevante", "table": { + "notes": "", "updated": "Fecha", "updatedBy": "Actualizada por", "version": "Versión" @@ -4614,6 +4614,7 @@ }, "canvas-actions": { "add-panel": "Añadir panel", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Agrupar en fila", @@ -4874,7 +4875,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Valores separados por coma" }, "datasource-options": { "name-filter": "Nombrar filtro", @@ -5694,6 +5696,7 @@ "validation": { "invalid-dashboard-id": "No se ha podido encontrar un ID de Grafana.com válido", "invalid-json": "JSON no válido", + "tag-too-long": "", "tags-expected-array": "etiquetas: matriz prevista", "tags-expected-strings": "etiquetas: matriz de cadenas prevista" }, @@ -5968,6 +5971,9 @@ }, "custom-variable-form": { "custom-options": "Opciones personalizadas", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Valores separados por comas", "selection-options": "Opciones de selección" }, @@ -6555,6 +6561,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Dashboard guardado" }, @@ -6578,6 +6589,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6637,8 +6649,9 @@ "tooltip-show-usages": "Mostrar usos" }, "variable-values-preview": { - "preview-of-values": "Vista previa de los valores", - "show-more": "Mostrar más" + "show-more": "Mostrar más", + "preview-of-values_one": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -9242,7 +9255,8 @@ "tags-input": { "add": "Añadir", "placeholder-new-tag": "Nueva etiqueta (pulsa la tecla Intro para añadirla)", - "remove": "Eliminar etiqueta: {{name}}" + "remove": "Eliminar etiqueta: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Sincronizar tiempos", @@ -10709,18 +10723,6 @@ "help/documentation": "Documentación", "help/keyboard-shortcuts": "Atajos de teclado", "help/support": "Asistencia", - "history-container": { - "drawer-tittle": "Historial" - }, - "history-wrapper": { - "collapse": "Contraer", - "expand": "Expandir", - "icon-selected": "Entrada seleccionada", - "icon-unselected": "Entrada normal", - "show-more": "Mostrar más", - "today": "Hoy", - "yesterday": "Ayer" - }, "home": { "title": "Inicio" }, @@ -11804,7 +11806,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Eliminar", "confirm-delete-keep-resources": "¿Seguro que quieres eliminar la configuración del repositorio pero conservar sus recursos?", "confirm-delete-with-resources": "¿Seguro que quieres eliminar la configuración del repositorio y todos sus recursos?", @@ -12068,6 +12116,7 @@ "jobs": "Trabajos" }, "repository-actions": { + "connections": "", "settings": "Configuración", "source-code": "Código fuente" }, diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 73d52b983df..0954b9d56b2 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -3759,7 +3759,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4416,6 +4415,7 @@ }, "no-properties-changed": "Aucune propriété pertinente n’a été modifiée", "table": { + "notes": "", "updated": "Date", "updatedBy": "Mis à jour par", "version": "Version" @@ -4614,6 +4614,7 @@ }, "canvas-actions": { "add-panel": "Ajouter un panneau", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Grouper en ligne", @@ -4874,7 +4875,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Valeurs séparées par une virgule" }, "datasource-options": { "name-filter": "Nom du filtre", @@ -5694,6 +5696,7 @@ "validation": { "invalid-dashboard-id": "Impossible de trouver un ID Grafana.com valide", "invalid-json": "JSON non valide", + "tag-too-long": "", "tags-expected-array": "balises attendues tableau", "tags-expected-strings": "balises attendues tableau de chaînes" }, @@ -5968,6 +5971,9 @@ }, "custom-variable-form": { "custom-options": "Personnaliser les options", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Valeurs séparées par des virgules", "selection-options": "Options de sélection" }, @@ -6555,6 +6561,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Tableau de bord enregistré" }, @@ -6578,6 +6589,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6637,8 +6649,9 @@ "tooltip-show-usages": "Afficher les usages" }, "variable-values-preview": { - "preview-of-values": "Aperçu des valeurs", - "show-more": "Afficher plus" + "show-more": "Afficher plus", + "preview-of-values_one": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -9242,7 +9255,8 @@ "tags-input": { "add": "Ajouter", "placeholder-new-tag": "Nouvelle étiquette (appuyez sur Entrée pour ajouter)", - "remove": "Supprimer la balise : {{name}}" + "remove": "Supprimer la balise : {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Synchroniser les horaires", @@ -10709,18 +10723,6 @@ "help/documentation": "Documentation", "help/keyboard-shortcuts": "Raccourcis clavier", "help/support": "Assistance", - "history-container": { - "drawer-tittle": "Historique" - }, - "history-wrapper": { - "collapse": "Réduire", - "expand": "Développer", - "icon-selected": "Entrée sélectionnée", - "icon-unselected": "Entrée normale", - "show-more": "Afficher plus", - "today": "Aujourd'hui", - "yesterday": "Hier" - }, "home": { "title": "Accueil" }, @@ -11804,7 +11806,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Supprimer", "confirm-delete-keep-resources": "Voulez-vous vraiment supprimer la configuration du référentiel tout en conservant ses ressources ?", "confirm-delete-with-resources": "Voulez-vous vraiment supprimer la configuration du référentiel ainsi que toutes ses ressources ?", @@ -12068,6 +12116,7 @@ "jobs": "Missions" }, "repository-actions": { + "connections": "", "settings": "Paramètres", "source-code": "Code source" }, diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index bbf358886d4..deb86e3a541 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -3759,7 +3759,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4416,6 +4415,7 @@ }, "no-properties-changed": "Nem változtak meg a releváns tulajdonságok", "table": { + "notes": "", "updated": "Dátum", "updatedBy": "Frissítette:", "version": "Verzió" @@ -4614,6 +4614,7 @@ }, "canvas-actions": { "add-panel": "Panel hozzáadása", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Csoportosítás sorba", @@ -4874,7 +4875,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Értékek vesszővel elválasztva" }, "datasource-options": { "name-filter": "Névszűrő", @@ -5694,6 +5696,7 @@ "validation": { "invalid-dashboard-id": "Nem található érvényes Grafana.com-azonosító", "invalid-json": "Érvénytelen JSON", + "tag-too-long": "", "tags-expected-array": "címke tömböt várt", "tags-expected-strings": "címke karakterláncok tömbjét várta" }, @@ -5968,6 +5971,9 @@ }, "custom-variable-form": { "custom-options": "Egyéni opciók", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Értékek vesszővel elválasztva", "selection-options": "Kijelölés beállításai" }, @@ -6555,6 +6561,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Irányítópult elmentve" }, @@ -6578,6 +6589,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6637,8 +6649,9 @@ "tooltip-show-usages": "Használatok megjelenítése" }, "variable-values-preview": { - "preview-of-values": "Értékek előnézete", - "show-more": "Több megjelenítése" + "show-more": "Több megjelenítése", + "preview-of-values_one": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -9242,7 +9255,8 @@ "tags-input": { "add": "Hozzáadás", "placeholder-new-tag": "Új címke (nyomja le az Enter billentyűt a hozzáadáshoz)", - "remove": "Címke eltávolítása: {{name}}" + "remove": "Címke eltávolítása: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Időpontok szinkronizálása", @@ -10709,18 +10723,6 @@ "help/documentation": "Dokumentáció", "help/keyboard-shortcuts": "Gyorsbillentyűk", "help/support": "Ügyfélszolgálat", - "history-container": { - "drawer-tittle": "Előzmények" - }, - "history-wrapper": { - "collapse": "Összecsukás", - "expand": "Kibontás", - "icon-selected": "Kijelölt bejegyzés", - "icon-unselected": "Normál bejegyzés", - "show-more": "Több megjelenítése", - "today": "Ma", - "yesterday": "Tegnap" - }, "home": { "title": "Kezdőlap" }, @@ -11804,7 +11806,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Törlés", "confirm-delete-keep-resources": "Biztosan törli az adattár konfigurációját, és megtartja az erőforrásait?", "confirm-delete-with-resources": "Biztosan törli az adattár konfigurációját és az összes erőforrását?", @@ -12068,6 +12116,7 @@ "jobs": "Feladatok" }, "repository-actions": { + "connections": "", "settings": "Beállítások", "source-code": "Forráskód" }, diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index b94e5d8ffec..d0aea63ab48 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -3743,7 +3743,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4397,6 +4396,7 @@ }, "no-properties-changed": "Tidak ada properti yang relevan yang diubah", "table": { + "notes": "", "updated": "Tanggal", "updatedBy": "Diperbarui Oleh", "version": "Versi" @@ -4595,6 +4595,7 @@ }, "canvas-actions": { "add-panel": "Tambah panel", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Kelompokkan ke dalam baris", @@ -4855,7 +4856,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Nilai dipisahkan dengan koma" }, "datasource-options": { "name-filter": "Filter nama", @@ -5674,6 +5676,7 @@ "validation": { "invalid-dashboard-id": "Tidak dapat menemukan ID Grafana.com yang valid", "invalid-json": "JSON Tidak Valid", + "tag-too-long": "", "tags-expected-array": "tag array yang diharapkan", "tags-expected-strings": "tag array yang diharapkan dari string" }, @@ -5947,6 +5950,9 @@ }, "custom-variable-form": { "custom-options": "Opsi kustom", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Nilai dipisahkan dengan koma", "selection-options": "Opsi pemilihan" }, @@ -6532,6 +6538,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Dasbor disimpan" }, @@ -6555,6 +6566,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6614,8 +6626,8 @@ "tooltip-show-usages": "Tampilkan penggunaan" }, "variable-values-preview": { - "preview-of-values": "Pratinjau nilai", - "show-more": "Tampilkan lebih banyak" + "show-more": "Tampilkan lebih banyak", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -9207,7 +9219,8 @@ "tags-input": { "add": "Tambahkan", "placeholder-new-tag": "Tag baru (masukkan kunci untuk menambahkan)", - "remove": "Hapus tag: {{name}}" + "remove": "Hapus tag: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Sinkronkan waktu", @@ -10666,18 +10679,6 @@ "help/documentation": "Dokumentasi", "help/keyboard-shortcuts": "Pintasan keyboard", "help/support": "Dukungan", - "history-container": { - "drawer-tittle": "Sejarah" - }, - "history-wrapper": { - "collapse": "Ciutkan", - "expand": "Perluas", - "icon-selected": "Entri yang dipilih", - "icon-unselected": "Entri Normal", - "show-more": "Tampilkan lebih banyak", - "today": "Hari ini", - "yesterday": "Kemarin" - }, "home": { "title": "Beranda" }, @@ -11755,7 +11756,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Hapus", "confirm-delete-keep-resources": "Anda yakin ingin menghapus konfigurasi repositori, tetapi menyimpan sumber dayanya?", "confirm-delete-with-resources": "Anda yakin ingin menghapus konfigurasi repositori dan semua sumber dayanya?", @@ -12017,6 +12064,7 @@ "jobs": "Pekerjaan" }, "repository-actions": { + "connections": "", "settings": "Pengaturan", "source-code": "Kode sumber" }, diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index e8d62fdaa78..8b92ef5753d 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -3759,7 +3759,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4416,6 +4415,7 @@ }, "no-properties-changed": "Nessuna proprietà rilevante modificata", "table": { + "notes": "", "updated": "Data", "updatedBy": "Aggiornato da", "version": "Versione" @@ -4614,6 +4614,7 @@ }, "canvas-actions": { "add-panel": "Aggiungi pannello", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Raggruppa in riga", @@ -4874,7 +4875,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Valori separati da virgola" }, "datasource-options": { "name-filter": "Filtro nome", @@ -5694,6 +5696,7 @@ "validation": { "invalid-dashboard-id": "Impossibile trovare un ID Grafana.com valido", "invalid-json": "JSON non valido", + "tag-too-long": "", "tags-expected-array": "array con tag previsti", "tags-expected-strings": "array di stringhe con tag previsti" }, @@ -5968,6 +5971,9 @@ }, "custom-variable-form": { "custom-options": "Opzioni personalizzate", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Valori separati da virgola", "selection-options": "Seleziona opzioni" }, @@ -6555,6 +6561,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Dashboard salvata" }, @@ -6578,6 +6589,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6637,8 +6649,9 @@ "tooltip-show-usages": "Mostra utilizzi" }, "variable-values-preview": { - "preview-of-values": "Anteprima dei valori", - "show-more": "Mostra di più" + "show-more": "Mostra di più", + "preview-of-values_one": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -9242,7 +9255,8 @@ "tags-input": { "add": "Aggiungi", "placeholder-new-tag": "Nuovo tag (inserisci chiave da aggiungere)", - "remove": "Rimuovi tag: {{name}}" + "remove": "Rimuovi tag: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Sincronizza orari", @@ -10709,18 +10723,6 @@ "help/documentation": "Documentazione", "help/keyboard-shortcuts": "Scelte rapide da tastiera", "help/support": "Servizio Clienti", - "history-container": { - "drawer-tittle": "Cronologia" - }, - "history-wrapper": { - "collapse": "Riduci", - "expand": "Espandi", - "icon-selected": "Voce selezionata", - "icon-unselected": "Ingresso normale", - "show-more": "Mostra di più", - "today": "Oggi", - "yesterday": "Ieri" - }, "home": { "title": "Home" }, @@ -11804,7 +11806,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Elimina", "confirm-delete-keep-resources": "Vuoi davvero eliminare la configurazione del repository ma conservarne le risorse?", "confirm-delete-with-resources": "Vuoi davvero eliminare la configurazione del repository e tutte le sue risorse?", @@ -12068,6 +12116,7 @@ "jobs": "Attività" }, "repository-actions": { + "connections": "", "settings": "Impostazioni", "source-code": "Codice sorgente" }, diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index fe83750305e..376bd220001 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -3743,7 +3743,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4397,6 +4396,7 @@ }, "no-properties-changed": "関連するプロパティは変更されていません", "table": { + "notes": "", "updated": "日付", "updatedBy": "更新者", "version": "バージョン" @@ -4595,6 +4595,7 @@ }, "canvas-actions": { "add-panel": "パネルを追加", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "行にグループ化", @@ -4855,7 +4856,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "カンマで区切った値" }, "datasource-options": { "name-filter": "名前フィルター", @@ -5674,6 +5676,7 @@ "validation": { "invalid-dashboard-id": "有効なGrafana.com IDが見つかりませんでした", "invalid-json": "無効なJSON", + "tag-too-long": "", "tags-expected-array": "タグは配列を期待しています", "tags-expected-strings": "タグは文字列の配列を期待しています" }, @@ -5947,6 +5950,9 @@ }, "custom-variable-form": { "custom-options": "カスタムオプション", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "カンマ区切りの値", "selection-options": "選択オプション" }, @@ -6532,6 +6538,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "ダッシュボードが保存されました" }, @@ -6555,6 +6566,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6614,8 +6626,8 @@ "tooltip-show-usages": "使用状況を表示" }, "variable-values-preview": { - "preview-of-values": "値のプレビュー", - "show-more": "さらに表示" + "show-more": "さらに表示", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -9207,7 +9219,8 @@ "tags-input": { "add": "追加", "placeholder-new-tag": "新しいタグ (Enterキーで追加)", - "remove": "タグ、{{name}}を削除" + "remove": "タグ、{{name}}を削除", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "時間を同期", @@ -10666,18 +10679,6 @@ "help/documentation": "ドキュメント", "help/keyboard-shortcuts": "キーボードショートカット", "help/support": "サポート", - "history-container": { - "drawer-tittle": "履歴" - }, - "history-wrapper": { - "collapse": "折りたたみ表示", - "expand": "展開", - "icon-selected": "選択したエントリー", - "icon-unselected": "通常のエントリー", - "show-more": "さらに表示", - "today": "今日", - "yesterday": "昨日" - }, "home": { "title": "ホーム" }, @@ -11755,7 +11756,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "削除", "confirm-delete-keep-resources": "リポジトリ設定を削除するものの、そのリソースを保持してもよろしいですか?", "confirm-delete-with-resources": "リポジトリ設定とそのすべてのリソースを削除してもよろしいですか?", @@ -12017,6 +12064,7 @@ "jobs": "ジョブ" }, "repository-actions": { + "connections": "", "settings": "設定", "source-code": "ソースコード" }, diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index 2638c2e40ce..836406edf6a 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -3743,7 +3743,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4397,6 +4396,7 @@ }, "no-properties-changed": "변경된 관련 속성 없음", "table": { + "notes": "", "updated": "날짜", "updatedBy": "업데이트한 사용자", "version": "버전" @@ -4595,6 +4595,7 @@ }, "canvas-actions": { "add-panel": "패널 추가", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "행으로 그룹화", @@ -4855,7 +4856,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "쉼표로 구분된 값" }, "datasource-options": { "name-filter": "이름 필터", @@ -5674,6 +5676,7 @@ "validation": { "invalid-dashboard-id": "유효한 Grafana.com ID를 찾지 못했습니다.", "invalid-json": "유효하지 않은 JSON", + "tag-too-long": "", "tags-expected-array": "태그로 배열을 입력해야 합니다.", "tags-expected-strings": "태그로 문자열 배열을 입력해야 합니다." }, @@ -5947,6 +5950,9 @@ }, "custom-variable-form": { "custom-options": "사용자 지정 옵션", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "쉼표로 구분된 값", "selection-options": "선택 옵션" }, @@ -6532,6 +6538,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "대시보드가 저장되었습니다" }, @@ -6555,6 +6566,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6614,8 +6626,8 @@ "tooltip-show-usages": "사용처 표시" }, "variable-values-preview": { - "preview-of-values": "값 미리 보기", - "show-more": "더 보기" + "show-more": "더 보기", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -9207,7 +9219,8 @@ "tags-input": { "add": "추가", "placeholder-new-tag": "새 태그(엔터 키를 눌러 추가하세요)", - "remove": "태그 제거: {{name}}" + "remove": "태그 제거: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "동기화 시간", @@ -10666,18 +10679,6 @@ "help/documentation": "문서", "help/keyboard-shortcuts": "키보드 단축키", "help/support": "지원", - "history-container": { - "drawer-tittle": "이력" - }, - "history-wrapper": { - "collapse": "접기", - "expand": "펼치기", - "icon-selected": "선택된 항목", - "icon-unselected": "일반 항목", - "show-more": "더 보기", - "today": "오늘", - "yesterday": "어제" - }, "home": { "title": "홈" }, @@ -11755,7 +11756,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "삭제", "confirm-delete-keep-resources": "정말 리포지토리 구성만 삭제하고 해당 리소스는 그대로 유지하시겠어요?", "confirm-delete-with-resources": "정말 리포지토리 구성과 해당하는 모든 리소스를 삭제하시겠어요?", @@ -12017,6 +12064,7 @@ "jobs": "작업" }, "repository-actions": { + "connections": "", "settings": "설정", "source-code": "소스 코드" }, diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index e43d2ab91ec..aa02d1f7445 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -3759,7 +3759,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4416,6 +4415,7 @@ }, "no-properties-changed": "Geen relevante eigenschappen gewijzigd", "table": { + "notes": "", "updated": "Datum", "updatedBy": "Bijgewerkt door", "version": "Versie" @@ -4614,6 +4614,7 @@ }, "canvas-actions": { "add-panel": "Paneel toevoegen", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Groeperen in rij", @@ -4874,7 +4875,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Waarden gescheiden door komma" }, "datasource-options": { "name-filter": "Filter een naam geven", @@ -5694,6 +5696,7 @@ "validation": { "invalid-dashboard-id": "Kon geen geldige Grafana.com-ID vinden", "invalid-json": "Ongeldige JSON", + "tag-too-long": "", "tags-expected-array": "tags expected array", "tags-expected-strings": "tags expected array van strings" }, @@ -5968,6 +5971,9 @@ }, "custom-variable-form": { "custom-options": "Aangepaste opties", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Waarden gescheiden door komma", "selection-options": "Selectiemogelijkheden" }, @@ -6555,6 +6561,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Dashboard opgeslagen" }, @@ -6578,6 +6589,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6637,8 +6649,9 @@ "tooltip-show-usages": "Gebruik weergeven" }, "variable-values-preview": { - "preview-of-values": "Voorbeeldweergave van waarden", - "show-more": "Meer weergeven" + "show-more": "Meer weergeven", + "preview-of-values_one": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -9242,7 +9255,8 @@ "tags-input": { "add": "Toevoegen", "placeholder-new-tag": "Nieuw label (enter-toets om toe te voegen)", - "remove": "Label verwijderen: {{name}}" + "remove": "Label verwijderen: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Tijden synchroniseren", @@ -10709,18 +10723,6 @@ "help/documentation": "Documentatie", "help/keyboard-shortcuts": "Sneltoetsen", "help/support": "Ondersteuning", - "history-container": { - "drawer-tittle": "Geschiedenis" - }, - "history-wrapper": { - "collapse": "Samenvouwen", - "expand": "Uitvouwen", - "icon-selected": "Geselecteerde invoer", - "icon-unselected": "Gewone invoer", - "show-more": "Meer weergeven", - "today": "Vandaag", - "yesterday": "Gisteren" - }, "home": { "title": "Startpagina" }, @@ -11804,7 +11806,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Verwijderen", "confirm-delete-keep-resources": "Weet je zeker dat je de repository-configuratie wilt verwijderen, maar de bronnen wilt behouden?", "confirm-delete-with-resources": "Weet je zeker dat je de repository-configuratie en alle bronnen wilt verwijderen?", @@ -12068,6 +12116,7 @@ "jobs": "Taken" }, "repository-actions": { + "connections": "", "settings": "Instellingen", "source-code": "Broncode" }, diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index 598411a8e47..715da5ac969 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -3791,7 +3791,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4454,6 +4453,7 @@ }, "no-properties-changed": "Nie zmieniono istotnych właściwości", "table": { + "notes": "", "updated": "Data", "updatedBy": "Zaktualizowane przez", "version": "Wersja" @@ -4652,6 +4652,7 @@ }, "canvas-actions": { "add-panel": "Dodaj panel", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Grupuj w wierszu", @@ -4912,7 +4913,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Wartości rozdzielone przecinkami" }, "datasource-options": { "name-filter": "Filtr nazwy", @@ -5734,6 +5736,7 @@ "validation": { "invalid-dashboard-id": "Nie można znaleźć prawidłowego identyfikatora Grafana.com", "invalid-json": "Nieprawidłowy plik JSON", + "tag-too-long": "", "tags-expected-array": "znaczniki oczekiwały tablicy", "tags-expected-strings": "znaczniki oczekiwały tablicy łańcuchów" }, @@ -6010,6 +6013,9 @@ }, "custom-variable-form": { "custom-options": "Opcje niestandardowe", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Wartości rozdzielone przecinkami", "selection-options": "Opcje wyboru" }, @@ -6601,6 +6607,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Pulpit został zapisany" }, @@ -6624,6 +6635,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6683,8 +6695,11 @@ "tooltip-show-usages": "Wyświetl użycie" }, "variable-values-preview": { - "preview-of-values": "Podgląd wartości", - "show-more": "Pokaż więcej" + "show-more": "Pokaż więcej", + "preview-of-values_one": "", + "preview-of-values_few": "", + "preview-of-values_many": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -9312,7 +9327,8 @@ "tags-input": { "add": "Dodaj", "placeholder-new-tag": "Nowy tag (wprowadź klucz, aby dodać)", - "remove": "Usuń znacznik: {{name}}" + "remove": "Usuń znacznik: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Synchronizuj czas", @@ -10795,18 +10811,6 @@ "help/documentation": "Dokumentacja", "help/keyboard-shortcuts": "Skróty klawiaturowe", "help/support": "Wsparcie", - "history-container": { - "drawer-tittle": "Historia" - }, - "history-wrapper": { - "collapse": "Zwiń", - "expand": "Rozwiń", - "icon-selected": "Zaznaczony wpis", - "icon-unselected": "Normalny wpis", - "show-more": "Pokaż więcej", - "today": "Dzisiaj", - "yesterday": "Wczoraj" - }, "home": { "title": "Strona główna" }, @@ -11902,7 +11906,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Usuń", "confirm-delete-keep-resources": "Na pewno chcesz usunąć konfigurację repozytorium, ale zachować jego zasoby?", "confirm-delete-with-resources": "Na pewno chcesz usunąć konfigurację repozytorium i wszystkie jego zasoby?", @@ -12170,6 +12220,7 @@ "jobs": "Zadania" }, "repository-actions": { + "connections": "", "settings": "Ustawienia", "source-code": "Kod źródłowy" }, diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 8542e2c4258..c91b4cc89cc 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -3759,7 +3759,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4416,6 +4415,7 @@ }, "no-properties-changed": "Nenhuma propriedade relevante alterada", "table": { + "notes": "", "updated": "Data", "updatedBy": "Atualizada por", "version": "Versão" @@ -4614,6 +4614,7 @@ }, "canvas-actions": { "add-panel": "Adicionar painel", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Agrupar em linha", @@ -4874,7 +4875,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Valores separados por vírgula" }, "datasource-options": { "name-filter": "Filtro de nome", @@ -5694,6 +5696,7 @@ "validation": { "invalid-dashboard-id": "Não foi possível encontrar um ID do Grafana.com válido", "invalid-json": "JSON inválido", + "tag-too-long": "", "tags-expected-array": "matriz de tags esperada", "tags-expected-strings": "matriz de strings de tags esperada" }, @@ -5968,6 +5971,9 @@ }, "custom-variable-form": { "custom-options": "Opções personalizadas", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Valores separados por vírgula", "selection-options": "Opções de seleção" }, @@ -6555,6 +6561,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Painel de controle salvo" }, @@ -6578,6 +6589,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6637,8 +6649,9 @@ "tooltip-show-usages": "Exibir usos" }, "variable-values-preview": { - "preview-of-values": "Pré-visualização de valores", - "show-more": "Exibir mais" + "show-more": "Exibir mais", + "preview-of-values_one": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -9242,7 +9255,8 @@ "tags-input": { "add": "Adicionar", "placeholder-new-tag": "Nova tag (pressione Enter para adicionar)", - "remove": "Remover etiqueta: {{name}}" + "remove": "Remover etiqueta: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Horários sincronizados", @@ -10709,18 +10723,6 @@ "help/documentation": "Documentação", "help/keyboard-shortcuts": "Atalhos do teclado", "help/support": "Suporte", - "history-container": { - "drawer-tittle": "Histórico" - }, - "history-wrapper": { - "collapse": "Recolher", - "expand": "Expandir", - "icon-selected": "Entrada selecionada", - "icon-unselected": "Entrada normal", - "show-more": "Exibir mais", - "today": "Hoje", - "yesterday": "Ontem" - }, "home": { "title": "Página inicial" }, @@ -11804,7 +11806,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Excluir", "confirm-delete-keep-resources": "Tem certeza de que deseja excluir a configuração do repositório, mas manter seus recursos?", "confirm-delete-with-resources": "Tem certeza de que deseja excluir a configuração do repositório e todos os recursos dele?", @@ -12068,6 +12116,7 @@ "jobs": "Tarefas" }, "repository-actions": { + "connections": "", "settings": "Configurações", "source-code": "Código fonte" }, diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 0374230582e..42eec6cc55f 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -3759,7 +3759,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4416,6 +4415,7 @@ }, "no-properties-changed": "Nenhuma propriedade relevante alterada", "table": { + "notes": "", "updated": "Data", "updatedBy": "Atualizado por", "version": "Versão" @@ -4614,6 +4614,7 @@ }, "canvas-actions": { "add-panel": "Adicionar painel", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Agrupar em linha", @@ -4874,7 +4875,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Valores separados por vírgulas" }, "datasource-options": { "name-filter": "Filtro de nome", @@ -5694,6 +5696,7 @@ "validation": { "invalid-dashboard-id": "Não foi possível encontrar uma ID válida de Grafana.com", "invalid-json": "JSON inválido", + "tag-too-long": "", "tags-expected-array": "matriz de etiquetas esperada", "tags-expected-strings": "matriz de strings de etiquetas esperada" }, @@ -5968,6 +5971,9 @@ }, "custom-variable-form": { "custom-options": "Opções personalizadas", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Valores separados por vírgulas", "selection-options": "Opções de seleção" }, @@ -6555,6 +6561,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Painel de controlo guardado" }, @@ -6578,6 +6589,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6637,8 +6649,9 @@ "tooltip-show-usages": "Mostrar utilizações" }, "variable-values-preview": { - "preview-of-values": "Pré-visualização de valores", - "show-more": "Mostrar mais" + "show-more": "Mostrar mais", + "preview-of-values_one": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -9242,7 +9255,8 @@ "tags-input": { "add": "Adicionar", "placeholder-new-tag": "Nova etiqueta (introduzir chave para adicionar)", - "remove": "Remover etiqueta: {{name}}" + "remove": "Remover etiqueta: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Horas sincronizadas", @@ -10709,18 +10723,6 @@ "help/documentation": "Documentação", "help/keyboard-shortcuts": "Atalhos de teclado", "help/support": "Apoio", - "history-container": { - "drawer-tittle": "Histórico" - }, - "history-wrapper": { - "collapse": "Recolher", - "expand": "Expandir", - "icon-selected": "Entrada selecionada", - "icon-unselected": "Entrada normal", - "show-more": "Mostrar mais", - "today": "Hoje", - "yesterday": "Ontem" - }, "home": { "title": "Início" }, @@ -11804,7 +11806,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Eliminar", "confirm-delete-keep-resources": "Tem a certeza de que pretende eliminar a configuração do repositório, mas manter os seus recursos?", "confirm-delete-with-resources": "Tem a certeza de que pretende eliminar a configuração do repositório e todos os seus recursos?", @@ -12068,6 +12116,7 @@ "jobs": "Trabalhos" }, "repository-actions": { + "connections": "", "settings": "Definições", "source-code": "Código-fonte" }, diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index b5998cd2504..c3f5952cb21 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -3791,7 +3791,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4454,6 +4453,7 @@ }, "no-properties-changed": "Нет изменений соответствующих свойств", "table": { + "notes": "", "updated": "Дата", "updatedBy": "Обновлено", "version": "Версия" @@ -4652,6 +4652,7 @@ }, "canvas-actions": { "add-panel": "Добавить панель", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Группировать в строку", @@ -4912,7 +4913,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Значения, разделенные запятыми" }, "datasource-options": { "name-filter": "Фильтр по названию", @@ -5734,6 +5736,7 @@ "validation": { "invalid-dashboard-id": "Не удалось найти действительный идентификатор Grafana.com", "invalid-json": "Неверный JSON-файл", + "tag-too-long": "", "tags-expected-array": "ожидаемый массив тегов", "tags-expected-strings": "ожидаемый массив строк тегов" }, @@ -6010,6 +6013,9 @@ }, "custom-variable-form": { "custom-options": "Пользовательские параметры", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Значения, разделенные запятыми", "selection-options": "Параметры выбора" }, @@ -6601,6 +6607,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Дашборд сохранен" }, @@ -6624,6 +6635,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6683,8 +6695,11 @@ "tooltip-show-usages": "Показать варианты использования" }, "variable-values-preview": { - "preview-of-values": "Просмотр значений", - "show-more": "Показать еще" + "show-more": "Показать еще", + "preview-of-values_one": "", + "preview-of-values_few": "", + "preview-of-values_many": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -9312,7 +9327,8 @@ "tags-input": { "add": "Добавить", "placeholder-new-tag": "Новый тег (введите ключ для добавления)", - "remove": "Удалить тег: {{name}} " + "remove": "Удалить тег: {{name}} ", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Синхронизировать время", @@ -10795,18 +10811,6 @@ "help/documentation": "Документация", "help/keyboard-shortcuts": "Сочетания клавиш", "help/support": "Поддержка", - "history-container": { - "drawer-tittle": "История" - }, - "history-wrapper": { - "collapse": "Свернуть", - "expand": "Развернуть", - "icon-selected": "Выделенная запись", - "icon-unselected": "Обычная запись", - "show-more": "Показать еще", - "today": "Сегодня", - "yesterday": "Вчера" - }, "home": { "title": "Главная" }, @@ -11902,7 +11906,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Удалить", "confirm-delete-keep-resources": "Вы уверены, что хотите удалить конфигурацию репозитория, но сохранить его ресурсы?", "confirm-delete-with-resources": "Вы уверены, что хотите удалить конфигурацию репозитория и все его ресурсы?", @@ -12170,6 +12220,7 @@ "jobs": "Задания" }, "repository-actions": { + "connections": "", "settings": "Параметры", "source-code": "Исходный код" }, diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index 34ad2defca4..aaa8f3197c1 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -3759,7 +3759,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4416,6 +4415,7 @@ }, "no-properties-changed": "Inga relevanta egenskaper har ändrats", "table": { + "notes": "", "updated": "Datum", "updatedBy": "Uppdaterad per", "version": "Version" @@ -4614,6 +4614,7 @@ }, "canvas-actions": { "add-panel": "Lägg till panel", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Gruppera i rad", @@ -4874,7 +4875,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Värden åtskilda med kommatecken" }, "datasource-options": { "name-filter": "Namnfilter", @@ -5694,6 +5696,7 @@ "validation": { "invalid-dashboard-id": "Kunde inte hitta ett giltigt Grafana.com-ID\n", "invalid-json": "Felaktigt JSON-format", + "tag-too-long": "", "tags-expected-array": "taggar förväntade matris", "tags-expected-strings": "taggar förväntade strängmatris" }, @@ -5968,6 +5971,9 @@ }, "custom-variable-form": { "custom-options": "Anpassade alternativ", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Värden åtskilda med kommatecken", "selection-options": "Urvalsalternativ" }, @@ -6555,6 +6561,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Kontrollpanelen sparades" }, @@ -6578,6 +6589,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6637,8 +6649,9 @@ "tooltip-show-usages": "Visa användningar" }, "variable-values-preview": { - "preview-of-values": "Förhandsgranska värden", - "show-more": "Visa mer" + "show-more": "Visa mer", + "preview-of-values_one": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -9242,7 +9255,8 @@ "tags-input": { "add": "Lägg till", "placeholder-new-tag": "Ny tagg (ange nyckel för att lägga till)", - "remove": "Ta bort tagg: {{name}}" + "remove": "Ta bort tagg: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Synkronisera tider", @@ -10709,18 +10723,6 @@ "help/documentation": "Dokumentation", "help/keyboard-shortcuts": "Tangentbordsgenvägar", "help/support": "Support", - "history-container": { - "drawer-tittle": "Historik" - }, - "history-wrapper": { - "collapse": "Minimera", - "expand": "Expandera", - "icon-selected": "Markerad inmatning", - "icon-unselected": "Normal inmatning", - "show-more": "Visa mer", - "today": "Idag", - "yesterday": "Igår" - }, "home": { "title": "Hem" }, @@ -11804,7 +11806,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Ta bort", "confirm-delete-keep-resources": "Är du säker på att du vill radera lagringsplatskonfigurationen men behålla dess resurser?", "confirm-delete-with-resources": "Är du säker på att du vill radera lagringsplatskonfigurationen och alla dess resurser?", @@ -12068,6 +12116,7 @@ "jobs": "Jobb" }, "repository-actions": { + "connections": "", "settings": "Inställningar", "source-code": "Källkod" }, diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index 7e3b1738568..11425218c88 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -3759,7 +3759,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4416,6 +4415,7 @@ }, "no-properties-changed": "İlgili hiçbir özellik değiştirilmedi", "table": { + "notes": "", "updated": "Tarih", "updatedBy": "Güncelleyen:", "version": "Sürüm" @@ -4614,6 +4614,7 @@ }, "canvas-actions": { "add-panel": "Panel ekle", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Satır olarak grupla", @@ -4874,7 +4875,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Virgülle ayrılmış değerler" }, "datasource-options": { "name-filter": "Ad filtresi", @@ -5694,6 +5696,7 @@ "validation": { "invalid-dashboard-id": "Geçerli bir Grafana.com kimliği bulunamadı", "invalid-json": "Geçerli JSON değil", + "tag-too-long": "", "tags-expected-array": "etiketler dizi olmalı", "tags-expected-strings": "etiklet dizi hâlinde metinlerden oluşmalı" }, @@ -5968,6 +5971,9 @@ }, "custom-variable-form": { "custom-options": "Özel seçenekler", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Virgülle ayrılmış değerler", "selection-options": "Seçim ayarları" }, @@ -6555,6 +6561,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Pano kaydedildi" }, @@ -6578,6 +6589,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6637,8 +6649,9 @@ "tooltip-show-usages": "Kullanımları göster" }, "variable-values-preview": { - "preview-of-values": "Değerlerin ön izlemesi", - "show-more": "Daha fazla göster" + "show-more": "Daha fazla göster", + "preview-of-values_one": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -9242,7 +9255,8 @@ "tags-input": { "add": "Ekle", "placeholder-new-tag": "Yeni etiket (eklemek için anahtarı girin)", - "remove": "Etiketi kaldır: {{name}}" + "remove": "Etiketi kaldır: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Zamanları senkronize et", @@ -10709,18 +10723,6 @@ "help/documentation": "Belgeler", "help/keyboard-shortcuts": "Klavye kısayolları", "help/support": "Destek", - "history-container": { - "drawer-tittle": "Geçmiş" - }, - "history-wrapper": { - "collapse": "Daralt", - "expand": "Genişlet", - "icon-selected": "Seçili giriş", - "icon-unselected": "Normal Giriş", - "show-more": "Daha fazla göster", - "today": "Bugün", - "yesterday": "Dün" - }, "home": { "title": "Ana sayfa" }, @@ -11804,7 +11806,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "Sil", "confirm-delete-keep-resources": "", "confirm-delete-with-resources": "", @@ -12068,6 +12116,7 @@ "jobs": "İşler" }, "repository-actions": { + "connections": "", "settings": "Ayarlar", "source-code": "Kaynak kodu" }, diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 7d53f6e4fd1..4a2a27e1ef4 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -3743,7 +3743,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4397,6 +4396,7 @@ }, "no-properties-changed": "没有相关属性更改", "table": { + "notes": "", "updated": "日期", "updatedBy": "更新者", "version": "版本" @@ -4595,6 +4595,7 @@ }, "canvas-actions": { "add-panel": "添加面板", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "分组成行", @@ -4855,7 +4856,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "以逗号分隔的值" }, "datasource-options": { "name-filter": "名称筛选器", @@ -5674,6 +5676,7 @@ "validation": { "invalid-dashboard-id": "找不到有效的 Grafana.com ID", "invalid-json": "无有效的 JSON", + "tag-too-long": "", "tags-expected-array": "标签预期数组", "tags-expected-strings": "标签预期字符串数组" }, @@ -5947,6 +5950,9 @@ }, "custom-variable-form": { "custom-options": "自定义选项", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "以逗号分隔的值", "selection-options": "选择内容选项" }, @@ -6532,6 +6538,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "数据面板已保存" }, @@ -6555,6 +6566,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6614,8 +6626,8 @@ "tooltip-show-usages": "显示使用情况" }, "variable-values-preview": { - "preview-of-values": "值预览", - "show-more": "显示更多" + "show-more": "显示更多", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -9207,7 +9219,8 @@ "tags-input": { "add": "添加", "placeholder-new-tag": "新标签(按 Enter 键以添加)", - "remove": "移除标记:{{name}}" + "remove": "移除标记:{{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "同步次数", @@ -10666,18 +10679,6 @@ "help/documentation": "文档", "help/keyboard-shortcuts": "快捷键", "help/support": "支持", - "history-container": { - "drawer-tittle": "历史记录" - }, - "history-wrapper": { - "collapse": "收起", - "expand": "展开", - "icon-selected": "所选条目", - "icon-unselected": "正常条目", - "show-more": "显示更多", - "today": "今天", - "yesterday": "昨天" - }, "home": { "title": "首页" }, @@ -11755,7 +11756,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "删除", "confirm-delete-keep-resources": "您确定要删除存储库配置但保留其资源吗?", "confirm-delete-with-resources": "您确定要删除存储库配置及其所有资源吗?", @@ -12017,6 +12064,7 @@ "jobs": "作业" }, "repository-actions": { + "connections": "", "settings": "设置", "source-code": "源代码" }, diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index 2e6243b80c2..331313dccce 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -3743,7 +3743,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4397,6 +4396,7 @@ }, "no-properties-changed": "沒有相關的屬性變更", "table": { + "notes": "", "updated": "日期", "updatedBy": "更新者", "version": "版本" @@ -4595,6 +4595,7 @@ }, "canvas-actions": { "add-panel": "新增面板", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "將群組分成列", @@ -4855,7 +4856,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "以逗號分隔的值" }, "datasource-options": { "name-filter": "名稱篩選", @@ -5674,6 +5676,7 @@ "validation": { "invalid-dashboard-id": "沒有找到有效的 Grafana.com ID", "invalid-json": "無效的 JSON", + "tag-too-long": "", "tags-expected-array": "標籤預期陣列", "tags-expected-strings": "標籤預期字串陣列" }, @@ -5947,6 +5950,9 @@ }, "custom-variable-form": { "custom-options": "自訂選項", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "以逗號分隔的值", "selection-options": "選擇選項" }, @@ -6532,6 +6538,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "儀表板已儲存" }, @@ -6555,6 +6566,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6614,8 +6626,8 @@ "tooltip-show-usages": "顯示使用情況" }, "variable-values-preview": { - "preview-of-values": "數值預覽", - "show-more": "顯示更多" + "show-more": "顯示更多", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -9207,7 +9219,8 @@ "tags-input": { "add": "新增", "placeholder-new-tag": "新標記(輸入金鑰以新增)", - "remove": "移除標籤:{{name}}" + "remove": "移除標籤:{{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "同步次數", @@ -10666,18 +10679,6 @@ "help/documentation": "文件", "help/keyboard-shortcuts": "鍵盤捷徑", "help/support": "支援", - "history-container": { - "drawer-tittle": "歷史紀錄" - }, - "history-wrapper": { - "collapse": "收闔", - "expand": "展開", - "icon-selected": "已選取條目", - "icon-unselected": "一般條目", - "show-more": "顯示更多", - "today": "今天", - "yesterday": "昨天" - }, "home": { "title": "首頁" }, @@ -11755,7 +11756,53 @@ "free-tier-limit-tooltip": "", "instance-fully-managed-tooltip": "" }, + "connection-form": { + "alert-connection-deleted": "", + "alert-connection-saved": "", + "alert-connection-updated": "", + "back-to-connections": "", + "button-save": "", + "button-saving": "", + "description-app-id": "", + "description-installation-id": "", + "description-private-key": "", + "description-provider": "", + "error-delete-connection": "", + "error-required": "", + "error-save-connection": "", + "label-app-id": "", + "label-installation-id": "", + "label-private-key": "", + "label-provider": "", + "not-found": "", + "not-found-description": "", + "page-subtitle": "", + "page-title-create": "", + "page-title-edit": "", + "placeholder-app-id": "", + "placeholder-installation-id": "", + "placeholder-private-key": "" + }, + "connections": { + "add-connection": "", + "cancel": "", + "delete": "", + "delete-confirm": "", + "delete-title": "", + "error-loading": "", + "no-connections": "", + "no-connections-message": "", + "no-results": "", + "page-subtitle": "", + "page-title": "", + "search-placeholder": "", + "status-connected": "", + "status-disconnected": "", + "status-unknown": "", + "view": "" + }, "delete-repository-button": { + "button-cancel": "", "button-delete": "刪除", "confirm-delete-keep-resources": "確定要刪除儲存庫設定,但保留其資源嗎?", "confirm-delete-with-resources": "確定要刪除儲存庫設定及其所有資源嗎?", @@ -12017,6 +12064,7 @@ "jobs": "作業" }, "repository-actions": { + "connections": "", "settings": "設定", "source-code": "原始碼" }, diff --git a/public/openapi3.json b/public/openapi3.json index cda13e8a2dc..8dac2bbc044 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -8018,6 +8018,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "format": "date-time", + "type": "string" + }, "email": { "type": "string" }, @@ -8264,10 +8268,7 @@ "type": "string" }, "timezone": { - "enum": [ - "utc", - "browser" - ], + "description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string", "type": "string" }, "weekStart": { @@ -10039,6 +10040,9 @@ }, "ReportOptions": { "properties": { + "csvEncoding": { + "type": "string" + }, "layout": { "type": "string" }, @@ -12651,10 +12655,7 @@ "type": "string" }, "timezone": { - "enum": [ - "utc", - "browser" - ], + "description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string", "type": "string" }, "weekStart": { @@ -12941,6 +12942,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "format": "date-time", + "type": "string" + }, "email": { "type": "string" }, @@ -18372,6 +18377,8 @@ }, "/dashboards/uid/{uid}/restore": { "post": { + "deprecated": true, + "description": "This API will be removed when /apis/dashboards.grafana.app/v1 is released.\nYou can restore a dashboard by reading it from history, then creating it again.", "operationId": "restoreDashboardVersionByUID", "parameters": [ { @@ -20724,13 +20731,21 @@ } }, { - "description": "A comma separated list of folder ID(s) to filter the elements by.", + "description": "A comma separated list of folder ID(s) to filter the elements by.\nDeprecated: Use FolderFilterUIDs instead.", "in": "query", "name": "folderFilter", "schema": { "type": "string" } }, + { + "description": "A comma separated list of folder UID(s) to filter the elements by.", + "in": "query", + "name": "folderFilterUIDs", + "schema": { + "type": "string" + } + }, { "description": "The number of results per page.", "in": "query", diff --git a/public/sass/_angular.scss b/public/sass/_angular.scss index 271965fff82..7282a7f02e6 100644 --- a/public/sass/_angular.scss +++ b/public/sass/_angular.scss @@ -1161,56 +1161,6 @@ div.editor-option label { content: '\e902'; } -.bootstrap-tagsinput { - display: inline-block; - padding: 0 0 0 6px; - vertical-align: middle; - max-width: 100%; - line-height: 22px; - background-color: $input-bg; - border: 1px solid $input-border-color; - - input { - display: inline-block; - border: none; - margin: 0px; - border-radius: 0; - padding: 8px 6px; - height: 100%; - width: 70px; - box-sizing: border-box; - - &.gf-form-input--has-help-icon { - padding-right: $space-xl; - } - } - - .tag { - margin-right: 2px; - color: $white; - - [data-role='remove'] { - margin-left: 8px; - cursor: pointer; - - &::after { - content: 'x'; - padding: 0px 2px; - } - - &:hover { - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.2), - 0 1px 2px rgba(0, 0, 0, 0.05); - - &:active { - box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); - } - } - } - } -} - .page-header { margin-top: $space-md; diff --git a/public/test/mocks/getGrafanaContextMock.ts b/public/test/mocks/getGrafanaContextMock.ts index 64e704986b7..aa1d061f7fe 100644 --- a/public/test/mocks/getGrafanaContextMock.ts +++ b/public/test/mocks/getGrafanaContextMock.ts @@ -19,6 +19,8 @@ export function getGrafanaContextMock(overrides: Partial = { clearAndInitGlobalBindings: jest.fn(), setupDashboardBindings: jest.fn(), setupTimeRangeBindings: jest.fn(), + bind: jest.fn(), + unbind: jest.fn(), } as unknown as KeybindingSrv, newAssetsChecker: { start: jest.fn(), diff --git a/public/vendor/tagsinput/bootstrap-tagsinput.js b/public/vendor/tagsinput/bootstrap-tagsinput.js deleted file mode 100644 index 66e1aa270b4..00000000000 --- a/public/vendor/tagsinput/bootstrap-tagsinput.js +++ /dev/null @@ -1,512 +0,0 @@ -(function ($) { - "use strict"; - - var defaultOptions = { - tagClass: function(item) { - return 'label label-info'; - }, - itemValue: function(item) { - return item ? item.toString() : item; - }, - itemText: function(item) { - return this.itemValue(item); - }, - freeInput: true, - maxTags: undefined, - confirmKeys: [13], - onTagExists: function(item, $tag) { - $tag.hide().fadeIn(); - } - }; - - /** - * Constructor function - */ - function TagsInput(element, options) { - this.itemsArray = []; - - this.$element = $(element); - this.$element.hide(); - - this.widthClass = options.widthClass || 'width-9'; - this.isSelect = (element.tagName === 'SELECT'); - this.multiple = (this.isSelect && element.hasAttribute('multiple')); - this.objectItems = options && options.itemValue; - this.placeholderText = element.hasAttribute('placeholder') ? this.$element.attr('placeholder') : ''; - - this.$container = $('
        '); - this.$input = $('').appendTo(this.$container); - - this.$element.after(this.$container); - - this.build(options); - } - - TagsInput.prototype = { - constructor: TagsInput, - - /** - * Adds the given item as a new tag. Pass true to dontPushVal to prevent - * updating the elements val() - */ - add: function(item, dontPushVal) { - var self = this; - - if (self.options.maxTags && self.itemsArray.length >= self.options.maxTags) - return; - - // Ignore falsey values, except false - if (item !== false && !item) - return; - - // Throw an error when trying to add an object while the itemValue option was not set - if (typeof item === "object" && !self.objectItems) - throw("Can't add objects when itemValue option is not set"); - - // Ignore strings only containg whitespace - if (item.toString().match(/^\s*$/)) - return; - - // If SELECT but not multiple, remove current tag - if (self.isSelect && !self.multiple && self.itemsArray.length > 0) - self.remove(self.itemsArray[0]); - - if (typeof item === "string" && this.$element[0].tagName === 'INPUT') { - var items = item.split(','); - if (items.length > 1) { - for (var i = 0; i < items.length; i++) { - this.add(items[i], true); - } - - if (!dontPushVal) - self.pushVal(); - return; - } - } - - var itemValue = self.options.itemValue(item), - itemText = self.options.itemText(item), - tagClass = self.options.tagClass(item); - - // Ignore items already added - var existing = $.grep(self.itemsArray, function(item) { return self.options.itemValue(item) === itemValue; } )[0]; - if (existing) { - // Invoke onTagExists - if (self.options.onTagExists) { - var $existingTag = $(".tag", self.$container).filter(function() { return $(this).data("item") === existing; }); - self.options.onTagExists(item, $existingTag); - } - return; - } - - // register item in internal array and map - self.itemsArray.push(item); - - // add a tag element - var $tag = $('' + htmlEncode(itemText) + ''); - $tag.data('item', item); - self.findInputWrapper().before($tag); - $tag.after(' '); - - // add