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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 316 additions and 11 deletions

View File

@ -66,6 +66,7 @@ export const Pages = {
container: 'data-testid new share button menu',
shareInternally: 'data-testid new share button share internally',
shareExternally: 'data-testid new share button share externally',
shareSnapshot: 'data-testid new share button share snapshot',
},
},
playlistControls: {
@ -287,6 +288,9 @@ export const Pages = {
copyUrlButton: 'data-testid share externally copy url button',
shareTypeSelect: 'data-testid share externally share type select',
},
ShareSnapshot: {
container: 'data-testid share snapshot drawer container',
},
},
PublicDashboard: {
page: 'public-dashboard-page',

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) => {

View File

@ -1695,6 +1695,14 @@
"title": "You haven't created any service accounts yet"
}
},
"share-dashboard": {
"menu": {
"share-externally-title": "Share externally",
"share-internally-description": "Advanced settings",
"share-internally-title": "Share internally",
"share-snapshot-title": "Share snapshot"
}
},
"share-drawer": {
"confirm-action": {
"back-arrow-button": "Back button"
@ -1845,6 +1853,23 @@
},
"external-badge": "External",
"name-column-header": "Name",
"share": {
"cancel-button": "Cancel",
"copy-link-button": "Copy link",
"delete-button": "Delete snapshot",
"delete-description": "Are you sure you want to delete this snapshot?",
"delete-title": "Delete snapshot",
"deleted-alert": "Your snapshot has been deleted. It might take up to an hour before the snapshot is cleared from any CDN caches.",
"expiration-label": "Expires in",
"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.",
"learn-more-button": "Learn more",
"local-button": "Publish snapshot",
"name-label": "Snapshot name*",
"new-snapshot-button": "New snapshot",
"success-creation": "Your snapshot has been created",
"success-delete": "Your snapshot has been deleted",
"view-all-button": "View all snapshots"
},
"url-column-header": "Snapshot url",
"view-button": "View"
},

View File

@ -1695,6 +1695,14 @@
"title": "Ÿőū ĥävęʼn'ŧ čřęäŧęđ äʼny şęřvįčę äččőūʼnŧş yęŧ"
}
},
"share-dashboard": {
"menu": {
"share-externally-title": "Ŝĥäřę ęχŧęřʼnäľľy",
"share-internally-description": "Åđväʼnčęđ şęŧŧįʼnģş",
"share-internally-title": "Ŝĥäřę įʼnŧęřʼnäľľy",
"share-snapshot-title": "Ŝĥäřę şʼnäpşĥőŧ"
}
},
"share-drawer": {
"confirm-action": {
"back-arrow-button": "ßäčĸ þūŧŧőʼn"
@ -1845,6 +1853,23 @@
},
"external-badge": "Ēχŧęřʼnäľ",
"name-column-header": "Ńämę",
"share": {
"cancel-button": "Cäʼnčęľ",
"copy-link-button": "Cőpy ľįʼnĸ",
"delete-button": "Đęľęŧę şʼnäpşĥőŧ",
"delete-description": "Åřę yőū şūřę yőū ŵäʼnŧ ŧő đęľęŧę ŧĥįş şʼnäpşĥőŧ?",
"delete-title": "Đęľęŧę şʼnäpşĥőŧ",
"deleted-alert": "Ÿőūř şʼnäpşĥőŧ ĥäş þęęʼn đęľęŧęđ. Ĩŧ mįģĥŧ ŧäĸę ūp ŧő äʼn ĥőūř þęƒőřę ŧĥę şʼnäpşĥőŧ įş čľęäřęđ ƒřőm äʼny CĐŃ čäčĥęş.",
"expiration-label": "Ēχpįřęş įʼn",
"info-alert": "Å Ğřäƒäʼnä đäşĥþőäřđ şʼnäpşĥőŧ pūþľįčľy şĥäřęş ä đäşĥþőäřđ ŵĥįľę řęmővįʼnģ şęʼnşįŧįvę đäŧä şūčĥ äş qūęřįęş äʼnđ päʼnęľ ľįʼnĸş, ľęävįʼnģ őʼnľy vįşįþľę męŧřįčş äʼnđ şęřįęş ʼnämęş. Åʼnyőʼnę ŵįŧĥ ŧĥę ľįʼnĸ čäʼn äččęşş ŧĥę şʼnäpşĥőŧ.",
"learn-more-button": "Ŀęäřʼn mőřę",
"local-button": "Pūþľįşĥ şʼnäpşĥőŧ",
"name-label": "Ŝʼnäpşĥőŧ ʼnämę*",
"new-snapshot-button": "Ńęŵ şʼnäpşĥőŧ",
"success-creation": "Ÿőūř şʼnäpşĥőŧ ĥäş þęęʼn čřęäŧęđ",
"success-delete": "Ÿőūř şʼnäpşĥőŧ ĥäş þęęʼn đęľęŧęđ",
"view-all-button": "Vįęŵ äľľ şʼnäpşĥőŧş"
},
"url-column-header": "Ŝʼnäpşĥőŧ ūřľ",
"view-button": "Vįęŵ"
},