mirror of https://github.com/grafana/grafana
Sharing: Export dashboard as image (#104207)
Co-authored-by: AgnesToulet <35176601+AgnesToulet@users.noreply.github.com>pull/108111/merge^2
parent
a977aa9d03
commit
b691b3288d
|
@ -0,0 +1,179 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { saveAs } from 'file-saver'; |
||||
import { useEffect } from 'react'; |
||||
import { useAsyncFn } from 'react-use'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { Trans, t } from '@grafana/i18n'; |
||||
import { config } from '@grafana/runtime'; |
||||
import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes'; |
||||
import { Alert, Button, useStyles2 } from '@grafana/ui'; |
||||
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions'; |
||||
import { getDashboardSceneFor } from 'app/features/dashboard-scene/utils/utils'; |
||||
|
||||
import { ImagePreview } from '../components/ImagePreview'; |
||||
import { SceneShareTabState, ShareView } from '../types'; |
||||
|
||||
import { generateDashboardImage } from './utils'; |
||||
|
||||
export class ExportAsImage extends SceneObjectBase<SceneShareTabState> implements ShareView { |
||||
static Component = ExportAsImageRenderer; |
||||
|
||||
public getTabLabel() { |
||||
return t('share-modal.image.title', 'Export as image'); |
||||
} |
||||
} |
||||
|
||||
function ExportAsImageRenderer({ model }: SceneComponentProps<ExportAsImage>) { |
||||
const { onDismiss } = model.useState(); |
||||
const dashboard = getDashboardSceneFor(model); |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const [{ loading: isLoading, value: imageBlob, error }, onExport] = useAsyncFn(async () => { |
||||
try { |
||||
const result = await generateDashboardImage({ |
||||
dashboard, |
||||
scale: config.rendererDefaultImageScale || 1, |
||||
}); |
||||
|
||||
if (result.error) { |
||||
throw new Error(result.error); |
||||
} |
||||
|
||||
DashboardInteractions.generateDashboardImageClicked({ |
||||
scale: config.rendererDefaultImageScale || 1, |
||||
shareResource: 'dashboard', |
||||
success: true, |
||||
}); |
||||
|
||||
return result.blob; |
||||
} catch (error) { |
||||
console.error('Error exporting image:', error); |
||||
DashboardInteractions.generateDashboardImageClicked({ |
||||
scale: config.rendererDefaultImageScale || 1, |
||||
shareResource: 'dashboard', |
||||
success: false, |
||||
error: error instanceof Error ? error.message : 'Failed to generate image', |
||||
}); |
||||
throw error; // Re-throw to let useAsyncFn handle the error state
|
||||
} |
||||
}, [dashboard]); |
||||
|
||||
// Clean up object URLs when component unmounts
|
||||
useEffect(() => { |
||||
return () => { |
||||
if (imageBlob) { |
||||
URL.revokeObjectURL(URL.createObjectURL(imageBlob)); |
||||
} |
||||
}; |
||||
}, [imageBlob]); |
||||
|
||||
const onDownload = () => { |
||||
if (!imageBlob) { |
||||
return; |
||||
} |
||||
|
||||
const time = new Date().getTime(); |
||||
const name = dashboard.state.title; |
||||
saveAs(imageBlob, `${name}-${time}.png`); |
||||
|
||||
DashboardInteractions.downloadDashboardImageClicked({ |
||||
fileName: `${name}-${time}.png`, |
||||
shareResource: 'dashboard', |
||||
}); |
||||
}; |
||||
|
||||
if (!config.rendererAvailable) { |
||||
return <RendererAlert />; |
||||
} |
||||
|
||||
return ( |
||||
<main> |
||||
<p className={styles.info}> |
||||
<Trans i18nKey="share-modal.image.info-text">Save this dashboard as an image</Trans> |
||||
</p> |
||||
|
||||
<div |
||||
className={styles.buttonRow} |
||||
role="group" |
||||
aria-label={t('share-modal.image.actions', 'Image export actions')} |
||||
> |
||||
{!imageBlob ? ( |
||||
<Button |
||||
variant="primary" |
||||
onClick={onExport} |
||||
disabled={isLoading} |
||||
icon="gf-layout-simple" |
||||
aria-describedby={isLoading ? 'generate-status' : undefined} |
||||
> |
||||
<Trans i18nKey="share-modal.image.generate-button">Generate image</Trans> |
||||
</Button> |
||||
) : ( |
||||
<Button variant="primary" onClick={onDownload} icon="download-alt"> |
||||
<Trans i18nKey="share-modal.image.download-button">Download image</Trans> |
||||
</Button> |
||||
)} |
||||
<Button variant="secondary" onClick={onDismiss} fill="outline"> |
||||
<Trans i18nKey="share-modal.image.cancel-button">Cancel</Trans> |
||||
</Button> |
||||
</div> |
||||
|
||||
{isLoading && ( |
||||
<div id="generate-status" aria-live="polite" className="sr-only"> |
||||
<Trans i18nKey="share-modal.image.generating">Generating image...</Trans> |
||||
</div> |
||||
)} |
||||
|
||||
<ImagePreview |
||||
imageBlob={imageBlob || null} |
||||
isLoading={isLoading} |
||||
error={ |
||||
error |
||||
? { |
||||
title: t('share-modal.image.error-title', 'Failed to generate image'), |
||||
message: error instanceof Error ? error.message : 'Failed to generate image', |
||||
} |
||||
: null |
||||
} |
||||
title={dashboard.state.title} |
||||
/> |
||||
</main> |
||||
); |
||||
} |
||||
|
||||
function RendererAlert() { |
||||
if (config.rendererAvailable) { |
||||
return null; |
||||
} |
||||
|
||||
return ( |
||||
<Alert severity="info" title={t('share-modal.link.render-alert', 'Image renderer plugin not installed')}> |
||||
<div>{t('share-modal.link.render-alert', 'Image renderer plugin not installed')}</div> |
||||
<div> |
||||
<Trans i18nKey="share-modal.link.render-instructions"> |
||||
To render an image, you must install the{' '} |
||||
<a |
||||
href="https://grafana.com/grafana/plugins/grafana-image-renderer" |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
className="external-link" |
||||
> |
||||
Grafana image renderer plugin |
||||
</a> |
||||
. Please contact your Grafana administrator to install the plugin. |
||||
</Trans> |
||||
</div> |
||||
</Alert> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
info: css({ |
||||
marginBottom: theme.spacing(2), |
||||
}), |
||||
buttonRow: css({ |
||||
display: 'flex', |
||||
gap: theme.spacing(2), |
||||
marginBottom: theme.spacing(2), |
||||
}), |
||||
}); |
@ -0,0 +1,132 @@ |
||||
import { of } from 'rxjs'; |
||||
|
||||
import { config, getBackendSrv } from '@grafana/runtime'; |
||||
import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl'; |
||||
|
||||
import { DashboardScene } from '../../scene/DashboardScene'; |
||||
|
||||
import { generateDashboardImage } from './utils'; |
||||
|
||||
// Mock the dependencies
|
||||
jest.mock('@grafana/runtime', () => ({ |
||||
config: { |
||||
rendererAvailable: true, |
||||
bootData: { |
||||
user: { |
||||
orgId: 1, |
||||
}, |
||||
}, |
||||
}, |
||||
getBackendSrv: jest.fn(), |
||||
})); |
||||
|
||||
jest.mock('app/features/dashboard-scene/utils/getDashboardUrl', () => ({ |
||||
getDashboardUrl: jest |
||||
.fn() |
||||
.mockImplementation((params: { updateQuery?: Record<string, string | number | boolean> }) => { |
||||
const url = new URL('http://test-url'); |
||||
if (params.updateQuery) { |
||||
Object.entries(params.updateQuery).forEach(([key, value]) => { |
||||
url.searchParams.append(key, String(value)); |
||||
}); |
||||
} |
||||
return url.toString(); |
||||
}), |
||||
})); |
||||
|
||||
describe('Dashboard Export Image Utils', () => { |
||||
describe('generateDashboardImage', () => { |
||||
it('should handle various error scenarios', async () => { |
||||
const testCases = [ |
||||
{ |
||||
setup: () => { |
||||
config.rendererAvailable = false; |
||||
// Reset the mock for this test case
|
||||
(getBackendSrv as jest.Mock).mockReset(); |
||||
}, |
||||
expectedError: 'Image renderer plugin not installed', |
||||
}, |
||||
{ |
||||
setup: () => { |
||||
config.rendererAvailable = true; |
||||
(getBackendSrv as jest.Mock).mockReturnValue({ |
||||
fetch: jest.fn().mockReturnValue(of({ ok: false, status: 500, statusText: 'Server Error' })), |
||||
}); |
||||
}, |
||||
expectedError: 'Failed to generate image: 500 Server Error', |
||||
}, |
||||
{ |
||||
setup: () => { |
||||
config.rendererAvailable = true; |
||||
(getBackendSrv as jest.Mock).mockReturnValue({ |
||||
fetch: jest.fn().mockReturnValue(of({ ok: true, data: 'invalid-data' })), |
||||
}); |
||||
}, |
||||
expectedError: 'Invalid response data format', |
||||
}, |
||||
{ |
||||
setup: () => { |
||||
config.rendererAvailable = true; |
||||
(getBackendSrv as jest.Mock).mockReturnValue({ |
||||
fetch: jest.fn().mockReturnValue(of(Promise.reject(new Error('Network error')))), |
||||
}); |
||||
}, |
||||
expectedError: 'Network error', |
||||
}, |
||||
]; |
||||
|
||||
const dashboard = { |
||||
state: { |
||||
uid: 'test-uid', |
||||
}, |
||||
} as DashboardScene; |
||||
|
||||
for (const testCase of testCases) { |
||||
testCase.setup(); |
||||
const result = await generateDashboardImage({ dashboard }); |
||||
expect(result.error).toBe(testCase.expectedError); |
||||
expect(result.blob).toBeInstanceOf(Blob); |
||||
expect(result.blob.size).toBe(0); |
||||
} |
||||
}); |
||||
|
||||
it('should generate image successfully with custom scale', async () => { |
||||
config.rendererAvailable = true; |
||||
const mockBlob = new Blob(['test'], { type: 'image/png' }); |
||||
const fetchMock = jest.fn().mockReturnValue(of({ ok: true, data: mockBlob })); |
||||
(getBackendSrv as jest.Mock).mockReturnValue({ fetch: fetchMock }); |
||||
|
||||
const dashboard = { |
||||
state: { |
||||
uid: 'test-uid', |
||||
}, |
||||
} as DashboardScene; |
||||
|
||||
const result = await generateDashboardImage({ dashboard, scale: 2 }); |
||||
|
||||
expect(result.error).toBeUndefined(); |
||||
expect(result.blob).toBe(mockBlob); |
||||
expect(fetchMock).toHaveBeenCalledWith( |
||||
expect.objectContaining({ |
||||
url: expect.stringMatching(/height=-1.*scale=2.*kiosk=true.*hideNav=true.*fullPageImage=true/), |
||||
responseType: 'blob', |
||||
}) |
||||
); |
||||
expect(getDashboardUrl).toHaveBeenCalledWith({ |
||||
uid: 'test-uid', |
||||
currentQueryParams: '', |
||||
render: true, |
||||
absolute: true, |
||||
updateQuery: { |
||||
height: -1, |
||||
width: 1000, |
||||
scale: 2, |
||||
kiosk: true, |
||||
hideNav: true, |
||||
orgId: '1', |
||||
fullPageImage: true, |
||||
}, |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,89 @@ |
||||
import { lastValueFrom } from 'rxjs'; |
||||
|
||||
import { config, getBackendSrv } from '@grafana/runtime'; |
||||
import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl'; |
||||
|
||||
import { DashboardScene } from '../../scene/DashboardScene'; |
||||
|
||||
/** |
||||
* Options for generating a dashboard image |
||||
*/ |
||||
export interface ImageGenerationOptions { |
||||
dashboard: DashboardScene; |
||||
scale?: number; |
||||
} |
||||
|
||||
/** |
||||
* Result of image generation attempt |
||||
*/ |
||||
export interface ImageGenerationResult { |
||||
blob: Blob; |
||||
error?: string; |
||||
} |
||||
|
||||
/** |
||||
* Generates a dashboard image using the renderer service |
||||
* @param options The options for image generation |
||||
* @returns A promise that resolves to the image generation result |
||||
*/ |
||||
export async function generateDashboardImage({ |
||||
dashboard, |
||||
scale = config.rendererDefaultImageScale || 1, |
||||
}: ImageGenerationOptions): Promise<ImageGenerationResult> { |
||||
try { |
||||
// Check if renderer plugin is available
|
||||
if (!config.rendererAvailable) { |
||||
return { |
||||
blob: new Blob(), |
||||
error: 'Image renderer plugin not installed', |
||||
}; |
||||
} |
||||
|
||||
const imageUrl = getDashboardUrl({ |
||||
uid: dashboard.state.uid, |
||||
currentQueryParams: window.location.search, |
||||
render: true, |
||||
absolute: true, |
||||
updateQuery: { |
||||
height: -1, // image renderer will scroll through the dashboard and set the appropriate height
|
||||
width: config.rendererDefaultImageWidth || 1000, |
||||
scale, |
||||
kiosk: true, |
||||
hideNav: true, |
||||
orgId: String(config.bootData.user.orgId), |
||||
fullPageImage: true, |
||||
}, |
||||
}); |
||||
|
||||
const response = await lastValueFrom( |
||||
getBackendSrv().fetch<Blob>({ |
||||
url: imageUrl, |
||||
responseType: 'blob', |
||||
}) |
||||
); |
||||
|
||||
if (!response.ok) { |
||||
return { |
||||
blob: new Blob(), |
||||
error: `Failed to generate image: ${response.status} ${response.statusText}`, |
||||
}; |
||||
} |
||||
|
||||
// Validate response data format
|
||||
if (!(response.data instanceof Blob)) { |
||||
return { |
||||
blob: new Blob(), |
||||
error: 'Invalid response data format', |
||||
}; |
||||
} |
||||
|
||||
return { |
||||
blob: response.data, |
||||
}; |
||||
} catch (error) { |
||||
return { |
||||
blob: new Blob(), |
||||
error: error instanceof Error && error.message ? error.message : 'Failed to generate image', |
||||
}; |
||||
} |
||||
} |
@ -0,0 +1,107 @@ |
||||
import { render, screen, cleanup } from '@testing-library/react'; |
||||
|
||||
import { ImagePreview } from './ImagePreview'; |
||||
|
||||
// Mock URL.createObjectURL and URL.revokeObjectURL
|
||||
const mockCreateObjectURL = jest.fn(); |
||||
const mockRevokeObjectURL = jest.fn(); |
||||
|
||||
beforeAll(() => { |
||||
global.URL.createObjectURL = mockCreateObjectURL; |
||||
global.URL.revokeObjectURL = mockRevokeObjectURL; |
||||
}); |
||||
|
||||
afterEach(() => { |
||||
mockCreateObjectURL.mockClear(); |
||||
mockRevokeObjectURL.mockClear(); |
||||
cleanup(); |
||||
}); |
||||
|
||||
describe('ImagePreview', () => { |
||||
const defaultProps = { |
||||
imageBlob: null, |
||||
isLoading: false, |
||||
error: null, |
||||
}; |
||||
|
||||
it('should render empty container when no image, loading, or error', () => { |
||||
render(<ImagePreview {...defaultProps} />); |
||||
// Container should exist with proper role and label
|
||||
expect(screen.getByRole('region', { name: 'Image preview' })).toBeInTheDocument(); |
||||
// Loading bar should not be visible
|
||||
expect(screen.queryByLabelText('Loading bar')).not.toBeInTheDocument(); |
||||
// Image should not be visible
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument(); |
||||
// Error alert should not be visible
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should show loading state with title when loading', () => { |
||||
render(<ImagePreview {...defaultProps} isLoading={true} title="Test Title" />); |
||||
// Loading state should be announced properly
|
||||
expect(screen.getByRole('status', { name: 'Generating image...' })).toBeInTheDocument(); |
||||
expect(screen.getByLabelText('Loading bar')).toBeInTheDocument(); |
||||
expect(screen.getByText('Test Title')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should show error state when error is present', () => { |
||||
const error = { |
||||
title: 'Error Title', |
||||
message: 'Error Message', |
||||
}; |
||||
render(<ImagePreview {...defaultProps} error={error} />); |
||||
expect(screen.getByRole('alert')).toBeInTheDocument(); |
||||
expect(screen.getByText('Error Title')).toBeInTheDocument(); |
||||
expect(screen.getByText('Error Message')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should show image when imageBlob is present', () => { |
||||
const imageBlob = new Blob(['test'], { type: 'image/png' }); |
||||
mockCreateObjectURL.mockReturnValue('mock-url'); |
||||
render(<ImagePreview {...defaultProps} imageBlob={imageBlob} />); |
||||
const image = screen.getByRole('img', { name: 'Generated image preview' }); |
||||
expect(image).toBeInTheDocument(); |
||||
expect(image).toHaveAttribute('alt', 'Preview'); |
||||
expect(image).toHaveAttribute('src', 'mock-url'); |
||||
expect(mockCreateObjectURL).toHaveBeenCalledWith(imageBlob); |
||||
}); |
||||
|
||||
it('should revoke object URL on unmount', () => { |
||||
const imageBlob = new Blob(['test'], { type: 'image/png' }); |
||||
mockCreateObjectURL.mockReturnValue('mock-url'); |
||||
const { unmount } = render(<ImagePreview {...defaultProps} imageBlob={imageBlob} />); |
||||
unmount(); |
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('mock-url'); |
||||
}); |
||||
|
||||
it('should not show image when loading', () => { |
||||
const imageBlob = new Blob(['test'], { type: 'image/png' }); |
||||
render(<ImagePreview {...defaultProps} imageBlob={imageBlob} isLoading={true} />); |
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument(); |
||||
// Should show loading state instead
|
||||
expect(screen.getByRole('status', { name: 'Generating image...' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should not show error when loading', () => { |
||||
const error = { |
||||
title: 'Error Title', |
||||
message: 'Error Message', |
||||
}; |
||||
render(<ImagePreview {...defaultProps} error={error} isLoading={true} />); |
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument(); |
||||
// Should show loading state instead
|
||||
expect(screen.getByRole('status', { name: 'Generating image...' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should not show duplicate message when error title and message are the same', () => { |
||||
const error = { |
||||
title: 'Failed to generate image', |
||||
message: 'Failed to generate image', |
||||
}; |
||||
render(<ImagePreview {...defaultProps} error={error} />); |
||||
expect(screen.getByRole('alert')).toBeInTheDocument(); |
||||
expect(screen.getByText('Failed to generate image')).toBeInTheDocument(); |
||||
// Check that the message doesn't appear twice by counting occurrences
|
||||
expect(screen.getAllByText('Failed to generate image')).toHaveLength(1); |
||||
}); |
||||
}); |
@ -0,0 +1,115 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { useMemo, useEffect } from 'react'; |
||||
import { useMeasure } from 'react-use'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { t } from '@grafana/i18n'; |
||||
import { Alert, LoadingBar, Text, useStyles2 } from '@grafana/ui'; |
||||
|
||||
type ErrorState = { |
||||
message: string; |
||||
title: string; |
||||
} | null; |
||||
|
||||
interface ImagePreviewProps { |
||||
imageBlob: Blob | null; |
||||
isLoading: boolean; |
||||
error: ErrorState; |
||||
title?: string; |
||||
} |
||||
|
||||
export function ImagePreview({ imageBlob, isLoading, error, title }: ImagePreviewProps) { |
||||
const styles = useStyles2(getStyles); |
||||
const [ref, { width: measuredWidth }] = useMeasure<HTMLDivElement>(); |
||||
|
||||
// Memoize and clean up the object URL for the image
|
||||
const imageUrl = useMemo(() => { |
||||
if (!imageBlob) { |
||||
return undefined; |
||||
} |
||||
const url = URL.createObjectURL(imageBlob); |
||||
return url; |
||||
}, [imageBlob]); |
||||
|
||||
useEffect(() => { |
||||
return () => { |
||||
if (imageUrl) { |
||||
URL.revokeObjectURL(imageUrl); |
||||
} |
||||
}; |
||||
}, [imageUrl]); |
||||
|
||||
return ( |
||||
<div |
||||
className={styles.previewContainer} |
||||
ref={ref} |
||||
aria-label={t('share-modal.image.preview-region', 'Image preview')} |
||||
role="region" |
||||
> |
||||
{isLoading && ( |
||||
<div |
||||
className={styles.loadingBarContainer} |
||||
role="status" |
||||
aria-label={t('share-modal.image.generating', 'Generating image...')} |
||||
> |
||||
<LoadingBar width={measuredWidth} /> |
||||
{title && ( |
||||
<div className={styles.titleContainer}> |
||||
<Text variant="body">{title}</Text> |
||||
</div> |
||||
)} |
||||
</div> |
||||
)} |
||||
|
||||
{error && !isLoading && <ErrorAlert error={error} />} |
||||
{!isLoading && imageUrl && ( |
||||
<img |
||||
src={imageUrl} |
||||
alt={t('share-modal.image.preview', 'Preview')} |
||||
className={styles.image} |
||||
aria-label={t('share-modal.image.preview-aria', 'Generated image preview')} |
||||
/> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function ErrorAlert({ error }: { error: ErrorState }) { |
||||
if (!error) { |
||||
return null; |
||||
} |
||||
|
||||
// Only show message if it's different from the title to avoid repetition
|
||||
const showMessage = error.message && error.message !== error.title; |
||||
|
||||
return ( |
||||
<Alert severity="error" title={error.title}> |
||||
{showMessage && <div>{error.message}</div>} |
||||
</Alert> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
previewContainer: css({ |
||||
position: 'relative', |
||||
width: '100%', |
||||
minHeight: '200px', |
||||
backgroundColor: theme.colors.background.secondary, |
||||
borderRadius: theme.shape.radius.default, |
||||
overflow: 'hidden', |
||||
}), |
||||
loadingBarContainer: css({ |
||||
position: 'absolute', |
||||
top: 0, |
||||
left: 0, |
||||
right: 0, |
||||
}), |
||||
titleContainer: css({ |
||||
padding: theme.spacing(1), |
||||
}), |
||||
image: css({ |
||||
maxWidth: '100%', |
||||
maxHeight: '100%', |
||||
objectFit: 'contain', |
||||
}), |
||||
}); |
Loading…
Reference in new issue