From bfedf0b51230e602267477896ed22c86b32a632e Mon Sep 17 00:00:00 2001 From: Ivan Ortega Alba Date: Wed, 12 Mar 2025 18:43:32 +0100 Subject: [PATCH] Dashboard: Redirect between `v1alpha1` and `v2alpha1` depending on stored version (#101292) * wip: Create a proxy state manager to avoid complexity * Read path redirecting * add tests for unified dashboard API * add tests * Contemplate both formats in DashboardProxy * Fix force old * Fix tests for proxy * catch errors * Save as V2 when dynamic dashboard is enabled * Improve tests * Remove feature toggle * Use kubernetesDashboards for e2e suite * Fix issue when loading snapshots * Fix typescript errors * Integrate with backend conversion error * Remove legacy annotation * fix snapshot loading; lint * Add missing hideTimeControls * fix test * make setupDashboardAPI to all suites * refactor getDashboardAPI * Add tests * fix DashboardScenePage tests * fix tests * fix go tests * Refactor to understand better the need of transforming to v2 to compare * Fix detect changes logic * yes status from schema gen --------- Co-authored-by: alexandra vargas Co-authored-by: Haris Rozajac --- .betterer.results | 14 +- .../feature-toggles/index.md | 1 - e2e/cypress/support/e2e.js | 6 +- e2e/run-suite | 6 +- .../src/types/featureToggles.gen.ts | 1 - pkg/apis/dashboard_manifest.go | 2 - pkg/registry/apis/dashboard/register.go | 2 +- pkg/registry/apis/dashboard/register_test.go | 2 +- 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 | 3 +- .../Select/DashboardPicker.test.tsx | 2 - .../components/Select/DashboardPicker.tsx | 34 ++- public/app/core/services/backend_srv.ts | 2 +- .../api/browseDashboardsAPI.ts | 53 ++--- .../pages/DashboardScenePage.tsx | 5 +- .../DashboardScenePageStateManager.test.ts | 195 +++++++++++++++--- .../pages/DashboardScenePageStateManager.ts | 163 ++++++++++++--- .../saving/SaveDashboardDrawer.tsx | 3 +- .../saving/SaveDashboardForm.tsx | 11 +- .../saving/getDashboardChanges.ts | 26 ++- .../dashboard-scene/saving/shared.tsx | 1 + .../dashboard-scene/scene/DashboardScene.tsx | 7 +- .../scene/NavToolbarActions.tsx | 4 +- .../DashboardSceneSerializer.test.ts | 8 - .../serialization/DashboardSceneSerializer.ts | 73 ++++--- .../transformSaveModelSchemaV2ToScene.ts | 105 +++++----- .../transformSaveModelToScene.ts | 84 ++++---- .../dashboard/api/UnifiedDashboardAPI.test.ts | 112 ++++++++++ .../dashboard/api/UnifiedDashboardAPI.ts | 50 +++++ .../dashboard/api/dashboard_api.test.ts | 40 ++-- .../features/dashboard/api/dashboard_api.ts | 26 +-- public/app/features/dashboard/api/types.ts | 25 ++- .../app/features/dashboard/api/utils.test.ts | 22 +- public/app/features/dashboard/api/utils.ts | 33 +-- public/app/features/dashboard/api/v1.ts | 7 +- public/app/features/dashboard/api/v2.test.ts | 10 +- public/app/features/dashboard/api/v2.ts | 39 ++-- .../SaveDashboard/SaveDashboardDiff.tsx | 16 +- .../containers/DashboardPageProxy.tsx | 28 +-- .../dashboard/services/DashboardLoaderSrv.ts | 16 +- .../features/dashboard/state/initDashboard.ts | 2 +- .../manage-dashboards/utils/validation.ts | 9 +- 44 files changed, 874 insertions(+), 386 deletions(-) create mode 100644 public/app/features/dashboard/api/UnifiedDashboardAPI.test.ts create mode 100644 public/app/features/dashboard/api/UnifiedDashboardAPI.ts diff --git a/.betterer.results b/.betterer.results index eccc02ef7df..57d2b1cf765 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3143,12 +3143,14 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "9"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "10"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "11"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "12"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "12"], [0, 0, 0, "No untranslated strings. Wrap text with ", "13"], [0, 0, 0, "No untranslated strings. Wrap text with ", "14"], [0, 0, 0, "No untranslated strings. Wrap text with ", "15"], [0, 0, 0, "No untranslated strings. Wrap text with ", "16"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "17"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "17"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "18"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "19"] ], "public/app/features/dashboard-scene/saving/SaveProvisionedDashboardForm.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], @@ -3166,7 +3168,8 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "2"], [0, 0, 0, "Do not use any type assertions.", "3"], [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"] + [0, 0, 0, "Do not use any type assertions.", "5"], + [0, 0, 0, "Unexpected any. Specify a different type.", "6"] ], "public/app/features/dashboard-scene/saving/shared.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], @@ -3714,8 +3717,9 @@ exports[`better eslint`] = { ], "public/app/features/dashboard/components/SaveDashboard/SaveDashboardDiff.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "2"] + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "3"] ], "public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], 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 c401c8dd581..60f30055ae8 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -214,7 +214,6 @@ Experimental features might be changed or removed without prior notice. | `enableSCIM` | Enables SCIM support for user and group management | | `crashDetection` | Enables browser crash detection reporting to Faro. | | `jaegerBackendMigration` | Enables querying the Jaeger data source without the proxy | -| `useV2DashboardsAPI` | Use the v2 kubernetes API in the frontend for dashboards | | `unifiedHistory` | Displays the navigation history so the user can navigate back to previous pages | | `investigationsBackend` | Enable the investigations backend API | | `k8SFolderCounts` | Enable folder's api server counts | diff --git a/e2e/cypress/support/e2e.js b/e2e/cypress/support/e2e.js index ddd50a58844..284ac975ef0 100644 --- a/e2e/cypress/support/e2e.js +++ b/e2e/cypress/support/e2e.js @@ -51,8 +51,8 @@ beforeEach(() => { cy.setLocalStorage('grafana.featureToggles', 'dashboardScene=false'); } - if (Cypress.env('useV2DashboardsAPI')) { - cy.logToConsole('enabling v2 dashboards API in localstorage'); - cy.setLocalStorage('grafana.featureToggles', 'useV2DashboardsAPI=true'); + if (Cypress.env('kubernetesDashboards')) { + cy.logToConsole('enabling kubernetes dashboards API in localstorage'); + cy.setLocalStorage('grafana.featureToggles', 'kubernetesDashboards=true'); } }); diff --git a/e2e/run-suite b/e2e/run-suite index bc0eebd1567..ef649a6c6c4 100755 --- a/e2e/run-suite +++ b/e2e/run-suite @@ -28,7 +28,7 @@ declare -A env=( testFilesForSingleSuite="*.spec.ts" rootForEnterpriseSuite="./e2e/extensions-suite" rootForOldArch="./e2e/old-arch" -rootForDashboardsSchemaV2="./e2e/dashboards-suite" +rootForKubernetesDashboards="./e2e/dashboards-suite" declare -A cypressConfig=( [screenshotsFolder]=./e2e/"${args[0]}"/screenshots @@ -113,8 +113,8 @@ case "$1" in env[DISABLE_SCENES]=true ;; "dashboards-schema-v2") - env[useV2DashboardsAPI]=true - cypressConfig[specPattern]=$rootForDashboardsSchemaV2/$testFilesForSingleSuite + env[kubernetesDashboards]=true + cypressConfig[specPattern]=$rootForKubernetesDashboards/$testFilesForSingleSuite cypressConfig[video]=false case "$2" in "debug") diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index edadbb77c33..d8f014be4da 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -225,7 +225,6 @@ export interface FeatureToggles { alertingUIOptimizeReducer?: boolean; azureMonitorEnableUserAuth?: boolean; alertingNotificationsStepMode?: boolean; - useV2DashboardsAPI?: boolean; feedbackButton?: boolean; unifiedStorageSearchUI?: boolean; elasticsearchCrossClusterSearch?: boolean; diff --git a/pkg/apis/dashboard_manifest.go b/pkg/apis/dashboard_manifest.go index bc68fa86ca6..9980594ef44 100644 --- a/pkg/apis/dashboard_manifest.go +++ b/pkg/apis/dashboard_manifest.go @@ -11,8 +11,6 @@ import ( "github.com/grafana/grafana-app-sdk/app" ) -var () - var appManifestData = app.ManifestData{ AppName: "dashboard", Group: "dashboard.grafana.app", diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index 9c750e605a9..b141e027b03 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -109,7 +109,7 @@ func RegisterAPIService( } func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion { - if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagUseV2DashboardsAPI) { + if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts) { // If dashboards v2 is enabled, we want to use v2alpha1 as the default API version. return []schema.GroupVersion{ dashboardv2alpha1.DashboardResourceInfo.GroupVersion(), diff --git a/pkg/registry/apis/dashboard/register_test.go b/pkg/registry/apis/dashboard/register_test.go index 80b1dd28665..21f9f492a45 100644 --- a/pkg/registry/apis/dashboard/register_test.go +++ b/pkg/registry/apis/dashboard/register_test.go @@ -192,7 +192,7 @@ func TestDashboardAPIBuilder_GetGroupVersions(t *testing.T) { { name: "should return v2alpha1 as the default if dashboards v2 is enabled", enabledFeatures: []string{ - featuremgmt.FlagUseV2DashboardsAPI, + featuremgmt.FlagDashboardNewLayouts, }, expected: []schema.GroupVersion{ v2alpha1.DashboardResourceInfo.GroupVersion(), diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d80d539dc7d..34432ae1dc7 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1559,13 +1559,6 @@ var ( FrontendOnly: true, Expression: "true", }, - { - Name: "useV2DashboardsAPI", - Description: "Use the v2 kubernetes API in the frontend for dashboards", - Stage: FeatureStageExperimental, - Owner: grafanaDashboardsSquad, - RequiresRestart: true, // changes the API routing - }, { Name: "feedbackButton", Description: "Enables a button to send feedback from the Grafana UI", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 3ac8a36313b..dece266a38d 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -206,7 +206,6 @@ reportingUseRawTimeRange,GA,@grafana/sharing-squad,false,false,false alertingUIOptimizeReducer,GA,@grafana/alerting-squad,false,false,true azureMonitorEnableUserAuth,GA,@grafana/partner-datasources,false,false,false alertingNotificationsStepMode,GA,@grafana/alerting-squad,false,false,true -useV2DashboardsAPI,experimental,@grafana/dashboards-squad,false,true,false feedbackButton,experimental,@grafana/grafana-operator-experience-squad,false,false,false unifiedStorageSearchUI,experimental,@grafana/search-and-storage,false,false,false elasticsearchCrossClusterSearch,preview,@grafana/aws-datasources,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 47f3239cd3d..a8b217d6a79 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -835,10 +835,6 @@ const ( // Enables simplified step mode in the notifications section FlagAlertingNotificationsStepMode = "alertingNotificationsStepMode" - // FlagUseV2DashboardsAPI - // Use the v2 kubernetes API in the frontend for dashboards - FlagUseV2DashboardsAPI = "useV2DashboardsAPI" - // FlagFeedbackButton // Enables a button to send feedback from the Grafana UI FlagFeedbackButton = "feedbackButton" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index c42c556a928..1f078ca5349 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -4259,7 +4259,8 @@ "metadata": { "name": "useV2DashboardsAPI", "resourceVersion": "1732535420861", - "creationTimestamp": "2024-12-17T21:17:09Z" + "creationTimestamp": "2024-12-17T21:17:09Z", + "deletionTimestamp": "2025-03-04T10:50:39Z" }, "spec": { "description": "Use the v2 kubernetes API in the frontend for dashboards", diff --git a/public/app/core/components/Select/DashboardPicker.test.tsx b/public/app/core/components/Select/DashboardPicker.test.tsx index 53b4d0131fe..d836257a252 100644 --- a/public/app/core/components/Select/DashboardPicker.test.tsx +++ b/public/app/core/components/Select/DashboardPicker.test.tsx @@ -2,7 +2,6 @@ import { noop } from 'lodash'; import { Props } from 'react-virtualized-auto-sizer'; import { render, screen, userEvent, waitFor } from 'test/test-utils'; -import { config } from '@grafana/runtime'; import { defaultDashboard as defaultDashboardData } from '@grafana/schema'; import { DashboardV2Spec, defaultDashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; import { backendSrv } from 'app/core/services/backend_srv'; @@ -104,7 +103,6 @@ describe('DashboardPicker', () => { ['v2', mockDashboardV2], ])('Dashboard %s', (format, dashboard) => { beforeEach(() => { - config.featureToggles.useV2DashboardsAPI = format === 'v2'; getDashboardDTO.mockResolvedValue(dashboard); }); diff --git a/public/app/core/components/Select/DashboardPicker.tsx b/public/app/core/components/Select/DashboardPicker.tsx index 530b98f4f61..efe594ad208 100644 --- a/public/app/core/components/Select/DashboardPicker.tsx +++ b/public/app/core/components/Select/DashboardPicker.tsx @@ -2,11 +2,11 @@ import debounce from 'debounce-promise'; import { useCallback, useEffect, useState } from 'react'; import { SelectableValue } from '@grafana/data'; -import { config } from '@grafana/runtime'; import { AsyncSelectProps, AsyncSelect } from '@grafana/ui'; import { backendSrv } from 'app/core/services/backend_srv'; import { AnnoKeyFolder, AnnoKeyFolderTitle } from 'app/features/apiserver/types'; import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; +import { isDashboardV2Resource } from 'app/features/dashboard/api/utils'; import { DashboardSearchItem } from 'app/features/search/types'; import { DashboardDTO } from 'app/types'; @@ -57,34 +57,28 @@ export const DashboardPicker = ({ (async () => { // value was manually changed from outside or we are rendering for the first time. // We need to fetch dashboard information. - const isUIReadyForV2 = config.featureToggles.useV2DashboardsAPI; - if (isUIReadyForV2) { - // When using getDashboardAPI, if isUIReadyForV2 is true, we will pass `v2` prop - // That will return a dashboard response using schema v2. We only ask for `v2` when the component is ready to process the new shape - const resWithSchemaV2 = await getDashboardAPI('v2').getDashboardDTO(value, undefined); + const dto = await getDashboardAPI().getDashboardDTO(value, undefined); + if (isDashboardV2Resource(dto)) { setCurrent({ value: { - uid: resWithSchemaV2.metadata.name, - title: resWithSchemaV2.spec.title, - folderTitle: resWithSchemaV2.metadata.annotations?.[AnnoKeyFolderTitle], - folderUid: resWithSchemaV2.metadata.annotations?.[AnnoKeyFolder], + uid: dto.metadata.name, + title: dto.spec.title, + folderTitle: dto.metadata.annotations?.[AnnoKeyFolderTitle], + folderUid: dto.metadata.annotations?.[AnnoKeyFolder], }, - label: formatLabel(resWithSchemaV2.metadata.annotations?.[AnnoKeyFolder], resWithSchemaV2.spec.title), + label: formatLabel(dto.metadata.annotations?.[AnnoKeyFolder], dto.spec.title), }); } else { - // when using getDashboardAPI, if isUIReadyForV2 is false, we will always return the v1 schema version - const resWithSchemaV1 = await getDashboardAPI().getDashboardDTO(value, undefined); - - if (resWithSchemaV1.dashboard) { + if (dto.dashboard) { setCurrent({ value: { - uid: resWithSchemaV1.dashboard.uid, - title: resWithSchemaV1.dashboard.title, - folderTitle: resWithSchemaV1.meta.folderTitle, - folderUid: resWithSchemaV1.meta.folderUid, + uid: dto.dashboard.uid, + title: dto.dashboard.title, + folderTitle: dto.meta.folderTitle, + folderUid: dto.meta.folderUid, }, - label: formatLabel(resWithSchemaV1.meta?.folderTitle, resWithSchemaV1.dashboard.title), + label: formatLabel(dto.meta?.folderTitle, dto.dashboard.title), }); } } diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index f0f42225547..57c6dc9d862 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -602,7 +602,7 @@ export class BackendSrv implements BackendService { // NOTE: When this is removed, we can also remove most instances of: // jest.mock('app/features/live/dashboard/dashboardWatcher deprecationWarning('backend_srv', 'getDashboardByUid(uid)', 'getDashboardAPI().getDashboardDTO(uid)'); - return getDashboardAPI().getDashboardDTO(uid); + return getDashboardAPI('v1').getDashboardDTO(uid); } validateDashboard(dashboard: DashboardModel): Promise { diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index 51f08201927..df579fcdf62 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -8,11 +8,10 @@ import { createBaseQuery, handleRequestError } from 'app/api/createBaseQuery'; import appEvents from 'app/core/app_events'; import { contextSrv } from 'app/core/core'; import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; -import { isV1DashboardCommand, isV2DashboardCommand } from 'app/features/dashboard/api/utils'; +import { isDashboardV2Resource, isV1DashboardCommand, isV2DashboardCommand } from 'app/features/dashboard/api/utils'; import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { - DashboardDTO, DescendantCount, DescendantCountDTO, FolderDTO, @@ -240,26 +239,16 @@ export const browseDashboardsAPI = createApi({ // Move all the dashboards sequentially // TODO error handling here for (const dashboardUID of selectedDashboards) { - if (config.featureToggles.useV2DashboardsAPI) { - const fullDash = await getDashboardAPI('v2').getDashboardDTO(dashboardUID); - - await getDashboardAPI('v2').saveDashboard({ - dashboard: fullDash.spec, - folderUid: destinationUID, - overwrite: false, - message: '', - k8s: fullDash.metadata, - }); - } else { - const fullDash: DashboardDTO = await getDashboardAPI().getDashboardDTO(dashboardUID); - - await getDashboardAPI().saveDashboard({ - dashboard: fullDash.dashboard, - folderUid: destinationUID, - overwrite: false, - message: '', - }); - } + const fullDash = await getDashboardAPI().getDashboardDTO(dashboardUID); + const dashboard = isDashboardV2Resource(fullDash) ? fullDash.spec : fullDash.dashboard; + const k8s = isDashboardV2Resource(fullDash) ? fullDash.metadata : undefined; + await getDashboardAPI().saveDashboard({ + dashboard, + folderUid: destinationUID, + overwrite: false, + message: '', + k8s, + }); } return { data: undefined }; }, @@ -308,21 +297,20 @@ export const browseDashboardsAPI = createApi({ const name = response?.title; if (name) { - const payload = - config.featureToggles.useV2DashboardsAPI || config.featureToggles.kubernetesDashboards - ? ['Dashboard moved to Recently deleted'] - : [ - t('browse-dashboards.soft-delete.success', 'Dashboard {{name}} moved to Recently deleted', { - name, - }), - ]; + const payload = config.featureToggles.kubernetesDashboards + ? ['Dashboard moved to Recently deleted'] + : [ + t('browse-dashboards.soft-delete.success', 'Dashboard {{name}} moved to Recently deleted', { + name, + }), + ]; appEvents.publish({ type: AppEvents.alertSuccess.name, payload, }); } - } else if (config.featureToggles.useV2DashboardsAPI || config.featureToggles.kubernetesDashboards) { + } else if (config.featureToggles.kubernetesDashboards) { appEvents.publish({ type: AppEvents.alertSuccess.name, payload: ['Dashboard deleted'], @@ -344,8 +332,7 @@ export const browseDashboardsAPI = createApi({ saveDashboard: builder.mutation>({ queryFn: async (cmd) => { try { - // When we use the `useV2DashboardsAPI` flag, we can save 'v2' schema dashboards - if (config.featureToggles.useV2DashboardsAPI && isV2DashboardCommand(cmd)) { + if (isV2DashboardCommand(cmd)) { const response = await getDashboardAPI('v2').saveDashboard(cmd); return { data: response }; } diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx index 19a4c412b42..7aad80d73e1 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx @@ -4,7 +4,6 @@ import { useParams } from 'react-router-dom-v5-compat'; import { usePrevious } from 'react-use'; import { PageLayoutType } from '@grafana/data'; -import { config } from '@grafana/runtime'; import { UrlSyncContextProvider } from '@grafana/scenes'; import { Box } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; @@ -25,9 +24,7 @@ export function DashboardScenePage({ route, queryParams, location }: Props) { const params = useParams(); const { type, slug, uid } = params; const prevMatch = usePrevious({ params }); - const stateManager = config.featureToggles.useV2DashboardsAPI - ? getDashboardScenePageStateManager('v2') - : getDashboardScenePageStateManager(); + const stateManager = getDashboardScenePageStateManager(); const { dashboard, isLoading, loadError } = stateManager.useState(); // After scene migration is complete and we get rid of old dashboard we should refactor dashboardWatcher so this route reload is not need const routeReloadCounter = (location.state as any)?.routeReloadCounter; diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts index a70fa61ab05..e8292b4446c 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -4,15 +4,16 @@ import { BackendSrv, setBackendSrv } from '@grafana/runtime'; import { DashboardV2Spec, defaultDashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; import store from 'app/core/store'; import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; -import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; +import { DashboardVersionError, DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { getDashboardSnapshotSrv } from 'app/features/dashboard/services/SnapshotSrv'; -import { DASHBOARD_FROM_LS_KEY, DashboardRoutes } from 'app/types'; +import { DASHBOARD_FROM_LS_KEY, DashboardDataDTO, DashboardDTO, DashboardRoutes } from 'app/types'; import { DashboardScene } from '../scene/DashboardScene'; import { setupLoadDashboardMock, setupLoadDashboardMockReject } from '../utils/test-utils'; import { DashboardScenePageStateManager, + UnifiedDashboardScenePageStateManager, DASHBOARD_CACHE_TTL, DashboardScenePageStateManagerV2, } from './DashboardScenePageStateManager'; @@ -21,6 +22,22 @@ jest.mock('app/features/dashboard/api/dashboard_api', () => ({ getDashboardAPI: jest.fn(), })); +const setupDashboardAPI = ( + d: DashboardWithAccessInfo | undefined, + spy: jest.Mock, + effect?: () => void +) => { + (getDashboardAPI as jest.Mock).mockImplementation(() => ({ + getDashboardDTO: async () => { + spy(); + effect?.(); + return d; + }, + deleteDashboard: jest.fn(), + saveDashboard: jest.fn(), + })); +}; + describe('DashboardScenePageStateManager v1', () => { afterEach(() => { store.delete(DASHBOARD_FROM_LS_KEY); @@ -165,7 +182,7 @@ describe('DashboardScenePageStateManager v1', () => { expect(loader.state.dashboard).toBeUndefined(); expect(loader.state.loadError).toEqual({ - message: 'v2 dashboard spec is not supported. Enable useV2DashboardsAPI feature toggle', + message: 'You are trying to load a v2 dashboard spec as v1. Use DashboardScenePageStateManagerV2 instead.', messageId: undefined, status: undefined, }); @@ -267,24 +284,6 @@ describe('DashboardScenePageStateManager v2', () => { }); describe('when fetching/loading a dashboard', () => { - const setupDashboardAPI = ( - d: DashboardWithAccessInfo | undefined, - spy: jest.Mock, - effect?: () => void - ) => { - (getDashboardAPI as jest.Mock).mockImplementation(() => { - // Return whatever you want for this mock - return { - getDashboardDTO: async () => { - spy(); - effect?.(); - return d; - }, - deleteDashboard: jest.fn(), - saveDashboard: jest.fn(), - }; - }); - }; it('should call loader from server if the dashboard is not cached', async () => { const getDashSpy = jest.fn(); setupDashboardAPI( @@ -473,10 +472,9 @@ describe('DashboardScenePageStateManager v2', () => { it('should not transform v2 custom home dashboard spec', async () => { setBackendSrv({ - get: () => - Promise.resolve({ - dashboard: customHomeDashboardV2Spec, - meta: { + get: () => { + return Promise.resolve({ + access: { canSave: false, canEdit: true, canAdmin: false, @@ -500,7 +498,16 @@ describe('DashboardScenePageStateManager v2', () => { provisionedExternalId: '', annotationsPermissions: null, }, - }), + apiVersion: 'v2alpha1', + kind: 'DashboardWithAccessInfo', + metadata: { + name: 'home', + creationTimestamp: '', + resourceVersion: '1', + }, + spec: customHomeDashboardV2Spec, + }); + }, } as unknown as BackendSrv); const loader = new DashboardScenePageStateManagerV2({}); @@ -648,6 +655,142 @@ describe('DashboardScenePageStateManager v2', () => { }); }); +describe('UnifiedDashboardScenePageStateManager', () => { + afterEach(() => { + store.delete(DASHBOARD_FROM_LS_KEY); + }); + + describe('when fetching/loading a dashboard', () => { + it('should use v1 manager by default and handle v1 dashboards', async () => { + const loadDashboardMock = setupLoadDashboardMock({ dashboard: { uid: 'fake-dash', editable: true }, meta: {} }); + + const manager = new UnifiedDashboardScenePageStateManager({}); + await manager.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); + + expect(loadDashboardMock).toHaveBeenCalledWith('db', '', 'fake-dash', undefined); + expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManager); + }); + + it('should switch to v2 manager when loading v2 dashboard', async () => { + setupLoadDashboardMockReject(new DashboardVersionError('v2alpha1')); + + const getDashSpy = jest.fn(); + setupDashboardAPI( + { + access: {}, + apiVersion: 'v2alpha1', + kind: 'DashboardWithAccessInfo', + metadata: { + name: 'fake-dash', + creationTimestamp: '', + resourceVersion: '1', + }, + spec: { ...defaultDashboardV2Spec() }, + }, + getDashSpy + ); + + const manager = new UnifiedDashboardScenePageStateManager({}); + await manager.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); + + expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2); + expect(getDashSpy).toHaveBeenCalledTimes(1); + }); + + it('should maintain active manager state between operations', async () => { + const getDashSpy = jest.fn(); + setupLoadDashboardMockReject(new DashboardVersionError('v2alpha1')); + setupDashboardAPI( + { + access: {}, + apiVersion: 'v2alpha1', + kind: 'DashboardWithAccessInfo', + metadata: { + name: 'fake-dash', + creationTimestamp: '', + resourceVersion: '1', + }, + spec: { ...defaultDashboardV2Spec() }, + }, + getDashSpy + ); + + const manager = new UnifiedDashboardScenePageStateManager({}); + + // First load switches to v2 + await manager.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); + expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2); + + // Cache should use the active v2 manager + const cachedDash = manager.getDashboardFromCache('fake-dash'); + expect(cachedDash).toBeDefined(); + }); + + it.todo('should handle snapshot loading for both v1 and v2'); + + it('should handle dashboard reloading with current active manager', async () => { + const getDashSpy = jest.fn(); + setupDashboardAPI( + { + access: {}, + apiVersion: 'v2alpha1', + kind: 'DashboardWithAccessInfo', + metadata: { + name: 'fake-dash', + creationTimestamp: '', + resourceVersion: '1', + }, + spec: { ...defaultDashboardV2Spec() }, + }, + getDashSpy + ); + setupLoadDashboardMockReject(new DashboardVersionError('v2alpha1')); + + const manager = new UnifiedDashboardScenePageStateManager({}); + + // Initial load with v2 dashboard + await manager.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); + expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2); + + // Reload for v2 is not supported yet + await expect( + manager.reloadDashboard({ version: 1, scopes: [], timeRange: { from: 'now-1h', to: 'now' }, variables: {} }) + ).rejects.toThrow('Method not implemented.'); + }); + + it('should transform responses correctly based on dashboard version', async () => { + const manager = new UnifiedDashboardScenePageStateManager({}); + + // V1 dashboard response + const v1Response: DashboardDTO = { + dashboard: { uid: 'v1-dash', title: 'V1 Dashboard' } as DashboardDataDTO, + meta: {}, + }; + + const v1Scene = manager.transformResponseToScene(v1Response, { uid: 'v1-dash', route: DashboardRoutes.Normal }); + expect(v1Scene).toBeInstanceOf(DashboardScene); + expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManager); + + // V2 dashboard response + const v2Response: DashboardWithAccessInfo = { + access: {}, + apiVersion: 'v2alpha1', + kind: 'DashboardWithAccessInfo', + metadata: { + name: 'v2-dash', + creationTimestamp: '', + resourceVersion: '1', + }, + spec: { ...defaultDashboardV2Spec() }, + }; + + const v2Scene = manager.transformResponseToScene(v2Response, { uid: 'v2-dash', route: DashboardRoutes.Normal }); + expect(v2Scene).toBeInstanceOf(DashboardScene); + expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2); + }); + }); +}); + const customHomeDashboardV2Spec = { title: 'Home Dashboard v2 schema', cursorSync: 'Off', diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 63da7075646..d66ac86c9fd 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -8,9 +8,8 @@ import { StateManagerBase } from 'app/core/services/StateManagerBase'; import { getMessageFromError, getMessageIdFromError, getStatusFromError } from 'app/core/utils/errors'; import { startMeasure, stopMeasure } from 'app/core/utils/metrics'; import { AnnoKeyFolder } from 'app/features/apiserver/types'; -import { ResponseTransformers } from 'app/features/dashboard/api/ResponseTransformers'; -import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; -import { isDashboardV2Spec } from 'app/features/dashboard/api/utils'; +import { DashboardVersionError, DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; +import { isDashboardV2Resource, isDashboardV2Spec } from 'app/features/dashboard/api/utils'; import { dashboardLoaderSrv, DashboardLoaderSrvV2 } from 'app/features/dashboard/services/DashboardLoaderSrv'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { emitDashboardViewEvent } from 'app/features/dashboard/state/analyticsProcessor'; @@ -293,6 +292,7 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag break; case DashboardRoutes.Home: + // TODO: Move this fetching to APIClient.getHomeDashboard() to be able to redirect to the correct api depending on the format for the saved dashboard rsp = await getBackendSrv().get('/api/dashboards/home'); if (isRedirectResponse(rsp)) { @@ -302,7 +302,9 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag } if (isDashboardV2Spec(rsp.dashboard)) { - throw new Error('v2 dashboard spec is not supported. Enable useV2DashboardsAPI feature toggle'); + throw new Error( + 'You are trying to load a v2 dashboard spec as v1. Use DashboardScenePageStateManagerV2 instead.' + ); } if (rsp?.meta) { @@ -501,6 +503,7 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan rsp = await buildNewDashboardSaveModelV2(urlFolderUid); break; case DashboardRoutes.Home: + // TODO: Move this fetching to APIClient.getHomeDashboard() to be able to redirect to the correct api depending on the format for the saved dashboard const dto = await getBackendSrv().get('/api/dashboards/home'); if (isRedirectResponse(dto)) { @@ -509,16 +512,15 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan return null; } - rsp = ResponseTransformers.ensureV2Response(dto); - // if custom home dashboard is v2 spec already, ignore the spec transformation - if (isDashboardV2Spec(dto.dashboard)) { - rsp.spec = dto.dashboard; + if (!isDashboardV2Resource(dto)) { + throw new Error('Custom home dashboard is not a v2 spec'); } - rsp.access.canSave = false; - rsp.access.canShare = false; - rsp.access.canStar = false; + rsp = dto; + dto.access.canSave = false; + dto.access.canShare = false; + dto.access.canStar = false; break; case DashboardRoutes.Public: { @@ -569,32 +571,143 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan } } +export class UnifiedDashboardScenePageStateManager extends DashboardScenePageStateManagerBase< + DashboardDTO | DashboardWithAccessInfo +> { + private v1Manager: DashboardScenePageStateManager; + private v2Manager: DashboardScenePageStateManagerV2; + private activeManager: DashboardScenePageStateManager | DashboardScenePageStateManagerV2; + + constructor(initialState: Partial) { + super(initialState); + this.v1Manager = new DashboardScenePageStateManager(initialState); + this.v2Manager = new DashboardScenePageStateManagerV2(initialState); + + // Start with v2 if newDashboardLayout is enabled, otherwise v1 + this.activeManager = this.v1Manager; + } + + private async withVersionHandling( + operation: (manager: DashboardScenePageStateManager | DashboardScenePageStateManagerV2) => Promise + ): Promise { + try { + const result = await operation(this.activeManager); + // need to sync the state of the active manager with the unified manager + // in cases when components are subscribed to unified manager's state + this.setState(this.activeManager.state); + return result; + } catch (error) { + if (error instanceof DashboardVersionError) { + const manager = error.data.storedVersion === 'v2alpha1' ? this.v2Manager : this.v1Manager; + this.activeManager = manager; + return await operation(manager); + } else { + throw error; + } + } + } + + public async fetchDashboard(options: LoadDashboardOptions) { + return this.withVersionHandling | null>((manager) => + manager.fetchDashboard(options) + ); + } + + public async reloadDashboard(params: LoadDashboardOptions['params']) { + return this.withVersionHandling((manager) => manager.reloadDashboard(params)); + } + + public getDashboardFromCache(uid: string) { + return this.activeManager.getDashboardFromCache(uid); + } + + transformResponseToScene( + rsp: DashboardDTO | DashboardWithAccessInfo | null, + options: LoadDashboardOptions + ): DashboardScene | null { + if (!rsp) { + return null; + } + + if (isDashboardV2Resource(rsp)) { + this.activeManager = this.v2Manager; + return this.v2Manager.transformResponseToScene(rsp, options); + } + + return this.v1Manager.transformResponseToScene(rsp, options); + } + + public async loadSnapshotScene(slug: string): Promise { + try { + return await this.v1Manager.loadSnapshotScene(slug); + } catch (error) { + if (error instanceof DashboardVersionError && error.data.storedVersion === 'v2alpha1') { + return await this.v2Manager.loadSnapshotScene(slug); + } + throw new Error('Snapshot not found'); + } + } + + public async loadSnapshot(slug: string) { + return this.withVersionHandling((manager) => manager.loadSnapshot(slug)); + } + + public clearDashboardCache() { + this.v1Manager.clearDashboardCache(); + this.v2Manager.clearDashboardCache(); + } + + public clearSceneCache() { + this.v1Manager.clearSceneCache(); + this.v2Manager.clearSceneCache(); + this.cache = {}; + } + + public getCache() { + return this.activeManager.getCache(); + } + + public setDashboardCache(cacheKey: string, dashboard: DashboardDTO | DashboardWithAccessInfo) { + if (isDashboardV2Resource(dashboard)) { + this.v2Manager.setDashboardCache(cacheKey, dashboard); + } else { + this.v1Manager.setDashboardCache(cacheKey, dashboard); + } + } +} + const managers: { v1?: DashboardScenePageStateManager; v2?: DashboardScenePageStateManagerV2; + unified?: UnifiedDashboardScenePageStateManager; } = { v1: undefined, v2: undefined, + unified: undefined, }; -export function getDashboardScenePageStateManager( - v: 'v2' -): DashboardScenePageStateManagerLike>; -export function getDashboardScenePageStateManager(): DashboardScenePageStateManagerLike; +export function getDashboardScenePageStateManager(): UnifiedDashboardScenePageStateManager; +export function getDashboardScenePageStateManager(v: 'v1'): DashboardScenePageStateManager; +export function getDashboardScenePageStateManager(v: 'v2'): DashboardScenePageStateManagerV2; -export function getDashboardScenePageStateManager( - v?: 'v2' -): DashboardScenePageStateManagerLike> { - if (v === 'v2') { - if (!managers.v2) { - managers.v2 = new DashboardScenePageStateManagerV2({}); - } - - return managers.v2; - } else { +export function getDashboardScenePageStateManager(v?: 'v1' | 'v2') { + if (v === 'v1') { if (!managers.v1) { managers.v1 = new DashboardScenePageStateManager({}); } return managers.v1; } + + if (v === 'v2') { + if (!managers.v2) { + managers.v2 = new DashboardScenePageStateManagerV2({}); + } + return managers.v2; + } + + if (!managers.unified) { + managers.unified = new UnifiedDashboardScenePageStateManager({}); + } + + return managers.unified; } diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx index c54a5bbd45a..a3d824cab71 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx @@ -42,7 +42,7 @@ export class SaveDashboardDrawer extends SceneObjectBase diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx index 7f77aac7f1b..22ca6c3e98d 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx @@ -25,7 +25,7 @@ export interface Props { } export function SaveDashboardForm({ dashboard, drawer, changeInfo }: Props) { - const { hasChanges, changedSaveModel } = changeInfo; + const { hasChanges, hasMigratedToV2, changedSaveModel } = changeInfo; const { state, onSaveDashboard } = useSaveDashboard(false); const [options, setOptions] = useState({ @@ -128,6 +128,15 @@ export function SaveDashboardForm({ dashboard, drawer, changeInfo }: Props) { return ( + {hasMigratedToV2 && ( + +

+ Because you're using new dashboards features only supported on new Grafana dashboard schema format, the + dashboard will be saved in the new format. Please make sure you want to perform this action or you prefer to + save the dashboard as a new copy. +

+
+ )}