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