ShareDrawer: Share snapshot (#89195)

This commit is contained in:
Juan Cabanas
2024-06-24 10:26:06 -03:00
committed by GitHub
parent 946545cfc5
commit 399651b9ad
9 changed files with 316 additions and 11 deletions

View File

@@ -26,6 +26,7 @@ describe('ShareMenu', () => {
expect(await screen.findByTestId(selector.shareInternally)).toBeInTheDocument();
expect(await screen.findByTestId(selector.shareExternally)).toBeInTheDocument();
expect(await screen.findByTestId(selector.shareSnapshot)).toBeInTheDocument();
});
it('should no share externally when public dashboard is disabled', async () => {
config.featureToggles.publicDashboards = false;

View File

@@ -4,12 +4,14 @@ import { useAsyncFn } from 'react-use';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { VizPanel } from '@grafana/scenes';
import { Menu } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { isPublicDashboardsEnabled } from '../../../dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
import { DashboardScene } from '../../scene/DashboardScene';
import { ShareDrawer } from '../ShareDrawer/ShareDrawer';
import { ShareExternally } from './share-externally/ShareExternally';
import { ShareSnapshot } from './share-snapshot/ShareSnapshot';
import { buildShareUrl } from './utils';
const newShareButtonSelector = e2eSelectors.pages.Dashboard.DashNav.newShareButton.menu;
@@ -21,30 +23,45 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc
const onShareExternallyClick = () => {
const drawer = new ShareDrawer({
title: 'Share externally',
title: t('share-dashboard.menu.share-externally-title', 'Share externally'),
body: new ShareExternally({}),
});
dashboard.showModal(drawer);
};
const onShareSnapshotClick = () => {
const drawer = new ShareDrawer({
title: t('share-dashboard.menu.share-snapshot-title', 'Share snapshot'),
body: new ShareSnapshot({ dashboardRef: dashboard.getRef() }),
});
dashboard.showModal(drawer);
};
return (
<Menu data-testid={newShareButtonSelector.container}>
<Menu.Item
testId={newShareButtonSelector.shareInternally}
label="Share internally"
description="Copy link"
label={t('share-dashboard.menu.share-internally-title', 'Share internally')}
description={t('share-dashboard.menu.share-internally-description', 'Advanced settings')}
icon="building"
onClick={buildUrl}
/>
{isPublicDashboardsEnabled() && (
<Menu.Item
testId={newShareButtonSelector.shareExternally}
label="Share externally"
label={t('share-dashboard.menu.share-externally-title', 'Share externally')}
icon="share-alt"
onClick={onShareExternallyClick}
/>
)}
<Menu.Item
testId={newShareButtonSelector.shareSnapshot}
label={t('share-dashboard.menu.share-snapshot-title', 'Share snapshot')}
icon="camera"
onClick={onShareSnapshotClick}
/>
</Menu>
);
}

View File

@@ -0,0 +1,102 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import {
Alert,
Button,
Divider,
Field,
RadioButtonGroup,
Spinner,
Stack,
Text,
TextLink,
useStyles2,
} from '@grafana/ui';
import { Input } from '@grafana/ui/src/components/Input/Input';
import { t } from '@grafana/ui/src/utils/i18n';
import { Trans } from 'app/core/internationalization';
import { SnapshotSharingOptions } from '../../../../dashboard/services/SnapshotSrv';
import { getExpireOptions } from '../../ShareSnapshotTab';
const SNAPSHOT_URL = 'https://grafana.com/docs/grafana/latest/dashboards/share-dashboards-panels/#publish-a-snapshot';
interface Props {
isLoading: boolean;
name: string;
selectedExpireOption: SelectableValue<number>;
sharingOptions?: SnapshotSharingOptions;
onCancelClick: () => void;
onCreateClick: (isExternal?: boolean) => void;
onNameChange: (v: string) => void;
onExpireChange: (v: number) => void;
}
export function CreateSnapshot({
name,
onNameChange,
onExpireChange,
selectedExpireOption,
sharingOptions,
onCancelClick,
onCreateClick,
isLoading,
}: Props) {
const styles = useStyles2(getStyles);
return (
<div className={styles.container}>
<Alert severity="info" title={''}>
<Stack justifyContent="space-between" gap={2} alignItems="center">
<Text>
<Trans i18nKey="snapshot.share.info-alert">
A Grafana dashboard snapshot publicly shares a dashboard while removing sensitive data such as queries and
panel links, leaving only visible metrics and series names. Anyone with the link can access the snapshot.
</Trans>
</Text>
<Button variant="secondary" onClick={() => window.open(SNAPSHOT_URL, '_blank')} type="button">
<Trans i18nKey="snapshot.share.learn-more-button">Learn more</Trans>
</Button>
</Stack>
</Alert>
<Field label={t('snapshot.share.name-label', 'Snapshot name*')}>
<Input id="snapshot-name-input" defaultValue={name} onBlur={(e) => onNameChange(e.target.value)} />
</Field>
<Field label={t('snapshot.share.expiration-label', 'Expires in')}>
<RadioButtonGroup<number>
id="expire-select-input"
options={getExpireOptions()}
value={selectedExpireOption?.value}
onChange={onExpireChange}
/>
</Field>
<Divider />
<Stack justifyContent="space-between" direction={{ xs: 'column', xl: 'row' }}>
<Stack gap={1} flex={1} direction={{ xs: 'column', sm: 'row' }}>
<Button variant="primary" disabled={isLoading} onClick={() => onCreateClick()}>
<Trans i18nKey="snapshot.share.local-button">Publish snapshot</Trans>
</Button>
{sharingOptions?.externalEnabled && (
<Button variant="secondary" disabled={isLoading} onClick={() => onCreateClick(true)}>
{sharingOptions?.externalSnapshotName}
</Button>
)}
<Button variant="secondary" fill="outline" onClick={onCancelClick}>
<Trans i18nKey="snapshot.share.cancel-button">Cancel</Trans>
</Button>
{isLoading && <Spinner />}
</Stack>
<TextLink icon="external-link-alt" href="/dashboard/snapshots">
{t('snapshot.share.view-all-button', 'View all snapshots')}
</TextLink>
</Stack>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
paddingBottom: theme.spacing(2),
}),
});

