Dashboard: Schema V2 - Auto-transform V2 dashboards in V1Resource export mode (#105997)

* experiment v2 to v1 in exporting

* refactor code to export to v1 resource

* Add unit test and fix linting

* fix typescript

* fix linting

* handle error gracefully when is not possible to convert to v1
This commit is contained in:
Alexa Vargas
2025-06-11 11:28:13 +02:00
committed by GitHub
parent 21297b90fa
commit 6af09ed763
3 changed files with 424 additions and 15 deletions
@@ -74,6 +74,25 @@ export function ResourceExport({
/>
</Stack>
)}
{initialSaveModelVersion === 'v2' && (
<Stack alignItems="center">
<Label>{switchExportModeLabel}</Label>
<RadioButtonGroup
options={[
{
label: t('dashboard-scene.resource-export.label.v2-resource', 'V2 Resource'),
value: ExportMode.V2Resource,
},
{
label: t('dashboard-scene.resource-export.label.v1-resource', 'V1 Resource'),
value: ExportMode.V1Resource,
},
]}
value={exportMode}
onChange={(value) => onExportModeChange(value)}
/>
</Stack>
)}
{exportMode !== ExportMode.Classic && (
<Stack gap={1} alignItems="center">
<Label>{switchExportFormatLabel}</Label>
@@ -87,7 +106,9 @@ export function ResourceExport({
/>
</Stack>
)}
{(isV2Dashboard || exportMode === ExportMode.Classic) && (
{(isV2Dashboard ||
exportMode === ExportMode.Classic ||
(initialSaveModelVersion === 'v2' && exportMode === ExportMode.V1Resource)) && (
<Stack gap={1} alignItems="start">
<Label>{switchExportLabel}</Label>
<Switch label={switchExportLabel} value={isSharingExternally} onChange={onShareExternallyChange} />
@@ -0,0 +1,333 @@
import { config } from '@grafana/runtime';
import { SceneTimeRange } from '@grafana/scenes';
import { Dashboard } from '@grafana/schema/dist/esm/index.gen';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import * as ResponseTransformers from 'app/features/dashboard/api/ResponseTransformers';
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { DashboardDataDTO } from 'app/types/dashboard';
import { DashboardScene } from '../scene/DashboardScene';
import * as exporters from '../scene/export/exporters';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import * as sceneToV1 from '../serialization/transformSceneToSaveModel';
import * as sceneToV2 from '../serialization/transformSceneToSaveModelSchemaV2';
import { ExportMode } from './ExportButton/ResourceExport';
import { ShareExportTab } from './ShareExportTab';
describe('ShareExportTab', () => {
// Spies to track function calls
let transformV2ToV1Spy: jest.SpyInstance;
let makeExportableV1Spy: jest.SpyInstance;
let transformSceneToV1Spy: jest.SpyInstance;
let transformSceneToV2Spy: jest.SpyInstance;
beforeEach(() => {
config.featureToggles.kubernetesDashboards = true;
// Set up spies on the functions we want to track
transformV2ToV1Spy = jest.spyOn(ResponseTransformers, 'transformDashboardV2SpecToV1').mockReturnValue({
title: 'Transformed V1',
uid: 'transformed-uid',
version: 1,
panels: [],
time: { from: 'now-6h', to: 'now' },
timepicker: {},
timezone: '',
weekStart: '',
fiscalYearStartMonth: 0,
refresh: '',
schemaVersion: 30,
tags: [],
templating: { list: [] },
} as DashboardDataDTO);
makeExportableV1Spy = jest.spyOn(exporters, 'makeExportableV1').mockImplementation(async (dashboard) => dashboard);
transformSceneToV1Spy = jest.spyOn(sceneToV1, 'transformSceneToSaveModel').mockReturnValue({
title: 'Scene V1',
uid: 'scene-v1-uid',
version: 1,
panels: [],
time: { from: 'now-6h', to: 'now' },
timepicker: {},
timezone: '',
weekStart: '',
fiscalYearStartMonth: 0,
refresh: '',
schemaVersion: 30,
tags: [],
templating: { list: [] },
} as Dashboard);
transformSceneToV2Spy = jest.spyOn(sceneToV2, 'transformSceneToSaveModelSchemaV2').mockReturnValue({
title: 'Scene V2',
annotations: [],
cursorSync: 'Off',
description: '',
editable: true,
elements: {},
layout: { kind: 'GridLayout', spec: { items: [] } },
links: [],
liveNow: false,
preload: false,
tags: [],
timeSettings: {
from: 'now-6h',
to: 'now',
autoRefresh: '',
autoRefreshIntervals: [],
hideTimepicker: false,
timezone: '',
weekStart: 'saturday',
fiscalYearStartMonth: 0,
},
variables: [],
} as DashboardV2Spec);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('V1Resource export mode', () => {
// If V1 dashboard → V1 Resource should export with V1 apiVersion
it('should export V1 dashboard as V1 resource with correct apiVersion', async () => {
const tab = buildV1DashboardScenario();
tab.setState({ exportMode: ExportMode.V1Resource });
const result = await tab.getExportableDashboardJson();
// Should use V1 API version
expect(result.json).toMatchObject({
apiVersion: 'dashboard.grafana.app/v1beta1',
kind: 'Dashboard',
status: {},
});
// Should call transformSceneToV1 (not transform V2→V1)
expect(transformSceneToV1Spy).toHaveBeenCalled();
expect(transformV2ToV1Spy).not.toHaveBeenCalled();
// Should report correct initial version
expect(result.initialSaveModelVersion).toBe('v1');
});
// If V2 dashboard → V1 Resource should auto-transform with V1 apiVersion
it('should auto-transform V2 dashboard to V1 resource with correct apiVersion', async () => {
const tab = buildV2DashboardScenario();
// user selects V1Resource even though is V2 dashboard
tab.setState({ exportMode: ExportMode.V1Resource });
const result = await tab.getExportableDashboardJson();
// Should use V1 API version (not V2!)
expect(result.json).toMatchObject({
apiVersion: 'dashboard.grafana.app/v1beta1',
kind: 'Dashboard',
status: {},
});
// Should auto-transform V2→V1
expect(transformSceneToV2Spy).toHaveBeenCalled(); // Get V2 spec first
expect(transformV2ToV1Spy).toHaveBeenCalled(); // Then transform to V1
// Should report correct initial version
expect(result.initialSaveModelVersion).toBe('v2');
});
// If V2 dashboard → V1 Resource with external sharing should transform and apply external sharing
it('should handle external sharing when transforming V2 to V1', async () => {
const tab = buildV2DashboardScenario();
tab.setState({
exportMode: ExportMode.V1Resource,
isSharingExternally: true,
});
const result = await tab.getExportableDashboardJson();
// Should use V1 API version
expect(result.json).toMatchObject({
apiVersion: 'dashboard.grafana.app/v1beta1',
kind: 'Dashboard',
status: {},
});
// Should auto-transform V2→V1
expect(transformSceneToV2Spy).toHaveBeenCalled();
expect(transformV2ToV1Spy).toHaveBeenCalled();
// Should call makeExportableV1 for external sharing
expect(makeExportableV1Spy).toHaveBeenCalled();
// Should report correct initial version
expect(result.initialSaveModelVersion).toBe('v2');
});
});
describe('V2Resource export mode', () => {
// If V2 dashboard → V2 Resource should export with V2 apiVersion
it('should export V2 dashboard as V2 resource with correct apiVersion', async () => {
const tab = buildV2DashboardScenario();
tab.setState({ exportMode: ExportMode.V2Resource });
const result = await tab.getExportableDashboardJson();
// Should use V2 API version
expect(result.json).toMatchObject({
apiVersion: 'dashboard.grafana.app/v2alpha1',
kind: 'Dashboard',
status: {},
});
// Should not call V2→V1 transformation since source is already V2
expect(transformV2ToV1Spy).not.toHaveBeenCalled();
// Should report correct initial version
expect(result.initialSaveModelVersion).toBe('v2');
});
});
describe('Classic export mode', () => {
// If V1 dashboard → Classic should export plain dashboard JSON
it('should export V1 dashboard in classic format', async () => {
const tab = buildV1DashboardScenario();
tab.setState({ exportMode: ExportMode.Classic });
const result = await tab.getExportableDashboardJson();
// Should return plain dashboard JSON (not wrapped in resource)
expect(result.json).toMatchObject({
title: 'Test Dashboard V1',
uid: 'test-uid-v1',
panels: expect.any(Array),
});
// Should NOT have resource wrapper properties
expect(result.json).not.toHaveProperty('apiVersion');
expect(result.json).not.toHaveProperty('kind');
expect(result.json).not.toHaveProperty('status');
// Should report correct initial version
expect(result.initialSaveModelVersion).toBe('v1');
});
});
describe('Export mode state management', () => {
// If switching to Classic mode should disable YAML viewing
it('should disable YAML viewing when switching to Classic mode', async () => {
const tab = buildV1DashboardScenario();
// Start with YAML viewing enabled
tab.setState({ isViewingYAML: true });
expect(tab.state.isViewingYAML).toBe(true);
// Switch to Classic mode
tab.onExportModeChange(ExportMode.Classic);
// Should disable YAML viewing
expect(tab.state.isViewingYAML).toBe(false);
});
// If switching to resource modes should preserve YAML viewing
it('should preserve YAML viewing when switching to resource modes', async () => {
const tab = buildV2DashboardScenario();
// Start with YAML viewing enabled
tab.setState({ isViewingYAML: true });
expect(tab.state.isViewingYAML).toBe(true);
// Switch to V1Resource mode
tab.onExportModeChange(ExportMode.V1Resource);
expect(tab.state.isViewingYAML).toBe(true); // Should preserve
// Switch to V2Resource mode
tab.onExportModeChange(ExportMode.V2Resource);
expect(tab.state.isViewingYAML).toBe(true); // Should preserve
});
});
// Helper functions to create test scenarios
function buildV1DashboardScenario(): ShareExportTab {
const mockV1Dashboard: DashboardDataDTO = {
title: 'Test Dashboard V1',
uid: 'test-uid-v1',
version: 1,
panels: [],
time: { from: 'now-6h', to: 'now' },
timepicker: {},
timezone: '',
weekStart: '',
fiscalYearStartMonth: 0,
refresh: '',
schemaVersion: 30,
tags: [],
templating: { list: [] },
};
const tab = new ShareExportTab({});
const scene = new DashboardScene({
title: 'Test Dashboard V1',
uid: 'test-uid-v1',
meta: { canEdit: true },
$timeRange: new SceneTimeRange({}),
body: DefaultGridLayoutManager.fromVizPanels([]),
overlay: tab,
});
const mockExportableDashboard: DashboardJson = {
...mockV1Dashboard,
panels: [],
} as DashboardJson;
scene.serializer.getSaveModel = jest.fn(() => mockV1Dashboard);
scene.serializer.makeExportableExternally = jest.fn(() => Promise.resolve(mockExportableDashboard));
scene.serializer.apiVersion = 'dashboard.grafana.app/v1beta1';
scene.getInitialSaveModel = jest.fn(() => mockV1Dashboard);
return tab;
}
function buildV2DashboardScenario(): ShareExportTab {
const mockV2Dashboard: DashboardV2Spec = {
title: 'Test Dashboard V2',
annotations: [],
cursorSync: 'Off',
description: 'Test V2 dashboard',
editable: true,
elements: {},
layout: { kind: 'GridLayout', spec: { items: [] } },
links: [],
liveNow: false,
preload: false,
tags: [],
timeSettings: {
from: 'now-6h',
to: 'now',
autoRefresh: '',
autoRefreshIntervals: [],
hideTimepicker: false,
timezone: '',
weekStart: 'saturday',
fiscalYearStartMonth: 0,
},
variables: [],
};
const tab = new ShareExportTab({});
const scene = new DashboardScene({
title: 'Test Dashboard V2',
uid: 'test-uid-v2',
meta: { canEdit: true },
$timeRange: new SceneTimeRange({}),
body: DefaultGridLayoutManager.fromVizPanels([]),
overlay: tab,
});
scene.serializer.getSaveModel = jest.fn(() => mockV2Dashboard);
scene.serializer.makeExportableExternally = jest.fn(() => Promise.resolve(mockV2Dashboard));
scene.serializer.apiVersion = 'dashboard.grafana.app/v2alpha1';
scene.getInitialSaveModel = jest.fn(() => mockV2Dashboard);
return tab;
}
});
@@ -12,12 +12,15 @@ import { Dashboard } from '@grafana/schema/dist/esm/index.gen';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { Button, ClipboardButton, CodeEditor, Field, Modal, Stack, Switch } from '@grafana/ui';
import { ObjectMeta } from 'app/features/apiserver/types';
import { transformDashboardV2SpecToV1 } from 'app/features/dashboard/api/ResponseTransformers';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { isDashboardV2Spec } from 'app/features/dashboard/api/utils';
import { K8S_V1_DASHBOARD_API_CONFIG } from 'app/features/dashboard/api/v1';
import { K8S_V2_DASHBOARD_API_CONFIG } from 'app/features/dashboard/api/v2';
import { shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { DashboardDataDTO } from 'app/types/dashboard';
import { DashboardScene } from '../scene/DashboardScene';
import { makeExportableV1, makeExportableV2 } from '../scene/export/exporters';
@@ -34,7 +37,7 @@ export interface ExportableResource {
apiVersion: string;
kind: 'Dashboard';
metadata: DashboardWithAccessInfo<DashboardV2Spec>['metadata'] | Partial<ObjectMeta>;
spec: Dashboard | DashboardModel | DashboardV2Spec | { error: unknown };
spec: Dashboard | DashboardModel | DashboardV2Spec | DashboardJson | DashboardDataDTO | { error: unknown };
// A placeholder for now because as code tooling expects it
status: {};
}
@@ -112,7 +115,12 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
const exportable = isSharingExternally ? exportableDashboard : origDashboard;
const metadata = getMetadata(scene, Boolean(isSharingExternally));
if (isDashboardV2Spec(origDashboard) && 'elements' in exportable && initialSaveModelVersion === 'v2') {
if (
isDashboardV2Spec(origDashboard) &&
'elements' in exportable &&
initialSaveModelVersion === 'v2' &&
exportMode !== ExportMode.V1Resource
) {
this.setState({
exportMode: ExportMode.V2Resource,
});
@@ -131,19 +139,66 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
}
if (exportMode === ExportMode.V1Resource) {
const spec = transformSceneToSaveModel(scene);
// Check if source is V2 and auto-transform to V1
if (isDashboardV2Spec(origDashboard) && initialSaveModelVersion === 'v2') {
try {
const spec = transformSceneToSaveModelSchemaV2(scene);
const metadata = getMetadata(scene, Boolean(isSharingExternally));
const spec1 = transformDashboardV2SpecToV1(spec, {
name: metadata.name ?? '',
generation: metadata.generation ?? 0,
resourceVersion: metadata.resourceVersion ?? '0',
creationTimestamp: metadata.creationTimestamp ?? '',
});
return {
json: {
apiVersion: scene.serializer.apiVersion ?? '',
kind: 'Dashboard',
metadata,
spec,
status: {},
},
initialSaveModelVersion,
hasLibraryPanels: undefined,
};
let exportableV1: Dashboard | DashboardDataDTO | DashboardJson | { error: unknown };
if (isSharingExternally) {
const oldModel = new DashboardModel(spec1, undefined, {
getVariablesFromState: () => {
return getVariablesCompatibility(window.__grafanaSceneContext);
},
});
exportableV1 = await makeExportableV1(oldModel);
} else {
exportableV1 = spec1;
}
return {
json: {
// Forcing V1 version here to match export mode selection
apiVersion: `${K8S_V1_DASHBOARD_API_CONFIG.group}/${K8S_V1_DASHBOARD_API_CONFIG.version}`,
kind: 'Dashboard',
metadata,
spec: exportableV1,
status: {},
},
initialSaveModelVersion,
hasLibraryPanels: undefined,
};
} catch (err) {
return {
json: {
error: `Failed to convert dashboard to v1. ${err}`,
},
initialSaveModelVersion,
hasLibraryPanels: undefined,
};
}
} else {
// Source is already V1, export as-is
const spec = transformSceneToSaveModel(scene);
return {
json: {
// Forcing V1 version here to match export mode selection
apiVersion: `${K8S_V1_DASHBOARD_API_CONFIG.group}/${K8S_V1_DASHBOARD_API_CONFIG.version}`,
kind: 'Dashboard',
metadata,
spec,
status: {},
},
initialSaveModelVersion,
hasLibraryPanels: undefined,
};
}
}
if (exportMode === ExportMode.V2Resource) {