DashboardScene: Viewer role only support (#76748)

* Allow starring DashboardScene

* Add feature toggle to enable dashbaord scene for viewers

* Basics for proxying viewers to a dashboard scene

* Removed isHomeDashboard flag

* Don't use proxy for rendering dashboard page

* Revert "Don't use proxy for rendering dashboard page"

This reverts commit 95836bdb2c.

* Pre-fetch dashboard prior page rendering

* Depend only on dashboard permissions

* Update default home dashboard to proper model...

* Fix breadcrumbs

* Types update

* Dashboards page proxy tests

* Fix missing controls

* URLs generation

* DashboardScenePageStateManager caching test

* Tests updates

* Fix wrong import

* Test update

* Gen
This commit is contained in:
Dominik Prokop 2023-11-02 20:02:25 +01:00 committed by GitHub
parent 03a7c65ead
commit 6e80a3d59b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 649 additions and 107 deletions

View File

@ -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

View File

@ -156,4 +156,5 @@ export interface FeatureToggles {
alertmanagerRemoteOnly?: boolean;
annotationPermissionUpdate?: boolean;
extractFieldsNameDeduplication?: boolean;
dashboardSceneForViewers?: boolean;
}

View File

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

View File

@ -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

1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
137 alertmanagerRemoteOnly experimental @grafana/alerting-squad false false false false
138 annotationPermissionUpdate experimental @grafana/grafana-authnz-team false false false false
139 extractFieldsNameDeduplication experimental @grafana/grafana-bi-squad false false false true
140 dashboardSceneForViewers experimental @grafana/dashboards-squad false false false true

View File

@ -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"
)

View File