View File

@@ -0,0 +1,91 @@
import React, { useState } from 'react';
import useAsyncFn from 'react-use/lib/useAsyncFn';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { SceneComponentProps } from '@grafana/scenes';
import { Alert } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
import { ShareDrawerConfirmAction } from '../../ShareDrawer/ShareDrawerConfirmAction';
import { ShareSnapshotTab } from '../../ShareSnapshotTab';
import { CreateSnapshot } from './CreateSnapshot';
import { SnapshotActions } from './SnapshotActions';
const selectors = e2eSelectors.pages.ShareDashboardDrawer.ShareSnapshot;
export class ShareSnapshot extends ShareSnapshotTab {
static Component = ShareSnapshotRenderer;
}
function ShareSnapshotRenderer({ model }: SceneComponentProps<ShareSnapshot>) {
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
const [showDeletedAlert, setShowDeletedAlert] = useState(false);
const [step, setStep] = useState(1);
const { snapshotName, snapshotSharingOptions, selectedExpireOption, dashboardRef } = model.useState();
const [snapshotResult, createSnapshot] = useAsyncFn(async (external = false) => {
const response = await model.onSnapshotCreate(external);
setStep(2);
return response;
});
const [deleteSnapshotResult, deleteSnapshot] = useAsyncFn(async (url: string) => {
const response = await model.onSnapshotDelete(url);
setStep(1);
setShowDeleteConfirmation(false);
setShowDeletedAlert(true);
return response;
});
const onCancelClick = () => {
dashboardRef.resolve().closeModal();
};
if (showDeleteConfirmation) {
return (
<ShareDrawerConfirmAction
title={t('snapshot.share.delete-title', 'Delete snapshot')}
confirmButtonLabel={t('snapshot.share.delete-button', 'Delete snapshot')}
onConfirm={() => deleteSnapshot(snapshotResult.value?.deleteUrl!)}
onDismiss={() => setShowDeleteConfirmation(false)}
description={t('snapshot.share.delete-description', 'Are you sure you want to delete this snapshot?')}
isActionLoading={deleteSnapshotResult.loading}
/>
);
}
return (
<div data-testid={selectors.container}>
{step === 1 && (
<>
{showDeletedAlert && (
<Alert severity="info" title={''} onRemove={() => setShowDeletedAlert(false)}>
<Trans i18nKey="snapshot.share.deleted-alert">
Your snapshot has been deleted. It might take up to an hour before the snapshot is cleared from any CDN
caches.
</Trans>
</Alert>
)}
<CreateSnapshot
name={snapshotName ?? ''}
selectedExpireOption={selectedExpireOption}
sharingOptions={snapshotSharingOptions}
onNameChange={model.onSnasphotNameChange}
onCancelClick={onCancelClick}
onExpireChange={model.onExpireChange}
onCreateClick={createSnapshot}
isLoading={snapshotResult.loading}
/>
</>
)}
{step === 2 && (
<SnapshotActions
url={snapshotResult.value!.url}
onDeleteClick={() => setShowDeleteConfirmation(true)}
onNewSnapshotClick={() => setStep(1)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { Button, ClipboardButton, Stack } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
interface Props {
url: string;
onDeleteClick: () => void;
onNewSnapshotClick: () => void;
}
export const SnapshotActions = ({ url, onDeleteClick, onNewSnapshotClick }: Props) => {
return (
<Stack justifyContent="flex-start" gap={1} direction={{ xs: 'column', sm: 'row' }}>
<ClipboardButton icon="link" variant="primary" fill="outline" getText={() => url}>
<Trans i18nKey="snapshot.share.copy-link-button">Copy link</Trans>
</ClipboardButton>
<Button icon="trash-alt" variant="destructive" fill="outline" onClick={onDeleteClick}>
<Trans i18nKey="snapshot.share.delete-button">Delete snapshot</Trans>
</Button>
<Button variant="secondary" fill="solid" onClick={onNewSnapshotClick}>
<Trans i18nKey="snapshot.share.new-snapshot-button">New snapshot</Trans>
</Button>
</Stack>
);
};

View File

@@ -6,9 +6,12 @@ import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { getBackendSrv } from '@grafana/runtime';
import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectRef, VizPanel } from '@grafana/scenes';
import { Button, ClipboardButton, Field, Input, Modal, RadioButtonGroup, Stack } from '@grafana/ui';
import { notifyApp } from 'app/core/actions';
import { createSuccessNotification } from 'app/core/copy/appNotification';
import { t, Trans } from 'app/core/internationalization';
import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
import { getDashboardSnapshotSrv, SnapshotSharingOptions } from 'app/features/dashboard/services/SnapshotSrv';
import { dispatch } from 'app/store/store';
import { transformSceneToSaveModel, trimDashboardForSnapshot } from '../serialization/transformSceneToSaveModel';
import { DashboardInteractions } from '../utils/interactions';
@@ -17,7 +20,7 @@ import { SceneShareTabState } from './types';
const selectors = e2eSelectors.pages.ShareDashboardModal.SnapshotScene;
const getExpireOptions = () => {
export const getExpireOptions = () => {
const DEFAULT_EXPIRE_OPTION: SelectableValue<number> = {
label: t('share-modal.snapshot.expire-week', '1 Week'),
value: 60 * 60 * 24 * 7,
@@ -46,17 +49,17 @@ const getDefaultExpireOption = () => {
export interface ShareSnapshotTabState extends SceneShareTabState {
panelRef?: SceneObjectRef<VizPanel>;
snapshotName?: string;
selectedExpireOption?: SelectableValue<number>;
snapshotName: string;
selectedExpireOption: SelectableValue<number>;
snapshotSharingOptions?: SnapshotSharingOptions;
}
export class ShareSnapshotTab extends SceneObjectBase<ShareSnapshotTabState> {
public tabId = shareDashboardType.snapshot;
static Component = ShareSnapshoTabRenderer;
static Component = ShareSnapshotTabRenderer;
public constructor(state: ShareSnapshotTabState) {
public constructor(state: Omit<ShareSnapshotTabState, 'snapshotName' | 'selectedExpireOption'>) {
super({
...state,
snapshotName: state.dashboardRef.resolve().state.title,
@@ -124,7 +127,11 @@ export class ShareSnapshotTab extends SceneObjectBase<ShareSnapshotTabState> {
};
try {
return await getDashboardSnapshotSrv().create(cmdData);
const response = await getDashboardSnapshotSrv().create(cmdData);
dispatch(
notifyApp(createSuccessNotification(t('snapshot.share.success-creation', 'Your snapshot has been created')))
);
return response;
} finally {
if (external) {
DashboardInteractions.publishSnapshotClicked({
@@ -139,9 +146,17 @@ export class ShareSnapshotTab extends SceneObjectBase<ShareSnapshotTabState> {
}
}
};
public onSnapshotDelete = async (url: string) => {
const response = await getBackendSrv().get(url);
dispatch(
notifyApp(createSuccessNotification(t('snapshot.share.success-delete', 'Your snapshot has been deleted')))
);
return response;
};
}
function ShareSnapshoTabRenderer({ model }: SceneComponentProps<ShareSnapshotTab>) {
function ShareSnapshotTabRenderer({ model }: SceneComponentProps<ShareSnapshotTab>) {
const { snapshotName, selectedExpireOption, modalRef, snapshotSharingOptions } = model.useState();
const [snapshotResult, createSnapshot] = useAsyncFn(async (external = false) => {