diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx index 97d683d92ab..cd324c10fe1 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx @@ -19,16 +19,28 @@ export function DashboardScenePage({ match, route, queryParams, history }: Props const routeReloadCounter = (history.location.state as any)?.routeReloadCounter; useEffect(() => { - stateManager.loadDashboard({ - uid: match.params.uid ?? '', - route: route.routeName as DashboardRoutes, - urlFolderUid: queryParams.folderUid, - }); + if (route.routeName === DashboardRoutes.Normal && match.params.type === 'snapshot') { + stateManager.loadSnapshot(match.params.slug!); + } else { + stateManager.loadDashboard({ + uid: match.params.uid ?? '', + route: route.routeName as DashboardRoutes, + urlFolderUid: queryParams.folderUid, + }); + } return () => { stateManager.clearState(); }; - }, [stateManager, match.params.uid, route.routeName, queryParams.folderUid, routeReloadCounter]); + }, [ + stateManager, + match.params.uid, + route.routeName, + queryParams.folderUid, + routeReloadCounter, + match.params.slug, + match.params.type, + ]); if (!dashboard) { return ( diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts index 0bd53cb8db3..c711e0723fa 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -56,6 +56,16 @@ describe('DashboardScenePageStateManager', () => { expect(loader.state.isLoading).toBe(false); }); + it('should use DashboardScene creator to initialize the snapshot scene', async () => { + setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); + + const loader = new DashboardScenePageStateManager({}); + await loader.loadSnapshot('fake-slug'); + + expect(loader.state.dashboard).toBeInstanceOf(DashboardScene); + expect(loader.state.isLoading).toBe(false); + }); + it('should initialize url sync', async () => { setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 3516e56a463..eea3b3816bb 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -116,6 +116,27 @@ export class DashboardScenePageStateManager extends StateManagerBase { + const rsp = await dashboardLoaderSrv.loadDashboard('snapshot', slug, ''); + + if (rsp?.dashboard) { + const scene = transformSaveModelToScene(rsp); + return scene; + } + + throw new Error('Snapshot not found'); + } + public async loadDashboard(options: LoadDashboardOptions) { try { const dashboard = await this.loadScene(options); diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx index 85c0986fcf9..c41f368b4dd 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx @@ -1,4 +1,5 @@ import { CoreApp } from '@grafana/data'; +import { config, locationService } from '@grafana/runtime'; import { sceneGraph, SceneGridItem, @@ -11,10 +12,12 @@ import { VizPanel, } from '@grafana/scenes'; import { Dashboard } from '@grafana/schema'; +import { ConfirmModal } from '@grafana/ui'; import appEvents from 'app/core/app_events'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { VariablesChanged } from 'app/features/variables/types'; +import { ShowModalReactEvent } from '../../../types/events'; import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { DecoratedRevisionModel } from '../settings/VersionsEditView'; import { historySrv } from '../settings/version-history/HistorySrv'; @@ -205,6 +208,63 @@ describe('DashboardScene', () => { } }); }); + + describe('when opening a dashboard from a snapshot', () => { + let scene: DashboardScene; + beforeEach(async () => { + scene = buildTestScene(); + locationService.push('/'); + // mockLocationHref('http://snapshots.grafana.com/snapshots/dashboard/abcdefghi/my-dash'); + const location = window.location; + + //@ts-ignore + delete window.location; + window.location = { + ...location, + href: 'http://snapshots.grafana.com/snapshots/dashboard/abcdefghi/my-dash', + }; + jest.spyOn(appEvents, 'publish'); + }); + + config.appUrl = 'http://snapshots.grafana.com/'; + + it('redirects to the original dashboard', () => { + scene.setInitialSaveModel({ + // @ts-ignore + snapshot: { originalUrl: '/d/c0d2742f-b827-466d-9269-fb34d6af24ff' }, + }); + + // Call the function + scene.onOpenSnapshotOriginalDashboard(); + + // Assertions + expect(appEvents.publish).toHaveBeenCalledTimes(0); + expect(locationService.getLocation().pathname).toEqual('/d/c0d2742f-b827-466d-9269-fb34d6af24ff'); + expect(window.location.href).toBe('http://snapshots.grafana.com/snapshots/dashboard/abcdefghi/my-dash'); + }); + + it('opens a confirmation modal', () => { + scene.setInitialSaveModel({ + // @ts-ignore + snapshot: { originalUrl: 'http://www.anotherdomain.com/' }, + }); + + // Call the function + scene.onOpenSnapshotOriginalDashboard(); + + // Assertions + expect(appEvents.publish).toHaveBeenCalledTimes(1); + expect(appEvents.publish).toHaveBeenCalledWith( + new ShowModalReactEvent( + expect.objectContaining({ + component: ConfirmModal, + }) + ) + ); + expect(locationService.getLocation().pathname).toEqual('/'); + expect(window.location.href).toBe('http://snapshots.grafana.com/snapshots/dashboard/abcdefghi/my-dash'); + }); + }); }); function buildTestScene(overrides?: Partial) { diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 96f9acba477..5a92f1c36af 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -1,8 +1,10 @@ +import { css } from '@emotion/css'; import * as H from 'history'; +import React from 'react'; import { Unsubscribable } from 'rxjs'; -import { CoreApp, DataQueryRequest, NavIndex, NavModelItem, locationUtil } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; +import { CoreApp, DataQueryRequest, NavIndex, NavModelItem, locationUtil, textUtil } from '@grafana/data'; +import { locationService, config } from '@grafana/runtime'; import { getUrlSyncManager, SceneFlexLayout, @@ -19,6 +21,7 @@ import { SceneVariableDependencyConfigLike, } from '@grafana/scenes'; import { Dashboard, DashboardLink } from '@grafana/schema'; +import { ConfirmModal } from '@grafana/ui'; import appEvents from 'app/core/app_events'; import { getNavModel } from 'app/core/selectors/navModel'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; @@ -26,7 +29,7 @@ import { DashboardModel } from 'app/features/dashboard/state'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { VariablesChanged } from 'app/features/variables/types'; import { DashboardDTO, DashboardMeta, SaveDashboardResponseDTO } from 'app/types'; -import { ShowConfirmModalEvent } from 'app/types/events'; +import { ShowModalReactEvent, ShowConfirmModalEvent } from 'app/types/events'; import { PanelEditor } from '../panel-edit/PanelEditor'; import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer'; @@ -423,6 +426,47 @@ export class DashboardScene extends SceneObjectBase { } } + public onOpenSnapshotOriginalDashboard = () => { + // @ts-ignore + const relativeURL = this.getInitialSaveModel()?.snapshot?.originalUrl ?? ''; + const sanitizedRelativeURL = textUtil.sanitizeUrl(relativeURL); + try { + const sanitizedAppUrl = new URL(sanitizedRelativeURL, config.appUrl); + const appUrl = new URL(config.appUrl); + if (sanitizedAppUrl.host !== appUrl.host) { + appEvents.publish( + new ShowModalReactEvent({ + component: ConfirmModal, + props: { + title: 'Proceed to external site?', + modalClass: css({ + width: 'max-content', + maxWidth: '80vw', + }), + body: ( + <> +

+ {`This link connects to an external website at`} {relativeURL} +

+

{"Are you sure you'd like to proceed?"}

+ + ), + confirmVariant: 'primary', + confirmText: 'Proceed', + onConfirm: () => { + window.location.href = sanitizedAppUrl.href; + }, + }, + }) + ); + } else { + locationService.push(sanitizedRelativeURL); + } + } catch (err) { + console.error('Failed to open original dashboard', err); + } + }; + public onOpenSettings = () => { locationService.partial({ editview: 'settings' }); }; diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index 31f61c7ad82..c3d5b25292d 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -72,7 +72,28 @@ export function ToolbarActions({ dashboard }: Props) { key="view-in-old-dashboard-button" tooltip={'Switch to old dashboard page'} icon="apps" - onClick={() => locationService.push(`/d/${uid}`)} + onClick={() => { + if (meta.isSnapshot) { + locationService.partial({ scenes: null }); + } else { + locationService.push(`/d/${uid}`); + } + }} + /> + ), + }); + + toolbarActions.push({ + group: 'icon-actions', + condition: meta.isSnapshot && !isEditing, + render: () => ( + { + dashboard.onOpenSnapshotOriginalDashboard(); + }} /> ), }); @@ -132,7 +153,7 @@ export function ToolbarActions({ dashboard }: Props) { toolbarActions.push({ group: 'main-buttons', - condition: uid && !isEditing, + condition: uid && !isEditing && !meta.isSnapshot, render: () => (