Dashboard export: Allow exports by resource and in YAML format (#104149)

* Dashboard export: Allow YAML export

* use js-yaml to fix ci errors

* add yaml transformation in the "Copy to cilpboard"

* simplify

* new ui for resource exports

* simplify

* Don't show export mode for v2 dashboard

* Add metadata, apiVersion, add logic for export type resources

* i18n; switch title to as code

* update export as file button

* Remove managedFields from metadata export

* Remove metadata fields that are not needed for sharing externally

* Copy

* fix legacy mode

* address bugs

* Update public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.tsx

Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com>

* i18n

* improve classic mode; rename

* Update public/app/features/dashboard-scene/sharing/ShareExportTab.tsx

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>

* change order

---------

Co-authored-by: Haris Rozajac <haris.rozajac12@gmail.com>
Co-authored-by: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com>
Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com>
This commit is contained in:
Dominik Prokop
2025-05-01 09:06:07 -06:00
committed by GitHub
co-authored by Agnès Toulet Haris Rozajac Haris Rozajac
parent ca2ae82e80
commit 1114d33936
13 changed files with 441 additions and 144 deletions
@@ -725,17 +725,23 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
};
/** Hacky temp function until we refactor transformSaveModelToScene a bit */
setInitialSaveModel(model?: Dashboard, meta?: DashboardMeta): void;
setInitialSaveModel(model?: DashboardV2Spec, meta?: DashboardWithAccessInfo<DashboardV2Spec>['metadata']): void;
setInitialSaveModel(model?: Dashboard, meta?: DashboardMeta, apiVersion?: string): void;
setInitialSaveModel(
model?: DashboardV2Spec,
meta?: DashboardWithAccessInfo<DashboardV2Spec>['metadata'],
apiVersion?: string
): void;
public setInitialSaveModel(
saveModel?: Dashboard | DashboardV2Spec,
meta?: DashboardMeta | DashboardWithAccessInfo<DashboardV2Spec>['metadata']
meta?: DashboardMeta | DashboardWithAccessInfo<DashboardV2Spec>['metadata'],
apiVersion?: string
): void {
this.serializer.initializeElementMapping(saveModel);
this.serializer.initializeDSReferencesMapping(saveModel);
const sortedModel = sortedDeepCloneWithoutNulls(saveModel);
this.serializer.initialSaveModel = sortedModel;
this.serializer.metadata = meta;
this.serializer.apiVersion = apiVersion;
}
public getTrackingInformation() {
@@ -1,5 +1,5 @@
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { locationService } from '@grafana/runtime';
import { config, locationService } from '@grafana/runtime';
import { t } from 'app/core/internationalization';
import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
@@ -11,23 +11,29 @@ import { ShareExportDashboardButton } from './ShareExportDashboardButton';
const newExportButtonSelector = e2eSelectors.pages.Dashboard.DashNav.NewExportButton;
export const ExportDashboardButton = ({ dashboard }: ToolbarActionProps) => (
<ShareExportDashboardButton
menu={() => <ExportMenu dashboard={dashboard} />}
groupTestId={newExportButtonSelector.container}
buttonLabel={t('dashboard.toolbar.new.export.title', 'Export')}
buttonTooltip={t('dashboard.toolbar.new.export.tooltip', 'Export as JSON')}
buttonTestId={newExportButtonSelector.container}
onButtonClick={() => {
locationService.partial({ shareView: shareDashboardType.export });
export const ExportDashboardButton = ({ dashboard }: ToolbarActionProps) => {
const buttonTooltip = config.featureToggles.kubernetesDashboards
? t('dashboard.toolbar.new.export.tooltip.as-code', 'Export as code')
: t('dashboard.toolbar.new.export.tooltip.json', 'Export as JSON');
DashboardInteractions.sharingCategoryClicked({
item: shareDashboardType.export,
shareResource: getTrackingSource(),
});
}}
arrowLabel={t('dashboard.toolbar.new.export.arrow', 'Export')}
arrowTestId={newExportButtonSelector.arrowMenu}
dashboard={dashboard}
/>
);
return (
<ShareExportDashboardButton
menu={() => <ExportMenu dashboard={dashboard} />}
groupTestId={newExportButtonSelector.container}
buttonLabel={t('dashboard.toolbar.new.export.title', 'Export')}
buttonTooltip={buttonTooltip}
buttonTestId={newExportButtonSelector.container}
onButtonClick={() => {
locationService.partial({ shareView: shareDashboardType.export });
DashboardInteractions.sharingCategoryClicked({
item: shareDashboardType.export,
shareResource: getTrackingSource(),
});
}}
arrowLabel={t('dashboard.toolbar.new.export.arrow', 'Export')}
arrowTestId={newExportButtonSelector.arrowMenu}
dashboard={dashboard}
/>
);
};
@@ -35,6 +35,7 @@ export interface DashboardSceneSerializerLike<T, M, I = T, E = T | { error: unkn
*/
initialSaveModel?: I;
metadata?: M;
apiVersion?: string;
initializeElementMapping(saveModel: T | undefined): void;
initializeDSReferencesMapping(saveModel: T | undefined): void;
getSaveModel: (s: DashboardScene) => T;
@@ -87,7 +87,7 @@ export type TypedVariableModelV2 =
| AdhocVariableKind;
export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<DashboardV2Spec>): DashboardScene {
const { spec: dashboard, metadata } = dto;
const { spec: dashboard, metadata, apiVersion } = dto;
// annotations might not come with the builtIn Grafana annotation, we need to add it
@@ -221,7 +221,7 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<D
'v2'
);
dashboardScene.setInitialSaveModel(dto.spec, dto.metadata);
dashboardScene.setInitialSaveModel(dto.spec, dto.metadata, apiVersion);
return dashboardScene;
}
@@ -22,6 +22,7 @@ import {
} from '@grafana/scenes';
import { isWeekStart } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { K8S_V1_DASHBOARD_API_CONFIG } from 'app/features/dashboard/api/v1';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { DashboardDTO, DashboardDataDTO } from 'app/types';
@@ -69,7 +70,12 @@ export function transformSaveModelToScene(rsp: DashboardDTO): DashboardScene {
const scene = createDashboardSceneFromDashboardModel(oldModel, rsp.dashboard);
// TODO: refactor createDashboardSceneFromDashboardModel to work on Dashboard schema model
scene.setInitialSaveModel(rsp.dashboard);
const apiVersion = config.featureToggles.kubernetesDashboards
? `${K8S_V1_DASHBOARD_API_CONFIG.group}/${K8S_V1_DASHBOARD_API_CONFIG.version}`
: undefined;
scene.setInitialSaveModel(rsp.dashboard, rsp.meta, apiVersion);
return scene;
}
@@ -1,22 +1,13 @@
import { css } from '@emotion/css';
import yaml from 'js-yaml';
import { useAsync } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { SceneComponentProps } from '@grafana/scenes';
import {
Alert,
Button,
ClipboardButton,
CodeEditor,
Label,
Spinner,
Stack,
Switch,
TextLink,
useStyles2,
} from '@grafana/ui';
import { Button, ClipboardButton, CodeEditor, Label, Spinner, Stack, Switch, useStyles2 } from '@grafana/ui';
import { notifyApp } from 'app/core/actions';
import { createSuccessNotification } from 'app/core/copy/appNotification';
import { t, Trans } from 'app/core/internationalization';
@@ -25,29 +16,33 @@ import { dispatch } from 'app/store/store';
import { DashboardInteractions } from '../../utils/interactions';
import { ShareExportTab } from '../ShareExportTab';
import { ExportMode, ResourceExport } from './ResourceExport';
const selector = e2eSelectors.pages.ExportDashboardDrawer.ExportAsJson;
export class ExportAsJson extends ShareExportTab {
static Component = ExportAsJsonRenderer;
export class ExportAsCode extends ShareExportTab {
static Component = ExportAsCodeRenderer;
public getTabLabel(): string {
return t('export.json.title', 'Export dashboard JSON');
return t('export.json.title', 'Export dashboard');
}
}
function ExportAsJsonRenderer({ model }: SceneComponentProps<ExportAsJson>) {
function ExportAsCodeRenderer({ model }: SceneComponentProps<ExportAsCode>) {
const styles = useStyles2(getStyles);
const { isSharingExternally } = model.useState();
const { isSharingExternally, isViewingYAML, exportMode } = model.useState();
const dashboardJson = useAsync(async () => {
const json = await model.getExportableDashboardJson();
return json;
}, [isSharingExternally]);
}, [isSharingExternally, exportMode]);
const stringifiedDashboardJson = JSON.stringify(dashboardJson.value?.json, null, 2);
const hasLibraryPanels = dashboardJson.value?.hasLibraryPanels;
const isV2Dashboard = dashboardJson.value?.json && 'elements' in dashboardJson.value.json;
const showV2LibPanelAlert = isV2Dashboard && isSharingExternally && hasLibraryPanels;
const stringifiedDashboardYAML = yaml.dump(dashboardJson.value?.json, {
skipInvalid: true,
});
const stringifiedDashboard = isViewingYAML ? stringifiedDashboardYAML : stringifiedDashboardJson;
const onClickDownload = async () => {
await model.onSaveAsFile();
@@ -61,50 +56,41 @@ function ExportAsJsonRenderer({ model }: SceneComponentProps<ExportAsJson>) {
<div data-testid={selector.container} className={styles.container}>
<p>
<Trans i18nKey="export.json.info-text">
Copy or download a JSON file containing the JSON of your dashboard
Copy or download a file containing the definition of your dashboard
</Trans>
</p>
<Stack gap={2} direction="column">
<Stack gap={1}>
{config.featureToggles.kubernetesDashboards ? (
<ResourceExport
dashboardJson={dashboardJson}
isSharingExternally={isSharingExternally ?? false}
exportMode={exportMode ?? ExportMode.Classic}
isViewingYAML={isViewingYAML ?? false}
onExportModeChange={model.onExportModeChange}
onShareExternallyChange={model.onShareExternallyChange}
onViewYAML={model.onViewYAML}
/>
) : (
<Stack gap={1} alignItems="start">
<Switch
label={switchExportLabel}
data-testid={selector.exportExternallyToggle}
id="export-externally-toggle"
value={isSharingExternally}
value={Boolean(isSharingExternally)}
onChange={model.onShareExternallyChange}
/>
<Label>{switchExportLabel}</Label>
</Stack>
)}
{showV2LibPanelAlert && (
<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'
)}
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>
.
</Trans>
</Alert>
)}
</Stack>
<div className={styles.codeEditorBox}>
<AutoSizer data-testid={selector.codeEditor}>
{({ width, height }) => {
if (stringifiedDashboardJson) {
if (stringifiedDashboard) {
return (
<CodeEditor
value={stringifiedDashboardJson}
language="json"
value={stringifiedDashboard}
language={isViewingYAML ? 'yaml' : 'json'}
showLineNumbers={true}
showMiniMap={false}
height={height}
@@ -133,7 +119,7 @@ function ExportAsJsonRenderer({ model }: SceneComponentProps<ExportAsJson>) {
variant="secondary"
icon="copy"
disabled={dashboardJson.loading}
getText={() => stringifiedDashboardJson ?? ''}
getText={() => stringifiedDashboard ?? ''}
onClipboardCopy={() => {
DashboardInteractions.exportCopyJsonClicked();
}}
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { locationService } from '@grafana/runtime';
import { config, locationService } from '@grafana/runtime';
import { IconName, Menu } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
@@ -37,11 +37,15 @@ export default function ExportMenu({ dashboard }: { dashboard: DashboardScene })
customShareDrawerItem.forEach((d) => menuItems.push(d));
const label = config.featureToggles.kubernetesDashboards
? t('dashboard.toolbar.new.export.tooltip.as-code', 'Export as code')
: t('share-dashboard.menu.export-json-title', 'Export as JSON');
menuItems.push({
shareId: shareDashboardType.export,
testId: newExportButtonSelector.exportAsJson,
icon: 'arrow',
label: t('share-dashboard.menu.export-json-title', 'Export as JSON'),
label,
renderCondition: true,
onClick: () => onMenuItemClick(shareDashboardType.export),
});
@@ -0,0 +1,113 @@
import { AsyncState } from 'react-use/lib/useAsync';
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 { t, Trans } from 'app/core/internationalization';
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { ExportableResource } from '../ShareExportTab';
export enum ExportMode {
Classic = 'classic',
V1Resource = 'v1-resource',
V2Resource = 'v2-resource',
}
interface Props {
dashboardJson: AsyncState<{
json: Dashboard | DashboardJson | DashboardV2Spec | ExportableResource | { error: unknown };
hasLibraryPanels?: boolean;
initialSaveModelVersion: 'v1' | 'v2';
}>;
isSharingExternally: boolean;
exportMode: ExportMode;
isViewingYAML: boolean;
onExportModeChange: (mode: ExportMode) => void;
onShareExternallyChange: () => void;
onViewYAML: () => void;
}
export function ResourceExport({
dashboardJson,
isSharingExternally,
exportMode,
isViewingYAML,
onExportModeChange,
onShareExternallyChange,
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 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: 'Classic', value: ExportMode.Classic },
{ label: 'V1 Resource', value: ExportMode.V1Resource },
{ label: 'V2 Resource', value: ExportMode.V2Resource },
]}
value={exportMode}
onChange={(value) => onExportModeChange(value)}
/>
</Stack>
)}
{exportMode !== ExportMode.Classic && (
<Stack gap={1} alignItems="center">
<Label>{switchExportFormatLabel}</Label>
<RadioButtonGroup
options={[
{ label: 'JSON', value: 'json' },
{ label: 'YAML', value: 'yaml' },
]}
value={isViewingYAML ? 'yaml' : 'json'}
onChange={onViewYAML}
/>
</Stack>
)}
{(isV2Dashboard || exportMode === ExportMode.Classic) && (
<Stack gap={1} alignItems="start">
<Label>{switchExportLabel}</Label>
<Switch label={switchExportLabel} value={isSharingExternally} onChange={onShareExternallyChange} />
</Stack>
)}
</Stack>
{showV2LibPanelAlert && (
<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'
)}
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>
.
</Trans>
</Alert>
)}
</Stack>
);
}
@@ -5,7 +5,7 @@ import { Drawer } from '@grafana/ui';
import { shareDashboardType } from '../../../dashboard/components/ShareModal/utils';
import { DashboardScene } from '../../scene/DashboardScene';
import { getDashboardSceneFor } from '../../utils/utils';
import { ExportAsJson } from '../ExportButton/ExportAsJson';
import { ExportAsCode } from '../ExportButton/ExportAsCode';
import { ShareExternally } from '../ShareButton/share-externally/ShareExternally';
import { ShareInternally } from '../ShareButton/share-internally/ShareInternally';
import { ShareSnapshot } from '../ShareButton/share-snapshot/ShareSnapshot';
@@ -90,7 +90,7 @@ function getShareView(
case shareDashboardType.snapshot:
return new ShareSnapshot({ dashboardRef, panelRef, onDismiss });
case shareDashboardType.export:
return new ExportAsJson({ onDismiss });
return new ExportAsCode({ onDismiss });
default:
return new ShareInternally({ onDismiss });
}
@@ -1,24 +1,48 @@
import saveAs from 'file-saver';
import yaml from 'js-yaml';
import { cloneDeep } from 'lodash';
import { useAsync } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer';
import { config } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase } 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 { Alert, Button, ClipboardButton, CodeEditor, Field, Modal, Stack, Switch, TextLink } from '@grafana/ui';
import { Button, ClipboardButton, CodeEditor, Field, Modal, Stack, Switch } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { ObjectMeta } from 'app/features/apiserver/types';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { isDashboardV2Spec } from 'app/features/dashboard/api/utils';
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 { DashboardScene } from '../scene/DashboardScene';
import { makeExportableV1, makeExportableV2 } from '../scene/export/exporters';
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
import { transformSceneToSaveModelSchemaV2 } from '../serialization/transformSceneToSaveModelSchemaV2';
import { getVariablesCompatibility } from '../utils/getVariablesCompatibility';
import { DashboardInteractions } from '../utils/interactions';
import { getDashboardSceneFor } from '../utils/utils';
import { ExportMode, ResourceExport } from './ExportButton/ResourceExport';
import { SceneShareTabState, ShareView } from './types';
export interface ExportableResource {
apiVersion: string;
kind: 'Dashboard';
metadata: DashboardWithAccessInfo<DashboardV2Spec>['metadata'] | Partial<ObjectMeta>;
spec: Dashboard | DashboardModel | DashboardV2Spec | { error: unknown };
// A placeholder for now because as code tooling expects it
status: {};
}
export interface ShareExportTabState extends SceneShareTabState {
isSharingExternally?: boolean;
isViewingJSON?: boolean;
isViewingYAML?: boolean;
exportMode?: ExportMode;
}
export class ShareExportTab extends SceneObjectBase<ShareExportTabState> implements ShareView {
@@ -27,9 +51,10 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
constructor(state: Omit<ShareExportTabState, 'panelRef'>) {
super({
...state,
isSharingExternally: false,
isViewingJSON: false,
...state,
exportMode: config.featureToggles.kubernetesDashboards ? ExportMode.Classic : undefined,
});
}
@@ -43,46 +68,140 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
});
};
public onExportModeChange = (exportMode: ExportMode) => {
this.setState({
exportMode,
});
if (exportMode === ExportMode.Classic) {
this.setState({
isViewingYAML: false,
});
}
};
public onViewJSON = () => {
this.setState({
isViewingJSON: !this.state.isViewingJSON,
});
};
public onViewYAML = () => {
this.setState({
isViewingYAML: !this.state.isViewingYAML,
});
};
public getClipboardText() {
return;
}
public getExportableDashboardJson = async (): Promise<{
json: Dashboard | DashboardJson | DashboardV2Spec | { error: unknown };
json: Dashboard | DashboardJson | DashboardV2Spec | ExportableResource | { error: unknown };
hasLibraryPanels?: boolean;
initialSaveModelVersion: 'v1' | 'v2';
}> => {
const { isSharingExternally } = this.state;
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') {
this.setState({
exportMode: ExportMode.V2Resource,
});
if (isDashboardV2Spec(origDashboard)) {
return {
json: exportable,
json: {
apiVersion: scene.serializer.apiVersion ?? '',
kind: 'Dashboard',
metadata,
spec: exportable,
status: {},
},
initialSaveModelVersion,
hasLibraryPanels: Object.values(origDashboard.elements).some((element) => element.kind === 'LibraryPanel'),
};
}
if (exportMode === ExportMode.V1Resource) {
const spec = transformSceneToSaveModel(scene);
return {
json: {
apiVersion: scene.serializer.apiVersion ?? '',
kind: 'Dashboard',
metadata,
spec,
status: {},
},
initialSaveModelVersion,
hasLibraryPanels: undefined,
};
}
if (exportMode === ExportMode.V2Resource) {
const spec = transformSceneToSaveModelSchemaV2(scene);
const specCopy = JSON.parse(JSON.stringify(spec));
const statelessSpec = await makeExportableV2(specCopy);
const exportableV2 = isSharingExternally ? statelessSpec : spec;
return {
json: {
// Forcing V2 version here because in this case we have v1 serializer
apiVersion: `${K8S_V2_DASHBOARD_API_CONFIG.group}/${K8S_V2_DASHBOARD_API_CONFIG.version}`,
kind: 'Dashboard',
metadata,
spec: exportableV2,
status: {},
},
initialSaveModelVersion,
};
}
// 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: undefined,
initialSaveModelVersion,
};
}
// legacy mode or classic mode when dashboardNewLayouts is disabled
return {
json: exportable,
hasLibraryPanels: undefined,
initialSaveModelVersion,
};
};
public onSaveAsFile = async () => {
const dashboard = await this.getExportableDashboardJson();
const dashboardJsonPretty = JSON.stringify(dashboard.json, null, 2);
const { isSharingExternally } = this.state;
const { isSharingExternally, isViewingYAML } = this.state;
const blob = new Blob([dashboardJsonPretty], {
const blob = new Blob([isViewingYAML ? yaml.dump(dashboard.json) : dashboardJsonPretty], {
type: 'application/json;charset=utf-8',
});
@@ -91,26 +210,73 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
if ('title' in dashboard.json && dashboard.json.title) {
title = dashboard.json.title;
}
saveAs(blob, `${title}-${time}.json`);
const extension = isViewingYAML ? 'yaml' : 'json';
saveAs(blob, `${title}-${time}.${extension}`);
DashboardInteractions.exportDownloadJsonClicked({
externally: isSharingExternally,
});
};
}
function getMetadata(
scene: DashboardScene,
isSharingExternally: boolean
): DashboardWithAccessInfo<DashboardV2Spec>['metadata'] | Partial<ObjectMeta> {
let result: Partial<ObjectMeta> = {};
if (scene.serializer.metadata) {
if ('k8s' in scene.serializer.metadata) {
result = scene.serializer.metadata.k8s ? cloneDeep(scene.serializer.metadata.k8s) : {};
} else if ('annotations' in scene.serializer.metadata) {
result = cloneDeep(scene.serializer.metadata);
}
}
if ('managedFields' in result) {
delete result['managedFields'];
}
if (isSharingExternally) {
// Remove fields that are not needed for sharing externally
if ('uid' in result) {
delete result['uid'];
}
delete result['resourceVersion'];
delete result['namespace'];
// iterate over labels and delete all keys that start with grafana.app/
for (const key in result['labels']) {
if (key.startsWith('grafana.app/')) {
// @ts-expect-error
delete result['labels'][key];
}
}
// iterate over annotations and delete all keys that start with grafana.app/
for (const key in result['annotations']) {
if (key.startsWith('grafana.app/')) {
// @ts-expect-error
delete result['annotations'][key];
}
}
}
return result;
}
function ShareExportTabRenderer({ model }: SceneComponentProps<ShareExportTab>) {
const { isSharingExternally, isViewingJSON, modalRef } = model.useState();
const { isSharingExternally, isViewingJSON, modalRef, exportMode, isViewingYAML } = model.useState();
const dashboardJson = useAsync(async () => {
const json = await model.getExportableDashboardJson();
return json;
}, [isViewingJSON, isSharingExternally]);
}, [isViewingJSON, isSharingExternally, exportMode]);
const stringifiedDashboardJson = JSON.stringify(dashboardJson.value?.json, null, 2);
const hasLibraryPanels = dashboardJson.value?.hasLibraryPanels;
const isV2Dashboard = dashboardJson.value?.json && 'elements' in dashboardJson.value.json;
const showV2LibPanelAlert = isV2Dashboard && isSharingExternally && hasLibraryPanels;
const stringifiedDashboardYAML = yaml.dump(dashboardJson.value?.json, {
skipInvalid: true,
});
const stringifiedDashboard = isViewingYAML ? stringifiedDashboardYAML : stringifiedDashboardJson;
const exportExternallyTranslation = t('share-modal.export.share-externally-label', `Export for sharing externally`);
@@ -121,35 +287,27 @@ function ShareExportTabRenderer({ model }: SceneComponentProps<ShareExportTab>)
<p>
<Trans i18nKey="share-modal.export.info-text">Export this dashboard.</Trans>
</p>
<Stack gap={2} direction="column">
<Field label={exportExternallyTranslation}>
<Switch
id="share-externally-toggle"
value={isSharingExternally}
onChange={model.onShareExternallyChange}
/>
</Field>
{showV2LibPanelAlert && (
<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'
)}
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>
.
</Trans>
</Alert>
)}
</Stack>
{config.featureToggles.kubernetesDashboards ? (
<ResourceExport
dashboardJson={dashboardJson}
isSharingExternally={isSharingExternally ?? false}
exportMode={exportMode ?? ExportMode.Classic}
isViewingYAML={isViewingYAML ?? false}
onExportModeChange={model.onExportModeChange}
onShareExternallyChange={model.onShareExternallyChange}
onViewYAML={model.onViewYAML}
/>
) : (
<Stack gap={2} direction="column">
<Field label={exportExternallyTranslation}>
<Switch
id="share-externally-toggle"
value={isSharingExternally}
onChange={model.onShareExternallyChange}
/>
</Field>
</Stack>
)}
<Modal.ButtonRow>
<Button
@@ -161,9 +319,15 @@ function ShareExportTabRenderer({ model }: SceneComponentProps<ShareExportTab>)
>
<Trans i18nKey="share-modal.export.cancel-button">Cancel</Trans>
</Button>
<Button variant="secondary" icon="brackets-curly" onClick={model.onViewJSON}>
<Trans i18nKey="share-modal.export.view-button">View JSON</Trans>
</Button>
{isViewingYAML ? (
<Button variant="secondary" icon="brackets-curly" onClick={model.onViewJSON}>
<Trans i18nKey="share-modal.export.view-button-yaml">View YAML</Trans>
</Button>
) : (
<Button variant="secondary" icon="brackets-curly" onClick={model.onViewJSON}>
<Trans i18nKey="share-modal.export.view-button">View JSON</Trans>
</Button>
)}
<Button variant="primary" icon="save" onClick={() => model.onSaveAsFile()}>
<Trans i18nKey="share-modal.export.save-button">Save to file</Trans>
</Button>
@@ -177,9 +341,9 @@ function ShareExportTabRenderer({ model }: SceneComponentProps<ShareExportTab>)
if (dashboardJson.value) {
return (
<CodeEditor
value={stringifiedDashboardJson}
value={stringifiedDashboard}
showLineNumbers={true}
language="json"
language={isViewingYAML ? 'yaml' : 'json'}
showMiniMap={false}
height="500px"
width={width}
@@ -208,7 +372,7 @@ function ShareExportTabRenderer({ model }: SceneComponentProps<ShareExportTab>)
variant="secondary"
icon="copy"
disabled={dashboardJson.loading}
getText={() => stringifiedDashboardJson ?? ''}
getText={() => stringifiedDashboard ?? ''}
>
<Trans i18nKey="share-modal.view-json.copy-button">Copy to Clipboard</Trans>
</ClipboardButton>
+7 -5
View File
@@ -21,15 +21,17 @@ import { SaveDashboardCommand } from '../components/SaveDashboard/types';
import { DashboardAPI, DashboardVersionError, DashboardWithAccessInfo } from './types';
export const K8S_V1_DASHBOARD_API_CONFIG = {
group: 'dashboard.grafana.app',
version: 'v1beta1',
resource: 'dashboards',
};
export class K8sDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> {
private client: ResourceClient<DashboardDataDTO>;
constructor() {
this.client = new ScopedResourceClient<DashboardDataDTO>({
group: 'dashboard.grafana.app',
version: 'v1beta1',
resource: 'dashboards',
});
this.client = new ScopedResourceClient<DashboardDataDTO>(K8S_V1_DASHBOARD_API_CONFIG);
}
saveDashboard(options: SaveDashboardCommand<Dashboard>): Promise<SaveDashboardResponseDTO> {
+7 -5
View File
@@ -24,17 +24,19 @@ import { SaveDashboardCommand } from '../components/SaveDashboard/types';
import { DashboardAPI, DashboardVersionError, DashboardWithAccessInfo } from './types';
export const K8S_V2_DASHBOARD_API_CONFIG = {
group: 'dashboard.grafana.app',
version: 'v2alpha1',
resource: 'dashboards',
};
export class K8sDashboardV2API
implements DashboardAPI<DashboardWithAccessInfo<DashboardV2Spec> | DashboardDTO, DashboardV2Spec>
{
private client: ResourceClient<DashboardV2Spec>;
constructor() {
this.client = new ScopedResourceClient<DashboardV2Spec>({
group: 'dashboard.grafana.app',
version: 'v2alpha1',
resource: 'dashboards',
});
this.client = new ScopedResourceClient<DashboardV2Spec>(K8S_V2_DASHBOARD_API_CONFIG);
}
async getDashboardDTO(uid: string) {
+11 -4
View File
@@ -3671,7 +3671,10 @@
"export": {
"arrow": "Export",
"title": "Export",
"tooltip": "Export as JSON"
"tooltip": {
"as-code": "Export as code",
"json": "Export as JSON"
}
},
"mark-favorite": "Mark as favorite",
"more-save-options": "More save options",
@@ -5109,8 +5112,11 @@
"download-button": "Download file",
"download-successful_toast_message": "Your JSON has been downloaded",
"export-externally-label": "Export the dashboard to use in another instance",
"info-text": "Copy or download a JSON file containing the JSON of your dashboard",
"title": "Export dashboard JSON"
"export-format": "Format",
"export-mode": "Model",
"export-remove-ds-refs": "Remove deployment details",
"info-text": "Copy or download a file containing the definition of your dashboard",
"title": "Export dashboard"
},
"menu": {
"export-as-json-label": "Export",
@@ -8219,7 +8225,8 @@
"loading": "Loading...",
"save-button": "Save to file",
"share-externally-label": "Export for sharing externally",
"view-button": "View JSON"
"view-button": "View JSON",
"view-button-yaml": "View YAML"
},
"library": {
"info": "Create library panel."