diff --git a/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/receiver_schema_gen.go b/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/receiver_schema_gen.go index acda5c7d1c9..314d36b602a 100644 --- a/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/receiver_schema_gen.go +++ b/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/receiver_schema_gen.go @@ -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) diff --git a/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/templategroup_schema_gen.go b/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/templategroup_schema_gen.go index 339d0bd6d04..509bfc4aeca 100644 --- a/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/templategroup_schema_gen.go +++ b/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/templategroup_schema_gen.go @@ -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) diff --git a/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/timeinterval_schema_gen.go b/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/timeinterval_schema_gen.go index a6c809e0d3b..8c5459f0059 100644 --- a/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/timeinterval_schema_gen.go +++ b/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/timeinterval_schema_gen.go @@ -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) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 7fd42947df5..6ec593c6761 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -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 */ diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 088caf6dfa1..93e5a545d8c 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -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; diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 1dcc45d4d30..93b359e034e 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -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: { diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 99402214c8c..3370a9c1d68 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -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.", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 7ceff2a62ca..f5b845f0b97 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -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 diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 0d47b99a68b..0f50eac6a1b 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -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" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 6c508a755c6..ab148e849ac 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -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", diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx index 3a6e2f7935c..384a2e842e6 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx @@ -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(); }); }); diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsImage.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsImage.tsx new file mode 100644 index 00000000000..54202039923 --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsImage.tsx @@ -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 implements ShareView { + static Component = ExportAsImageRenderer; + + public getTabLabel() { + return t('share-modal.image.title', 'Export as image'); + } +} + +function ExportAsImageRenderer({ model }: SceneComponentProps) { + 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 ; + } + + return ( +
+

+ Save this dashboard as an image +

+ +
+ {!imageBlob ? ( + + ) : ( + + )} + +
+ + {isLoading && ( +
+ Generating image... +
+ )} + + +
+ ); +} + +function RendererAlert() { + if (config.rendererAvailable) { + return null; + } + + return ( + +
{t('share-modal.link.render-alert', 'Image renderer plugin not installed')}
+
+ + To render an image, you must install the{' '} + + Grafana image renderer plugin + + . Please contact your Grafana administrator to install the plugin. + +
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + info: css({ + marginBottom: theme.spacing(2), + }), + buttonRow: css({ + display: 'flex', + gap: theme.spacing(2), + marginBottom: theme.spacing(2), + }), +}); diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.test.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.test.tsx index 22c5d54e16f..5fa8dc77b48 100644 --- a/public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.test.tsx +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.test.tsx @@ -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.findByTestId(selector.arrowMenu)).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /export dashboard/i })).toBeInTheDocument(); }); - it('should render menu when arrow button clicked', async () => { + it('should show menu when export button is clicked', async () => { setup(); - const arrowMenu = await screen.findByTestId(selector.arrowMenu); - await userEvent.click(arrowMenu); + const exportButton = await screen.findByRole('button', { name: /export dashboard/i }); + expect(exportButton).toHaveAttribute('aria-expanded', 'false'); - expect(await screen.findByTestId(selector.Menu.container)).toBeInTheDocument(); + await userEvent.click(exportButton); + + expect(exportButton).toHaveAttribute('aria-expanded', 'true'); + expect(await screen.findByRole('menu')).toBeInTheDocument(); + }); + + it('should show export options in the menu', async () => { + setup(); + + const exportButton = await screen.findByRole('button', { name: /export dashboard/i }); + await userEvent.click(exportButton); + + // Should show JSON export option + expect(await screen.findByRole('menuitem', { name: /export as json/i })).toBeInTheDocument(); }); }); diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.tsx index 5870eb7a5ec..7486301500b 100644 --- a/public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.tsx +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.tsx @@ -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 = () => ; return ( - + diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.test.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.test.tsx index 530dd8ae765..82af5dd953e 100644 --- a/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.test.tsx +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.test.tsx @@ -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(); } diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.tsx index a38a8fa1964..d5524108b07 100644 --- a/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.tsx +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.tsx @@ -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 ( - + {buildMenuItems().map((item) => ( onClick(item)} + testId={item.testId} /> ))} diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/utils.test.ts b/public/app/features/dashboard-scene/sharing/ExportButton/utils.test.ts new file mode 100644 index 00000000000..1c4ad371d0a --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ExportButton/utils.test.ts @@ -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 }) => { + 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, + }, + }); + }); + }); +}); diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/utils.ts b/public/app/features/dashboard-scene/sharing/ExportButton/utils.ts new file mode 100644 index 00000000000..64d84893b45 --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ExportButton/utils.ts @@ -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 { + 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({ + 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', + }; + } +} diff --git a/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.tsx b/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.tsx index 7b5b87a11cf..80a880a04c9 100644 --- a/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.tsx @@ -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 }); } diff --git a/public/app/features/dashboard-scene/sharing/ShareInternallyConfiguration.tsx b/public/app/features/dashboard-scene/sharing/ShareInternallyConfiguration.tsx index 0c32b943cda..3fcf705d9a9 100644 --- a/public/app/features/dashboard-scene/sharing/ShareInternallyConfiguration.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareInternallyConfiguration.tsx @@ -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" > Lock time range diff --git a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx index 6b6166b3eb9..91c4a9baa03 100644 --- a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx @@ -214,7 +214,7 @@ function ShareLinkTabRenderer({ model }: SceneComponentProps) { bottomSpacing={0} > - To render a panel image, you must install the{' '} + To render an image, you must install the{' '} { + 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(); + // 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(); + // 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(); + 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(); + 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(); + unmount(); + expect(mockRevokeObjectURL).toHaveBeenCalledWith('mock-url'); + }); + + it('should not show image when loading', () => { + const imageBlob = new Blob(['test'], { type: 'image/png' }); + render(); + 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(); + 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(); + 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); + }); +}); diff --git a/public/app/features/dashboard-scene/sharing/components/ImagePreview.tsx b/public/app/features/dashboard-scene/sharing/components/ImagePreview.tsx new file mode 100644 index 00000000000..0d54c68dff6 --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/components/ImagePreview.tsx @@ -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(); + + // 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 ( +
+ {isLoading && ( +
+ + {title && ( +
+ {title} +
+ )} +
+ )} + + {error && !isLoading && } + {!isLoading && imageUrl && ( + {t('share-modal.image.preview', + )} +
+ ); +} + +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 ( + + {showMessage &&
{error.message}
} +
+ ); +} + +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', + }), +}); diff --git a/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.test.tsx b/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.test.tsx index d2cbfaa14f7..a02ed3e5a1d 100644 --- a/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.test.tsx +++ b/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.test.tsx @@ -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(); - 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(); + // 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'); + + expect(screen.getByRole('button', { name: /generate image/i })).toBeEnabled(); + expect(screen.getByRole('button', { name: /download image/i })).toBeDisabled(); }); }); diff --git a/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.tsx b/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.tsx index ebea6880e9a..668a04a9b32 100644 --- a/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.tsx +++ b/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.tsx @@ -78,7 +78,7 @@ function SharePanelInternallyRenderer({ model }: SceneComponentProps - To render a panel image, you must install the{' '} + To render an image, you must install the{' '}
+
- + Panel preview -
+
@@ -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} > Generate image @@ -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} > Download image + {disabled && ( + + Save the dashboard to enable image generation + + )} + {!image && !loading && ( + + + Generate an image first to enable download + + + )}
- {loading && ( -
- -
- {title || ''} -
-
- )} - {image && !loading && panel-preview-img} - {error && !loading && ( - - {isFetchError(error) - ? error.statusText - : t('link.share-panel.render-image-error-description', 'An error occurred when generating the image')} - - )} + +
- +
); } @@ -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), - }), }); diff --git a/public/app/features/dashboard-scene/utils/interactions.ts b/public/app/features/dashboard-scene/utils/interactions.ts index c7a0791e513..8713caa4112 100644 --- a/public/app/features/dashboard-scene/utils/interactions.ts +++ b/public/app/features/dashboard-scene/utils/interactions.ts @@ -124,6 +124,14 @@ export const DashboardInteractions = { showMoreVersionsClicked: () => { reportDashboardInteraction('show_more_versions_clicked'); }, + + // Image export interactions + generateDashboardImageClicked: (properties?: Record) => { + reportDashboardInteraction('dashboard_image_generated', properties); + }, + downloadDashboardImageClicked: (properties?: Record) => { + reportDashboardInteraction('dashboard_image_downloaded', properties); + }, }; const reportDashboardInteraction: typeof reportInteraction = (name, properties) => { diff --git a/public/app/features/dashboard/components/ShareModal/ShareLink.tsx b/public/app/features/dashboard/components/ShareModal/ShareLink.tsx index b044e6b6b96..6c578299f66 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareLink.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareLink.tsx @@ -163,7 +163,7 @@ export class ShareLink extends PureComponent { bottomSpacing={0} > - To render a panel image, you must install the{' '} + To render an image, you must install the{' '} Grafana image renderer plugin diff --git a/public/app/features/dashboard/components/ShareModal/utils.ts b/public/app/features/dashboard/components/ShareModal/utils.ts index bcf6925da56..9693afc8e0f 100644 --- a/public/app/features/dashboard/components/ShareModal/utils.ts +++ b/public/app/features/dashboard/components/ShareModal/utils.ts @@ -181,4 +181,5 @@ export const shareDashboardType: { report: 'report', publicDashboard: 'public_dashboard', inviteUser: 'invite_user', + image: 'image', }; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index d7243ee9e13..804e7c186c3 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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. Please contact your Grafana administrator to install the plugin.", + "render-instructions": "To render an image, you must install the <2>Grafana image renderer plugin. 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" },