diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index bd049762d54..9aec15cb7fe 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -162,6 +162,7 @@ Experimental features might be changed or removed without prior notice. | `alertmanagerRemoteOnly` | Disable the internal Alertmanager and only use the external one defined. | | `annotationPermissionUpdate` | Separate annotation permissions from dashboard permissions to allow for more granular control. | | `extractFieldsNameDeduplication` | Make sure extracted field names are unique in the dataframe | +| `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles | ## Development feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 09eb6c3f322..9315579eb18 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -156,4 +156,5 @@ export interface FeatureToggles { alertmanagerRemoteOnly?: boolean; annotationPermissionUpdate?: boolean; extractFieldsNameDeduplication?: boolean; + dashboardSceneForViewers?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 157911d1972..d28bca5998e 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -967,5 +967,12 @@ var ( FrontendOnly: true, Owner: grafanaBiSquad, }, + { + Name: "dashboardSceneForViewers", + Description: "Enables dashboard rendering using Scenes for viewer roles", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaDashboardsSquad, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index f0b8125952d..8276c937fa3 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -137,3 +137,4 @@ alertmanagerRemotePrimary,experimental,@grafana/alerting-squad,false,false,false alertmanagerRemoteOnly,experimental,@grafana/alerting-squad,false,false,false,false annotationPermissionUpdate,experimental,@grafana/grafana-authnz-team,false,false,false,false extractFieldsNameDeduplication,experimental,@grafana/grafana-bi-squad,false,false,false,true +dashboardSceneForViewers,experimental,@grafana/dashboards-squad,false,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 28f9e832dc8..bd3493e5147 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -558,4 +558,8 @@ const ( // FlagExtractFieldsNameDeduplication // Make sure extracted field names are unique in the dataframe FlagExtractFieldsNameDeduplication = "extractFieldsNameDeduplication" + + // FlagDashboardSceneForViewers + // Enables dashboard rendering using Scenes for viewer roles + FlagDashboardSceneForViewers = "dashboardSceneForViewers" ) diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx index 5c27e377406..9b04f129591 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx @@ -5,26 +5,32 @@ import { PageLayoutType } from '@grafana/data'; import { Page } from 'app/core/components/Page/Page'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { DashboardPageRouteParams } from 'app/features/dashboard/containers/types'; +import { DashboardRoutes } from 'app/types'; import { getDashboardScenePageStateManager } from './DashboardScenePageStateManager'; -export interface Props extends GrafanaRouteComponentProps<{ uid: string }> {} +export interface Props extends GrafanaRouteComponentProps {} -export function DashboardScenePage({ match }: Props) { +export function DashboardScenePage({ match, route }: Props) { const stateManager = getDashboardScenePageStateManager(); const { dashboard, isLoading, loadError } = stateManager.useState(); useEffect(() => { - stateManager.loadDashboard(match.params.uid); + if (route.routeName === DashboardRoutes.Home) { + stateManager.loadDashboard(route.routeName); + } else { + stateManager.loadDashboard(match.params.uid!); + } return () => { stateManager.clearState(); }; - }, [stateManager, match.params.uid]); + }, [stateManager, match.params.uid, route.routeName]); if (!dashboard) { return ( - + {isLoading && } {loadError &&

{loadError}

}
diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts index f0a8eef1a0c..dce180fa0df 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -1,15 +1,17 @@ +import { advanceBy } from 'jest-date-mock'; + import { locationService } from '@grafana/runtime'; import { getUrlSyncManager } from '@grafana/scenes'; import { DashboardScene } from '../scene/DashboardScene'; import { setupLoadDashboardMock } from '../utils/test-utils'; -import { DashboardScenePageStateManager } from './DashboardScenePageStateManager'; +import { DashboardScenePageStateManager, DASHBOARD_CACHE_TTL } from './DashboardScenePageStateManager'; describe('DashboardScenePageStateManager', () => { describe('when fetching/loading a dashboard', () => { it('should call loader from server if the dashboard is not cached', async () => { - const loadDashboardMock = setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); + const loadDashboardMock = setupLoadDashboardMock({ dashboard: { uid: 'fake-dash', editable: true }, meta: {} }); const loader = new DashboardScenePageStateManager({}); await loader.loadDashboard('fake-dash'); @@ -74,5 +76,39 @@ describe('DashboardScenePageStateManager', () => { expect(dash2!.state.$timeRange?.state.from).toEqual('now-10m'); }); + + describe('caching', () => { + it('should cache the dashboard DTO', async () => { + setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); + + const loader = new DashboardScenePageStateManager({}); + + expect(loader.getFromCache('fake-dash')).toBeNull(); + + await loader.loadDashboard('fake-dash'); + + expect(loader.getFromCache('fake-dash')).toBeDefined(); + }); + + it('should load dashboard DTO from cache if requested again within 2s', async () => { + const loadDashSpy = jest.fn(); + setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }, loadDashSpy); + + const loader = new DashboardScenePageStateManager({}); + + expect(loader.getFromCache('fake-dash')).toBeNull(); + + await loader.fetchDashboard('fake-dash'); + expect(loadDashSpy).toHaveBeenCalledTimes(1); + + advanceBy(DASHBOARD_CACHE_TTL / 2); + await loader.fetchDashboard('fake-dash'); + expect(loadDashSpy).toHaveBeenCalledTimes(1); + + advanceBy(DASHBOARD_CACHE_TTL / 2 + 1); + await loader.fetchDashboard('fake-dash'); + expect(loadDashSpy).toHaveBeenCalledTimes(2); + }); + }); }); }); diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 78faff65731..b8ec4022740 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -1,6 +1,14 @@ +import { locationUtil } from '@grafana/data'; +import { getBackendSrv, isFetchError, locationService } from '@grafana/runtime'; +import { updateNavIndex } from 'app/core/actions'; import { StateManagerBase } from 'app/core/services/StateManagerBase'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { newBrowseDashboardsEnabled } from 'app/features/browse-dashboards/featureFlag'; import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { buildNavModel } from 'app/features/folders/state/navModel'; +import { store } from 'app/store/store'; +import { DashboardDTO, DashboardMeta, DashboardRoutes } from 'app/types'; import { buildPanelEditScene, PanelEditor } from '../panel-edit/PanelEditor'; import { DashboardScene } from '../scene/DashboardScene'; @@ -14,8 +22,69 @@ export interface DashboardScenePageState { loadError?: string; } +export const DASHBOARD_CACHE_TTL = 2000; + +interface DashboardCacheEntry { + dashboard: DashboardDTO; + ts: number; +} export class DashboardScenePageStateManager extends StateManagerBase { private cache: Record = {}; + // This is a simplistic, short-term cache for DashboardDTOs to avoid fetching the same dashboard multiple times across a short time span. + private dashboardCache: Map = new Map(); + + // To eventualy replace the fetchDashboard function from Dashboard redux state management. + // For now it's a simplistic version to support Home and Normal dashboard routes. + public async fetchDashboard(uid: string) { + const cachedDashboard = this.getFromCache(uid); + + if (cachedDashboard) { + return cachedDashboard; + } + + let rsp: DashboardDTO | undefined; + + try { + if (uid === DashboardRoutes.Home) { + rsp = await getBackendSrv().get('/api/dashboards/home'); + + // If user specified a custom home dashboard redirect to that + if (rsp?.redirectUri) { + const newUrl = locationUtil.stripBaseFromUrl(rsp.redirectUri); + locationService.replace(newUrl); + return null; + } + + if (rsp?.meta) { + rsp.meta.canSave = false; + rsp.meta.canShare = false; + rsp.meta.canStar = false; + } + } else { + rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid); + } + + if (rsp) { + // Fill in meta fields + const dashboard = this.initDashboardMeta(rsp); + + // Populate nav model in global store according to the folder + await this.initNavModel(dashboard); + + this.dashboardCache.set(uid, { dashboard, ts: Date.now() }); + } + } catch (e) { + // Ignore cancelled errors + if (isFetchError(e) && e.cancelled) { + return null; + } + + console.error(e); + throw e; + } + + return rsp; + } public async loadDashboard(uid: string) { try { @@ -55,10 +124,11 @@ export class DashboardScenePageStateManager extends StateManagerBase DASHBOARD_CACHE_TTL; + } + + private initDashboardMeta(dashboard: DashboardDTO): DashboardDTO { + return { + ...dashboard, + meta: initDashboardMeta(dashboard.meta, Boolean(dashboard.dashboard?.editable)), + }; + } + + private async initNavModel(dashboard: DashboardDTO) { + // only the folder API has information about ancestors + // get parent folder (if it exists) and put it in the store + // this will be used to populate the full breadcrumb trail + if (newBrowseDashboardsEnabled() && dashboard.meta.folderUid) { + try { + const folder = await backendSrv.getFolderByUid(dashboard.meta.folderUid); + store.dispatch(updateNavIndex(buildNavModel(folder))); + } catch (err) { + console.warn('Error fetching parent folder', dashboard.meta.folderUid, 'for dashboard', err); + } + } + } + public clearState() { getDashboardSrv().setCurrent(undefined); this.setState({ dashboard: undefined, loadError: undefined, isLoading: false, panelEditor: undefined }); } + + public setDashboardCache(uid: string, dashboard: DashboardDTO) { + this.dashboardCache.set(uid, { dashboard, ts: Date.now() }); + } } let stateManager: DashboardScenePageStateManager | null = null; @@ -81,3 +190,25 @@ export function getDashboardScenePageStateManager(): DashboardScenePageStateMana return stateManager; } + +function initDashboardMeta(source: DashboardMeta, isEditable: boolean) { + const result = source ? { ...source } : {}; + + result.canShare = source.canShare !== false; + result.canSave = source.canSave !== false; + result.canStar = source.canStar !== false; + result.canEdit = source.canEdit !== false; + result.canDelete = source.canDelete !== false; + + result.showSettings = source.canEdit; + result.canMakeEditable = source.canSave && !isEditable; + result.hasUnsavedFolderChange = false; + + if (!isEditable) { + result.canEdit = false; + result.canDelete = false; + result.canSave = false; + } + + return result; +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index 7d0416a645f..b63244c728c 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -1,5 +1,6 @@ import * as H from 'history'; +import { NavIndex } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { getUrlSyncManager, @@ -54,10 +55,10 @@ export class PanelEditor extends SceneObjectBase { getUrlSyncManager().initSync(this); } - public getPageNav(location: H.Location) { + public getPageNav(location: H.Location, navIndex: NavIndex) { return { text: 'Edit panel', - parentItem: this.state.dashboardRef.resolve().getPageNav(location), + parentItem: this.state.dashboardRef.resolve().getPageNav(location, navIndex), }; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx index 9af29338ca7..d44801cbaa5 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx @@ -8,6 +8,7 @@ import { Button, useStyles2 } from '@grafana/ui'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator'; import { Page } from 'app/core/components/Page/Page'; +import { useSelector } from 'app/types/store'; import { PanelEditor } from './PanelEditor'; @@ -15,7 +16,8 @@ export function PanelEditorRenderer({ model }: SceneComponentProps) const { body, controls, drawer } = model.useState(); const styles = useStyles2(getStyles); const location = useLocation(); - const pageNav = model.getPageNav(location); + const navIndex = useSelector((state) => state.navIndex); + const pageNav = model.getPageNav(location, navIndex); return ( diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 4a849ee9c94..e380d2da55e 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -1,8 +1,8 @@ import * as H from 'history'; import { Unsubscribable } from 'rxjs'; -import { CoreApp, DataQueryRequest, NavModelItem, UrlQueryMap } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; +import { CoreApp, DataQueryRequest, NavIndex, NavModelItem, UrlQueryMap } from '@grafana/data'; +import { config, locationService } from '@grafana/runtime'; import { getUrlSyncManager, SceneFlexLayout, @@ -14,6 +14,8 @@ import { SceneObjectStateChangedEvent, sceneUtils, } from '@grafana/scenes'; +import { getNavModel } from 'app/core/selectors/navModel'; +import { newBrowseDashboardsEnabled } from 'app/features/browse-dashboards/featureFlag'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { DashboardMeta } from 'app/types'; @@ -155,16 +157,45 @@ export class DashboardScene extends SceneObjectBase { this.setState({ overlay: new SaveDashboardDrawer({ dashboardRef: this.getRef() }) }); }; - public getPageNav(location: H.Location) { + public getPageNav(location: H.Location, navIndex: NavIndex) { + const { meta } = this.state; + let pageNav: NavModelItem = { text: this.state.title, url: getDashboardUrl({ uid: this.state.uid, currentQueryParams: location.search, updateQuery: { viewPanel: null, inspect: null }, + useExperimentalURL: Boolean(config.featureToggles.dashboardSceneForViewers && meta.canEdit), }), }; + const { folderTitle, folderUid } = meta; + + if (folderUid) { + if (newBrowseDashboardsEnabled()) { + 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, + }; + } + } else { + if (folderTitle) { + pageNav = { + ...pageNav, + parentItem: { + text: folderTitle, + url: `/dashboards/f/${meta.folderUid}`, + }, + }; + } + } + } + if (this.state.viewPanelKey) { pageNav = { text: 'View panel', @@ -216,6 +247,25 @@ export class DashboardScene extends SceneObjectBase { 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); + } + } + /** * Called by the SceneQueryRunner to privide contextural parameters (tracking) props for the request */ @@ -230,6 +280,8 @@ export class DashboardScene extends SceneObjectBase { } canEditDashboard() { - return Boolean(this.state.meta.canEdit || this.state.meta.canMakeEditable); + const { meta } = this.state; + + return Boolean(meta.canEdit || meta.canMakeEditable); } } diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx index 63bb7df05e6..74943eb95fb 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx @@ -1,11 +1,14 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import React from 'react'; import { useLocation } from 'react-router-dom'; import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { SceneComponentProps, SceneDebugger } from '@grafana/scenes'; import { CustomScrollbar, useStyles2 } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; +import { getNavModel } from 'app/core/selectors/navModel'; +import { useSelector } from 'app/types'; import { DashboardScene } from './DashboardScene'; import { NavToolbarActions } from './NavToolbarActions'; @@ -14,14 +17,20 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps state.navIndex); + const pageNav = model.getPageNav(location, navIndex); const bodyToRender = model.getBodyToRender(viewPanelId); + const navProps = config.featureToggles.dashboardSceneForViewers + ? { navModel: getNavModel(navIndex, 'dashboards/browse') } + : { navId: 'scenes' }; + return ( - +
+ {controls && (
{controls.map((control) => ( @@ -30,7 +39,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps
)} -
+
@@ -57,6 +66,7 @@ function getStyles(theme: GrafanaTheme2) { gap: '8px', marginBottom: theme.spacing(2), }), + controls: css({ display: 'flex', flexWrap: 'wrap', diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index 2a634c2b5c5..6f5b4285c67 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -16,10 +16,28 @@ interface Props { } export const NavToolbarActions = React.memo(({ dashboard }) => { - const { actions = [], isEditing, viewPanelKey, isDirty, uid } = dashboard.useState(); + const { actions = [], isEditing, viewPanelKey, isDirty, uid, meta } = dashboard.useState(); const toolbarActions = (actions ?? []).map((action) => ); if (uid) { + if (meta.canStar) { + let desc = meta.isStarred + ? t('dashboard.toolbar.unmark-favorite', 'Unmark as favorite') + : t('dashboard.toolbar.mark-favorite', 'Mark as favorite'); + + toolbarActions.push( + { + dashboard.onStarDashboard(); + }} + /> + ); + } toolbarActions.push( (({ dashboard }) => { } if (!isEditing) { - // TODO check permissions - toolbarActions.push( - - ); + if (dashboard.canEditDashboard()) { + toolbarActions.push( + + ); + } } else { - // TODO check permissions - toolbarActions.push( - - ); - toolbarActions.push( - - ); - toolbarActions.push( - - ); + if (dashboard.canEditDashboard()) { + toolbarActions.push( + + ); + toolbarActions.push( + + ); + toolbarActions.push( + + ); + } } return ; diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx index 383cccb445d..199a2e06586 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx @@ -80,6 +80,9 @@ async function buildTestScene(options: SceneOptions) { const scene = new DashboardScene({ title: 'hello', uid: 'dash-1', + meta: { + canEdit: true, + }, body: new SceneGridLayout({ children: [ new SceneGridItem({ diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx index 343c5030447..70699f37b4d 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx @@ -35,19 +35,22 @@ export function panelMenuBehavior(menu: VizPanelMenu) { href: getViewPanelUrl(panel), }); - // We could check isEditing here but I kind of think this should always be in the menu, - // and going into panel edit should make the dashboard go into edit mode is it's not already - items.push({ - text: t('panel.header-menu.edit', `Edit`), - iconClassName: 'eye', - shortcut: 'e', - onClick: () => reportInteraction('dashboards_panelheader_menu', { item: 'edit' }), - href: getDashboardUrl({ - uid: dashboard.state.uid, - subPath: `/panel-edit/${panelId}`, - currentQueryParams: location.search, - }), - }); + if (dashboard.canEditDashboard()) { + // We could check isEditing here but I kind of think this should always be in the menu, + // and going into panel edit should make the dashboard go into edit mode is it's not already + items.push({ + text: t('panel.header-menu.edit', `Edit`), + iconClassName: 'eye', + shortcut: 'e', + onClick: () => reportInteraction('dashboards_panelheader_menu', { item: 'edit' }), + href: getDashboardUrl({ + uid: dashboard.state.uid, + subPath: `/panel-edit/${panelId}`, + currentQueryParams: location.search, + useExperimentalURL: true, + }), + }); + } items.push({ text: t('panel.header-menu.share', `Share`), diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index 7057b410a2f..68c86ce9083 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -203,18 +203,23 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel) }); } - const controls: SceneObject[] = [ + let controls: SceneObject[] = [ new VariableValueSelectors({}), ...filtersSets, new SceneDataLayerControls(), new SceneControlsSpacer(), - new SceneTimePicker({}), - new SceneRefreshPicker({ - refresh: oldModel.refresh, - intervals: oldModel.timepicker.refresh_intervals, - }), ]; + if (!Boolean(oldModel.timepicker.hidden)) { + controls = controls.concat([ + new SceneTimePicker({}), + new SceneRefreshPicker({ + refresh: oldModel.refresh, + intervals: oldModel.timepicker.refresh_intervals, + }), + ]); + } + return new DashboardScene({ title: oldModel.title, uid: oldModel.uid, diff --git a/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx b/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx index 1cfe3167fc6..e9e2e35a7ab 100644 --- a/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx @@ -29,6 +29,7 @@ describe('ShareLinkTab', () => { config.appUrl = 'http://dashboards.grafana.com/grafana/'; config.rendererAvailable = true; config.bootData.user.orgId = 1; + config.featureToggles.dashboardSceneForViewers = true; locationService.push('/scenes/dashboard/dash-1?from=now-6h&to=now'); }); @@ -98,6 +99,9 @@ function buildAndRenderScenario(options: ScenarioOptions) { const dashboard = new DashboardScene({ title: 'hello', uid: 'dash-1', + meta: { + canEdit: true, + }, $timeRange: new SceneTimeRange({}), body: new SceneGridLayout({ children: [ diff --git a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx index f5c2db5387a..61e38a55d65 100644 --- a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx @@ -74,6 +74,7 @@ export class ShareLinkTab extends SceneObjectBase { currentQueryParams: location.search, updateQuery: urlParamsUpdate, absolute: true, + useExperimentalURL: Boolean(config.featureToggles.dashboardSceneForViewers && dashboard.state.meta.canEdit), }); if (useShortUrl) { diff --git a/public/app/features/dashboard-scene/utils/test-utils.ts b/public/app/features/dashboard-scene/utils/test-utils.ts index f0fae6b310b..aa8a8afe29f 100644 --- a/public/app/features/dashboard-scene/utils/test-utils.ts +++ b/public/app/features/dashboard-scene/utils/test-utils.ts @@ -18,8 +18,8 @@ import { DashboardDTO } from 'app/types'; import { PanelRepeaterGridItem, RepeatDirection } from '../scene/PanelRepeaterGridItem'; import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; -export function setupLoadDashboardMock(rsp: DeepPartial) { - const loadDashboardMock = jest.fn().mockResolvedValue(rsp); +export function setupLoadDashboardMock(rsp: DeepPartial, spy?: jest.Mock) { + const loadDashboardMock = (spy || jest.fn()).mockResolvedValue(rsp); setDashboardLoaderSrv({ loadDashboard: loadDashboardMock, // disabling type checks since this is a test util diff --git a/public/app/features/dashboard-scene/utils/urlBuilders.test.ts b/public/app/features/dashboard-scene/utils/urlBuilders.test.ts index 7c7e2899f4c..192f6e8f78d 100644 --- a/public/app/features/dashboard-scene/utils/urlBuilders.test.ts +++ b/public/app/features/dashboard-scene/utils/urlBuilders.test.ts @@ -2,13 +2,18 @@ import { getDashboardUrl } from './urlBuilders'; describe('dashboard utils', () => { it('Can getUrl', () => { - const url = getDashboardUrl({ uid: 'dash-1', currentQueryParams: '?orgId=1&filter=A' }); + const url = getDashboardUrl({ uid: 'dash-1', currentQueryParams: '?orgId=1&filter=A', useExperimentalURL: true }); expect(url).toBe('/scenes/dashboard/dash-1?orgId=1&filter=A'); }); it('Can getUrl with subpath', () => { - const url = getDashboardUrl({ uid: 'dash-1', subPath: '/panel-edit/2', currentQueryParams: '?orgId=1&filter=A' }); + const url = getDashboardUrl({ + uid: 'dash-1', + subPath: '/panel-edit/2', + currentQueryParams: '?orgId=1&filter=A', + useExperimentalURL: true, + }); expect(url).toBe('/scenes/dashboard/dash-1/panel-edit/2?orgId=1&filter=A'); }); @@ -18,6 +23,7 @@ describe('dashboard utils', () => { uid: 'dash-1', currentQueryParams: '?orgId=1&filter=A', updateQuery: { filter: null, new: 'A' }, + useExperimentalURL: true, }); expect(url).toBe('/scenes/dashboard/dash-1?orgId=1&new=A'); diff --git a/public/app/features/dashboard-scene/utils/urlBuilders.ts b/public/app/features/dashboard-scene/utils/urlBuilders.ts index 1539a04f348..7d3bfc5b274 100644 --- a/public/app/features/dashboard-scene/utils/urlBuilders.ts +++ b/public/app/features/dashboard-scene/utils/urlBuilders.ts @@ -20,10 +20,15 @@ export interface DashboardUrlOptions { absolute?: boolean; // Add tz to query params timeZone?: string; + + // Add tz to query params + useExperimentalURL?: boolean; } export function getDashboardUrl(options: DashboardUrlOptions) { - let path = `/scenes/dashboard/${options.uid}${options.subPath ?? ''}`; + let path = options.useExperimentalURL + ? `/scenes/dashboard/${options.uid}${options.subPath ?? ''}` + : `/d/${options.uid}${options.subPath ?? ''}`; if (options.soloRoute) { path = `/d-solo/${options.uid}${options.subPath ?? ''}`; diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index b2975758d84..8e20cf9a98b 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -40,27 +40,7 @@ import { cleanUpDashboardAndVariables } from '../state/actions'; import { initDashboard } from '../state/initDashboard'; import { calculateNewPanelGridPos } from '../utils/panel'; -export interface DashboardPageRouteParams { - uid?: string; - type?: string; - slug?: string; - accessToken?: string; -} - -export type DashboardPageRouteSearchParams = { - tab?: string; - folderUid?: string; - editPanel?: string; - viewPanel?: string; - editview?: string; - addWidget?: boolean; - panelType?: string; - inspect?: string; - from?: string; - to?: string; - refresh?: string; - kiosk?: string | true; -}; +import { DashboardPageRouteParams, DashboardPageRouteSearchParams } from './types'; export const mapStateToProps = (state: StoreState) => ({ initPhase: state.dashboard.initPhase, diff --git a/public/app/features/dashboard/containers/DashboardPageProxy.test.tsx b/public/app/features/dashboard/containers/DashboardPageProxy.test.tsx new file mode 100644 index 00000000000..17bd3adb68b --- /dev/null +++ b/public/app/features/dashboard/containers/DashboardPageProxy.test.tsx @@ -0,0 +1,171 @@ +import { render, screen, act, waitFor } from '@testing-library/react'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { Router } from 'react-router-dom'; +import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; + +import { config, locationService } from '@grafana/runtime'; +import { GrafanaContext } from 'app/core/context/GrafanaContext'; +import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager'; +import { configureStore } from 'app/store/configureStore'; +import { DashboardDTO, DashboardRoutes } from 'app/types'; + +import DashboardPageProxy, { DashboardPageProxyProps } from './DashboardPageProxy'; + +const dashMock: DashboardDTO = { + dashboard: { + id: 1, + annotations: { + list: [], + }, + uid: 'uid', + title: 'title', + panels: [], + version: 1, + schemaVersion: 1, + }, + meta: { + canEdit: false, + }, +}; + +const dashMockEditable = { + ...dashMock, + meta: { + ...dashMock.meta, + canEdit: true, + }, +}; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: jest.fn().mockReturnValue({ + getInstanceSettings: () => { + return { name: 'Grafana' }; + }, + get: jest.fn().mockResolvedValue({}), + }), +})); + +function setup(props: Partial) { + const context = getGrafanaContextMock(); + const store = configureStore({}); + + return render( + + + + null, path: '/' }} + match={{ params: {}, isExact: true, path: '/', url: '/' }} + {...props} + /> + + + + ); +} + +describe('DashboardPageProxy', () => { + describe('when dashboardSceneForViewers feature toggle disabled', () => { + beforeEach(() => { + config.featureToggles.dashboardSceneForViewers = false; + }); + + it('home dashboard', async () => { + getDashboardScenePageStateManager().setDashboardCache(DashboardRoutes.Home, dashMock); + act(() => { + setup({ + route: { routeName: DashboardRoutes.Home, component: () => null, path: '/' }, + match: { params: {}, isExact: true, path: '/', url: '/' }, + }); + }); + + await waitFor(() => { + expect(screen.queryAllByTestId('dashboard-scene-page')).toHaveLength(0); + }); + }); + + it('uid dashboard', async () => { + getDashboardScenePageStateManager().setDashboardCache('abc-def', dashMock); + + act(() => { + setup({ + route: { routeName: DashboardRoutes.Home, component: () => null, path: '/' }, + match: { params: {}, isExact: true, path: '/', url: '/' }, + }); + }); + + await waitFor(() => { + expect(screen.queryAllByTestId('dashboard-scene-page')).toHaveLength(0); + }); + }); + }); + + describe('when dashboardSceneForViewers feature toggle enabled', () => { + beforeEach(() => { + config.featureToggles.dashboardSceneForViewers = true; + }); + + describe('when user can edit a dashboard ', () => { + it('should not render DashboardScenePage if route is Home', async () => { + getDashboardScenePageStateManager().setDashboardCache(DashboardRoutes.Home, dashMockEditable); + act(() => { + setup({ + route: { routeName: DashboardRoutes.Home, component: () => null, path: '/' }, + match: { params: {}, isExact: true, path: '/', url: '/' }, + }); + }); + + await waitFor(() => { + expect(screen.queryAllByTestId('dashboard-scene-page')).toHaveLength(0); + }); + }); + + it('should not render DashboardScenePage if route is Normal and has uid', async () => { + getDashboardScenePageStateManager().setDashboardCache('abc-def', dashMockEditable); + act(() => { + setup({ + route: { routeName: DashboardRoutes.Normal, component: () => null, path: '/' }, + match: { params: { uid: 'abc-def' }, isExact: true, path: '/', url: '/' }, + }); + }); + await waitFor(() => { + expect(screen.queryAllByTestId('dashboard-scene-page')).toHaveLength(0); + }); + }); + }); + + describe('when user can only view a dashboard ', () => { + it('should render DashboardScenePage if route is Home', async () => { + getDashboardScenePageStateManager().setDashboardCache(DashboardRoutes.Home, dashMock); + act(() => { + setup({ + route: { routeName: DashboardRoutes.Home, component: () => null, path: '/' }, + match: { params: {}, isExact: true, path: '/', url: '/' }, + }); + }); + + await waitFor(() => { + expect(screen.queryAllByTestId('dashboard-scene-page')).toHaveLength(1); + }); + }); + + it('should render DashboardScenePage if route is Normal and has uid', async () => { + getDashboardScenePageStateManager().setDashboardCache('abc-def', dashMock); + act(() => { + setup({ + route: { routeName: DashboardRoutes.Normal, component: () => null, path: '/' }, + match: { params: { uid: 'abc-def' }, isExact: true, path: '/', url: '/' }, + }); + }); + await waitFor(() => { + expect(screen.queryAllByTestId('dashboard-scene-page')).toHaveLength(1); + }); + }); + }); + }); +}); diff --git a/public/app/features/dashboard/containers/DashboardPageProxy.tsx b/public/app/features/dashboard/containers/DashboardPageProxy.tsx new file mode 100644 index 00000000000..019eedfe11b --- /dev/null +++ b/public/app/features/dashboard/containers/DashboardPageProxy.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { useAsync } from 'react-use'; + +import { config } from '@grafana/runtime'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import DashboardScenePage from 'app/features/dashboard-scene/pages/DashboardScenePage'; +import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager'; +import { DashboardRoutes } from 'app/types'; + +import DashboardPage from './DashboardPage'; +import { DashboardPageRouteParams, DashboardPageRouteSearchParams } from './types'; + +export type DashboardPageProxyProps = GrafanaRouteComponentProps< + DashboardPageRouteParams, + DashboardPageRouteSearchParams +>; + +// This proxy component is used for Dashboard -> Scenes migration. +// It will render DashboardScenePage if the user is only allowed to view the dashboard. +function DashboardPageProxy(props: DashboardPageProxyProps) { + const stateManager = getDashboardScenePageStateManager(); + const isScenesSupportedRoute = Boolean( + props.route.routeName === DashboardRoutes.Home || + (props.route.routeName === DashboardRoutes.Normal && props.match.params.uid) + ); + + // We pre-fetch dashboard to render dashboard page component depending on dashboard permissions. + // To avoid querying single dashboard multiple times, stateManager.fetchDashboard uses a simple, short-lived cache. + const dashboard = useAsync(async () => { + const dashToFetch = props.route.routeName === DashboardRoutes.Home ? props.route.routeName : props.match.params.uid; + + if (!dashToFetch) { + return null; + } + + return stateManager.fetchDashboard(dashToFetch); + }, [props.match.params.uid, props.route.routeName]); + + if (!config.featureToggles.dashboardSceneForViewers) { + return ; + } + + if (dashboard.loading) { + return null; + } + + if (dashboard.value && !dashboard.value.meta.canEdit && isScenesSupportedRoute) { + return ; + } else { + return ; + } +} + +export default DashboardPageProxy; diff --git a/public/app/features/dashboard/containers/types.ts b/public/app/features/dashboard/containers/types.ts new file mode 100644 index 00000000000..d7416f6f104 --- /dev/null +++ b/public/app/features/dashboard/containers/types.ts @@ -0,0 +1,21 @@ +export interface DashboardPageRouteParams { + uid?: string; + type?: string; + slug?: string; + accessToken?: string; +} + +export type DashboardPageRouteSearchParams = { + tab?: string; + folderUid?: string; + editPanel?: string; + viewPanel?: string; + editview?: string; + addWidget?: boolean; + panelType?: string; + inspect?: string; + from?: string; + to?: string; + refresh?: string; + kiosk?: string | true; +}; diff --git a/public/app/features/dashboard/services/DashboardLoaderSrv.ts b/public/app/features/dashboard/services/DashboardLoaderSrv.ts index b11de1ddeda..c55aa2f99de 100644 --- a/public/app/features/dashboard/services/DashboardLoaderSrv.ts +++ b/public/app/features/dashboard/services/DashboardLoaderSrv.ts @@ -7,6 +7,7 @@ import { getBackendSrv, locationService } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; import impressionSrv from 'app/core/services/impression_srv'; import kbn from 'app/core/utils/kbn'; +import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { DashboardDataDTO, DashboardDTO, DashboardMeta } from 'app/types'; @@ -35,6 +36,7 @@ export class DashboardLoaderSrv { } loadDashboard(type: UrlQueryValue, slug: string | undefined, uid: string | undefined): Promise { + const stateManager = getDashboardScenePageStateManager(); let promise; if (type === 'script' && slug) { @@ -71,6 +73,11 @@ export class DashboardLoaderSrv { }; }); } else if (uid) { + const cachedDashboard = stateManager.getFromCache(uid); + if (cachedDashboard) { + return Promise.resolve(cachedDashboard); + } + promise = backendSrv .getDashboardByUid(uid) .then((result) => { diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index eae608bdaa2..5e1b81415ec 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -10,6 +10,7 @@ import { newBrowseDashboardsEnabled } from 'app/features/browse-dashboards/featu import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; import { DashboardSrv, getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager'; import { getFolderByUid } from 'app/features/folders/state/actions'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; @@ -62,6 +63,13 @@ async function fetchDashboard( try { switch (args.routeName) { case DashboardRoutes.Home: { + const stateManager = getDashboardScenePageStateManager(); + const cachedDashboard = stateManager.getFromCache(DashboardRoutes.Home); + + if (cachedDashboard) { + return cachedDashboard; + } + // load home dash const dashDTO: DashboardDTO = await backendSrv.get('/api/dashboards/home'); diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 5814ad5ce5a..91910b4a96a 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -36,7 +36,7 @@ export function getAppRoutes(): RouteDescriptor[] { pageClass: 'page-dashboard', routeName: DashboardRoutes.Home, component: SafeDynamicImport( - () => import(/* webpackChunkName: "DashboardPage" */ '../features/dashboard/containers/DashboardPage') + () => import(/* webpackChunkName: "DashboardPageProxy" */ '../features/dashboard/containers/DashboardPageProxy') ), }, { @@ -44,7 +44,7 @@ export function getAppRoutes(): RouteDescriptor[] { pageClass: 'page-dashboard', routeName: DashboardRoutes.Normal, component: SafeDynamicImport( - () => import(/* webpackChunkName: "DashboardPage" */ '../features/dashboard/containers/DashboardPage') + () => import(/* webpackChunkName: "DashboardPageProxy" */ '../features/dashboard/containers/DashboardPageProxy') ), }, { @@ -53,7 +53,7 @@ export function getAppRoutes(): RouteDescriptor[] { pageClass: 'page-dashboard', routeName: DashboardRoutes.New, component: SafeDynamicImport( - () => import(/* webpackChunkName: "DashboardPage" */ '../features/dashboard/containers/DashboardPage') + () => import(/* webpackChunkName: "DashboardPage" */ '../features/dashboard/containers/DashboardPageProxy') ), }, { @@ -68,7 +68,7 @@ export function getAppRoutes(): RouteDescriptor[] { pageClass: 'page-dashboard', routeName: DashboardRoutes.Normal, component: SafeDynamicImport( - () => import(/* webpackChunkName: "DashboardPage" */ '../features/dashboard/containers/DashboardPage') + () => import(/* webpackChunkName: "DashboardPage" */ '../features/dashboard/containers/DashboardPageProxy') ), }, { diff --git a/public/dashboards/home.json b/public/dashboards/home.json index 821a7eaa35a..d9e24324df7 100644 --- a/public/dashboards/home.json +++ b/public/dashboards/home.json @@ -23,16 +23,18 @@ "x": 0, "y": 4 }, - "headings": true, "id": 3, - "limit": 30, "links": [], - "options": {}, - "query": "", - "recent": true, - "search": false, - "starred": true, - "tags": [], + "options": { + "showStarred": true, + "showRecentlyViewed": true, + "showSearch": false, + "showHeadings": true, + "folderId": 0, + "maxItems": 30, + "tags":[], + "query": "" + }, "title": "Dashboards", "type": "dashlist" },