From 6af09ed763a9b28b741ab35069697c6da00b03b2 Mon Sep 17 00:00:00 2001
From: Alexa Vargas <239999+axelavargas@users.noreply.github.com>
Date: Wed, 11 Jun 2025 11:28:13 +0200
Subject: [PATCH] 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
---
.../sharing/ExportButton/ResourceExport.tsx | 23 +-
.../sharing/ShareExportTab.test.tsx | 333 ++++++++++++++++++
.../sharing/ShareExportTab.tsx | 83 ++++-
3 files changed, 424 insertions(+), 15 deletions(-)
create mode 100644 public/app/features/dashboard-scene/sharing/ShareExportTab.test.tsx
diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx
index a44652a0fe3..fae5d4a5991 100644
--- a/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx
+++ b/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx
@@ -74,6 +74,25 @@ export function ResourceExport({
/>
)}
+ {initialSaveModelVersion === 'v2' && (
+
+
+ onExportModeChange(value)}
+ />
+
+ )}
{exportMode !== ExportMode.Classic && (
@@ -87,7 +106,9 @@ export function ResourceExport({
/>
)}
- {(isV2Dashboard || exportMode === ExportMode.Classic) && (
+ {(isV2Dashboard ||
+ exportMode === ExportMode.Classic ||
+ (initialSaveModelVersion === 'v2' && exportMode === ExportMode.V1Resource)) && (
diff --git a/public/app/features/dashboard-scene/sharing/ShareExportTab.test.tsx b/public/app/features/dashboard-scene/sharing/ShareExportTab.test.tsx
new file mode 100644
index 00000000000..fb416a280fb
--- /dev/null
+++ b/public/app/features/dashboard-scene/sharing/ShareExportTab.test.tsx
@@ -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;
+ }
+});
diff --git a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx
index c6878c3fcd4..4cb3d0951ea 100644
--- a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx
+++ b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx
@@ -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['metadata'] | Partial;
- 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 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 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) {