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:
co-authored by
Agnès Toulet
Haris Rozajac
Haris Rozajac
parent
ca2ae82e80
commit
1114d33936
@@ -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() {
|
||||
|
||||
+26
-20
@@ -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;
|
||||
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
|
||||
+36
-50
@@ -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>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user