import * as H from 'history'; import { AppEvents, CoreApp, DataQueryRequest, NavIndex, NavModelItem, locationUtil, DataSourceGetTagKeysOptions, DataSourceGetTagValuesOptions, } from '@grafana/data'; import { config, locationService } from '@grafana/runtime'; import { SceneGridRow, SceneObject, SceneObjectBase, SceneObjectRef, SceneObjectState, SceneTimeRange, sceneUtils, SceneVariable, SceneVariableDependencyConfigLike, VizPanel, } from '@grafana/scenes'; import { Dashboard, DashboardLink, LibraryPanel } from '@grafana/schema'; import appEvents from 'app/core/app_events'; import { ScrollRefElement } from 'app/core/components/NativeScrollbar'; import { LS_PANEL_COPY_KEY } from 'app/core/constants'; import { getNavModel } from 'app/core/selectors/navModel'; import store from 'app/core/store'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { getClosestScopesFacade, ScopesFacade } from 'app/features/scopes'; import { VariablesChanged } from 'app/features/variables/types'; import { DashboardDTO, DashboardMeta, KioskMode, SaveDashboardResponseDTO } from 'app/types'; import { ShowConfirmModalEvent } from 'app/types/events'; import { PanelEditor } from '../panel-edit/PanelEditor'; import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker'; import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer'; import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { gridItemToPanel } from '../serialization/transformSceneToSaveModel'; import { DecoratedRevisionModel } from '../settings/VersionsEditView'; import { DashboardEditView } from '../settings/utils'; import { historySrv } from '../settings/version-history'; import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { djb2Hash } from '../utils/djb2Hash'; import { getDashboardUrl, getViewPanelUrl } from '../utils/urlBuilders'; import { getClosestVizPanel, getDashboardSceneFor, getDefaultVizPanel, getPanelIdForVizPanel, getVizPanelKeyForPanelId, isPanelClone, } from '../utils/utils'; import { AddLibraryPanelDrawer } from './AddLibraryPanelDrawer'; import { DashboardControls } from './DashboardControls'; import { DashboardGridItem } from './DashboardGridItem'; import { DashboardSceneRenderer } from './DashboardSceneRenderer'; import { DashboardSceneUrlSync } from './DashboardSceneUrlSync'; import { LibraryPanelBehavior } from './LibraryPanelBehavior'; import { RowRepeaterBehavior } from './RowRepeaterBehavior'; import { ViewPanelScene } from './ViewPanelScene'; import { setupKeyboardShortcuts } from './keyboardShortcuts'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; import { DashboardLayoutManager } from './types'; export const PERSISTED_PROPS = ['title', 'description', 'tags', 'editable', 'graphTooltip', 'links', 'meta', 'preload']; export interface DashboardSceneState extends SceneObjectState { /** The title */ title: string; /** The description */ description?: string; /** Tags */ tags?: string[]; /** Links */ links: DashboardLink[]; /** Is editable */ editable?: boolean; /** Allows disabling grid lazy loading */ preload?: boolean; /** A uid when saved */ uid?: string; /** @deprecated */ id?: number | null; /** Layout of panels */ body: DashboardLayoutManager; /** NavToolbar actions */ actions?: SceneObject[]; /** Fixed row at the top of the canvas with for example variables and time range controls */ controls?: DashboardControls; /** True when editing */ isEditing?: boolean; /** True when user made a change */ isDirty?: boolean; /** meta flags */ meta: DashboardMeta; /** Version of the dashboard */ version?: number; /** Panel to inspect */ inspectPanelKey?: string; /** Panel to view in fullscreen */ viewPanelScene?: ViewPanelScene; /** Edit view */ editview?: DashboardEditView; /** Edit panel */ editPanel?: PanelEditor; /** Scene object that handles the current drawer or modal */ overlay?: SceneObject; /** The dashboard doesn't have panels */ isEmpty?: boolean; /** Kiosk mode */ kioskMode?: KioskMode; /** Share view */ shareView?: string; } export class DashboardScene extends SceneObjectBase { static Component = DashboardSceneRenderer; /** * Handles url sync */ protected _urlSync = new DashboardSceneUrlSync(this); /** * Get notified when variables change */ protected _variableDependency = new DashboardVariableDependency(this); /** * State before editing started */ private _initialState?: DashboardSceneState; /** * The save model which the scene was originally created from */ private _initialSaveModel?: Dashboard; /** * Url state before editing started */ private _initialUrlState?: H.Location; /** * Dashboard changes tracker */ private _changeTracker: DashboardSceneChangeTracker; /** * Flag to indicate if the user came from Explore */ private _fromExplore = false; /** * A reference to the scopes facade */ private _scopesFacade: ScopesFacade | null; /** * Remember scroll position when going into panel edit */ private _scrollRef?: ScrollRefElement; private _prevScrollPos?: number; public constructor(state: Partial) { super({ title: 'Dashboard', meta: {}, editable: true, $timeRange: state.$timeRange ?? new SceneTimeRange({}), body: state.body ?? DefaultGridLayoutManager.fromVizPanels(), links: state.links ?? [], ...state, }); this._scopesFacade = getClosestScopesFacade(this); this._changeTracker = new DashboardSceneChangeTracker(this); this.addActivationHandler(() => this._activationHandler()); } private _activationHandler() { let prevSceneContext = window.__grafanaSceneContext; window.__grafanaSceneContext = this; if (this.state.isEditing) { this._initialUrlState = locationService.getLocation(); this._changeTracker.startTrackingChanges(); } if (this.state.meta.isNew) { this.onEnterEditMode(); this.setState({ isDirty: true }); } if (!this.state.meta.isEmbedded && this.state.uid) { dashboardWatcher.watch(this.state.uid); } let clearKeyBindings = () => {}; if (!config.publicDashboardAccessToken) { clearKeyBindings = setupKeyboardShortcuts(this); } const oldDashboardWrapper = new DashboardModelCompatibilityWrapper(this); // @ts-expect-error getDashboardSrv().setCurrent(oldDashboardWrapper); // Deactivation logic return () => { window.__grafanaSceneContext = prevSceneContext; clearKeyBindings(); this._changeTracker.terminate(); oldDashboardWrapper.destroy(); dashboardWatcher.leave(); }; } public onEnterEditMode = (fromExplore = false) => { this._fromExplore = fromExplore; // Save this state this._initialState = sceneUtils.cloneSceneObjectState(this.state); this._initialUrlState = locationService.getLocation(); // Switch to edit mode this.setState({ isEditing: true }); // Propagate change edit mode change to children this.state.body.editModeChanged(true); // Propagate edit mode to scopes this._scopesFacade?.enterReadOnly(); this._changeTracker.startTrackingChanges(); }; public saveCompleted(saveModel: Dashboard, result: SaveDashboardResponseDTO, folderUid?: string) { this._initialSaveModel = { ...saveModel, id: result.id, uid: result.uid, version: result.version, }; this._changeTracker.stopTrackingChanges(); this.setState({ version: result.version, isDirty: false, uid: result.uid, id: result.id, meta: { ...this.state.meta, uid: result.uid, url: result.url, slug: result.slug, folderUid: folderUid, isNew: false, }, }); this.state.editPanel?.dashboardSaved(); this._changeTracker.startTrackingChanges(); } public exitEditMode({ skipConfirm, restoreInitialState }: { skipConfirm: boolean; restoreInitialState?: boolean }) { if (!this.canDiscard()) { console.error('Trying to discard back to a state that does not exist, initialState undefined'); return; } if (!this.state.isDirty || skipConfirm) { this.exitEditModeConfirmed(restoreInitialState || this.state.isDirty); this._scopesFacade?.exitReadOnly(); return; } appEvents.publish( new ShowConfirmModalEvent({ title: 'Discard changes to dashboard?', text: `You have unsaved changes to this dashboard. Are you sure you want to discard them?`, icon: 'trash-alt', yesText: 'Discard', onConfirm: () => { this.exitEditModeConfirmed(); this._scopesFacade?.exitReadOnly(); }, }) ); } private exitEditModeConfirmed(restoreInitialState = true) { // No need to listen to changes anymore this._changeTracker.stopTrackingChanges(); // We are updating url and removing editview and editPanel. // The initial url may be including edit view, edit panel or inspect query params if the user pasted the url, // hence we need to cleanup those query params to get back to the dashboard view. Otherwise url sync can trigger overlays. locationService.replace( locationUtil.getUrlForPartial(this._initialUrlState!, { editPanel: null, editview: null, inspect: null, inspectTab: null, shareView: null, }) ); if (this._fromExplore) { this.cleanupStateFromExplore(); } if (restoreInitialState) { // Restore initial state and disable editing this.setState({ ...this._initialState, isEditing: false }); } else { // Do not restore this.setState({ isEditing: false }); } // if we are in edit panel, we need to onDiscard() // so the useEffect cleanup comes later and // doesn't try to commit the changes if (this.state.editPanel) { this.state.editPanel.onDiscard(); } // Disable grid dragging this.state.body.editModeChanged(false); } private cleanupStateFromExplore() { this._fromExplore = false; // When coming from explore but discarding changes, remove the panel that explore is potentially adding. if (this._initialSaveModel?.panels) { this._initialSaveModel.panels = this._initialSaveModel.panels.slice(1); } if (this._initialState) { this._initialState.body.cleanUpStateFromExplore?.(); } } public canDiscard() { return this._initialState !== undefined; } public pauseTrackingChanges() { this._changeTracker.stopTrackingChanges(); } public resumeTrackingChanges() { this._changeTracker.startTrackingChanges(); } public onRestore = async (version: DecoratedRevisionModel): Promise => { const versionRsp = await historySrv.restoreDashboard(version.uid, version.version); if (!Number.isInteger(versionRsp.version)) { return false; } const dashboardDTO: DashboardDTO = { dashboard: new DashboardModel(version.data), meta: this.state.meta, }; const dashScene = transformSaveModelToScene(dashboardDTO); const newState = sceneUtils.cloneSceneObjectState(dashScene.state); newState.version = versionRsp.version; this.setState(newState); this.exitEditMode({ skipConfirm: true, restoreInitialState: false }); return true; }; public openSaveDrawer({ saveAsCopy, onSaveSuccess }: { saveAsCopy?: boolean; onSaveSuccess?: () => void }) { if (!this.state.isEditing) { return; } this.setState({ overlay: new SaveDashboardDrawer({ dashboardRef: this.getRef(), saveAsCopy, onSaveSuccess, }), }); } public getPageNav(location: H.Location, navIndex: NavIndex) { const { meta, viewPanelScene, editPanel } = this.state; if (meta.dashboardNotFound) { return { text: 'Not found' }; } let pageNav: NavModelItem = { text: this.state.title, url: getDashboardUrl({ uid: this.state.uid, slug: meta.slug, currentQueryParams: location.search, updateQuery: { viewPanel: null, inspect: null, editview: null, editPanel: null, tab: null, shareView: null }, isHomeDashboard: !meta.url && !meta.slug && !meta.isNew, }), }; const { folderUid } = meta; if (folderUid) { const folderNavModel = getNavModel(navIndex, `folder-dashboards-${folderUid}`).main; // If the folder hasn't loaded (maybe user doesn't have permission on it?) then // don't show the "page not found" breadcrumb if (folderNavModel.id !== 'not-found') { pageNav = { ...pageNav, parentItem: folderNavModel, }; } } if (viewPanelScene) { pageNav = { text: 'View panel', parentItem: pageNav, url: getViewPanelUrl(viewPanelScene.state.panelRef.resolve()), }; } if (editPanel) { pageNav = { text: 'Edit panel', parentItem: pageNav, }; } return pageNav; } /** * Returns the body (layout) or the full view panel */ public getBodyToRender(): SceneObject { return this.state.viewPanelScene ?? this.state.body; } public getInitialState(): DashboardSceneState | undefined { return this._initialState; } public addPanel(vizPanel: VizPanel): void { if (!this.state.isEditing) { this.onEnterEditMode(); } this.state.body.addPanel(vizPanel); } public createLibraryPanel(panelToReplace: VizPanel, libPanel: LibraryPanel) { const body = panelToReplace.clone({ $behaviors: [new LibraryPanelBehavior({ uid: libPanel.uid, name: libPanel.name })], }); const gridItem = panelToReplace.parent; if (!(gridItem instanceof DashboardGridItem)) { throw new Error("Trying to replace a panel that doesn't have a parent grid item"); } gridItem.setState({ body }); } public duplicatePanel(vizPanel: VizPanel) { this.state.body.duplicatePanel(vizPanel); } public copyPanel(vizPanel: VizPanel) { if (!vizPanel.parent) { return; } let gridItem = vizPanel.parent; if (!(gridItem instanceof DashboardGridItem)) { console.error('Trying to copy a panel that is not DashboardGridItem child'); throw new Error('Trying to copy a panel that is not DashboardGridItem child'); } const jsonData = gridItemToPanel(gridItem); store.set(LS_PANEL_COPY_KEY, JSON.stringify(jsonData)); appEvents.emit(AppEvents.alertSuccess, ['Panel copied. Use **Paste panel** toolbar action to paste.']); } public pastePanel() { const jsonData = store.get(LS_PANEL_COPY_KEY); const jsonObj = JSON.parse(jsonData); const panelModel = new PanelModel(jsonObj); const gridItem = buildGridItemForPanel(panelModel); const panelId = dashboardSceneGraph.getNextPanelId(this); const panel = gridItem.state.body; panel.setState({ key: getVizPanelKeyForPanelId(panelId) }); panel.clearParent(); this.state.body.addPanel(panel); store.delete(LS_PANEL_COPY_KEY); } public removePanel(panel: VizPanel) { this.state.body.removePanel(panel); } public unlinkLibraryPanel(panel: VizPanel) { if (!panel.parent) { return; } const gridItem = panel.parent; if (!(gridItem instanceof DashboardGridItem)) { console.error('Trying to unlink a lib panel in a layout that is not DashboardGridItem'); return; } gridItem.state.body.setState({ $behaviors: undefined }); } public showModal(modal: SceneObject) { this.setState({ overlay: modal }); } public closeModal() { this.setState({ overlay: undefined }); } public async onStarDashboard() { const { meta, uid } = this.state; if (!uid) { return; } try { const result = await getDashboardSrv().starDashboard(uid, Boolean(meta.isStarred)); this.setState({ meta: { ...meta, isStarred: result, }, }); } catch (err) { console.error('Failed to star dashboard', err); } } public onOpenSettings = () => { locationService.partial({ editview: 'settings' }); }; public onShowAddLibraryPanelDrawer(panelToReplaceRef?: SceneObjectRef) { this.setState({ overlay: new AddLibraryPanelDrawer({ panelToReplaceRef }), }); } public onCreateNewRow() { this.state.body.addNewRow(); } public onCreateNewPanel(): VizPanel { const vizPanel = getDefaultVizPanel(this); this.addPanel(vizPanel); return vizPanel; } /** * Called by the SceneQueryRunner to privide contextural parameters (tracking) props for the request */ public enrichDataRequest(sceneObject: SceneObject): Partial { const dashboard = getDashboardSceneFor(sceneObject); let panel = getClosestVizPanel(sceneObject); if (dashboard.state.isEditing && dashboard.state.editPanel) { panel = dashboard.state.editPanel.state.panelRef.resolve(); } let panelId = 0; if (panel && panel.state.key) { if (isPanelClone(panel.state.key)) { panelId = djb2Hash(panel?.state.key); } else { panelId = getPanelIdForVizPanel(panel); } } return { app: CoreApp.Dashboard, dashboardUID: this.state.uid, panelId, panelPluginId: panel?.state.pluginId, scopes: this._scopesFacade?.value, }; } public enrichFiltersRequest(): Partial { return { scopes: this._scopesFacade?.value, }; } canEditDashboard() { const { meta } = this.state; return Boolean(meta.canEdit || meta.canMakeEditable); } public getInitialSaveModel() { return this._initialSaveModel; } /** Hacky temp function until we refactor transformSaveModelToScene a bit */ public setInitialSaveModel(saveModel: Dashboard) { this._initialSaveModel = saveModel; } public async onDashboardDelete() { // Need to mark it non dirty to navigate away without unsaved changes warning this.setState({ isDirty: false }); locationService.replace('/'); } public onSetScrollRef = (scrollElement: ScrollRefElement): void => { this._scrollRef = scrollElement; }; public rememberScrollPos() { this._prevScrollPos = this._scrollRef?.scrollTop; } public restoreScrollPos() { if (this._prevScrollPos !== undefined) { this._scrollRef?.scrollTo(0, this._prevScrollPos!); } } } export class DashboardVariableDependency implements SceneVariableDependencyConfigLike { private _emptySet = new Set(); public constructor(private _dashboard: DashboardScene) {} getNames(): Set { return this._emptySet; } public hasDependencyOn(): boolean { return false; } public variableUpdateCompleted(variable: SceneVariable, hasChanged: boolean) { if (hasChanged) { // Temp solution for some core panels (like dashlist) to know that variables have changed appEvents.publish(new VariablesChanged({ refreshAll: true, panelIds: [] })); } /** * Propagate variable changes to repeat row behavior as it does not get it when it's nested under local value * The first repeated row has the row repeater behavior but it also has a local SceneVariableSet with a local variable value */ const layout = this._dashboard.state.body; if (!(layout instanceof DefaultGridLayoutManager)) { return; } for (const child of layout.state.grid.state.children) { if (!(child instanceof SceneGridRow) || !child.state.$behaviors) { continue; } for (const behavior of child.state.$behaviors) { if (behavior instanceof RowRepeaterBehavior) { if (behavior.isWaitingForVariables || (behavior.state.variableName === variable.state.name && hasChanged)) { behavior.performRepeat(true); } else if (!behavior.isWaitingForVariables && behavior.state.variableName === variable.state.name) { behavior.notifyRepeatedPanelsWaitingForVariables(variable); } } } } } }