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 <dominik.prokop@grafana.com>
This commit is contained in:
Haris Rozajac
2026-01-08 08:12:02 -07:00
committed by GitHub
co-authored by Dominik Prokop
parent 0aae7e01bc
commit 8f4fa9ed05
7 changed files with 216 additions and 86 deletions
@@ -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<T> {
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<T>
extends StateManagerBase<DashboardScenePageState>
implements DashboardScenePageStateManagerLike<T>
@@ -155,7 +187,7 @@ abstract class DashboardScenePageStateManagerBase<T>
private async loadHomeDashboard(): Promise<DashboardScene | null> {
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)) {
@@ -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];
@@ -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
@@ -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);
@@ -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<Dashboard> {
* 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);
}
@@ -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<ShareExportTabState> 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;
@@ -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);