Sharing: Export dashboard as image (#104207)

Co-authored-by: AgnesToulet <35176601+AgnesToulet@users.noreply.github.com>
pull/108111/merge^2
Nathan Marrs 5 days ago committed by GitHub
parent a977aa9d03
commit b691b3288d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      apps/alerting/notifications/pkg/apis/alerting/v0alpha1/receiver_schema_gen.go
  2. 2
      apps/alerting/notifications/pkg/apis/alerting/v0alpha1/templategroup_schema_gen.go
  3. 2
      apps/alerting/notifications/pkg/apis/alerting/v0alpha1/timeinterval_schema_gen.go
  4. 4
      packages/grafana-data/src/types/featureToggles.gen.ts
  5. 57
      packages/grafana-e2e-selectors/src/selectors/components.ts
  6. 3
      packages/grafana-e2e-selectors/src/selectors/pages.ts
  7. 8
      pkg/services/featuremgmt/registry.go
  8. 1
      pkg/services/featuremgmt/toggles_gen.csv
  9. 4
      pkg/services/featuremgmt/toggles_gen.go
  10. 14
      pkg/services/featuremgmt/toggles_gen.json
  11. 4
      public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx
  12. 179
      public/app/features/dashboard-scene/sharing/ExportButton/ExportAsImage.tsx
  13. 28
      public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.test.tsx
  14. 11
      public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.tsx
  15. 22
      public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.test.tsx
  16. 17
      public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.tsx
  17. 132
      public/app/features/dashboard-scene/sharing/ExportButton/utils.test.ts
  18. 89
      public/app/features/dashboard-scene/sharing/ExportButton/utils.ts
  19. 3
      public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.tsx
  20. 1
      public/app/features/dashboard-scene/sharing/ShareInternallyConfiguration.tsx
  21. 2
      public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx
  22. 107
      public/app/features/dashboard-scene/sharing/components/ImagePreview.test.tsx
  23. 115
      public/app/features/dashboard-scene/sharing/components/ImagePreview.tsx
  24. 48
      public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.test.tsx
  25. 2
      public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.tsx
  26. 78
      public/app/features/dashboard-scene/sharing/panel-share/SharePanelPreview.tsx
  27. 8
      public/app/features/dashboard-scene/utils/interactions.ts
  28. 2
      public/app/features/dashboard/components/ShareModal/ShareLink.tsx
  29. 1
      public/app/features/dashboard/components/ShareModal/utils.ts
  30. 34
      public/locales/en-US/grafana.json

@ -13,7 +13,7 @@ import (
// schema is unexported to prevent accidental overwrites
var (
schemaReceiver = resource.NewSimpleSchema("notifications.alerting.grafana.app", "v0alpha1", &Receiver{}, &ReceiverList{}, resource.WithKind("Receiver"),
resource.WithPlural("receivers"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{resource.SelectableField{
resource.WithPlural("receivers"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{{
FieldSelector: "spec.title",
FieldValueFunc: func(o resource.Object) (string, error) {
cast, ok := o.(*Receiver)

@ -13,7 +13,7 @@ import (
// schema is unexported to prevent accidental overwrites
var (
schemaTemplateGroup = resource.NewSimpleSchema("notifications.alerting.grafana.app", "v0alpha1", &TemplateGroup{}, &TemplateGroupList{}, resource.WithKind("TemplateGroup"),
resource.WithPlural("templategroups"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{resource.SelectableField{
resource.WithPlural("templategroups"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{{
FieldSelector: "spec.title",
FieldValueFunc: func(o resource.Object) (string, error) {
cast, ok := o.(*TemplateGroup)

@ -13,7 +13,7 @@ import (
// schema is unexported to prevent accidental overwrites
var (
schemaTimeInterval = resource.NewSimpleSchema("notifications.alerting.grafana.app", "v0alpha1", &TimeInterval{}, &TimeIntervalList{}, resource.WithKind("TimeInterval"),
resource.WithPlural("timeintervals"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{resource.SelectableField{
resource.WithPlural("timeintervals"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{{
FieldSelector: "spec.name",
FieldValueFunc: func(o resource.Object) (string, error) {
cast, ok := o.(*TimeInterval)

@ -993,6 +993,10 @@ export interface FeatureToggles {
*/
alertingImportAlertmanagerAPI?: boolean;
/**
* Enables image sharing functionality for dashboards
*/
sharingDashboardImage?: boolean;
/**
* Prefer library panel title over viz panel title.
* @default false
*/

@ -1362,6 +1362,63 @@ export const versionedComponents = {
'11.5.0': 'data-testid portal-container',
},
},
ExportImage: {
formatOptions: {
container: {
['12.1.0']: 'data-testid export-image-format-options',
},
png: {
['12.1.0']: 'data-testid export-image-format-png',
},
jpg: {
['12.1.0']: 'data-testid export-image-format-jpg',
},
},
rendererAlert: {
container: {
['12.1.0']: 'data-testid export-image-renderer-alert',
},
title: {
['12.1.0']: 'data-testid export-image-renderer-alert-title',
},
description: {
['12.1.0']: 'data-testid export-image-renderer-alert-description',
},
},
buttons: {
generate: {
['12.1.0']: 'data-testid export-image-generate-button',
},
download: {
['12.1.0']: 'data-testid export-image-download-button',
},
cancel: {
['12.1.0']: 'data-testid export-image-cancel-button',
},
},
preview: {
container: {
['12.1.0']: 'data-testid export-image-preview-container',
},
loading: {
['12.1.0']: 'data-testid export-image-preview-loading',
},
image: {
['12.1.0']: 'data-testid export-image-preview',
},
error: {
container: {
['12.1.0']: 'data-testid export-image-error',
},
title: {
['12.1.0']: 'data-testid export-image-error-title',
},
message: {
['12.1.0']: 'data-testid export-image-error-message',
},
},
},
},
} satisfies VersionedSelectorGroup;
export type VersionedComponents = typeof versionedComponents;

@ -227,6 +227,9 @@ export const versionedPages = {
exportAsJson: {
'11.2.0': 'data-testid new export button export as json',
},
exportAsImage: {
'12.1.0': 'data-testid new export button export as image',
},
},
},
playlistControls: {

@ -1705,6 +1705,14 @@ var (
HideFromDocs: true,
Expression: "false",
},
{
Name: "sharingDashboardImage",
Description: "Enables image sharing functionality for dashboards",
Stage: FeatureStageExperimental,
Owner: grafanaSharingSquad,
HideFromDocs: true,
FrontendOnly: true,
},
{
Name: "preferLibraryPanelTitle",
Description: "Prefer library panel title over viz panel title.",

@ -222,6 +222,7 @@ restoreDashboards,experimental,@grafana/grafana-frontend-platform,false,false,fa
skipTokenRotationIfRecent,GA,@grafana/identity-access-team,false,false,false
alertEnrichment,experimental,@grafana/alerting-squad,false,false,false
alertingImportAlertmanagerAPI,experimental,@grafana/alerting-squad,false,false,false
sharingDashboardImage,experimental,@grafana/sharing-squad,false,false,true
preferLibraryPanelTitle,privatePreview,@grafana/dashboards-squad,false,false,false
tabularNumbers,GA,@grafana/grafana-frontend-platform,false,false,false
newInfluxDSConfigPageDesign,privatePreview,@grafana/partner-datasources,false,false,false

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
222 skipTokenRotationIfRecent GA @grafana/identity-access-team false false false
223 alertEnrichment experimental @grafana/alerting-squad false false false
224 alertingImportAlertmanagerAPI experimental @grafana/alerting-squad false false false
225 sharingDashboardImage experimental @grafana/sharing-squad false false true
226 preferLibraryPanelTitle privatePreview @grafana/dashboards-squad false false false
227 tabularNumbers GA @grafana/grafana-frontend-platform false false false
228 newInfluxDSConfigPageDesign privatePreview @grafana/partner-datasources false false false

@ -899,6 +899,10 @@ const (
// Enables the API to import Alertmanager configuration
FlagAlertingImportAlertmanagerAPI = "alertingImportAlertmanagerAPI"
// FlagSharingDashboardImage
// Enables image sharing functionality for dashboards
FlagSharingDashboardImage = "sharingDashboardImage"
// FlagPreferLibraryPanelTitle
// Prefer library panel title over viz panel title.
FlagPreferLibraryPanelTitle = "preferLibraryPanelTitle"

@ -2815,6 +2815,20 @@
"codeowner": "@grafana/grafana-operator-experience-squad"
}
},
{
"metadata": {
"name": "sharingDashboardImage",
"resourceVersion": "1747090555040",
"creationTimestamp": "2025-05-12T22:55:55Z"
},
"spec": {
"description": "Enables image sharing functionality for dashboards",
"stage": "experimental",
"codeowner": "@grafana/sharing-squad",
"frontend": true,
"hideFromDocs": true
}
},
{
"metadata": {
"name": "showDashboardValidationWarnings",

@ -151,7 +151,7 @@ describe('NavToolbarActions', () => {
expect(await screen.findByText('Share')).toBeInTheDocument();
const newShareButton = screen.queryByTestId(selectors.pages.Dashboard.DashNav.newShareButton.container);
expect(newShareButton).not.toBeInTheDocument();
const newExportButton = screen.queryByTestId(selectors.pages.Dashboard.DashNav.NewExportButton.container);
const newExportButton = screen.queryByRole('button', { name: /export dashboard/i });
expect(newExportButton).not.toBeInTheDocument();
});
it('Should show new share button when newDashboardSharingComponent FF is enabled', async () => {
@ -165,7 +165,7 @@ describe('NavToolbarActions', () => {
it('Should show new export button when newDashboardSharingComponent FF is enabled', async () => {
config.featureToggles.newDashboardSharingComponent = true;
setup();
const newExportButton = screen.getByTestId(selectors.pages.Dashboard.DashNav.NewExportButton.container);
const newExportButton = screen.getByRole('button', { name: /export dashboard/i });
expect(newExportButton).toBeInTheDocument();
});
});

@ -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),
}),
});

@ -1,7 +1,6 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { SceneTimeRange, VizPanel } from '@grafana/scenes';
import { DashboardScene } from '../../scene/DashboardScene';
@ -9,21 +8,32 @@ import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGrid
import ExportButton from './ExportButton';
const selector = e2eSelectors.pages.Dashboard.DashNav.NewExportButton;
describe('ExportButton', () => {
it('should render Export menu', async () => {
it('should render Export button', async () => {
setup();
expect(await screen.findByRole('button', { name: /export dashboard/i })).toBeInTheDocument();
});
it('should show menu when export button is clicked', async () => {
setup();
expect(await screen.findByTestId(selector.arrowMenu)).toBeInTheDocument();
const exportButton = await screen.findByRole('button', { name: /export dashboard/i });
expect(exportButton).toHaveAttribute('aria-expanded', 'false');
await userEvent.click(exportButton);
expect(exportButton).toHaveAttribute('aria-expanded', 'true');
expect(await screen.findByRole('menu')).toBeInTheDocument();
});
it('should render menu when arrow button clicked', async () => {
it('should show export options in the menu', async () => {
setup();
const arrowMenu = await screen.findByTestId(selector.arrowMenu);
await userEvent.click(arrowMenu);
const exportButton = await screen.findByRole('button', { name: /export dashboard/i });
await userEvent.click(exportButton);
expect(await screen.findByTestId(selector.Menu.container)).toBeInTheDocument();
// Should show JSON export option
expect(await screen.findByRole('menuitem', { name: /export as json/i })).toBeInTheDocument();
});
});

@ -8,8 +8,6 @@ import { DashboardScene } from '../../scene/DashboardScene';
import ExportMenu from './ExportMenu';
const newExportButtonSelector = e2eSelectors.pages.Dashboard.DashNav.NewExportButton;
interface Props {
dashboard: DashboardScene;
}
@ -24,17 +22,20 @@ export default function ExportButton({ dashboard }: Props) {
const MenuActions = () => <ExportMenu dashboard={dashboard} />;
return (
<ButtonGroup data-testid={newExportButtonSelector.container}>
<ButtonGroup>
<Dropdown overlay={MenuActions} placement="bottom-end" onVisibleChange={onMenuClick}>
<Button
data-testid={newExportButtonSelector.arrowMenu}
size="sm"
variant="secondary"
fill="solid"
tooltip={t('export.menu.export-as-json-tooltip', 'Export')}
aria-label={t('dashboard.export.button.label', 'Export dashboard')}
aria-haspopup="menu"
aria-expanded={isOpen}
data-testid={e2eSelectors.pages.Dashboard.DashNav.NewExportButton.arrowMenu}
>
<Trans i18nKey="export.menu.export-as-json-label">Export</Trans>&nbsp;
<Icon name={isOpen ? 'angle-up' : 'angle-down'} size="sm" />
<Icon name={isOpen ? 'angle-up' : 'angle-down'} size="sm" aria-hidden="true" />
</Button>
</Dropdown>
</ButtonGroup>

@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { SceneTimeRange, VizPanel } from '@grafana/scenes';
import { DashboardScene } from '../../scene/DashboardScene';
@ -8,12 +8,24 @@ import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGrid
import ExportMenu from './ExportMenu';
const selector = e2eSelectors.pages.Dashboard.DashNav.NewExportButton.Menu;
describe('ExportMenu', () => {
it('should render menu items', async () => {
setup();
expect(await screen.findByTestId(selector.exportAsJson)).toBeInTheDocument();
expect(await screen.findByRole('menuitem', { name: /export as json/i })).toBeInTheDocument();
});
describe('sharingDashboardImage feature toggle', () => {
it('should render image export option when enabled', async () => {
config.featureToggles.sharingDashboardImage = true;
setup();
expect(await screen.findByRole('menuitem', { name: /export as image/i })).toBeInTheDocument();
});
it('should not render image export option when disabled', async () => {
config.featureToggles.sharingDashboardImage = false;
setup();
expect(screen.queryByRole('menuitem', { name: /export as image/i })).not.toBeInTheDocument();
});
});
});
@ -23,11 +35,13 @@ function setup() {
pluginId: 'table',
key: 'panel-12',
});
const dashboard = new DashboardScene({
title: 'hello',
uid: 'dash-1',
$timeRange: new SceneTimeRange({}),
body: DefaultGridLayoutManager.fromVizPanels([panel]),
});
render(<ExportMenu dashboard={dashboard} />);
}

@ -13,7 +13,7 @@ const newExportButtonSelector = e2eSelectors.pages.Dashboard.DashNav.NewExportBu
export interface ExportDrawerMenuItem {
shareId: string;
testId: string;
testId?: string;
label: string;
description?: string;
icon: IconName;
@ -50,6 +50,14 @@ export default function ExportMenu({ dashboard }: { dashboard: DashboardScene })
onClick: () => onMenuItemClick(shareDashboardType.export),
});
menuItems.push({
shareId: shareDashboardType.image,
icon: 'camera',
label: t('share-dashboard.menu.export-image-title', 'Export as image'),
renderCondition: Boolean(config.featureToggles.sharingDashboardImage),
onClick: () => onMenuItemClick(shareDashboardType.image),
});
return menuItems.filter((item) => item.renderCondition);
}, []);
@ -63,15 +71,18 @@ export default function ExportMenu({ dashboard }: { dashboard: DashboardScene })
};
return (
<Menu data-testid={newExportButtonSelector.container}>
<Menu
ariaLabel={t('dashboard.export.menu.label', 'Export dashboard menu')}
data-testid={newExportButtonSelector.container}
>
{buildMenuItems().map((item) => (
<Menu.Item
key={item.label}
testId={item.testId}
label={item.label}
icon={item.icon}
description={item.description}
onClick={() => onClick(item)}
testId={item.testId}
/>
))}
</Menu>

@ -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',
};
}
}

@ -6,6 +6,7 @@ import { shareDashboardType } from '../../../dashboard/components/ShareModal/uti
import { DashboardScene } from '../../scene/DashboardScene';
import { getDashboardSceneFor } from '../../utils/utils';
import { ExportAsCode } from '../ExportButton/ExportAsCode';
import { ExportAsImage } from '../ExportButton/ExportAsImage';
import { ShareExternally } from '../ShareButton/share-externally/ShareExternally';
import { ShareInternally } from '../ShareButton/share-internally/ShareInternally';
import { ShareSnapshot } from '../ShareButton/share-snapshot/ShareSnapshot';
@ -93,6 +94,8 @@ function getShareView(
return new ShareSnapshot({ dashboardRef, panelRef, onDismiss });
case shareDashboardType.export:
return new ExportAsCode({ onDismiss });
case shareDashboardType.image:
return new ExportAsImage({ onDismiss });
default:
return new ShareInternally({ onDismiss });
}

@ -42,6 +42,7 @@ export default function ShareInternallyConfiguration({
'link.share.time-range-description',
'Change the current relative time range to an absolute time range'
)}
id="time-range-description"
>
<Trans i18nKey="link.share.time-range-label">Lock time range</Trans>
</Label>

@ -214,7 +214,7 @@ function ShareLinkTabRenderer({ model }: SceneComponentProps<ShareLinkTab>) {
bottomSpacing={0}
>
<Trans i18nKey="share-modal.link.render-instructions">
To render a panel image, you must install the{' '}
To render an image, you must install the{' '}
<a
href="https://grafana.com/grafana/plugins/grafana-image-renderer"
target="_blank"

@ -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',
}),
});

@ -1,7 +1,6 @@
import { render, screen } from '@testing-library/react';
import { getPanelPlugin } from '@grafana/data/test';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { config, setPluginImportUtils } from '@grafana/runtime';
import { SceneTimeRange, VizPanel } from '@grafana/scenes';
@ -17,38 +16,45 @@ setPluginImportUtils({
getPanelPluginFromCache: (id: string) => undefined,
});
const selector = e2eSelectors.pages.ShareDashboardDrawer.ShareInternally.SharePanel;
describe('SharePanelInternally', () => {
it('should disable all image generation inputs when renderer is not available', async () => {
config.rendererAvailable = false;
buildAndRenderScenario();
expect(await screen.findByTestId(selector.preview)).toBeInTheDocument();
[
selector.widthInput,
selector.heightInput,
selector.scaleFactorInput,
selector.generateImageButton,
selector.downloadImageButton,
].forEach((selector) => {
expect(screen.getByTestId(selector)).toBeDisabled();
});
// Check that the panel preview is rendered
expect(await screen.findByText('Panel preview')).toBeInTheDocument();
// All inputs should be disabled - use placeholder text to find inputs
expect(screen.getByPlaceholderText('1000')).toBeDisabled(); // Width input
expect(screen.getByPlaceholderText('500')).toBeDisabled(); // Height input
expect(screen.getByPlaceholderText('1')).toBeDisabled(); // Scale factor input
expect(screen.getByRole('button', { name: /generate image/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /download image/i })).toBeDisabled();
});
it('should enable all image generation inputs when renderer is available', async () => {
config.rendererAvailable = true;
buildAndRenderScenario();
expect(await screen.findByTestId(selector.preview)).toBeInTheDocument();
[selector.widthInput, selector.heightInput, selector.scaleFactorInput].forEach((selector) => {
expect(screen.getByTestId(selector)).toBeEnabled();
});
// Check that the panel preview is rendered
expect(await screen.findByText('Panel preview')).toBeInTheDocument();
// Form inputs should be enabled
expect(screen.getByPlaceholderText('1000')).toBeEnabled(); // Width input
expect(screen.getByPlaceholderText('500')).toBeEnabled(); // Height input
expect(screen.getByPlaceholderText('1')).toBeEnabled(); // Scale factor input
// Test form interaction
const widthInput = screen.getByPlaceholderText('1000');
const heightInput = screen.getByPlaceholderText('500');
await userEvent.clear(widthInput);
await userEvent.type(widthInput, '1000');
await userEvent.clear(heightInput);
await userEvent.type(heightInput, '2000');
await userEvent.type(screen.getByTestId(selector.widthInput), '1000');
await userEvent.type(screen.getByTestId(selector.widthInput), '2000');
expect(screen.getByTestId(selector.generateImageButton)).toBeEnabled();
expect(screen.getByTestId(selector.downloadImageButton)).toBeDisabled();
expect(screen.getByRole('button', { name: /generate image/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /download image/i })).toBeDisabled();
});
});

@ -78,7 +78,7 @@ function SharePanelInternallyRenderer({ model }: SceneComponentProps<SharePanelI
bottomSpacing={0}
>
<Trans i18nKey="share-modal.link.render-instructions">
To render a panel image, you must install the{' '}
To render an image, you must install the{' '}
<a
href="https://grafana.com/grafana/plugins/grafana-image-renderer"
target="_blank"

@ -6,12 +6,12 @@ import { useAsyncFn } from 'react-use';
import { lastValueFrom } from 'rxjs';
import { GrafanaTheme2, UrlQueryMap } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { config, getBackendSrv, isFetchError } from '@grafana/runtime';
import { Alert, Button, Field, FieldSet, Icon, Input, LoadingBar, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui';
import { config, getBackendSrv } from '@grafana/runtime';
import { Button, Field, FieldSet, Icon, Input, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui';
import { DashboardInteractions } from '../../utils/interactions';
import { ImagePreview } from '../components/ImagePreview';
type ImageSettingsForm = {
width: number;
@ -27,8 +27,6 @@ type Props = {
theme: string;
};
const selector = e2eSelectors.pages.ShareDashboardDrawer.ShareInternally.SharePanel;
export function SharePanelPreview({ title, imageUrl, buildUrl, disabled, theme }: Props) {
const styles = useStyles2(getStyles);
@ -78,12 +76,15 @@ export function SharePanelPreview({ title, imageUrl, buildUrl, disabled, theme }
};
return (
<div data-testid={selector.preview}>
<section aria-labelledby="panel-preview-heading">
<Stack gap={2} direction="column">
<Text element="h4">
<Text element="h4" id="panel-preview-heading">
<Trans i18nKey="share-panel-image.preview.title">Panel preview</Trans>
</Text>
<form onSubmit={handleSubmit(onRenderImageClick)}>
<form
onSubmit={handleSubmit(onRenderImageClick)}
aria-label={t('share-panel-image.form.label', 'Panel image settings')}
>
<FieldSet
disabled={!config.rendererAvailable}
label={
@ -123,7 +124,7 @@ export function SharePanelPreview({ title, imageUrl, buildUrl, disabled, theme }
placeholder={t('share-panel-image.settings.width-placeholder', '1000')}
type="number"
suffix="px"
data-testid={selector.widthInput}
aria-label={t('share-panel-image.settings.width-label', 'Width')}
/>
</Field>
<Field
@ -146,7 +147,7 @@ export function SharePanelPreview({ title, imageUrl, buildUrl, disabled, theme }
placeholder={t('share-panel-image.settings.height-placeholder', '500')}
type="number"
suffix="px"
data-testid={selector.heightInput}
aria-label={t('share-panel-image.settings.height-label', 'Height')}
/>
</Field>
<Field
@ -171,7 +172,7 @@ export function SharePanelPreview({ title, imageUrl, buildUrl, disabled, theme }
})}
placeholder={t('share-panel-image.settings.scale-factor-placeholder', '1')}
type="number"
data-testid={selector.scaleFactorInput}
aria-label={t('share-panel-image.settings.scale-factor-label', 'Scale factor')}
/>
</Field>
</Stack>
@ -182,7 +183,7 @@ export function SharePanelPreview({ title, imageUrl, buildUrl, disabled, theme }
fill="solid"
type="submit"
disabled={disabled || loading || !isValid}
data-testid={selector.generateImageButton}
aria-describedby={disabled ? 'generate-button-disabled-help' : undefined}
>
<Trans i18nKey="link.share-panel.render-image">Generate image</Trans>
</Button>
@ -191,31 +192,38 @@ export function SharePanelPreview({ title, imageUrl, buildUrl, disabled, theme }
icon={'download-alt'}
variant="secondary"
disabled={!image || loading || disabled}
data-testid={selector.downloadImageButton}
aria-describedby={!image && !loading ? 'download-button-disabled-help' : undefined}
>
<Trans i18nKey="link.share-panel.download-image">Download image</Trans>
</Button>
</Stack>
{disabled && (
<Text variant="bodySmall" color="secondary" id="generate-button-disabled-help">
<Trans i18nKey="share-panel-image.disabled-help">Save the dashboard to enable image generation</Trans>
</Text>
)}
{!image && !loading && (
<Text variant="bodySmall" color="secondary" id="download-button-disabled-help">
<Trans i18nKey="share-panel-image.download-disabled-help">
Generate an image first to enable download
</Trans>
</Text>
)}
</FieldSet>
</form>
{loading && (
<div>
<LoadingBar width={128} />
<div className={styles.imageLoadingContainer}>
<Text variant="body">{title || ''}</Text>
</div>
</div>
)}
{image && !loading && <img src={URL.createObjectURL(image)} alt="panel-preview-img" className={styles.image} />}
{error && !loading && (
<Alert severity="error" title={t('link.share-panel.render-image-error', 'Failed to render panel image')}>
{isFetchError(error)
? error.statusText
: t('link.share-panel.render-image-error-description', 'An error occurred when generating the image')}
</Alert>
)}
<ImagePreview
imageBlob={image || null}
isLoading={loading}
error={
error
? { title: t('share-panel-image.error-title', 'Failed to generate image'), message: error.message }
: null
}
title={title}
/>
</Stack>
</div>
</section>
);
}
@ -223,14 +231,4 @@ const getStyles = (theme: GrafanaTheme2) => ({
imageConfigurationField: css({
flex: 1,
}),
image: css({
maxWidth: '100%',
width: 'max-content',
}),
imageLoadingContainer: css({
maxWidth: '100%',
height: 362,
border: `1px solid ${theme.components.input.borderColor}`,
padding: theme.spacing(1),
}),
});

@ -124,6 +124,14 @@ export const DashboardInteractions = {
showMoreVersionsClicked: () => {
reportDashboardInteraction('show_more_versions_clicked');
},
// Image export interactions
generateDashboardImageClicked: (properties?: Record<string, unknown>) => {
reportDashboardInteraction('dashboard_image_generated', properties);
},
downloadDashboardImageClicked: (properties?: Record<string, unknown>) => {
reportDashboardInteraction('dashboard_image_downloaded', properties);
},
};
const reportDashboardInteraction: typeof reportInteraction = (name, properties) => {

@ -163,7 +163,7 @@ export class ShareLink extends PureComponent<Props, State> {
bottomSpacing={0}
>
<Trans i18nKey="share-modal.link.render-instructions">
To render a panel image, you must install the{' '}
To render an image, you must install the{' '}
<TextLink href="https://grafana.com/grafana/plugins/grafana-image-renderer" external>
Grafana image renderer plugin
</TextLink>

@ -181,4 +181,5 @@ export const shareDashboardType: {
report: 'report',
publicDashboard: 'public_dashboard',
inviteUser: 'invite_user',
image: 'image',
};

@ -4617,6 +4617,14 @@
"errors": {
"failed-to-load": "Failed to load dashboard"
},
"export": {
"button": {
"label": "Export dashboard"
},
"menu": {
"label": "Export dashboard menu"
}
},
"fetch-dashboard": {
"message": {
"failed-to-fetch-dashboard": "Failed to fetch dashboard"
@ -8641,9 +8649,7 @@
"share-panel": {
"config-description": "Create a personalized, direct link to share your panel within your organization, with the following customization settings:",
"download-image": "Download image",
"render-image": "Generate image",
"render-image-error": "Failed to render panel image",
"render-image-error-description": "An error occurred when generating the image"
"render-image": "Generate image"
}
},
"live": {
@ -11406,6 +11412,7 @@
},
"share-dashboard": {
"menu": {
"export-image-title": "Export as image",
"export-json-title": "Export as JSON",
"share-externally-title": "Share externally",
"share-internally-title": "Share internally",
@ -11441,6 +11448,19 @@
"view-button": "View JSON",
"view-button-yaml": "View YAML"
},
"image": {
"actions": "Image export actions",
"cancel-button": "Cancel",
"download-button": "Download image",
"error-title": "Failed to generate image",
"generate-button": "Generate image",
"generating": "Generating image...",
"info-text": "Save this dashboard as an image",
"preview": "Preview",
"preview-aria": "Generated image preview",
"preview-region": "Image preview",
"title": "Export as image"
},
"library": {
"info": "Create library panel."
},
@ -11449,7 +11469,7 @@
"info-text": "Create a direct link to this dashboard or panel, customized with the options below.",
"link-url": "Link URL",
"render-alert": "Image renderer plugin not installed",
"render-instructions": "To render a panel image, you must install the <2>Grafana image renderer plugin</2>. Please contact your Grafana administrator to install the plugin.",
"render-instructions": "To render an image, you must install the <2>Grafana image renderer plugin</2>. Please contact your Grafana administrator to install the plugin.",
"rendered-image": "Direct link rendered image",
"save-alert": "Dashboard is not saved",
"save-dashboard": "To render a panel image, you must save the dashboard first.",
@ -11517,6 +11537,12 @@
}
},
"share-panel-image": {
"disabled-help": "Save the dashboard to enable image generation",
"download-disabled-help": "Generate an image first to enable download",
"error-title": "Failed to generate image",
"form": {
"label": "Panel image settings"
},
"preview": {
"title": "Panel preview"
},

Loading…
Cancel
Save