Dashboard: SchemaV2 - Inline Library Panel during Export (#107201)
* Basic implementation of inline library panel * modify the warning message, we support the export partially by converting regular panels the library panels * Improve message around the conversion, keep it concise * Fix linting * Refactor conversion, extract existing logic into new funcition and reuse it * remove unnecesary comment * show error notification when library panel does not load * Apply PR feedback, remove unnecesary try catch and return json with error, don't throw * Add unit tests * Fix monitoring report, add case when v2 is loaded originally or when transformed from v1 * Add unit tests for detect libraryPanels, relevant for ensure monitoring is correct
This commit is contained in:
@@ -5,6 +5,8 @@ import { Dashboard, DashboardCursorSync, ThresholdsMode } from '@grafana/schema'
|
||||
import { handyTestingSchema } from '@grafana/schema/dist/esm/schema/dashboard/v2_examples';
|
||||
import {
|
||||
DatasourceVariableKind,
|
||||
LibraryPanelKind,
|
||||
PanelKind,
|
||||
QueryVariableKind,
|
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
|
||||
import config from 'app/core/config';
|
||||
@@ -50,8 +52,36 @@ jest.mock('@grafana/runtime', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('app/features/library-panels/state/api', () => ({
|
||||
getLibraryPanel: jest.fn().mockReturnValue(
|
||||
Promise.resolve({
|
||||
getLibraryPanel: jest.fn().mockImplementation((uid: string) => {
|
||||
if (uid === 'test-library-panel-uid') {
|
||||
return Promise.resolve({
|
||||
name: 'Test Library Panel',
|
||||
uid: 'test-library-panel-uid',
|
||||
model: {
|
||||
type: 'timeseries',
|
||||
datasource: {
|
||||
type: 'testdb',
|
||||
uid: 'gfdb',
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
refId: 'A',
|
||||
datasource: {
|
||||
type: 'testdb',
|
||||
uid: 'gfdb',
|
||||
},
|
||||
},
|
||||
],
|
||||
id: 123,
|
||||
title: 'Test Library Panel',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (uid === 'invalid-uid') {
|
||||
return Promise.reject(new Error('Library panel not found'));
|
||||
}
|
||||
// Default behavior for other UIDs
|
||||
return Promise.resolve({
|
||||
name: 'Testing lib panel 1',
|
||||
uid: 'abc-123',
|
||||
model: {
|
||||
@@ -60,9 +90,18 @@ jest.mock('app/features/library-panels/state/api', () => ({
|
||||
type: 'testdb',
|
||||
uid: '${DS_GFDB}',
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
refId: 'A',
|
||||
datasource: {
|
||||
type: 'testdb',
|
||||
uid: '${DS_GFDB}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
),
|
||||
});
|
||||
}),
|
||||
}));
|
||||
|
||||
variableAdapters.register(createQueryVariableAdapter());
|
||||
@@ -514,6 +553,12 @@ describe('dashboard exporter v1', () => {
|
||||
expect(element.kind).toBe(LibraryElementKind.Panel);
|
||||
expect(element.model).toEqual({
|
||||
datasource: { type: 'testdb', uid: '${DS_GFDB}' },
|
||||
targets: [
|
||||
{
|
||||
datasource: { type: 'testdb', uid: '${DS_GFDB}' },
|
||||
refId: 'A',
|
||||
},
|
||||
],
|
||||
type: 'graph',
|
||||
});
|
||||
});
|
||||
@@ -528,6 +573,12 @@ describe('dashboard exporter v1', () => {
|
||||
type: 'testdb',
|
||||
uid: '${DS_GFDB}',
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
datasource: { type: 'testdb', uid: '${DS_GFDB}' },
|
||||
refId: 'A',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -600,15 +651,6 @@ describe('dashboard exporter v2', () => {
|
||||
expect(annotationQuery.spec.datasource?.uid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should remove library panels from layout', async () => {
|
||||
const { dashboard, originalSchema } = await setup();
|
||||
const elementRef = 'panel-2';
|
||||
const libraryPanel = dashboard.elements[elementRef];
|
||||
const origLibraryPanel = originalSchema.elements[elementRef];
|
||||
expect(origLibraryPanel.kind).toBe('LibraryPanel');
|
||||
expect(libraryPanel).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not remove datasource ref from panel that uses a datasource variable', async () => {
|
||||
const { dashboard } = await setup();
|
||||
const panel = dashboard.elements['panel-using-datasource-var'];
|
||||
@@ -622,6 +664,121 @@ describe('dashboard exporter v2', () => {
|
||||
uid: '${datasourceVar}',
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert library panels to inline panels when sharing externally', async () => {
|
||||
const setupWithLibraryPanel = async (isSharingExternally: boolean) => {
|
||||
const schemaCopy = JSON.parse(JSON.stringify(handyTestingSchema));
|
||||
|
||||
// Add a library panel to test conversion
|
||||
schemaCopy.elements['test-library-panel'] = {
|
||||
kind: 'LibraryPanel',
|
||||
spec: {
|
||||
id: 123,
|
||||
title: 'Test Library Panel',
|
||||
libraryPanel: {
|
||||
uid: 'test-library-panel-uid',
|
||||
name: 'Test Library Panel',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Handle makeExportableV2 union return type: DashboardV2Spec | { error: unknown }
|
||||
const dashboard = await makeExportableV2(schemaCopy, isSharingExternally);
|
||||
if (typeof dashboard === 'object' && 'error' in dashboard) {
|
||||
throw dashboard.error;
|
||||
}
|
||||
return { dashboard, originalSchema: schemaCopy };
|
||||
};
|
||||
|
||||
const { dashboard } = await setupWithLibraryPanel(true); // isSharingExternally = true
|
||||
|
||||
// Library panel should be converted to inline panel
|
||||
const convertedPanel = dashboard.elements['test-library-panel'] as PanelKind;
|
||||
expect(convertedPanel.kind).toBe('Panel');
|
||||
expect(convertedPanel.spec.id).toBe(123);
|
||||
|
||||
// Check that the panel was properly converted
|
||||
expect(convertedPanel.spec.data.spec.queries[0].spec.query.kind).toBe('testdb');
|
||||
expect(convertedPanel.spec.data.spec.queries[0].spec.refId).toBe('A');
|
||||
});
|
||||
|
||||
it('should keep library panels as-is when not sharing externally', async () => {
|
||||
const setupWithLibraryPanel = async (isSharingExternally: boolean) => {
|
||||
const schemaCopy = JSON.parse(JSON.stringify(handyTestingSchema));
|
||||
|
||||
// Add a library panel
|
||||
schemaCopy.elements['test-library-panel'] = {
|
||||
kind: 'LibraryPanel',
|
||||
spec: {
|
||||
id: 124,
|
||||
title: 'Test Library Panel',
|
||||
libraryPanel: {
|
||||
uid: 'abc-123',
|
||||
name: 'Testing lib panel 1',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Handle makeExportableV2 union return type: DashboardV2Spec | { error: unknown }
|
||||
const dashboard = await makeExportableV2(schemaCopy, isSharingExternally);
|
||||
if (typeof dashboard === 'object' && 'error' in dashboard) {
|
||||
throw dashboard.error;
|
||||
}
|
||||
return { dashboard, originalSchema: schemaCopy };
|
||||
};
|
||||
|
||||
const { dashboard } = await setupWithLibraryPanel(false); // isSharingExternally = false
|
||||
|
||||
// Library panel should remain as library panel
|
||||
const libraryPanel = dashboard.elements['test-library-panel'];
|
||||
expect(libraryPanel.kind).toBe('LibraryPanel');
|
||||
expect((libraryPanel as LibraryPanelKind).spec.libraryPanel.uid).toBe('abc-123');
|
||||
});
|
||||
|
||||
it('should handle library panel conversion errors gracefully', async () => {
|
||||
// Mock console.error to avoid Jest warnings
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const setupWithInvalidLibraryPanel = async () => {
|
||||
const schemaCopy = JSON.parse(JSON.stringify(handyTestingSchema));
|
||||
|
||||
// Add a library panel with invalid uid that will cause getLibraryPanel to fail
|
||||
schemaCopy.elements['invalid-library-panel'] = {
|
||||
kind: 'LibraryPanel',
|
||||
spec: {
|
||||
id: 125,
|
||||
title: 'Invalid Library Panel',
|
||||
libraryPanel: {
|
||||
uid: 'invalid-uid',
|
||||
name: 'Invalid Library Panel',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Handle makeExportableV2 union return type: DashboardV2Spec | { error: unknown }
|
||||
const dashboard = await makeExportableV2(schemaCopy, true); // isSharingExternally = true
|
||||
if (typeof dashboard === 'object' && 'error' in dashboard) {
|
||||
throw dashboard.error;
|
||||
}
|
||||
|
||||
return { dashboard, originalSchema: schemaCopy };
|
||||
};
|
||||
|
||||
const { dashboard } = await setupWithInvalidLibraryPanel();
|
||||
|
||||
// Should return a placeholder panel
|
||||
const placeholderPanel = dashboard.elements['invalid-library-panel'];
|
||||
expect(placeholderPanel.kind).toBe('Panel');
|
||||
expect((placeholderPanel as PanelKind).spec.id).toBe(125);
|
||||
expect((placeholderPanel as PanelKind).spec.title).toBe('Invalid Library Panel');
|
||||
expect((placeholderPanel as PanelKind).spec.vizConfig.kind).toBe('text');
|
||||
|
||||
// Verify console.error was called
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to load library panel invalid-uid:', expect.any(Error));
|
||||
|
||||
// Restore console.error
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
function getStubInstanceSettings(v: string | DataSourceRef): DataSourceInstanceSettings {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defaults, each, sortBy } from 'lodash';
|
||||
|
||||
import { DataSourceRef, PanelPluginMeta, VariableOption, VariableRefresh } from '@grafana/data';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { Panel } from '@grafana/schema';
|
||||
import {
|
||||
Spec as DashboardV2Spec,
|
||||
PanelKind,
|
||||
@@ -9,20 +10,23 @@ import {
|
||||
AnnotationQueryKind,
|
||||
QueryVariableKind,
|
||||
LibraryPanelRef,
|
||||
LibraryPanelKind,
|
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import config from 'app/core/config';
|
||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||
import { buildPanelKind } from 'app/features/dashboard/api/ResponseTransformers';
|
||||
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
||||
import { PanelModel, GridPos } from 'app/features/dashboard/state/PanelModel';
|
||||
import { getLibraryPanel } from 'app/features/library-panels/state/api';
|
||||
import { variableRegex } from 'app/features/variables/utils';
|
||||
import { dispatch } from 'app/store/store';
|
||||
|
||||
import { isPanelModelLibraryPanel } from '../../../library-panels/guard';
|
||||
import { LibraryElementKind } from '../../../library-panels/types';
|
||||
import { DashboardJson } from '../../../manage-dashboards/types';
|
||||
import { isConstant } from '../../../variables/guard';
|
||||
|
||||
import { removePanelRefFromLayout } from './utils';
|
||||
|
||||
export interface InputUsage {
|
||||
libraryPanels?: LibraryPanelRef[];
|
||||
}
|
||||
@@ -331,7 +335,65 @@ export async function makeExportableV1(dashboard: DashboardModel) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function makeExportableV2(dashboard: DashboardV2Spec) {
|
||||
/**
|
||||
* Converts a LibraryPanelKind to a PanelKind with embedded panel configuration
|
||||
*/
|
||||
async function convertLibraryPanelToInlinePanel(libraryPanelElement: LibraryPanelKind): Promise<PanelKind> {
|
||||
const { libraryPanel, id, title } = libraryPanelElement.spec;
|
||||
|
||||
try {
|
||||
// Load the full library panel definition
|
||||
const fullLibraryPanel = await getLibraryPanel(libraryPanel.uid, true);
|
||||
const panelModel: Panel = fullLibraryPanel.model;
|
||||
const inlinePanel = buildPanelKind(panelModel);
|
||||
// keep the original id
|
||||
inlinePanel.spec.id = id;
|
||||
return inlinePanel;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load library panel ${libraryPanel.uid}:`, error);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
dispatch(
|
||||
notifyApp(
|
||||
createErrorNotification(
|
||||
`Unable to load library panel "${libraryPanel.name}": ${errorMessage}. It will appear as a placeholder in the export.`
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Return a placeholder panel if library panel can't be loaded
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
id,
|
||||
title: title || `Library Panel: ${libraryPanel.name}`,
|
||||
description: '',
|
||||
links: [],
|
||||
data: {
|
||||
kind: 'QueryGroup',
|
||||
spec: {
|
||||
queries: [],
|
||||
transformations: [],
|
||||
queryOptions: {},
|
||||
},
|
||||
},
|
||||
vizConfig: {
|
||||
kind: 'text',
|
||||
spec: {
|
||||
pluginVersion: '',
|
||||
options: {
|
||||
content: `**Library Panel Load Error**\n\nUnable to load library panel: ${libraryPanel.name} (${libraryPanel.uid})\n\nError: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
mode: 'markdown',
|
||||
},
|
||||
fieldConfig: { defaults: {}, overrides: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function makeExportableV2(dashboard: DashboardV2Spec, isSharingExternally = false) {
|
||||
const variableLookup: { [key: string]: any } = {};
|
||||
|
||||
// get all datasource variables
|
||||
@@ -367,17 +429,21 @@ export async function makeExportableV2(dashboard: DashboardV2Spec) {
|
||||
|
||||
try {
|
||||
const elements = dashboard.elements;
|
||||
const layout = dashboard.layout;
|
||||
|
||||
// process elements
|
||||
for (const [key, element] of Object.entries(elements)) {
|
||||
if (element.kind === 'Panel') {
|
||||
processPanel(element);
|
||||
} else if (element.kind === 'LibraryPanel') {
|
||||
// just remove the library panel
|
||||
delete elements[key];
|
||||
// remove reference from layout
|
||||
removePanelRefFromLayout(layout, key);
|
||||
if (isSharingExternally) {
|
||||
// Convert library panel to inline panel for external sharing
|
||||
const inlinePanel = await convertLibraryPanelToInlinePanel(element);
|
||||
// Apply datasource templating to the converted panel
|
||||
processPanel(inlinePanel);
|
||||
// Replace the library panel with the inline panel
|
||||
elements[key] = inlinePanel;
|
||||
}
|
||||
// For internal exports, keep library panels as-is
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
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 { Alert, Label, RadioButtonGroup, Stack, Switch, TextLink } from '@grafana/ui';
|
||||
import { Alert, Label, RadioButtonGroup, Stack, Switch } from '@grafana/ui';
|
||||
import { DashboardJson } from 'app/features/manage-dashboards/types';
|
||||
|
||||
import { ExportableResource } from '../ShareExportTab';
|
||||
@@ -127,19 +127,13 @@ export function ResourceExport({
|
||||
<Alert
|
||||
title={t(
|
||||
'dashboard-scene.save-dashboard-form.schema-v2-library-panels-export-title',
|
||||
'Dashboard Schema V2 does not support exporting library panels to be used in another instance yet'
|
||||
'Library panels will be converted to regular panels'
|
||||
)}
|
||||
severity="warning"
|
||||
>
|
||||
<Trans i18nKey="dashboard-scene.save-dashboard-form.schema-v2-library-panels-export">
|
||||
The dynamic dashboard functionality is experimental, and has not full feature parity with current dashboards
|
||||
behaviour. It is based on a new schema format, that does not support library panels. This means that when
|
||||
exporting the dashboard to use it in another instance, we will not include library panels. We intend to
|
||||
support them as we progress in the feature{' '}
|
||||
<TextLink external href="https://grafana.com/docs/release-life-cycle/">
|
||||
life cycle
|
||||
</TextLink>
|
||||
.
|
||||
Due to limitations in the new dashboard schema (V2), library panels will be converted to regular panels with
|
||||
embedded content during external export.
|
||||
</Trans>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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 {
|
||||
Spec as DashboardV2Spec,
|
||||
defaultQueryGroupKind,
|
||||
defaultVizConfigSpec,
|
||||
} 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';
|
||||
@@ -24,6 +28,7 @@ describe('ShareExportTab', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
config.featureToggles.kubernetesDashboards = true;
|
||||
config.featureToggles.dashboardNewLayouts = false;
|
||||
|
||||
// Set up spies on the functions we want to track
|
||||
transformV2ToV1Spy = jest.spyOn(ResponseTransformers, 'transformDashboardV2SpecToV1').mockReturnValue({
|
||||
@@ -186,6 +191,88 @@ describe('ShareExportTab', () => {
|
||||
// Should report correct initial version
|
||||
expect(result.initialSaveModelVersion).toBe('v2');
|
||||
});
|
||||
|
||||
// If V1 dashboard → V2 Resource should detect library panels correctly
|
||||
it('should detect library panels in V1 dashboard when exporting as V2 resource', async () => {
|
||||
const tab = buildV1DashboardWithLibraryPanels();
|
||||
tab.setState({ exportMode: ExportMode.V2Resource });
|
||||
|
||||
const result = await tab.getExportableDashboardJson();
|
||||
|
||||
// Should detect library panels from V1 dashboard
|
||||
expect(result.hasLibraryPanels).toBe(true);
|
||||
expect(result.initialSaveModelVersion).toBe('v1');
|
||||
});
|
||||
|
||||
// If V1 dashboard with dashboardNewLayouts disabled → V2 Resource should detect library panels correctly
|
||||
it('should detect library panels in V1 dashboard when user selects V2Resource export mode', async () => {
|
||||
const tab = buildV1DashboardWithLibraryPanels();
|
||||
tab.setState({ exportMode: ExportMode.V2Resource });
|
||||
|
||||
const result = await tab.getExportableDashboardJson();
|
||||
|
||||
// Should detect library panels from V1 dashboard (first branch of the logic)
|
||||
expect(result.hasLibraryPanels).toBe(true);
|
||||
expect(result.initialSaveModelVersion).toBe('v1');
|
||||
});
|
||||
|
||||
// If V1 dashboard without library panels → V2 Resource should return false
|
||||
it('should return false for hasLibraryPanels when V1 dashboard has no library panels', async () => {
|
||||
const tab = buildV1DashboardScenario();
|
||||
tab.setState({ exportMode: ExportMode.V2Resource });
|
||||
|
||||
const result = await tab.getExportableDashboardJson();
|
||||
|
||||
// Should not detect library panels
|
||||
expect(result.hasLibraryPanels).toBe(false);
|
||||
expect(result.initialSaveModelVersion).toBe('v1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('V2Resource export mode with dashboardNewLayouts disabled', () => {
|
||||
beforeEach(() => {
|
||||
config.featureToggles.dashboardNewLayouts = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.featureToggles.dashboardNewLayouts = true;
|
||||
});
|
||||
|
||||
// If V2 dashboard → V2 Resource should detect library panels correctly
|
||||
it('should detect library panels in V2 dashboard when exporting as V2 resource', async () => {
|
||||
const tab = buildV2DashboardWithLibraryPanels();
|
||||
tab.setState({ exportMode: ExportMode.V2Resource });
|
||||
|
||||
const result = await tab.getExportableDashboardJson();
|
||||
|
||||
// Should detect library panels from V2 dashboard elements (second branch of the logic)
|
||||
expect(result.hasLibraryPanels).toBe(true);
|
||||
expect(result.initialSaveModelVersion).toBe('v2');
|
||||
});
|
||||
|
||||
// Test the second branch: V2 dashboard with V1 initial save model
|
||||
it('should detect library panels in V2 dashboard with V1 initial save model', async () => {
|
||||
const tab = buildV2DashboardWithV1InitialSaveModel();
|
||||
tab.setState({ exportMode: ExportMode.V2Resource });
|
||||
|
||||
const result = await tab.getExportableDashboardJson();
|
||||
|
||||
// Should detect library panels from V2 dashboard elements (second branch of the logic)
|
||||
expect(result.hasLibraryPanels).toBe(true);
|
||||
expect(result.initialSaveModelVersion).toBe('v1');
|
||||
});
|
||||
|
||||
// If V2 dashboard without library panels → V2 Resource should return false
|
||||
it('should return false for hasLibraryPanels when V2 dashboard has no library panels', async () => {
|
||||
const tab = buildV2DashboardScenario();
|
||||
tab.setState({ exportMode: ExportMode.V2Resource });
|
||||
|
||||
const result = await tab.getExportableDashboardJson();
|
||||
|
||||
// Should not detect library panels
|
||||
expect(result.hasLibraryPanels).toBe(false);
|
||||
expect(result.initialSaveModelVersion).toBe('v2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Classic export mode', () => {
|
||||
@@ -247,13 +334,37 @@ describe('ShareExportTab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions to create test scenarios
|
||||
function buildV1DashboardScenario(): ShareExportTab {
|
||||
// Helper factory to create test scenarios
|
||||
function createDashboardScenario(options: {
|
||||
version: 'v1' | 'v2';
|
||||
hasLibraryPanels?: boolean;
|
||||
initialSaveModelVersion?: 'v1' | 'v2';
|
||||
}): ShareExportTab {
|
||||
const { version, hasLibraryPanels = false, initialSaveModelVersion = version } = options;
|
||||
|
||||
// Create V1 dashboard
|
||||
const mockV1Dashboard: DashboardDataDTO = {
|
||||
title: 'Test Dashboard V1',
|
||||
title: `Test Dashboard V1`,
|
||||
uid: 'test-uid-v1',
|
||||
version: 1,
|
||||
panels: [],
|
||||
panels: hasLibraryPanels
|
||||
? [
|
||||
{
|
||||
id: 1,
|
||||
type: 'stat',
|
||||
title: 'Regular Panel',
|
||||
gridPos: { x: 0, y: 0, w: 12, h: 8 },
|
||||
targets: [],
|
||||
options: {},
|
||||
fieldConfig: { defaults: {}, overrides: [] },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'library-panel-ref',
|
||||
libraryPanel: { uid: 'lib-panel-uid', name: 'My Library Panel' },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
time: { from: 'now-6h', to: 'now' },
|
||||
timepicker: {},
|
||||
timezone: '',
|
||||
@@ -265,36 +376,42 @@ describe('ShareExportTab', () => {
|
||||
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 {
|
||||
// Create V2 dashboard
|
||||
const mockV2Dashboard: DashboardV2Spec = {
|
||||
title: 'Test Dashboard V2',
|
||||
title: `Test Dashboard V2`,
|
||||
annotations: [],
|
||||
cursorSync: 'Off',
|
||||
description: 'Test V2 dashboard',
|
||||
editable: true,
|
||||
elements: {},
|
||||
elements: hasLibraryPanels
|
||||
? {
|
||||
'element-1': {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
id: 1,
|
||||
title: 'Regular Panel',
|
||||
description: '',
|
||||
links: [],
|
||||
data: defaultQueryGroupKind(),
|
||||
vizConfig: {
|
||||
kind: 'stat',
|
||||
spec: defaultVizConfigSpec(),
|
||||
},
|
||||
},
|
||||
},
|
||||
'element-2': {
|
||||
kind: 'LibraryPanel',
|
||||
spec: {
|
||||
id: 2,
|
||||
title: 'My Library Panel',
|
||||
libraryPanel: {
|
||||
uid: 'lib-panel-uid',
|
||||
name: 'My Library Panel',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {},
|
||||
layout: { kind: 'GridLayout', spec: { items: [] } },
|
||||
links: [],
|
||||
liveNow: false,
|
||||
@@ -315,19 +432,40 @@ describe('ShareExportTab', () => {
|
||||
|
||||
const tab = new ShareExportTab({});
|
||||
const scene = new DashboardScene({
|
||||
title: 'Test Dashboard V2',
|
||||
uid: 'test-uid-v2',
|
||||
title: `Test Dashboard ${version.toUpperCase()}`,
|
||||
uid: `test-uid-${version}`,
|
||||
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);
|
||||
// Set up the scene based on current version
|
||||
const currentDashboard = version === 'v1' ? mockV1Dashboard : mockV2Dashboard;
|
||||
const initialSaveModel = initialSaveModelVersion === 'v1' ? mockV1Dashboard : mockV2Dashboard;
|
||||
const apiVersion = version === 'v1' ? 'dashboard.grafana.app/v1beta1' : 'dashboard.grafana.app/v2alpha1';
|
||||
|
||||
scene.serializer.getSaveModel = jest.fn(() => currentDashboard);
|
||||
scene.serializer.makeExportableExternally = jest.fn(() =>
|
||||
Promise.resolve(
|
||||
version === 'v1' ? ({ ...mockV1Dashboard, panels: mockV1Dashboard.panels } as DashboardJson) : mockV2Dashboard
|
||||
)
|
||||
);
|
||||
scene.serializer.apiVersion = apiVersion;
|
||||
scene.getInitialSaveModel = jest.fn(() => initialSaveModel);
|
||||
|
||||
return tab;
|
||||
}
|
||||
|
||||
// util functions for common scenarios
|
||||
const buildV1DashboardScenario = () => createDashboardScenario({ version: 'v1' });
|
||||
const buildV2DashboardScenario = () => createDashboardScenario({ version: 'v2' });
|
||||
const buildV1DashboardWithLibraryPanels = () => createDashboardScenario({ version: 'v1', hasLibraryPanels: true });
|
||||
const buildV2DashboardWithLibraryPanels = () => createDashboardScenario({ version: 'v2', hasLibraryPanels: true });
|
||||
const buildV2DashboardWithV1InitialSaveModel = () =>
|
||||
createDashboardScenario({
|
||||
version: 'v2',
|
||||
hasLibraryPanels: true,
|
||||
initialSaveModelVersion: 'v1',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,12 +124,27 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
|
||||
exportMode: ExportMode.V2Resource,
|
||||
});
|
||||
|
||||
// For automatic V2 path, also process library panels when sharing externally
|
||||
let finalSpec = exportable;
|
||||
if (isSharingExternally && isDashboardV2Spec(exportable)) {
|
||||
const specCopy = JSON.parse(JSON.stringify(exportable));
|
||||
const result = await makeExportableV2(specCopy, isSharingExternally);
|
||||
if ('error' in result) {
|
||||
return {
|
||||
json: { error: result.error },
|
||||
initialSaveModelVersion,
|
||||
hasLibraryPanels: Object.values(origDashboard.elements).some((element) => element.kind === 'LibraryPanel'),
|
||||
};
|
||||
}
|
||||
finalSpec = result;
|
||||
}
|
||||
|
||||
return {
|
||||
json: {
|
||||
apiVersion: scene.serializer.apiVersion ?? '',
|
||||
kind: 'Dashboard',
|
||||
metadata,
|
||||
spec: exportable,
|
||||
spec: finalSpec,
|
||||
status: {},
|
||||
},
|
||||
initialSaveModelVersion,
|
||||
@@ -203,8 +218,17 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
|
||||
if (exportMode === ExportMode.V2Resource) {
|
||||
const spec = transformSceneToSaveModelSchemaV2(scene);
|
||||
const specCopy = JSON.parse(JSON.stringify(spec));
|
||||
const statelessSpec = await makeExportableV2(specCopy);
|
||||
const statelessSpec = await makeExportableV2(specCopy, isSharingExternally);
|
||||
const exportableV2 = isSharingExternally ? statelessSpec : spec;
|
||||
// Check if dashboard contains library panels based on dashboard version
|
||||
let hasLibraryPanels = false;
|
||||
// Case: V1 dashboard loaded (with kubernetesDashboards enabled and dashboardNewLayouts disabled), and user explicitly selected V2Resource export mode
|
||||
if (initialSaveModelVersion === 'v1' && !isDashboardV2Spec(origDashboard)) {
|
||||
hasLibraryPanels = hasLibraryPanelsInV1Dashboard(origDashboard);
|
||||
} else if (isDashboardV2Spec(origDashboard)) {
|
||||
// Case: V2 dashboard (either originally V2 or transformed from V1) being exported as V2Resource
|
||||
hasLibraryPanels = Object.values(origDashboard.elements).some((element) => element.kind === 'LibraryPanel');
|
||||
}
|
||||
|
||||
return {
|
||||
json: {
|
||||
@@ -216,10 +240,7 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
|
||||
status: {},
|
||||
},
|
||||
initialSaveModelVersion,
|
||||
hasLibraryPanels:
|
||||
initialSaveModelVersion === 'v1' && !isDashboardV2Spec(origDashboard)
|
||||
? hasLibraryPanelsInV1Dashboard(origDashboard)
|
||||
: false,
|
||||
hasLibraryPanels,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -423,52 +423,7 @@ function buildElement(p: Panel): [PanelKind | LibraryPanelKind, string] {
|
||||
return [panelKind, element_identifier];
|
||||
} else {
|
||||
// PanelKind
|
||||
|
||||
const queries = getPanelQueries(
|
||||
(p.targets as unknown as DataQuery[]) || [],
|
||||
p.datasource || getDefaultDatasource()
|
||||
);
|
||||
|
||||
const transformations = getPanelTransformations(p.transformations || []);
|
||||
|
||||
const panelKind: PanelKind = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
title: p.title || '',
|
||||
description: p.description || '',
|
||||
vizConfig: {
|
||||
kind: p.type,
|
||||
spec: {
|
||||
fieldConfig: (p.fieldConfig as any) || defaultFieldConfigSource(),
|
||||
options: p.options as any,
|
||||
pluginVersion: p.pluginVersion!,
|
||||
},
|
||||
},
|
||||
links:
|
||||
p.links?.map<DataLink>((l) => ({
|
||||
title: l.title,
|
||||
url: l.url || '',
|
||||
targetBlank: l.targetBlank,
|
||||
})) || [],
|
||||
id: p.id!,
|
||||
data: {
|
||||
kind: 'QueryGroup',
|
||||
spec: {
|
||||
queries,
|
||||
transformations,
|
||||
queryOptions: {
|
||||
cacheTimeout: p.cacheTimeout,
|
||||
maxDataPoints: p.maxDataPoints,
|
||||
interval: p.interval,
|
||||
hideTimeOverride: p.hideTimeOverride,
|
||||
queryCachingTTL: p.queryCachingTTL,
|
||||
timeFrom: p.timeFrom,
|
||||
timeShift: p.timeShift,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const panelKind = buildPanelKind(p);
|
||||
return [panelKind, element_identifier];
|
||||
}
|
||||
}
|
||||
@@ -515,6 +470,52 @@ export function getPanelQueries(targets: DataQuery[], panelDatasource: DataSourc
|
||||
});
|
||||
}
|
||||
|
||||
export function buildPanelKind(p: Panel): PanelKind {
|
||||
const queries = getPanelQueries((p.targets as unknown as DataQuery[]) || [], p.datasource || getDefaultDatasource());
|
||||
|
||||
const transformations = getPanelTransformations(p.transformations || []);
|
||||
|
||||
const panelKind: PanelKind = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
title: p.title || '',
|
||||
description: p.description || '',
|
||||
vizConfig: {
|
||||
kind: p.type,
|
||||
spec: {
|
||||
fieldConfig: (p.fieldConfig as any) || defaultFieldConfigSource(),
|
||||
options: p.options as any,
|
||||
pluginVersion: p.pluginVersion!,
|
||||
},
|
||||
},
|
||||
links:
|
||||
p.links?.map<DataLink>((l) => ({
|
||||
title: l.title,
|
||||
url: l.url || '',
|
||||
targetBlank: l.targetBlank,
|
||||
})) || [],
|
||||
id: p.id!,
|
||||
data: {
|
||||
kind: 'QueryGroup',
|
||||
spec: {
|
||||
queries,
|
||||
transformations,
|
||||
queryOptions: {
|
||||
cacheTimeout: p.cacheTimeout,
|
||||
maxDataPoints: p.maxDataPoints,
|
||||
interval: p.interval,
|
||||
hideTimeOverride: p.hideTimeOverride,
|
||||
queryCachingTTL: p.queryCachingTTL,
|
||||
timeFrom: p.timeFrom,
|
||||
timeShift: p.timeShift,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return panelKind;
|
||||
}
|
||||
|
||||
function getPanelTransformations(transformations: DataTransformerConfig[]): TransformationKind[] {
|
||||
return transformations.map((t) => {
|
||||
return {
|
||||
|
||||
@@ -5935,8 +5935,8 @@
|
||||
"title-someone-else-has-updated-this-dashboard": "Someone else has updated this dashboard",
|
||||
"would-still-dashboard": "Would you still like to save this dashboard?"
|
||||
},
|
||||
"schema-v2-library-panels-export": "The dynamic dashboard functionality is experimental, and has not full feature parity with current dashboards behaviour. It is based on a new schema format, that does not support library panels. This means that when exporting the dashboard to use it in another instance, we will not include library panels. We intend to support them as we progress in the feature <2>life cycle</2>.",
|
||||
"schema-v2-library-panels-export-title": "Dashboard Schema V2 does not support exporting library panels to be used in another instance yet",
|
||||
"schema-v2-library-panels-export": "Due to limitations in the new dashboard schema (V2), library panels will be converted to regular panels with embedded content during external export.",
|
||||
"schema-v2-library-panels-export-title": "Library panels will be converted to regular panels",
|
||||
"title-dashboard-drastically-changed": "Dashboard irreversibly changed"
|
||||
},
|
||||
"save-dashboard-form-common-options": {
|
||||
|
||||
Reference in New Issue
Block a user