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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user