From 8f4fa9ed054615622a92a9aacd60a2be01024b0b Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Thu, 8 Jan 2026 08:12:02 -0700 Subject: [PATCH] ExportAsCode: Use layout creator when exporting v1 dashboard as v2 (#115754) * Alt to #115457 * fix tests * Remove exports * skip scene creation options for template route --------- Co-authored-by: Dominik Prokop --- .../pages/DashboardScenePageStateManager.ts | 45 ++++++++++-- .../transformSaveModelToScene.test.ts | 25 ++++--- .../transformSaveModelToScene.ts | 68 ++++++++++++++---- .../transformSaveModelV1ToV2.test.ts | 70 +++++++++++-------- .../transformSaveModelV2ToV1.test.ts | 36 ++++++---- .../sharing/ShareExportTab.tsx | 23 +++++- .../api/ResponseTransformersToBackend.test.ts | 35 ++++++---- 7 files changed, 216 insertions(+), 86 deletions(-) diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 097c0d8d26c..515087010fb 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -40,7 +40,11 @@ import { PanelEditor } from '../panel-edit/PanelEditor'; import { DashboardScene } from '../scene/DashboardScene'; import { buildNewDashboardSaveModel, buildNewDashboardSaveModelV2 } from '../serialization/buildNewDashboardSaveModel'; import { transformSaveModelSchemaV2ToScene } from '../serialization/transformSaveModelSchemaV2ToScene'; -import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; +import { + createV2RowsLayout, + SceneCreationOptions, + transformSaveModelToScene, +} from '../serialization/transformSaveModelToScene'; import { restoreDashboardStateFromLocalStorage } from '../utils/dashboardSessionState'; import { processQueryParamsForDashboardLoad, updateNavModel } from './utils'; @@ -106,6 +110,34 @@ interface DashboardScenePageStateManagerLike { useState: () => DashboardScenePageState; } +/** + * Creates scene creation options with appropriate layout creator + * based on feature flags and dashboard type. + */ +export function getSceneCreationOptions( + loadOptions?: LoadDashboardOptions, + meta?: { isSnapshot?: boolean } +): SceneCreationOptions | undefined { + const isReport = loadOptions?.route === DashboardRoutes.Report; + const isTemplate = loadOptions?.route === DashboardRoutes.Template; + const isSnapshot = meta?.isSnapshot ?? false; + + // Don't use v2 layout for reports or snapshots + if (isReport || isSnapshot || isTemplate) { + return undefined; + } + + // Use v2 layout creator when v2 API is enabled + if (shouldForceV2API()) { + return { + createLayout: createV2RowsLayout, + targetVersion: 'v2', + }; + } + + return undefined; +} + abstract class DashboardScenePageStateManagerBase extends StateManagerBase implements DashboardScenePageStateManagerLike @@ -155,7 +187,7 @@ abstract class DashboardScenePageStateManagerBase private async loadHomeDashboard(): Promise { const rsp = await this.fetchHomeDashboard(); if (rsp) { - return transformSaveModelToScene(rsp); + return transformSaveModelToScene(rsp, undefined, getSceneCreationOptions()); } return null; @@ -441,7 +473,8 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag } if (rsp?.dashboard) { - const scene = transformSaveModelToScene(rsp, options); + const sceneCreationOptions = getSceneCreationOptions(options, rsp.meta); + const scene = transformSaveModelToScene(rsp, options, sceneCreationOptions); // Special handling for Template route - set up edit mode and dirty state if ( @@ -474,7 +507,8 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag throw new DashboardVersionError('v2beta1', 'Using legacy snapshot API to get a V2 dashboard'); } - const scene = transformSaveModelToScene(rsp); + // Snapshots should use default v1 layout + const scene = transformSaveModelToScene(rsp, undefined, getSceneCreationOptions(undefined, { isSnapshot: true })); return scene; } @@ -755,7 +789,8 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag return; } - const scene = transformSaveModelToScene(rsp); + const sceneCreationOptions = getSceneCreationOptions(undefined, rsp.meta); + const scene = transformSaveModelToScene(rsp, undefined, sceneCreationOptions); // we need to call and restore dashboard state on every reload that pulls a new dashboard version if (config.featureToggles.preserveDashboardStateWhenNavigating && Boolean(uid)) { diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts index 0bcea1e0ecd..bfb5095f3cf 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts @@ -27,6 +27,7 @@ import { createPanelSaveModel } from 'app/features/dashboard/state/__fixtures__/ import { SHARED_DASHBOARD_QUERY, DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/constants'; import { DashboardDataDTO } from 'app/types/dashboard'; +import { getSceneCreationOptions } from '../pages/DashboardScenePageStateManager'; import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet'; import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; @@ -822,10 +823,14 @@ describe('transformSaveModelToScene', () => { }); it('Should convert legacy rows to new rows', () => { - const scene = transformSaveModelToScene({ - dashboard: repeatingRowsAndPanelsDashboardJson as DashboardDataDTO, - meta: {}, - }); + const scene = transformSaveModelToScene( + { + dashboard: repeatingRowsAndPanelsDashboardJson as DashboardDataDTO, + meta: {}, + }, + undefined, + getSceneCreationOptions() + ); const layout = scene.state.body as RowsLayoutManager; const row1 = layout.state.rows[0]; @@ -857,10 +862,14 @@ describe('transformSaveModelToScene', () => { }); it('Should convert legacy rows to new rows with free panels before first row', () => { - const scene = transformSaveModelToScene({ - dashboard: rowsAfterFreePanels as DashboardDataDTO, - meta: {}, - }); + const scene = transformSaveModelToScene( + { + dashboard: rowsAfterFreePanels as DashboardDataDTO, + meta: {}, + }, + undefined, + getSceneCreationOptions() + ); const layout = scene.state.body as RowsLayoutManager; const row1 = layout.state.rows[0]; diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index 3da62d6cdf4..8f80dcc526e 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -29,11 +29,11 @@ import { } from 'app/features/dashboard/services/DashboardProfiler'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; -import { DashboardDTO, DashboardDataDTO, DashboardRoutes } from 'app/types/dashboard'; +import { DashboardDTO, DashboardDataDTO } from 'app/types/dashboard'; import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior'; import { dashboardAnalyticsInitializer } from '../behaviors/DashboardAnalyticsInitializerBehavior'; -import { LoadDashboardOptions, shouldForceV2API } from '../pages/DashboardScenePageStateManager'; +import { LoadDashboardOptions } from '../pages/DashboardScenePageStateManager'; import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; import { DashboardControls } from '../scene/DashboardControls'; @@ -76,11 +76,53 @@ export interface SaveModelToSceneOptions { isEmbedded?: boolean; } -export function transformSaveModelToScene(rsp: DashboardDTO, options?: LoadDashboardOptions): DashboardScene { +type LayoutCreator = (panels: PanelModel[], preload?: boolean) => DashboardLayoutManager; + +export interface SceneCreationOptions { + /** + * When provided, this function is used to create the dashboard body/layout instead of the default v1 behavior. + * This allows callers to inject v2 layout strategy. + */ + createLayout?: LayoutCreator; + /** + * Determines how the dashboard scene is serialized. + * @default 'v1' + */ + targetVersion?: 'v1' | 'v2'; +} + +// Rows as SceneGridRow within the grid. +const createDefaultGridLayout: LayoutCreator = (panels, preload) => { + return new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + isLazy: getIsLazy(preload), + children: createSceneObjectsForPanels(panels), + }), + }); +}; + +/** + * V2 layout creator - uses RowsLayoutManager when dashboard has rows. + * This creates a layout that can be properly serialized to v2 format. + */ +export const createV2RowsLayout: LayoutCreator = (panels, preload) => { + const hasRows = panels.some((p) => p.type === 'row'); + if (hasRows) { + return createRowsFromPanels(panels); + } + // Fall back to default grid layout when no rows + return createDefaultGridLayout(panels, preload); +}; + +export function transformSaveModelToScene( + rsp: DashboardDTO, + options?: LoadDashboardOptions, + sceneOptions?: SceneCreationOptions +): DashboardScene { // Just to have migrations run const oldModel = new DashboardModel(rsp.dashboard, rsp.meta); - const scene = createDashboardSceneFromDashboardModel(oldModel, rsp.dashboard, options); + const scene = createDashboardSceneFromDashboardModel(oldModel, rsp.dashboard, options, sceneOptions); // TODO: refactor createDashboardSceneFromDashboardModel to work on Dashboard schema model const apiVersion = config.featureToggles.kubernetesDashboards @@ -92,7 +134,7 @@ export function transformSaveModelToScene(rsp: DashboardDTO, options?: LoadDashb return scene; } -export function createRowsFromPanels(oldPanels: PanelModel[]): RowsLayoutManager { +function createRowsFromPanels(oldPanels: PanelModel[]): RowsLayoutManager { const rowItems: RowItem[] = []; let currentLegacyRow: PanelModel | null = null; @@ -143,7 +185,7 @@ export function createRowsFromPanels(oldPanels: PanelModel[]): RowsLayoutManager }); } -export function createSceneObjectsForPanels(oldPanels: PanelModel[]): SceneGridItemLike[] { +function createSceneObjectsForPanels(oldPanels: PanelModel[]): SceneGridItemLike[] { // collects all panels and rows const panels: SceneGridItemLike[] = []; @@ -259,14 +301,14 @@ function createRowItemFromLegacyRow(row: PanelModel, panels: DashboardGridItem[] export function createDashboardSceneFromDashboardModel( oldModel: DashboardModel, dto: DashboardDataDTO, - options?: LoadDashboardOptions + options?: LoadDashboardOptions, + sceneOptions?: SceneCreationOptions ) { let variables: SceneVariableSet | undefined; let annotationLayers: SceneDataLayerProvider[] = []; let alertStatesLayer: AlertStatesDataLayer | undefined; const uid = oldModel.uid; - const isReport = options?.route === DashboardRoutes.Report; - const serializerVersion = shouldForceV2API() && !oldModel.meta.isSnapshot && !isReport ? 'v2' : 'v1'; + const targetVersion = sceneOptions?.targetVersion ?? 'v1'; if (oldModel.meta.isSnapshot) { variables = createVariablesForSnapshot(oldModel); @@ -354,9 +396,11 @@ export function createDashboardSceneFromDashboardModel( let body: DashboardLayoutManager; - if (serializerVersion === 'v2' && oldModel.panels.some((p) => p.type === 'row')) { - body = createRowsFromPanels(oldModel.panels); + if (sceneOptions?.createLayout) { + // Use injected layout creator (allows callers to specify v2 or custom layout strategy) + body = sceneOptions.createLayout(oldModel.panels, dto.preload); } else { + // Default v1 layout: DefaultGridLayoutManager body = new DefaultGridLayoutManager({ grid: new SceneGridLayout({ isLazy: getIsLazy(dto.preload), @@ -404,7 +448,7 @@ export function createDashboardSceneFromDashboardModel( hideTimeControls: oldModel.timepicker.hidden, }), }, - serializerVersion + targetVersion ); // Enable panel profiling for this dashboard using the composed SceneRenderProfiler diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts index 9dbf8e36f0a..a870f4feef5 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts @@ -1,6 +1,8 @@ import { readdirSync, readFileSync } from 'fs'; import path from 'path'; +import { getSceneCreationOptions } from '../pages/DashboardScenePageStateManager'; + import { normalizeBackendOutputForFrontendComparison } from './serialization-test-utils'; import { transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene'; import { transformSaveModelToScene } from './transformSaveModelToScene'; @@ -207,22 +209,26 @@ describe('V1 to V2 Dashboard Transformation Comparison', () => { delete dashboardSpec.snapshot; // Wrap in DashboardDTO structure that transformSaveModelToScene expects - const scene = transformSaveModelToScene({ - dashboard: dashboardSpec, - meta: { - isNew: false, - isFolder: false, - canSave: true, - canEdit: true, - canDelete: false, - canShare: false, - canStar: false, - canAdmin: false, - isSnapshot: false, - provisioned: false, - version: 1, + const scene = transformSaveModelToScene( + { + dashboard: dashboardSpec, + meta: { + isNew: false, + isFolder: false, + canSave: true, + canEdit: true, + canDelete: false, + canShare: false, + canStar: false, + canAdmin: false, + isSnapshot: false, + provisioned: false, + version: 1, + }, }, - }); + undefined, + getSceneCreationOptions() + ); const frontendOutput = transformSceneToSaveModelSchemaV2(scene, false); @@ -279,22 +285,26 @@ describe('V1 to V2 Dashboard Transformation Comparison', () => { delete dashboardSpec.snapshot; // Wrap in DashboardDTO structure that transformSaveModelToScene expects - const scene = transformSaveModelToScene({ - dashboard: dashboardSpec, - meta: { - isNew: false, - isFolder: false, - canSave: true, - canEdit: true, - canDelete: false, - canShare: false, - canStar: false, - canAdmin: false, - isSnapshot: false, - provisioned: false, - version: 1, + const scene = transformSaveModelToScene( + { + dashboard: dashboardSpec, + meta: { + isNew: false, + isFolder: false, + canSave: true, + canEdit: true, + canDelete: false, + canShare: false, + canStar: false, + canAdmin: false, + isSnapshot: false, + provisioned: false, + version: 1, + }, }, - }); + undefined, + getSceneCreationOptions() + ); const frontendOutput = transformSceneToSaveModelSchemaV2(scene, false); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelV2ToV1.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelV2ToV1.test.ts index 74208ff8a8f..af6116b9adf 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelV2ToV1.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelV2ToV1.test.ts @@ -6,6 +6,8 @@ import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboa import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { DashboardDataDTO } from 'app/types/dashboard'; +import { getSceneCreationOptions } from '../pages/DashboardScenePageStateManager'; + import { transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene'; import { transformSaveModelToScene } from './transformSaveModelToScene'; import { transformSceneToSaveModel } from './transformSceneToSaveModel'; @@ -228,22 +230,26 @@ function removeMetadata(spec: Dashboard): Partial { * identical processing. */ function loadAndSerializeV1SaveModel(dashboard: Dashboard): Dashboard { - const scene = transformSaveModelToScene({ - dashboard: dashboard as DashboardDataDTO, - meta: { - isNew: false, - isFolder: false, - canSave: true, - canEdit: true, - canDelete: false, - canShare: false, - canStar: false, - canAdmin: false, - isSnapshot: false, - provisioned: false, - version: 1, + const scene = transformSaveModelToScene( + { + dashboard: dashboard as DashboardDataDTO, + meta: { + isNew: false, + isFolder: false, + canSave: true, + canEdit: true, + canDelete: false, + canShare: false, + canStar: false, + canAdmin: false, + isSnapshot: false, + provisioned: false, + version: 1, + }, }, - }); + undefined, + getSceneCreationOptions() + ); return transformSceneToSaveModel(scene, false); } diff --git a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx index 0e9d6ad49c5..c255cd4af5b 100644 --- a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx @@ -23,6 +23,7 @@ import { DashboardDataDTO } from 'app/types/dashboard'; import { DashboardScene } from '../scene/DashboardScene'; import { makeExportableV1, makeExportableV2 } from '../scene/export/exporters'; +import { createV2RowsLayout, transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; import { transformSceneToSaveModelSchemaV2 } from '../serialization/transformSceneToSaveModelSchemaV2'; import { getVariablesCompatibility } from '../utils/getVariablesCompatibility'; @@ -216,7 +217,27 @@ export class ShareExportTab extends SceneObjectBase impleme } if (exportMode === ExportMode.V2Resource) { - const spec = transformSceneToSaveModelSchemaV2(scene); + let sceneForV2Export = scene; + + // When exporting v1 dashboard as v2, we need to recreate the scene with v2 layout creator + // to ensure rows are properly serialized. The v1 scene uses DefaultGridLayoutManager which + // doesn't know about RowsLayoutManager structure needed for v2 serialization. + if (initialSaveModelVersion === 'v1' && initialSaveModel && isV1ClassicDashboard(initialSaveModel)) { + // Recreate scene with v2 layout creator to properly handle rows + sceneForV2Export = transformSaveModelToScene( + { + dashboard: { ...initialSaveModel, title: initialSaveModel.title ?? '', uid: initialSaveModel.uid ?? '' }, + meta: scene.state.meta, + }, + undefined, + { + createLayout: createV2RowsLayout, + targetVersion: 'v2', + } + ); + } + + const spec = transformSceneToSaveModelSchemaV2(sceneForV2Export); const specCopy = JSON.parse(JSON.stringify(spec)); const statelessSpec = await makeExportableV2(specCopy, isSharingExternally); const exportableV2 = isSharingExternally ? statelessSpec : spec; diff --git a/public/app/features/dashboard/api/ResponseTransformersToBackend.test.ts b/public/app/features/dashboard/api/ResponseTransformersToBackend.test.ts index 2ca8d2a2411..63fb8016294 100644 --- a/public/app/features/dashboard/api/ResponseTransformersToBackend.test.ts +++ b/public/app/features/dashboard/api/ResponseTransformersToBackend.test.ts @@ -2,6 +2,7 @@ import { readdirSync, readFileSync } from 'fs'; import path from 'path'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import { getSceneCreationOptions } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager'; import { normalizeBackendOutputForFrontendComparison } from 'app/features/dashboard-scene/serialization/serialization-test-utils'; import { transformSaveModelSchemaV2ToScene } from 'app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene'; import { transformSaveModelToScene } from 'app/features/dashboard-scene/serialization/transformSaveModelToScene'; @@ -193,22 +194,26 @@ describe('V1 to V2 Dashboard Transformation Comparison (ResponseTransformers)', delete dashboardSpec.snapshot; // Wrap in DashboardDTO structure that transformSaveModelToScene expects - const scene = transformSaveModelToScene({ - dashboard: dashboardSpec, - meta: { - isNew: false, - isFolder: false, - canSave: true, - canEdit: true, - canDelete: false, - canShare: false, - canStar: false, - canAdmin: false, - isSnapshot: false, - provisioned: false, - version: 1, + const scene = transformSaveModelToScene( + { + dashboard: dashboardSpec, + meta: { + isNew: false, + isFolder: false, + canSave: true, + canEdit: true, + canDelete: false, + canShare: false, + canStar: false, + canAdmin: false, + isSnapshot: false, + provisioned: false, + version: 1, + }, }, - }); + undefined, + getSceneCreationOptions() + ); const frontendOutput = transformSceneToSaveModelSchemaV2(scene, false);