Dashboard: Add Monitoring to v1 and v2 export flow (#107899)

* Dashboard: Add Monitoring to v1 and v2 export flow, including library panels interaction

* fix error with v2 transformed dashboard not having haslibraryPanels

* apply PR suggestions, extract as const and refactor for readability

* Add unit test for the new functions
This commit is contained in:
Alexa Vargas
2025-07-16 11:53:44 +02:00
committed by GitHub
parent d87f7ecfc1
commit 5b0ebb1693
5 changed files with 455 additions and 10 deletions
@@ -13,7 +13,6 @@ import { notifyApp } from 'app/core/actions';
import { createSuccessNotification } from 'app/core/copy/appNotification';
import { dispatch } from 'app/store/store';
import { DashboardInteractions } from '../../utils/interactions';
import { ShareExportTab } from '../ShareExportTab';
import { ExportMode, ResourceExport } from './ResourceExport';
@@ -120,9 +119,7 @@ function ExportAsCodeRenderer({ model }: SceneComponentProps<ExportAsCode>) {
icon="copy"
disabled={dashboardJson.loading}
getText={() => stringifiedDashboard ?? ''}
onClipboardCopy={() => {
DashboardInteractions.exportCopyJsonClicked();
}}
onClipboardCopy={model.onClipboardCopy}
>
<Trans i18nKey="export.json.copy-button">Copy to clipboard</Trans>
</ClipboardButton>
@@ -13,7 +13,7 @@ import { Button, ClipboardButton, CodeEditor, Field, Modal, Stack, Switch } from
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 { isDashboardV2Spec, isV1ClassicDashboard } 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';
@@ -27,7 +27,7 @@ import { transformSceneToSaveModel } from '../serialization/transformSceneToSave
import { transformSceneToSaveModelSchemaV2 } from '../serialization/transformSceneToSaveModelSchemaV2';
import { getVariablesCompatibility } from '../utils/getVariablesCompatibility';
import { DashboardInteractions } from '../utils/interactions';
import { getDashboardSceneFor } from '../utils/utils';
import { getDashboardSceneFor, hasLibraryPanelsInV1Dashboard } from '../utils/utils';
import { ExportMode, ResourceExport } from './ExportButton/ResourceExport';
import { SceneShareTabState, ShareView } from './types';
@@ -171,7 +171,7 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
status: {},
},
initialSaveModelVersion,
hasLibraryPanels: undefined,
hasLibraryPanels: hasLibraryPanelsInV1Dashboard(spec1),
};
} catch (err) {
return {
@@ -195,7 +195,7 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
status: {},
},
initialSaveModelVersion,
hasLibraryPanels: undefined,
hasLibraryPanels: hasLibraryPanelsInV1Dashboard(spec),
};
}
}
@@ -216,6 +216,10 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
status: {},
},
initialSaveModelVersion,
hasLibraryPanels:
initialSaveModelVersion === 'v1' && !isDashboardV2Spec(origDashboard)
? hasLibraryPanelsInV1Dashboard(origDashboard)
: false,
};
}
@@ -238,15 +242,19 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
const exportableV1 = isSharingExternally ? await makeExportableV1(oldModel) : initialSaveModel;
return {
json: exportableV1,
hasLibraryPanels: undefined,
hasLibraryPanels: hasLibraryPanelsInV1Dashboard(initialSaveModel),
initialSaveModelVersion,
};
}
// legacy mode or classic mode when dashboardNewLayouts is disabled
// At this point we know that dashboard should be V1 or could have produced an error
return {
json: exportable,
hasLibraryPanels: undefined,
hasLibraryPanels:
'error' in exportable || !isV1ClassicDashboard(origDashboard)
? false
: hasLibraryPanelsInV1Dashboard(origDashboard),
initialSaveModelVersion,
};
};
@@ -267,8 +275,27 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
}
const extension = isViewingYAML ? 'yaml' : 'json';
saveAs(blob, `${title}-${time}.${extension}`);
DashboardInteractions.exportDownloadJsonClicked({
externally: isSharingExternally,
dashboard_schema_version: dashboard.initialSaveModelVersion,
has_library_panels: Boolean(dashboard.hasLibraryPanels),
format: isViewingYAML ? 'yaml' : 'json',
action: 'download',
});
};
public onClipboardCopy = async () => {
const dashboard = await this.getExportableDashboardJson();
const { isSharingExternally, isViewingYAML, exportMode } = this.state;
DashboardInteractions.exportCopyJsonClicked({
externally: isSharingExternally,
dashboard_schema_version: dashboard.initialSaveModelVersion,
has_library_panels: Boolean(dashboard.hasLibraryPanels),
export_mode: exportMode || 'classic',
format: isViewingYAML ? 'yaml' : 'json',
action: 'copy',
});
};
}
@@ -428,6 +455,7 @@ function ShareExportTabRenderer({ model }: SceneComponentProps<ShareExportTab>)
icon="copy"
disabled={dashboardJson.loading}
getText={() => stringifiedDashboard ?? ''}
onClipboardCopy={model.onClipboardCopy}
>
<Trans i18nKey="share-modal.view-json.copy-button">Copy to Clipboard</Trans>
</ClipboardButton>
@@ -0,0 +1,372 @@
import { Dashboard, Panel, RowPanel } from '@grafana/schema';
import { isValidLibraryPanelRef, hasLibraryPanelsInV1Dashboard } from './utils';
describe('utils', () => {
describe('isValidLibraryPanelRef', () => {
it('should return true for valid library panel reference', () => {
const panel: Panel = {
id: 1,
title: 'Test Panel',
type: 'graph',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
libraryPanel: {
uid: 'lib-panel-uid',
name: 'Library Panel Name',
},
};
expect(isValidLibraryPanelRef(panel)).toBe(true);
});
it('should return false for panel without libraryPanel property', () => {
const panel: Panel = {
id: 1,
title: 'Test Panel',
type: 'graph',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
};
expect(isValidLibraryPanelRef(panel)).toBe(false);
});
it('should return false for panel with libraryPanel but missing uid', () => {
const panel: Panel = {
id: 1,
title: 'Test Panel',
type: 'graph',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
// @ts-expect-error - Testing invalid library panel ref without uid
libraryPanel: {
name: 'Library Panel Name',
},
};
expect(isValidLibraryPanelRef(panel)).toBe(false);
});
it('should return false for panel with libraryPanel but missing name', () => {
const panel: Panel = {
id: 1,
title: 'Test Panel',
type: 'graph',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
// @ts-expect-error - Testing invalid library panel ref without name
libraryPanel: {
uid: 'lib-panel-uid',
},
};
expect(isValidLibraryPanelRef(panel)).toBe(false);
});
it('should return false for panel with libraryPanel but empty uid', () => {
const panel: Panel = {
id: 1,
title: 'Test Panel',
type: 'graph',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
libraryPanel: {
uid: '',
name: 'Library Panel Name',
},
};
expect(isValidLibraryPanelRef(panel)).toBe(false);
});
it('should return false for panel with libraryPanel but empty name', () => {
const panel: Panel = {
id: 1,
title: 'Test Panel',
type: 'graph',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
libraryPanel: {
uid: 'lib-panel-uid',
name: '',
},
};
expect(isValidLibraryPanelRef(panel)).toBe(false);
});
it('should return false for panel with null libraryPanel', () => {
const panel: Panel = {
id: 1,
title: 'Test Panel',
type: 'graph',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
// @ts-expect-error - Testing invalid library panel ref
libraryPanel: null,
};
expect(isValidLibraryPanelRef(panel)).toBe(false);
});
});
describe('hasLibraryPanelsInV1Dashboard', () => {
it('should return false for undefined dashboard', () => {
expect(hasLibraryPanelsInV1Dashboard(undefined)).toBe(false);
});
it('should return false for dashboard without panels', () => {
const dashboard: Dashboard = {
id: 1,
title: 'Test Dashboard',
tags: [],
timezone: 'browser',
panels: [],
time: { from: 'now-6h', to: 'now' },
timepicker: {},
templating: { list: [] },
annotations: { list: [] },
refresh: '',
schemaVersion: 30,
version: 1,
links: [],
};
expect(hasLibraryPanelsInV1Dashboard(dashboard)).toBe(false);
});
it('should return false for dashboard with no library panels', () => {
const dashboard: Dashboard = {
id: 1,
title: 'Test Dashboard',
tags: [],
timezone: 'browser',
panels: [
{
id: 1,
title: 'Regular Panel',
type: 'graph',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
},
],
time: { from: 'now-6h', to: 'now' },
timepicker: {},
templating: { list: [] },
annotations: { list: [] },
refresh: '',
schemaVersion: 30,
version: 1,
links: [],
};
expect(hasLibraryPanelsInV1Dashboard(dashboard)).toBe(false);
});
it('should return true for dashboard with library panels', () => {
const dashboard: Dashboard = {
id: 1,
title: 'Test Dashboard',
tags: [],
timezone: 'browser',
panels: [
{
id: 1,
title: 'Library Panel',
type: 'graph',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
libraryPanel: {
uid: 'lib-panel-uid',
name: 'Library Panel Name',
},
},
],
time: { from: 'now-6h', to: 'now' },
timepicker: {},
templating: { list: [] },
annotations: { list: [] },
refresh: '',
schemaVersion: 30,
version: 1,
links: [],
};
expect(hasLibraryPanelsInV1Dashboard(dashboard)).toBe(true);
});
it('should return true for dashboard with library panels in collapsed row', () => {
const collapsedRowPanel: RowPanel = {
id: 1,
title: 'Row Panel',
type: 'row',
gridPos: { x: 0, y: 0, w: 24, h: 1 },
collapsed: true,
panels: [
{
id: 2,
title: 'Library Panel in Row',
type: 'graph',
gridPos: { x: 0, y: 1, w: 12, h: 8 },
libraryPanel: {
uid: 'lib-panel-uid',
name: 'Library Panel Name',
},
},
],
};
const dashboard: Dashboard = {
id: 1,
title: 'Test Dashboard',
tags: [],
timezone: 'browser',
panels: [collapsedRowPanel],
time: { from: 'now-6h', to: 'now' },
timepicker: {},
templating: { list: [] },
annotations: { list: [] },
refresh: '',
schemaVersion: 30,
version: 1,
links: [],
};
expect(hasLibraryPanelsInV1Dashboard(dashboard)).toBe(true);
});
it('should return false for dashboard with collapsed row but no library panels', () => {
const collapsedRowPanel: RowPanel = {
id: 1,
title: 'Row Panel',
type: 'row',
gridPos: { x: 0, y: 0, w: 24, h: 1 },
collapsed: true,
panels: [
{
id: 2,
title: 'Regular Panel in Row',
type: 'graph',
gridPos: { x: 0, y: 1, w: 12, h: 8 },
},
],
};
const dashboard: Dashboard = {
id: 1,
title: 'Test Dashboard',
tags: [],
timezone: 'browser',
panels: [collapsedRowPanel],
time: { from: 'now-6h', to: 'now' },
timepicker: {},
templating: { list: [] },
annotations: { list: [] },
refresh: '',
schemaVersion: 30,
version: 1,
links: [],
};
expect(hasLibraryPanelsInV1Dashboard(dashboard)).toBe(false);
});
it('should return false for dashboard with expanded row (not collapsed)', () => {
const expandedRowPanel: RowPanel = {
id: 1,
title: 'Row Panel',
type: 'row',
gridPos: { x: 0, y: 0, w: 24, h: 1 },
collapsed: false,
panels: [
{
id: 2,
title: 'Library Panel in Row',
type: 'graph',
gridPos: { x: 0, y: 1, w: 12, h: 8 },
libraryPanel: {
uid: 'lib-panel-uid',
name: 'Library Panel Name',
},
},
],
};
const dashboard: Dashboard = {
id: 1,
title: 'Test Dashboard',
tags: [],
timezone: 'browser',
panels: [expandedRowPanel],
time: { from: 'now-6h', to: 'now' },
timepicker: {},
templating: { list: [] },
annotations: { list: [] },
refresh: '',
schemaVersion: 30,
version: 1,
links: [],
};
expect(hasLibraryPanelsInV1Dashboard(dashboard)).toBe(false);
});
it('should return false for dashboard with row panel without panels property', () => {
//@ts-expect-error - Testing invalid row panel
const rowPanel: RowPanel = {
id: 1,
title: 'Row Panel',
type: 'row',
gridPos: { x: 0, y: 0, w: 24, h: 1 },
collapsed: true,
};
const dashboard: Dashboard = {
id: 1,
title: 'Test Dashboard',
tags: [],
timezone: 'browser',
panels: [rowPanel],
time: { from: 'now-6h', to: 'now' },
timepicker: {},
templating: { list: [] },
annotations: { list: [] },
refresh: '',
schemaVersion: 30,
version: 1,
links: [],
};
expect(hasLibraryPanelsInV1Dashboard(dashboard)).toBe(false);
});
it('should return true for dashboard with mixed panels and library panels', () => {
const dashboard: Dashboard = {
id: 1,
title: 'Test Dashboard',
tags: [],
timezone: 'browser',
panels: [
{
id: 1,
title: 'Regular Panel',
type: 'graph',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
},
{
id: 2,
title: 'Library Panel',
type: 'graph',
gridPos: { x: 12, y: 0, w: 12, h: 8 },
libraryPanel: {
uid: 'lib-panel-uid',
name: 'Library Panel Name',
},
},
],
time: { from: 'now-6h', to: 'now' },
timepicker: {},
templating: { list: [] },
annotations: { list: [] },
refresh: '',
schemaVersion: 30,
version: 1,
links: [],
};
expect(hasLibraryPanelsInV1Dashboard(dashboard)).toBe(true);
});
});
});
@@ -13,6 +13,7 @@ import {
VizPanel,
VizPanelMenu,
} from '@grafana/scenes';
import { Dashboard, Panel, RowPanel } from '@grafana/schema';
import { createLogger } from '@grafana/ui';
import { initialIntervalVariableModelState } from 'app/features/variables/interval/reducer';
@@ -32,6 +33,11 @@ import { containsCloneKey, getLastKeyFromClone, getOriginalKey, isInCloneChain }
export const NEW_PANEL_HEIGHT = 8;
export const NEW_PANEL_WIDTH = 12;
const V1_PANEL_PROPERTIES = {
LIBRARY_PANEL: 'libraryPanel',
COLLAPSED: 'collapsed',
} as const;
export function getVizPanelKeyForPanelId(panelId: number) {
return `panel-${panelId}`;
}
@@ -470,4 +476,42 @@ export function getLayoutOrchestratorFor(scene: SceneObject): DashboardLayoutOrc
return getDashboardSceneFor(scene).state.layoutOrchestrator;
}
// @returns true if the panel is a valid library panel reference
// a valid library panel reference is a panel with this
// property: `libraryPanel: {name: string, uid: string}`
export function isValidLibraryPanelRef(panel: Panel): boolean {
return (
(V1_PANEL_PROPERTIES.LIBRARY_PANEL in panel &&
panel.libraryPanel &&
Boolean(panel.libraryPanel?.uid && panel.libraryPanel?.name)) ||
false
);
}
/**
* Checks if a V1 dashboard contains library panels
* @returns true if the dashboard contains library panels
*/
export function hasLibraryPanelsInV1Dashboard(dashboard: Dashboard | undefined): boolean {
if (!dashboard?.panels) {
return false;
}
return dashboard.panels.some((panel: Panel | RowPanel) => {
if (isValidLibraryPanelRef(panel)) {
return true;
}
// Check if this is a collapsed row containing library panels
const isCollapsedRow =
V1_PANEL_PROPERTIES.COLLAPSED in panel && panel.collapsed && 'panels' in panel && panel.panels;
if (!isCollapsedRow) {
return false;
}
return panel.panels.some(isValidLibraryPanelRef);
});
}
export const dashboardLog = createLogger('Dashboard');
@@ -74,6 +74,10 @@ export function isV1DashboardCommand(
return !isDashboardV2Spec(cmd.dashboard);
}
export function isV1ClassicDashboard(obj: Dashboard | DashboardV2Spec): obj is Dashboard {
return !isDashboardV2Spec(obj);
}
export function isV2DashboardCommand(
cmd: SaveDashboardCommand<Dashboard | DashboardV2Spec>
): cmd is SaveDashboardCommand<DashboardV2Spec> {