From 68bf19d840ac8374202404367fa4dd2ca5c2212b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Tue, 13 Jan 2026 09:53:54 +0100 Subject: [PATCH 01/57] Provisioning: handle resource version conflicts in connection CRUDL test (#116184) fix: handle resource version conflicts in connection CRUDL test After updating a connection resource, the controller may update the resource status, changing the resource version. This causes the delete operation to fail with a resource version conflict. Add retry logic to handle conflicts gracefully by retrying the delete operation when encountering resource version conflicts. --- .../apis/provisioning/connection_test.go | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/pkg/tests/apis/provisioning/connection_test.go b/pkg/tests/apis/provisioning/connection_test.go index 98418f7b54e..95d03e0a03e 100644 --- a/pkg/tests/apis/provisioning/connection_test.go +++ b/pkg/tests/apis/provisioning/connection_test.go @@ -166,8 +166,24 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) { githubInfo = spec["github"].(map[string]any) 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") From 5dd9a149035d940323526e0926f2a320668c0258 Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Tue, 13 Jan 2026 09:55:52 +0100 Subject: [PATCH 02/57] Plugins: Fix the flaky configuration tab on the plugin details page for cloud instances (#114922) Fix flaky configuration tab for plugin details page at cloud instances --- public/app/features/plugins/admin/hooks/usePluginConfig.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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); From ce8663ac2425421e04c2abaf1e608b8bba4aeefe Mon Sep 17 00:00:00 2001 From: Ihor Yeromin Date: Tue, 13 Jan 2026 10:26:33 +0100 Subject: [PATCH 03/57] SQL Expressions: Filter Dashboard datasource queries from schema fetching (#116129) * fix(sql expression): sql schema frontend datasources filtering * add one more test --- .../hooks/useSQLSchemas.test.ts | 26 +++++++++++++++++++ .../SqlExpressions/hooks/useSQLSchemas.ts | 12 +++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 public/app/features/expressions/components/SqlExpressions/hooks/useSQLSchemas.test.ts 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(), } From 60c4fab06365f8efb8fe14b26d705e28f9c04463 Mon Sep 17 00:00:00 2001 From: Vardan Torosyan Date: Tue, 13 Jan 2026 11:23:33 +0100 Subject: [PATCH 04/57] [Docs] Add Synthentic Monitoring app to the list of RBAC supported apps (#116167) * [Docs] Add Synthentic Monitoring app to the list of RBAC supported apps * Run prettier --- .../rbac-for-app-plugins/index.md | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) 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 From 98f271f3450ed8e714f8fa843d1ee82ef246996c Mon Sep 17 00:00:00 2001 From: Rafael Bortolon Paulovic Date: Tue, 13 Jan 2026 11:24:13 +0100 Subject: [PATCH 05/57] chore(unified): remove unifiedStorageSearchSprinkles feature toggle (#116139) chore: remove unifiedStorageSearchSprinkles feature flag The feature flag is no longer needed because: - OSS: usageinsights code doesn't exist in OSS builds - Enterprise On-Prem: uses local SQL storage when enable_search=true - Cloud: explicitly configures sprinkles_api_server URL The sprinkles functionality now works automatically based on: - enable_search config (enforced true for unified storage mode 5) - sprinkles_api_server config (empty = local storage, set = remote API) --- e2e/dashboards-search-suite/mode0.ini | 1 - e2e/dashboards-search-suite/mode1.ini | 1 - .../mode2-legacy-search-api.ini | 1 - e2e/dashboards-search-suite/mode2.ini | 1 - e2e/dashboards-search-suite/mode3.ini | 1 - e2e/dashboards-search-suite/mode4.ini | 1 - e2e/dashboards-search-suite/mode5.ini | 1 - .../grafana-data/src/types/featureToggles.gen.ts | 4 ---- pkg/services/featuremgmt/registry.go | 7 ------- pkg/services/featuremgmt/toggles-gitlog.csv | 1 - pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 ---- pkg/services/featuremgmt/toggles_gen.json | 13 ------------- pkg/storage/unified/README.md | 8 -------- 14 files changed, 45 deletions(-) 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/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index f1b1ce4154d..4850555be08 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -649,10 +649,6 @@ export interface FeatureToggles { */ rolePickerDrawer?: boolean; /** - * Enable sprinkles on unified storage search - */ - unifiedStorageSearchSprinkles?: boolean; - /** * Pick the dual write mode from database configs */ managedDualWriter?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index c9f2ca03184..64511b3ccfa 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1073,13 +1073,6 @@ var ( Stage: FeatureStageExperimental, Owner: identityAccessTeam, }, - { - 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", 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 7557323e43b..9f74c053697 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -148,7 +148,6 @@ 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 -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 diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 97ecf868a64..d68fa56ec8c 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -455,10 +455,6 @@ const ( // Enables the new role picker drawer design FlagRolePickerDrawer = "rolePickerDrawer" - // FlagUnifiedStorageSearchSprinkles - // Enable sprinkles on unified storage search - FlagUnifiedStorageSearchSprinkles = "unifiedStorageSearchSprinkles" - // FlagManagedDualWriter // Pick the dual write mode from database configs FlagManagedDualWriter = "managedDualWriter" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 49cdbd86374..d96eb8e8d5a 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3723,19 +3723,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/storage/unified/README.md b/pkg/storage/unified/README.md index 9cd0d1fd01d..5aadb0ad2b0 100644 --- a/pkg/storage/unified/README.md +++ b/pkg/storage/unified/README.md @@ -237,7 +237,6 @@ kubernetesFolders = true unifiedStorage = true unifiedStorageHistoryPruner = true unifiedStorageSearchPermissionFiltering = false -unifiedStorageSearchSprinkles = false [unified_storage] enable_search = true @@ -315,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 @@ -934,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 @@ -955,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 From 7b80c44ac7b1874d83e85751d1f759217c9cebdd Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Tue, 13 Jan 2026 11:43:07 +0100 Subject: [PATCH 06/57] Alerting: Fix label value search not filtering results (#116133) Fixes the issue where typing in the label value dropdown would display all values instead of filtering them based on the search input. The bug was in `createAsyncValuesLoader` which was ignoring the `valueQuery` parameter and returning all combined values instead of the filtered subset. Changes: - Rename `_inputValue` parameter to `valueQuery` to indicate it should be used - Filter combined values based on case-insensitive search query - Return only filtered values instead of all values Tests: - Add test to verify correct values are shown for each label key - Add test to verify search filtering works correctly - Improve test infrastructure with proper portal container and element mocking for virtualized dropdown rendering --- .../rule-editor/labels/LabelsField.tsx | 7 +- .../labels/LabelsFieldOpsLabels.test.tsx | 121 +++++++++++++----- 2 files changed, 95 insertions(+), 33 deletions(-) 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(); }); }); From 43d9fbc0563c829a7fdbd596bdd9730a1c68d580 Mon Sep 17 00:00:00 2001 From: Gareth Date: Tue, 13 Jan 2026 19:47:44 +0900 Subject: [PATCH 07/57] Tempo: Fix search streaming queries (#116136) * Tempo: Fix search queries * apply variables for metrics streaming queries --- public/app/plugins/datasource/tempo/datasource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index b15c8d6146a..d439e861023 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -755,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 From c95e3da2d50624021c78c625c122893c67d01ff5 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Tue, 13 Jan 2026 11:13:11 +0000 Subject: [PATCH 08/57] Theme: Convert themes to json and define schemas using zod (#116006) * convert all theme files to json * automatically discover extra themes in go backend * use zod * error tidy up * error tidy up p2 * generate theme json schema from zod * generate theme list at build time, don't do it at runtime * make name and id required in the theme schema --- Makefile | 7 +- package.json | 6 +- packages/grafana-data/package.json | 10 +- packages/grafana-data/rollup.config.ts | 5 +- packages/grafana-data/src/internal/index.ts | 1 + .../grafana-data/src/themes/createColors.ts | 156 +++-- .../grafana-data/src/themes/createShape.ts | 11 +- .../grafana-data/src/themes/createSpacing.ts | 10 +- .../grafana-data/src/themes/createTheme.ts | 39 +- .../src/themes/createTypography.ts | 25 +- .../src/themes/createVisualizationColors.ts | 51 +- packages/grafana-data/src/themes/registry.ts | 25 +- .../src/themes/schema.generated.json | 608 ++++++++++++++++++ .../src/themes/scripts/generateSchema.ts | 19 + .../themes/themeDefinitions/aubergine.json | 50 ++ .../src/themes/themeDefinitions/aubergine.ts | 53 -- .../src/themes/themeDefinitions/debug.json | 60 ++ .../src/themes/themeDefinitions/debug.ts | 71 -- .../themes/themeDefinitions/desertbloom.json | 71 ++ .../themes/themeDefinitions/desertbloom.ts | 75 --- .../themes/themeDefinitions/gildedgrove.json | 62 ++ .../themes/themeDefinitions/gildedgrove.ts | 65 -- .../src/themes/themeDefinitions/gloom.json | 52 ++ .../src/themes/themeDefinitions/gloom.ts | 80 --- .../src/themes/themeDefinitions/index.ts | 24 +- .../src/themes/themeDefinitions/mars.json | 50 ++ .../src/themes/themeDefinitions/mars.ts | 53 -- .../src/themes/themeDefinitions/matrix.json | 41 ++ .../src/themes/themeDefinitions/matrix.ts | 44 -- .../themes/themeDefinitions/sapphiredusk.json | 76 +++ .../themes/themeDefinitions/sapphiredusk.ts | 79 --- .../themes/themeDefinitions/synthwave.json | 50 ++ .../src/themes/themeDefinitions/synthwave.ts | 53 -- .../src/themes/themeDefinitions/tron.json | 50 ++ .../src/themes/themeDefinitions/tron.ts | 53 -- .../themes/themeDefinitions/victorian.json | 54 ++ .../src/themes/themeDefinitions/victorian.ts | 57 -- .../src/themes/themeDefinitions/zen.json | 50 ++ .../src/themes/themeDefinitions/zen.ts | 53 -- packages/grafana-data/src/themes/types.ts | 33 +- packages/grafana-data/src/unstable.ts | 3 +- pkg/services/preference/generate_themes.go | 90 +++ pkg/services/preference/themes.go | 20 +- pkg/services/preference/themes_generated.go | 21 + .../app/features/theme-playground/README.md | 3 - .../theme-playground/ThemePlayground.tsx | 32 +- .../theme-playground/schema.generated.json | 551 ---------------- yarn.lock | 388 +++++++++-- 48 files changed, 2005 insertions(+), 1535 deletions(-) create mode 100644 packages/grafana-data/src/themes/schema.generated.json create mode 100644 packages/grafana-data/src/themes/scripts/generateSchema.ts create mode 100644 packages/grafana-data/src/themes/themeDefinitions/aubergine.json delete mode 100644 packages/grafana-data/src/themes/themeDefinitions/aubergine.ts create mode 100644 packages/grafana-data/src/themes/themeDefinitions/debug.json delete mode 100644 packages/grafana-data/src/themes/themeDefinitions/debug.ts create mode 100644 packages/grafana-data/src/themes/themeDefinitions/desertbloom.json delete mode 100644 packages/grafana-data/src/themes/themeDefinitions/desertbloom.ts create mode 100644 packages/grafana-data/src/themes/themeDefinitions/gildedgrove.json delete mode 100644 packages/grafana-data/src/themes/themeDefinitions/gildedgrove.ts create mode 100644 packages/grafana-data/src/themes/themeDefinitions/gloom.json delete mode 100644 packages/grafana-data/src/themes/themeDefinitions/gloom.ts create mode 100644 packages/grafana-data/src/themes/themeDefinitions/mars.json delete mode 100644 packages/grafana-data/src/themes/themeDefinitions/mars.ts create mode 100644 packages/grafana-data/src/themes/themeDefinitions/matrix.json delete mode 100644 packages/grafana-data/src/themes/themeDefinitions/matrix.ts create mode 100644 packages/grafana-data/src/themes/themeDefinitions/sapphiredusk.json delete mode 100644 packages/grafana-data/src/themes/themeDefinitions/sapphiredusk.ts create mode 100644 packages/grafana-data/src/themes/themeDefinitions/synthwave.json delete mode 100644 packages/grafana-data/src/themes/themeDefinitions/synthwave.ts create mode 100644 packages/grafana-data/src/themes/themeDefinitions/tron.json delete mode 100644 packages/grafana-data/src/themes/themeDefinitions/tron.ts create mode 100644 packages/grafana-data/src/themes/themeDefinitions/victorian.json delete mode 100644 packages/grafana-data/src/themes/themeDefinitions/victorian.ts create mode 100644 packages/grafana-data/src/themes/themeDefinitions/zen.json delete mode 100644 packages/grafana-data/src/themes/themeDefinitions/zen.ts create mode 100644 pkg/services/preference/generate_themes.go create mode 100644 pkg/services/preference/themes_generated.go delete mode 100644 public/app/features/theme-playground/README.md delete mode 100644 public/app/features/theme-playground/schema.generated.json 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/package.json b/package.json index 72a8f638cb6..a6887b216e4 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", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index df595973cca..384666ea7f8 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -47,11 +47,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 ./src/themes/scripts/generateSchema.ts" }, "dependencies": { "@braintree/sanitize-url": "7.0.1", @@ -81,10 +82,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", @@ -101,6 +104,7 @@ "rollup": "^4.22.4", "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..0c40d731724 100644 --- a/packages/grafana-data/rollup.config.ts +++ b/packages/grafana-data/rollup.config.ts @@ -1,3 +1,4 @@ +import json from '@rollup/plugin-json'; import { createRequire } from 'node:module'; import { entryPoint, plugins, esmOutput, cjsOutput } from '../rollup.config.parts'; @@ -8,13 +9,13 @@ const pkg = rq('./package.json'); export default [ { input: entryPoint, - plugins, + plugins: [...plugins, json()], output: [cjsOutput(pkg, 'grafana-data'), esmOutput(pkg, 'grafana-data')], treeshake: false, }, { input: 'src/unstable.ts', - plugins, + plugins: [...plugins, json()], output: [cjsOutput(pkg, 'grafana-data'), esmOutput(pkg, 'grafana-data')], treeshake: false, }, diff --git a/packages/grafana-data/src/internal/index.ts b/packages/grafana-data/src/internal/index.ts index e2dab753baa..1b1e3c64a7d 100644 --- a/packages/grafana-data/src/internal/index.ts +++ b/packages/grafana-data/src/internal/index.ts @@ -106,3 +106,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..4fca3c5d7be 100644 --- a/packages/grafana-data/src/themes/registry.ts +++ b/packages/grafana-data/src/themes/registry.ts @@ -1,6 +1,6 @@ import { Registry, RegistryItem } from '../utils/Registry'; -import { createTheme } from './createTheme'; +import { createTheme, NewThemeOptionsSchema } from './createTheme'; import * as extraThemes from './themeDefinitions'; import { GrafanaTheme2 } from './types'; @@ -42,9 +42,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 +50,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/scripts/generateSchema.ts b/packages/grafana-data/src/themes/scripts/generateSchema.ts new file mode 100644 index 00000000000..09369f5e67f --- /dev/null +++ b/packages/grafana-data/src/themes/scripts/generateSchema.ts @@ -0,0 +1,19 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +import { NewThemeOptionsSchema } from '../createTheme'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +fs.writeFileSync( + path.join(__dirname, '../schema.generated.json'), + JSON.stringify( + NewThemeOptionsSchema.toJSONSchema({ + target: 'draft-07', + }), + undefined, + 2 + ) +); 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 index 151ae00593e..b4270192032 100644 --- a/packages/grafana-data/src/themes/themeDefinitions/index.ts +++ b/packages/grafana-data/src/themes/themeDefinitions/index.ts @@ -1,12 +1,12 @@ -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'; +export { default as aubergine } from './aubergine.json'; +export { default as debug } from './debug.json'; +export { default as desertbloom } from './desertbloom.json'; +export { default as gildedgrove } from './gildedgrove.json'; +export { default as mars } from './mars.json'; +export { default as matrix } from './matrix.json'; +export { default as sapphiredusk } from './sapphiredusk.json'; +export { default as synthwave } from './synthwave.json'; +export { default as tron } from './tron.json'; +export { default as victorian } from './victorian.json'; +export { default as zen } from './zen.json'; +export { default as gloom } from './gloom.json'; 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/unstable.ts b/packages/grafana-data/src/unstable.ts index 8a42447206f..3200085428a 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 { default as themeJsonSchema } from './themes/schema.generated.json'; 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/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/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 2a8da67b340..85dee240c7f 100644 --- a/public/app/features/theme-playground/ThemePlayground.tsx +++ b/public/app/features/theme-playground/ThemePlayground.tsx @@ -2,7 +2,8 @@ import { css } from '@emotion/css'; import { useId, useState } from 'react'; import { createTheme, GrafanaTheme2, NewThemeOptions } from '@grafana/data'; -import { experimentalThemeDefinitions } from '@grafana/data/internal'; +import { experimentalThemeDefinitions, NewThemeOptionsSchema } from '@grafana/data/internal'; +import { themeJsonSchema } from '@grafana/data/unstable'; import { t } from '@grafana/i18n'; import { useChromeHeaderHeight } from '@grafana/runtime'; import { CodeEditor, Combobox, Field, Stack, useStyles2 } from '@grafana/ui'; @@ -16,24 +17,33 @@ 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, }; +// Add additional themes +for (const [name, json] of Object.entries(experimentalThemeDefinitions)) { + 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 +69,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 +129,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/yarn.lock b/yarn.lock index 1b4710e4062..41cc10d8353 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1964,6 +1964,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/aix-ppc64@npm:0.27.2" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/android-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/android-arm64@npm:0.25.8" @@ -1971,6 +1978,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-arm64@npm:0.27.2" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/android-arm@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/android-arm@npm:0.25.8" @@ -1978,6 +1992,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-arm@npm:0.27.2" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@esbuild/android-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/android-x64@npm:0.25.8" @@ -1985,6 +2006,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-x64@npm:0.27.2" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + "@esbuild/darwin-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/darwin-arm64@npm:0.25.8" @@ -1992,6 +2020,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/darwin-arm64@npm:0.27.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/darwin-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/darwin-x64@npm:0.25.8" @@ -1999,6 +2034,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/darwin-x64@npm:0.27.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@esbuild/freebsd-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/freebsd-arm64@npm:0.25.8" @@ -2006,6 +2048,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/freebsd-arm64@npm:0.27.2" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/freebsd-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/freebsd-x64@npm:0.25.8" @@ -2013,6 +2062,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/freebsd-x64@npm:0.27.2" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/linux-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-arm64@npm:0.25.8" @@ -2020,6 +2076,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-arm64@npm:0.27.2" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/linux-arm@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-arm@npm:0.25.8" @@ -2027,6 +2090,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-arm@npm:0.27.2" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@esbuild/linux-ia32@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-ia32@npm:0.25.8" @@ -2034,6 +2104,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ia32@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-ia32@npm:0.27.2" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/linux-loong64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-loong64@npm:0.25.8" @@ -2041,6 +2118,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-loong64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-loong64@npm:0.27.2" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + "@esbuild/linux-mips64el@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-mips64el@npm:0.25.8" @@ -2048,6 +2132,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-mips64el@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-mips64el@npm:0.27.2" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + "@esbuild/linux-ppc64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-ppc64@npm:0.25.8" @@ -2055,6 +2146,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ppc64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-ppc64@npm:0.27.2" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/linux-riscv64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-riscv64@npm:0.25.8" @@ -2062,6 +2160,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-riscv64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-riscv64@npm:0.27.2" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + "@esbuild/linux-s390x@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-s390x@npm:0.25.8" @@ -2069,6 +2174,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-s390x@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-s390x@npm:0.27.2" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + "@esbuild/linux-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-x64@npm:0.25.8" @@ -2076,6 +2188,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-x64@npm:0.27.2" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@esbuild/netbsd-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/netbsd-arm64@npm:0.25.8" @@ -2083,6 +2202,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/netbsd-arm64@npm:0.27.2" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/netbsd-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/netbsd-x64@npm:0.25.8" @@ -2090,6 +2216,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/netbsd-x64@npm:0.27.2" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openbsd-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/openbsd-arm64@npm:0.25.8" @@ -2097,6 +2230,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openbsd-arm64@npm:0.27.2" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/openbsd-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/openbsd-x64@npm:0.25.8" @@ -2104,6 +2244,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openbsd-x64@npm:0.27.2" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openharmony-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/openharmony-arm64@npm:0.25.8" @@ -2111,6 +2258,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openharmony-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openharmony-arm64@npm:0.27.2" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/sunos-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/sunos-x64@npm:0.25.8" @@ -2118,6 +2272,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/sunos-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/sunos-x64@npm:0.27.2" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + "@esbuild/win32-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/win32-arm64@npm:0.25.8" @@ -2125,6 +2286,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-arm64@npm:0.27.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/win32-ia32@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/win32-ia32@npm:0.25.8" @@ -2132,6 +2300,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-ia32@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-ia32@npm:0.27.2" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/win32-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/win32-x64@npm:0.25.8" @@ -2139,6 +2314,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-x64@npm:0.27.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0, @eslint-community/eslint-utils@npm:^4.7.0": version: 4.7.0 resolution: "@eslint-community/eslint-utils@npm:4.7.0" @@ -3109,6 +3291,7 @@ __metadata: "@grafana/scenes": "npm:6.38.0" "@grafana/schema": "npm:12.4.0-pre" "@leeoniya/ufuzzy": "npm:1.0.19" + "@rollup/plugin-json": "npm:6.1.0" "@rollup/plugin-node-resolve": "npm:16.0.1" "@testing-library/react": "npm:16.3.0" "@types/d3-interpolate": "npm:^3.0.0" @@ -3147,9 +3330,11 @@ __metadata: string-hash: "npm:^1.1.3" tinycolor2: "npm:1.6.0" tslib: "npm:2.8.1" + tsx: "npm:^4.21.0" typescript: "npm:5.9.2" uplot: "npm:1.6.32" xss: "npm:^1.0.14" + zod: "npm:^4.3.0" peerDependencies: react: ^18.0.0 react-dom: ^18.0.0 @@ -10766,15 +10951,6 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^18.11.9": - version: 18.19.129 - resolution: "@types/node@npm:18.19.129" - dependencies: - undici-types: "npm:~5.26.4" - checksum: 10/0db4cb246d6012b1b523661a59c2e8e0b24527f1c02cfa3deb8e0b884492a07f2547c5353f56272b70037408e3dbe690ae923b8073fd7b0814e389148245e59f - languageName: node - linkType: hard - "@types/nodemailer@npm:*": version: 6.4.15 resolution: "@types/nodemailer@npm:6.4.15" @@ -17190,6 +17366,95 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:~0.27.0": + version: 0.27.2 + resolution: "esbuild@npm:0.27.2" + dependencies: + "@esbuild/aix-ppc64": "npm:0.27.2" + "@esbuild/android-arm": "npm:0.27.2" + "@esbuild/android-arm64": "npm:0.27.2" + "@esbuild/android-x64": "npm:0.27.2" + "@esbuild/darwin-arm64": "npm:0.27.2" + "@esbuild/darwin-x64": "npm:0.27.2" + "@esbuild/freebsd-arm64": "npm:0.27.2" + "@esbuild/freebsd-x64": "npm:0.27.2" + "@esbuild/linux-arm": "npm:0.27.2" + "@esbuild/linux-arm64": "npm:0.27.2" + "@esbuild/linux-ia32": "npm:0.27.2" + "@esbuild/linux-loong64": "npm:0.27.2" + "@esbuild/linux-mips64el": "npm:0.27.2" + "@esbuild/linux-ppc64": "npm:0.27.2" + "@esbuild/linux-riscv64": "npm:0.27.2" + "@esbuild/linux-s390x": "npm:0.27.2" + "@esbuild/linux-x64": "npm:0.27.2" + "@esbuild/netbsd-arm64": "npm:0.27.2" + "@esbuild/netbsd-x64": "npm:0.27.2" + "@esbuild/openbsd-arm64": "npm:0.27.2" + "@esbuild/openbsd-x64": "npm:0.27.2" + "@esbuild/openharmony-arm64": "npm:0.27.2" + "@esbuild/sunos-x64": "npm:0.27.2" + "@esbuild/win32-arm64": "npm:0.27.2" + "@esbuild/win32-ia32": "npm:0.27.2" + "@esbuild/win32-x64": "npm:0.27.2" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10/7f1229328b0efc63c4184a61a7eb303df1e99818cc1d9e309fb92600703008e69821e8e984e9e9f54a627da14e0960d561db3a93029482ef96dc82dd267a60c2 + languageName: node + linkType: hard + "escalade@npm:^3.1.1, escalade@npm:^3.2.0": version: 3.2.0 resolution: "escalade@npm:3.2.0" @@ -18785,6 +19050,16 @@ __metadata: languageName: node linkType: hard +"fsevents@npm:~2.3.3": + version: 2.3.3 + resolution: "fsevents@npm:2.3.3" + dependencies: + node-gyp: "npm:latest" + checksum: 10/4c1ade961ded57cdbfbb5cac5106ec17bc8bccd62e16343c569a0ceeca83b9dfef87550b4dc5cbb89642da412b20c5071f304c8c464b80415446e8e155a038c0 + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": version: 2.3.2 resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" @@ -18794,6 +19069,15 @@ __metadata: languageName: node linkType: hard +"fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": + version: 2.3.3 + resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + "function-bind@npm:^1.1.2": version: 1.1.2 resolution: "function-bind@npm:1.1.2" @@ -19008,6 +19292,15 @@ __metadata: languageName: node linkType: hard +"get-tsconfig@npm:^4.7.5": + version: 4.13.0 + resolution: "get-tsconfig@npm:4.13.0" + dependencies: + resolve-pkg-maps: "npm:^1.0.0" + checksum: 10/3603c6da30e312636e4c20461e779114c9126601d1eca70ee4e36e3e3c00e3c21892d2d920027333afa2cc9e20998a436b14abe03a53cde40742581cb0e9ceb2 + languageName: node + linkType: hard + "get-uri@npm:^6.0.1": version: 6.0.5 resolution: "get-uri@npm:6.0.5" @@ -19254,7 +19547,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^7.0.3, glob@npm:^7.1.2, glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.6, glob@npm:^7.1.7": +"glob@npm:^7.0.3, glob@npm:^7.1.2, glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.6": version: 7.2.3 resolution: "glob@npm:7.2.3" dependencies: @@ -19796,7 +20089,6 @@ __metadata: tween-functions: "npm:^1.2.0" type-fest: "npm:^4.18.2" typescript: "npm:5.9.2" - typescript-json-schema: "npm:^0.65.1" uplot: "npm:1.6.32" uuid: "npm:11.1.0" vis-data: "npm:^8.0.0" @@ -19812,7 +20104,7 @@ __metadata: whatwg-fetch: "npm:3.6.20" yaml: "npm:^2.0.0" yargs: "npm:^18.0.0" - zod: "npm:^4.0.0" + zod: "npm:^4.3.0" dependenciesMeta: cypress: built: true @@ -26756,13 +27048,6 @@ __metadata: languageName: node linkType: hard -"path-equal@npm:^1.2.5": - version: 1.2.5 - resolution: "path-equal@npm:1.2.5" - checksum: 10/fa4ef398dea6bd7bf36c5fe62b5f5c2c14fe1f1340cf355eb8a40c86577318dfa0401df86464bb0cc33ed227f115b2afec10d1adaa64260dedbbc23d33f3abbb - languageName: node - linkType: hard - "path-exists@npm:^3.0.0": version: 3.0.0 resolution: "path-exists@npm:3.0.0" @@ -30218,13 +30503,6 @@ __metadata: languageName: node linkType: hard -"safe-stable-stringify@npm:^2.2.0": - version: 2.5.0 - resolution: "safe-stable-stringify@npm:2.5.0" - checksum: 10/2697fa186c17c38c3ca5309637b4ac6de2f1c3d282da27cd5e1e3c88eca0fb1f9aea568a6aabdf284111592c8782b94ee07176f17126031be72ab1313ed46c5c - languageName: node - linkType: hard - "safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0, safer-buffer@npm:^2.0.2, safer-buffer@npm:^2.1.0, safer-buffer@npm:~2.1.0": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" @@ -32786,7 +33064,7 @@ __metadata: languageName: node linkType: hard -"ts-node@npm:10.9.2, ts-node@npm:^10.9.1": +"ts-node@npm:10.9.2": version: 10.9.2 resolution: "ts-node@npm:10.9.2" dependencies: @@ -32893,6 +33171,22 @@ __metadata: languageName: node linkType: hard +"tsx@npm:^4.21.0": + version: 4.21.0 + resolution: "tsx@npm:4.21.0" + dependencies: + esbuild: "npm:~0.27.0" + fsevents: "npm:~2.3.3" + get-tsconfig: "npm:^4.7.5" + dependenciesMeta: + fsevents: + optional: true + bin: + tsx: dist/cli.mjs + checksum: 10/7afedeff855ba98c47dc28b33d7e8e253c4dc1f791938db402d79c174bdf806b897c1a5f91e5b1259c112520c816f826b4c5d98f0bad7e95b02dec66fedb64d2 + languageName: node + linkType: hard + "tuf-js@npm:^2.2.1": version: 2.2.1 resolution: "tuf-js@npm:2.2.1" @@ -33128,24 +33422,6 @@ __metadata: languageName: node linkType: hard -"typescript-json-schema@npm:^0.65.1": - version: 0.65.1 - resolution: "typescript-json-schema@npm:0.65.1" - dependencies: - "@types/json-schema": "npm:^7.0.9" - "@types/node": "npm:^18.11.9" - glob: "npm:^7.1.7" - path-equal: "npm:^1.2.5" - safe-stable-stringify: "npm:^2.2.0" - ts-node: "npm:^10.9.1" - typescript: "npm:~5.5.0" - yargs: "npm:^17.1.1" - bin: - typescript-json-schema: bin/typescript-json-schema - checksum: 10/50a1935378639d5d47e452702766a3fdab22e1d06192f26f81b79e0da504e71af987ff21cb13909479a202aad8d1216a654f16ebda2ee2056b5f859584b4c7d2 - languageName: node - linkType: hard - "typescript-string-operations@npm:^1.4.1": version: 1.5.1 resolution: "typescript-string-operations@npm:1.5.1" @@ -33153,7 +33429,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:5.5.4, typescript@npm:~5.5.0": +"typescript@npm:5.5.4": version: 5.5.4 resolution: "typescript@npm:5.5.4" bin: @@ -33183,7 +33459,7 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A5.5.4#optional!builtin, typescript@patch:typescript@npm%3A~5.5.0#optional!builtin": +"typescript@patch:typescript@npm%3A5.5.4#optional!builtin": version: 5.5.4 resolution: "typescript@patch:typescript@npm%3A5.5.4#optional!builtin::version=5.5.4&hash=379a07" bin: @@ -33272,13 +33548,6 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~5.26.4": - version: 5.26.5 - resolution: "undici-types@npm:5.26.5" - checksum: 10/0097779d94bc0fd26f0418b3a05472410408877279141ded2bd449167be1aed7ea5b76f756562cb3586a07f251b90799bab22d9019ceba49c037c76445f7cddd - languageName: node - linkType: hard - "undici-types@npm:~7.16.0": version: 7.16.0 resolution: "undici-types@npm:7.16.0" @@ -34824,7 +35093,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:17.7.2, yargs@npm:^17.0.1, yargs@npm:^17.1.1, yargs@npm:^17.3.1, yargs@npm:^17.6.2, yargs@npm:^17.7.2": +"yargs@npm:17.7.2, yargs@npm:^17.0.1, yargs@npm:^17.3.1, yargs@npm:^17.6.2, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: @@ -34948,13 +35217,20 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.25 || ^4.0, zod@npm:^4.0.0": +"zod@npm:^3.25 || ^4.0": version: 4.1.13 resolution: "zod@npm:4.1.13" checksum: 10/0679190318928f69fcb07751063719de232c663b13955fcdb55db59839569d39f3f29b955cb0cba7af0b724233f88c06b3e84c550397ad4e68f8088fa6799d88 languageName: node linkType: hard +"zod@npm:^4.3.0": + version: 4.3.5 + resolution: "zod@npm:4.3.5" + checksum: 10/3148bd52e56ab7c1641ec397e6be6eddbb1d8f5db71e95baab9bb9622a0ea49d8a385885fc1c22b90fa6d8c5234e051f4ef5d469cfe3fb90198d5a91402fd89c + languageName: node + linkType: hard + "zstddec@npm:^0.1.0": version: 0.1.0 resolution: "zstddec@npm:0.1.0" From 5219ccddb612a8f885e311d12094d9e540490b9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Sencer=20=C3=96zcan?= <32759850+mustafasencer@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:42:21 +0100 Subject: [PATCH 09/57] fix: improve resilience for unified storage and search service grpc clients (#116122) * fix: reliability * fix: resilience * fix: add connection backoff * fix: reduce backoff --- pkg/server/ring.go | 53 +++++++++++++++++++++ pkg/services/apiserver/options/storage.go | 57 +++++++++++++++++++---- pkg/storage/unified/client.go | 6 ++- pkg/storage/unified/client_retry.go | 15 ++++++ 4 files changed, 121 insertions(+), 10 deletions(-) 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/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/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, + }) +} From b57b8d43599b9488643e9db3b0faeb05624b4de0 Mon Sep 17 00:00:00 2001 From: Tito Lins Date: Tue, 13 Jan 2026 12:48:16 +0100 Subject: [PATCH 10/57] fix: handle go mod issues (#116187) --- go.mod | 19 +++++++++++++------ go.sum | 15 +++++++++++++-- go.work.sum | 4 +++- pkg/extensions/enterprise_imports.go | 1 + 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 102310173d3..e859abe4369 100644 --- a/go.mod +++ b/go.mod @@ -32,14 +32,14 @@ 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 @@ -82,7 +82,7 @@ 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 @@ -113,6 +113,7 @@ 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 @@ -260,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 @@ -294,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 ( @@ -652,11 +656,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/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 @@ -676,6 +681,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 diff --git a/go.sum b/go.sum index 0a828108d08..ed466f5d065 100644 --- a/go.sum +++ b/go.sum @@ -680,6 +680,7 @@ github.com/Azure/azure-storage-blob-go v0.15.0 h1:rXtgp8tN1p29GvpGgfJetavIG0V7Og 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-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= @@ -737,6 +738,8 @@ github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXY github.com/IBM/pgxpoolprometheus v1.1.2 h1:sHJwxoL5Lw4R79Zt+H4Uj1zZ4iqXJLdk7XDE7TPs97U= github.com/IBM/pgxpoolprometheus v1.1.2/go.mod h1:+vWzISN6S9ssgurhUNmm6AlXL9XLah3TdWJktquKTR8= github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= +github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E= +github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= @@ -759,6 +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.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= @@ -1026,6 +1031,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -1664,8 +1671,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= @@ -1753,6 +1758,8 @@ github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5O github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +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/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= @@ -1877,6 +1884,10 @@ github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbd github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= 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/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/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= diff --git a/go.work.sum b/go.work.sum index 0300e5becbc..44ed523184c 100644 --- a/go.work.sum +++ b/go.work.sum @@ -280,7 +280,6 @@ github.com/Azure/go-amqp v0.17.0/go.mod h1:9YJ3RhxRT1gquYnzpZO1vcYMMpAdJT+QEg6fw github.com/Azure/go-amqp v1.4.0 h1:Xj3caqi4comOF/L1Uc5iuBxR/pB6KumejC01YQOqOR4= github.com/Azure/go-amqp v1.4.0/go.mod h1:vZAogwdrkbyK3Mla8m/CxSc/aKdnTZ4IbPxl51Y5WZE= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-autorest/autorest v0.11.18/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA= github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 h1:Ov8avRZi2vmrE2JcXw+tu5K/yB41r7xK9GZDiBF7NdM= github.com/Azure/go-autorest/autorest/azure/auth v0.5.13/go.mod h1:5BAVfWLWXihP47vYrPuBKKf4cS0bXI+KM9Qx6ETDJYo= @@ -906,6 +905,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= @@ -1911,6 +1912,7 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0/go.mod h1:hYwym2nDEeZfG/motx0p7L7J1N1vyzIThemQsb4g2qY= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0/go.mod h1:Y5+XiUG4Emn1hTfciPzGPJaSI+RpDts6BnCIir0SLqk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ= go.opentelemetry.io/otel/exporters/prometheus v0.58.0/go.mod h1:7qo/4CLI+zYSNbv0GMNquzuss2FVZo3OYrGh96n4HNc= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.36.0/go.mod h1:PD57idA/AiFD5aqoxGxCvT/ILJPeHy3MjqU/NS7KogY= diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index 472652cc103..3a65082459e 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -11,6 +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/service/secretsmanager" _ "github.com/beevik/etree" _ "github.com/blugelabs/bluge" _ "github.com/blugelabs/bluge_segment_api" From d1064da4cd6a4203e3c4743f625eedd26cb075ba Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Tue, 13 Jan 2026 13:09:08 +0100 Subject: [PATCH 11/57] Scopes: Add RTK Query API client for caching (#115494) * Scopes API client * Initial RTK query commit * Copy API client from generated enterprise folder * Mock ScopesApiClient for integration tests * Update e2e tests * Handle group expansion for dashboard navigation * Extract integration test mocks * Move mock to only be for integration tests * Update path for enterprise sync script * Re-export mockData * Disregard caching for search * Leave name parameters empty * Disable subscriptions for client requests * Add functionality to reset cache between mocked requests * Use grafana-test-utils for scopes integration tests * Rollback mock setup * Remove store form window object * Remove cache helper * Restore scopenode search functionality * Improve request erro handling * Clean up subscription in case subscription: false lies * Fix logging security risk * Rewrite tests to cover RTK query usage and improve error catching * Update USE_LIVE_DATA to be consistent * Remove unused timout parameter * Fix error handling * Make dashboard-navigation test pass --- .../dashboard-cujs/adhoc-filters-cujs.spec.ts | 9 +- .../dashboard-cujs/cuj-selectors.ts | 11 + .../dashboard-navigation.spec.ts | 21 +- .../dashboard-cujs/dashboard-view.spec.ts | 5 +- .../dashboard-cujs/scope-cujs.spec.ts | 50 +- .../dashboard-cujs/scope-redirect.spec.ts | 33 +- e2e-playwright/utils/scope-helpers.ts | 198 +- .../src/clients/rtkq/createBaseQuery.ts | 2 + .../grafana-test-utils/src/fixtures/scopes.ts | 500 +++++ .../src/handlers/all-handlers.ts | 2 + .../scope.grafana.app/v0alpha1/handlers.ts | 131 ++ packages/grafana-test-utils/src/unstable.ts | 9 + .../app/api/clients/scope/v0alpha1/baseAPI.ts | 16 + .../clients/scope/v0alpha1/endpoints.gen.ts | 1727 +++++++++++++++++ .../app/api/clients/scope/v0alpha1/index.ts | 3 + .../scope/v0alpha1/sync-from-enterprise.sh | 43 + public/app/core/reducers/root.ts | 2 + .../features/scopes/ScopesApiClient.test.ts | 723 +++++-- public/app/features/scopes/ScopesApiClient.ts | 256 ++- .../ScopesDashboardsService.test.ts | 6 +- .../scopes/tests/dashboardReload.test.ts | 10 +- .../scopes/tests/dashboardsList.test.ts | 26 +- .../features/scopes/tests/selector.test.ts | 13 +- public/app/features/scopes/tests/tree.test.ts | 10 +- .../features/scopes/tests/utils/mockData.ts | 92 + .../app/features/scopes/tests/utils/mocks.ts | 586 ------ .../features/scopes/tests/utils/render.tsx | 3 - .../features/scopes/tests/viewMode.test.ts | 10 +- public/app/store/configureStore.ts | 2 + 29 files changed, 3591 insertions(+), 908 deletions(-) create mode 100644 packages/grafana-test-utils/src/fixtures/scopes.ts create mode 100644 packages/grafana-test-utils/src/handlers/apis/scope.grafana.app/v0alpha1/handlers.ts create mode 100644 public/app/api/clients/scope/v0alpha1/baseAPI.ts create mode 100644 public/app/api/clients/scope/v0alpha1/endpoints.gen.ts create mode 100644 public/app/api/clients/scope/v0alpha1/index.ts create mode 100755 public/app/api/clients/scope/v0alpha1/sync-from-enterprise.sh create mode 100644 public/app/features/scopes/tests/utils/mockData.ts 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/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/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-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/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/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/features/scopes/ScopesApiClient.test.ts b/public/app/features/scopes/ScopesApiClient.test.ts index 979da40b6ce..77d9051999d 100644 --- a/public/app/features/scopes/ScopesApiClient.test.ts +++ b/public/app/features/scopes/ScopesApiClient.test.ts @@ -1,74 +1,194 @@ -import { getBackendSrv, config } from '@grafana/runtime'; +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'; -// Mock the runtime dependencies -jest.mock('@grafana/runtime', () => ({ - getBackendSrv: jest.fn(), - config: { - featureToggles: { - useMultipleScopeNodesEndpoint: true, - useScopeSingleNodeEndpoint: true, +// 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('@grafana/api-clients', () => ({ - getAPIBaseURL: jest.fn().mockReturnValue('/apis/scope.grafana.app/v0alpha1'), +jest.mock('app/store/store', () => ({ + dispatch: jest.fn((action) => action), })); describe('ScopesApiClient', () => { let apiClient: ScopesApiClient; - let mockBackendSrv: jest.Mocked<{ get: jest.Mock }>; beforeEach(() => { - mockBackendSrv = { - get: jest.fn(), - }; - (getBackendSrv as jest.Mock).mockReturnValue(mockBackendSrv); 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 () => { - const mockNodes = [ - { - metadata: { name: 'node-1' }, - spec: { nodeType: 'container', title: 'Node 1', parentName: '' }, - }, - { - metadata: { name: 'node-2' }, - spec: { nodeType: 'leaf', title: 'Node 2', parentName: 'node-1' }, - }, - ]; + // Expected: Both nodes exist in MOCK_NODES + const nodeNames = ['applications-grafana', 'applications-mimir']; + const expectedNodes = MOCK_NODES.filter((n) => nodeNames.includes(n.metadata.name)); - mockBackendSrv.get.mockResolvedValue({ items: mockNodes }); - - const result = await apiClient.fetchMultipleScopeNodes(['node-1', 'node-2']); - - expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { - names: ['node-1', 'node-2'], + const mockSubscription = createMockSubscription({ + data: { items: expectedNodes }, }); - expect(result).toEqual(mockNodes); + (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(mockBackendSrv.get).not.toHaveBeenCalled(); expect(result).toEqual([]); }); it('should return empty array when feature toggle is disabled', async () => { config.featureToggles.useMultipleScopeNodesEndpoint = false; - const result = await apiClient.fetchMultipleScopeNodes(['node-1']); + const result = await apiClient.fetchMultipleScopeNodes(['applications-grafana']); - expect(mockBackendSrv.get).not.toHaveBeenCalled(); expect(result).toEqual([]); // Restore feature toggle @@ -76,79 +196,94 @@ describe('ScopesApiClient', () => { }); it('should handle API errors gracefully', async () => { - mockBackendSrv.get.mockRejectedValue(new Error('Network error')); + // 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(['node-1']); + 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 () => { - mockBackendSrv.get.mockResolvedValue({}); + // 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(['node-1']); - - expect(result).toEqual([]); - }); - - it('should handle response with null items', async () => { - mockBackendSrv.get.mockResolvedValue({ items: null }); - - const result = await apiClient.fetchMultipleScopeNodes(['node-1']); + 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 () => { - const names = Array.from({ length: 100 }, (_, i) => `node-${i}`); - const mockNodes = names.map((name) => ({ - metadata: { name }, - spec: { nodeType: 'leaf', title: name, parentName: '' }, - })); + // 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 + ); - mockBackendSrv.get.mockResolvedValue({ items: mockNodes }); + const result = await apiClient.fetchMultipleScopeNodes(nonExistentNodeNames); - const result = await apiClient.fetchMultipleScopeNodes(names); - - expect(result).toEqual(mockNodes); - expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { - names, - }); + // 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 () => { - const names = ['node-with-special-chars_123', 'node.with.dots', 'node-with-dashes']; - mockBackendSrv.get.mockResolvedValue({ items: [] }); + // 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 + ); - await apiClient.fetchMultipleScopeNodes(names); + const result = await apiClient.fetchMultipleScopeNodes(nodeNames); - expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { - names, + // 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 () => { - const mockNode = { - metadata: { name: 'test-node' }, - spec: { nodeType: 'leaf', title: 'Test Node', parentName: 'parent' }, - }; + // Expected: Node exists in MOCK_NODES + const nodeName = 'applications-grafana'; + const expectedNode = MOCK_NODES.find((n) => n.metadata.name === nodeName); + expect(expectedNode).toBeDefined(); - mockBackendSrv.get.mockResolvedValue(mockNode); + const mockSubscription = createMockSubscription({ data: expectedNode }); + (scopeAPIv0alpha1.endpoints.getScopeNode.initiate as jest.Mock).mockReturnValue(mockSubscription); - const result = await apiClient.fetchScopeNode('test-node'); + const result = await apiClient.fetchScopeNode(nodeName); - expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/scopenodes/test-node'); - expect(result).toEqual(mockNode); + // 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('test-node'); + const result = await apiClient.fetchScopeNode('applications-grafana'); - expect(mockBackendSrv.get).not.toHaveBeenCalled(); expect(result).toBeUndefined(); // Restore feature toggle @@ -156,65 +291,95 @@ describe('ScopesApiClient', () => { }); it('should return undefined on API error', async () => { - mockBackendSrv.get.mockRejectedValue(new Error('Not found')); + // 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('non-existent'); + 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 () => { - const mockNodes = [ - { - metadata: { name: 'child-1' }, - spec: { nodeType: 'leaf', title: 'Child 1', parentName: 'parent' }, - }, - ]; + // Expected: MOCK_NODES contains nodes with parentName 'applications' + const parentName = 'applications'; + const expectedNodes = MOCK_NODES.filter((n) => n.spec.parentName === parentName); - mockBackendSrv.get.mockResolvedValue({ items: mockNodes }); + const mockSubscription = createMockSubscription({ + data: { items: expectedNodes }, + }); + (scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); - const result = await apiClient.fetchNodes({ parent: 'parent' }); + const result = await apiClient.fetchNodes({ parent: parentName }); - expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { - parent: 'parent', - query: undefined, - limit: 1000, + // 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); }); - expect(result).toEqual(mockNodes); }); it('should fetch nodes with query filter', async () => { - const mockNodes = [ - { - metadata: { name: 'matching-node' }, - spec: { nodeType: 'leaf', title: 'Matching Node', parentName: '' }, - }, - ]; + // 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())); - mockBackendSrv.get.mockResolvedValue({ items: mockNodes }); + const mockSubscription = createMockSubscription({ + data: { items: expectedNodes }, + }); + (scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); - const result = await apiClient.fetchNodes({ query: 'matching' }); + const result = await apiClient.fetchNodes({ query }); - expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { - parent: undefined, - query: 'matching', - limit: 1000, + // 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); }); - expect(result).toEqual(mockNodes); }); it('should respect custom limit', async () => { - mockBackendSrv.get.mockResolvedValue({ items: [] }); - - await apiClient.fetchNodes({ limit: 50 }); - - expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { - parent: undefined, - query: undefined, - limit: 50, + 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 () => { @@ -226,137 +391,297 @@ describe('ScopesApiClient', () => { }); it('should use default limit of 1000 when not specified', async () => { - mockBackendSrv.get.mockResolvedValue({ items: [] }); - - await apiClient.fetchNodes({}); - - expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { - parent: undefined, - query: undefined, - limit: 1000, + 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 () => { - mockBackendSrv.get.mockRejectedValue(new Error('API Error')); - - const result = await apiClient.fetchNodes({ parent: 'test' }); - - expect(result).toEqual([]); - }); - }); - - describe('fetchScope', () => { - it('should fetch a scope by name', async () => { - const mockScope = { - metadata: { name: 'test-scope' }, - spec: { - title: 'Test Scope', - filters: [], - }, - }; - - mockBackendSrv.get.mockResolvedValue(mockScope); - - const result = await apiClient.fetchScope('test-scope'); - - expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/scopes/test-scope'); - expect(result).toEqual(mockScope); - }); - - it('should return undefined on error', async () => { + const mockSubscription = createMockSubscription({ data: { items: [] } }); + (scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - mockBackendSrv.get.mockRejectedValue(new Error('Not found')); - const result = await apiClient.fetchScope('non-existent'); + const result = await apiClient.fetchNodes({ parent: 'non-existent-parent' }); - expect(result).toBeUndefined(); + expect(Array.isArray(result)).toBe(true); consoleErrorSpy.mockRestore(); }); - it('should log error to console', async () => { - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - const error = new Error('Not found'); - mockBackendSrv.get.mockRejectedValue(error); + 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()) + ); - await apiClient.fetchScope('non-existent'); + const mockSubscription = createMockSubscription({ + data: { items: expectedNodes }, + }); + (scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue( + mockSubscription + ); - expect(consoleErrorSpy).toHaveBeenCalledWith(error); - consoleErrorSpy.mockRestore(); + 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('fetchMultipleScopes', () => { - it('should fetch multiple scopes in parallel', async () => { - const mockScopes = [ + 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: 'scope-1' }, - spec: { title: 'Scope 1', filters: [] }, - }, - { - metadata: { name: 'scope-2' }, - spec: { title: 'Scope 2', filters: [] }, + 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 + ); - mockBackendSrv.get.mockResolvedValueOnce(mockScopes[0]).mockResolvedValueOnce(mockScopes[1]); + const result = await apiClient.fetchDashboards(scopeNames); - const result = await apiClient.fetchMultipleScopes(['scope-1', 'scope-2']); - - expect(mockBackendSrv.get).toHaveBeenCalledTimes(2); - expect(result).toEqual(mockScopes); + // Validate: returns bindings for the requested scope + expect(Array.isArray(result)).toBe(true); + result.forEach((binding) => { + expect(binding.spec.scope).toBe('grafana'); + }); }); - it('should filter out undefined scopes', async () => { - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - const mockScope = { - metadata: { name: 'scope-1' }, - spec: { title: 'Scope 1', filters: [] }, - }; + 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 + ); - mockBackendSrv.get.mockResolvedValueOnce(mockScope).mockRejectedValueOnce(new Error('Not found')); + const result = await apiClient.fetchDashboards(scopeNames); - const result = await apiClient.fetchMultipleScopes(['scope-1', 'non-existent']); - - expect(result).toEqual([mockScope]); - consoleErrorSpy.mockRestore(); + // 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 scopes provided', async () => { - const result = await apiClient.fetchMultipleScopes([]); + 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([]); - expect(mockBackendSrv.get).not.toHaveBeenCalled(); + }); + + 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 () => { - mockBackendSrv.get.mockResolvedValue({ items: [] }); + // 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 + ); - await apiClient.fetchMultipleScopeNodes(['node-1', 'node-2', 'node-3', 'node-4', 'node-5']); + const result = await apiClient.fetchMultipleScopeNodes(nodeNames); - // Should make exactly 1 API call - expect(mockBackendSrv.get).toHaveBeenCalledTimes(1); + 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 () => { - mockBackendSrv.get.mockResolvedValue({ - metadata: { name: 'test' }, - spec: { nodeType: 'leaf', title: 'Test', parentName: '' }, + // 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); }); - // Simulate old pattern of fetching nodes one by one - await Promise.all([ - apiClient.fetchScopeNode('node-1'), - apiClient.fetchScopeNode('node-2'), - apiClient.fetchScopeNode('node-3'), - apiClient.fetchScopeNode('node-4'), - apiClient.fetchScopeNode('node-5'), + 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'), ]); - // Should make 5 separate API calls - expect(mockBackendSrv.get).toHaveBeenCalledTimes(5); + 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/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/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 56921ac6f7d..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 () => { 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/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/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 ), From 5dbbe8164ba9830527bdcfee9223609e5977c07d Mon Sep 17 00:00:00 2001 From: "alerting-team[bot]" <158350966+alerting-team[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:27:35 +0000 Subject: [PATCH 12/57] Alerting: Update alerting module to 98a49ed9557fd9b5f33ecb77cbaa0748f13dc568 (#116197) * [create-pull-request] automated change * update prometheus-alertmanager --------- Co-authored-by: titolins <8942194+titolins@users.noreply.github.com> Co-authored-by: Tito Lins --- apps/alerting/historian/go.mod | 2 +- apps/alerting/historian/go.sum | 4 ++-- apps/plugins/go.mod | 2 +- apps/plugins/go.sum | 4 ++-- go.mod | 4 ++-- go.sum | 8 ++++---- go.work | 2 +- go.work.sum | 2 ++ 8 files changed, 15 insertions(+), 13 deletions(-) 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/plugins/go.mod b/apps/plugins/go.mod index e4866731967..7e9e5f47876 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -97,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 diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index 29738a48cca..c991111f90e 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -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= diff --git a/go.mod b/go.mod index e859abe4369..fe3e62e3fde 100644 --- a/go.mod +++ b/go.mod @@ -89,7 +89,7 @@ require ( 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 @@ -704,7 +704,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 ed466f5d065..67d95c625b6 100644 --- a/go.sum +++ b/go.sum @@ -1627,8 +1627,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= @@ -1681,8 +1681,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 208eca0454a..462bbb36a04 100644 --- a/go.work +++ b/go.work @@ -38,6 +38,6 @@ use ( ./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 44ed523184c..5f97bbdd6ef 100644 --- a/go.work.sum +++ b/go.work.sum @@ -997,6 +997,8 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975 github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975/go.mod h1:FGdGvhI40Dq+CTQaSzK9evuve774cgOUdGfVO04OXkw= 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/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.8/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= 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= From dffae66fdc90fdd58d2787c17fea2b2c5b7cf69e Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Tue, 13 Jan 2026 13:10:04 +0000 Subject: [PATCH 13/57] Storybook: Add workflow to deploy canary storybook (#116138) * add first attempt at storybook deploy action for canary * don't run on push to main yet! * add CODEOWNER --- .github/CODEOWNERS | 1 + .github/workflows/deploy-storybook.yml | 79 ++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 .github/workflows/deploy-storybook.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ec656436ee7..98eb0aee15a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1275,6 +1275,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/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 From d2b788eb5395040d27aff07d0379895786947827 Mon Sep 17 00:00:00 2001 From: Will Browne Date: Tue, 13 Jan 2026 13:41:40 +0000 Subject: [PATCH 14/57] Plugins: Remove angular details from meta API (#116194) remove angular details from meta API --- apps/plugins/kinds/meta.cue | 3 --- .../pkg/apis/plugins/v0alpha1/meta_spec_gen.go | 11 ----------- apps/plugins/pkg/apis/plugins_manifest.go | 2 +- apps/plugins/pkg/app/meta/converter.go | 16 +--------------- 4 files changed, 2 insertions(+), 30 deletions(-) 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 } From c9a14f177459da2375302c23032aec1d61c31916 Mon Sep 17 00:00:00 2001 From: Misi Date: Tue, 13 Jan 2026 14:45:18 +0100 Subject: [PATCH 15/57] IAM: Target resource authorization for TeamBinding (#116117) * wip * Review VerbGet vs VerbGetPermissions * Fix tests --- pkg/registry/apis/iam/authorizer.go | 2 +- .../iam/authorizer/team_binding_authorizer.go | 156 +++++++++++ .../team_binding_authorizer_test.go | 253 ++++++++++++++++++ pkg/registry/apis/iam/register.go | 12 +- .../iam/team_bindings_integration_test.go | 126 +++++---- .../testdata/teambinding-test-create-v0.yaml | 2 +- 6 files changed, 496 insertions(+), 55 deletions(-) create mode 100644 pkg/registry/apis/iam/authorizer/team_binding_authorizer.go create mode 100644 pkg/registry/apis/iam/authorizer/team_binding_authorizer_test.go diff --git a/pkg/registry/apis/iam/authorizer.go b/pkg/registry/apis/iam/authorizer.go index efcf6fa5b39..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 @@ -55,6 +54,7 @@ func newIAMAuthorizer( resourceAuthorizer[iamv0.UserResourceInfo.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/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/register.go b/pkg/registry/apis/iam/register.go index 7f42d620987..895f1d0bf22 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -361,7 +361,7 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateTeamBindingsAPIGroup(opts bui 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 { @@ -376,8 +376,16 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateTeamBindingsAPIGroup(opts bui 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) + } } + + authzWrapper := storewrapper.New(teamBindingStore, iamauthorizer.NewTeamBindingAuthorizer(b.accessClient)) + storage[teamBindingResource.StoragePath()] = authzWrapper return nil } 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: "" From 86652a6515ce0c5d19e646b18673cf31ac85c20e Mon Sep 17 00:00:00 2001 From: Will Assis <35489495+gassiss@users.noreply.github.com> Date: Tue, 13 Jan 2026 09:35:02 -0500 Subject: [PATCH 16/57] unified-storage: sql backend key_path backfill (#116142) --- .../unified/sql/db/migrations/resource_mig.go | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/pkg/storage/unified/sql/db/migrations/resource_mig.go b/pkg/storage/unified/sql/db/migrations/resource_mig.go index 8f9be689718..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 { @@ -220,5 +223,142 @@ func initResourceTables(mg *migrator.Migrator) string { 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 +} From aa9b587cc1e50bcbfe31d78e98473c78d451bf67 Mon Sep 17 00:00:00 2001 From: Will Browne Date: Tue, 13 Jan 2026 14:35:45 +0000 Subject: [PATCH 17/57] Plugins: Add module hash field to plugin model (#116119) * add module hash field to plugin model * fix tests * fix lint issues --- apps/plugins/pkg/app/meta/local.go | 7 +- pkg/api/frontendsettings.go | 6 +- pkg/api/frontendsettings_test.go | 7 +- pkg/api/plugins.go | 2 +- pkg/api/plugins_test.go | 5 +- pkg/plugins/ifaces.go | 4 +- pkg/plugins/manager/loader/loader_test.go | 25 +- .../manager/pipeline/bootstrap/bootstrap.go | 3 +- .../manager/pipeline/bootstrap/steps.go | 27 +- pkg/plugins/manager/signature/manifest.go | 55 +-- .../manager/signature/manifest_test.go | 32 +- pkg/plugins/pluginassets/modulehash.go | 90 +++++ pkg/plugins/pluginassets/modulehash_test.go | 356 +++++++++++++++++ .../module-hash-no-manifest-txt/module.js | 0 .../module-hash-no-manifest-txt/plugin.json | 0 .../module-hash-no-module-js/MANIFEST.txt | 0 .../module-hash-no-module-js/plugin.json | 0 .../module-hash-no-module-js/something.js | 0 .../MANIFEST.txt | 0 .../datasource/module.js | 0 .../datasource/panels/one/module.js | 0 .../datasource/panels/one/plugin.json | 0 .../datasource/plugin.json | 0 .../module-hash-valid-deeply-nested/module.js | 0 .../plugin.json | 0 .../module-hash-valid-nested/MANIFEST.txt | 0 .../datasource/module.js | 0 .../datasource/plugin.json | 0 .../module-hash-valid-nested/module.js | 0 .../panels/one/module.js | 0 .../panels/one/plugin.json | 0 .../module-hash-valid-nested/plugin.json | 0 .../testdata/module-hash-valid/MANIFEST.txt | 0 .../testdata/module-hash-valid/module.js | 0 .../testdata/module-hash-valid/plugin.json | 0 pkg/plugins/plugins.go | 27 +- pkg/server/wire_gen.go | 12 +- .../pluginsintegration/loader/loader_test.go | 206 ++++++++-- .../pluginsintegration/pipeline/pipeline.go | 5 +- .../pluginassets/pluginassets.go | 132 +----- .../pluginassets/pluginassets_test.go | 375 ------------------ .../pluginsintegration/pluginstore/plugins.go | 6 +- .../pluginsintegration/test_helper.go | 5 +- 43 files changed, 767 insertions(+), 620 deletions(-) create mode 100644 pkg/plugins/pluginassets/modulehash.go create mode 100644 pkg/plugins/pluginassets/modulehash_test.go rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-no-manifest-txt/module.js (100%) rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-no-manifest-txt/plugin.json (100%) rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-no-module-js/MANIFEST.txt (100%) rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-no-module-js/plugin.json (100%) rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-no-module-js/something.js (100%) rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-valid-deeply-nested/MANIFEST.txt (100%) rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/module.js (100%) rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/module.js (100%) rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/plugin.json (100%) rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/plugin.json (100%) rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-valid-deeply-nested/module.js (100%) rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-valid-deeply-nested/plugin.json (100%) rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-valid-nested/MANIFEST.txt (100%) rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-valid-nested/datasource/module.js (100%) rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-valid-nested/datasource/plugin.json (100%) rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-valid-nested/module.js (100%) rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-valid-nested/panels/one/module.js (100%) rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-valid-nested/panels/one/plugin.json (100%) rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-valid-nested/plugin.json (100%) rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-valid/MANIFEST.txt (100%) rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-valid/module.js (100%) rename pkg/{services/pluginsintegration => plugins}/pluginassets/testdata/module-hash-valid/plugin.json (100%) diff --git a/apps/plugins/pkg/app/meta/local.go b/apps/plugins/pkg/app/meta/local.go index 2c699520cfc..af40316cd73 100644 --- a/apps/plugins/pkg/app/meta/local.go +++ b/apps/plugins/pkg/app/meta/local.go @@ -13,10 +13,9 @@ const ( ) // PluginAssetsCalculator is an interface for calculating plugin asset information. -// LocalProvider requires this to calculate loading strategy and module hash. +// LocalProvider requires this to calculate loading strategy. type PluginAssetsCalculator interface { LoadingStrategy(ctx context.Context, p pluginstore.Plugin) plugins.LoadingStrategy - ModuleHash(ctx context.Context, p pluginstore.Plugin) string } // LocalProvider retrieves plugin metadata for locally installed plugins. @@ -27,7 +26,7 @@ type LocalProvider struct { } // NewLocalProvider creates a new LocalProvider for locally installed plugins. -// pluginAssets is required for calculating loading strategy and module hash. +// pluginAssets is required for calculating loading strategy. func NewLocalProvider(pluginStore pluginstore.Store, pluginAssets PluginAssetsCalculator) *LocalProvider { return &LocalProvider{ store: pluginStore, @@ -43,7 +42,7 @@ func (p *LocalProvider) GetMeta(ctx context.Context, pluginID, version string) ( } loadingStrategy := p.pluginAssets.LoadingStrategy(ctx, plugin) - moduleHash := p.pluginAssets.ModuleHash(ctx, plugin) + moduleHash := plugin.ModuleHash spec := pluginStorePluginToMeta(plugin, loadingStrategy, moduleHash) return &Result{ diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index b57262087e5..64165cfb98a 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -161,7 +161,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro AliasIDs: panel.AliasIDs, Info: panel.Info, Module: panel.Module, - ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), panel), + ModuleHash: panel.ModuleHash, BaseURL: panel.BaseURL, SkipDataQuery: panel.SkipDataQuery, Suggestions: panel.Suggestions, @@ -527,7 +527,7 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug JSONData: plugin.JSONData, Signature: plugin.Signature, Module: plugin.Module, - ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), plugin), + ModuleHash: plugin.ModuleHash, BaseURL: plugin.BaseURL, Angular: plugin.Angular, MultiValueFilterOperators: plugin.MultiValueFilterOperators, @@ -641,7 +641,7 @@ func (hs *HTTPServer) newAppDTO(ctx context.Context, plugin pluginstore.Plugin, LoadingStrategy: hs.pluginAssets.LoadingStrategy(ctx, plugin), Extensions: plugin.Extensions, Dependencies: plugin.Dependencies, - ModuleHash: hs.pluginAssets.ModuleHash(ctx, plugin), + ModuleHash: plugin.ModuleHash, Translations: plugin.Translations, BuildMode: plugin.BuildMode, } diff --git a/pkg/api/frontendsettings_test.go b/pkg/api/frontendsettings_test.go index ba67837be5d..4741f6b10bc 100644 --- a/pkg/api/frontendsettings_test.go +++ b/pkg/api/frontendsettings_test.go @@ -20,8 +20,6 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/manager/pluginfakes" - "github.com/grafana/grafana/pkg/plugins/manager/signature" - "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" "github.com/grafana/grafana/pkg/plugins/pluginscdn" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" @@ -79,8 +77,7 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F var pluginsAssets = passets if pluginsAssets == nil { - sig := signature.ProvideService(pluginsCfg, statickey.New()) - pluginsAssets = pluginassets.ProvideService(pluginsCfg, pluginsCDN, sig, pluginStore) + pluginsAssets = pluginassets.ProvideService(pluginsCfg, pluginsCDN, pluginStore) } hs := &HTTPServer{ @@ -714,6 +711,6 @@ func newPluginAssets() func() *pluginassets.Service { func newPluginAssetsWithConfig(pCfg *config.PluginManagementCfg) func() *pluginassets.Service { return func() *pluginassets.Service { - return pluginassets.ProvideService(pCfg, pluginscdn.ProvideService(pCfg), signature.ProvideService(pCfg, statickey.New()), &pluginstore.FakePluginStore{}) + return pluginassets.ProvideService(pCfg, pluginscdn.ProvideService(pCfg), &pluginstore.FakePluginStore{}) } } diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 50e32326565..079dae066bb 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -201,7 +201,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response. Includes: plugin.Includes, BaseUrl: plugin.BaseURL, Module: plugin.Module, - ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), plugin), + ModuleHash: plugin.ModuleHash, DefaultNavUrl: path.Join(hs.Cfg.AppSubURL, plugin.DefaultNavURL), State: plugin.State, Signature: plugin.Signature, diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index 342c6293d7e..238b0b3390f 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -28,8 +28,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/filestore" "github.com/grafana/grafana/pkg/plugins/manager/pluginfakes" "github.com/grafana/grafana/pkg/plugins/manager/registry" - "github.com/grafana/grafana/pkg/plugins/manager/signature" - "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" "github.com/grafana/grafana/pkg/plugins/pluginerrs" "github.com/grafana/grafana/pkg/plugins/pluginscdn" ac "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -848,8 +846,7 @@ func Test_PluginsSettings(t *testing.T) { } pCfg := &config.PluginManagementCfg{} pluginCDN := pluginscdn.ProvideService(pCfg) - sig := signature.ProvideService(pCfg, statickey.New()) - hs.pluginAssets = pluginassets.ProvideService(pCfg, pluginCDN, sig, hs.pluginStore) + hs.pluginAssets = pluginassets.ProvideService(pCfg, pluginCDN, hs.pluginStore) hs.pluginErrorResolver = pluginerrs.ProvideStore(errTracker) hs.pluginsUpdateChecker, err = updatemanager.ProvidePluginsService( hs.Cfg, diff --git a/pkg/plugins/ifaces.go b/pkg/plugins/ifaces.go index 9b719b8b180..1d33d34e6c9 100644 --- a/pkg/plugins/ifaces.go +++ b/pkg/plugins/ifaces.go @@ -140,7 +140,9 @@ type Licensing interface { } type SignatureCalculator interface { - Calculate(ctx context.Context, src PluginSource, plugin FoundPlugin) (Signature, error) + // Calculate calculates the signature and returns both the signature and the manifest. + // The manifest may be nil if the plugin is unsigned or if an error occurred. + Calculate(ctx context.Context, src PluginSource, plugin FoundPlugin) (Signature, *PluginManifest, error) } type KeyStore interface { diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go index 7613392f497..f4c63e71e15 100644 --- a/pkg/plugins/manager/loader/loader_test.go +++ b/pkg/plugins/manager/loader/loader_test.go @@ -216,10 +216,27 @@ func TestLoader_Load(t *testing.T) { ExtensionPoints: []plugins.ExtensionPoint{}, }, }, - Class: plugins.ClassExternal, - Module: "public/plugins/test-app/module.js", - BaseURL: "public/plugins/test-app", - FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/includes-symlinks")), + Class: plugins.ClassExternal, + Module: "public/plugins/test-app/module.js", + BaseURL: "public/plugins/test-app", + FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/includes-symlinks")), + Manifest: &plugins.PluginManifest{ + Plugin: "test-app", + Version: "1.0.0", + KeyID: "7e4d0c6a708866e7", + Time: 1622547655175, + Files: map[string]string{ + "dashboards/connections.json": "bea86da4be970b98dc4681802ab55cdef3441dc3eb3c654cb207948d17b25303", + "dashboards/extra/memory.json": "7c042464941084caa91d0a9a2f188b05315a9796308a652ccdee31ca4fbcbfee", + "plugin.json": "c59a51bf6d7ecd7a99608ccb99353390c8b973672a938a0247164324005c0caf", + "symlink_to_txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048", + "text.txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048", + }, + ManifestVersion: "2.0.0", + SignatureType: plugins.SignatureTypeGrafana, + SignedByOrg: "grafana", + SignedByOrgName: "Grafana Labs", + }, Signature: "valid", SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", diff --git a/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go b/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go index f20c1ff1ead..c129e203dc5 100644 --- a/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go +++ b/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/pluginassets" + "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/plugins/tracing" "github.com/grafana/grafana/pkg/semconv" ) @@ -54,7 +55,7 @@ func New(cfg *config.PluginManagementCfg, opts Opts) *Bootstrap { } if opts.DecorateFuncs == nil { - opts.DecorateFuncs = DefaultDecorateFuncs(cfg) + opts.DecorateFuncs = DefaultDecorateFuncs(cfg, pluginscdn.ProvideService(cfg)) } return &Bootstrap{ diff --git a/pkg/plugins/manager/pipeline/bootstrap/steps.go b/pkg/plugins/manager/pipeline/bootstrap/steps.go index 5c365ebb47c..5ab85dc6e43 100644 --- a/pkg/plugins/manager/pipeline/bootstrap/steps.go +++ b/pkg/plugins/manager/pipeline/bootstrap/steps.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/pluginassets" + "github.com/grafana/grafana/pkg/plugins/pluginscdn" ) // DefaultConstructor implements the default ConstructFunc used for the Construct step of the Bootstrap stage. @@ -28,12 +29,13 @@ func DefaultConstructFunc(cfg *config.PluginManagementCfg, signatureCalculator p } // DefaultDecorateFuncs are the default DecorateFuncs used for the Decorate step of the Bootstrap stage. -func DefaultDecorateFuncs(cfg *config.PluginManagementCfg) []DecorateFunc { +func DefaultDecorateFuncs(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) []DecorateFunc { return []DecorateFunc{ AppDefaultNavURLDecorateFunc, TemplateDecorateFunc, AppChildDecorateFunc(), SkipHostEnvVarsDecorateFunc(cfg), + ModuleHashDecorateFunc(cfg, cdn), } } @@ -48,19 +50,30 @@ func NewDefaultConstructor(cfg *config.PluginManagementCfg, signatureCalculator // Construct will calculate the plugin's signature state and create the plugin using the pluginFactoryFunc. func (c *DefaultConstructor) Construct(ctx context.Context, src plugins.PluginSource, bundle *plugins.FoundBundle) ([]*plugins.Plugin, error) { - sig, err := c.signatureCalculator.Calculate(ctx, src, bundle.Primary) + // Calculate signature and cache manifest + sig, manifest, err := c.signatureCalculator.Calculate(ctx, src, bundle.Primary) if err != nil { c.log.Warn("Could not calculate plugin signature state", "pluginId", bundle.Primary.JSONData.ID, "error", err) return nil, err } + plugin, err := c.pluginFactoryFunc(bundle, src.PluginClass(ctx), sig) if err != nil { c.log.Error("Could not create primary plugin base", "pluginId", bundle.Primary.JSONData.ID, "error", err) return nil, err } + + plugin.Manifest = manifest + res := make([]*plugins.Plugin, 0, len(plugin.Children)+1) res = append(res, plugin) - res = append(res, plugin.Children...) + for _, child := range plugin.Children { + // Child plugins use the parent's manifest + if child.Parent != nil && child.Parent.Manifest != nil { + child.Manifest = child.Parent.Manifest + } + res = append(res, child) + } return res, nil } @@ -145,3 +158,11 @@ func SkipHostEnvVarsDecorateFunc(cfg *config.PluginManagementCfg) DecorateFunc { return p, nil } } + +// ModuleHashDecorateFunc returns a DecorateFunc that calculates and sets the module hash for the plugin. +func ModuleHashDecorateFunc(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) DecorateFunc { + return func(_ context.Context, p *plugins.Plugin) (*plugins.Plugin, error) { + p.ModuleHash = pluginassets.CalculateModuleHash(p, cfg, cdn) + return p, nil + } +} diff --git a/pkg/plugins/manager/signature/manifest.go b/pkg/plugins/manager/signature/manifest.go index 6d790c79873..4b6bc531d89 100644 --- a/pkg/plugins/manager/signature/manifest.go +++ b/pkg/plugins/manager/signature/manifest.go @@ -14,7 +14,6 @@ import ( "path" "path/filepath" "runtime" - "strings" "github.com/ProtonMail/go-crypto/openpgp" "github.com/ProtonMail/go-crypto/openpgp/clearsign" @@ -37,26 +36,6 @@ var ( fromSlash = filepath.FromSlash ) -// PluginManifest holds details for the file manifest -type PluginManifest struct { - Plugin string `json:"plugin"` - Version string `json:"version"` - KeyID string `json:"keyId"` - Time int64 `json:"time"` - Files map[string]string `json:"files"` - - // V2 supported fields - ManifestVersion string `json:"manifestVersion"` - SignatureType plugins.SignatureType `json:"signatureType"` - SignedByOrg string `json:"signedByOrg"` - SignedByOrgName string `json:"signedByOrgName"` - RootURLs []string `json:"rootUrls"` -} - -func (m *PluginManifest) IsV2() bool { - return strings.HasPrefix(m.ManifestVersion, "2.") -} - type Signature struct { kr plugins.KeyRetriever cfg *config.PluginManagementCfg @@ -87,14 +66,14 @@ func DefaultCalculator(cfg *config.PluginManagementCfg) *Signature { // readPluginManifest attempts to read and verify the plugin manifest // if any error occurs or the manifest is not valid, this will return an error -func (s *Signature) readPluginManifest(ctx context.Context, body []byte) (*PluginManifest, error) { +func (s *Signature) readPluginManifest(ctx context.Context, body []byte) (*plugins.PluginManifest, error) { block, _ := clearsign.Decode(body) if block == nil { return nil, errors.New("unable to decode manifest") } // Convert to a well typed object - var manifest PluginManifest + var manifest plugins.PluginManifest err := json.Unmarshal(block.Plaintext, &manifest) if err != nil { return nil, fmt.Errorf("%v: %w", "Error parsing manifest JSON", err) @@ -111,7 +90,7 @@ var ErrSignatureTypeUnsigned = errors.New("plugin is unsigned") // ReadPluginManifestFromFS reads the plugin manifest from the provided plugins.FS. // If the manifest is not found, it will return an error wrapping ErrSignatureTypeUnsigned. -func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS) (*PluginManifest, error) { +func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS) (*plugins.PluginManifest, error) { f, err := pfs.Open("MANIFEST.txt") if err != nil { if errors.Is(err, plugins.ErrFileNotExist) { @@ -140,9 +119,9 @@ func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS return manifest, nil } -func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, error) { +func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, *plugins.PluginManifest, error) { if defaultSignature, exists := src.DefaultSignature(ctx, plugin.JSONData.ID); exists { - return defaultSignature, nil + return defaultSignature, nil, nil } manifest, err := s.ReadPluginManifestFromFS(ctx, plugin.FS) @@ -151,29 +130,29 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu s.log.Warn("Plugin is unsigned", "id", plugin.JSONData.ID, "err", err) return plugins.Signature{ Status: plugins.SignatureStatusUnsigned, - }, nil + }, nil, nil case err != nil: s.log.Warn("Plugin signature is invalid", "id", plugin.JSONData.ID, "err", err) return plugins.Signature{ Status: plugins.SignatureStatusInvalid, - }, nil + }, nil, nil } if !manifest.IsV2() { return plugins.Signature{ Status: plugins.SignatureStatusInvalid, - }, nil + }, nil, nil } fsFiles, err := plugin.FS.Files() if err != nil { - return plugins.Signature{}, fmt.Errorf("files: %w", err) + return plugins.Signature{}, nil, fmt.Errorf("files: %w", err) } if len(fsFiles) == 0 { s.log.Warn("No plugin file information in directory", "pluginId", plugin.JSONData.ID) return plugins.Signature{ Status: plugins.SignatureStatusInvalid, - }, nil + }, nil, nil } // Make sure the versions all match @@ -181,20 +160,20 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu s.log.Debug("Plugin signature invalid because ID or Version mismatch", "pluginId", plugin.JSONData.ID, "manifestPluginId", manifest.Plugin, "pluginVersion", plugin.JSONData.Info.Version, "manifestPluginVersion", manifest.Version) return plugins.Signature{ Status: plugins.SignatureStatusModified, - }, nil + }, nil, nil } // Validate that plugin is running within defined root URLs if len(manifest.RootURLs) > 0 { if match, err := urlMatch(manifest.RootURLs, s.cfg.GrafanaAppURL, manifest.SignatureType); err != nil { s.log.Warn("Could not verify if root URLs match", "plugin", plugin.JSONData.ID, "rootUrls", manifest.RootURLs) - return plugins.Signature{}, err + return plugins.Signature{}, nil, err } else if !match { s.log.Warn("Could not find root URL that matches running application URL", "plugin", plugin.JSONData.ID, "appUrl", s.cfg.GrafanaAppURL, "rootUrls", manifest.RootURLs) return plugins.Signature{ Status: plugins.SignatureStatusInvalid, - }, nil + }, nil, nil } } @@ -207,7 +186,7 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu s.log.Debug("Plugin signature invalid", "pluginId", plugin.JSONData.ID, "error", err) return plugins.Signature{ Status: plugins.SignatureStatusModified, - }, nil + }, nil, nil } manifestFiles[p] = struct{}{} @@ -236,7 +215,7 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu s.log.Warn("The following files were not included in the signature", "plugin", plugin.JSONData.ID, "files", unsignedFiles) return plugins.Signature{ Status: plugins.SignatureStatusModified, - }, nil + }, nil, nil } s.log.Debug("Plugin signature valid", "id", plugin.JSONData.ID) @@ -244,7 +223,7 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu Status: plugins.SignatureStatusValid, Type: manifest.SignatureType, SigningOrg: manifest.SignedByOrgName, - }, nil + }, manifest, nil } func verifyHash(mlog log.Logger, plugin plugins.FoundPlugin, path, hash string) error { @@ -321,7 +300,7 @@ func (r invalidFieldErr) Error() string { return fmt.Sprintf("valid manifest field %s is required", r.field) } -func (s *Signature) validateManifest(ctx context.Context, m PluginManifest, block *clearsign.Block) error { +func (s *Signature) validateManifest(ctx context.Context, m plugins.PluginManifest, block *clearsign.Block) error { if len(m.Plugin) == 0 { return invalidFieldErr{field: "plugin"} } diff --git a/pkg/plugins/manager/signature/manifest_test.go b/pkg/plugins/manager/signature/manifest_test.go index d399268b464..5768f0f32f5 100644 --- a/pkg/plugins/manager/signature/manifest_test.go +++ b/pkg/plugins/manager/signature/manifest_test.go @@ -164,7 +164,7 @@ func TestCalculate(t *testing.T) { for _, tc := range tcs { basePath := filepath.Join(parentDir, "testdata/non-pvt-with-root-url/plugin") s := provideTestServiceWithConfig(&config.PluginManagementCfg{GrafanaAppURL: tc.appURL}) - sig, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{ + sig, _, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassExternal }, @@ -192,7 +192,7 @@ func TestCalculate(t *testing.T) { runningWindows = true s := provideDefaultTestService() - sig, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{ + sig, _, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassExternal }, @@ -260,7 +260,7 @@ func TestCalculate(t *testing.T) { require.NoError(t, err) pfs, err = newPathSeparatorOverrideFS(string(tc.platform.separator), pfs) require.NoError(t, err) - sig, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{ + sig, _, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassExternal }, @@ -396,7 +396,7 @@ func TestFSPathSeparatorFiles(t *testing.T) { } } -func fileList(manifest *PluginManifest) []string { +func fileList(manifest *plugins.PluginManifest) []string { keys := make([]string, 0, len(manifest.Files)) for k := range manifest.Files { keys = append(keys, k) @@ -682,52 +682,52 @@ func Test_urlMatch_private(t *testing.T) { func Test_validateManifest(t *testing.T) { tcs := []struct { name string - manifest *PluginManifest + manifest *plugins.PluginManifest expectedErr string }{ { name: "Empty plugin field", - manifest: createV2Manifest(t, func(m *PluginManifest) { m.Plugin = "" }), + manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Plugin = "" }), expectedErr: "valid manifest field plugin is required", }, { name: "Empty keyId field", - manifest: createV2Manifest(t, func(m *PluginManifest) { m.KeyID = "" }), + manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.KeyID = "" }), expectedErr: "valid manifest field keyId is required", }, { name: "Empty signedByOrg field", - manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignedByOrg = "" }), + manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignedByOrg = "" }), expectedErr: "valid manifest field signedByOrg is required", }, { name: "Empty signedByOrgName field", - manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignedByOrgName = "" }), + manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignedByOrgName = "" }), expectedErr: "valid manifest field SignedByOrgName is required", }, { name: "Empty signatureType field", - manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignatureType = "" }), + manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignatureType = "" }), expectedErr: "valid manifest field signatureType is required", }, { name: "Invalid signatureType field", - manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignatureType = "invalidSignatureType" }), + manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignatureType = "invalidSignatureType" }), expectedErr: "valid manifest field signatureType is required", }, { name: "Empty files field", - manifest: createV2Manifest(t, func(m *PluginManifest) { m.Files = map[string]string{} }), + manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Files = map[string]string{} }), expectedErr: "valid manifest field files is required", }, { name: "Empty time field", - manifest: createV2Manifest(t, func(m *PluginManifest) { m.Time = 0 }), + manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Time = 0 }), expectedErr: "valid manifest field time is required", }, { name: "Empty version field", - manifest: createV2Manifest(t, func(m *PluginManifest) { m.Version = "" }), + manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Version = "" }), expectedErr: "valid manifest field version is required", }, } @@ -740,10 +740,10 @@ func Test_validateManifest(t *testing.T) { } } -func createV2Manifest(t *testing.T, cbs ...func(*PluginManifest)) *PluginManifest { +func createV2Manifest(t *testing.T, cbs ...func(*plugins.PluginManifest)) *plugins.PluginManifest { t.Helper() - m := &PluginManifest{ + m := &plugins.PluginManifest{ Plugin: "grafana-test-app", Version: "2.5.3", KeyID: "7e4d0c6a708866e7", diff --git a/pkg/plugins/pluginassets/modulehash.go b/pkg/plugins/pluginassets/modulehash.go new file mode 100644 index 00000000000..fd1ff13c578 --- /dev/null +++ b/pkg/plugins/pluginassets/modulehash.go @@ -0,0 +1,90 @@ +package pluginassets + +import ( + "encoding/base64" + "encoding/hex" + "path" + "path/filepath" + + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/plugins/pluginscdn" +) + +// CalculateModuleHash calculates the module.js SHA256 hash for a plugin in the format expected by the browser for SRI checks. +// The module hash is read from the plugin's cached manifest. +// For nested plugins, the module hash is read from the root parent plugin's manifest. +// If the plugin is unsigned or not a CDN plugin, an empty string is returned. +func CalculateModuleHash(p *plugins.Plugin, cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) string { + if cfg == nil || !cfg.Features.SriChecksEnabled { + return "" + } + + if !p.Signature.IsValid() { + return "" + } + + rootParent := findRootParent(p) + if rootParent.Manifest == nil { + return "" + } + + if !rootParent.Manifest.IsV2() { + return "" + } + + if !cdnEnabled(rootParent, cdn) { + return "" + } + + modulePath := getModulePathInManifest(p, rootParent) + moduleHash, ok := rootParent.Manifest.Files[modulePath] + if !ok { + return "" + } + + return convertHashForSRI(moduleHash) +} + +// findRootParent returns the root parent plugin (the one that contains the manifest). +// For non-nested plugins, it returns the plugin itself. +func findRootParent(p *plugins.Plugin) *plugins.Plugin { + root := p + for root.Parent != nil { + root = root.Parent + } + return root +} + +// getModulePathInManifest returns the path to module.js as it appears in the manifest. +// For nested plugins, this is the relative path from the root parent to the plugin's module.js. +// For non-nested plugins, this is simply "module.js". +func getModulePathInManifest(p *plugins.Plugin, rootParent *plugins.Plugin) string { + if p == rootParent { + return "module.js" + } + + // Calculate the relative path from root parent to this plugin + relPath, err := rootParent.FS.Rel(p.FS.Base()) + if err != nil { + return "" + } + + // MANIFEST.txt uses forward slashes as path separators + pluginRootPath := filepath.ToSlash(relPath) + return path.Join(pluginRootPath, "module.js") +} + +// convertHashForSRI takes a SHA256 hash string and returns it as expected by the browser for SRI checks. +func convertHashForSRI(h string) string { + hb, err := hex.DecodeString(h) + if err != nil { + return "" + } + return "sha256-" + base64.StdEncoding.EncodeToString(hb) +} + +// cdnEnabled checks if a plugin is loaded via CDN +func cdnEnabled(p *plugins.Plugin, cdn *pluginscdn.Service) bool { + return p.FS.Type().CDN() || cdn.PluginSupported(p.ID) +} diff --git a/pkg/plugins/pluginassets/modulehash_test.go b/pkg/plugins/pluginassets/modulehash_test.go new file mode 100644 index 00000000000..c11fcb7f6a9 --- /dev/null +++ b/pkg/plugins/pluginassets/modulehash_test.go @@ -0,0 +1,356 @@ +package pluginassets + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/plugins/pluginscdn" +) + +func TestConvertHashForSRI(t *testing.T) { + for _, tc := range []struct { + hash string + expHash string + expErr bool + }{ + { + hash: "ddfcb449445064e6c39f0c20b15be3cb6a55837cf4781df23d02de005f436811", + expHash: "sha256-3fy0SURQZObDnwwgsVvjy2pVg3z0eB3yPQLeAF9DaBE=", + }, + { + hash: "not-a-valid-hash", + expErr: true, + }, + } { + t.Run(tc.hash, func(t *testing.T) { + r := convertHashForSRI(tc.hash) + if tc.expErr { + // convertHashForSRI returns empty string on error + require.Empty(t, r) + } else { + require.Equal(t, tc.expHash, r) + } + }) + } +} + +func TestCalculateModuleHash(t *testing.T) { + const ( + pluginID = "grafana-test-datasource" + parentPluginID = "grafana-test-app" + ) + + // Helper to create a plugin with manifest + createPluginWithManifest := func(id string, manifest *plugins.PluginManifest, parent *plugins.Plugin) *plugins.Plugin { + p := &plugins.Plugin{ + JSONData: plugins.JSONData{ + ID: id, + }, + Signature: plugins.SignatureStatusValid, + Manifest: manifest, + } + if parent != nil { + p.Parent = parent + } + return p + } + + // Helper to create a v2 manifest + createV2Manifest := func(files map[string]string) *plugins.PluginManifest { + return &plugins.PluginManifest{ + ManifestVersion: "2.0.0", + Files: files, + } + } + + for _, tc := range []struct { + name string + plugin *plugins.Plugin + cfg *config.PluginManagementCfg + cdn *pluginscdn.Service + expModuleHash string + }{ + { + name: "should return empty string when cfg is nil", + plugin: createPluginWithManifest(pluginID, createV2Manifest(map[string]string{ + "module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03", + }), nil), + cfg: nil, + cdn: nil, + expModuleHash: "", + }, + { + name: "should return empty string when SRI checks are disabled", + plugin: createPluginWithManifest(pluginID, createV2Manifest(map[string]string{ + "module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03", + }), nil), + cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: false}}, + cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}), + expModuleHash: "", + }, + { + name: "should return empty string for unsigned plugin", + plugin: &plugins.Plugin{ + JSONData: plugins.JSONData{ID: pluginID}, + Signature: plugins.SignatureStatusUnsigned, + Manifest: createV2Manifest(map[string]string{"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"}), + FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")), + }, + cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}}, + cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}), + expModuleHash: "", + }, + { + name: "should return module hash for valid plugin", + plugin: &plugins.Plugin{ + JSONData: plugins.JSONData{ID: pluginID}, + Signature: plugins.SignatureStatusValid, + Manifest: createV2Manifest(map[string]string{"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"}), + FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")), + }, + cfg: &config.PluginManagementCfg{ + PluginsCDNURLTemplate: "https://cdn.example.com", + Features: config.Features{SriChecksEnabled: true}, + PluginSettings: config.PluginSettings{ + pluginID: {"cdn": "true"}, + }, + }, + cdn: func() *pluginscdn.Service { + cfg := &config.PluginManagementCfg{ + PluginsCDNURLTemplate: "https://cdn.example.com", + PluginSettings: config.PluginSettings{ + pluginID: {"cdn": "true"}, + }, + } + return pluginscdn.ProvideService(cfg) + }(), + expModuleHash: "sha256-WJG1tSLV3whtD/CxEPvZ0hu0/HFjrzTQgoai6Eb2vgM=", + }, + { + name: "should return empty string when manifest is nil", + plugin: &plugins.Plugin{ + JSONData: plugins.JSONData{ID: pluginID}, + Signature: plugins.SignatureStatusValid, + Manifest: nil, + FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")), + }, + cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}}, + cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}), + expModuleHash: "", + }, + { + name: "should return empty string for v1 manifest", + plugin: &plugins.Plugin{ + JSONData: plugins.JSONData{ID: pluginID}, + Signature: plugins.SignatureStatusValid, + Manifest: &plugins.PluginManifest{ + ManifestVersion: "1.0.0", + Files: map[string]string{"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"}, + }, + FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")), + }, + cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}}, + cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}), + expModuleHash: "", + }, + { + name: "should return empty string when module.js is not in manifest", + plugin: &plugins.Plugin{ + JSONData: plugins.JSONData{ID: pluginID}, + Signature: plugins.SignatureStatusValid, + Manifest: createV2Manifest(map[string]string{"plugin.json": "129fab4e0584d18c778ebdfa5fe1a68edf2e5c5aeb8290b2c68182c857cb59f8"}), + FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")), + }, + cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}}, + cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}), + expModuleHash: "", + }, + { + name: "missing module.js entry from MANIFEST.txt should not return module hash", + plugin: &plugins.Plugin{ + JSONData: plugins.JSONData{ID: pluginID}, + Signature: plugins.SignatureStatusValid, + Manifest: createV2Manifest(map[string]string{"plugin.json": "129fab4e0584d18c778ebdfa5fe1a68edf2e5c5aeb8290b2c68182c857cb59f8"}), + FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-module-js")), + }, + cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}}, + cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}), + expModuleHash: "", + }, + { + name: "signed status but missing MANIFEST.txt should not return module hash", + plugin: &plugins.Plugin{ + JSONData: plugins.JSONData{ID: pluginID}, + Signature: plugins.SignatureStatusValid, + Manifest: nil, + FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-manifest-txt")), + }, + cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}}, + cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}), + expModuleHash: "", + }, + { + // parentPluginID (/) + // └── pluginID (/datasource) + name: "nested plugin should return module hash from parent MANIFEST.txt", + plugin: func() *plugins.Plugin { + parent := &plugins.Plugin{ + JSONData: plugins.JSONData{ID: parentPluginID}, + Signature: plugins.SignatureStatusValid, + Manifest: createV2Manifest(map[string]string{ + "module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a", + "datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711", + }), + FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested")), + } + return &plugins.Plugin{ + JSONData: plugins.JSONData{ID: pluginID}, + Signature: plugins.SignatureStatusValid, + Parent: parent, + FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "datasource")), + } + }(), + cfg: &config.PluginManagementCfg{ + PluginsCDNURLTemplate: "https://cdn.example.com", + Features: config.Features{SriChecksEnabled: true}, + PluginSettings: config.PluginSettings{ + pluginID: {"cdn": "true"}, + parentPluginID: {"cdn": "true"}, + }, + }, + cdn: func() *pluginscdn.Service { + cfg := &config.PluginManagementCfg{ + PluginsCDNURLTemplate: "https://cdn.example.com", + PluginSettings: config.PluginSettings{ + pluginID: {"cdn": "true"}, + parentPluginID: {"cdn": "true"}, + }, + } + return pluginscdn.ProvideService(cfg) + }(), + expModuleHash: "sha256-BNcNsJHZbEd1+zK6Wo+EzCKJPrQ6/bZJcmZh1EJcZxE=", + }, + { + // parentPluginID (/) + // └── pluginID (/panels/one) + name: "nested plugin deeper than one subfolder should return module hash from parent MANIFEST.txt", + plugin: func() *plugins.Plugin { + parent := &plugins.Plugin{ + JSONData: plugins.JSONData{ID: parentPluginID}, + Signature: plugins.SignatureStatusValid, + Manifest: createV2Manifest(map[string]string{ + "module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a", + "panels/one/module.js": "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f", + "datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711", + }), + FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested")), + } + return &plugins.Plugin{ + JSONData: plugins.JSONData{ID: pluginID}, + Signature: plugins.SignatureStatusValid, + Parent: parent, + FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one")), + } + }(), + cfg: &config.PluginManagementCfg{ + PluginsCDNURLTemplate: "https://cdn.example.com", + Features: config.Features{SriChecksEnabled: true}, + PluginSettings: config.PluginSettings{ + pluginID: {"cdn": "true"}, + parentPluginID: {"cdn": "true"}, + }, + }, + cdn: func() *pluginscdn.Service { + cfg := &config.PluginManagementCfg{ + PluginsCDNURLTemplate: "https://cdn.example.com", + PluginSettings: config.PluginSettings{ + pluginID: {"cdn": "true"}, + parentPluginID: {"cdn": "true"}, + }, + } + return pluginscdn.ProvideService(cfg) + }(), + expModuleHash: "sha256-y9GsIoRkWg4emocipyn1vN0rgxIicocJxjYL7s3WFD8=", + }, + { + // grand-parent-app (/) + // ├── parent-datasource (/datasource) + // │ └── child-panel (/datasource/panels/one) + name: "nested plugin of a nested plugin should return module hash from grandparent MANIFEST.txt", + plugin: func() *plugins.Plugin { + grandparent := &plugins.Plugin{ + JSONData: plugins.JSONData{ID: "grand-parent-app"}, + Signature: plugins.SignatureStatusValid, + Manifest: createV2Manifest(map[string]string{ + "module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a", + "datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711", + "datasource/panels/one/module.js": "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f", + }), + FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested")), + } + parent := &plugins.Plugin{ + JSONData: plugins.JSONData{ID: "parent-datasource"}, + Signature: plugins.SignatureStatusValid, + Parent: grandparent, + FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource")), + } + return &plugins.Plugin{ + JSONData: plugins.JSONData{ID: "child-panel"}, + Signature: plugins.SignatureStatusValid, + Parent: parent, + FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource", "panels", "one")), + } + }(), + cfg: &config.PluginManagementCfg{ + PluginsCDNURLTemplate: "https://cdn.example.com", + Features: config.Features{SriChecksEnabled: true}, + PluginSettings: config.PluginSettings{ + "child-panel": {"cdn": "true"}, + "parent-datasource": {"cdn": "true"}, + "grand-parent-app": {"cdn": "true"}, + }, + }, + cdn: func() *pluginscdn.Service { + cfg := &config.PluginManagementCfg{ + PluginsCDNURLTemplate: "https://cdn.example.com", + PluginSettings: config.PluginSettings{ + "child-panel": {"cdn": "true"}, + "parent-datasource": {"cdn": "true"}, + "grand-parent-app": {"cdn": "true"}, + }, + } + return pluginscdn.ProvideService(cfg) + }(), + expModuleHash: "sha256-y9GsIoRkWg4emocipyn1vN0rgxIicocJxjYL7s3WFD8=", + }, + { + name: "nested plugin should not return module hash when parent manifest is nil", + plugin: func() *plugins.Plugin { + parent := &plugins.Plugin{ + JSONData: plugins.JSONData{ID: parentPluginID}, + Signature: plugins.SignatureStatusValid, + Manifest: nil, // Parent has no manifest + FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested")), + } + return &plugins.Plugin{ + JSONData: plugins.JSONData{ID: pluginID}, + Signature: plugins.SignatureStatusValid, + Parent: parent, + FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one")), + } + }(), + cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}}, + cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}), + expModuleHash: "", + }, + } { + t.Run(tc.name, func(t *testing.T) { + result := CalculateModuleHash(tc.plugin, tc.cfg, tc.cdn) + require.Equal(t, tc.expModuleHash, result) + }) + } +} diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-manifest-txt/module.js b/pkg/plugins/pluginassets/testdata/module-hash-no-manifest-txt/module.js similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-manifest-txt/module.js rename to pkg/plugins/pluginassets/testdata/module-hash-no-manifest-txt/module.js diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-manifest-txt/plugin.json b/pkg/plugins/pluginassets/testdata/module-hash-no-manifest-txt/plugin.json similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-manifest-txt/plugin.json rename to pkg/plugins/pluginassets/testdata/module-hash-no-manifest-txt/plugin.json diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/MANIFEST.txt b/pkg/plugins/pluginassets/testdata/module-hash-no-module-js/MANIFEST.txt similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/MANIFEST.txt rename to pkg/plugins/pluginassets/testdata/module-hash-no-module-js/MANIFEST.txt diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/plugin.json b/pkg/plugins/pluginassets/testdata/module-hash-no-module-js/plugin.json similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/plugin.json rename to pkg/plugins/pluginassets/testdata/module-hash-no-module-js/plugin.json diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/something.js b/pkg/plugins/pluginassets/testdata/module-hash-no-module-js/something.js similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/something.js rename to pkg/plugins/pluginassets/testdata/module-hash-no-module-js/something.js diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/MANIFEST.txt b/pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/MANIFEST.txt similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/MANIFEST.txt rename to pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/MANIFEST.txt diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/module.js b/pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/module.js similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/module.js rename to pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/module.js diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/module.js b/pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/module.js similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/module.js rename to pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/module.js diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/plugin.json b/pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/plugin.json similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/plugin.json rename to pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/plugin.json diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/plugin.json b/pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/plugin.json similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/plugin.json rename to pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/plugin.json diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/module.js b/pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/module.js similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/module.js rename to pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/module.js diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/plugin.json b/pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/plugin.json similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/plugin.json rename to pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/plugin.json diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/MANIFEST.txt b/pkg/plugins/pluginassets/testdata/module-hash-valid-nested/MANIFEST.txt similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/MANIFEST.txt rename to pkg/plugins/pluginassets/testdata/module-hash-valid-nested/MANIFEST.txt diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/datasource/module.js b/pkg/plugins/pluginassets/testdata/module-hash-valid-nested/datasource/module.js similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/datasource/module.js rename to pkg/plugins/pluginassets/testdata/module-hash-valid-nested/datasource/module.js diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/datasource/plugin.json b/pkg/plugins/pluginassets/testdata/module-hash-valid-nested/datasource/plugin.json similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/datasource/plugin.json rename to pkg/plugins/pluginassets/testdata/module-hash-valid-nested/datasource/plugin.json diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/module.js b/pkg/plugins/pluginassets/testdata/module-hash-valid-nested/module.js similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/module.js rename to pkg/plugins/pluginassets/testdata/module-hash-valid-nested/module.js diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/panels/one/module.js b/pkg/plugins/pluginassets/testdata/module-hash-valid-nested/panels/one/module.js similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/panels/one/module.js rename to pkg/plugins/pluginassets/testdata/module-hash-valid-nested/panels/one/module.js diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/panels/one/plugin.json b/pkg/plugins/pluginassets/testdata/module-hash-valid-nested/panels/one/plugin.json similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/panels/one/plugin.json rename to pkg/plugins/pluginassets/testdata/module-hash-valid-nested/panels/one/plugin.json diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/plugin.json b/pkg/plugins/pluginassets/testdata/module-hash-valid-nested/plugin.json similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/plugin.json rename to pkg/plugins/pluginassets/testdata/module-hash-valid-nested/plugin.json diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/MANIFEST.txt b/pkg/plugins/pluginassets/testdata/module-hash-valid/MANIFEST.txt similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/MANIFEST.txt rename to pkg/plugins/pluginassets/testdata/module-hash-valid/MANIFEST.txt diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/module.js b/pkg/plugins/pluginassets/testdata/module-hash-valid/module.js similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/module.js rename to pkg/plugins/pluginassets/testdata/module-hash-valid/module.js diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/plugin.json b/pkg/plugins/pluginassets/testdata/module-hash-valid/plugin.json similarity index 100% rename from pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/plugin.json rename to pkg/plugins/pluginassets/testdata/module-hash-valid/plugin.json diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index bf1b23a35b0..e1c26b4f87d 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -40,6 +40,7 @@ type Plugin struct { Pinned bool // Signature fields + Manifest *PluginManifest Signature SignatureStatus SignatureType SignatureType SignatureOrg string @@ -48,8 +49,9 @@ type Plugin struct { Error *Error // SystemJS fields - Module string - BaseURL string + Module string + ModuleHash string + BaseURL string Angular AngularMeta @@ -532,3 +534,24 @@ func (pt Type) IsValid() bool { } return false } + +// PluginManifest holds details for the file manifest +type PluginManifest struct { + Plugin string `json:"plugin"` + Version string `json:"version"` + KeyID string `json:"keyId"` + Time int64 `json:"time"` + Files map[string]string `json:"files"` + + // V2 supported fields + ManifestVersion string `json:"manifestVersion"` + SignatureType SignatureType `json:"signatureType"` + SignedByOrg string `json:"signedByOrg"` + SignedByOrgName string `json:"signedByOrgName"` + RootURLs []string `json:"rootUrls"` +} + +// IsV2 returns true if the manifest is version 2.x +func (m *PluginManifest) IsV2() bool { + return strings.HasPrefix(m.ManifestVersion, "2.") +} diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 218e9fabc36..5b4f3bed5bb 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -376,7 +376,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api keyretrieverService := keyretriever.ProvideService(keyRetriever) signatureSignature := signature.ProvideService(pluginManagementCfg, keyretrieverService) localProvider := pluginassets.NewLocalProvider() - bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider) + pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg) + bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider, pluginscdnService) unsignedPluginAuthorizer := signature.ProvideOSSAuthorizer(pluginManagementCfg) validation := signature.ProvideValidatorService(unsignedPluginAuthorizer) angularpatternsstoreService := angularpatternsstore.ProvideService(kvStore) @@ -714,8 +715,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg) - pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, signatureSignature, pluginstoreService) + pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, pluginstoreService) avatarCacheServer := avatar.ProvideAvatarCacheServer(cfg) prefService := prefimpl.ProvideService(sqlStore, cfg) dashboardPermissionsService, err := ossaccesscontrol.ProvideDashboardPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, dashboardService, folderimplService, acimplService, teamService, userService, actionSetService, dashboardServiceImpl, eventualRestConfigProvider) @@ -1042,7 +1042,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac keyretrieverService := keyretriever.ProvideService(keyRetriever) signatureSignature := signature.ProvideService(pluginManagementCfg, keyretrieverService) localProvider := pluginassets.NewLocalProvider() - bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider) + pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg) + bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider, pluginscdnService) unsignedPluginAuthorizer := signature.ProvideOSSAuthorizer(pluginManagementCfg) validation := signature.ProvideValidatorService(unsignedPluginAuthorizer) angularpatternsstoreService := angularpatternsstore.ProvideService(kvStore) @@ -1382,8 +1383,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg) - pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, signatureSignature, pluginstoreService) + pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, pluginstoreService) avatarCacheServer := avatar.ProvideAvatarCacheServer(cfg) prefService := prefimpl.ProvideService(sqlStore, cfg) dashboardPermissionsService, err := ossaccesscontrol.ProvideDashboardPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, dashboardService, folderimplService, acimplService, teamService, userService, actionSetService, dashboardServiceImpl, eventualRestConfigProvider) diff --git a/pkg/services/pluginsintegration/loader/loader_test.go b/pkg/services/pluginsintegration/loader/loader_test.go index a5a7a7fd8db..eee04acedfc 100644 --- a/pkg/services/pluginsintegration/loader/loader_test.go +++ b/pkg/services/pluginsintegration/loader/loader_test.go @@ -26,6 +26,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/plugins/pluginassets" "github.com/grafana/grafana/pkg/plugins/pluginerrs" + "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" "github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins" @@ -213,10 +214,27 @@ func TestLoader_Load(t *testing.T) { ExtensionPoints: []plugins.ExtensionPoint{}, }, }, - Class: plugins.ClassExternal, - Module: "public/plugins/test-app/module.js", - BaseURL: "public/plugins/test-app", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "includes-symlinks")), + Class: plugins.ClassExternal, + Module: "public/plugins/test-app/module.js", + BaseURL: "public/plugins/test-app", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "includes-symlinks")), + Manifest: &plugins.PluginManifest{ + Plugin: "test-app", + Version: "1.0.0", + KeyID: "7e4d0c6a708866e7", + Time: 1622547655175, + Files: map[string]string{ + "dashboards/connections.json": "bea86da4be970b98dc4681802ab55cdef3441dc3eb3c654cb207948d17b25303", + "dashboards/extra/memory.json": "7c042464941084caa91d0a9a2f188b05315a9796308a652ccdee31ca4fbcbfee", + "plugin.json": "c59a51bf6d7ecd7a99608ccb99353390c8b973672a938a0247164324005c0caf", + "symlink_to_txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048", + "text.txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048", + }, + ManifestVersion: "2.0.0", + SignatureType: plugins.SignatureTypeGrafana, + SignedByOrg: "grafana", + SignedByOrgName: "Grafana Labs", + }, Signature: "valid", SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", @@ -647,10 +665,24 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) { Executable: "test", State: plugins.ReleaseStateAlpha, }, - Class: plugins.ClassExternal, - Module: "public/plugins/test-datasource/module.js", - BaseURL: "public/plugins/test-datasource", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature/plugin")), + Class: plugins.ClassExternal, + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature/plugin")), + Manifest: &plugins.PluginManifest{ + Plugin: "test-datasource", + Version: "1.0.0", + KeyID: "7e4d0c6a708866e7", + Time: 1661171417046, + Files: map[string]string{ + "plugin.json": "203ef4a613c5693c437a665cd67f95e2756a0f71b336b2ffb265db7c180d0b19", + }, + ManifestVersion: "2.0.0", + SignatureType: plugins.SignatureTypePrivate, + SignedByOrg: "willbrowne", + SignedByOrgName: "Will Browne", + RootURLs: []string{"http://localhost:3000/"}, + }, Signature: "valid", SignatureType: plugins.SignatureTypePrivate, SignatureOrg: "Will Browne", @@ -767,8 +799,22 @@ func TestLoader_Load_RBACReady(t *testing.T) { }, Backend: false, }, - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-roles")), - Class: plugins.ClassExternal, + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-roles")), + Class: plugins.ClassExternal, + Manifest: &plugins.PluginManifest{ + Plugin: "test-app", + Version: "1.0.0", + KeyID: "7e4d0c6a708866e7", + Time: 1667484928676, + Files: map[string]string{ + "plugin.json": "3348335ec100392b325f3eeb882a07c729e9cbf0f1ae331239f46840bb1a01eb", + }, + ManifestVersion: "2.0.0", + SignatureType: plugins.SignatureTypePrivate, + SignedByOrg: "gabrielmabille", + SignedByOrgName: "gabrielmabille", + RootURLs: []string{"http://localhost:3000/"}, + }, Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypePrivate, SignatureOrg: "gabrielmabille", @@ -837,8 +883,22 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) { Backend: true, Executable: "test", }, - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature-root-url-uri/plugin")), - Class: plugins.ClassExternal, + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature-root-url-uri/plugin")), + Class: plugins.ClassExternal, + Manifest: &plugins.PluginManifest{ + Plugin: "test-datasource", + Version: "1.0.0", + KeyID: "7e4d0c6a708866e7", + Time: 1661171981629, + Files: map[string]string{ + "plugin.json": "203ef4a613c5693c437a665cd67f95e2756a0f71b336b2ffb265db7c180d0b19", + }, + ManifestVersion: "2.0.0", + SignatureType: plugins.SignatureTypePrivate, + SignedByOrg: "willbrowne", + SignedByOrgName: "Will Browne", + RootURLs: []string{"http://localhost:3000/grafana"}, + }, Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypePrivate, SignatureOrg: "Will Browne", @@ -925,8 +985,24 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) { }, Backend: false, }, - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app")), - Class: plugins.ClassExternal, + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app")), + Class: plugins.ClassExternal, + Manifest: &plugins.PluginManifest{ + Plugin: "test-app", + Version: "1.0.0", + KeyID: "7e4d0c6a708866e7", + Time: 1621356785895, + Files: map[string]string{ + "plugin.json": "c59a51bf6d7ecd7a99608ccb99353390c8b973672a938a0247164324005c0caf", + "dashboards/connections.json": "bea86da4be970b98dc4681802ab55cdef3441dc3eb3c654cb207948d17b25303", + "dashboards/memory.json": "7c042464941084caa91d0a9a2f188b05315a9796308a652ccdee31ca4fbcbfee", + "dashboards/connections_result.json": "124d85c9c2e40214b83273f764574937a79909cfac3f925276fbb72543c224dc", + }, + ManifestVersion: "2.0.0", + SignatureType: plugins.SignatureTypeGrafana, + SignedByOrg: "grafana", + SignedByOrgName: "Grafana Labs", + }, Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", @@ -1017,8 +1093,24 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) { }, Backend: false, }, - FS: mustNewStaticFSForTests(t, pluginDir1), - Class: plugins.ClassExternal, + FS: mustNewStaticFSForTests(t, pluginDir1), + Class: plugins.ClassExternal, + Manifest: &plugins.PluginManifest{ + Plugin: "test-app", + Version: "1.0.0", + KeyID: "7e4d0c6a708866e7", + Time: 1621356785895, + Files: map[string]string{ + "plugin.json": "c59a51bf6d7ecd7a99608ccb99353390c8b973672a938a0247164324005c0caf", + "dashboards/connections.json": "bea86da4be970b98dc4681802ab55cdef3441dc3eb3c654cb207948d17b25303", + "dashboards/memory.json": "7c042464941084caa91d0a9a2f188b05315a9796308a652ccdee31ca4fbcbfee", + "dashboards/connections_result.json": "124d85c9c2e40214b83273f764574937a79909cfac3f925276fbb72543c224dc", + }, + ManifestVersion: "2.0.0", + SignatureType: plugins.SignatureTypeGrafana, + SignedByOrg: "grafana", + SignedByOrgName: "Grafana Labs", + }, Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", @@ -1180,9 +1272,23 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { }, Backend: true, }, - Module: "public/plugins/test-datasource/module.js", - BaseURL: "public/plugins/test-datasource", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent")), + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent")), + Manifest: &plugins.PluginManifest{ + Plugin: "test-datasource", + Version: "1.0.0", + KeyID: "7e4d0c6a708866e7", + Time: 1661172777367, + Files: map[string]string{ + "plugin.json": "a029469ace740e9502bfb0d40924d1cccae73d0b18adcd8f1ceb7f17bf36beb8", + "nested/plugin.json": "e64abd35cd211e0e4682974ad5cdd1be7a0b7cd24951d302a16d9e2cb6cefea4", + }, + ManifestVersion: "2.0.0", + SignatureType: plugins.SignatureTypeGrafana, + SignedByOrg: "grafana", + SignedByOrgName: "Grafana Labs", + }, Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", @@ -1225,9 +1331,23 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { ExtensionPoints: []plugins.ExtensionPoint{}, }, }, - Module: "public/plugins/test-panel/module.js", - BaseURL: "public/plugins/test-panel", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent/nested")), + Module: "public/plugins/test-panel/module.js", + BaseURL: "public/plugins/test-panel", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent/nested")), + Manifest: &plugins.PluginManifest{ + Plugin: "test-datasource", + Version: "1.0.0", + KeyID: "7e4d0c6a708866e7", + Time: 1661172777367, + Files: map[string]string{ + "plugin.json": "a029469ace740e9502bfb0d40924d1cccae73d0b18adcd8f1ceb7f17bf36beb8", + "nested/plugin.json": "e64abd35cd211e0e4682974ad5cdd1be7a0b7cd24951d302a16d9e2cb6cefea4", + }, + ManifestVersion: "2.0.0", + SignatureType: plugins.SignatureTypeGrafana, + SignedByOrg: "grafana", + SignedByOrgName: "Grafana Labs", + }, Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", @@ -1375,10 +1495,25 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { }, Backend: false, }, - Module: "public/plugins/myorgid-simple-app/module.js", - BaseURL: "public/plugins/myorgid-simple-app", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist")), - DefaultNavURL: "/plugins/myorgid-simple-app/page/root-page-react", + Module: "public/plugins/myorgid-simple-app/module.js", + BaseURL: "public/plugins/myorgid-simple-app", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist")), + DefaultNavURL: "/plugins/myorgid-simple-app/page/root-page-react", + Manifest: &plugins.PluginManifest{ + Plugin: "myorgid-simple-app", + Version: "%VERSION%", + KeyID: "7e4d0c6a708866e7", + Time: 1642614241713, + Files: map[string]string{ + "plugin.json": "1abecfd0229814f6c284ff3c8dd744548f8d676ab3250cd7902c99dabf11480e", + "child/plugin.json": "66ba0dffaf3b1bfa17eb9a8672918fc66d1001f465b1061f4fc19c2f2c100f51", + }, + ManifestVersion: "2.0.0", + SignatureType: plugins.SignatureTypeGrafana, + SignedByOrg: "grafana", + SignedByOrgName: "Grafana Labs", + RootURLs: []string{}, + }, Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", @@ -1431,6 +1566,21 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { BaseURL: "public/plugins/myorgid-simple-panel", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist/child")), IncludedInAppID: parent.ID, + Manifest: &plugins.PluginManifest{ + Plugin: "myorgid-simple-app", + Version: "%VERSION%", + KeyID: "7e4d0c6a708866e7", + Time: 1642614241713, + Files: map[string]string{ + "plugin.json": "1abecfd0229814f6c284ff3c8dd744548f8d676ab3250cd7902c99dabf11480e", + "child/plugin.json": "66ba0dffaf3b1bfa17eb9a8672918fc66d1001f465b1061f4fc19c2f2c100f51", + }, + ManifestVersion: "2.0.0", + SignatureType: plugins.SignatureTypeGrafana, + SignedByOrg: "grafana", + SignedByOrgName: "Grafana Labs", + RootURLs: []string{}, + }, Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", @@ -1484,7 +1634,7 @@ func newLoader(t *testing.T, cfg *config.PluginManagementCfg, reg registry.Servi require.NoError(t, err) return ProvideService(cfg, pipeline.ProvideDiscoveryStage(cfg, reg), - pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginAssetsProvider), + pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginAssetsProvider, pluginscdn.ProvideService(cfg)), pipeline.ProvideValidationStage(cfg, signature.NewValidator(signature.NewUnsignedAuthorizer(cfg)), angularInspector), pipeline.ProvideInitializationStage(cfg, reg, backendFactory, proc, &pluginfakes.FakeAuthService{}, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), pluginfakes.NewFakePluginEnvProvider(), tracing.InitializeTracerForTest(), provisionedplugins.NewNoop()), terminate, errTracker) @@ -1514,7 +1664,7 @@ func newLoaderWithOpts(t *testing.T, cfg *config.PluginManagementCfg, opts loade } return ProvideService(cfg, pipeline.ProvideDiscoveryStage(cfg, reg), - pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginassets.NewLocalProvider()), + pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginassets.NewLocalProvider(), pluginscdn.ProvideService(cfg)), pipeline.ProvideValidationStage(cfg, signature.NewValidator(signature.NewUnsignedAuthorizer(cfg)), angularInspector), pipeline.ProvideInitializationStage(cfg, reg, backendFactoryProvider, proc, authServiceRegistry, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), pluginfakes.NewFakePluginEnvProvider(), tracing.InitializeTracerForTest(), provisionedplugins.NewNoop()), terminate, errTracker) diff --git a/pkg/services/pluginsintegration/pipeline/pipeline.go b/pkg/services/pluginsintegration/pipeline/pipeline.go index f377e9eb867..94b5331ac00 100644 --- a/pkg/services/pluginsintegration/pipeline/pipeline.go +++ b/pkg/services/pluginsintegration/pipeline/pipeline.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/pluginassets" + "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/services/pluginsintegration/coreplugin" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" "github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins" @@ -42,7 +43,7 @@ func ProvideDiscoveryStage(cfg *config.PluginManagementCfg, pr registry.Service) }) } -func ProvideBootstrapStage(cfg *config.PluginManagementCfg, sc plugins.SignatureCalculator, ap pluginassets.Provider) *bootstrap.Bootstrap { +func ProvideBootstrapStage(cfg *config.PluginManagementCfg, sc plugins.SignatureCalculator, ap pluginassets.Provider, cdn *pluginscdn.Service) *bootstrap.Bootstrap { disableAlertingForTempoDecorateFunc := func(ctx context.Context, p *plugins.Plugin) (*plugins.Plugin, error) { if p.ID == coreplugin.Tempo && !cfg.Features.TempoAlertingEnabled { p.Alerting = false @@ -52,7 +53,7 @@ func ProvideBootstrapStage(cfg *config.PluginManagementCfg, sc plugins.Signature return bootstrap.New(cfg, bootstrap.Opts{ ConstructFunc: bootstrap.DefaultConstructFunc(cfg, sc, ap), - DecorateFuncs: append(bootstrap.DefaultDecorateFuncs(cfg), disableAlertingForTempoDecorateFunc), + DecorateFuncs: append(bootstrap.DefaultDecorateFuncs(cfg, cdn), disableAlertingForTempoDecorateFunc), }) } diff --git a/pkg/services/pluginsintegration/pluginassets/pluginassets.go b/pkg/services/pluginsintegration/pluginassets/pluginassets.go index 4d9a7ec1a53..8735f7a4354 100644 --- a/pkg/services/pluginsintegration/pluginassets/pluginassets.go +++ b/pkg/services/pluginsintegration/pluginassets/pluginassets.go @@ -2,19 +2,12 @@ package pluginassets import ( "context" - "encoding/base64" - "encoding/hex" - "fmt" - "path" - "path/filepath" - "sync" "github.com/Masterminds/semver/v3" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" - "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" ) @@ -28,24 +21,20 @@ var ( scriptLoadingMinSupportedVersion = semver.MustParse(CreatePluginVersionScriptSupportEnabled) ) -func ProvideService(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service, sig *signature.Signature, store pluginstore.Store) *Service { +func ProvideService(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service, store pluginstore.Store) *Service { return &Service{ - cfg: cfg, - cdn: cdn, - signature: sig, - store: store, - log: log.New("pluginassets"), + cfg: cfg, + cdn: cdn, + store: store, + log: log.New("pluginassets"), } } type Service struct { - cfg *config.PluginManagementCfg - cdn *pluginscdn.Service - signature *signature.Signature - store pluginstore.Store - log log.Logger - - moduleHashCache sync.Map + cfg *config.PluginManagementCfg + cdn *pluginscdn.Service + store pluginstore.Store + log log.Logger } // LoadingStrategy calculates the loading strategy for a plugin. @@ -82,95 +71,6 @@ func (s *Service) LoadingStrategy(_ context.Context, p pluginstore.Plugin) plugi return plugins.LoadingStrategyFetch } -// ModuleHash returns the module.js SHA256 hash for a plugin in the format expected by the browser for SRI checks. -// The module hash is read from the plugin's MANIFEST.txt file. -// The plugin can also be a nested plugin. -// If the plugin is unsigned, an empty string is returned. -// The results are cached to avoid repeated reads from the MANIFEST.txt file. -func (s *Service) ModuleHash(ctx context.Context, p pluginstore.Plugin) string { - k := s.moduleHashCacheKey(p) - cachedValue, ok := s.moduleHashCache.Load(k) - if ok { - return cachedValue.(string) - } - mh, err := s.moduleHash(ctx, p, "") - if err != nil { - s.log.Error("Failed to calculate module hash", "plugin", p.ID, "error", err) - } - s.moduleHashCache.Store(k, mh) - return mh -} - -// moduleHash is the underlying function for ModuleHash. See its documentation for more information. -// If the plugin is not a CDN plugin, the function will return an empty string. -// It will read the module hash from the MANIFEST.txt in the [[plugins.FS]] of the provided plugin. -// If childFSBase is provided, the function will try to get the hash from MANIFEST.txt for the provided children's -// module.js file, rather than for the provided plugin. -func (s *Service) moduleHash(ctx context.Context, p pluginstore.Plugin, childFSBase string) (r string, err error) { - if !s.cfg.Features.SriChecksEnabled { - return "", nil - } - - // Ignore unsigned plugins - if !p.Signature.IsValid() { - return "", nil - } - - if p.Parent != nil { - // Nested plugin - parent, ok := s.store.Plugin(ctx, p.Parent.ID) - if !ok { - return "", fmt.Errorf("parent plugin plugin %q for child plugin %q not found", p.Parent.ID, p.ID) - } - - // The module hash is contained within the parent's MANIFEST.txt file. - // For example, the parent's MANIFEST.txt will contain an entry similar to this: - // - // ``` - // "datasource/module.js": "1234567890abcdef..." - // ``` - // - // Recursively call moduleHash with the parent plugin and with the children plugin folder path - // to get the correct module hash for the nested plugin. - if childFSBase == "" { - childFSBase = p.Base() - } - return s.moduleHash(ctx, parent, childFSBase) - } - - // Only CDN plugins are supported for SRI checks. - // CDN plugins have the version as part of the URL, which acts as a cache-buster. - // Needed due to: https://github.com/grafana/plugin-tools/pull/1426 - // FS plugins build before this change will have SRI mismatch issues. - if !s.cdnEnabled(p.ID, p.FS) { - return "", nil - } - - manifest, err := s.signature.ReadPluginManifestFromFS(ctx, p.FS) - if err != nil { - return "", fmt.Errorf("read plugin manifest: %w", err) - } - if !manifest.IsV2() { - return "", nil - } - - var childPath string - if childFSBase != "" { - // Calculate the relative path of the child plugin folder from the parent plugin folder. - childPath, err = p.FS.Rel(childFSBase) - if err != nil { - return "", fmt.Errorf("rel path: %w", err) - } - // MANIFETS.txt uses forward slashes as path separators. - childPath = filepath.ToSlash(childPath) - } - moduleHash, ok := manifest.Files[path.Join(childPath, "module.js")] - if !ok { - return "", nil - } - return convertHashForSRI(moduleHash) -} - func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool { if cpv, ok := ps[CreatePluginVersionCfgKey]; ok { createPluginVer, err := semver.NewVersion(cpv) @@ -188,17 +88,3 @@ func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool { func (s *Service) cdnEnabled(pluginID string, fs plugins.FS) bool { return s.cdn.PluginSupported(pluginID) || fs.Type().CDN() } - -// convertHashForSRI takes a SHA256 hash string and returns it as expected by the browser for SRI checks. -func convertHashForSRI(h string) (string, error) { - hb, err := hex.DecodeString(h) - if err != nil { - return "", fmt.Errorf("hex decode string: %w", err) - } - return "sha256-" + base64.StdEncoding.EncodeToString(hb), nil -} - -// moduleHashCacheKey returns a unique key for the module hash cache. -func (s *Service) moduleHashCacheKey(p pluginstore.Plugin) string { - return p.ID + ":" + p.Info.Version -} diff --git a/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go b/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go index 192717c34ff..91b8b515bb1 100644 --- a/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go +++ b/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go @@ -2,19 +2,14 @@ package pluginassets import ( "context" - "fmt" - "path/filepath" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/manager/pluginfakes" - "github.com/grafana/grafana/pkg/plugins/manager/signature" - "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" ) @@ -179,349 +174,6 @@ func TestService_Calculate(t *testing.T) { } } -func TestService_ModuleHash(t *testing.T) { - const ( - pluginID = "grafana-test-datasource" - parentPluginID = "grafana-test-app" - ) - for _, tc := range []struct { - name string - features *config.Features - store []pluginstore.Plugin - - // Can be used to configure plugin's fs - // fs cdn type = loaded from CDN with no files on disk - // fs local type = files on disk but served from CDN only if cdn=true - plugin pluginstore.Plugin - - // When true, set cdn=true in config - cdn bool - expModuleHash string - }{ - { - name: "unsigned should not return module hash", - plugin: newPlugin(pluginID, withSignatureStatus(plugins.SignatureStatusUnsigned)), - cdn: false, - features: &config.Features{SriChecksEnabled: false}, - expModuleHash: "", - }, - { - plugin: newPlugin( - pluginID, - withSignatureStatus(plugins.SignatureStatusValid), - withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))), - withClass(plugins.ClassExternal), - ), - cdn: true, - features: &config.Features{SriChecksEnabled: true}, - expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"), - }, - { - plugin: newPlugin( - pluginID, - withSignatureStatus(plugins.SignatureStatusValid), - withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))), - withClass(plugins.ClassExternal), - ), - cdn: true, - features: &config.Features{SriChecksEnabled: true}, - expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"), - }, - { - plugin: newPlugin( - pluginID, - withSignatureStatus(plugins.SignatureStatusValid), - withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))), - ), - cdn: false, - features: &config.Features{SriChecksEnabled: true}, - expModuleHash: "", - }, - { - plugin: newPlugin( - pluginID, - withSignatureStatus(plugins.SignatureStatusValid), - withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))), - ), - cdn: true, - features: &config.Features{SriChecksEnabled: false}, - expModuleHash: "", - }, - { - plugin: newPlugin( - pluginID, - withSignatureStatus(plugins.SignatureStatusValid), - withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))), - ), - cdn: false, - features: &config.Features{SriChecksEnabled: false}, - expModuleHash: "", - }, - { - // parentPluginID (/) - // └── pluginID (/datasource) - name: "nested plugin should return module hash from parent MANIFEST.txt", - store: []pluginstore.Plugin{ - newPlugin( - parentPluginID, - withSignatureStatus(plugins.SignatureStatusValid), - withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))), - ), - }, - plugin: newPlugin( - pluginID, - withSignatureStatus(plugins.SignatureStatusValid), - withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "datasource"))), - withParent(parentPluginID), - ), - cdn: true, - features: &config.Features{SriChecksEnabled: true}, - expModuleHash: newSRIHash(t, "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711"), - }, - { - // parentPluginID (/) - // └── pluginID (/panels/one) - name: "nested plugin deeper than one subfolder should return module hash from parent MANIFEST.txt", - store: []pluginstore.Plugin{ - newPlugin( - parentPluginID, - withSignatureStatus(plugins.SignatureStatusValid), - withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))), - ), - }, - plugin: newPlugin( - pluginID, - withSignatureStatus(plugins.SignatureStatusValid), - withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one"))), - withParent(parentPluginID), - ), - cdn: true, - features: &config.Features{SriChecksEnabled: true}, - expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"), - }, - { - // grand-parent-app (/) - // ├── parent-datasource (/datasource) - // │ └── child-panel (/datasource/panels/one) - name: "nested plugin of a nested plugin should return module hash from parent MANIFEST.txt", - store: []pluginstore.Plugin{ - newPlugin( - "grand-parent-app", - withSignatureStatus(plugins.SignatureStatusValid), - withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested"))), - ), - newPlugin( - "parent-datasource", - withSignatureStatus(plugins.SignatureStatusValid), - withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource"))), - withParent("grand-parent-app"), - ), - }, - plugin: newPlugin( - "child-panel", - withSignatureStatus(plugins.SignatureStatusValid), - withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource", "panels", "one"))), - withParent("parent-datasource"), - ), - cdn: true, - features: &config.Features{SriChecksEnabled: true}, - expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"), - }, - { - name: "nested plugin should not return module hash from parent if it's not registered in the store", - store: []pluginstore.Plugin{}, - plugin: newPlugin( - pluginID, - withSignatureStatus(plugins.SignatureStatusValid), - withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one"))), - withParent(parentPluginID), - ), - cdn: false, - features: &config.Features{SriChecksEnabled: true}, - expModuleHash: "", - }, - { - name: "missing module.js entry from MANIFEST.txt should not return module hash", - plugin: newPlugin( - pluginID, - withSignatureStatus(plugins.SignatureStatusValid), - withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-module-js"))), - ), - cdn: false, - features: &config.Features{SriChecksEnabled: true}, - expModuleHash: "", - }, - { - name: "signed status but missing MANIFEST.txt should not return module hash", - plugin: newPlugin( - pluginID, - withSignatureStatus(plugins.SignatureStatusValid), - withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-manifest-txt"))), - ), - cdn: false, - features: &config.Features{SriChecksEnabled: true}, - expModuleHash: "", - }, - } { - if tc.name == "" { - var expS string - if tc.expModuleHash == "" { - expS = "should not return module hash" - } else { - expS = "should return module hash" - } - tc.name = fmt.Sprintf("feature=%v, cdn_config=%v, class=%v %s", tc.features.SriChecksEnabled, tc.cdn, tc.plugin.Class, expS) - } - - t.Run(tc.name, func(t *testing.T) { - var pluginSettings config.PluginSettings - if tc.cdn { - pluginSettings = config.PluginSettings{ - pluginID: { - "cdn": "true", - }, - parentPluginID: map[string]string{ - "cdn": "true", - }, - "grand-parent-app": map[string]string{ - "cdn": "true", - }, - } - } - features := tc.features - if features == nil { - features = &config.Features{} - } - pCfg := &config.PluginManagementCfg{ - PluginsCDNURLTemplate: "http://cdn.example.com", - PluginSettings: pluginSettings, - Features: *features, - } - svc := ProvideService( - pCfg, - pluginscdn.ProvideService(pCfg), - signature.ProvideService(pCfg, statickey.New()), - pluginstore.NewFakePluginStore(tc.store...), - ) - mh := svc.ModuleHash(context.Background(), tc.plugin) - require.Equal(t, tc.expModuleHash, mh) - }) - } -} - -func TestService_ModuleHash_Cache(t *testing.T) { - pCfg := &config.PluginManagementCfg{ - PluginSettings: config.PluginSettings{}, - Features: config.Features{SriChecksEnabled: true}, - } - svc := ProvideService( - pCfg, - pluginscdn.ProvideService(pCfg), - signature.ProvideService(pCfg, statickey.New()), - pluginstore.NewFakePluginStore(), - ) - const pluginID = "grafana-test-datasource" - - t.Run("cache key", func(t *testing.T) { - t.Run("with version", func(t *testing.T) { - const pluginVersion = "1.0.0" - p := newPlugin(pluginID, withInfo(plugins.Info{Version: pluginVersion})) - k := svc.moduleHashCacheKey(p) - require.Equal(t, pluginID+":"+pluginVersion, k, "cache key should be correct") - }) - - t.Run("without version", func(t *testing.T) { - p := newPlugin(pluginID) - k := svc.moduleHashCacheKey(p) - require.Equal(t, pluginID+":", k, "cache key should be correct") - }) - }) - - t.Run("ModuleHash usage", func(t *testing.T) { - pV1 := newPlugin( - pluginID, - withInfo(plugins.Info{Version: "1.0.0"}), - withSignatureStatus(plugins.SignatureStatusValid), - withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))), - ) - - pCfg = &config.PluginManagementCfg{ - PluginsCDNURLTemplate: "https://cdn.grafana.com", - PluginSettings: config.PluginSettings{ - pluginID: { - "cdn": "true", - }, - }, - Features: config.Features{SriChecksEnabled: true}, - } - svc = ProvideService( - pCfg, - pluginscdn.ProvideService(pCfg), - signature.ProvideService(pCfg, statickey.New()), - pluginstore.NewFakePluginStore(), - ) - - k := svc.moduleHashCacheKey(pV1) - - _, ok := svc.moduleHashCache.Load(k) - require.False(t, ok, "cache should initially be empty") - - mhV1 := svc.ModuleHash(context.Background(), pV1) - pV1Exp := newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03") - require.Equal(t, pV1Exp, mhV1, "returned value should be correct") - - cachedMh, ok := svc.moduleHashCache.Load(k) - require.True(t, ok) - require.Equal(t, pV1Exp, cachedMh, "cache should contain the returned value") - - t.Run("different version uses different cache key", func(t *testing.T) { - pV2 := newPlugin( - pluginID, - withInfo(plugins.Info{Version: "2.0.0"}), - withSignatureStatus(plugins.SignatureStatusValid), - // different fs for different hash - withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))), - ) - mhV2 := svc.ModuleHash(context.Background(), pV2) - require.NotEqual(t, mhV2, mhV1, "different version should have different hash") - require.Equal(t, newSRIHash(t, "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a"), mhV2) - }) - - t.Run("cache should be used", func(t *testing.T) { - // edit cache directly - svc.moduleHashCache.Store(k, "hax") - require.Equal(t, "hax", svc.ModuleHash(context.Background(), pV1)) - }) - }) -} - -func TestConvertHashFromSRI(t *testing.T) { - for _, tc := range []struct { - hash string - expHash string - expErr bool - }{ - { - hash: "ddfcb449445064e6c39f0c20b15be3cb6a55837cf4781df23d02de005f436811", - expHash: "sha256-3fy0SURQZObDnwwgsVvjy2pVg3z0eB3yPQLeAF9DaBE=", - }, - { - hash: "not-a-valid-hash", - expErr: true, - }, - } { - t.Run(tc.hash, func(t *testing.T) { - r, err := convertHashForSRI(tc.hash) - if tc.expErr { - require.Error(t, err) - } else { - require.NoError(t, err) - require.Equal(t, tc.expHash, r) - } - }) - } -} - func newPlugin(pluginID string, cbs ...func(p pluginstore.Plugin) pluginstore.Plugin) pluginstore.Plugin { p := pluginstore.Plugin{ JSONData: plugins.JSONData{ @@ -534,13 +186,6 @@ func newPlugin(pluginID string, cbs ...func(p pluginstore.Plugin) pluginstore.Pl return p } -func withInfo(info plugins.Info) func(p pluginstore.Plugin) pluginstore.Plugin { - return func(p pluginstore.Plugin) pluginstore.Plugin { - p.Info = info - return p - } -} - func withFS(fs plugins.FS) func(p pluginstore.Plugin) pluginstore.Plugin { return func(p pluginstore.Plugin) pluginstore.Plugin { p.FS = fs @@ -548,13 +193,6 @@ func withFS(fs plugins.FS) func(p pluginstore.Plugin) pluginstore.Plugin { } } -func withSignatureStatus(status plugins.SignatureStatus) func(p pluginstore.Plugin) pluginstore.Plugin { - return func(p pluginstore.Plugin) pluginstore.Plugin { - p.Signature = status - return p - } -} - func withAngular(angular bool) func(p pluginstore.Plugin) pluginstore.Plugin { return func(p pluginstore.Plugin) pluginstore.Plugin { p.Angular = plugins.AngularMeta{Detected: angular} @@ -562,13 +200,6 @@ func withAngular(angular bool) func(p pluginstore.Plugin) pluginstore.Plugin { } } -func withParent(parentID string) func(p pluginstore.Plugin) pluginstore.Plugin { - return func(p pluginstore.Plugin) pluginstore.Plugin { - p.Parent = &pluginstore.ParentPlugin{ID: parentID} - return p - } -} - func withClass(class plugins.Class) func(p pluginstore.Plugin) pluginstore.Plugin { return func(p pluginstore.Plugin) pluginstore.Plugin { p.Class = class @@ -587,9 +218,3 @@ func newPluginSettings(pluginID string, kv map[string]string) config.PluginSetti pluginID: kv, } } - -func newSRIHash(t *testing.T, s string) string { - r, err := convertHashForSRI(s) - require.NoError(t, err) - return r -} diff --git a/pkg/services/pluginsintegration/pluginstore/plugins.go b/pkg/services/pluginsintegration/pluginstore/plugins.go index f4504d254c5..77fe1365fc3 100644 --- a/pkg/services/pluginsintegration/pluginstore/plugins.go +++ b/pkg/services/pluginsintegration/pluginstore/plugins.go @@ -30,8 +30,9 @@ type Plugin struct { Error *plugins.Error // SystemJS fields - Module string - BaseURL string + Module string + BaseURL string + ModuleHash string Angular plugins.AngularMeta @@ -80,6 +81,7 @@ func ToGrafanaDTO(p *plugins.Plugin) Plugin { ExternalService: p.ExternalService, Angular: p.Angular, Translations: p.Translations, + ModuleHash: p.ModuleHash, } if p.Parent != nil { diff --git a/pkg/services/pluginsintegration/test_helper.go b/pkg/services/pluginsintegration/test_helper.go index 9daad43e3e2..9957fc11b22 100644 --- a/pkg/services/pluginsintegration/test_helper.go +++ b/pkg/services/pluginsintegration/test_helper.go @@ -24,6 +24,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" "github.com/grafana/grafana/pkg/plugins/pluginassets" "github.com/grafana/grafana/pkg/plugins/pluginerrs" + "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/pluginsintegration/coreplugin" "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" @@ -49,7 +50,7 @@ func CreateIntegrationTestCtx(t *testing.T, cfg *setting.Cfg, coreRegistry *core proc := process.ProvideService() disc := pipeline.ProvideDiscoveryStage(pCfg, reg) - boot := pipeline.ProvideBootstrapStage(pCfg, signature.ProvideService(pCfg, statickey.New()), pluginassets.NewLocalProvider()) + boot := pipeline.ProvideBootstrapStage(pCfg, signature.ProvideService(pCfg, statickey.New()), pluginassets.NewLocalProvider(), pluginscdn.ProvideService(pCfg)) valid := pipeline.ProvideValidationStage(pCfg, signature.NewValidator(signature.NewUnsignedAuthorizer(pCfg)), angularInspector) init := pipeline.ProvideInitializationStage(pCfg, reg, coreplugin.ProvideCoreProvider(coreRegistry), proc, &pluginfakes.FakeAuthService{}, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), nil, tracing.InitializeTracerForTest(), provisionedplugins.NewNoop()) term, err := pipeline.ProvideTerminationStage(pCfg, reg, proc) @@ -87,7 +88,7 @@ func CreateTestLoader(t *testing.T, cfg *pluginsCfg.PluginManagementCfg, opts Lo } if opts.Bootstrapper == nil { - opts.Bootstrapper = pipeline.ProvideBootstrapStage(cfg, signature.ProvideService(cfg, statickey.New()), pluginassets.NewLocalProvider()) + opts.Bootstrapper = pipeline.ProvideBootstrapStage(cfg, signature.ProvideService(cfg, statickey.New()), pluginassets.NewLocalProvider(), pluginscdn.ProvideService(cfg)) } if opts.Validator == nil { From f5aa39cc27343026d797cfefe5c6e68c1c838a1a Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Tue, 13 Jan 2026 07:38:35 -0700 Subject: [PATCH 18/57] Dashboard Conversion: Add missing dev dashboards when comparing v1 to v2 conversion (#115967) * descritpion and transformations * add fieldMinMax to schema * add nullValueMode to schema * convert actions * disabled prop in transformations * update * fix * gauge * remove index (deprecated) and decimals as strings * gofmt * codegen * lint * open api --- .../kinds/v2alpha1/dashboard_spec.cue | 10 + .../kinds/v2beta1/dashboard_spec.cue | 10 + .../dashboard/v2alpha1/dashboard_spec.cue | 10 + .../dashboard/v2alpha1/dashboard_spec_gen.go | 15 + .../v2alpha1/zz_generated.openapi.go | 14 + .../apis/dashboard/v2beta1/dashboard_spec.cue | 10 + .../dashboard/v2beta1/dashboard_spec_gen.go | 15 + .../dashboard/v2beta1/zz_generated.openapi.go | 14 + apps/dashboard/pkg/apis/dashboard_manifest.go | 4 +- .../v1beta1.annotation-filtering.v42.json | 427 ++++++++++++++++++ ...> v1beta1.multi-lane-annotations.v42.json} | 0 ...=> v1beta1.elasticsearch_complex.v42.json} | 0 ... v1beta1.elasticsearch_migration.v42.json} | 0 ... => v1beta1.elasticsearch_simple.v42.json} | 0 ...42.json => v1beta1.influxdb-logs.v42.json} | 0 ...on => v1beta1.influxdb-templated.v42.json} | 0 ...42.json => v1beta1.loki_fakedata.v42.json} | 0 ... => v1beta1.loki_query_splitting.v42.json} | 24 +- ...2.json => v1beta1.mssql_fakedata.v42.json} | 0 ...2.json => v1beta1.mssql_unittest.v42.json} | 0 ...2.json => v1beta1.mysql_fakedata.v42.json} | 0 ...2.json => v1beta1.mysql_unittest.v42.json} | 0 ...sdb.v42.json => v1beta1.opentsdb.v42.json} | 0 ...v42.json => v1beta1.opentsdb_v23.v42.json} | 0 ...son => v1beta1.postgres_fakedata.v42.json} | 0 ...son => v1beta1.postgres_unittest.v42.json} | 0 ....json => v1beta1.bar-gauge-demo2.v42.json} | 0 ....demo1.v42.json => v1beta1.demo1.v42.json} | 0 ...n => v1beta1.new_features_in_v74.v42.json} | 0 ...on => v1beta1.new_features_in_v8.v42.json} | 0 ...> v1beta1.Repeating-Kitchen-Sink.v42.json} | 0 ...1.Repeating-a-panel-horizontally.v42.json} | 0 ...ta1.Repeating-a-panel-vertically.v42.json} | 0 ...ith-a-repeating-horizontal-panel.v42.json} | 0 ...-with-a-repeating-vertical-panel.v42.json} | 0 ...> v1beta1.Repeating-an-empty-row.v42.json} | 0 ... v1beta1.link-onclick-extensions.v42.json} | 0 ... => v1beta1.link-path-extensions.v42.json} | 0 ....json => v1beta1.datadata-macros.v42.json} | 0 ...obal-variables-and-interpolation.v42.json} | 0 ...ng-dashboard-links-and-variables.v42.json} | 0 ...eta1.templating-repeating-panels.v42.json} | 0 ...1beta1.templating-repeating-rows.v42.json} | 0 ...templating-textbox-e2e-scenarios.v42.json} | 0 ...on => v1beta1.testdata-datalinks.v42.json} | 0 ...tdata-nested-variables-drilldown.v42.json} | 0 ...1beta1.testdata-nested-variables.v42.json} | 0 ...a1.testdata-test-variable-output.v42.json} | 0 ...beta1.testdata-variables-textbox.v42.json} | 0 ...ables-that-update-on-time-change.v42.json} | 0 ...n => v1beta1.live-flakey-refresh.v42.json} | 0 ....v42.json => v1beta1.live-flakey.v42.json} | 0 ...v42.json => v1beta1.live-publish.v42.json} | 0 ...v42.json => v1beta1.live-streams.v42.json} | 0 ...s.v42.json => v1beta1.migrations.v42.json} | 0 ...n => v1beta1.barchart-autosizing.v42.json} | 0 ...barchart-label-rotation-skipping.v42.json} | 0 ...> v1beta1.barchart-series-toggle.v42.json} | 0 ...ta1.barchart-thresholds-mappings.v42.json} | 0 ...1beta1.barchart-tooltips-legends.v42.json} | 0 ...2.json => v1beta1.bar_gauge_demo.v42.json} | 0 ...=> v1beta1.panel_tests_bar_gauge.v42.json} | 0 ...> v1beta1.panel_tests_bar_gauge2.v42.json} | 0 ....v42.json => v1beta1.candlestick.v42.json} | 0 ...beta1.canvas-connection-examples.v42.json} | 0 ...json => v1beta1.canvas-datalinks.v42.json} | 0 ....json => v1beta1.canvas-examples.v42.json} | 0 ...42.json => v1beta1.auto_decimals.v42.json} | 0 ....v42.json => v1beta1.color_modes.v42.json} | 0 ...v42.json => v1beta1.lazy_loading.v42.json} | 0 ...z.v42.json => v1beta1.linked-viz.v42.json} | 0 ... => v1beta1.panels_without_title.v42.json} | 0 ...2.json => v1beta1.shared_queries.v42.json} | 0 ...ist.v42.json => v1beta1.dashlist.v42.json} | 0 ...> v1beta1.datagrid_metric_values.v42.json} | 0 ... v1beta1.panel_tests_flame_graph.v42.json} | 0 ...on => v1beta1.gauge-multi-series.v42.json} | 0 ....v42.json => v1beta1.gauge_tests.v42.json} | 72 +-- ....json => v1beta1.gauge_tests_new.v42.json} | 0 ...> v1beta1.gauge_tests_old_to_new.v42.json} | 0 ...on => v1beta1.geomap-color-field.v42.json} | 0 ...on => v1beta1.geomap-photo-layer.v42.json} | 0 ...on => v1beta1.geomap-route-layer.v42.json} | 0 ...p-spatial-operations-transformer.v42.json} | 0 ...1.v42.json => v1beta1.geomap-v91.v42.json} | 0 ...n => v1beta1.geomap_multi-layers.v42.json} | 0 ...v42.json => v1beta1.panel-geomap.v42.json} | 0 ...1beta1.graph-gradient-area-fills.v42.json} | 0 ...=> v1beta1.graph-shared-tooltips.v42.json} | 0 ...on => v1beta1.graph-time-regions.v42.json} | 0 ....v42.json => v1beta1.graph_tests.v42.json} | 0 ...v42.json => v1beta1.graph_y_axis.v42.json} | 0 ...=> v1beta1.heatmap-calculate-log.v42.json} | 0 ...2.json => v1beta1.heatmap-legacy.v42.json} | 0 ...-x.v42.json => v1beta1.heatmap-x.v42.json} | 0 ....json => v1beta1.histogram_tests.v42.json} | 33 +- ...42.json => v1beta1.panel-library.v42.json} | 0 ...n => v1beta1.panel_test_piechart.v42.json} | 0 ...42.json => v1beta1.polystat_test.v42.json} | 0 ...json => v1beta1.panel-stat-tests.v42.json} | 0 ...atus-history-thresholds-mappings.v42.json} | 0 ...v42.json => v1beta1.table_footer.v42.json} | 0 ...on => v1beta1.table_kitchen_sink.v42.json} | 0 ...2.json => v1beta1.table_markdown.v42.json} | 0 ...json => v1beta1.table_pagination.v42.json} | 0 ... => v1beta1.table_sparkline_cell.v42.json} | 0 ....v42.json => v1beta1.table_tests.v42.json} | 0 ....json => v1beta1.table_tests_new.v42.json} | 0 ...> v1beta1.table_v12_2_migrations.v42.json} | 0 ...v42.json => v1beta1.text-options.v42.json} | 0 ...> v1beta1.timeline-align-endtime.v42.json} | 0 ...eta1.timeline-align-nulls-retain.v42.json} | 0 ...42.json => v1beta1.timeline-demo.v42.json} | 0 ...2.json => v1beta1.timeline-modes.v42.json} | 0 ...ta1.timeline-thresholds-mappings.v42.json} | 0 ...ta1.timeseries-bars-high-density.v42.json} | 0 ...imeseries-by-value-color-schemes.v42.json} | 0 ...on => v1beta1.timeseries-formats.v42.json} | 0 ...v1beta1.timeseries-gradient-area.v42.json} | 0 ...v1beta1.timeseries-hue-gradients.v42.json} | 0 ...json => v1beta1.timeseries-nulls.v42.json} | 0 ...> v1beta1.timeseries-out-of-rage.v42.json} | 0 ...s-shared-tooltip-cursor-position.v42.json} | 0 ...> v1beta1.timeseries-soft-limits.v42.json} | 0 ...n => v1beta1.timeseries-stacking.v42.json} | 0 ... => v1beta1.timeseries-stacking2.v42.json} | 0 ...=> v1beta1.timeseries-thresholds.v42.json} | 0 ....json => v1beta1.timeseries-time.v42.json} | 0 ...timeseries-y-ticks-zero-decimals.v42.json} | 0 ...> v1beta1.timeseries-yaxis-ticks.v42.json} | 0 ...s.v42.json => v1beta1.timeseries.v42.json} | 0 ...42.json => v1beta1.trend_example.v42.json} | 0 ...v42.json => v1beta1.xychart-demo.v42.json} | 0 ...on => v1beta1.xychart-migrations.v42.json} | 0 ...beta1.xychart-tooltip-color-test.v42.json} | 21 +- ...> v1beta1.mostly-blank-dashboard.v42.json} | 0 ...beta1.relative_time_zone_support.v42.json} | 0 ...ta1.slow_queries_and_annotations.v42.json} | 0 ...2.json => v1beta1.tall_dashboard.v42.json} | 0 ...son => v1beta1.time_zone_support.v42.json} | 0 ...son => v1beta1.config-from-query.v42.json} | 0 ...on => v1beta1.extract-json-paths.v42.json} | 0 ...ilter.v42.json => v1beta1.filter.v42.json} | 0 ...42.json => v1beta1.join-by-field.v42.json} | 0 ...2.json => v1beta1.join-by-labels.v42.json} | 0 ...n => v1beta1.regression-analysis.v42.json} | 0 ....reuse.v42.json => v1beta1.reuse.v42.json} | 0 ...2.json => v1beta1.rows-to-fields.v42.json} | 0 ...beta1.v1beta1.v1beta1.all-panels.v42.json} | 0 ... => v1beta1.v1beta1.v1beta1.home.v42.json} | 0 ...1.multi-lane-annotations.v42.v2alpha1.json | 6 +- ...a1.multi-lane-annotations.v42.v2beta1.json | 6 +- ...pha1.loki_query_splitting.v42.v1beta1.json | 24 +- ...estdata-nested-variables.v42.v2alpha1.json | 6 +- ...testdata-nested-variables.v42.v2beta1.json | 6 +- .../v0alpha1.heatmap-x.v42.v2alpha1.json | 9 +- .../v0alpha1.heatmap-x.v42.v2beta1.json | 9 +- .../v0alpha1.histogram_tests.v42.v1beta1.json | 33 +- ...v0alpha1.histogram_tests.v42.v2alpha1.json | 2 + .../v0alpha1.histogram_tests.v42.v2beta1.json | 2 + ...tory-thresholds-mappings.v42.v2alpha1.json | 12 +- ...story-thresholds-mappings.v42.v2beta1.json | 12 +- .../v0alpha1.table_footer.v42.v2alpha1.json | 24 +- .../v0alpha1.table_footer.v42.v2beta1.json | 24 +- ...lpha1.table_kitchen_sink.v42.v2alpha1.json | 21 +- ...alpha1.table_kitchen_sink.v42.v2beta1.json | 21 +- ...ha1.table_sparkline_cell.v42.v2alpha1.json | 17 + ...pha1.table_sparkline_cell.v42.v2beta1.json | 17 + ...1.table_v12_2_migrations.v42.v2alpha1.json | 24 +- ...a1.table_v12_2_migrations.v42.v2beta1.json | 24 +- ...line-thresholds-mappings.v42.v2alpha1.json | 12 +- ...eline-thresholds-mappings.v42.v2beta1.json | 12 +- ...timeseries-gradient-area.v42.v2alpha1.json | 18 +- ....timeseries-gradient-area.v42.v2beta1.json | 18 +- ...timeseries-hue-gradients.v42.v2alpha1.json | 27 +- ....timeseries-hue-gradients.v42.v2beta1.json | 27 +- .../v0alpha1.timeseries.v42.v2alpha1.json | 6 +- .../v0alpha1.timeseries.v42.v2beta1.json | 6 +- ...ychart-tooltip-color-test.v42.v1beta1.json | 21 +- ...chart-tooltip-color-test.v42.v2alpha1.json | 18 +- ...ychart-tooltip-color-test.v42.v2beta1.json | 18 +- .../v0alpha1.filter.v42.v2alpha1.json | 8 + .../v0alpha1.filter.v42.v2beta1.json | 8 + .../conversion/v1beta1_to_v2alpha1.go | 188 +++++++- .../conversion/v2alpha1_to_v1beta1.go | 132 ++++++ .../conversion/v2alpha1_to_v2beta1.go | 59 +++ .../loki_query_splitting.v42.json | 24 +- .../panel-gauge/gauge_tests.v42.json | 70 +-- .../panel-histogram/histogram_tests.v42.json | 33 +- .../xychart-tooltip-color-test.v42.json | 21 +- .../datasource-loki/loki_query_splitting.json | 24 +- .../panel-gauge/gauge_tests.json | 61 +-- .../panel-histogram/histogram_tests.json | 33 +- .../xychart-tooltip-color-test.json | 21 +- go.work.sum | 2 + .../dashboard.grafana.app-v2alpha1.json | 28 +- .../dashboard.grafana.app-v2beta1.json | 24 +- .../serialization/serialization-test-utils.ts | 68 +++ .../transformSaveModelV1ToV2.test.ts | 98 ++-- .../transformSaveModelV2ToV1.test.ts | 25 +- .../transformSceneToSaveModel.ts | 2 +- .../transformSceneToSaveModelSchemaV2.ts | 2 +- 202 files changed, 1501 insertions(+), 591 deletions(-) create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/annotations/v1beta1.annotation-filtering.v42.json rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/annotations/{v0alpha1.multi-lane-annotations.v42.json => v1beta1.multi-lane-annotations.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-elasticsearch/{v0alpha1.elasticsearch_complex.v42.json => v1beta1.elasticsearch_complex.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-elasticsearch/{v0alpha1.elasticsearch_migration.v42.json => v1beta1.elasticsearch_migration.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-elasticsearch/{v0alpha1.elasticsearch_simple.v42.json => v1beta1.elasticsearch_simple.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-influxdb/{v0alpha1.influxdb-logs.v42.json => v1beta1.influxdb-logs.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-influxdb/{v0alpha1.influxdb-templated.v42.json => v1beta1.influxdb-templated.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-loki/{v0alpha1.loki_fakedata.v42.json => v1beta1.loki_fakedata.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-loki/{v0alpha1.loki_query_splitting.v42.json => v1beta1.loki_query_splitting.v42.json} (98%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-mssql/{v0alpha1.mssql_fakedata.v42.json => v1beta1.mssql_fakedata.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-mssql/{v0alpha1.mssql_unittest.v42.json => v1beta1.mssql_unittest.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-mysql/{v0alpha1.mysql_fakedata.v42.json => v1beta1.mysql_fakedata.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-mysql/{v0alpha1.mysql_unittest.v42.json => v1beta1.mysql_unittest.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-opentsdb/{v0alpha1.opentsdb.v42.json => v1beta1.opentsdb.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-opentsdb/{v0alpha1.opentsdb_v23.v42.json => v1beta1.opentsdb_v23.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-postgres/{v0alpha1.postgres_fakedata.v42.json => v1beta1.postgres_fakedata.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-postgres/{v0alpha1.postgres_unittest.v42.json => v1beta1.postgres_unittest.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-testdata/{v0alpha1.bar-gauge-demo2.v42.json => v1beta1.bar-gauge-demo2.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-testdata/{v0alpha1.demo1.v42.json => v1beta1.demo1.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-testdata/{v0alpha1.new_features_in_v74.v42.json => v1beta1.new_features_in_v74.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/datasource-testdata/{v0alpha1.new_features_in_v8.v42.json => v1beta1.new_features_in_v8.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/{v0alpha1.Repeating-Kitchen-Sink.v42.json => v1beta1.Repeating-Kitchen-Sink.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/{v0alpha1.Repeating-a-panel-horizontally.v42.json => v1beta1.Repeating-a-panel-horizontally.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/{v0alpha1.Repeating-a-panel-vertically.v42.json => v1beta1.Repeating-a-panel-vertically.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/{v0alpha1.Repeating-a-row-with-a-repeating-horizontal-panel.v42.json => v1beta1.Repeating-a-row-with-a-repeating-horizontal-panel.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/{v0alpha1.Repeating-a-row-with-a-repeating-vertical-panel.v42.json => v1beta1.Repeating-a-row-with-a-repeating-vertical-panel.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/e2e-repeats/{v0alpha1.Repeating-an-empty-row.v42.json => v1beta1.Repeating-an-empty-row.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/extensions/{v0alpha1.link-onclick-extensions.v42.json => v1beta1.link-onclick-extensions.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/extensions/{v0alpha1.link-path-extensions.v42.json => v1beta1.link-path-extensions.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/{v0alpha1.datadata-macros.v42.json => v1beta1.datadata-macros.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/{v0alpha1.global-variables-and-interpolation.v42.json => v1beta1.global-variables-and-interpolation.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/{v0alpha1.templating-dashboard-links-and-variables.v42.json => v1beta1.templating-dashboard-links-and-variables.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/{v0alpha1.templating-repeating-panels.v42.json => v1beta1.templating-repeating-panels.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/{v0alpha1.templating-repeating-rows.v42.json => v1beta1.templating-repeating-rows.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/{v0alpha1.templating-textbox-e2e-scenarios.v42.json => v1beta1.templating-textbox-e2e-scenarios.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/{v0alpha1.testdata-datalinks.v42.json => v1beta1.testdata-datalinks.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/{v0alpha1.testdata-nested-variables-drilldown.v42.json => v1beta1.testdata-nested-variables-drilldown.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/{v0alpha1.testdata-nested-variables.v42.json => v1beta1.testdata-nested-variables.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/{v0alpha1.testdata-test-variable-output.v42.json => v1beta1.testdata-test-variable-output.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/{v0alpha1.testdata-variables-textbox.v42.json => v1beta1.testdata-variables-textbox.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/feature-templating/{v0alpha1.testdata-variables-that-update-on-time-change.v42.json => v1beta1.testdata-variables-that-update-on-time-change.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/live/{v0alpha1.live-flakey-refresh.v42.json => v1beta1.live-flakey-refresh.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/live/{v0alpha1.live-flakey.v42.json => v1beta1.live-flakey.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/live/{v0alpha1.live-publish.v42.json => v1beta1.live-publish.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/live/{v0alpha1.live-streams.v42.json => v1beta1.live-streams.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/migrations/{v0alpha1.migrations.v42.json => v1beta1.migrations.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/{v0alpha1.barchart-autosizing.v42.json => v1beta1.barchart-autosizing.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/{v0alpha1.barchart-label-rotation-skipping.v42.json => v1beta1.barchart-label-rotation-skipping.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/{v0alpha1.barchart-series-toggle.v42.json => v1beta1.barchart-series-toggle.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/{v0alpha1.barchart-thresholds-mappings.v42.json => v1beta1.barchart-thresholds-mappings.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-barchart/{v0alpha1.barchart-tooltips-legends.v42.json => v1beta1.barchart-tooltips-legends.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-bargauge/{v0alpha1.bar_gauge_demo.v42.json => v1beta1.bar_gauge_demo.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-bargauge/{v0alpha1.panel_tests_bar_gauge.v42.json => v1beta1.panel_tests_bar_gauge.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-bargauge/{v0alpha1.panel_tests_bar_gauge2.v42.json => v1beta1.panel_tests_bar_gauge2.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-candlestick/{v0alpha1.candlestick.v42.json => v1beta1.candlestick.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-canvas/{v0alpha1.canvas-connection-examples.v42.json => v1beta1.canvas-connection-examples.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-canvas/{v0alpha1.canvas-datalinks.v42.json => v1beta1.canvas-datalinks.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-canvas/{v0alpha1.canvas-examples.v42.json => v1beta1.canvas-examples.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/{v0alpha1.auto_decimals.v42.json => v1beta1.auto_decimals.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/{v0alpha1.color_modes.v42.json => v1beta1.color_modes.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/{v0alpha1.lazy_loading.v42.json => v1beta1.lazy_loading.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/{v0alpha1.linked-viz.v42.json => v1beta1.linked-viz.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/{v0alpha1.panels_without_title.v42.json => v1beta1.panels_without_title.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-common/{v0alpha1.shared_queries.v42.json => v1beta1.shared_queries.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-dashlist/{v0alpha1.dashlist.v42.json => v1beta1.dashlist.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-datagrid/{v0alpha1.datagrid_metric_values.v42.json => v1beta1.datagrid_metric_values.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-flamegraph/{v0alpha1.panel_tests_flame_graph.v42.json => v1beta1.panel_tests_flame_graph.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/{v0alpha1.gauge-multi-series.v42.json => v1beta1.gauge-multi-series.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/{v0alpha1.gauge_tests.v42.json => v1beta1.gauge_tests.v42.json} (93%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/{v0alpha1.gauge_tests_new.v42.json => v1beta1.gauge_tests_new.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/{v0alpha1.gauge_tests_old_to_new.v42.json => v1beta1.gauge_tests_old_to_new.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/{v0alpha1.geomap-color-field.v42.json => v1beta1.geomap-color-field.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/{v0alpha1.geomap-photo-layer.v42.json => v1beta1.geomap-photo-layer.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/{v0alpha1.geomap-route-layer.v42.json => v1beta1.geomap-route-layer.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/{v0alpha1.geomap-spatial-operations-transformer.v42.json => v1beta1.geomap-spatial-operations-transformer.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/{v0alpha1.geomap-v91.v42.json => v1beta1.geomap-v91.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/{v0alpha1.geomap_multi-layers.v42.json => v1beta1.geomap_multi-layers.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-geomap/{v0alpha1.panel-geomap.v42.json => v1beta1.panel-geomap.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/{v0alpha1.graph-gradient-area-fills.v42.json => v1beta1.graph-gradient-area-fills.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/{v0alpha1.graph-shared-tooltips.v42.json => v1beta1.graph-shared-tooltips.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/{v0alpha1.graph-time-regions.v42.json => v1beta1.graph-time-regions.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/{v0alpha1.graph_tests.v42.json => v1beta1.graph_tests.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-graph/{v0alpha1.graph_y_axis.v42.json => v1beta1.graph_y_axis.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-heatmap/{v0alpha1.heatmap-calculate-log.v42.json => v1beta1.heatmap-calculate-log.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-heatmap/{v0alpha1.heatmap-legacy.v42.json => v1beta1.heatmap-legacy.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-heatmap/{v0alpha1.heatmap-x.v42.json => v1beta1.heatmap-x.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-histogram/{v0alpha1.histogram_tests.v42.json => v1beta1.histogram_tests.v42.json} (99%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-library/{v0alpha1.panel-library.v42.json => v1beta1.panel-library.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-piechart/{v0alpha1.panel_test_piechart.v42.json => v1beta1.panel_test_piechart.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-polystat/{v0alpha1.polystat_test.v42.json => v1beta1.polystat_test.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-stat/{v0alpha1.panel-stat-tests.v42.json => v1beta1.panel-stat-tests.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-status-history/{v0alpha1.status-history-thresholds-mappings.v42.json => v1beta1.status-history-thresholds-mappings.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/{v0alpha1.table_footer.v42.json => v1beta1.table_footer.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/{v0alpha1.table_kitchen_sink.v42.json => v1beta1.table_kitchen_sink.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/{v0alpha1.table_markdown.v42.json => v1beta1.table_markdown.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/{v0alpha1.table_pagination.v42.json => v1beta1.table_pagination.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/{v0alpha1.table_sparkline_cell.v42.json => v1beta1.table_sparkline_cell.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/{v0alpha1.table_tests.v42.json => v1beta1.table_tests.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/{v0alpha1.table_tests_new.v42.json => v1beta1.table_tests_new.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-table/{v0alpha1.table_v12_2_migrations.v42.json => v1beta1.table_v12_2_migrations.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-text/{v0alpha1.text-options.v42.json => v1beta1.text-options.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/{v0alpha1.timeline-align-endtime.v42.json => v1beta1.timeline-align-endtime.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/{v0alpha1.timeline-align-nulls-retain.v42.json => v1beta1.timeline-align-nulls-retain.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/{v0alpha1.timeline-demo.v42.json => v1beta1.timeline-demo.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/{v0alpha1.timeline-modes.v42.json => v1beta1.timeline-modes.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeline/{v0alpha1.timeline-thresholds-mappings.v42.json => v1beta1.timeline-thresholds-mappings.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/{v0alpha1.timeseries-bars-high-density.v42.json => v1beta1.timeseries-bars-high-density.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/{v0alpha1.timeseries-by-value-color-schemes.v42.json => v1beta1.timeseries-by-value-color-schemes.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/{v0alpha1.timeseries-formats.v42.json => v1beta1.timeseries-formats.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/{v0alpha1.timeseries-gradient-area.v42.json => v1beta1.timeseries-gradient-area.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/{v0alpha1.timeseries-hue-gradients.v42.json => v1beta1.timeseries-hue-gradients.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/{v0alpha1.timeseries-nulls.v42.json => v1beta1.timeseries-nulls.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/{v0alpha1.timeseries-out-of-rage.v42.json => v1beta1.timeseries-out-of-rage.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/{v0alpha1.timeseries-shared-tooltip-cursor-position.v42.json => v1beta1.timeseries-shared-tooltip-cursor-position.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/{v0alpha1.timeseries-soft-limits.v42.json => v1beta1.timeseries-soft-limits.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/{v0alpha1.timeseries-stacking.v42.json => v1beta1.timeseries-stacking.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/{v0alpha1.timeseries-stacking2.v42.json => v1beta1.timeseries-stacking2.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/{v0alpha1.timeseries-thresholds.v42.json => v1beta1.timeseries-thresholds.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/{v0alpha1.timeseries-time.v42.json => v1beta1.timeseries-time.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/{v0alpha1.timeseries-y-ticks-zero-decimals.v42.json => v1beta1.timeseries-y-ticks-zero-decimals.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/{v0alpha1.timeseries-yaxis-ticks.v42.json => v1beta1.timeseries-yaxis-ticks.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-timeseries/{v0alpha1.timeseries.v42.json => v1beta1.timeseries.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-trend/{v0alpha1.trend_example.v42.json => v1beta1.trend_example.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-xychart/{v0alpha1.xychart-demo.v42.json => v1beta1.xychart-demo.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-xychart/{v0alpha1.xychart-migrations.v42.json => v1beta1.xychart-migrations.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-xychart/{v0alpha1.xychart-tooltip-color-test.v42.json => v1beta1.xychart-tooltip-color-test.v42.json} (98%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/{v0alpha1.mostly-blank-dashboard.v42.json => v1beta1.mostly-blank-dashboard.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/{v0alpha1.relative_time_zone_support.v42.json => v1beta1.relative_time_zone_support.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/{v0alpha1.slow_queries_and_annotations.v42.json => v1beta1.slow_queries_and_annotations.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/{v0alpha1.tall_dashboard.v42.json => v1beta1.tall_dashboard.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/scenarios/{v0alpha1.time_zone_support.v42.json => v1beta1.time_zone_support.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/{v0alpha1.config-from-query.v42.json => v1beta1.config-from-query.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/{v0alpha1.extract-json-paths.v42.json => v1beta1.extract-json-paths.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/{v0alpha1.filter.v42.json => v1beta1.filter.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/{v0alpha1.join-by-field.v42.json => v1beta1.join-by-field.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/{v0alpha1.join-by-labels.v42.json => v1beta1.join-by-labels.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/{v0alpha1.regression-analysis.v42.json => v1beta1.regression-analysis.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/{v0alpha1.reuse.v42.json => v1beta1.reuse.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/transforms/{v0alpha1.rows-to-fields.v42.json => v1beta1.rows-to-fields.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/{v0alpha1.v1beta1.v1beta1.all-panels.v42.json => v1beta1.v1beta1.v1beta1.all-panels.v42.json} (100%) rename apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/{v0alpha1.v1beta1.v1beta1.home.v42.json => v1beta1.v1beta1.v1beta1.home.v42.json} (100%) 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/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/v1beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go index 3eeb61893fa..bfff3c49797 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go @@ -2230,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) } } @@ -2349,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: @@ -2531,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 { @@ -2543,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) @@ -2842,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 5dc7ecf21fd..18d9ae90814 100644 --- a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go +++ b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go @@ -1090,6 +1090,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 @@ -1985,9 +1996,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) } @@ -2193,3 +2213,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-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/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-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/go.work.sum b/go.work.sum index 5f97bbdd6ef..d064248a16a 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= @@ -1000,6 +1001,7 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36 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.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= 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/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/transformSaveModelV1ToV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts index a870f4feef5..afd8dd97c03 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts @@ -1,9 +1,13 @@ -import { readdirSync, readFileSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import path from 'path'; import { getSceneCreationOptions } from '../pages/DashboardScenePageStateManager'; -import { normalizeBackendOutputForFrontendComparison } from './serialization-test-utils'; +import { + getFilesRecursively, + normalizeBackendOutputForFrontendComparison, + removeEmptyArrays, +} from './serialization-test-utils'; import { transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene'; import { transformSaveModelToScene } from './transformSaveModelToScene'; import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2'; @@ -173,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')); @@ -202,10 +219,11 @@ 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 @@ -238,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')); @@ -316,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 af6116b9adf..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'; @@ -8,32 +8,11 @@ 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 = { diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts index 3052e4d8118..c5d01b433e3 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, 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, From a01777eafa016203700d250bc84f7eaa3c82d3ad Mon Sep 17 00:00:00 2001 From: Jo Date: Tue, 13 Jan 2026 16:11:15 +0100 Subject: [PATCH 19/57] docs: improve RBAC and role creation documentation (#116188) * docs: improve RBAC and role creation documentation - Clarify that file-based RBAC provisioning is for self-managed instances only - Distinguish between Grafana Admin (Server Admin) and Org Admin - Remove incorrect UI instructions for custom role creation - Add Terraform example for creating custom roles and assignments * Apply suggestions from code review Co-authored-by: Anna Urbiztondo --------- Co-authored-by: Anna Urbiztondo --- .../roles-and-permissions/_index.md | 8 +- .../access-control/manage-rbac-roles/index.md | 161 +++++++++++------- .../rbac-grafana-provisioning/index.md | 5 +- 3 files changed, 109 insertions(+), 65 deletions(-) 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-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:** From fe5aa3e2810547ce31b09ff45d0f39f46d606ae8 Mon Sep 17 00:00:00 2001 From: Yunwen Zheng Date: Tue, 13 Jan 2026 10:20:17 -0500 Subject: [PATCH 20/57] RecentlyViewedDashboards: UI tweaks (#116171) --- .../components/RecentlyViewedDashboards.tsx | 1 + .../plugins/panel/dashlist/DashListItem.tsx | 51 +++++++++++-------- public/app/plugins/panel/dashlist/styles.ts | 4 ++ 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx b/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx index f0c2d4d3a13..b11e7c35f85 100644 --- a/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx +++ b/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx @@ -123,6 +123,7 @@ const getStyles = (theme: GrafanaTheme2) => { color: 'transparent', cursor: 'pointer', }, + padding: 0, }), content: css({ paddingTop: theme.spacing(0), diff --git a/public/app/plugins/panel/dashlist/DashListItem.tsx b/public/app/plugins/panel/dashlist/DashListItem.tsx index 6d12eec30e9..cbb093c51f4 100644 --- a/public/app/plugins/panel/dashlist/DashListItem.tsx +++ b/public/app/plugins/panel/dashlist/DashListItem.tsx @@ -1,3 +1,5 @@ +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'; @@ -25,6 +27,7 @@ export function DashListItem({ onStarChange, }: Props) { const css = useStyles2(getStyles); + const shortTitle = truncate(dashboard.name, { length: 40, omission: '…' }); const onCardLinkClick = () => { reportInteraction('grafana_recently_viewed_dashboards_click_card', { @@ -54,27 +57,35 @@ export function DashListItem({
) : ( - - - {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), + }), }; }; From ec1ace398e1ed3ae35a358e9dbbe7e6f50f67375 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Tue, 13 Jan 2026 17:22:20 +0200 Subject: [PATCH 21/57] Recent dashboards: Add experimental toggle (#116121) * Add experimentRecentlyViewedDashboards toggle * Emit dashboards_browse_list_viewed event * Move feature toggle to parent * merge --- .../src/types/featureToggles.gen.ts | 5 ++++ pkg/services/featuremgmt/registry.go | 9 +++++++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.json | 15 +++++++++++ .../BrowseDashboardsPage.tsx | 25 ++++++++++++++++--- .../components/RecentlyViewedDashboards.tsx | 6 +---- 6 files changed, 53 insertions(+), 8 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 4850555be08..aa0581004e1 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -984,6 +984,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 */ diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 64511b3ccfa..a41e6804176 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1625,6 +1625,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.", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 9f74c053697..94de5c790c3 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -223,6 +223,7 @@ kubernetesAuthnMutation,experimental,@grafana/identity-access-team,false,false,f 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 diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index d96eb8e8d5a..75e3ca69afe 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1365,6 +1365,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", 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); @@ -48,7 +44,7 @@ export function RecentlyViewedDashboards() { setIsOpen(!isOpen); }; - if (!evaluateBooleanFlag('recentlyViewedDashboards', false) || recentDashboards.length === 0) { + if (recentDashboards.length === 0) { return null; } From 1d3f09d5193aff18782b7a8784885c2b8337fb22 Mon Sep 17 00:00:00 2001 From: Alyssa Joyner <58453566+alyssajoyner@users.noreply.github.com> Date: Tue, 13 Jan 2026 08:32:09 -0700 Subject: [PATCH 22/57] [InfluxDB]: Remove banner (#116141) --- .../editor/config-v2/ConfigEditor.test.tsx | 5 ----- .../editor/config-v2/ConfigEditor.tsx | 19 +------------------ 2 files changed, 1 insertion(+), 23 deletions(-) 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 From b687ca6b6d9f641835d97fdd90a0d1ac33f48d19 Mon Sep 17 00:00:00 2001 From: Motte <37443982+dmotte@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:48:50 +0100 Subject: [PATCH 23/57] Chore: Improve packaging/docker/run.sh (#114012) * Chore: set -e line in packaging/docker/run.sh * Chore: fix ShellCheck SC2188 in packaging/docker/run.sh * Chore: fix ShellCheck SC2166 in packaging/docker/run.sh --- packaging/docker/run.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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" From a28076ef5ea0c681b356220856c3f5c1ce818a22 Mon Sep 17 00:00:00 2001 From: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:41:35 -0600 Subject: [PATCH 24/57] Logs: Feature flag clean up (#116205) * chore: reassign flags to big tent --- pkg/services/featuremgmt/registry.go | 10 ++--- pkg/services/featuremgmt/toggles_gen.csv | 10 ++--- pkg/services/featuremgmt/toggles_gen.json | 45 +++++++++++++++-------- 3 files changed, 40 insertions(+), 25 deletions(-) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index a41e6804176..4c0456e9457 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", @@ -340,7 +340,7 @@ var ( Description: "Enables running Loki queries in parallel", Stage: FeatureStagePrivatePreview, FrontendOnly: false, - Owner: grafanaObservabilityLogsSquad, + Owner: grafanaOSSBigTent, }, { Name: "externalServiceAccounts", @@ -745,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, }, @@ -1260,7 +1260,7 @@ var ( Name: "lokiLabelNamesQueryApi", Description: "Defaults to using the Loki `/labels` API instead of `/series`", Stage: FeatureStageGeneralAvailability, - Owner: grafanaObservabilityLogsSquad, + Owner: grafanaOSSBigTent, Expression: "true", }, { diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 94de5c790c3..caba7cdab90 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 @@ -45,7 +45,7 @@ aiGeneratedDashboardChanges,experimental,@grafana/dashboards-squad,false,false,t 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 @@ -102,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 @@ -173,7 +173,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 -lokiLabelNamesQueryApi,GA,@grafana/observability-logs,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 diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 75e3ca69afe..bfefc20f08b 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2222,13 +2222,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" } @@ -2304,38 +2307,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" } }, { @@ -2368,13 +2380,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" } }, { From 6186aac5d4fc35f9b0bd64200ff4481d3aa2c456 Mon Sep 17 00:00:00 2001 From: Will Browne Date: Tue, 13 Jan 2026 18:34:39 +0000 Subject: [PATCH 25/57] Revert "Plugins: Add module hash field to plugin model" (#116211) * Revert "Plugins: Add module hash field to plugin model (#116119)" This reverts commit aa9b587cc1e50bcbfe31d78e98473c78d451bf67. * trigger * trigger --- apps/plugins/pkg/app/meta/local.go | 7 +- pkg/api/frontendsettings.go | 6 +- pkg/api/frontendsettings_test.go | 7 +- pkg/api/plugins.go | 2 +- pkg/api/plugins_test.go | 5 +- pkg/plugins/ifaces.go | 4 +- pkg/plugins/manager/loader/loader_test.go | 25 +- .../manager/pipeline/bootstrap/bootstrap.go | 3 +- .../manager/pipeline/bootstrap/steps.go | 27 +- pkg/plugins/manager/signature/manifest.go | 55 ++- .../manager/signature/manifest_test.go | 32 +- pkg/plugins/pluginassets/modulehash.go | 90 ----- pkg/plugins/pluginassets/modulehash_test.go | 356 ----------------- pkg/plugins/plugins.go | 27 +- pkg/server/wire_gen.go | 12 +- .../pluginsintegration/loader/loader_test.go | 206 ++-------- .../pluginsintegration/pipeline/pipeline.go | 5 +- .../pluginassets/pluginassets.go | 132 +++++- .../pluginassets/pluginassets_test.go | 375 ++++++++++++++++++ .../module-hash-no-manifest-txt/module.js | 0 .../module-hash-no-manifest-txt/plugin.json | 0 .../module-hash-no-module-js/MANIFEST.txt | 0 .../module-hash-no-module-js/plugin.json | 0 .../module-hash-no-module-js/something.js | 0 .../MANIFEST.txt | 0 .../datasource/module.js | 0 .../datasource/panels/one/module.js | 0 .../datasource/panels/one/plugin.json | 0 .../datasource/plugin.json | 0 .../module-hash-valid-deeply-nested/module.js | 0 .../plugin.json | 0 .../module-hash-valid-nested/MANIFEST.txt | 0 .../datasource/module.js | 0 .../datasource/plugin.json | 0 .../module-hash-valid-nested/module.js | 0 .../panels/one/module.js | 0 .../panels/one/plugin.json | 0 .../module-hash-valid-nested/plugin.json | 0 .../testdata/module-hash-valid/MANIFEST.txt | 0 .../testdata/module-hash-valid/module.js | 0 .../testdata/module-hash-valid/plugin.json | 0 .../pluginsintegration/pluginstore/plugins.go | 6 +- .../pluginsintegration/test_helper.go | 5 +- 43 files changed, 620 insertions(+), 767 deletions(-) delete mode 100644 pkg/plugins/pluginassets/modulehash.go delete mode 100644 pkg/plugins/pluginassets/modulehash_test.go rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-no-manifest-txt/module.js (100%) rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-no-manifest-txt/plugin.json (100%) rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-no-module-js/MANIFEST.txt (100%) rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-no-module-js/plugin.json (100%) rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-no-module-js/something.js (100%) rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-valid-deeply-nested/MANIFEST.txt (100%) rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/module.js (100%) rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/module.js (100%) rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/plugin.json (100%) rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/plugin.json (100%) rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-valid-deeply-nested/module.js (100%) rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-valid-deeply-nested/plugin.json (100%) rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-valid-nested/MANIFEST.txt (100%) rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-valid-nested/datasource/module.js (100%) rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-valid-nested/datasource/plugin.json (100%) rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-valid-nested/module.js (100%) rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-valid-nested/panels/one/module.js (100%) rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-valid-nested/panels/one/plugin.json (100%) rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-valid-nested/plugin.json (100%) rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-valid/MANIFEST.txt (100%) rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-valid/module.js (100%) rename pkg/{plugins => services/pluginsintegration}/pluginassets/testdata/module-hash-valid/plugin.json (100%) diff --git a/apps/plugins/pkg/app/meta/local.go b/apps/plugins/pkg/app/meta/local.go index af40316cd73..2c699520cfc 100644 --- a/apps/plugins/pkg/app/meta/local.go +++ b/apps/plugins/pkg/app/meta/local.go @@ -13,9 +13,10 @@ const ( ) // PluginAssetsCalculator is an interface for calculating plugin asset information. -// LocalProvider requires this to calculate loading strategy. +// LocalProvider requires this to calculate loading strategy and module hash. type PluginAssetsCalculator interface { LoadingStrategy(ctx context.Context, p pluginstore.Plugin) plugins.LoadingStrategy + ModuleHash(ctx context.Context, p pluginstore.Plugin) string } // LocalProvider retrieves plugin metadata for locally installed plugins. @@ -26,7 +27,7 @@ type LocalProvider struct { } // NewLocalProvider creates a new LocalProvider for locally installed plugins. -// pluginAssets is required for calculating loading strategy. +// pluginAssets is required for calculating loading strategy and module hash. func NewLocalProvider(pluginStore pluginstore.Store, pluginAssets PluginAssetsCalculator) *LocalProvider { return &LocalProvider{ store: pluginStore, @@ -42,7 +43,7 @@ func (p *LocalProvider) GetMeta(ctx context.Context, pluginID, version string) ( } loadingStrategy := p.pluginAssets.LoadingStrategy(ctx, plugin) - moduleHash := plugin.ModuleHash + moduleHash := p.pluginAssets.ModuleHash(ctx, plugin) spec := pluginStorePluginToMeta(plugin, loadingStrategy, moduleHash) return &Result{ diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 64165cfb98a..b57262087e5 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -161,7 +161,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro AliasIDs: panel.AliasIDs, Info: panel.Info, Module: panel.Module, - ModuleHash: panel.ModuleHash, + ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), panel), BaseURL: panel.BaseURL, SkipDataQuery: panel.SkipDataQuery, Suggestions: panel.Suggestions, @@ -527,7 +527,7 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug JSONData: plugin.JSONData, Signature: plugin.Signature, Module: plugin.Module, - ModuleHash: plugin.ModuleHash, + ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), plugin), BaseURL: plugin.BaseURL, Angular: plugin.Angular, MultiValueFilterOperators: plugin.MultiValueFilterOperators, @@ -641,7 +641,7 @@ func (hs *HTTPServer) newAppDTO(ctx context.Context, plugin pluginstore.Plugin, LoadingStrategy: hs.pluginAssets.LoadingStrategy(ctx, plugin), Extensions: plugin.Extensions, Dependencies: plugin.Dependencies, - ModuleHash: plugin.ModuleHash, + ModuleHash: hs.pluginAssets.ModuleHash(ctx, plugin), Translations: plugin.Translations, BuildMode: plugin.BuildMode, } diff --git a/pkg/api/frontendsettings_test.go b/pkg/api/frontendsettings_test.go index 4741f6b10bc..ba67837be5d 100644 --- a/pkg/api/frontendsettings_test.go +++ b/pkg/api/frontendsettings_test.go @@ -20,6 +20,8 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/manager/pluginfakes" + "github.com/grafana/grafana/pkg/plugins/manager/signature" + "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" "github.com/grafana/grafana/pkg/plugins/pluginscdn" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" @@ -77,7 +79,8 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F var pluginsAssets = passets if pluginsAssets == nil { - pluginsAssets = pluginassets.ProvideService(pluginsCfg, pluginsCDN, pluginStore) + sig := signature.ProvideService(pluginsCfg, statickey.New()) + pluginsAssets = pluginassets.ProvideService(pluginsCfg, pluginsCDN, sig, pluginStore) } hs := &HTTPServer{ @@ -711,6 +714,6 @@ func newPluginAssets() func() *pluginassets.Service { func newPluginAssetsWithConfig(pCfg *config.PluginManagementCfg) func() *pluginassets.Service { return func() *pluginassets.Service { - return pluginassets.ProvideService(pCfg, pluginscdn.ProvideService(pCfg), &pluginstore.FakePluginStore{}) + return pluginassets.ProvideService(pCfg, pluginscdn.ProvideService(pCfg), signature.ProvideService(pCfg, statickey.New()), &pluginstore.FakePluginStore{}) } } diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 079dae066bb..50e32326565 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -201,7 +201,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response. Includes: plugin.Includes, BaseUrl: plugin.BaseURL, Module: plugin.Module, - ModuleHash: plugin.ModuleHash, + ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), plugin), DefaultNavUrl: path.Join(hs.Cfg.AppSubURL, plugin.DefaultNavURL), State: plugin.State, Signature: plugin.Signature, diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index 238b0b3390f..342c6293d7e 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -28,6 +28,8 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/filestore" "github.com/grafana/grafana/pkg/plugins/manager/pluginfakes" "github.com/grafana/grafana/pkg/plugins/manager/registry" + "github.com/grafana/grafana/pkg/plugins/manager/signature" + "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" "github.com/grafana/grafana/pkg/plugins/pluginerrs" "github.com/grafana/grafana/pkg/plugins/pluginscdn" ac "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -846,7 +848,8 @@ func Test_PluginsSettings(t *testing.T) { } pCfg := &config.PluginManagementCfg{} pluginCDN := pluginscdn.ProvideService(pCfg) - hs.pluginAssets = pluginassets.ProvideService(pCfg, pluginCDN, hs.pluginStore) + sig := signature.ProvideService(pCfg, statickey.New()) + hs.pluginAssets = pluginassets.ProvideService(pCfg, pluginCDN, sig, hs.pluginStore) hs.pluginErrorResolver = pluginerrs.ProvideStore(errTracker) hs.pluginsUpdateChecker, err = updatemanager.ProvidePluginsService( hs.Cfg, diff --git a/pkg/plugins/ifaces.go b/pkg/plugins/ifaces.go index 1d33d34e6c9..9b719b8b180 100644 --- a/pkg/plugins/ifaces.go +++ b/pkg/plugins/ifaces.go @@ -140,9 +140,7 @@ type Licensing interface { } type SignatureCalculator interface { - // Calculate calculates the signature and returns both the signature and the manifest. - // The manifest may be nil if the plugin is unsigned or if an error occurred. - Calculate(ctx context.Context, src PluginSource, plugin FoundPlugin) (Signature, *PluginManifest, error) + Calculate(ctx context.Context, src PluginSource, plugin FoundPlugin) (Signature, error) } type KeyStore interface { diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go index f4c63e71e15..7613392f497 100644 --- a/pkg/plugins/manager/loader/loader_test.go +++ b/pkg/plugins/manager/loader/loader_test.go @@ -216,27 +216,10 @@ func TestLoader_Load(t *testing.T) { ExtensionPoints: []plugins.ExtensionPoint{}, }, }, - Class: plugins.ClassExternal, - Module: "public/plugins/test-app/module.js", - BaseURL: "public/plugins/test-app", - FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/includes-symlinks")), - Manifest: &plugins.PluginManifest{ - Plugin: "test-app", - Version: "1.0.0", - KeyID: "7e4d0c6a708866e7", - Time: 1622547655175, - Files: map[string]string{ - "dashboards/connections.json": "bea86da4be970b98dc4681802ab55cdef3441dc3eb3c654cb207948d17b25303", - "dashboards/extra/memory.json": "7c042464941084caa91d0a9a2f188b05315a9796308a652ccdee31ca4fbcbfee", - "plugin.json": "c59a51bf6d7ecd7a99608ccb99353390c8b973672a938a0247164324005c0caf", - "symlink_to_txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048", - "text.txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048", - }, - ManifestVersion: "2.0.0", - SignatureType: plugins.SignatureTypeGrafana, - SignedByOrg: "grafana", - SignedByOrgName: "Grafana Labs", - }, + Class: plugins.ClassExternal, + Module: "public/plugins/test-app/module.js", + BaseURL: "public/plugins/test-app", + FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/includes-symlinks")), Signature: "valid", SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", diff --git a/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go b/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go index c129e203dc5..f20c1ff1ead 100644 --- a/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go +++ b/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go @@ -11,7 +11,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/pluginassets" - "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/plugins/tracing" "github.com/grafana/grafana/pkg/semconv" ) @@ -55,7 +54,7 @@ func New(cfg *config.PluginManagementCfg, opts Opts) *Bootstrap { } if opts.DecorateFuncs == nil { - opts.DecorateFuncs = DefaultDecorateFuncs(cfg, pluginscdn.ProvideService(cfg)) + opts.DecorateFuncs = DefaultDecorateFuncs(cfg) } return &Bootstrap{ diff --git a/pkg/plugins/manager/pipeline/bootstrap/steps.go b/pkg/plugins/manager/pipeline/bootstrap/steps.go index 5ab85dc6e43..5c365ebb47c 100644 --- a/pkg/plugins/manager/pipeline/bootstrap/steps.go +++ b/pkg/plugins/manager/pipeline/bootstrap/steps.go @@ -11,7 +11,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/pluginassets" - "github.com/grafana/grafana/pkg/plugins/pluginscdn" ) // DefaultConstructor implements the default ConstructFunc used for the Construct step of the Bootstrap stage. @@ -29,13 +28,12 @@ func DefaultConstructFunc(cfg *config.PluginManagementCfg, signatureCalculator p } // DefaultDecorateFuncs are the default DecorateFuncs used for the Decorate step of the Bootstrap stage. -func DefaultDecorateFuncs(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) []DecorateFunc { +func DefaultDecorateFuncs(cfg *config.PluginManagementCfg) []DecorateFunc { return []DecorateFunc{ AppDefaultNavURLDecorateFunc, TemplateDecorateFunc, AppChildDecorateFunc(), SkipHostEnvVarsDecorateFunc(cfg), - ModuleHashDecorateFunc(cfg, cdn), } } @@ -50,30 +48,19 @@ func NewDefaultConstructor(cfg *config.PluginManagementCfg, signatureCalculator // Construct will calculate the plugin's signature state and create the plugin using the pluginFactoryFunc. func (c *DefaultConstructor) Construct(ctx context.Context, src plugins.PluginSource, bundle *plugins.FoundBundle) ([]*plugins.Plugin, error) { - // Calculate signature and cache manifest - sig, manifest, err := c.signatureCalculator.Calculate(ctx, src, bundle.Primary) + sig, err := c.signatureCalculator.Calculate(ctx, src, bundle.Primary) if err != nil { c.log.Warn("Could not calculate plugin signature state", "pluginId", bundle.Primary.JSONData.ID, "error", err) return nil, err } - plugin, err := c.pluginFactoryFunc(bundle, src.PluginClass(ctx), sig) if err != nil { c.log.Error("Could not create primary plugin base", "pluginId", bundle.Primary.JSONData.ID, "error", err) return nil, err } - - plugin.Manifest = manifest - res := make([]*plugins.Plugin, 0, len(plugin.Children)+1) res = append(res, plugin) - for _, child := range plugin.Children { - // Child plugins use the parent's manifest - if child.Parent != nil && child.Parent.Manifest != nil { - child.Manifest = child.Parent.Manifest - } - res = append(res, child) - } + res = append(res, plugin.Children...) return res, nil } @@ -158,11 +145,3 @@ func SkipHostEnvVarsDecorateFunc(cfg *config.PluginManagementCfg) DecorateFunc { return p, nil } } - -// ModuleHashDecorateFunc returns a DecorateFunc that calculates and sets the module hash for the plugin. -func ModuleHashDecorateFunc(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) DecorateFunc { - return func(_ context.Context, p *plugins.Plugin) (*plugins.Plugin, error) { - p.ModuleHash = pluginassets.CalculateModuleHash(p, cfg, cdn) - return p, nil - } -} diff --git a/pkg/plugins/manager/signature/manifest.go b/pkg/plugins/manager/signature/manifest.go index 4b6bc531d89..6d790c79873 100644 --- a/pkg/plugins/manager/signature/manifest.go +++ b/pkg/plugins/manager/signature/manifest.go @@ -14,6 +14,7 @@ import ( "path" "path/filepath" "runtime" + "strings" "github.com/ProtonMail/go-crypto/openpgp" "github.com/ProtonMail/go-crypto/openpgp/clearsign" @@ -36,6 +37,26 @@ var ( fromSlash = filepath.FromSlash ) +// PluginManifest holds details for the file manifest +type PluginManifest struct { + Plugin string `json:"plugin"` + Version string `json:"version"` + KeyID string `json:"keyId"` + Time int64 `json:"time"` + Files map[string]string `json:"files"` + + // V2 supported fields + ManifestVersion string `json:"manifestVersion"` + SignatureType plugins.SignatureType `json:"signatureType"` + SignedByOrg string `json:"signedByOrg"` + SignedByOrgName string `json:"signedByOrgName"` + RootURLs []string `json:"rootUrls"` +} + +func (m *PluginManifest) IsV2() bool { + return strings.HasPrefix(m.ManifestVersion, "2.") +} + type Signature struct { kr plugins.KeyRetriever cfg *config.PluginManagementCfg @@ -66,14 +87,14 @@ func DefaultCalculator(cfg *config.PluginManagementCfg) *Signature { // readPluginManifest attempts to read and verify the plugin manifest // if any error occurs or the manifest is not valid, this will return an error -func (s *Signature) readPluginManifest(ctx context.Context, body []byte) (*plugins.PluginManifest, error) { +func (s *Signature) readPluginManifest(ctx context.Context, body []byte) (*PluginManifest, error) { block, _ := clearsign.Decode(body) if block == nil { return nil, errors.New("unable to decode manifest") } // Convert to a well typed object - var manifest plugins.PluginManifest + var manifest PluginManifest err := json.Unmarshal(block.Plaintext, &manifest) if err != nil { return nil, fmt.Errorf("%v: %w", "Error parsing manifest JSON", err) @@ -90,7 +111,7 @@ var ErrSignatureTypeUnsigned = errors.New("plugin is unsigned") // ReadPluginManifestFromFS reads the plugin manifest from the provided plugins.FS. // If the manifest is not found, it will return an error wrapping ErrSignatureTypeUnsigned. -func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS) (*plugins.PluginManifest, error) { +func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS) (*PluginManifest, error) { f, err := pfs.Open("MANIFEST.txt") if err != nil { if errors.Is(err, plugins.ErrFileNotExist) { @@ -119,9 +140,9 @@ func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS return manifest, nil } -func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, *plugins.PluginManifest, error) { +func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, error) { if defaultSignature, exists := src.DefaultSignature(ctx, plugin.JSONData.ID); exists { - return defaultSignature, nil, nil + return defaultSignature, nil } manifest, err := s.ReadPluginManifestFromFS(ctx, plugin.FS) @@ -130,29 +151,29 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu s.log.Warn("Plugin is unsigned", "id", plugin.JSONData.ID, "err", err) return plugins.Signature{ Status: plugins.SignatureStatusUnsigned, - }, nil, nil + }, nil case err != nil: s.log.Warn("Plugin signature is invalid", "id", plugin.JSONData.ID, "err", err) return plugins.Signature{ Status: plugins.SignatureStatusInvalid, - }, nil, nil + }, nil } if !manifest.IsV2() { return plugins.Signature{ Status: plugins.SignatureStatusInvalid, - }, nil, nil + }, nil } fsFiles, err := plugin.FS.Files() if err != nil { - return plugins.Signature{}, nil, fmt.Errorf("files: %w", err) + return plugins.Signature{}, fmt.Errorf("files: %w", err) } if len(fsFiles) == 0 { s.log.Warn("No plugin file information in directory", "pluginId", plugin.JSONData.ID) return plugins.Signature{ Status: plugins.SignatureStatusInvalid, - }, nil, nil + }, nil } // Make sure the versions all match @@ -160,20 +181,20 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu s.log.Debug("Plugin signature invalid because ID or Version mismatch", "pluginId", plugin.JSONData.ID, "manifestPluginId", manifest.Plugin, "pluginVersion", plugin.JSONData.Info.Version, "manifestPluginVersion", manifest.Version) return plugins.Signature{ Status: plugins.SignatureStatusModified, - }, nil, nil + }, nil } // Validate that plugin is running within defined root URLs if len(manifest.RootURLs) > 0 { if match, err := urlMatch(manifest.RootURLs, s.cfg.GrafanaAppURL, manifest.SignatureType); err != nil { s.log.Warn("Could not verify if root URLs match", "plugin", plugin.JSONData.ID, "rootUrls", manifest.RootURLs) - return plugins.Signature{}, nil, err + return plugins.Signature{}, err } else if !match { s.log.Warn("Could not find root URL that matches running application URL", "plugin", plugin.JSONData.ID, "appUrl", s.cfg.GrafanaAppURL, "rootUrls", manifest.RootURLs) return plugins.Signature{ Status: plugins.SignatureStatusInvalid, - }, nil, nil + }, nil } } @@ -186,7 +207,7 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu s.log.Debug("Plugin signature invalid", "pluginId", plugin.JSONData.ID, "error", err) return plugins.Signature{ Status: plugins.SignatureStatusModified, - }, nil, nil + }, nil } manifestFiles[p] = struct{}{} @@ -215,7 +236,7 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu s.log.Warn("The following files were not included in the signature", "plugin", plugin.JSONData.ID, "files", unsignedFiles) return plugins.Signature{ Status: plugins.SignatureStatusModified, - }, nil, nil + }, nil } s.log.Debug("Plugin signature valid", "id", plugin.JSONData.ID) @@ -223,7 +244,7 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu Status: plugins.SignatureStatusValid, Type: manifest.SignatureType, SigningOrg: manifest.SignedByOrgName, - }, manifest, nil + }, nil } func verifyHash(mlog log.Logger, plugin plugins.FoundPlugin, path, hash string) error { @@ -300,7 +321,7 @@ func (r invalidFieldErr) Error() string { return fmt.Sprintf("valid manifest field %s is required", r.field) } -func (s *Signature) validateManifest(ctx context.Context, m plugins.PluginManifest, block *clearsign.Block) error { +func (s *Signature) validateManifest(ctx context.Context, m PluginManifest, block *clearsign.Block) error { if len(m.Plugin) == 0 { return invalidFieldErr{field: "plugin"} } diff --git a/pkg/plugins/manager/signature/manifest_test.go b/pkg/plugins/manager/signature/manifest_test.go index 5768f0f32f5..d399268b464 100644 --- a/pkg/plugins/manager/signature/manifest_test.go +++ b/pkg/plugins/manager/signature/manifest_test.go @@ -164,7 +164,7 @@ func TestCalculate(t *testing.T) { for _, tc := range tcs { basePath := filepath.Join(parentDir, "testdata/non-pvt-with-root-url/plugin") s := provideTestServiceWithConfig(&config.PluginManagementCfg{GrafanaAppURL: tc.appURL}) - sig, _, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{ + sig, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassExternal }, @@ -192,7 +192,7 @@ func TestCalculate(t *testing.T) { runningWindows = true s := provideDefaultTestService() - sig, _, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{ + sig, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassExternal }, @@ -260,7 +260,7 @@ func TestCalculate(t *testing.T) { require.NoError(t, err) pfs, err = newPathSeparatorOverrideFS(string(tc.platform.separator), pfs) require.NoError(t, err) - sig, _, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{ + sig, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassExternal }, @@ -396,7 +396,7 @@ func TestFSPathSeparatorFiles(t *testing.T) { } } -func fileList(manifest *plugins.PluginManifest) []string { +func fileList(manifest *PluginManifest) []string { keys := make([]string, 0, len(manifest.Files)) for k := range manifest.Files { keys = append(keys, k) @@ -682,52 +682,52 @@ func Test_urlMatch_private(t *testing.T) { func Test_validateManifest(t *testing.T) { tcs := []struct { name string - manifest *plugins.PluginManifest + manifest *PluginManifest expectedErr string }{ { name: "Empty plugin field", - manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Plugin = "" }), + manifest: createV2Manifest(t, func(m *PluginManifest) { m.Plugin = "" }), expectedErr: "valid manifest field plugin is required", }, { name: "Empty keyId field", - manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.KeyID = "" }), + manifest: createV2Manifest(t, func(m *PluginManifest) { m.KeyID = "" }), expectedErr: "valid manifest field keyId is required", }, { name: "Empty signedByOrg field", - manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignedByOrg = "" }), + manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignedByOrg = "" }), expectedErr: "valid manifest field signedByOrg is required", }, { name: "Empty signedByOrgName field", - manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignedByOrgName = "" }), + manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignedByOrgName = "" }), expectedErr: "valid manifest field SignedByOrgName is required", }, { name: "Empty signatureType field", - manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignatureType = "" }), + manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignatureType = "" }), expectedErr: "valid manifest field signatureType is required", }, { name: "Invalid signatureType field", - manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignatureType = "invalidSignatureType" }), + manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignatureType = "invalidSignatureType" }), expectedErr: "valid manifest field signatureType is required", }, { name: "Empty files field", - manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Files = map[string]string{} }), + manifest: createV2Manifest(t, func(m *PluginManifest) { m.Files = map[string]string{} }), expectedErr: "valid manifest field files is required", }, { name: "Empty time field", - manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Time = 0 }), + manifest: createV2Manifest(t, func(m *PluginManifest) { m.Time = 0 }), expectedErr: "valid manifest field time is required", }, { name: "Empty version field", - manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Version = "" }), + manifest: createV2Manifest(t, func(m *PluginManifest) { m.Version = "" }), expectedErr: "valid manifest field version is required", }, } @@ -740,10 +740,10 @@ func Test_validateManifest(t *testing.T) { } } -func createV2Manifest(t *testing.T, cbs ...func(*plugins.PluginManifest)) *plugins.PluginManifest { +func createV2Manifest(t *testing.T, cbs ...func(*PluginManifest)) *PluginManifest { t.Helper() - m := &plugins.PluginManifest{ + m := &PluginManifest{ Plugin: "grafana-test-app", Version: "2.5.3", KeyID: "7e4d0c6a708866e7", diff --git a/pkg/plugins/pluginassets/modulehash.go b/pkg/plugins/pluginassets/modulehash.go deleted file mode 100644 index fd1ff13c578..00000000000 --- a/pkg/plugins/pluginassets/modulehash.go +++ /dev/null @@ -1,90 +0,0 @@ -package pluginassets - -import ( - "encoding/base64" - "encoding/hex" - "path" - "path/filepath" - - "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/config" - "github.com/grafana/grafana/pkg/plugins/pluginscdn" -) - -// CalculateModuleHash calculates the module.js SHA256 hash for a plugin in the format expected by the browser for SRI checks. -// The module hash is read from the plugin's cached manifest. -// For nested plugins, the module hash is read from the root parent plugin's manifest. -// If the plugin is unsigned or not a CDN plugin, an empty string is returned. -func CalculateModuleHash(p *plugins.Plugin, cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) string { - if cfg == nil || !cfg.Features.SriChecksEnabled { - return "" - } - - if !p.Signature.IsValid() { - return "" - } - - rootParent := findRootParent(p) - if rootParent.Manifest == nil { - return "" - } - - if !rootParent.Manifest.IsV2() { - return "" - } - - if !cdnEnabled(rootParent, cdn) { - return "" - } - - modulePath := getModulePathInManifest(p, rootParent) - moduleHash, ok := rootParent.Manifest.Files[modulePath] - if !ok { - return "" - } - - return convertHashForSRI(moduleHash) -} - -// findRootParent returns the root parent plugin (the one that contains the manifest). -// For non-nested plugins, it returns the plugin itself. -func findRootParent(p *plugins.Plugin) *plugins.Plugin { - root := p - for root.Parent != nil { - root = root.Parent - } - return root -} - -// getModulePathInManifest returns the path to module.js as it appears in the manifest. -// For nested plugins, this is the relative path from the root parent to the plugin's module.js. -// For non-nested plugins, this is simply "module.js". -func getModulePathInManifest(p *plugins.Plugin, rootParent *plugins.Plugin) string { - if p == rootParent { - return "module.js" - } - - // Calculate the relative path from root parent to this plugin - relPath, err := rootParent.FS.Rel(p.FS.Base()) - if err != nil { - return "" - } - - // MANIFEST.txt uses forward slashes as path separators - pluginRootPath := filepath.ToSlash(relPath) - return path.Join(pluginRootPath, "module.js") -} - -// convertHashForSRI takes a SHA256 hash string and returns it as expected by the browser for SRI checks. -func convertHashForSRI(h string) string { - hb, err := hex.DecodeString(h) - if err != nil { - return "" - } - return "sha256-" + base64.StdEncoding.EncodeToString(hb) -} - -// cdnEnabled checks if a plugin is loaded via CDN -func cdnEnabled(p *plugins.Plugin, cdn *pluginscdn.Service) bool { - return p.FS.Type().CDN() || cdn.PluginSupported(p.ID) -} diff --git a/pkg/plugins/pluginassets/modulehash_test.go b/pkg/plugins/pluginassets/modulehash_test.go deleted file mode 100644 index c11fcb7f6a9..00000000000 --- a/pkg/plugins/pluginassets/modulehash_test.go +++ /dev/null @@ -1,356 +0,0 @@ -package pluginassets - -import ( - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/config" - "github.com/grafana/grafana/pkg/plugins/pluginscdn" -) - -func TestConvertHashForSRI(t *testing.T) { - for _, tc := range []struct { - hash string - expHash string - expErr bool - }{ - { - hash: "ddfcb449445064e6c39f0c20b15be3cb6a55837cf4781df23d02de005f436811", - expHash: "sha256-3fy0SURQZObDnwwgsVvjy2pVg3z0eB3yPQLeAF9DaBE=", - }, - { - hash: "not-a-valid-hash", - expErr: true, - }, - } { - t.Run(tc.hash, func(t *testing.T) { - r := convertHashForSRI(tc.hash) - if tc.expErr { - // convertHashForSRI returns empty string on error - require.Empty(t, r) - } else { - require.Equal(t, tc.expHash, r) - } - }) - } -} - -func TestCalculateModuleHash(t *testing.T) { - const ( - pluginID = "grafana-test-datasource" - parentPluginID = "grafana-test-app" - ) - - // Helper to create a plugin with manifest - createPluginWithManifest := func(id string, manifest *plugins.PluginManifest, parent *plugins.Plugin) *plugins.Plugin { - p := &plugins.Plugin{ - JSONData: plugins.JSONData{ - ID: id, - }, - Signature: plugins.SignatureStatusValid, - Manifest: manifest, - } - if parent != nil { - p.Parent = parent - } - return p - } - - // Helper to create a v2 manifest - createV2Manifest := func(files map[string]string) *plugins.PluginManifest { - return &plugins.PluginManifest{ - ManifestVersion: "2.0.0", - Files: files, - } - } - - for _, tc := range []struct { - name string - plugin *plugins.Plugin - cfg *config.PluginManagementCfg - cdn *pluginscdn.Service - expModuleHash string - }{ - { - name: "should return empty string when cfg is nil", - plugin: createPluginWithManifest(pluginID, createV2Manifest(map[string]string{ - "module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03", - }), nil), - cfg: nil, - cdn: nil, - expModuleHash: "", - }, - { - name: "should return empty string when SRI checks are disabled", - plugin: createPluginWithManifest(pluginID, createV2Manifest(map[string]string{ - "module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03", - }), nil), - cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: false}}, - cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}), - expModuleHash: "", - }, - { - name: "should return empty string for unsigned plugin", - plugin: &plugins.Plugin{ - JSONData: plugins.JSONData{ID: pluginID}, - Signature: plugins.SignatureStatusUnsigned, - Manifest: createV2Manifest(map[string]string{"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"}), - FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")), - }, - cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}}, - cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}), - expModuleHash: "", - }, - { - name: "should return module hash for valid plugin", - plugin: &plugins.Plugin{ - JSONData: plugins.JSONData{ID: pluginID}, - Signature: plugins.SignatureStatusValid, - Manifest: createV2Manifest(map[string]string{"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"}), - FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")), - }, - cfg: &config.PluginManagementCfg{ - PluginsCDNURLTemplate: "https://cdn.example.com", - Features: config.Features{SriChecksEnabled: true}, - PluginSettings: config.PluginSettings{ - pluginID: {"cdn": "true"}, - }, - }, - cdn: func() *pluginscdn.Service { - cfg := &config.PluginManagementCfg{ - PluginsCDNURLTemplate: "https://cdn.example.com", - PluginSettings: config.PluginSettings{ - pluginID: {"cdn": "true"}, - }, - } - return pluginscdn.ProvideService(cfg) - }(), - expModuleHash: "sha256-WJG1tSLV3whtD/CxEPvZ0hu0/HFjrzTQgoai6Eb2vgM=", - }, - { - name: "should return empty string when manifest is nil", - plugin: &plugins.Plugin{ - JSONData: plugins.JSONData{ID: pluginID}, - Signature: plugins.SignatureStatusValid, - Manifest: nil, - FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")), - }, - cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}}, - cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}), - expModuleHash: "", - }, - { - name: "should return empty string for v1 manifest", - plugin: &plugins.Plugin{ - JSONData: plugins.JSONData{ID: pluginID}, - Signature: plugins.SignatureStatusValid, - Manifest: &plugins.PluginManifest{ - ManifestVersion: "1.0.0", - Files: map[string]string{"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"}, - }, - FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")), - }, - cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}}, - cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}), - expModuleHash: "", - }, - { - name: "should return empty string when module.js is not in manifest", - plugin: &plugins.Plugin{ - JSONData: plugins.JSONData{ID: pluginID}, - Signature: plugins.SignatureStatusValid, - Manifest: createV2Manifest(map[string]string{"plugin.json": "129fab4e0584d18c778ebdfa5fe1a68edf2e5c5aeb8290b2c68182c857cb59f8"}), - FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")), - }, - cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}}, - cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}), - expModuleHash: "", - }, - { - name: "missing module.js entry from MANIFEST.txt should not return module hash", - plugin: &plugins.Plugin{ - JSONData: plugins.JSONData{ID: pluginID}, - Signature: plugins.SignatureStatusValid, - Manifest: createV2Manifest(map[string]string{"plugin.json": "129fab4e0584d18c778ebdfa5fe1a68edf2e5c5aeb8290b2c68182c857cb59f8"}), - FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-module-js")), - }, - cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}}, - cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}), - expModuleHash: "", - }, - { - name: "signed status but missing MANIFEST.txt should not return module hash", - plugin: &plugins.Plugin{ - JSONData: plugins.JSONData{ID: pluginID}, - Signature: plugins.SignatureStatusValid, - Manifest: nil, - FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-manifest-txt")), - }, - cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}}, - cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}), - expModuleHash: "", - }, - { - // parentPluginID (/) - // └── pluginID (/datasource) - name: "nested plugin should return module hash from parent MANIFEST.txt", - plugin: func() *plugins.Plugin { - parent := &plugins.Plugin{ - JSONData: plugins.JSONData{ID: parentPluginID}, - Signature: plugins.SignatureStatusValid, - Manifest: createV2Manifest(map[string]string{ - "module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a", - "datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711", - }), - FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested")), - } - return &plugins.Plugin{ - JSONData: plugins.JSONData{ID: pluginID}, - Signature: plugins.SignatureStatusValid, - Parent: parent, - FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "datasource")), - } - }(), - cfg: &config.PluginManagementCfg{ - PluginsCDNURLTemplate: "https://cdn.example.com", - Features: config.Features{SriChecksEnabled: true}, - PluginSettings: config.PluginSettings{ - pluginID: {"cdn": "true"}, - parentPluginID: {"cdn": "true"}, - }, - }, - cdn: func() *pluginscdn.Service { - cfg := &config.PluginManagementCfg{ - PluginsCDNURLTemplate: "https://cdn.example.com", - PluginSettings: config.PluginSettings{ - pluginID: {"cdn": "true"}, - parentPluginID: {"cdn": "true"}, - }, - } - return pluginscdn.ProvideService(cfg) - }(), - expModuleHash: "sha256-BNcNsJHZbEd1+zK6Wo+EzCKJPrQ6/bZJcmZh1EJcZxE=", - }, - { - // parentPluginID (/) - // └── pluginID (/panels/one) - name: "nested plugin deeper than one subfolder should return module hash from parent MANIFEST.txt", - plugin: func() *plugins.Plugin { - parent := &plugins.Plugin{ - JSONData: plugins.JSONData{ID: parentPluginID}, - Signature: plugins.SignatureStatusValid, - Manifest: createV2Manifest(map[string]string{ - "module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a", - "panels/one/module.js": "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f", - "datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711", - }), - FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested")), - } - return &plugins.Plugin{ - JSONData: plugins.JSONData{ID: pluginID}, - Signature: plugins.SignatureStatusValid, - Parent: parent, - FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one")), - } - }(), - cfg: &config.PluginManagementCfg{ - PluginsCDNURLTemplate: "https://cdn.example.com", - Features: config.Features{SriChecksEnabled: true}, - PluginSettings: config.PluginSettings{ - pluginID: {"cdn": "true"}, - parentPluginID: {"cdn": "true"}, - }, - }, - cdn: func() *pluginscdn.Service { - cfg := &config.PluginManagementCfg{ - PluginsCDNURLTemplate: "https://cdn.example.com", - PluginSettings: config.PluginSettings{ - pluginID: {"cdn": "true"}, - parentPluginID: {"cdn": "true"}, - }, - } - return pluginscdn.ProvideService(cfg) - }(), - expModuleHash: "sha256-y9GsIoRkWg4emocipyn1vN0rgxIicocJxjYL7s3WFD8=", - }, - { - // grand-parent-app (/) - // ├── parent-datasource (/datasource) - // │ └── child-panel (/datasource/panels/one) - name: "nested plugin of a nested plugin should return module hash from grandparent MANIFEST.txt", - plugin: func() *plugins.Plugin { - grandparent := &plugins.Plugin{ - JSONData: plugins.JSONData{ID: "grand-parent-app"}, - Signature: plugins.SignatureStatusValid, - Manifest: createV2Manifest(map[string]string{ - "module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a", - "datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711", - "datasource/panels/one/module.js": "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f", - }), - FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested")), - } - parent := &plugins.Plugin{ - JSONData: plugins.JSONData{ID: "parent-datasource"}, - Signature: plugins.SignatureStatusValid, - Parent: grandparent, - FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource")), - } - return &plugins.Plugin{ - JSONData: plugins.JSONData{ID: "child-panel"}, - Signature: plugins.SignatureStatusValid, - Parent: parent, - FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource", "panels", "one")), - } - }(), - cfg: &config.PluginManagementCfg{ - PluginsCDNURLTemplate: "https://cdn.example.com", - Features: config.Features{SriChecksEnabled: true}, - PluginSettings: config.PluginSettings{ - "child-panel": {"cdn": "true"}, - "parent-datasource": {"cdn": "true"}, - "grand-parent-app": {"cdn": "true"}, - }, - }, - cdn: func() *pluginscdn.Service { - cfg := &config.PluginManagementCfg{ - PluginsCDNURLTemplate: "https://cdn.example.com", - PluginSettings: config.PluginSettings{ - "child-panel": {"cdn": "true"}, - "parent-datasource": {"cdn": "true"}, - "grand-parent-app": {"cdn": "true"}, - }, - } - return pluginscdn.ProvideService(cfg) - }(), - expModuleHash: "sha256-y9GsIoRkWg4emocipyn1vN0rgxIicocJxjYL7s3WFD8=", - }, - { - name: "nested plugin should not return module hash when parent manifest is nil", - plugin: func() *plugins.Plugin { - parent := &plugins.Plugin{ - JSONData: plugins.JSONData{ID: parentPluginID}, - Signature: plugins.SignatureStatusValid, - Manifest: nil, // Parent has no manifest - FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested")), - } - return &plugins.Plugin{ - JSONData: plugins.JSONData{ID: pluginID}, - Signature: plugins.SignatureStatusValid, - Parent: parent, - FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one")), - } - }(), - cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}}, - cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}), - expModuleHash: "", - }, - } { - t.Run(tc.name, func(t *testing.T) { - result := CalculateModuleHash(tc.plugin, tc.cfg, tc.cdn) - require.Equal(t, tc.expModuleHash, result) - }) - } -} diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index e1c26b4f87d..bf1b23a35b0 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -40,7 +40,6 @@ type Plugin struct { Pinned bool // Signature fields - Manifest *PluginManifest Signature SignatureStatus SignatureType SignatureType SignatureOrg string @@ -49,9 +48,8 @@ type Plugin struct { Error *Error // SystemJS fields - Module string - ModuleHash string - BaseURL string + Module string + BaseURL string Angular AngularMeta @@ -534,24 +532,3 @@ func (pt Type) IsValid() bool { } return false } - -// PluginManifest holds details for the file manifest -type PluginManifest struct { - Plugin string `json:"plugin"` - Version string `json:"version"` - KeyID string `json:"keyId"` - Time int64 `json:"time"` - Files map[string]string `json:"files"` - - // V2 supported fields - ManifestVersion string `json:"manifestVersion"` - SignatureType SignatureType `json:"signatureType"` - SignedByOrg string `json:"signedByOrg"` - SignedByOrgName string `json:"signedByOrgName"` - RootURLs []string `json:"rootUrls"` -} - -// IsV2 returns true if the manifest is version 2.x -func (m *PluginManifest) IsV2() bool { - return strings.HasPrefix(m.ManifestVersion, "2.") -} diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 5b4f3bed5bb..218e9fabc36 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -376,8 +376,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api keyretrieverService := keyretriever.ProvideService(keyRetriever) signatureSignature := signature.ProvideService(pluginManagementCfg, keyretrieverService) localProvider := pluginassets.NewLocalProvider() - pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg) - bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider, pluginscdnService) + bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider) unsignedPluginAuthorizer := signature.ProvideOSSAuthorizer(pluginManagementCfg) validation := signature.ProvideValidatorService(unsignedPluginAuthorizer) angularpatternsstoreService := angularpatternsstore.ProvideService(kvStore) @@ -715,7 +714,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, pluginstoreService) + pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg) + pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, signatureSignature, pluginstoreService) avatarCacheServer := avatar.ProvideAvatarCacheServer(cfg) prefService := prefimpl.ProvideService(sqlStore, cfg) dashboardPermissionsService, err := ossaccesscontrol.ProvideDashboardPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, dashboardService, folderimplService, acimplService, teamService, userService, actionSetService, dashboardServiceImpl, eventualRestConfigProvider) @@ -1042,8 +1042,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac keyretrieverService := keyretriever.ProvideService(keyRetriever) signatureSignature := signature.ProvideService(pluginManagementCfg, keyretrieverService) localProvider := pluginassets.NewLocalProvider() - pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg) - bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider, pluginscdnService) + bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider) unsignedPluginAuthorizer := signature.ProvideOSSAuthorizer(pluginManagementCfg) validation := signature.ProvideValidatorService(unsignedPluginAuthorizer) angularpatternsstoreService := angularpatternsstore.ProvideService(kvStore) @@ -1383,7 +1382,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, pluginstoreService) + pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg) + pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, signatureSignature, pluginstoreService) avatarCacheServer := avatar.ProvideAvatarCacheServer(cfg) prefService := prefimpl.ProvideService(sqlStore, cfg) dashboardPermissionsService, err := ossaccesscontrol.ProvideDashboardPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, dashboardService, folderimplService, acimplService, teamService, userService, actionSetService, dashboardServiceImpl, eventualRestConfigProvider) diff --git a/pkg/services/pluginsintegration/loader/loader_test.go b/pkg/services/pluginsintegration/loader/loader_test.go index eee04acedfc..a5a7a7fd8db 100644 --- a/pkg/services/pluginsintegration/loader/loader_test.go +++ b/pkg/services/pluginsintegration/loader/loader_test.go @@ -26,7 +26,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/plugins/pluginassets" "github.com/grafana/grafana/pkg/plugins/pluginerrs" - "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" "github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins" @@ -214,27 +213,10 @@ func TestLoader_Load(t *testing.T) { ExtensionPoints: []plugins.ExtensionPoint{}, }, }, - Class: plugins.ClassExternal, - Module: "public/plugins/test-app/module.js", - BaseURL: "public/plugins/test-app", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "includes-symlinks")), - Manifest: &plugins.PluginManifest{ - Plugin: "test-app", - Version: "1.0.0", - KeyID: "7e4d0c6a708866e7", - Time: 1622547655175, - Files: map[string]string{ - "dashboards/connections.json": "bea86da4be970b98dc4681802ab55cdef3441dc3eb3c654cb207948d17b25303", - "dashboards/extra/memory.json": "7c042464941084caa91d0a9a2f188b05315a9796308a652ccdee31ca4fbcbfee", - "plugin.json": "c59a51bf6d7ecd7a99608ccb99353390c8b973672a938a0247164324005c0caf", - "symlink_to_txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048", - "text.txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048", - }, - ManifestVersion: "2.0.0", - SignatureType: plugins.SignatureTypeGrafana, - SignedByOrg: "grafana", - SignedByOrgName: "Grafana Labs", - }, + Class: plugins.ClassExternal, + Module: "public/plugins/test-app/module.js", + BaseURL: "public/plugins/test-app", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "includes-symlinks")), Signature: "valid", SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", @@ -665,24 +647,10 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) { Executable: "test", State: plugins.ReleaseStateAlpha, }, - Class: plugins.ClassExternal, - Module: "public/plugins/test-datasource/module.js", - BaseURL: "public/plugins/test-datasource", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature/plugin")), - Manifest: &plugins.PluginManifest{ - Plugin: "test-datasource", - Version: "1.0.0", - KeyID: "7e4d0c6a708866e7", - Time: 1661171417046, - Files: map[string]string{ - "plugin.json": "203ef4a613c5693c437a665cd67f95e2756a0f71b336b2ffb265db7c180d0b19", - }, - ManifestVersion: "2.0.0", - SignatureType: plugins.SignatureTypePrivate, - SignedByOrg: "willbrowne", - SignedByOrgName: "Will Browne", - RootURLs: []string{"http://localhost:3000/"}, - }, + Class: plugins.ClassExternal, + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature/plugin")), Signature: "valid", SignatureType: plugins.SignatureTypePrivate, SignatureOrg: "Will Browne", @@ -799,22 +767,8 @@ func TestLoader_Load_RBACReady(t *testing.T) { }, Backend: false, }, - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-roles")), - Class: plugins.ClassExternal, - Manifest: &plugins.PluginManifest{ - Plugin: "test-app", - Version: "1.0.0", - KeyID: "7e4d0c6a708866e7", - Time: 1667484928676, - Files: map[string]string{ - "plugin.json": "3348335ec100392b325f3eeb882a07c729e9cbf0f1ae331239f46840bb1a01eb", - }, - ManifestVersion: "2.0.0", - SignatureType: plugins.SignatureTypePrivate, - SignedByOrg: "gabrielmabille", - SignedByOrgName: "gabrielmabille", - RootURLs: []string{"http://localhost:3000/"}, - }, + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-roles")), + Class: plugins.ClassExternal, Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypePrivate, SignatureOrg: "gabrielmabille", @@ -883,22 +837,8 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) { Backend: true, Executable: "test", }, - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature-root-url-uri/plugin")), - Class: plugins.ClassExternal, - Manifest: &plugins.PluginManifest{ - Plugin: "test-datasource", - Version: "1.0.0", - KeyID: "7e4d0c6a708866e7", - Time: 1661171981629, - Files: map[string]string{ - "plugin.json": "203ef4a613c5693c437a665cd67f95e2756a0f71b336b2ffb265db7c180d0b19", - }, - ManifestVersion: "2.0.0", - SignatureType: plugins.SignatureTypePrivate, - SignedByOrg: "willbrowne", - SignedByOrgName: "Will Browne", - RootURLs: []string{"http://localhost:3000/grafana"}, - }, + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature-root-url-uri/plugin")), + Class: plugins.ClassExternal, Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypePrivate, SignatureOrg: "Will Browne", @@ -985,24 +925,8 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) { }, Backend: false, }, - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app")), - Class: plugins.ClassExternal, - Manifest: &plugins.PluginManifest{ - Plugin: "test-app", - Version: "1.0.0", - KeyID: "7e4d0c6a708866e7", - Time: 1621356785895, - Files: map[string]string{ - "plugin.json": "c59a51bf6d7ecd7a99608ccb99353390c8b973672a938a0247164324005c0caf", - "dashboards/connections.json": "bea86da4be970b98dc4681802ab55cdef3441dc3eb3c654cb207948d17b25303", - "dashboards/memory.json": "7c042464941084caa91d0a9a2f188b05315a9796308a652ccdee31ca4fbcbfee", - "dashboards/connections_result.json": "124d85c9c2e40214b83273f764574937a79909cfac3f925276fbb72543c224dc", - }, - ManifestVersion: "2.0.0", - SignatureType: plugins.SignatureTypeGrafana, - SignedByOrg: "grafana", - SignedByOrgName: "Grafana Labs", - }, + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app")), + Class: plugins.ClassExternal, Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", @@ -1093,24 +1017,8 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) { }, Backend: false, }, - FS: mustNewStaticFSForTests(t, pluginDir1), - Class: plugins.ClassExternal, - Manifest: &plugins.PluginManifest{ - Plugin: "test-app", - Version: "1.0.0", - KeyID: "7e4d0c6a708866e7", - Time: 1621356785895, - Files: map[string]string{ - "plugin.json": "c59a51bf6d7ecd7a99608ccb99353390c8b973672a938a0247164324005c0caf", - "dashboards/connections.json": "bea86da4be970b98dc4681802ab55cdef3441dc3eb3c654cb207948d17b25303", - "dashboards/memory.json": "7c042464941084caa91d0a9a2f188b05315a9796308a652ccdee31ca4fbcbfee", - "dashboards/connections_result.json": "124d85c9c2e40214b83273f764574937a79909cfac3f925276fbb72543c224dc", - }, - ManifestVersion: "2.0.0", - SignatureType: plugins.SignatureTypeGrafana, - SignedByOrg: "grafana", - SignedByOrgName: "Grafana Labs", - }, + FS: mustNewStaticFSForTests(t, pluginDir1), + Class: plugins.ClassExternal, Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", @@ -1272,23 +1180,9 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { }, Backend: true, }, - Module: "public/plugins/test-datasource/module.js", - BaseURL: "public/plugins/test-datasource", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent")), - Manifest: &plugins.PluginManifest{ - Plugin: "test-datasource", - Version: "1.0.0", - KeyID: "7e4d0c6a708866e7", - Time: 1661172777367, - Files: map[string]string{ - "plugin.json": "a029469ace740e9502bfb0d40924d1cccae73d0b18adcd8f1ceb7f17bf36beb8", - "nested/plugin.json": "e64abd35cd211e0e4682974ad5cdd1be7a0b7cd24951d302a16d9e2cb6cefea4", - }, - ManifestVersion: "2.0.0", - SignatureType: plugins.SignatureTypeGrafana, - SignedByOrg: "grafana", - SignedByOrgName: "Grafana Labs", - }, + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent")), Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", @@ -1331,23 +1225,9 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { ExtensionPoints: []plugins.ExtensionPoint{}, }, }, - Module: "public/plugins/test-panel/module.js", - BaseURL: "public/plugins/test-panel", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent/nested")), - Manifest: &plugins.PluginManifest{ - Plugin: "test-datasource", - Version: "1.0.0", - KeyID: "7e4d0c6a708866e7", - Time: 1661172777367, - Files: map[string]string{ - "plugin.json": "a029469ace740e9502bfb0d40924d1cccae73d0b18adcd8f1ceb7f17bf36beb8", - "nested/plugin.json": "e64abd35cd211e0e4682974ad5cdd1be7a0b7cd24951d302a16d9e2cb6cefea4", - }, - ManifestVersion: "2.0.0", - SignatureType: plugins.SignatureTypeGrafana, - SignedByOrg: "grafana", - SignedByOrgName: "Grafana Labs", - }, + Module: "public/plugins/test-panel/module.js", + BaseURL: "public/plugins/test-panel", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent/nested")), Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", @@ -1495,25 +1375,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { }, Backend: false, }, - Module: "public/plugins/myorgid-simple-app/module.js", - BaseURL: "public/plugins/myorgid-simple-app", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist")), - DefaultNavURL: "/plugins/myorgid-simple-app/page/root-page-react", - Manifest: &plugins.PluginManifest{ - Plugin: "myorgid-simple-app", - Version: "%VERSION%", - KeyID: "7e4d0c6a708866e7", - Time: 1642614241713, - Files: map[string]string{ - "plugin.json": "1abecfd0229814f6c284ff3c8dd744548f8d676ab3250cd7902c99dabf11480e", - "child/plugin.json": "66ba0dffaf3b1bfa17eb9a8672918fc66d1001f465b1061f4fc19c2f2c100f51", - }, - ManifestVersion: "2.0.0", - SignatureType: plugins.SignatureTypeGrafana, - SignedByOrg: "grafana", - SignedByOrgName: "Grafana Labs", - RootURLs: []string{}, - }, + Module: "public/plugins/myorgid-simple-app/module.js", + BaseURL: "public/plugins/myorgid-simple-app", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist")), + DefaultNavURL: "/plugins/myorgid-simple-app/page/root-page-react", Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", @@ -1566,21 +1431,6 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { BaseURL: "public/plugins/myorgid-simple-panel", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist/child")), IncludedInAppID: parent.ID, - Manifest: &plugins.PluginManifest{ - Plugin: "myorgid-simple-app", - Version: "%VERSION%", - KeyID: "7e4d0c6a708866e7", - Time: 1642614241713, - Files: map[string]string{ - "plugin.json": "1abecfd0229814f6c284ff3c8dd744548f8d676ab3250cd7902c99dabf11480e", - "child/plugin.json": "66ba0dffaf3b1bfa17eb9a8672918fc66d1001f465b1061f4fc19c2f2c100f51", - }, - ManifestVersion: "2.0.0", - SignatureType: plugins.SignatureTypeGrafana, - SignedByOrg: "grafana", - SignedByOrgName: "Grafana Labs", - RootURLs: []string{}, - }, Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", @@ -1634,7 +1484,7 @@ func newLoader(t *testing.T, cfg *config.PluginManagementCfg, reg registry.Servi require.NoError(t, err) return ProvideService(cfg, pipeline.ProvideDiscoveryStage(cfg, reg), - pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginAssetsProvider, pluginscdn.ProvideService(cfg)), + pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginAssetsProvider), pipeline.ProvideValidationStage(cfg, signature.NewValidator(signature.NewUnsignedAuthorizer(cfg)), angularInspector), pipeline.ProvideInitializationStage(cfg, reg, backendFactory, proc, &pluginfakes.FakeAuthService{}, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), pluginfakes.NewFakePluginEnvProvider(), tracing.InitializeTracerForTest(), provisionedplugins.NewNoop()), terminate, errTracker) @@ -1664,7 +1514,7 @@ func newLoaderWithOpts(t *testing.T, cfg *config.PluginManagementCfg, opts loade } return ProvideService(cfg, pipeline.ProvideDiscoveryStage(cfg, reg), - pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginassets.NewLocalProvider(), pluginscdn.ProvideService(cfg)), + pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginassets.NewLocalProvider()), pipeline.ProvideValidationStage(cfg, signature.NewValidator(signature.NewUnsignedAuthorizer(cfg)), angularInspector), pipeline.ProvideInitializationStage(cfg, reg, backendFactoryProvider, proc, authServiceRegistry, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), pluginfakes.NewFakePluginEnvProvider(), tracing.InitializeTracerForTest(), provisionedplugins.NewNoop()), terminate, errTracker) diff --git a/pkg/services/pluginsintegration/pipeline/pipeline.go b/pkg/services/pluginsintegration/pipeline/pipeline.go index 94b5331ac00..f377e9eb867 100644 --- a/pkg/services/pluginsintegration/pipeline/pipeline.go +++ b/pkg/services/pluginsintegration/pipeline/pipeline.go @@ -18,7 +18,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/pluginassets" - "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/services/pluginsintegration/coreplugin" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" "github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins" @@ -43,7 +42,7 @@ func ProvideDiscoveryStage(cfg *config.PluginManagementCfg, pr registry.Service) }) } -func ProvideBootstrapStage(cfg *config.PluginManagementCfg, sc plugins.SignatureCalculator, ap pluginassets.Provider, cdn *pluginscdn.Service) *bootstrap.Bootstrap { +func ProvideBootstrapStage(cfg *config.PluginManagementCfg, sc plugins.SignatureCalculator, ap pluginassets.Provider) *bootstrap.Bootstrap { disableAlertingForTempoDecorateFunc := func(ctx context.Context, p *plugins.Plugin) (*plugins.Plugin, error) { if p.ID == coreplugin.Tempo && !cfg.Features.TempoAlertingEnabled { p.Alerting = false @@ -53,7 +52,7 @@ func ProvideBootstrapStage(cfg *config.PluginManagementCfg, sc plugins.Signature return bootstrap.New(cfg, bootstrap.Opts{ ConstructFunc: bootstrap.DefaultConstructFunc(cfg, sc, ap), - DecorateFuncs: append(bootstrap.DefaultDecorateFuncs(cfg, cdn), disableAlertingForTempoDecorateFunc), + DecorateFuncs: append(bootstrap.DefaultDecorateFuncs(cfg), disableAlertingForTempoDecorateFunc), }) } diff --git a/pkg/services/pluginsintegration/pluginassets/pluginassets.go b/pkg/services/pluginsintegration/pluginassets/pluginassets.go index 8735f7a4354..4d9a7ec1a53 100644 --- a/pkg/services/pluginsintegration/pluginassets/pluginassets.go +++ b/pkg/services/pluginsintegration/pluginassets/pluginassets.go @@ -2,12 +2,19 @@ package pluginassets import ( "context" + "encoding/base64" + "encoding/hex" + "fmt" + "path" + "path/filepath" + "sync" "github.com/Masterminds/semver/v3" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" ) @@ -21,20 +28,24 @@ var ( scriptLoadingMinSupportedVersion = semver.MustParse(CreatePluginVersionScriptSupportEnabled) ) -func ProvideService(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service, store pluginstore.Store) *Service { +func ProvideService(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service, sig *signature.Signature, store pluginstore.Store) *Service { return &Service{ - cfg: cfg, - cdn: cdn, - store: store, - log: log.New("pluginassets"), + cfg: cfg, + cdn: cdn, + signature: sig, + store: store, + log: log.New("pluginassets"), } } type Service struct { - cfg *config.PluginManagementCfg - cdn *pluginscdn.Service - store pluginstore.Store - log log.Logger + cfg *config.PluginManagementCfg + cdn *pluginscdn.Service + signature *signature.Signature + store pluginstore.Store + log log.Logger + + moduleHashCache sync.Map } // LoadingStrategy calculates the loading strategy for a plugin. @@ -71,6 +82,95 @@ func (s *Service) LoadingStrategy(_ context.Context, p pluginstore.Plugin) plugi return plugins.LoadingStrategyFetch } +// ModuleHash returns the module.js SHA256 hash for a plugin in the format expected by the browser for SRI checks. +// The module hash is read from the plugin's MANIFEST.txt file. +// The plugin can also be a nested plugin. +// If the plugin is unsigned, an empty string is returned. +// The results are cached to avoid repeated reads from the MANIFEST.txt file. +func (s *Service) ModuleHash(ctx context.Context, p pluginstore.Plugin) string { + k := s.moduleHashCacheKey(p) + cachedValue, ok := s.moduleHashCache.Load(k) + if ok { + return cachedValue.(string) + } + mh, err := s.moduleHash(ctx, p, "") + if err != nil { + s.log.Error("Failed to calculate module hash", "plugin", p.ID, "error", err) + } + s.moduleHashCache.Store(k, mh) + return mh +} + +// moduleHash is the underlying function for ModuleHash. See its documentation for more information. +// If the plugin is not a CDN plugin, the function will return an empty string. +// It will read the module hash from the MANIFEST.txt in the [[plugins.FS]] of the provided plugin. +// If childFSBase is provided, the function will try to get the hash from MANIFEST.txt for the provided children's +// module.js file, rather than for the provided plugin. +func (s *Service) moduleHash(ctx context.Context, p pluginstore.Plugin, childFSBase string) (r string, err error) { + if !s.cfg.Features.SriChecksEnabled { + return "", nil + } + + // Ignore unsigned plugins + if !p.Signature.IsValid() { + return "", nil + } + + if p.Parent != nil { + // Nested plugin + parent, ok := s.store.Plugin(ctx, p.Parent.ID) + if !ok { + return "", fmt.Errorf("parent plugin plugin %q for child plugin %q not found", p.Parent.ID, p.ID) + } + + // The module hash is contained within the parent's MANIFEST.txt file. + // For example, the parent's MANIFEST.txt will contain an entry similar to this: + // + // ``` + // "datasource/module.js": "1234567890abcdef..." + // ``` + // + // Recursively call moduleHash with the parent plugin and with the children plugin folder path + // to get the correct module hash for the nested plugin. + if childFSBase == "" { + childFSBase = p.Base() + } + return s.moduleHash(ctx, parent, childFSBase) + } + + // Only CDN plugins are supported for SRI checks. + // CDN plugins have the version as part of the URL, which acts as a cache-buster. + // Needed due to: https://github.com/grafana/plugin-tools/pull/1426 + // FS plugins build before this change will have SRI mismatch issues. + if !s.cdnEnabled(p.ID, p.FS) { + return "", nil + } + + manifest, err := s.signature.ReadPluginManifestFromFS(ctx, p.FS) + if err != nil { + return "", fmt.Errorf("read plugin manifest: %w", err) + } + if !manifest.IsV2() { + return "", nil + } + + var childPath string + if childFSBase != "" { + // Calculate the relative path of the child plugin folder from the parent plugin folder. + childPath, err = p.FS.Rel(childFSBase) + if err != nil { + return "", fmt.Errorf("rel path: %w", err) + } + // MANIFETS.txt uses forward slashes as path separators. + childPath = filepath.ToSlash(childPath) + } + moduleHash, ok := manifest.Files[path.Join(childPath, "module.js")] + if !ok { + return "", nil + } + return convertHashForSRI(moduleHash) +} + func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool { if cpv, ok := ps[CreatePluginVersionCfgKey]; ok { createPluginVer, err := semver.NewVersion(cpv) @@ -88,3 +188,17 @@ func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool { func (s *Service) cdnEnabled(pluginID string, fs plugins.FS) bool { return s.cdn.PluginSupported(pluginID) || fs.Type().CDN() } + +// convertHashForSRI takes a SHA256 hash string and returns it as expected by the browser for SRI checks. +func convertHashForSRI(h string) (string, error) { + hb, err := hex.DecodeString(h) + if err != nil { + return "", fmt.Errorf("hex decode string: %w", err) + } + return "sha256-" + base64.StdEncoding.EncodeToString(hb), nil +} + +// moduleHashCacheKey returns a unique key for the module hash cache. +func (s *Service) moduleHashCacheKey(p pluginstore.Plugin) string { + return p.ID + ":" + p.Info.Version +} diff --git a/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go b/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go index 91b8b515bb1..192717c34ff 100644 --- a/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go +++ b/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go @@ -2,14 +2,19 @@ package pluginassets import ( "context" + "fmt" + "path/filepath" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/manager/pluginfakes" + "github.com/grafana/grafana/pkg/plugins/manager/signature" + "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" ) @@ -174,6 +179,349 @@ func TestService_Calculate(t *testing.T) { } } +func TestService_ModuleHash(t *testing.T) { + const ( + pluginID = "grafana-test-datasource" + parentPluginID = "grafana-test-app" + ) + for _, tc := range []struct { + name string + features *config.Features + store []pluginstore.Plugin + + // Can be used to configure plugin's fs + // fs cdn type = loaded from CDN with no files on disk + // fs local type = files on disk but served from CDN only if cdn=true + plugin pluginstore.Plugin + + // When true, set cdn=true in config + cdn bool + expModuleHash string + }{ + { + name: "unsigned should not return module hash", + plugin: newPlugin(pluginID, withSignatureStatus(plugins.SignatureStatusUnsigned)), + cdn: false, + features: &config.Features{SriChecksEnabled: false}, + expModuleHash: "", + }, + { + plugin: newPlugin( + pluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))), + withClass(plugins.ClassExternal), + ), + cdn: true, + features: &config.Features{SriChecksEnabled: true}, + expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"), + }, + { + plugin: newPlugin( + pluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))), + withClass(plugins.ClassExternal), + ), + cdn: true, + features: &config.Features{SriChecksEnabled: true}, + expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"), + }, + { + plugin: newPlugin( + pluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))), + ), + cdn: false, + features: &config.Features{SriChecksEnabled: true}, + expModuleHash: "", + }, + { + plugin: newPlugin( + pluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))), + ), + cdn: true, + features: &config.Features{SriChecksEnabled: false}, + expModuleHash: "", + }, + { + plugin: newPlugin( + pluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))), + ), + cdn: false, + features: &config.Features{SriChecksEnabled: false}, + expModuleHash: "", + }, + { + // parentPluginID (/) + // └── pluginID (/datasource) + name: "nested plugin should return module hash from parent MANIFEST.txt", + store: []pluginstore.Plugin{ + newPlugin( + parentPluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))), + ), + }, + plugin: newPlugin( + pluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "datasource"))), + withParent(parentPluginID), + ), + cdn: true, + features: &config.Features{SriChecksEnabled: true}, + expModuleHash: newSRIHash(t, "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711"), + }, + { + // parentPluginID (/) + // └── pluginID (/panels/one) + name: "nested plugin deeper than one subfolder should return module hash from parent MANIFEST.txt", + store: []pluginstore.Plugin{ + newPlugin( + parentPluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))), + ), + }, + plugin: newPlugin( + pluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one"))), + withParent(parentPluginID), + ), + cdn: true, + features: &config.Features{SriChecksEnabled: true}, + expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"), + }, + { + // grand-parent-app (/) + // ├── parent-datasource (/datasource) + // │ └── child-panel (/datasource/panels/one) + name: "nested plugin of a nested plugin should return module hash from parent MANIFEST.txt", + store: []pluginstore.Plugin{ + newPlugin( + "grand-parent-app", + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested"))), + ), + newPlugin( + "parent-datasource", + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource"))), + withParent("grand-parent-app"), + ), + }, + plugin: newPlugin( + "child-panel", + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource", "panels", "one"))), + withParent("parent-datasource"), + ), + cdn: true, + features: &config.Features{SriChecksEnabled: true}, + expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"), + }, + { + name: "nested plugin should not return module hash from parent if it's not registered in the store", + store: []pluginstore.Plugin{}, + plugin: newPlugin( + pluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one"))), + withParent(parentPluginID), + ), + cdn: false, + features: &config.Features{SriChecksEnabled: true}, + expModuleHash: "", + }, + { + name: "missing module.js entry from MANIFEST.txt should not return module hash", + plugin: newPlugin( + pluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-module-js"))), + ), + cdn: false, + features: &config.Features{SriChecksEnabled: true}, + expModuleHash: "", + }, + { + name: "signed status but missing MANIFEST.txt should not return module hash", + plugin: newPlugin( + pluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-manifest-txt"))), + ), + cdn: false, + features: &config.Features{SriChecksEnabled: true}, + expModuleHash: "", + }, + } { + if tc.name == "" { + var expS string + if tc.expModuleHash == "" { + expS = "should not return module hash" + } else { + expS = "should return module hash" + } + tc.name = fmt.Sprintf("feature=%v, cdn_config=%v, class=%v %s", tc.features.SriChecksEnabled, tc.cdn, tc.plugin.Class, expS) + } + + t.Run(tc.name, func(t *testing.T) { + var pluginSettings config.PluginSettings + if tc.cdn { + pluginSettings = config.PluginSettings{ + pluginID: { + "cdn": "true", + }, + parentPluginID: map[string]string{ + "cdn": "true", + }, + "grand-parent-app": map[string]string{ + "cdn": "true", + }, + } + } + features := tc.features + if features == nil { + features = &config.Features{} + } + pCfg := &config.PluginManagementCfg{ + PluginsCDNURLTemplate: "http://cdn.example.com", + PluginSettings: pluginSettings, + Features: *features, + } + svc := ProvideService( + pCfg, + pluginscdn.ProvideService(pCfg), + signature.ProvideService(pCfg, statickey.New()), + pluginstore.NewFakePluginStore(tc.store...), + ) + mh := svc.ModuleHash(context.Background(), tc.plugin) + require.Equal(t, tc.expModuleHash, mh) + }) + } +} + +func TestService_ModuleHash_Cache(t *testing.T) { + pCfg := &config.PluginManagementCfg{ + PluginSettings: config.PluginSettings{}, + Features: config.Features{SriChecksEnabled: true}, + } + svc := ProvideService( + pCfg, + pluginscdn.ProvideService(pCfg), + signature.ProvideService(pCfg, statickey.New()), + pluginstore.NewFakePluginStore(), + ) + const pluginID = "grafana-test-datasource" + + t.Run("cache key", func(t *testing.T) { + t.Run("with version", func(t *testing.T) { + const pluginVersion = "1.0.0" + p := newPlugin(pluginID, withInfo(plugins.Info{Version: pluginVersion})) + k := svc.moduleHashCacheKey(p) + require.Equal(t, pluginID+":"+pluginVersion, k, "cache key should be correct") + }) + + t.Run("without version", func(t *testing.T) { + p := newPlugin(pluginID) + k := svc.moduleHashCacheKey(p) + require.Equal(t, pluginID+":", k, "cache key should be correct") + }) + }) + + t.Run("ModuleHash usage", func(t *testing.T) { + pV1 := newPlugin( + pluginID, + withInfo(plugins.Info{Version: "1.0.0"}), + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))), + ) + + pCfg = &config.PluginManagementCfg{ + PluginsCDNURLTemplate: "https://cdn.grafana.com", + PluginSettings: config.PluginSettings{ + pluginID: { + "cdn": "true", + }, + }, + Features: config.Features{SriChecksEnabled: true}, + } + svc = ProvideService( + pCfg, + pluginscdn.ProvideService(pCfg), + signature.ProvideService(pCfg, statickey.New()), + pluginstore.NewFakePluginStore(), + ) + + k := svc.moduleHashCacheKey(pV1) + + _, ok := svc.moduleHashCache.Load(k) + require.False(t, ok, "cache should initially be empty") + + mhV1 := svc.ModuleHash(context.Background(), pV1) + pV1Exp := newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03") + require.Equal(t, pV1Exp, mhV1, "returned value should be correct") + + cachedMh, ok := svc.moduleHashCache.Load(k) + require.True(t, ok) + require.Equal(t, pV1Exp, cachedMh, "cache should contain the returned value") + + t.Run("different version uses different cache key", func(t *testing.T) { + pV2 := newPlugin( + pluginID, + withInfo(plugins.Info{Version: "2.0.0"}), + withSignatureStatus(plugins.SignatureStatusValid), + // different fs for different hash + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))), + ) + mhV2 := svc.ModuleHash(context.Background(), pV2) + require.NotEqual(t, mhV2, mhV1, "different version should have different hash") + require.Equal(t, newSRIHash(t, "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a"), mhV2) + }) + + t.Run("cache should be used", func(t *testing.T) { + // edit cache directly + svc.moduleHashCache.Store(k, "hax") + require.Equal(t, "hax", svc.ModuleHash(context.Background(), pV1)) + }) + }) +} + +func TestConvertHashFromSRI(t *testing.T) { + for _, tc := range []struct { + hash string + expHash string + expErr bool + }{ + { + hash: "ddfcb449445064e6c39f0c20b15be3cb6a55837cf4781df23d02de005f436811", + expHash: "sha256-3fy0SURQZObDnwwgsVvjy2pVg3z0eB3yPQLeAF9DaBE=", + }, + { + hash: "not-a-valid-hash", + expErr: true, + }, + } { + t.Run(tc.hash, func(t *testing.T) { + r, err := convertHashForSRI(tc.hash) + if tc.expErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tc.expHash, r) + } + }) + } +} + func newPlugin(pluginID string, cbs ...func(p pluginstore.Plugin) pluginstore.Plugin) pluginstore.Plugin { p := pluginstore.Plugin{ JSONData: plugins.JSONData{ @@ -186,6 +534,13 @@ func newPlugin(pluginID string, cbs ...func(p pluginstore.Plugin) pluginstore.Pl return p } +func withInfo(info plugins.Info) func(p pluginstore.Plugin) pluginstore.Plugin { + return func(p pluginstore.Plugin) pluginstore.Plugin { + p.Info = info + return p + } +} + func withFS(fs plugins.FS) func(p pluginstore.Plugin) pluginstore.Plugin { return func(p pluginstore.Plugin) pluginstore.Plugin { p.FS = fs @@ -193,6 +548,13 @@ func withFS(fs plugins.FS) func(p pluginstore.Plugin) pluginstore.Plugin { } } +func withSignatureStatus(status plugins.SignatureStatus) func(p pluginstore.Plugin) pluginstore.Plugin { + return func(p pluginstore.Plugin) pluginstore.Plugin { + p.Signature = status + return p + } +} + func withAngular(angular bool) func(p pluginstore.Plugin) pluginstore.Plugin { return func(p pluginstore.Plugin) pluginstore.Plugin { p.Angular = plugins.AngularMeta{Detected: angular} @@ -200,6 +562,13 @@ func withAngular(angular bool) func(p pluginstore.Plugin) pluginstore.Plugin { } } +func withParent(parentID string) func(p pluginstore.Plugin) pluginstore.Plugin { + return func(p pluginstore.Plugin) pluginstore.Plugin { + p.Parent = &pluginstore.ParentPlugin{ID: parentID} + return p + } +} + func withClass(class plugins.Class) func(p pluginstore.Plugin) pluginstore.Plugin { return func(p pluginstore.Plugin) pluginstore.Plugin { p.Class = class @@ -218,3 +587,9 @@ func newPluginSettings(pluginID string, kv map[string]string) config.PluginSetti pluginID: kv, } } + +func newSRIHash(t *testing.T, s string) string { + r, err := convertHashForSRI(s) + require.NoError(t, err) + return r +} diff --git a/pkg/plugins/pluginassets/testdata/module-hash-no-manifest-txt/module.js b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-manifest-txt/module.js similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-no-manifest-txt/module.js rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-manifest-txt/module.js diff --git a/pkg/plugins/pluginassets/testdata/module-hash-no-manifest-txt/plugin.json b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-manifest-txt/plugin.json similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-no-manifest-txt/plugin.json rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-manifest-txt/plugin.json diff --git a/pkg/plugins/pluginassets/testdata/module-hash-no-module-js/MANIFEST.txt b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/MANIFEST.txt similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-no-module-js/MANIFEST.txt rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/MANIFEST.txt diff --git a/pkg/plugins/pluginassets/testdata/module-hash-no-module-js/plugin.json b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/plugin.json similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-no-module-js/plugin.json rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/plugin.json diff --git a/pkg/plugins/pluginassets/testdata/module-hash-no-module-js/something.js b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/something.js similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-no-module-js/something.js rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/something.js diff --git a/pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/MANIFEST.txt b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/MANIFEST.txt similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/MANIFEST.txt rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/MANIFEST.txt diff --git a/pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/module.js b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/module.js similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/module.js rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/module.js diff --git a/pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/module.js b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/module.js similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/module.js rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/module.js diff --git a/pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/plugin.json b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/plugin.json similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/plugin.json rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/plugin.json diff --git a/pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/plugin.json b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/plugin.json similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/plugin.json rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/plugin.json diff --git a/pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/module.js b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/module.js similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/module.js rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/module.js diff --git a/pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/plugin.json b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/plugin.json similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-valid-deeply-nested/plugin.json rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/plugin.json diff --git a/pkg/plugins/pluginassets/testdata/module-hash-valid-nested/MANIFEST.txt b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/MANIFEST.txt similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-valid-nested/MANIFEST.txt rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/MANIFEST.txt diff --git a/pkg/plugins/pluginassets/testdata/module-hash-valid-nested/datasource/module.js b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/datasource/module.js similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-valid-nested/datasource/module.js rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/datasource/module.js diff --git a/pkg/plugins/pluginassets/testdata/module-hash-valid-nested/datasource/plugin.json b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/datasource/plugin.json similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-valid-nested/datasource/plugin.json rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/datasource/plugin.json diff --git a/pkg/plugins/pluginassets/testdata/module-hash-valid-nested/module.js b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/module.js similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-valid-nested/module.js rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/module.js diff --git a/pkg/plugins/pluginassets/testdata/module-hash-valid-nested/panels/one/module.js b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/panels/one/module.js similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-valid-nested/panels/one/module.js rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/panels/one/module.js diff --git a/pkg/plugins/pluginassets/testdata/module-hash-valid-nested/panels/one/plugin.json b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/panels/one/plugin.json similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-valid-nested/panels/one/plugin.json rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/panels/one/plugin.json diff --git a/pkg/plugins/pluginassets/testdata/module-hash-valid-nested/plugin.json b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/plugin.json similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-valid-nested/plugin.json rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/plugin.json diff --git a/pkg/plugins/pluginassets/testdata/module-hash-valid/MANIFEST.txt b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/MANIFEST.txt similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-valid/MANIFEST.txt rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/MANIFEST.txt diff --git a/pkg/plugins/pluginassets/testdata/module-hash-valid/module.js b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/module.js similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-valid/module.js rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/module.js diff --git a/pkg/plugins/pluginassets/testdata/module-hash-valid/plugin.json b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/plugin.json similarity index 100% rename from pkg/plugins/pluginassets/testdata/module-hash-valid/plugin.json rename to pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/plugin.json diff --git a/pkg/services/pluginsintegration/pluginstore/plugins.go b/pkg/services/pluginsintegration/pluginstore/plugins.go index 77fe1365fc3..f4504d254c5 100644 --- a/pkg/services/pluginsintegration/pluginstore/plugins.go +++ b/pkg/services/pluginsintegration/pluginstore/plugins.go @@ -30,9 +30,8 @@ type Plugin struct { Error *plugins.Error // SystemJS fields - Module string - BaseURL string - ModuleHash string + Module string + BaseURL string Angular plugins.AngularMeta @@ -81,7 +80,6 @@ func ToGrafanaDTO(p *plugins.Plugin) Plugin { ExternalService: p.ExternalService, Angular: p.Angular, Translations: p.Translations, - ModuleHash: p.ModuleHash, } if p.Parent != nil { diff --git a/pkg/services/pluginsintegration/test_helper.go b/pkg/services/pluginsintegration/test_helper.go index 9957fc11b22..9daad43e3e2 100644 --- a/pkg/services/pluginsintegration/test_helper.go +++ b/pkg/services/pluginsintegration/test_helper.go @@ -24,7 +24,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" "github.com/grafana/grafana/pkg/plugins/pluginassets" "github.com/grafana/grafana/pkg/plugins/pluginerrs" - "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/pluginsintegration/coreplugin" "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" @@ -50,7 +49,7 @@ func CreateIntegrationTestCtx(t *testing.T, cfg *setting.Cfg, coreRegistry *core proc := process.ProvideService() disc := pipeline.ProvideDiscoveryStage(pCfg, reg) - boot := pipeline.ProvideBootstrapStage(pCfg, signature.ProvideService(pCfg, statickey.New()), pluginassets.NewLocalProvider(), pluginscdn.ProvideService(pCfg)) + boot := pipeline.ProvideBootstrapStage(pCfg, signature.ProvideService(pCfg, statickey.New()), pluginassets.NewLocalProvider()) valid := pipeline.ProvideValidationStage(pCfg, signature.NewValidator(signature.NewUnsignedAuthorizer(pCfg)), angularInspector) init := pipeline.ProvideInitializationStage(pCfg, reg, coreplugin.ProvideCoreProvider(coreRegistry), proc, &pluginfakes.FakeAuthService{}, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), nil, tracing.InitializeTracerForTest(), provisionedplugins.NewNoop()) term, err := pipeline.ProvideTerminationStage(pCfg, reg, proc) @@ -88,7 +87,7 @@ func CreateTestLoader(t *testing.T, cfg *pluginsCfg.PluginManagementCfg, opts Lo } if opts.Bootstrapper == nil { - opts.Bootstrapper = pipeline.ProvideBootstrapStage(cfg, signature.ProvideService(cfg, statickey.New()), pluginassets.NewLocalProvider(), pluginscdn.ProvideService(cfg)) + opts.Bootstrapper = pipeline.ProvideBootstrapStage(cfg, signature.ProvideService(cfg, statickey.New()), pluginassets.NewLocalProvider()) } if opts.Validator == nil { From 60abd9a159139702769f0069a6de5ac3bd30f524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ida=20=C5=A0tambuk?= Date: Tue, 13 Jan 2026 19:42:47 +0100 Subject: [PATCH 26/57] Dynamic dashboards: Add tests for custom grid repeats (#114545) --- .github/CODEOWNERS | 1 + .../dashboards-repeats-custom-grid.spec.ts | 150 +++++- e2e-playwright/dashboard-new-layouts/utils.ts | 9 + .../dashboards/V2DashWithRowRepeats.json | 486 ++++++++++++++++++ 4 files changed, 640 insertions(+), 6 deletions(-) create mode 100644 e2e-playwright/dashboards/V2DashWithRowRepeats.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 98eb0aee15a..ca9de90ba02 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -440,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 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/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": {} +} From 82d8d44977d8149d8a8e4f2c30ec50b2b8a633e5 Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:44:36 -0700 Subject: [PATCH 27/57] Dashboard Conversion: Remove duplicated data loss function (#116214) remove duplicated dataloss function --- .../conversion/v2alpha1_to_v1beta1.go | 28 ------------------- 1 file changed, 28 deletions(-) diff --git a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go index 18d9ae90814..857af7ca866 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: From 6db51cbdb94ec76f863c54c514dc3a234f3bdc0d Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Tue, 13 Jan 2026 14:54:42 -0500 Subject: [PATCH 28/57] Legends: Revert scrolled truncated legend for now (#116217) * Revert "PieChart: Fix right-oriented legends (#116084)" This reverts commit 0c8c886930f31f805b4ee2f85e082cc0f5b5014e. * Revert "TimeSeries: Fix truncated label text in legend table mode (#115647)" This reverts commit f91efcfe2c8f6e6078d64118e3fd1332f9c12873. --- .../panel_test_piechart.v42.json | 26 +--- .../panel-piechart/panel_test_piechart.json | 26 +--- .../VizLegend/VizLegendTable.test.tsx | 78 ------------ .../VizLegend/VizLegendTableItem.test.tsx | 112 ------------------ .../VizLegend/VizLegendTableItem.tsx | 65 ++++------ 5 files changed, 30 insertions(+), 277 deletions(-) delete mode 100644 packages/grafana-ui/src/components/VizLegend/VizLegendTable.test.tsx delete mode 100644 packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.test.tsx diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-piechart/panel_test_piechart.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-piechart/panel_test_piechart.v42.json index 5b07f246ae3..f705124be5b 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-piechart/panel_test_piechart.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-piechart/panel_test_piechart.v42.json @@ -290,7 +290,7 @@ ], "legend": { "displayMode": "table", - "placement": "right", + "placement": "bottom", "showLegend": true, "values": [ "percent" @@ -304,7 +304,7 @@ "fields": "", "values": false }, - "showLegend": true, + "showLegend": false, "strokeWidth": 1, "text": {} }, @@ -323,15 +323,6 @@ } ], "title": "Percent", - "transformations": [ - { - "id": "renameByRegex", - "options": { - "regex": "^Backend-(.*)$", - "renamePattern": "b-$1" - } - } - ], "type": "piechart" }, { @@ -375,7 +366,7 @@ ], "legend": { "displayMode": "table", - "placement": "right", + "placement": "bottom", "showLegend": true, "values": [ "value" @@ -389,7 +380,7 @@ "fields": "", "values": false }, - "showLegend": true, + "showLegend": false, "strokeWidth": 1, "text": {} }, @@ -408,15 +399,6 @@ } ], "title": "Value", - "transformations": [ - { - "id": "renameByRegex", - "options": { - "regex": "(.*)", - "renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood" - } - } - ], "type": "piechart" }, { diff --git a/devenv/dev-dashboards/panel-piechart/panel_test_piechart.json b/devenv/dev-dashboards/panel-piechart/panel_test_piechart.json index ac11fd803b9..4333993ea8e 100644 --- a/devenv/dev-dashboards/panel-piechart/panel_test_piechart.json +++ b/devenv/dev-dashboards/panel-piechart/panel_test_piechart.json @@ -248,7 +248,7 @@ "legend": { "values": ["percent"], "displayMode": "table", - "placement": "right" + "placement": "bottom" }, "pieType": "pie", "reduceOptions": { @@ -256,7 +256,7 @@ "fields": "", "values": false }, - "showLegend": true, + "showLegend": false, "strokeWidth": 1, "text": {} }, @@ -272,15 +272,6 @@ "timeFrom": null, "timeShift": null, "title": "Percent", - "transformations": [ - { - "id": "renameByRegex", - "options": { - "regex": "^Backend-(.*)$", - "renamePattern": "b-$1" - } - } - ], "type": "piechart" }, { @@ -320,7 +311,7 @@ "legend": { "values": ["value"], "displayMode": "table", - "placement": "right" + "placement": "bottom" }, "pieType": "pie", "reduceOptions": { @@ -328,7 +319,7 @@ "fields": "", "values": false }, - "showLegend": true, + "showLegend": false, "strokeWidth": 1, "text": {} }, @@ -344,15 +335,6 @@ "timeFrom": null, "timeShift": null, "title": "Value", - "transformations": [ - { - "id": "renameByRegex", - "options": { - "regex": "(.*)", - "renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood" - } - } - ], "type": "piechart" }, { 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/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 6de77fd660b..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,28 +128,6 @@ const getStyles = (theme: GrafanaTheme2) => { background: rowHoverBg, }, }), - labelCell: css({ - label: 'LegendLabelCell', - maxWidth: 0, - width: '100%', - minWidth: theme.spacing(16), - }), - 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', @@ -159,6 +135,9 @@ const getStyles = (theme: GrafanaTheme2) => { border: 'none', fontSize: 'inherit', padding: 0, + maxWidth: '600px', + textOverflow: 'ellipsis', + overflow: 'hidden', userSelect: 'text', }), labelDisabled: css({ From e2f2011d9e66415e80951af4bcf3be38b3c464fb Mon Sep 17 00:00:00 2001 From: Anton Chimrov <89776717+chim678@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:01:22 +0100 Subject: [PATCH 29/57] Restore Canvas element key simplification to prevent blinking icons (#113693) * Simplify Canvas element key to prevent blinking icons * Fix formatting with prettier --- public/app/features/canvas/runtime/element.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/public/app/features/canvas/runtime/element.tsx b/public/app/features/canvas/runtime/element.tsx index 4a7c5ec8a7b..c1e3d519d8f 100644 --- a/public/app/features/canvas/runtime/element.tsx +++ b/public/app/features/canvas/runtime/element.tsx @@ -1108,12 +1108,7 @@ export class ElementState implements LayerElement { tabIndex={0} style={{ userSelect: 'none' }} > - + {this.showActionConfirmation && this.renderActionsConfirmModal(this.getPrimaryAction())} {this.showActionVarsModal && this.renderVariablesInputModal(this.getPrimaryAction())} From d3beed7dd23aa0ccd68e1aaf01728569491da774 Mon Sep 17 00:00:00 2001 From: sabithamuppuri Date: Tue, 13 Jan 2026 13:37:09 -0800 Subject: [PATCH 30/57] Docs: add unified_alerting.state_history configuration section (fixes #114670) (#115607) Co-authored-by: Pepe Cano <825430+ppcano@users.noreply.github.com> Co-authored-by: Johnny Kartheiser <140559259+JohnnyK-Grafana@users.noreply.github.com> --- .../setup-grafana/configure-grafana/_index.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index d9125991c12..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) From 215d25ef69d2f71363811f631d472dcc08053a19 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 00:43:07 +0000 Subject: [PATCH 31/57] I18n: Download translations from Crowdin (#116232) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/cs-CZ/grafana.json | 47 +++++++++++++++++++++++++++++ public/locales/de-DE/grafana.json | 47 +++++++++++++++++++++++++++++ public/locales/es-ES/grafana.json | 47 +++++++++++++++++++++++++++++ public/locales/fr-FR/grafana.json | 47 +++++++++++++++++++++++++++++ public/locales/hu-HU/grafana.json | 47 +++++++++++++++++++++++++++++ public/locales/id-ID/grafana.json | 47 +++++++++++++++++++++++++++++ public/locales/it-IT/grafana.json | 47 +++++++++++++++++++++++++++++ public/locales/ja-JP/grafana.json | 47 +++++++++++++++++++++++++++++ public/locales/ko-KR/grafana.json | 47 +++++++++++++++++++++++++++++ public/locales/nl-NL/grafana.json | 47 +++++++++++++++++++++++++++++ public/locales/pl-PL/grafana.json | 47 +++++++++++++++++++++++++++++ public/locales/pt-BR/grafana.json | 47 +++++++++++++++++++++++++++++ public/locales/pt-PT/grafana.json | 47 +++++++++++++++++++++++++++++ public/locales/ru-RU/grafana.json | 47 +++++++++++++++++++++++++++++ public/locales/sv-SE/grafana.json | 47 +++++++++++++++++++++++++++++ public/locales/tr-TR/grafana.json | 47 +++++++++++++++++++++++++++++ public/locales/zh-Hans/grafana.json | 47 +++++++++++++++++++++++++++++ public/locales/zh-Hant/grafana.json | 47 +++++++++++++++++++++++++++++ 18 files changed, 846 insertions(+) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 52d56bb948c..4e8a700bc26 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -11906,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?", @@ -12174,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 616960f15e7..1613801e0af 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -11806,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?", @@ -12070,6 +12116,7 @@ "jobs": "Aufträge" }, "repository-actions": { + "connections": "", "settings": "Einstellungen", "source-code": "Quellcode" }, diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index e4a00ce24fe..e94cd155216 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -11806,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?", @@ -12070,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 7d1ad65f31a..0954b9d56b2 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -11806,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 ?", @@ -12070,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 88e927039ac..deb86e3a541 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -11806,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?", @@ -12070,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 df8ee95d6aa..d0aea63ab48 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -11756,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?", @@ -12018,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 554727b9577..8b92ef5753d 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -11806,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?", @@ -12070,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 16cd9eb957c..376bd220001 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -11756,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": "リポジトリ設定とそのすべてのリソースを削除してもよろしいですか?", @@ -12018,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 2ea09fb8471..836406edf6a 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -11756,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": "정말 리포지토리 구성과 해당하는 모든 리소스를 삭제하시겠어요?", @@ -12018,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 7cf226932bc..aa02d1f7445 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -11806,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?", @@ -12070,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 4cf8c601bfc..715da5ac969 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -11906,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?", @@ -12174,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 aeb840530ca..c91b4cc89cc 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -11806,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?", @@ -12070,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 2a4c53052d8..42eec6cc55f 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -11806,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?", @@ -12070,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 571918fa148..c3f5952cb21 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -11906,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": "Вы уверены, что хотите удалить конфигурацию репозитория и все его ресурсы?", @@ -12174,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 27b318f57e3..aaa8f3197c1 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -11806,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?", @@ -12070,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 59d394e89a2..11425218c88 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -11806,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": "", @@ -12070,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 2a3c4b999d7..4a2a27e1ef4 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -11756,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": "您确定要删除存储库配置及其所有资源吗?", @@ -12018,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 246ad78138e..331313dccce 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -11756,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": "確定要刪除儲存庫設定及其所有資源嗎?", @@ -12018,6 +12064,7 @@ "jobs": "作業" }, "repository-actions": { + "connections": "", "settings": "設定", "source-code": "原始碼" }, From bd0140b6f0e62b019c182be768fd370be5a20563 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Wed, 14 Jan 2026 06:30:05 +0100 Subject: [PATCH 32/57] GrafanaBootData: Deprecate config.apps (#115610) * GrafanaBootData: decouple `config.apps` from boot data IV * chore: changed to openfeature flags eval * chore: updates after PR feedback * chore: updates after PR feedback * chore: copy types to runtime package * chore: add code ownership * chore: deprecate in interface too * chore: add important notice to comments * chore: deprecate the whole interface --- .github/CODEOWNERS | 1 + apps/plugins/Makefile | 13 +- apps/plugins/README.md | 3 +- apps/plugins/kinds/manifest.cue | 2 +- .../meta/v0alpha1/meta_object_gen.ts | 49 + .../meta/v0alpha1/types.metadata.gen.ts | 30 + .../generated/meta/v0alpha1/types.spec.gen.ts | 278 ++ .../meta/v0alpha1/types.status.gen.ts | 30 + .../plugin/v0alpha1/plugin_object_gen.ts | 49 + .../plugin/v0alpha1/types.metadata.gen.ts | 30 + .../plugin/v0alpha1/types.spec.gen.ts | 13 + .../plugin/v0alpha1/types.status.gen.ts | 30 + eslint-suppressions.json | 128 + eslint.config.js | 38 + packages/grafana-data/src/types/config.ts | 2 + packages/grafana-data/src/types/plugin.ts | 1 + packages/grafana-runtime/src/config.ts | 1 + packages/grafana-runtime/src/index.ts | 2 + .../grafana-runtime/src/internal/index.ts | 2 + .../src/services/pluginMeta/apps.test.ts | 258 + .../src/services/pluginMeta/apps.ts | 71 + .../src/services/pluginMeta/hooks.test.tsx | 214 + .../src/services/pluginMeta/hooks.tsx | 35 + .../services/pluginMeta/mappers/mappers.ts | 7 + .../mappers/v0alpha1AppMapper.test.ts | 84 + .../pluginMeta/mappers/v0alpha1AppMapper.ts | 111 + .../src/services/pluginMeta/plugins.test.ts | 153 + .../src/services/pluginMeta/plugins.ts | 41 + .../pluginMeta/test-fixtures/config.apps.ts | 303 ++ .../test-fixtures/v0alpha1Response.ts | 4378 +++++++++++++++++ .../src/services/pluginMeta/types.ts | 10 + .../pluginMeta/types/meta_object_gen.ts | 49 + .../pluginMeta/types/types.spec.gen.ts | 278 ++ .../pluginMeta/types/types.status.gen.ts | 30 + 34 files changed, 6718 insertions(+), 6 deletions(-) create mode 100644 apps/plugins/plugin/src/generated/meta/v0alpha1/meta_object_gen.ts create mode 100644 apps/plugins/plugin/src/generated/meta/v0alpha1/types.metadata.gen.ts create mode 100644 apps/plugins/plugin/src/generated/meta/v0alpha1/types.spec.gen.ts create mode 100644 apps/plugins/plugin/src/generated/meta/v0alpha1/types.status.gen.ts create mode 100644 apps/plugins/plugin/src/generated/plugin/v0alpha1/plugin_object_gen.ts create mode 100644 apps/plugins/plugin/src/generated/plugin/v0alpha1/types.metadata.gen.ts create mode 100644 apps/plugins/plugin/src/generated/plugin/v0alpha1/types.spec.gen.ts create mode 100644 apps/plugins/plugin/src/generated/plugin/v0alpha1/types.status.gen.ts create mode 100644 packages/grafana-runtime/src/services/pluginMeta/apps.test.ts create mode 100644 packages/grafana-runtime/src/services/pluginMeta/apps.ts create mode 100644 packages/grafana-runtime/src/services/pluginMeta/hooks.test.tsx create mode 100644 packages/grafana-runtime/src/services/pluginMeta/hooks.tsx create mode 100644 packages/grafana-runtime/src/services/pluginMeta/mappers/mappers.ts create mode 100644 packages/grafana-runtime/src/services/pluginMeta/mappers/v0alpha1AppMapper.test.ts create mode 100644 packages/grafana-runtime/src/services/pluginMeta/mappers/v0alpha1AppMapper.ts create mode 100644 packages/grafana-runtime/src/services/pluginMeta/plugins.test.ts create mode 100644 packages/grafana-runtime/src/services/pluginMeta/plugins.ts create mode 100644 packages/grafana-runtime/src/services/pluginMeta/test-fixtures/config.apps.ts create mode 100644 packages/grafana-runtime/src/services/pluginMeta/test-fixtures/v0alpha1Response.ts create mode 100644 packages/grafana-runtime/src/services/pluginMeta/types.ts create mode 100644 packages/grafana-runtime/src/services/pluginMeta/types/meta_object_gen.ts create mode 100644 packages/grafana-runtime/src/services/pluginMeta/types/types.spec.gen.ts create mode 100644 packages/grafana-runtime/src/services/pluginMeta/types/types.status.gen.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ca9de90ba02..0dda8519ef6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -658,6 +658,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 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/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/plugin/src/generated/meta/v0alpha1/meta_object_gen.ts b/apps/plugins/plugin/src/generated/meta/v0alpha1/meta_object_gen.ts new file mode 100644 index 00000000000..044ec1f4cd8 --- /dev/null +++ b/apps/plugins/plugin/src/generated/meta/v0alpha1/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/apps/plugins/plugin/src/generated/meta/v0alpha1/types.metadata.gen.ts b/apps/plugins/plugin/src/generated/meta/v0alpha1/types.metadata.gen.ts new file mode 100644 index 00000000000..4377f3c1d08 --- /dev/null +++ b/apps/plugins/plugin/src/generated/meta/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/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/plugins/plugin/src/generated/meta/v0alpha1/types.status.gen.ts b/apps/plugins/plugin/src/generated/meta/v0alpha1/types.status.gen.ts new file mode 100644 index 00000000000..01be8df7961 --- /dev/null +++ b/apps/plugins/plugin/src/generated/meta/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/plugins/plugin/src/generated/plugin/v0alpha1/plugin_object_gen.ts b/apps/plugins/plugin/src/generated/plugin/v0alpha1/plugin_object_gen.ts new file mode 100644 index 00000000000..c4e625fc418 --- /dev/null +++ b/apps/plugins/plugin/src/generated/plugin/v0alpha1/plugin_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 Plugin { + kind: string; + apiVersion: string; + metadata: Metadata; + spec: Spec; + status: Status; +} 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/eslint-suppressions.json b/eslint-suppressions.json index f633ed5b4eb..70df9a82829 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1337,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 @@ -1377,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 @@ -1617,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 @@ -1632,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 @@ -1663,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": { @@ -1724,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 @@ -2063,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 @@ -2889,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 diff --git a/eslint.config.js b/eslint.config.js index 5e44ffebaf4..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', + }, ], }, }, 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/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-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 => ({ +}); + From cf452c167b4efcb43e16f167b0f33422e7c4b564 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Wed, 14 Jan 2026 07:41:10 +0200 Subject: [PATCH 33/57] Provisioning: Do not show the page when the toggle is off (#116206) --- pkg/services/navtree/navtreeimpl/admin.go | 3 +-- public/app/features/provisioning/GettingStarted/features.ts | 2 +- public/app/features/provisioning/utils/routes.ts | 6 ++++++ 3 files changed, 8 insertions(+), 3 deletions(-) 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/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/utils/routes.ts b/public/app/features/provisioning/utils/routes.ts index 13106188c6a..57d62d3987f 100644 --- a/public/app/features/provisioning/utils/routes.ts +++ b/public/app/features/provisioning/utils/routes.ts @@ -1,3 +1,4 @@ +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'; @@ -6,6 +7,11 @@ import { checkRequiredFeatures } from '../GettingStarted/features'; 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 [ { From ccb032f376ea93ba51b6c8ce60e58086ad678aca Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Wed, 14 Jan 2026 08:31:13 +0100 Subject: [PATCH 34/57] Alerting: Single alertmanager contact points versions (#116076) * POC ssingle AM * wip * add query param ?version=2 * wip2 * wip3 * Update logic * update badges and tests * remove unsused import * fix: update NewReceiverView snapshots to include version field * update translations * fix: delegate version determination to backend for new integrations - Remove hardcoded version: 'v1' from defaultChannelValues - Reset version to undefined when integration type changes - Backend uses GetCurrentVersion() when no version is provided - Update snapshots to reflect version handling changes - Remove unused getDefaultVersionForNotifier function * update snapshot * fix(alerting): fix contact point form issues - Fix empty info alert showing when notifier.dto.info is undefined - Fix options not loading for new contact points by using default creatable version * fix(alerting): only show version badge for legacy integrations * update tests for version badge and getOptionsForVersion changes * docs: add comment explaining currentVersion field in NotifierDTO * Show user-friendly 'Legacy' label for legacy integrations - Replace technical version strings (v0mimir1, v0mimir2) with user-friendly labels - v0mimir1 -> 'Legacy', v0mimir2 -> 'Legacy v2', etc. - Technical version is still shown in tooltip for reference - Add getLegacyVersionLabel() utility function - Update tests for badge display and utility function * Add v0mimir2 to test mock for Legacy v2 badge test * hasLegacyIntegrations now uses isLegacyVersion - Accept notifiers array to properly check canCreate: false - No longer relies on version string comparison (v1 check) - Uses isLegacyVersion for consistent legacy detection - Update tests to pass notifiers and test correct behavior * update translations --- .../alerting/unified/api/alertmanagerApi.ts | 15 +- .../unified/components/Provisioning.tsx | 18 + .../receivers/form/ChannelSubForm.test.tsx | 241 +++++++++- .../receivers/form/ChannelSubForm.tsx | 96 ++-- .../receivers/form/GrafanaReceiverForm.tsx | 22 +- .../alerting/unified/types/alerting.ts | 31 ++ .../alerting/unified/types/receiver-form.ts | 1 + .../unified/utils/notifier-versions.test.ts | 429 ++++++++++++++++++ .../unified/utils/notifier-versions.ts | 126 +++++ .../alerting/unified/utils/receiver-form.ts | 2 + .../plugins/datasource/alertmanager/types.ts | 4 + public/locales/en-US/grafana.json | 5 +- 12 files changed, 952 insertions(+), 38 deletions(-) create mode 100644 public/app/features/alerting/unified/utils/notifier-versions.test.ts create mode 100644 public/app/features/alerting/unified/utils/notifier-versions.ts 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/components/Provisioning.tsx b/public/app/features/alerting/unified/components/Provisioning.tsx index 73beb8a0865..7a88d1e21d7 100644 --- a/public/app/features/alerting/unified/components/Provisioning.tsx +++ b/public/app/features/alerting/unified/components/Provisioning.tsx @@ -36,6 +36,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, diff --git a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.test.tsx b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.test.tsx index 00a933acec3..76de0099416 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.test.tsx @@ -1,11 +1,12 @@ import 'core-js/stable/structured-clone'; import { FormProvider, useForm } from 'react-hook-form'; import { clickSelectOption } from 'test/helpers/selectOptionInTest'; -import { render } from 'test/test-utils'; +import { render, screen } from 'test/test-utils'; import { byRole, byTestId } from 'testing-library-selector'; import { grafanaAlertNotifiers } from 'app/features/alerting/unified/mockGrafanaNotifiers'; import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext'; +import { NotifierDTO } from 'app/features/alerting/unified/types/alerting'; import { ChannelSubForm } from './ChannelSubForm'; import { GrafanaCommonChannelSettings } from './GrafanaCommonChannelSettings'; @@ -16,6 +17,7 @@ type TestChannelValues = { type: string; settings: Record; 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..f103589cb9b 100644 --- a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx @@ -18,12 +18,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 +40,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 +70,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 @@ -135,15 +137,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 +170,12 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode } )} - {contactPoint?.provisioned && } + {contactPoint?.provisioned && hasLegacyIntegrations(contactPoint, grafanaNotifiers) && ( + + )} + {contactPoint?.provisioned && !hasLegacyIntegrations(contactPoint, grafanaNotifiers) && ( + + )} contactPointId={contactPoint?.id} 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/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/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/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index ef56b44c767..3fb512c88c6 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 diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 4b99a5811e7..a5545957399 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,7 +2177,9 @@ "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": { From fd955f90ac8d6c57b0d138f21a14f3f1dc275346 Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Wed, 14 Jan 2026 09:48:07 +0100 Subject: [PATCH 35/57] Alerting: Enable server-side folder search for GMA rules (#116201) * Alerting: Support backend filtering for folder search Updates the Grafana managed rules API and filter logic to support server-side filtering by folder (namespace). Changes: - Add `searchFolder` parameter to `getGrafanaGroups` API endpoint - Map filter state `namespace` to `searchFolder` in backend filter - Disable client-side namespace filtering when backend filtering is enabled - Update tests to verify correct behavior for folder search with backend filters * Add missing property in filter options * Update tests --- .../alerting/unified/api/prometheusApi.ts | 3 ++ .../rule-list/hooks/grafanaFilter.test.ts | 39 +++++++++++++++---- .../unified/rule-list/hooks/grafanaFilter.ts | 3 +- .../hooks/prometheusGroupsGenerator.ts | 1 + .../rule-list/paginationLimits.test.ts | 22 +---------- 5 files changed, 40 insertions(+), 28 deletions(-) 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 { 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 }); - } - ); }); }); }); From 9d1d0e72c2a40f5b8836898141370c38b1735acd Mon Sep 17 00:00:00 2001 From: Tito Lins Date: Wed, 14 Jan 2026 10:04:29 +0100 Subject: [PATCH 36/57] Alerting: add sync timer support (#114602) - add new feature flag to support enabling the dispatcher sync timer on the alertmanager - this attempts to synchronize the flushes across HA nodes to decrease amount of duplicate notifications --------- Co-authored-by: Yuri Tseretyan --- .../src/types/featureToggles.gen.ts | 4 ++ pkg/services/featuremgmt/registry.go | 11 +++- pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 ++ pkg/services/featuremgmt/toggles_gen.json | 17 +++++- pkg/services/ngalert/ngalert.go | 4 ++ pkg/services/ngalert/notifier/alertmanager.go | 26 ++++++++++ .../ngalert/notifier/dispatch_timer.go | 16 ++++++ .../ngalert/notifier/dispatch_timer_test.go | 36 +++++++++++++ pkg/services/ngalert/notifier/file_store.go | 10 ++++ .../ngalert/notifier/file_store_test.go | 45 ++++++++++++++++ .../ngalert/notifier/multiorg_alertmanager.go | 3 +- pkg/services/ngalert/notifier/state.go | 5 +- pkg/services/ngalert/notifier/testing.go | 52 +++++++++++++++++-- pkg/services/ngalert/remote/alertmanager.go | 24 +++++++-- .../client/alertmanager_configuration.go | 5 ++ 16 files changed, 251 insertions(+), 12 deletions(-) create mode 100644 pkg/services/ngalert/notifier/dispatch_timer.go create mode 100644 pkg/services/ngalert/notifier/dispatch_timer_test.go diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index aa0581004e1..cdcd4b53092 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1251,4 +1251,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/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 4c0456e9457..6b716789f8c 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -981,7 +981,8 @@ var ( Stage: FeatureStageDeprecated, Owner: grafanaPartnerPluginsSquad, Expression: "true", // Enabled by default for now - }, { + }, + { Name: "alertingFilterV2", Description: "Enable the new alerting search experience", Stage: FeatureStageExperimental, @@ -2069,6 +2070,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/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index caba7cdab90..09c75a82041 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -280,3 +280,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 d68fa56ec8c..17498d7afb9 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -789,4 +789,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 bfefc20f08b..071f1b0671e 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -511,6 +511,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 +676,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", 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/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/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/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 60740d935af..07fa5e3138f 100644 --- a/pkg/services/ngalert/remote/alertmanager.go +++ b/pkg/services/ngalert/remote/alertmanager.go @@ -47,6 +47,7 @@ import ( 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. @@ -86,6 +87,8 @@ type Alertmanager struct { promoteConfig bool externalURL string + + runtimeConfig remoteClient.RuntimeConfig } type AlertmanagerConfig struct { @@ -111,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 { @@ -203,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. @@ -331,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) @@ -388,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) } @@ -677,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/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) { From 78d507d285aeda286a95353ae09aa725453e4b99 Mon Sep 17 00:00:00 2001 From: Bogdan Matei Date: Wed, 14 Jan 2026 11:50:37 +0200 Subject: [PATCH 37/57] Dynamic Dashboards: Change the stage of the feature toggle (#116189) --- .../configure-grafana/feature-toggles/index.md | 1 + packages/grafana-data/src/types/featureToggles.gen.ts | 2 +- pkg/services/featuremgmt/registry.go | 4 ++-- pkg/services/featuremgmt/toggles_gen.csv | 2 +- pkg/services/featuremgmt/toggles_gen.go | 2 +- pkg/services/featuremgmt/toggles_gen.json | 11 +++++++---- 6 files changed, 13 insertions(+), 9 deletions(-) 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 b7f55555e07..813efb29eba 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -83,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/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index cdcd4b53092..1581af4e3d7 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -356,7 +356,7 @@ export interface FeatureToggles { */ dashboardScene?: boolean; /** - * Enables experimental new dashboard layouts + * Enables new dashboard layouts */ dashboardNewLayouts?: boolean; /** diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 6b716789f8c..b436639a896 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -574,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, }, diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 09c75a82041..c605f098bb5 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -79,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 diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 17498d7afb9..3a71abab00a 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -260,7 +260,7 @@ const ( FlagAnnotationPermissionUpdate = "annotationPermissionUpdate" // FlagDashboardNewLayouts - // Enables experimental new dashboard layouts + // Enables new dashboard layouts FlagDashboardNewLayouts = "dashboardNewLayouts" // FlagPdfTables diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 071f1b0671e..66cff415ec4 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1030,12 +1030,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" } }, From d680537ea1072b14759eaba4daa9e56a729a1a53 Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Wed, 14 Jan 2026 11:05:16 +0100 Subject: [PATCH 38/57] Advisor: Simplify interface used (#116191) --- apps/advisor/pkg/app/checks/datasourcecheck/check.go | 4 ++-- .../pkg/app/checks/datasourcecheck/missing_plugin_step.go | 2 +- apps/advisor/pkg/app/checks/ifaces.go | 8 ++++++++ apps/advisor/pkg/app/checks/plugincheck/check.go | 4 ++-- 4 files changed, 13 insertions(+), 5 deletions(-) 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 From afd84f033511d68fc4c787ca06794e132b37baf1 Mon Sep 17 00:00:00 2001 From: Natalia Bernarte Oses Date: Wed, 14 Jan 2026 11:10:51 +0100 Subject: [PATCH 39/57] Datagrid: Deprecate panel (#116071) * deprecate datagrid * Update docs/sources/visualizations/panels-visualizations/visualizations/datagrid/index.md Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> --------- Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> --- .../panels-visualizations/visualizations/datagrid/index.md | 4 +++- public/app/plugins/panel/datagrid/plugin.json | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) 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/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": { From 0d1e0bc21cf690e5df837f765b21a5ab6c0a9467 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Wed, 14 Jan 2026 11:29:43 +0100 Subject: [PATCH 40/57] PanelMenu: use openInNewTab links extensions API correctly (#116200) * Extensons: Make links use openInNewTab API * Use openInNewTab api correctly in the UI * Bump scenes * Fx circular dep * test * Revert "test" This reverts commit 8784a7992c60889dda824433331b7072ea80b0cd. --- package.json | 4 ++-- packages/grafana-data/src/index.ts | 2 +- packages/grafana-data/src/types/dataLink.ts | 3 +-- packages/grafana-data/src/types/linkTarget.ts | 4 ++++ packages/grafana-data/src/types/navModel.ts | 2 +- packages/grafana-data/src/types/panel.ts | 2 ++ .../plugins/extensions/getPluginExtensions.ts | 1 + .../app/features/plugins/extensions/utils.tsx | 2 ++ yarn.lock | 22 +++++++++---------- 9 files changed, 25 insertions(+), 17 deletions(-) create mode 100644 packages/grafana-data/src/types/linkTarget.ts diff --git a/package.json b/package.json index a6887b216e4..0a39ff67aea 100644 --- a/package.json +++ b/package.json @@ -293,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:*", 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/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/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/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index c81fd7c26f4..6f02a7daf77 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -141,6 +141,7 @@ export const getPluginExtensions: GetExtensions = ({ description: overrides?.description || addedLink.description || '', path: isString(path) ? getLinkExtensionPathWithTracking(pluginId, path, extensionPointId) : undefined, category: overrides?.category || addedLink.category, + openInNewTab: overrides?.openInNewTab ?? addedLink.openInNewTab, }; extensions.push(extension); diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index adb1ed8616d..0046aab0fe6 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -420,6 +420,7 @@ export function createExtensionSubMenu(extensions: PluginExtensionLink[]): Panel href: extension.path, onClick: extension.onClick, iconClassName: extension.icon, + target: extension.openInNewTab ? '_blank' : undefined, }); continue; } @@ -433,6 +434,7 @@ export function createExtensionSubMenu(extensions: PluginExtensionLink[]): Panel href: extension.path, onClick: extension.onClick, iconClassName: extension.icon, + target: extension.openInNewTab ? '_blank' : undefined, }); } diff --git a/yarn.lock b/yarn.lock index 41cc10d8353..1069acd8544 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3789,11 +3789,11 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes-react@npm:v6.52.1": - version: 6.52.1 - resolution: "@grafana/scenes-react@npm:6.52.1" +"@grafana/scenes-react@npm:6.52.2": + version: 6.52.2 + resolution: "@grafana/scenes-react@npm:6.52.2" dependencies: - "@grafana/scenes": "npm:6.52.1" + "@grafana/scenes": "npm:6.52.2" lru-cache: "npm:^10.2.2" react-use: "npm:^17.4.0" peerDependencies: @@ -3805,7 +3805,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/2f7c6ca8e26befd331808afb0cb934e2991e889a4de78be1122c536219676261c59c6204510761a1d4250fd44a3767818f0f225d23b2e7243cfc17baf8ca6ca3 + checksum: 10/c393faf6612e78254dab79b15cc970448d74ba9784ccda623953c5dbc21d91a8da94b7ad7d0d294eac51314cc193c419a7cb48295fd50b1f9c4472699669eb3e languageName: node linkType: hard @@ -3835,9 +3835,9 @@ __metadata: languageName: node linkType: hard -"@grafana/scenes@npm:6.52.1, @grafana/scenes@npm:v6.52.1": - version: 6.52.1 - resolution: "@grafana/scenes@npm:6.52.1" +"@grafana/scenes@npm:6.52.2": + version: 6.52.2 + resolution: "@grafana/scenes@npm:6.52.2" dependencies: "@floating-ui/react": "npm:^0.26.16" "@leeoniya/ufuzzy": "npm:^1.0.16" @@ -3857,7 +3857,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/d6172b51121e03c7dcbf30046772f99fc45922c1f7b360a7c3d2c0391300e378f306cb78251dda3b30895679379c38db30e4d52fee67a56cd95f18f38aadf3fb + checksum: 10/f6dbe20db78bb1aa09cc38025534917887713d73119a172febb44700837ed859363ee0436b5f4bda6bc063f9432115e32519ab4c8da7834cf1fc22d43fea7711 languageName: node linkType: hard @@ -19790,8 +19790,8 @@ __metadata: "@grafana/plugin-ui": "npm:^0.11.1" "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" - "@grafana/scenes": "npm:v6.52.1" - "@grafana/scenes-react": "npm:v6.52.1" + "@grafana/scenes": "npm:6.52.2" + "@grafana/scenes-react": "npm:6.52.2" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/test-utils": "workspace:*" From 170ac31c5ad5527c17e838949ce68dc5c5752c34 Mon Sep 17 00:00:00 2001 From: Alejandro Fraenkel Date: Wed, 14 Jan 2026 11:58:11 +0100 Subject: [PATCH 41/57] Alerting: Add alertingNavigationV2 feature toggle (#116215) feat(alerting): add alertingNavigationV2 feature toggle Introduces a new feature toggle to enable the improved Alerting navigation structure with grouped menu items. This toggle will allow: - Safe incremental rollout of navigation changes - Quick rollback if issues arise - Handling BE/FE deployment timing differences Toggle details: - Name: alertingNavigationV2 - Stage: Experimental - Owner: @grafana/alerting-squad - Default: false (disabled) - Affects: Both backend (navtree) and frontend (navigation hooks) --- .../grafana-data/src/types/featureToggles.gen.ts | 4 ++++ pkg/services/featuremgmt/registry.go | 7 +++++++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 ++++ pkg/services/featuremgmt/toggles_gen.json | 12 ++++++++++++ 5 files changed, 28 insertions(+) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 1581af4e3d7..eed0d330481 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -531,6 +531,10 @@ export interface FeatureToggles { */ alertingListViewV2?: boolean; /** + * Enables the new Alerting navigation structure with improved menu grouping + */ + alertingNavigationV2?: boolean; + /** * Enables saved searches for alert rules list */ alertingSavedSearches?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index b436639a896..72623cba2fa 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -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", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index c605f098bb5..61505b65571 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -121,6 +121,7 @@ dashboardLibrary,experimental,@grafana/sharing-squad,false,false,false suggestedDashboards,experimental,@grafana/sharing-squad,false,false,false dashboardTemplates,preview,@grafana/sharing-squad,false,false,false 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 diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 3a71abab00a..db2b4484e42 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -371,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" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 66cff415ec4..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", From 987c1fc6b68e1ae63f807f13fa3d01a2a7c80d14 Mon Sep 17 00:00:00 2001 From: Rafael Bortolon Paulovic Date: Wed, 14 Jan 2026 12:07:53 +0100 Subject: [PATCH 42/57] feat(unified): add index scoring model config (#116210) * feat(unified): add bm25 index scoring model We want try BM25 scoring model since they have global scoring which we can probably re-use for fan-in/fan-out logic https://github.com/blevesearch/bleve/blob/32d98823c4b7482c62cc6c847508ed7659c23c37/docs/scoring.md#global-scoring * fix(plugins): update plugin test data --- pkg/setting/setting.go | 1 + pkg/setting/setting_unified_storage.go | 4 +++ pkg/storage/unified/search/bleve.go | 7 +++- .../unified/search/bleve_integration_test.go | 31 +++++++++++++++++ pkg/storage/unified/search/bleve_mappings.go | 6 ++-- .../unified/search/bleve_mappings_test.go | 2 +- .../unified/search/bleve_search_test.go | 2 ++ pkg/storage/unified/search/bleve_test.go | 3 ++ pkg/storage/unified/search/options.go | 1 + .../unified/sql/test/integration_test.go | 34 ++++++++++++------- .../api/plugins/data/expectedListResp.json | 30 ++++++++-------- 11 files changed, 89 insertions(+), 32 deletions(-) diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 9667b82b9fa..1e26b9067ef 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -600,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 diff --git a/pkg/setting/setting_unified_storage.go b/pkg/setting/setting_unified_storage.go index 21a3f455993..b47e8879826 100644 --- a/pkg/setting/setting_unified_storage.go +++ b/pkg/setting/setting_unified_storage.go @@ -123,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() diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index eec7290633b..a988b71aa38 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -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 } 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 c9c3967cd58..a88951a100a 100644 --- a/pkg/storage/unified/search/bleve_test.go +++ b/pkg/storage/unified/search/bleve_test.go @@ -14,6 +14,7 @@ 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" @@ -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) diff --git a/pkg/storage/unified/search/options.go b/pkg/storage/unified/search/options.go index d450e9ae24b..64cf074f52c 100644 --- a/pkg/storage/unified/search/options.go +++ b/pkg/storage/unified/search/options.go @@ -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/sql/test/integration_test.go b/pkg/storage/unified/sql/test/integration_test.go index 166e2dac372..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" @@ -129,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/tests/api/plugins/data/expectedListResp.json b/pkg/tests/api/plugins/data/expectedListResp.json index 24f705eccd1..3d1ccce6a59 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": "", @@ -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 }, From 040854c8af5e5a556e4fa05ae95df0c966338ae4 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 14 Jan 2026 14:55:05 +0300 Subject: [PATCH 43/57] Search: Allow query field selection (#116238) --- pkg/storage/unified/proto/search.proto | 31 ++ pkg/storage/unified/resource/document.go | 1 - pkg/storage/unified/resourcepb/search.pb.go | 432 ++++++++++++------ .../unified/resourcepb/search_grpc.pb.go | 4 + pkg/storage/unified/search/bleve.go | 85 ++-- pkg/tests/apis/dashboard/search_test.go | 58 ++- ...-query.json => t01-query-single-word.json} | 6 +- .../searchV0/t02-query-multiple-words.json | 17 + ...xt-panel.json => t03-with-text-panel.json} | 0 .../searchV0/t04-title-ngram-prefix.json | 17 + .../searchV0/t05-title-ngram-middle-word.json | 17 + 11 files changed, 474 insertions(+), 194 deletions(-) rename pkg/tests/apis/dashboard/testdata/searchV0/{t01-simple-query.json => t01-query-single-word.json} (88%) create mode 100644 pkg/tests/apis/dashboard/testdata/searchV0/t02-query-multiple-words.json rename pkg/tests/apis/dashboard/testdata/searchV0/{t02-with-text-panel.json => t03-with-text-panel.json} (100%) create mode 100644 pkg/tests/apis/dashboard/testdata/searchV0/t04-title-ngram-prefix.json create mode 100644 pkg/tests/apis/dashboard/testdata/searchV0/t05-title-ngram-middle-word.json 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/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/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 a988b71aa38..785d81af3c8 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -1182,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() @@ -1240,42 +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. - searchQuery := bleve.NewDisjunctionQuery(queryExact) + 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 - searchQuery.AddQuery(queryPhrase) - - // Query 3: Match query with standard analyzer - queryAnalyzed := bleve.NewMatchQuery(removeSmallTerms(req.Query)) - queryAnalyzed.SetField(resource.SEARCH_FIELD_TITLE) - queryAnalyzed.SetBoost(2.0) - queryAnalyzed.Analyzer = standard.Name - queryAnalyzed.Operator = query.MatchQueryOperatorAnd // Make sure all terms from the query are matched - searchQuery.AddQuery(queryAnalyzed) - - 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) { diff --git a/pkg/tests/apis/dashboard/search_test.go b/pkg/tests/apis/dashboard/search_test.go index 2227c67287e..df03e6a9670 100644 --- a/pkg/tests/apis/dashboard/search_test.go +++ b/pkg/tests/apis/dashboard/search_test.go @@ -97,7 +97,7 @@ func TestIntegrationSearchDevDashboards(t *testing.T) { require.Equal(t, 16, fileCount, "file count from %s", devenv) // Helper to call search - callSearch := func(user apis.User, params string) dashboardV0.SearchResults { + callSearch := func(user apis.User, params map[string]string) dashboardV0.SearchResults { require.NotNil(t, user) ns := user.Identity.GetNamespace() cfg := dynamic.ConfigFor(user.NewRestConfig()) @@ -107,17 +107,12 @@ func TestIntegrationSearchDevDashboards(t *testing.T) { 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 kv := range strings.SplitSeq(params, "&") { - if kv == "" { - continue - } - parts := strings.SplitN(kv, "=", 2) - if len(parts) == 2 { - req = req.Param(parts[0], parts[1]) - } + for k, v := range params { + req = req.Param(k, v) } res := req.Do(ctx).StatusCode(&statusCode) require.NoError(t, res.Error()) @@ -140,22 +135,47 @@ func TestIntegrationSearchDevDashboards(t *testing.T) { testCases := []struct { name string user apis.User - params string + params map[string]string }{ { - name: "all", - user: helper.Org1.Admin, - params: "", // only dashboards + name: "all", + user: helper.Org1.Admin, }, { - name: "simple-query", - user: helper.Org1.Admin, - params: "query=stacking", + name: "query-single-word", + user: helper.Org1.Admin, + params: map[string]string{ + "query": "stacking", + }, }, { - name: "with-text-panel", - user: helper.Org1.Admin, - params: "field=panel_types&panelType=text", + 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 { diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t01-simple-query.json b/pkg/tests/apis/dashboard/testdata/searchV0/t01-query-single-word.json similarity index 88% rename from pkg/tests/apis/dashboard/testdata/searchV0/t01-simple-query.json rename to pkg/tests/apis/dashboard/testdata/searchV0/t01-query-single-word.json index 6c9a935dfe8..02eed11383a 100644 --- a/pkg/tests/apis/dashboard/testdata/searchV0/t01-simple-query.json +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t01-query-single-word.json @@ -10,7 +10,7 @@ "panel-tests", "graph-ng" ], - "score": 0.658 + "score": 0.284 }, { "resource": "dashboards", @@ -21,8 +21,8 @@ "panel-tests", "graph-ng" ], - "score": 0.625 + "score": 0.269 } ], - "maxScore": 0.658 + "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/t02-with-text-panel.json b/pkg/tests/apis/dashboard/testdata/searchV0/t03-with-text-panel.json similarity index 100% rename from pkg/tests/apis/dashboard/testdata/searchV0/t02-with-text-panel.json rename to pkg/tests/apis/dashboard/testdata/searchV0/t03-with-text-panel.json 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 From 8bad33de4c9f1354643ef3d2af4f4c4bf7e14254 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Wed, 14 Jan 2026 13:05:23 +0100 Subject: [PATCH 44/57] Grafana/data: Fix theme types schema resolution (#116240) * fix(grafana-data): copy theme schema json to types so declaration resolves * refactor(grafana-data): move node scripts out of source code * feat(grafana-data): generate types for theme schema * chore(codeowners): update for grafana-data/scripts file move * feat(grafana-data): put back copy plugin for theme json files * revert(grafana-data): remove definition output * feat(grafana-data): make builds great again * minor tidy up --------- Co-authored-by: Ashley Harrison --- .github/CODEOWNERS | 1 + packages/grafana-data/package.json | 11 ++++++- packages/grafana-data/rollup.config.ts | 23 +++++++++++-- .../grafana-data/scripts/generateSchema.ts | 22 +++++++++++++ packages/grafana-data/src/internal/index.ts | 1 - packages/grafana-data/src/themes/registry.ts | 28 +++++++++++++++- .../src/themes/scripts/generateSchema.ts | 19 ----------- .../src/themes/themeDefinitions/index.ts | 12 ------- packages/grafana-data/src/unstable.ts | 2 +- packages/grafana-data/tsconfig.json | 3 +- .../theme-playground/ThemePlayground.tsx | 33 +++++++++++++++++-- scripts/validate-npm-packages.sh | 1 + yarn.lock | 1 + 13 files changed, 116 insertions(+), 41 deletions(-) create mode 100644 packages/grafana-data/scripts/generateSchema.ts delete mode 100644 packages/grafana-data/src/themes/scripts/generateSchema.ts delete mode 100644 packages/grafana-data/src/themes/themeDefinitions/index.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0dda8519ef6..6b40b814064 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 384666ea7f8..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": { @@ -52,7 +60,7 @@ "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", - "themes-schema": "tsx ./src/themes/scripts/generateSchema.ts" + "themes-schema": "tsx ./scripts/generateSchema.ts" }, "dependencies": { "@braintree/sanitize-url": "7.0.1", @@ -102,6 +110,7 @@ "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", diff --git a/packages/grafana-data/rollup.config.ts b/packages/grafana-data/rollup.config.ts index 0c40d731724..50af331c37c 100644 --- a/packages/grafana-data/rollup.config.ts +++ b/packages/grafana-data/rollup.config.ts @@ -1,21 +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, json()], + plugins: grafanaDataPlugins, output: [cjsOutput(pkg, 'grafana-data'), esmOutput(pkg, 'grafana-data')], treeshake: false, }, { input: 'src/unstable.ts', - plugins: [...plugins, json()], + 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/internal/index.ts b/packages/grafana-data/src/internal/index.ts index 1b1e3c64a7d..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'; diff --git a/packages/grafana-data/src/themes/registry.ts b/packages/grafana-data/src/themes/registry.ts index 4fca3c5d7be..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, NewThemeOptionsSchema } from './createTheme'; -import * as extraThemes from './themeDefinitions'; +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 diff --git a/packages/grafana-data/src/themes/scripts/generateSchema.ts b/packages/grafana-data/src/themes/scripts/generateSchema.ts deleted file mode 100644 index 09369f5e67f..00000000000 --- a/packages/grafana-data/src/themes/scripts/generateSchema.ts +++ /dev/null @@ -1,19 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -import { NewThemeOptionsSchema } from '../createTheme'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -fs.writeFileSync( - path.join(__dirname, '../schema.generated.json'), - JSON.stringify( - NewThemeOptionsSchema.toJSONSchema({ - target: 'draft-07', - }), - undefined, - 2 - ) -); 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 b4270192032..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { default as aubergine } from './aubergine.json'; -export { default as debug } from './debug.json'; -export { default as desertbloom } from './desertbloom.json'; -export { default as gildedgrove } from './gildedgrove.json'; -export { default as mars } from './mars.json'; -export { default as matrix } from './matrix.json'; -export { default as sapphiredusk } from './sapphiredusk.json'; -export { default as synthwave } from './synthwave.json'; -export { default as tron } from './tron.json'; -export { default as victorian } from './victorian.json'; -export { default as zen } from './zen.json'; -export { default as gloom } from './gloom.json'; diff --git a/packages/grafana-data/src/unstable.ts b/packages/grafana-data/src/unstable.ts index 3200085428a..43c2ff3071f 100644 --- a/packages/grafana-data/src/unstable.ts +++ b/packages/grafana-data/src/unstable.ts @@ -9,4 +9,4 @@ * and be subject to the standard policies */ -export { default as themeJsonSchema } from './themes/schema.generated.json'; +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/public/app/features/theme-playground/ThemePlayground.tsx b/public/app/features/theme-playground/ThemePlayground.tsx index 85dee240c7f..d331f3932bc 100644 --- a/public/app/features/theme-playground/ThemePlayground.tsx +++ b/public/app/features/theme-playground/ThemePlayground.tsx @@ -2,8 +2,20 @@ import { css } from '@emotion/css'; import { useId, useState } from 'react'; import { createTheme, GrafanaTheme2, NewThemeOptions } from '@grafana/data'; -import { experimentalThemeDefinitions, NewThemeOptionsSchema } from '@grafana/data/internal'; -import { themeJsonSchema } from '@grafana/data/unstable'; +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'; @@ -34,8 +46,23 @@ const themeMap: Record = { }, }; +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(experimentalThemeDefinitions)) { +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}`); diff --git a/scripts/validate-npm-packages.sh b/scripts/validate-npm-packages.sh index a3e07c4d7be..80e36b968d1 100755 --- a/scripts/validate-npm-packages.sh +++ b/scripts/validate-npm-packages.sh @@ -11,6 +11,7 @@ failed_checks=() for file in "$ARTIFACTS_DIR"/*.tgz; do echo "🔍 Checking NPM package: $file" + # If you need to debug ATTW issues, pass "--format json" to get verbose output. if ! NODE_OPTIONS="-C @grafana-app/source" yarn attw "$file" --ignore-rules "false-cjs" --profile "node16"; then echo "attw check failed for $file" echo "" diff --git a/yarn.lock b/yarn.lock index 1069acd8544..d16b10ef5f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3324,6 +3324,7 @@ __metadata: react-use: "npm:17.6.0" rimraf: "npm:6.0.1" rollup: "npm:^4.22.4" + rollup-plugin-copy: "npm:3.5.0" rollup-plugin-esbuild: "npm:6.2.1" rollup-plugin-node-externals: "npm:^8.0.0" rxjs: "npm:7.8.2" From 48625d67e5adffd276f4bf19227a50523ae50d54 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 14 Jan 2026 15:15:19 +0300 Subject: [PATCH 45/57] Chore: update blevesearch dependencies (#116251) --- go.mod | 23 +++++++-------- go.sum | 46 ++++++++++++++--------------- go.work.sum | 29 ++++++++++++++++-- pkg/storage/unified/search/bleve.go | 2 +- 4 files changed, 61 insertions(+), 39 deletions(-) diff --git a/go.mod b/go.mod index fe3e62e3fde..ade26f2e7d1 100644 --- a/go.mod +++ b/go.mod @@ -44,8 +44,8 @@ require ( 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 @@ -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 @@ -443,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 diff --git a/go.sum b/go.sum index 67d95c625b6..f997af7c68e 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/go.work.sum b/go.work.sum index d064248a16a..6388285d2bf 100644 --- a/go.work.sum +++ b/go.work.sum @@ -520,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= @@ -998,8 +1024,6 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975 github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975/go.mod h1:FGdGvhI40Dq+CTQaSzK9evuve774cgOUdGfVO04OXkw= 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/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.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= @@ -1092,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= diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index 785d81af3c8..09ef2dc9230 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -1898,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 From 7143324229c7808f970f2f952dbc88cb024ebccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Wed, 14 Jan 2026 07:51:42 -0500 Subject: [PATCH 46/57] Elasticsearch: Add support for serverless connections (#114855) * serverless connecction * Adding api key * fix * addressing pr comments * fixing tests * refactoring * changing to value semantic * addressing pr comments * minor changes --------- Co-authored-by: Lucas Francisco Lopez --- .../api/elasticsearch/elasticsearch_test.go | 21 +- pkg/tsdb/elasticsearch/client/client.go | 7 +- pkg/tsdb/elasticsearch/client/cluster_info.go | 51 +++++ .../elasticsearch/client/cluster_info_test.go | 188 ++++++++++++++++++ pkg/tsdb/elasticsearch/elasticsearch.go | 14 ++ pkg/tsdb/elasticsearch/elasticsearch_test.go | 57 ++++++ pkg/tsdb/elasticsearch/healthcheck.go | 9 +- .../configuration/ApiKeyConfig.tsx | 22 ++ .../configuration/ConfigEditor.tsx | 16 +- .../plugins/datasource/elasticsearch/types.ts | 5 + 10 files changed, 384 insertions(+), 6 deletions(-) create mode 100644 pkg/tsdb/elasticsearch/client/cluster_info.go create mode 100644 pkg/tsdb/elasticsearch/client/cluster_info_test.go create mode 100644 public/app/plugins/datasource/elasticsearch/configuration/ApiKeyConfig.tsx 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/tsdb/elasticsearch/client/client.go b/pkg/tsdb/elasticsearch/client/client.go index fbb3e09f092..49b24651609 100644 --- a/pkg/tsdb/elasticsearch/client/client.go +++ b/pkg/tsdb/elasticsearch/client/client.go @@ -35,6 +35,7 @@ type DatasourceInfo struct { Interval string MaxConcurrentShardRequests int64 IncludeFrozen bool + ClusterInfo ClusterInfo } type ConfiguredFields struct { @@ -197,7 +198,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/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/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/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/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'; From c1a46fdcb51135f2a6e5e77b7a874822de8d3ccd Mon Sep 17 00:00:00 2001 From: Andreas Christou Date: Wed, 14 Jan 2026 13:54:21 +0100 Subject: [PATCH 47/57] Elasticsearch: Decoupling from core (#115900) * Complete decoupling of backend - Replace usage of featuremgmt - Copy simplejson - Add standalone logic * Complete frontend decoupling - Fix imports - Copy store and reducer logic * Add required files for full decoupling * Regen cue * Prettier * Remove unneeded script * Jest fix * Add jest config * Lint * Lit * Prune suppresions --- .golangci.yml | 2 + eslint-suppressions.json | 25 - jest.config.js | 1 + .../x/ElasticsearchDataQuery_types.gen.ts | 2 +- .../api/plugins/data/expectedListResp.json | 2 +- pkg/tsdb/elasticsearch/aggregation_factory.go | 2 +- pkg/tsdb/elasticsearch/client/client.go | 3 +- pkg/tsdb/elasticsearch/client/client_test.go | 2 +- .../client/search_request_test.go | 2 +- .../elasticsearch/data_query_processor.go | 2 +- pkg/tsdb/elasticsearch/data_query_settings.go | 2 +- .../metrics_response_processor.go | 2 +- pkg/tsdb/elasticsearch/models.go | 2 +- pkg/tsdb/elasticsearch/parse_query.go | 2 +- .../raw_dsl_aggregation_parser.go | 2 +- pkg/tsdb/elasticsearch/response_parser.go | 2 +- pkg/tsdb/elasticsearch/response_utils.go | 2 +- .../elasticsearch/simplejson/simplejson.go | 582 ++++++++++++++++++ .../simplejson/simplejson_go11.go | 90 +++ .../simplejson/simplejson_test.go | 274 +++++++++ .../elasticsearch/standalone/datasource.go | 48 ++ pkg/tsdb/elasticsearch/standalone/main.go | 23 + .../app/features/plugins/built_in_plugins.ts | 3 - .../datasource/elasticsearch/CHANGELOG.md | 0 .../DateHistogramSettingsEditor.test.tsx | 3 +- .../DateHistogramSettingsEditor.tsx | 6 +- .../FiltersSettingsEditor/index.tsx | 2 +- .../FiltersSettingsEditor/state/actions.ts | 2 +- .../state/reducer.test.ts | 5 +- .../FiltersSettingsEditor/state/reducer.ts | 3 +- .../FiltersSettingsEditor/utils.ts | 2 +- .../TermsSettingsEditor.test.tsx | 9 +- .../SettingsEditor/TermsSettingsEditor.tsx | 12 +- .../SettingsEditor/index.tsx | 2 +- .../SettingsEditor/useDescription.ts | 5 +- .../BucketAggregationsEditor/state/actions.ts | 6 +- .../state/reducer.test.ts | 7 +- .../BucketAggregationsEditor/state/reducer.ts | 14 +- .../BucketScriptSettingsEditor/index.tsx | 8 +- .../state/reducer.test.ts | 3 +- .../state/reducer.ts | 3 +- .../BucketScriptSettingsEditor/utils.ts | 2 +- .../SettingsEditor/SettingField.tsx | 7 +- .../TopMetricsSettingsEditor.tsx | 2 +- .../SettingsEditor/index.test.tsx | 2 +- .../SettingsEditor/index.tsx | 6 +- .../SettingsEditor/useDescription.ts | 3 +- .../MetricAggregationsEditor/state/actions.ts | 3 +- .../state/reducer.test.ts | 8 +- .../MetricAggregationsEditor/state/reducer.ts | 4 +- .../elasticsearch/components/reducerTester.ts | 2 +- .../datasource/elasticsearch/jest-setup.js | 1 + .../datasource/elasticsearch/jest.config.js | 3 + .../datasource/elasticsearch/package.json | 62 ++ .../datasource/elasticsearch/plugin.json | 8 +- .../datasource/elasticsearch/project.json | 9 + .../elasticsearch/reducers/actions/cleanUp.ts | 11 + .../datasource/elasticsearch/reducers/root.ts | 21 + .../elasticsearch/store/configureStore.ts | 47 ++ .../datasource/elasticsearch/store/store.ts | 26 + .../datasource/elasticsearch/tsconfig.json | 8 + .../datasource/elasticsearch/types/store.ts | 46 ++ .../elasticsearch/webpack.config.ts | 9 + yarn.lock | 47 ++ 64 files changed, 1378 insertions(+), 128 deletions(-) create mode 100644 pkg/tsdb/elasticsearch/simplejson/simplejson.go create mode 100644 pkg/tsdb/elasticsearch/simplejson/simplejson_go11.go create mode 100644 pkg/tsdb/elasticsearch/simplejson/simplejson_test.go create mode 100644 pkg/tsdb/elasticsearch/standalone/datasource.go create mode 100644 pkg/tsdb/elasticsearch/standalone/main.go create mode 100644 public/app/plugins/datasource/elasticsearch/CHANGELOG.md create mode 100644 public/app/plugins/datasource/elasticsearch/jest-setup.js create mode 100644 public/app/plugins/datasource/elasticsearch/jest.config.js create mode 100644 public/app/plugins/datasource/elasticsearch/package.json create mode 100644 public/app/plugins/datasource/elasticsearch/project.json create mode 100644 public/app/plugins/datasource/elasticsearch/reducers/actions/cleanUp.ts create mode 100644 public/app/plugins/datasource/elasticsearch/reducers/root.ts create mode 100644 public/app/plugins/datasource/elasticsearch/store/configureStore.ts create mode 100644 public/app/plugins/datasource/elasticsearch/store/store.ts create mode 100644 public/app/plugins/datasource/elasticsearch/tsconfig.json create mode 100644 public/app/plugins/datasource/elasticsearch/types/store.ts create mode 100644 public/app/plugins/datasource/elasticsearch/webpack.config.ts diff --git a/.golangci.yml b/.golangci.yml index 069e88632ff..d7037bf6fac 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -121,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/eslint-suppressions.json b/eslint-suppressions.json index 70df9a82829..25d3225375e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -3743,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 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/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/pkg/tests/api/plugins/data/expectedListResp.json b/pkg/tests/api/plugins/data/expectedListResp.json index 3d1ccce6a59..83debc4c410 100644 --- a/pkg/tests/api/plugins/data/expectedListResp.json +++ b/pkg/tests/api/plugins/data/expectedListResp.json @@ -639,7 +639,7 @@ ] }, "dependencies": { - "grafanaDependency": "", + "grafanaDependency": "\u003e=11.6.0", "grafanaVersion": "*", "plugins": [], "extensions": { 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 49b24651609..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 @@ -160,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 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/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/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/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 | 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/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/reducerTester.ts b/public/app/plugins/datasource/elasticsearch/components/reducerTester.ts index 79e2fddbc2d..b6a53173cdd 100644 --- a/public/app/plugins/datasource/elasticsearch/components/reducerTester.ts +++ b/public/app/plugins/datasource/elasticsearch/components/reducerTester.ts @@ -2,7 +2,7 @@ import { AnyAction } from '@reduxjs/toolkit'; import { cloneDeep } from 'lodash'; import { Action } from 'redux'; -import { StoreState } from 'app/types/store'; +import { StoreState } from '../types/store'; type GrafanaReducer = (state: S, action: A) => S; 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/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/yarn.lock b/yarn.lock index d16b10ef5f3..5559a4bed77 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2610,6 +2610,53 @@ __metadata: languageName: node linkType: hard +"@grafana-plugins/elasticsearch@workspace:public/app/plugins/datasource/elasticsearch": + version: 0.0.0-use.local + resolution: "@grafana-plugins/elasticsearch@workspace:public/app/plugins/datasource/elasticsearch" + dependencies: + "@emotion/css": "npm:11.13.5" + "@grafana/aws-sdk": "npm:0.8.3" + "@grafana/data": "npm:12.4.0-pre" + "@grafana/e2e-selectors": "npm:12.4.0-pre" + "@grafana/plugin-configs": "npm:12.4.0-pre" + "@grafana/plugin-ui": "npm:^0.11.1" + "@grafana/runtime": "npm:12.4.0-pre" + "@grafana/schema": "npm:12.4.0-pre" + "@grafana/ui": "npm:12.4.0-pre" + "@reduxjs/toolkit": "npm:2.10.1" + "@testing-library/dom": "npm:10.4.1" + "@testing-library/jest-dom": "npm:6.6.4" + "@testing-library/react": "npm:16.3.0" + "@testing-library/user-event": "npm:14.6.1" + "@types/jest": "npm:29.5.14" + "@types/lodash": "npm:4.17.20" + "@types/lucene": "npm:^2" + "@types/node": "npm:24.10.1" + "@types/react": "npm:18.3.18" + "@types/react-dom": "npm:18.3.5" + "@types/semver": "npm:7.7.1" + jest: "npm:29.7.0" + lodash: "npm:4.17.21" + lucene: "npm:^2.1.1" + react: "npm:18.3.1" + react-dom: "npm:18.3.1" + react-redux: "npm:9.2.0" + react-select: "npm:5.10.2" + react-select-event: "npm:5.5.1" + react-use: "npm:17.6.0" + redux: "npm:5.0.1" + redux-thunk: "npm:3.1.0" + rxjs: "npm:7.8.2" + semver: "npm:7.7.3" + ts-node: "npm:10.9.2" + tslib: "npm:2.8.1" + typescript: "npm:5.9.2" + webpack: "npm:5.101.0" + peerDependencies: + "@grafana/runtime": "*" + languageName: unknown + linkType: soft + "@grafana-plugins/grafana-azure-monitor-datasource@workspace:public/app/plugins/datasource/azuremonitor": version: 0.0.0-use.local resolution: "@grafana-plugins/grafana-azure-monitor-datasource@workspace:public/app/plugins/datasource/azuremonitor" From f704b8aa798b4b56724c20b26379c6b4d9ce1a8e Mon Sep 17 00:00:00 2001 From: Andreas Christou Date: Wed, 14 Jan 2026 14:16:09 +0100 Subject: [PATCH 48/57] Cloud Monitoring: Add support for Google Cloud universe_domain (#115931) * feat(cloud-monitoring): add support for Google Cloud universe_domain (#110083) This change introduces support for Google Cloud's `universe_domain`, enabling connections to sovereign cloud environments with custom API endpoints. - Adds an optional "Universe Domain" field in the Google Cloud Monitoring data source configuration (frontend and backend). - Allows specifying a custom API domain (e.g., `s3nsapis.fr`) for use in sovereign environments. - Defaults to `googleapis.com` to ensure backward compatibility for existing configurations. Signed-off-by: Andreas Christou * Minor docs update * Doc updates * Update editor * Update docs/sources/datasources/google-cloud-monitoring/_index.md Co-authored-by: Larissa Wandzura <126723338+lwandz13@users.noreply.github.com> * Lint * Review --------- Signed-off-by: Andreas Christou Co-authored-by: Larissa Wandzura <126723338+lwandz13@users.noreply.github.com> --- .../google-cloud-monitoring/_index.md | 12 ++++--- pkg/tsdb/cloud-monitoring/cloudmonitoring.go | 9 ++++-- pkg/tsdb/cloud-monitoring/httpclient.go | 11 +++++-- .../cloud-monitoring/resource_handler_test.go | 2 +- .../components/ConfigEditor/ConfigEditor.tsx | 31 +++++++++++++++---- .../cloud-monitoring/types/types.ts | 1 + 6 files changed, 50 insertions(+), 16 deletions(-) diff --git a/docs/sources/datasources/google-cloud-monitoring/_index.md b/docs/sources/datasources/google-cloud-monitoring/_index.md index 27bc7b390cd..d5412c33ef7 100644 --- a/docs/sources/datasources/google-cloud-monitoring/_index.md +++ b/docs/sources/datasources/google-cloud-monitoring/_index.md @@ -103,10 +103,11 @@ To configure basic settings for the data source, complete the following steps: 1. Set the data source's basic configuration options: - | Name | Description | - | ----------- | ------------------------------------------------------------------------ | - | **Name** | Sets the name you use to refer to the data source in panels and queries. | - | **Default** | Sets whether the data source is pre-selected for new panels. | + | Name | Description | + | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | **Name** | Sets the name you use to refer to the data source in panels and queries. | + | **Default** | Sets whether the data source is pre-selected for new panels. | + | **Universe Domain** | The universe domain to connect to. For more information, refer to [Documentation on universe domains](https://docs.cloud.google.com/python/docs/reference/monitoring/latest/google.cloud.monitoring_v3.services.service_monitoring_service.ServiceMonitoringServiceAsyncClient#google_cloud_monitoring_v3_services_service_monitoring_service_ServiceMonitoringServiceAsyncClient_universe_domain). Defaults to `googleapis.com`. | ### Provision the data source @@ -129,6 +130,7 @@ datasources: clientEmail: stackdriver@myproject.iam.gserviceaccount.com authenticationType: jwt defaultProject: my-project-name + universeDomain: googleapis.com secureJsonData: privateKey: | -----BEGIN PRIVATE KEY----- @@ -152,6 +154,7 @@ datasources: clientEmail: stackdriver@myproject.iam.gserviceaccount.com authenticationType: jwt defaultProject: my-project-name + universeDomain: googleapis.com privateKeyPath: /etc/secrets/gce.pem ``` @@ -166,6 +169,7 @@ datasources: access: proxy jsonData: authenticationType: gce + universeDomain: googleapis.com ``` ## Import pre-configured dashboards 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/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 {} From ba6a783997552ad5154918918f79ba1ab06584bc Mon Sep 17 00:00:00 2001 From: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:20:24 +0100 Subject: [PATCH 49/57] Add Db2 plugin (#116190) * Add new Db2 plugin * Fix ID * Fix ID * Run `i18n-extract` * Linting * Fix ID * Linting * Rename plugin * Fix i18n entry * Run `yarn i18n-extract` --- .../introduction/grafana-enterprise.md | 1 + .../datasources/state/buildCategories.test.ts | 2 +- .../datasources/state/buildCategories.ts | 7 + public/img/plugins/db2.svg | 937 ++++++++++++++++++ public/locales/en-US/grafana.json | 1 + 5 files changed, 947 insertions(+), 1 deletion(-) create mode 100644 public/img/plugins/db2.svg 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/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/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/en-US/grafana.json b/public/locales/en-US/grafana.json index a5545957399..0725626911c 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -6983,6 +6983,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", From 9f44f868aa4f0529255ca4d5f7773f3d8deaa47e Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Wed, 14 Jan 2026 15:48:48 +0200 Subject: [PATCH 50/57] Stars: Fix infinite loading with no starred items (#116248) --- public/app/features/search/service/unified.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/public/app/features/search/service/unified.ts b/public/app/features/search/service/unified.ts index 15280602202..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(); } @@ -331,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; } From d0df6b8de4067923f02b0bc70be464d12b25d212 Mon Sep 17 00:00:00 2001 From: Rodrigo Vasconcelos de Barros Date: Wed, 14 Jan 2026 08:58:03 -0500 Subject: [PATCH 51/57] Alerting: Provisioning Status Differentiation for ALL resources (#115773) * Show different badge for converted prometheus provisioned resource * Update contact points and templates to populate provenance * Update notification policies * Handle non k8s contact point in ContactPointHeader * Fix provenance check in enhanceContactPointsWithMetadata * Update translations * Fix unused import * Refactor provenance enum * Derive provisioned status from provenance in Route type * Remove unused imports * Treat PROVENANCE_NONE as no provenance in isRouteProvisione * Rename KnownProvenance.None to .Empty to avoid confusion * Change copy text for resources with converted_prometheus provenance * Derive provisioned status from provenance in GrafanaManagedContactPoint * Fix linter errors * Extract helper method to check if contact point is provisioned * Replace string literal with constant * Refactor KnownProvenance enum values Refactored the KnownProvenance enum to better reflect the known provenances defined by the backend. Also refactored the methods where we assert if a resource is provisioned to better reflect the cases for which a provenance value reflects no provisioning. A resource is considered not provisioned when the provenance is equal to '', 'none' or undefined. * Use provenance to infer provenance status for Templates Refactored useNotificationTemplateMetadata to use only provenance value, and extracted method used to assert if resource is provisioned or not to k8s/utils in order to be more resource agnostic. * Replace empty string with 'none' for KnownProvenance enum The empty string valye for provenance gets mapped to the string literal 'none' before being passed down in the api response, therefore we can use only 'none' * Replace PROVENANCE_NONE with KnownProvenance.None Replaced the constant PROVENANCE_NONE with the KnownProvenance.None enum value since the values where duplicated * Fix JSDoc * Change copy text for ProvisioningBadge * Add missing tooltip in notification policy badge * Add missing tooltip in TemplatesTable badge * fix conflicts --------- Co-authored-by: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Co-authored-by: Sonia Aguilar --- .../unified/components/Provisioning.test.tsx | 67 +++++ .../unified/components/Provisioning.tsx | 12 +- .../ContactPointHeader.test.tsx | 60 +++++ .../contact-points/ContactPointHeader.tsx | 17 +- .../contact-points/ContactPoints.test.tsx | 5 +- .../useContactPoints.test.tsx.snap | 20 +- .../contact-points/useContactPoints.test.tsx | 234 ++++++++++++++++++ .../contact-points/useContactPoints.ts | 9 +- .../useNotificationTemplates.ts | 15 +- .../components/contact-points/utils.test.ts | 111 +++++++++ .../components/contact-points/utils.ts | 7 + .../mute-timings/useMuteTimings.tsx | 4 +- .../NotificationPoliciesList.tsx | 8 +- .../notification-policies/Policy.test.tsx | 55 ++++ .../notification-policies/Policy.tsx | 4 +- .../useNotificationPolicyRoute.test.tsx | 91 ++++++- .../useNotificationPolicyRoute.ts | 14 +- .../components/receivers/TemplateForm.tsx | 4 +- .../receivers/TemplatesTable.test.tsx | 98 ++++++++ .../components/receivers/TemplatesTable.tsx | 6 +- .../receivers/form/GrafanaReceiverForm.tsx | 15 +- .../form/fields/TemplateSelector.test.tsx | 4 +- .../mocks/server/entities/k8s/routingtrees.ts | 5 +- .../server/handlers/k8s/receivers.k8s.ts | 5 +- .../server/handlers/k8s/templates.k8s.ts | 5 +- .../server/handlers/k8s/timeIntervals.k8s.ts | 5 +- .../alerting/unified/types/knownProvenance.ts | 6 + .../alerting/unified/utils/k8s/constants.ts | 3 - .../alerting/unified/utils/k8s/utils.test.ts | 30 ++- .../alerting/unified/utils/k8s/utils.ts | 10 +- .../plugins/datasource/alertmanager/types.ts | 4 +- public/locales/en-US/grafana.json | 1 + 32 files changed, 867 insertions(+), 67 deletions(-) create mode 100644 public/app/features/alerting/unified/components/Provisioning.test.tsx create mode 100644 public/app/features/alerting/unified/components/contact-points/ContactPointHeader.test.tsx create mode 100644 public/app/features/alerting/unified/components/receivers/TemplatesTable.test.tsx create mode 100644 public/app/features/alerting/unified/types/knownProvenance.ts diff --git a/public/app/features/alerting/unified/components/Provisioning.test.tsx b/public/app/features/alerting/unified/components/Provisioning.test.tsx new file mode 100644 index 00000000000..4519a8db133 --- /dev/null +++ b/public/app/features/alerting/unified/components/Provisioning.test.tsx @@ -0,0 +1,67 @@ +import { render, screen } from 'test/test-utils'; + +import { KnownProvenance } from '../types/knownProvenance'; + +import { ProvisioningBadge } from './Provisioning'; + +describe('ProvisioningBadge', () => { + 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 7a88d1e21d7..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', @@ -64,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/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 && ( )} - {contactPoint?.provisioned && hasLegacyIntegrations(contactPoint, grafanaNotifiers) && ( - - )} - {contactPoint?.provisioned && !hasLegacyIntegrations(contactPoint, grafanaNotifiers) && ( + {isProvisioned && hasLegacyIntegrations(contactPoint, grafanaNotifiers) && } + {isProvisioned && !hasLegacyIntegrations(contactPoint, grafanaNotifiers) && ( )} 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/mocks/server/entities/k8s/routingtrees.ts b/public/app/features/alerting/unified/mocks/server/entities/k8s/routingtrees.ts index 9babb1332bf..1005267badf 100644 --- a/public/app/features/alerting/unified/mocks/server/entities/k8s/routingtrees.ts +++ b/public/app/features/alerting/unified/mocks/server/entities/k8s/routingtrees.ts @@ -4,7 +4,8 @@ import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route, ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree, } from 'app/features/alerting/unified/openapi/routesApi.gen'; -import { K8sAnnotations, PROVENANCE_NONE, ROOT_ROUTE_NAME } from 'app/features/alerting/unified/utils/k8s/constants'; +import { KnownProvenance } from 'app/features/alerting/unified/types/knownProvenance'; +import { K8sAnnotations, ROOT_ROUTE_NAME } from 'app/features/alerting/unified/utils/k8s/constants'; import { AlertManagerCortexConfig, MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types'; /** @@ -66,7 +67,7 @@ export const getUserDefinedRoutingTree: ( name: ROOT_ROUTE_NAME, namespace: 'default', annotations: { - [K8sAnnotations.Provenance]: PROVENANCE_NONE, + [K8sAnnotations.Provenance]: KnownProvenance.None, }, // Resource versions are much shorter than this in reality, but this is an easy way // for us to mock the concurrency logic and check if the policies have updated since the last fetch diff --git a/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts b/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts index a620fe528c2..177b5c23499 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts @@ -6,8 +6,9 @@ import { } 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 { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver } from 'app/features/alerting/unified/openapi/receiversApi.gen'; +import { KnownProvenance } from 'app/features/alerting/unified/types/knownProvenance'; 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'; const usedByPolicies = ['grafana-default-email']; const usedByRules = ['grafana-default-email']; @@ -23,7 +24,7 @@ const getReceiversList = () => { 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 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/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index 3fb512c88c6..1f964e6b664 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -108,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[]; } @@ -148,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/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 0725626911c..5bf71d4e7f8 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2184,6 +2184,7 @@ }, "provisioning-badge": { "badge": { + "text-converted-prometheus": "Imported", "text-provisioned": "Provisioned" } }, From d95c51b20ef8e85fff0de219cb4e4c3973d25ea3 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 14 Jan 2026 17:09:37 +0300 Subject: [PATCH 52/57] Chore: Deprecate experimental restore dashboard API (#116256) --- .../http-api/dashboard_versions.md | 143 ------------------ .../src/clients/rtkq/legacy/endpoints.gen.ts | 35 ----- pkg/api/dashboard.go | 4 + public/api-merged.json | 2 + public/openapi3.json | 2 + 5 files changed, 8 insertions(+), 178 deletions(-) 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/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts index 4ceed793cad..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`, @@ -2628,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; @@ -4568,9 +4537,6 @@ export type DashboardAclUpdateItem = { export type UpdateDashboardAclCommand = { items?: DashboardAclUpdateItem[]; }; -export type RestoreDashboardVersionCommand = { - version?: number; -}; export type DashboardVersionMeta = { created?: string; createdBy?: string; @@ -6633,7 +6599,6 @@ export const { useGetDashboardPermissionsListByUidQuery, useLazyGetDashboardPermissionsListByUidQuery, useUpdateDashboardPermissionsByUidMutation, - useRestoreDashboardVersionByUidMutation, useGetDashboardVersionsByUidQuery, useLazyGetDashboardVersionsByUidQuery, useGetDashboardVersionByUidQuery, 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/public/api-merged.json b/public/api-merged.json index d511139c7bd..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", diff --git a/public/openapi3.json b/public/openapi3.json index 4a7c5f6be08..8dac2bbc044 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -18377,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": [ { From ea2a0936df07b48b0cc61392d21fe4745c62294b Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Wed, 14 Jan 2026 07:29:51 -0700 Subject: [PATCH 53/57] Dashboard Conversion: Preserve repeat property when converting tabs to rows (#116180) * preserve repeat property * fix test * preserve repeat when converting panels in tabs or rows with autogrid layout * fix v1 serialization of autogrid --- ...beta1.tabs-and-rows-repeated.v0alpha1.json | 4 ++++ ...2beta1.tabs-and-rows-repeated.v1beta1.json | 4 ++++ .../conversion/v2alpha1_to_v1beta1.go | 20 +++++++++++++++++++ .../transformSceneToSaveModel.ts | 13 ++++++++++++ 4 files changed, 41 insertions(+) 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/v2alpha1_to_v1beta1.go b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go index 857af7ca866..f9f953f965d 100644 --- a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go +++ b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go @@ -439,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, @@ -819,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) diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts index c5d01b433e3..4b0e109e864 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts @@ -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 From 0e6651c72997056a893b0b0f481fdcdfaa3341f1 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Wed, 14 Jan 2026 09:55:12 -0500 Subject: [PATCH 54/57] Gauge: Re-introduce minVizHeight and minVizWidth (#116034) --- .../panelcfg/x/NewGaugePanelCfg_types.gen.ts | 6 +++ .../panel/radialbar/RadialBarPanel.tsx | 7 +-- public/app/plugins/panel/radialbar/module.tsx | 48 ++++++++++++++++++- .../app/plugins/panel/radialbar/panelcfg.cue | 3 ++ .../plugins/panel/radialbar/panelcfg.gen.ts | 6 +++ 5 files changed, 64 insertions(+), 6 deletions(-) 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/public/app/plugins/panel/radialbar/RadialBarPanel.tsx b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx index 8b32e651f84..3b952381554 100644 --- a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx +++ b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx @@ -85,9 +85,6 @@ export function RadialBarPanel({ }); } - const minVizHeight = 60; - const minVizWidth = 60; - if (getValues()[0]?.display?.text === 'No data') { return ; } @@ -104,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', }; From d6ac674f3e313efe20924c03eb6dabbd5bc5d328 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Wed, 14 Jan 2026 09:55:34 -0500 Subject: [PATCH 55/57] Gauge: Fix issue with gdev dashboard (#116235) --- .../panel-gauge/gauge_tests_new.v42.json | 6 +++--- devenv/dev-dashboards/panel-gauge/gauge_tests_new.json | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) 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/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, From 399b3def4f470a2150b4db73bb46f2aa9ab64c9b Mon Sep 17 00:00:00 2001 From: Tania <10127682+undef1nd@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:57:12 +0100 Subject: [PATCH 56/57] Chore: Fix `pluginsAutoUpdate` flag evaluation (#116065) * Experimental: Test flag evaluation * Attempt to inject requester into the context * fixup! Attempt to inject requester into the context --- pkg/services/updatemanager/plugins.go | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) 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) } From 505fa869ee3da7b0fd9bb8f9f3558f6f3391b60b Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:03:19 -0500 Subject: [PATCH 57/57] Docs: Dashboard schema v2 public preview updates (#115293) --- .../as-code/observability-as-code/_index.md | 6 +- .../observability-as-code/schema-v2/_index.md | 243 --------- .../schema-v2/annotations-schema.md | 86 --- .../schema-v2/layout-schema.md | 339 ------------ .../schema-v2/librarypanel-schema.md | 68 --- .../schema-v2/links-schema.md | 67 --- .../schema-v2/panel-schema.md | 305 ----------- .../schema-v2/timesettings-schema.md | 87 --- .../schema-v2/variables-schema.md | 501 ------------------ .../view-dashboard-json-model/index.md | 213 ++++++-- 10 files changed, 166 insertions(+), 1749 deletions(-) delete mode 100644 docs/sources/as-code/observability-as-code/schema-v2/_index.md delete mode 100644 docs/sources/as-code/observability-as-code/schema-v2/annotations-schema.md delete mode 100644 docs/sources/as-code/observability-as-code/schema-v2/layout-schema.md delete mode 100644 docs/sources/as-code/observability-as-code/schema-v2/librarypanel-schema.md delete mode 100644 docs/sources/as-code/observability-as-code/schema-v2/links-schema.md delete mode 100644 docs/sources/as-code/observability-as-code/schema-v2/panel-schema.md delete mode 100644 docs/sources/as-code/observability-as-code/schema-v2/timesettings-schema.md delete mode 100644 docs/sources/as-code/observability-as-code/schema-v2/variables-schema.md 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 65c73a49cbe..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` - -Tags associated with the dashboard. Each tag can be up to 50 characters long. - -` [...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/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..ce6095d88e6 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**. -1. Go to the **JSON Model** tab. -1. When you've finished viewing the JSON, click **Back to dashboard** and **Exit edit**. +There are currently three dashboard JSON schema models: -## JSON fields - -When a user creates a new dashboard, a new dashboard JSON object is initialized with the following fields: +- [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" >}} -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. +[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** in the top-right corner of the dashboard. +1. Click the gear icon in the right sidebar and click **Settings** in the secondary sidebar. +1. Select the **JSON Model** tab. +1. Update the JSON structure as needed. +1. Click **Save changes**. + +## Classic model + +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. +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).