Compare commits

...

5 Commits

Author SHA1 Message Date
Haris Rozajac
44af4449a1 i18n 2025-11-25 22:57:37 -07:00
Haris Rozajac
a5242dfaac base export externally label on dashboard shape 2025-11-25 22:47:40 -07:00
Haris Rozajac
4a5530c32f Simplify export by allowing only one resource export based on dashboard schema version 2025-11-25 22:37:34 -07:00
Haris Rozajac
e0f210d8c4 when k8s dashboards are enabled; bypass regular import and use k8s API; for library panels, use library panel 2025-11-24 14:30:50 -07:00
Haris Rozajac
d1d8e0b30f wip 2025-11-24 07:28:51 -07:00
6 changed files with 211 additions and 117 deletions

View File

@@ -2136,11 +2136,6 @@
"count": 2
}
},
"public/app/features/dashboard-scene/sharing/ShareExportTab.tsx": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx": {
"no-restricted-syntax": {
"count": 4
@@ -2942,11 +2937,6 @@
"count": 1
}
},
"public/app/features/manage-dashboards/components/ImportDashboardOverview.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/features/manage-dashboards/state/actions.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1

View File

@@ -19,7 +19,6 @@ interface Props {
dashboardJson: AsyncState<{
json: Dashboard | DashboardJson | DashboardV2Spec | ExportableResource | { error: unknown };
hasLibraryPanels?: boolean;
initialSaveModelVersion: 'v1' | 'v2';
}>;
isSharingExternally: boolean;
exportMode: ExportMode;
@@ -41,49 +40,27 @@ export function ResourceExport({
onViewYAML,
}: Props) {
const hasLibraryPanels = dashboardJson.value?.hasLibraryPanels;
const initialSaveModelVersion = dashboardJson.value?.initialSaveModelVersion;
const isV2Dashboard =
dashboardJson.value?.json && 'spec' in dashboardJson.value.json && 'elements' in dashboardJson.value.json.spec;
const showV2LibPanelAlert = isV2Dashboard && isSharingExternally && hasLibraryPanels;
const switchExportLabel =
exportMode === ExportMode.V2Resource
? t('export.json.export-remove-ds-refs', 'Remove deployment details')
: t('share-modal.export.share-externally-label', `Export for sharing externally`);
const switchExportLabel = isV2Dashboard
? t('export.json.export-remove-ds-refs', 'Remove deployment details')
: t('share-modal.export.share-externally-label', `Export for sharing externally`);
const switchExportModeLabel = t('export.json.export-mode', 'Model');
const switchExportFormatLabel = t('export.json.export-format', 'Format');
return (
<Stack gap={2} direction="column">
<Stack gap={1} direction="column">
{initialSaveModelVersion === 'v1' && (
<Stack alignItems="center">
<Label>{switchExportModeLabel}</Label>
<RadioButtonGroup
options={[
{ label: t('dashboard-scene.resource-export.label.classic', 'Classic'), value: ExportMode.Classic },
{
label: t('dashboard-scene.resource-export.label.v1-resource', 'V1 Resource'),
value: ExportMode.V1Resource,
},
{
label: t('dashboard-scene.resource-export.label.v2-resource', 'V2 Resource'),
value: ExportMode.V2Resource,
},
]}
value={exportMode}
onChange={(value) => onExportModeChange(value)}
/>
</Stack>
)}
{initialSaveModelVersion === 'v2' && (
{!isV2Dashboard && (
<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.classic', 'Classic'),
value: ExportMode.Classic,
},
{
label: t('dashboard-scene.resource-export.label.v1-resource', 'V1 Resource'),
@@ -108,9 +85,7 @@ export function ResourceExport({
/>
</Stack>
)}
{(isV2Dashboard ||
exportMode === ExportMode.Classic ||
(initialSaveModelVersion === 'v2' && exportMode === ExportMode.V1Resource)) && (
{exportMode !== ExportMode.V1Resource && (
<Stack gap={1} alignItems="start">
<Label>{switchExportLabel}</Label>
<Switch

View File

@@ -113,9 +113,6 @@ describe('ShareExportTab', () => {
// 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
@@ -136,9 +133,6 @@ describe('ShareExportTab', () => {
// 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
@@ -164,9 +158,6 @@ describe('ShareExportTab', () => {
// Should call makeExportableV1 for external sharing
expect(makeExportableV1Spy).toHaveBeenCalled();
// Should report correct initial version
expect(result.initialSaveModelVersion).toBe('v2');
});
});
@@ -187,9 +178,6 @@ describe('ShareExportTab', () => {
// 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');
});
// If V1 dashboard → V2 Resource should detect library panels correctly
@@ -201,7 +189,6 @@ describe('ShareExportTab', () => {
// 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
@@ -213,7 +200,6 @@ describe('ShareExportTab', () => {
// 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
@@ -225,7 +211,6 @@ describe('ShareExportTab', () => {
// Should not detect library panels
expect(result.hasLibraryPanels).toBe(false);
expect(result.initialSaveModelVersion).toBe('v1');
});
});
@@ -247,7 +232,6 @@ describe('ShareExportTab', () => {
// 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
@@ -259,7 +243,6 @@ describe('ShareExportTab', () => {
// 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
@@ -271,7 +254,6 @@ describe('ShareExportTab', () => {
// Should not detect library panels
expect(result.hasLibraryPanels).toBe(false);
expect(result.initialSaveModelVersion).toBe('v2');
});
});
@@ -294,9 +276,6 @@ describe('ShareExportTab', () => {
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');
});
});

View File

@@ -102,28 +102,16 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
public getExportableDashboardJson = async (): Promise<{
json: Dashboard | DashboardJson | DashboardV2Spec | ExportableResource | { error: unknown };
hasLibraryPanels?: boolean;
initialSaveModelVersion: 'v1' | 'v2';
}> => {
const { isSharingExternally, exportMode } = this.state;
const scene = getDashboardSceneFor(this);
const exportableDashboard = await scene.serializer.makeExportableExternally(scene);
const initialSaveModel = scene.getInitialSaveModel();
const initialSaveModelVersion = initialSaveModel && isDashboardV2Spec(initialSaveModel) ? 'v2' : 'v1';
const origDashboard = scene.serializer.getSaveModel(scene);
const exportable = isSharingExternally ? exportableDashboard : origDashboard;
const metadata = getMetadata(scene, Boolean(isSharingExternally));
if (
isDashboardV2Spec(origDashboard) &&
'elements' in exportable &&
initialSaveModelVersion === 'v2' &&
exportMode !== ExportMode.V1Resource
) {
this.setState({
exportMode: ExportMode.V2Resource,
});
if (isDashboardV2Spec(origDashboard) && 'elements' in exportable && exportMode !== ExportMode.V1Resource) {
// For automatic V2 path, also process library panels when sharing externally
let finalSpec = exportable;
if (isSharingExternally && isDashboardV2Spec(exportable)) {
@@ -132,7 +120,6 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
if ('error' in result) {
return {
json: { error: result.error },
initialSaveModelVersion,
hasLibraryPanels: Object.values(origDashboard.elements).some((element) => element.kind === 'LibraryPanel'),
};
}
@@ -147,14 +134,13 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
spec: finalSpec,
status: {},
},
initialSaveModelVersion,
hasLibraryPanels: Object.values(origDashboard.elements).some((element) => element.kind === 'LibraryPanel'),
};
}
if (exportMode === ExportMode.V1Resource) {
// Check if source is V2 and auto-transform to V1
if (isDashboardV2Spec(origDashboard) && initialSaveModelVersion === 'v2') {
if (isDashboardV2Spec(origDashboard)) {
try {
const spec = transformSceneToSaveModelSchemaV2(scene);
const metadata = getMetadata(scene, Boolean(isSharingExternally));
@@ -185,7 +171,6 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
spec: exportableV1,
status: {},
},
initialSaveModelVersion,
hasLibraryPanels: hasLibraryPanelsInV1Dashboard(spec1),
};
} catch (err) {
@@ -193,7 +178,6 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
json: {
error: `Failed to convert dashboard to v1. ${err}`,
},
initialSaveModelVersion,
hasLibraryPanels: undefined,
};
}
@@ -209,7 +193,6 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
spec,
status: {},
},
initialSaveModelVersion,
hasLibraryPanels: hasLibraryPanelsInV1Dashboard(spec),
};
}
@@ -223,7 +206,7 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
// 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)) {
if (!isDashboardV2Spec(origDashboard)) {
hasLibraryPanels = hasLibraryPanelsInV1Dashboard(origDashboard);
} else if (isDashboardV2Spec(origDashboard)) {
// Case: V2 dashboard (either originally V2 or transformed from V1) being exported as V2Resource
@@ -239,35 +222,10 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
spec: exportableV2,
status: {},
},
initialSaveModelVersion,
hasLibraryPanels,
};
}
// Classic mode
// This handles a case when:
// 1. dashboardNewLayouts feature toggle is enabled
// 2. v1 dashboard is loaded
// 3. dashboard hasn't been edited yet - if it was edited, user would be forced to save it in v2 version
if (
initialSaveModelVersion === 'v1' &&
isDashboardV2Spec(origDashboard) &&
initialSaveModel &&
'panels' in initialSaveModel
) {
const oldModel = new DashboardModel(initialSaveModel, undefined, {
getVariablesFromState: () => {
return getVariablesCompatibility(window.__grafanaSceneContext);
},
});
const exportableV1 = isSharingExternally ? await makeExportableV1(oldModel) : initialSaveModel;
return {
json: exportableV1,
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 {
@@ -276,7 +234,6 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
'error' in exportable || !isV1ClassicDashboard(origDashboard)
? false
: hasLibraryPanelsInV1Dashboard(origDashboard),
initialSaveModelVersion,
};
};
@@ -297,9 +254,11 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
const extension = isViewingYAML ? 'yaml' : 'json';
saveAs(blob, `${title}-${time}.${extension}`);
const isV2Dashboard = 'spec' in dashboard.json && 'elements' in dashboard.json.spec;
DashboardInteractions.exportDownloadJsonClicked({
externally: isSharingExternally,
dashboard_schema_version: dashboard.initialSaveModelVersion,
dashboard_schema_version: isV2Dashboard ? 'v2' : 'v1',
has_library_panels: Boolean(dashboard.hasLibraryPanels),
format: isViewingYAML ? 'yaml' : 'json',
action: 'download',
@@ -310,9 +269,11 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
const dashboard = await this.getExportableDashboardJson();
const { isSharingExternally, isViewingYAML, exportMode } = this.state;
const isV2Dashboard = 'spec' in dashboard.json && 'elements' in dashboard.json.spec;
DashboardInteractions.exportCopyJsonClicked({
externally: isSharingExternally,
dashboard_schema_version: dashboard.initialSaveModelVersion,
dashboard_schema_version: isV2Dashboard ? 'v2' : 'v1',
has_library_panels: Boolean(dashboard.hasLibraryPanels),
export_mode: exportMode || 'classic',
format: isViewingYAML ? 'yaml' : 'json',
@@ -402,7 +363,7 @@ function ShareExportTabRenderer({ model }: SceneComponentProps<ShareExportTab>)
/>
) : (
<Stack gap={2} direction="column">
<Field label={exportExternallyTranslation}>
<Field noMargin label={exportExternallyTranslation}>
<Switch
id="share-externally-toggle"
value={isSharingExternally}

View File

@@ -1,15 +1,21 @@
import { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { dateTimeFormat } from '@grafana/data';
import { DataSourceInstanceSettings, dateTimeFormat, locationUtil, TypedVariableModel } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { locationService, reportInteraction } from '@grafana/runtime';
import { locationService, reportInteraction, config } from '@grafana/runtime';
import { Panel } from '@grafana/schema/dist/esm/raw/dashboard/x/dashboard_types.gen';
import { AnnotationQuery, Dashboard } from '@grafana/schema/dist/esm/veneer/dashboard.types';
import { Box, Legend, TextLink } from '@grafana/ui';
import { Form } from 'app/core/components/Form/Form';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { addLibraryPanel } from 'app/features/library-panels/state/api';
import { StoreState } from 'app/types/store';
import { clearLoadedDashboard, importDashboard } from '../state/actions';
import { DashboardSource, ImportDashboardDTO } from '../state/reducers';
import { DashboardSource, DataSourceInput, ImportDashboardDTO, LibraryPanelInputState } from '../state/reducers';
import { ImportDashboardForm } from './ImportDashboardForm';
@@ -45,9 +51,80 @@ class ImportDashboardOverviewUnConnected extends PureComponent<Props, State> {
uidReset: false,
};
onSubmit = (form: ImportDashboardDTO) => {
onSubmit = async (form: ImportDashboardDTO) => {
reportInteraction(IMPORT_FINISHED_EVENT_NAME);
const { dashboard, inputs, folder } = this.props;
// when kubernetesDashboard are enabled, we bypass api/dashboard/import
// and hit the k8s dashboard API directly
if (config.featureToggles.kubernetesDashboards) {
// 1. process datasources so the template placeholder is replaced with the actual value user selected
const annotations = dashboard.annotations.list.map((annotation: AnnotationQuery) => {
return processAnnotation(annotation, inputs, form);
});
const panels = dashboard.panels.map((panel: Panel) => {
return processPanel(panel, inputs, form);
});
const variables = dashboard.templating.list.map((variable: TypedVariableModel) => {
return processVariable(variable, inputs, form);
});
const dashboardWithDataSources: Dashboard = {
...dashboard,
title: form.title,
annotations,
panels,
templating: {
list: variables,
},
uid: form.uid,
};
const newLibraryPanels = inputs.libraryPanels.filter((lp) => lp.state === LibraryPanelInputState.New);
// for library panels that don't exist in the instance, we create them by hitting the library panel API
for (const lp of newLibraryPanels) {
const libPanelWithPanelModel = new PanelModel(lp.model.model);
let { scopedVars, ...panelSaveModel } = libPanelWithPanelModel.getSaveModel();
panelSaveModel = {
libraryPanel: {
name: lp.model.name,
uid: lp.model.uid,
},
...panelSaveModel,
};
try {
await addLibraryPanel(panelSaveModel, folder.uid);
} catch (error) {
console.error('Error adding library panel during dashboard import', error);
}
}
const dashboardK8SPayload: SaveDashboardCommand<Dashboard> = {
dashboard: dashboardWithDataSources,
k8s: {
annotations: {
'grafana.app/folder': form.folder.uid,
},
},
};
// hit v1 API directly
const result = await getDashboardAPI('v1').saveDashboard(dashboardK8SPayload);
if (result.url) {
const dashboardUrl = locationUtil.stripBaseFromUrl(result.url);
locationService.push(dashboardUrl);
}
return;
}
this.props.importDashboard(form);
};
@@ -126,3 +203,116 @@ class ImportDashboardOverviewUnConnected extends PureComponent<Props, State> {
export const ImportDashboardOverview = connector(ImportDashboardOverviewUnConnected);
ImportDashboardOverview.displayName = 'ImportDashboardOverview';
function hasUid(query: Record<string, unknown> | {}): query is { uid: string } {
return 'uid' in query && typeof query['uid'] === 'string';
}
/*
Checks whether the templateized uid matches the user prodvided datasource input
*/
function checkUserInputMatch(
templateizedUid: string,
datasourceInputs: DataSourceInput[],
userDsInputs: DataSourceInstanceSettings[]
) {
const dsName = templateizedUid.replace(/\$\{(.*)\}/, '$1');
const input = datasourceInputs?.find((ds) => ds.name === dsName);
const userInput = input && userDsInputs.find((ds) => ds.type === input.pluginId);
return userInput;
}
function processAnnotation(
annotation: AnnotationQuery,
inputs: { dataSources: DataSourceInput[] },
form: ImportDashboardDTO
): AnnotationQuery {
if (annotation.datasource && annotation.datasource.uid && annotation.datasource.uid.startsWith('$')) {
const userInput = checkUserInputMatch(annotation.datasource.uid, inputs.dataSources, form.dataSources);
if (userInput) {
return {
...annotation,
datasource: {
...annotation.datasource,
uid: userInput.uid,
},
};
}
}
return annotation;
}
function processPanel(panel: Panel, inputs: { dataSources: DataSourceInput[] }, form: ImportDashboardDTO): Panel {
if (panel.datasource && panel.datasource.uid && panel.datasource.uid.startsWith('$')) {
const userInput = checkUserInputMatch(panel.datasource.uid, inputs.dataSources, form.dataSources);
const queries = panel.targets?.map((target) => {
if (target.datasource && hasUid(target.datasource) && target.datasource.uid.startsWith('$')) {
const userInput = checkUserInputMatch(target.datasource.uid, inputs.dataSources, form.dataSources);
if (userInput) {
return {
...target,
datasource: {
...target.datasource,
uid: userInput.uid,
},
};
}
}
return target;
});
if (userInput) {
return {
...panel,
targets: queries,
datasource: {
...panel.datasource,
uid: userInput.uid,
},
};
}
}
return panel;
}
function processVariable(
variable: TypedVariableModel,
inputs: { dataSources: DataSourceInput[] },
form: ImportDashboardDTO
): TypedVariableModel {
if (variable.type === 'query') {
if (variable.datasource && variable.datasource.uid?.startsWith('$')) {
const userInput = checkUserInputMatch(variable.datasource.uid, inputs.dataSources, form.dataSources);
if (userInput) {
return {
...variable,
datasource: {
...variable.datasource,
uid: userInput.uid,
},
};
}
}
}
if (variable.type === 'datasource') {
if (variable.current && variable.current.value && String(variable.current.value).startsWith('$')) {
const userInput = checkUserInputMatch(String(variable.current.value), inputs.dataSources, form.dataSources);
if (userInput) {
return {
...variable,
current: {
selected: variable.current.selected,
text: userInput.name,
value: userInput.uid,
},
};
}
}
}
return variable;
}

View File

@@ -6215,7 +6215,6 @@
"classic": "Classic",
"json": "JSON",
"v1-resource": "V1 Resource",
"v2-resource": "V2 Resource",
"yaml": "YAML"
}
},