@ -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<DashboardPageRouteParams> {}
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 (
<Page layout={PageLayoutType.Canvas}>
<Page layout={PageLayoutType.Canvas} data-testid={'dashboard-scene-page'}>
{isLoading && <PageLoader />}
{loadError && <h2>{loadError}</h2>}
</Page>

View File

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

View File

@ -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<DashboardScenePageState> {
private cache: Record<string, DashboardScene> = {};
// 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<string, DashboardCacheEntry> = 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<DashboardSc
this.setState({ isLoading: true });
const rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid);
const rsp = await this.fetchDashboard(uid);
if (rsp.dashboard) {
if (rsp?.dashboard) {
const scene = transformSaveModelToScene(rsp);
this.cache[uid] = scene;
return scene;
}
@ -66,10 +136,49 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
throw new Error('Dashboard not found');
}
public getFromCache(uid: string) {
const cachedDashboard = this.dashboardCache.get(uid);
if (cachedDashboard && !this.hasExpired(cachedDashboard)) {
return cachedDashboard.dashboard;
}
return null;
}
private hasExpired(entry: DashboardCacheEntry) {
return Date.now() - entry.ts > 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;
}

View File

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

View File

@ -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<PanelEditor>)
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 (
<Page navId="scenes" pageNav={pageNav} layout={PageLayoutType.Custom}>

View File

@ -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<DashboardSceneState> {
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<DashboardSceneState> {
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<DashboardSceneState> {
}
canEditDashboard() {
return Boolean(this.state.meta.canEdit || this.state.meta.canMakeEditable);
const { meta } = this.state;
return Boolean(meta.canEdit || meta.canMakeEditable);
}
}

View File

@ -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<DashboardS
const { controls, viewPanelKey: viewPanelId, overlay } = 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);
const bodyToRender = model.getBodyToRender(viewPanelId);
const navProps = config.featureToggles.dashboardSceneForViewers
? { navModel: getNavModel(navIndex, 'dashboards/browse') }
: { navId: 'scenes' };
return (
<Page navId="scenes" pageNav={pageNav} layout={PageLayoutType.Custom}>
<Page {...navProps} pageNav={pageNav} layout={PageLayoutType.Custom}>
<CustomScrollbar autoHeightMin={'100%'}>
<div className={styles.canvasContent}>
<NavToolbarActions dashboard={model} />
{controls && (
<div className={styles.controls}>
{controls.map((control) => (
@ -30,7 +39,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
<SceneDebugger scene={model} key={'scene-debugger'} />
</div>
)}
<div className={styles.body}>
<div className={cx(styles.body)}>
<bodyToRender.Component model={bodyToRender} />
</div>
</div>
@ -57,6 +66,7 @@ function getStyles(theme: GrafanaTheme2) {
gap: '8px',
marginBottom: theme.spacing(2),
}),
controls: css({
display: 'flex',
flexWrap: 'wrap',

View File

@ -16,10 +16,28 @@ interface Props {
}
export const NavToolbarActions = React.memo<Props>(({ dashboard }) => {
const { actions = [], isEditing, viewPanelKey, isDirty, uid } = dashboard.useState();
const { actions = [], isEditing, viewPanelKey, isDirty, uid, meta } = dashboard.useState();
const toolbarActions = (actions ?? []).map((action) => <action.Component key={action.state.key} model={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(
<DashNavButton
key="star-dashboard-button"
tooltip={desc}
icon={meta.isStarred ? 'favorite' : 'star'}
iconType={meta.isStarred ? 'mono' : 'default'}
iconSize="lg"
onClick={() => {
dashboard.onStarDashboard();
}}
/>
);
}
toolbarActions.push(
<DashNavButton
key="share-dashboard-button"
@ -61,36 +79,38 @@ export const NavToolbarActions = React.memo<Props>(({ dashboard }) => {
}
if (!isEditing) {
// TODO check permissions
toolbarActions.push(
<Button
onClick={dashboard.onEnterEditMode}
tooltip="Enter edit mode"
key="edit"
variant="primary"
icon="pen"
fill="text"
>
Edit
</Button>
);
if (dashboard.canEditDashboard()) {
toolbarActions.push(
<Button
onClick={dashboard.onEnterEditMode}
tooltip="Enter edit mode"
key="edit"
variant="primary"
icon="pen"
fill="text"
>
Edit
</Button>
);
}
} else {
// TODO check permissions
toolbarActions.push(
<Button onClick={dashboard.onSave} tooltip="Save as copy" fill="text" key="save-as">
Save as
</Button>
);
toolbarActions.push(
<Button onClick={dashboard.onDiscard} tooltip="Save changes" fill="text" key="discard" variant="destructive">
Discard
</Button>
);
toolbarActions.push(
<Button onClick={dashboard.onSave} tooltip="Save changes" key="save" disabled={!isDirty}>
Save
</Button>
);
if (dashboard.canEditDashboard()) {
toolbarActions.push(
<Button onClick={dashboard.onSave} tooltip="Save as copy" fill="text" key="save-as">
Save as
</Button>
);
toolbarActions.push(
<Button onClick={dashboard.onDiscard} tooltip="Save changes" fill="text" key="discard" variant="destructive">
Discard
</Button>
);
toolbarActions.push(
<Button onClick={dashboard.onSave} tooltip="Save changes" key="save" disabled={!isDirty}>
Save
</Button>
);
}
}
return <AppChromeUpdate actions={toolbarActions} />;

View File

@ -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({

View File

@ -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`),

View File

@ -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,

View File

@ -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: [

View File

@ -74,6 +74,7 @@ export class ShareLinkTab extends SceneObjectBase<ShareLinkTabState> {
currentQueryParams: location.search,
updateQuery: urlParamsUpdate,
absolute: true,
useExperimentalURL: Boolean(config.featureToggles.dashboardSceneForViewers && dashboard.state.meta.canEdit),
});
if (useShortUrl) {

View File

@ -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<DashboardDTO>) {
const loadDashboardMock = jest.fn().mockResolvedValue(rsp);
export function setupLoadDashboardMock(rsp: DeepPartial<DashboardDTO>, spy?: jest.Mock) {
const loadDashboardMock = (spy || jest.fn()).mockResolvedValue(rsp);
setDashboardLoaderSrv({
loadDashboard: loadDashboardMock,
// disabling type checks since this is a test util

View File

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

View File

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

View File

@ -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,

View File

@ -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<DashboardPageProxyProps>) {
const context = getGrafanaContextMock();
const store = configureStore({});
return render(
<GrafanaContext.Provider value={context}>
<Provider store={store}>
<Router history={locationService.getHistory()}>
<DashboardPageProxy
location={locationService.getLocation()}
history={locationService.getHistory()}
queryParams={{}}
route={{ routeName: DashboardRoutes.Home, component: () => null, path: '/' }}
match={{ params: {}, isExact: true, path: '/', url: '/' }}
{...props}
/>
</Router>
</Provider>
</GrafanaContext.Provider>
);
}
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);
});
});
});
});
});

View File

@ -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 <DashboardPage {...props} />;
}
if (dashboard.loading) {
return null;
}
if (dashboard.value && !dashboard.value.meta.canEdit && isScenesSupportedRoute) {
return <DashboardScenePage {...props} />;
} else {
return <DashboardPage {...props} />;
}
}
export default DashboardPageProxy;

View File

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

View File

@ -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<DashboardDTO> {
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) => {

View File

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

View File

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

View File

@ -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"
},