mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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. |
|
| `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. |
|
| `annotationPermissionUpdate` | Separate annotation permissions from dashboard permissions to allow for more granular control. |
|
||||||
| `extractFieldsNameDeduplication` | Make sure extracted field names are unique in the dataframe |
|
| `extractFieldsNameDeduplication` | Make sure extracted field names are unique in the dataframe |
|
||||||
|
| `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles |
|
||||||
|
|
||||||
## Development feature toggles
|
## Development feature toggles
|
||||||
|
|
||||||
|
|||||||
@@ -156,4 +156,5 @@ export interface FeatureToggles {
|
|||||||
alertmanagerRemoteOnly?: boolean;
|
alertmanagerRemoteOnly?: boolean;
|
||||||
annotationPermissionUpdate?: boolean;
|
annotationPermissionUpdate?: boolean;
|
||||||
extractFieldsNameDeduplication?: boolean;
|
extractFieldsNameDeduplication?: boolean;
|
||||||
|
dashboardSceneForViewers?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -967,5 +967,12 @@ var (
|
|||||||
FrontendOnly: true,
|
FrontendOnly: true,
|
||||||
Owner: grafanaBiSquad,
|
Owner: grafanaBiSquad,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "dashboardSceneForViewers",
|
||||||
|
Description: "Enables dashboard rendering using Scenes for viewer roles",
|
||||||
|
Stage: FeatureStageExperimental,
|
||||||
|
FrontendOnly: true,
|
||||||
|
Owner: grafanaDashboardsSquad,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -137,3 +137,4 @@ alertmanagerRemotePrimary,experimental,@grafana/alerting-squad,false,false,false
|
|||||||
alertmanagerRemoteOnly,experimental,@grafana/alerting-squad,false,false,false,false
|
alertmanagerRemoteOnly,experimental,@grafana/alerting-squad,false,false,false,false
|
||||||
annotationPermissionUpdate,experimental,@grafana/grafana-authnz-team,false,false,false,false
|
annotationPermissionUpdate,experimental,@grafana/grafana-authnz-team,false,false,false,false
|
||||||
extractFieldsNameDeduplication,experimental,@grafana/grafana-bi-squad,false,false,false,true
|
extractFieldsNameDeduplication,experimental,@grafana/grafana-bi-squad,false,false,false,true
|
||||||
|
dashboardSceneForViewers,experimental,@grafana/dashboards-squad,false,false,false,true
|
||||||
|
|||||||
|
@@ -558,4 +558,8 @@ const (
|
|||||||
// FlagExtractFieldsNameDeduplication
|
// FlagExtractFieldsNameDeduplication
|
||||||
// Make sure extracted field names are unique in the dataframe
|
// Make sure extracted field names are unique in the dataframe
|
||||||
FlagExtractFieldsNameDeduplication = "extractFieldsNameDeduplication"
|
FlagExtractFieldsNameDeduplication = "extractFieldsNameDeduplication"
|
||||||
|
|
||||||
|
// FlagDashboardSceneForViewers
|
||||||
|
// Enables dashboard rendering using Scenes for viewer roles
|
||||||
|
FlagDashboardSceneForViewers = "dashboardSceneForViewers"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,26 +5,32 @@ import { PageLayoutType } from '@grafana/data';
|
|||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||||
|
import { DashboardPageRouteParams } from 'app/features/dashboard/containers/types';
|
||||||
|
import { DashboardRoutes } from 'app/types';
|
||||||
|
|
||||||
import { getDashboardScenePageStateManager } from './DashboardScenePageStateManager';
|
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 stateManager = getDashboardScenePageStateManager();
|
||||||
const { dashboard, isLoading, loadError } = stateManager.useState();
|
const { dashboard, isLoading, loadError } = stateManager.useState();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
stateManager.loadDashboard(match.params.uid);
|
if (route.routeName === DashboardRoutes.Home) {
|
||||||
|
stateManager.loadDashboard(route.routeName);
|
||||||
|
} else {
|
||||||
|
stateManager.loadDashboard(match.params.uid!);
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
stateManager.clearState();
|
stateManager.clearState();
|
||||||
};
|
};
|
||||||
}, [stateManager, match.params.uid]);
|
}, [stateManager, match.params.uid, route.routeName]);
|
||||||
|
|
||||||
if (!dashboard) {
|
if (!dashboard) {
|
||||||
return (
|
return (
|
||||||
<Page layout={PageLayoutType.Canvas}>
|
<Page layout={PageLayoutType.Canvas} data-testid={'dashboard-scene-page'}>
|
||||||
{isLoading && <PageLoader />}
|
{isLoading && <PageLoader />}
|
||||||
{loadError && <h2>{loadError}</h2>}
|
{loadError && <h2>{loadError}</h2>}
|
||||||
</Page>
|
</Page>
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
|
import { advanceBy } from 'jest-date-mock';
|
||||||
|
|
||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
import { getUrlSyncManager } from '@grafana/scenes';
|
import { getUrlSyncManager } from '@grafana/scenes';
|
||||||
|
|
||||||
import { DashboardScene } from '../scene/DashboardScene';
|
import { DashboardScene } from '../scene/DashboardScene';
|
||||||
import { setupLoadDashboardMock } from '../utils/test-utils';
|
import { setupLoadDashboardMock } from '../utils/test-utils';
|
||||||
|
|
||||||
import { DashboardScenePageStateManager } from './DashboardScenePageStateManager';
|
import { DashboardScenePageStateManager, DASHBOARD_CACHE_TTL } from './DashboardScenePageStateManager';
|
||||||
|
|
||||||
describe('DashboardScenePageStateManager', () => {
|
describe('DashboardScenePageStateManager', () => {
|
||||||
describe('when fetching/loading a dashboard', () => {
|
describe('when fetching/loading a dashboard', () => {
|
||||||
it('should call loader from server if the dashboard is not cached', async () => {
|
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({});
|
const loader = new DashboardScenePageStateManager({});
|
||||||
await loader.loadDashboard('fake-dash');
|
await loader.loadDashboard('fake-dash');
|
||||||
@@ -74,5 +76,39 @@ describe('DashboardScenePageStateManager', () => {
|
|||||||
|
|
||||||
expect(dash2!.state.$timeRange?.state.from).toEqual('now-10m');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { 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 { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
|
||||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
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 { buildPanelEditScene, PanelEditor } from '../panel-edit/PanelEditor';
|
||||||
import { DashboardScene } from '../scene/DashboardScene';
|
import { DashboardScene } from '../scene/DashboardScene';
|
||||||
@@ -14,8 +22,69 @@ export interface DashboardScenePageState {
|
|||||||
loadError?: string;
|
loadError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DASHBOARD_CACHE_TTL = 2000;
|
||||||
|
|
||||||
|
interface DashboardCacheEntry {
|
||||||
|
dashboard: DashboardDTO;
|
||||||
|
ts: number;
|
||||||
|
}
|
||||||
export class DashboardScenePageStateManager extends StateManagerBase<DashboardScenePageState> {
|
export class DashboardScenePageStateManager extends StateManagerBase<DashboardScenePageState> {
|
||||||
private cache: Record<string, DashboardScene> = {};
|
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) {
|
public async loadDashboard(uid: string) {
|
||||||
try {
|
try {
|
||||||
@@ -55,10 +124,11 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
|
|||||||
|
|
||||||
this.setState({ isLoading: true });
|
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);
|
const scene = transformSaveModelToScene(rsp);
|
||||||
|
|
||||||
this.cache[uid] = scene;
|
this.cache[uid] = scene;
|
||||||
return scene;
|
return scene;
|
||||||
}
|
}
|
||||||
@@ -66,10 +136,49 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
|
|||||||
throw new Error('Dashboard not found');
|
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() {
|
public clearState() {
|
||||||
getDashboardSrv().setCurrent(undefined);
|
getDashboardSrv().setCurrent(undefined);
|
||||||
this.setState({ dashboard: undefined, loadError: undefined, isLoading: false, panelEditor: 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;
|
let stateManager: DashboardScenePageStateManager | null = null;
|
||||||
@@ -81,3 +190,25 @@ export function getDashboardScenePageStateManager(): DashboardScenePageStateMana
|
|||||||
|
|
||||||
return stateManager;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as H from 'history';
|
import * as H from 'history';
|
||||||
|
|
||||||
|
import { NavIndex } from '@grafana/data';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
import {
|
import {
|
||||||
getUrlSyncManager,
|
getUrlSyncManager,
|
||||||
@@ -54,10 +55,10 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
|
|||||||
getUrlSyncManager().initSync(this);
|
getUrlSyncManager().initSync(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getPageNav(location: H.Location) {
|
public getPageNav(location: H.Location, navIndex: NavIndex) {
|
||||||
return {
|
return {
|
||||||
text: 'Edit panel',
|
text: 'Edit panel',
|
||||||
parentItem: this.state.dashboardRef.resolve().getPageNav(location),
|
parentItem: this.state.dashboardRef.resolve().getPageNav(location, navIndex),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Button, useStyles2 } from '@grafana/ui';
|
|||||||
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
|
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
|
||||||
import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator';
|
import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
|
import { useSelector } from 'app/types/store';
|
||||||
|
|
||||||
import { PanelEditor } from './PanelEditor';
|
import { PanelEditor } from './PanelEditor';
|
||||||
|
|
||||||
@@ -15,7 +16,8 @@ export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>)
|
|||||||
const { body, controls, drawer } = model.useState();
|
const { body, controls, drawer } = model.useState();
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const pageNav = model.getPageNav(location);
|
const navIndex = useSelector((state) => state.navIndex);
|
||||||
|
const pageNav = model.getPageNav(location, navIndex);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page navId="scenes" pageNav={pageNav} layout={PageLayoutType.Custom}>
|
<Page navId="scenes" pageNav={pageNav} layout={PageLayoutType.Custom}>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as H from 'history';
|
import * as H from 'history';
|
||||||
import { Unsubscribable } from 'rxjs';
|
import { Unsubscribable } from 'rxjs';
|
||||||
|
|
||||||
import { CoreApp, DataQueryRequest, NavModelItem, UrlQueryMap } from '@grafana/data';
|
import { CoreApp, DataQueryRequest, NavIndex, NavModelItem, UrlQueryMap } from '@grafana/data';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { config, locationService } from '@grafana/runtime';
|
||||||
import {
|
import {
|
||||||
getUrlSyncManager,
|
getUrlSyncManager,
|
||||||
SceneFlexLayout,
|
SceneFlexLayout,
|
||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
SceneObjectStateChangedEvent,
|
SceneObjectStateChangedEvent,
|
||||||
sceneUtils,
|
sceneUtils,
|
||||||
} from '@grafana/scenes';
|
} 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 { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||||
import { DashboardMeta } from 'app/types';
|
import { DashboardMeta } from 'app/types';
|
||||||
|
|
||||||
@@ -155,16 +157,45 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
|||||||
this.setState({ overlay: new SaveDashboardDrawer({ dashboardRef: this.getRef() }) });
|
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 = {
|
let pageNav: NavModelItem = {
|
||||||
text: this.state.title,
|
text: this.state.title,
|
||||||
url: getDashboardUrl({
|
url: getDashboardUrl({
|
||||||
uid: this.state.uid,
|
uid: this.state.uid,
|
||||||
currentQueryParams: location.search,
|
currentQueryParams: location.search,
|
||||||
updateQuery: { viewPanel: null, inspect: null },
|
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) {
|
if (this.state.viewPanelKey) {
|
||||||
pageNav = {
|
pageNav = {
|
||||||
text: 'View panel',
|
text: 'View panel',
|
||||||
@@ -216,6 +247,25 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
|||||||
this.setState({ overlay: undefined });
|
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
|
* Called by the SceneQueryRunner to privide contextural parameters (tracking) props for the request
|
||||||
*/
|
*/
|
||||||
@@ -230,6 +280,8 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
canEditDashboard() {
|
canEditDashboard() {
|
||||||
return Boolean(this.state.meta.canEdit || this.state.meta.canMakeEditable);
|
const { meta } = this.state;
|
||||||
|
|
||||||
|
return Boolean(meta.canEdit || meta.canMakeEditable);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
|
import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
import { SceneComponentProps, SceneDebugger } from '@grafana/scenes';
|
import { SceneComponentProps, SceneDebugger } from '@grafana/scenes';
|
||||||
import { CustomScrollbar, useStyles2 } from '@grafana/ui';
|
import { CustomScrollbar, useStyles2 } from '@grafana/ui';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
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 { DashboardScene } from './DashboardScene';
|
||||||
import { NavToolbarActions } from './NavToolbarActions';
|
import { NavToolbarActions } from './NavToolbarActions';
|
||||||
@@ -14,14 +17,20 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
|
|||||||
const { controls, viewPanelKey: viewPanelId, overlay } = model.useState();
|
const { controls, viewPanelKey: viewPanelId, overlay } = model.useState();
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const location = useLocation();
|
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 bodyToRender = model.getBodyToRender(viewPanelId);
|
||||||
|
|
||||||
|
const navProps = config.featureToggles.dashboardSceneForViewers
|
||||||
|
? { navModel: getNavModel(navIndex, 'dashboards/browse') }
|
||||||
|
: { navId: 'scenes' };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page navId="scenes" pageNav={pageNav} layout={PageLayoutType.Custom}>
|
<Page {...navProps} pageNav={pageNav} layout={PageLayoutType.Custom}>
|
||||||
<CustomScrollbar autoHeightMin={'100%'}>
|
<CustomScrollbar autoHeightMin={'100%'}>
|
||||||
<div className={styles.canvasContent}>
|
<div className={styles.canvasContent}>
|
||||||
<NavToolbarActions dashboard={model} />
|
<NavToolbarActions dashboard={model} />
|
||||||
|
|
||||||
{controls && (
|
{controls && (
|
||||||
<div className={styles.controls}>
|
<div className={styles.controls}>
|
||||||
{controls.map((control) => (
|
{controls.map((control) => (
|
||||||
@@ -30,7 +39,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
|
|||||||
<SceneDebugger scene={model} key={'scene-debugger'} />
|
<SceneDebugger scene={model} key={'scene-debugger'} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={styles.body}>
|
<div className={cx(styles.body)}>
|
||||||
<bodyToRender.Component model={bodyToRender} />
|
<bodyToRender.Component model={bodyToRender} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,6 +66,7 @@ function getStyles(theme: GrafanaTheme2) {
|
|||||||
gap: '8px',
|
gap: '8px',
|
||||||
marginBottom: theme.spacing(2),
|
marginBottom: theme.spacing(2),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
controls: css({
|
controls: css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
|
|||||||
@@ -16,10 +16,28 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const NavToolbarActions = React.memo<Props>(({ dashboard }) => {
|
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} />);
|
const toolbarActions = (actions ?? []).map((action) => <action.Component key={action.state.key} model={action} />);
|
||||||
|
|
||||||
if (uid) {
|
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(
|
toolbarActions.push(
|
||||||
<DashNavButton
|
<DashNavButton
|
||||||
key="share-dashboard-button"
|
key="share-dashboard-button"
|
||||||
@@ -61,36 +79,38 @@ export const NavToolbarActions = React.memo<Props>(({ dashboard }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
// TODO check permissions
|
if (dashboard.canEditDashboard()) {
|
||||||
toolbarActions.push(
|
toolbarActions.push(
|
||||||
<Button
|
<Button
|
||||||
onClick={dashboard.onEnterEditMode}
|
onClick={dashboard.onEnterEditMode}
|
||||||
tooltip="Enter edit mode"
|
tooltip="Enter edit mode"
|
||||||
key="edit"
|
key="edit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
icon="pen"
|
icon="pen"
|
||||||
fill="text"
|
fill="text"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// TODO check permissions
|
if (dashboard.canEditDashboard()) {
|
||||||
toolbarActions.push(
|
toolbarActions.push(
|
||||||
<Button onClick={dashboard.onSave} tooltip="Save as copy" fill="text" key="save-as">
|
<Button onClick={dashboard.onSave} tooltip="Save as copy" fill="text" key="save-as">
|
||||||
Save as
|
Save as
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
toolbarActions.push(
|
toolbarActions.push(
|
||||||
<Button onClick={dashboard.onDiscard} tooltip="Save changes" fill="text" key="discard" variant="destructive">
|
<Button onClick={dashboard.onDiscard} tooltip="Save changes" fill="text" key="discard" variant="destructive">
|
||||||
Discard
|
Discard
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
toolbarActions.push(
|
toolbarActions.push(
|
||||||
<Button onClick={dashboard.onSave} tooltip="Save changes" key="save" disabled={!isDirty}>
|
<Button onClick={dashboard.onSave} tooltip="Save changes" key="save" disabled={!isDirty}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AppChromeUpdate actions={toolbarActions} />;
|
return <AppChromeUpdate actions={toolbarActions} />;
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ async function buildTestScene(options: SceneOptions) {
|
|||||||
const scene = new DashboardScene({
|
const scene = new DashboardScene({
|
||||||
title: 'hello',
|
title: 'hello',
|
||||||
uid: 'dash-1',
|
uid: 'dash-1',
|
||||||
|
meta: {
|
||||||
|
canEdit: true,
|
||||||
|
},
|
||||||
body: new SceneGridLayout({
|
body: new SceneGridLayout({
|
||||||
children: [
|
children: [
|
||||||
new SceneGridItem({
|
new SceneGridItem({
|
||||||
|
|||||||
@@ -35,19 +35,22 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
|
|||||||
href: getViewPanelUrl(panel),
|
href: getViewPanelUrl(panel),
|
||||||
});
|
});
|
||||||
|
|
||||||
// We could check isEditing here but I kind of think this should always be in the menu,
|
if (dashboard.canEditDashboard()) {
|
||||||
// and going into panel edit should make the dashboard go into edit mode is it's not already
|
// We could check isEditing here but I kind of think this should always be in the menu,
|
||||||
items.push({
|
// and going into panel edit should make the dashboard go into edit mode is it's not already
|
||||||
text: t('panel.header-menu.edit', `Edit`),
|
items.push({
|
||||||
iconClassName: 'eye',
|
text: t('panel.header-menu.edit', `Edit`),
|
||||||
shortcut: 'e',
|
iconClassName: 'eye',
|
||||||
onClick: () => reportInteraction('dashboards_panelheader_menu', { item: 'edit' }),
|
shortcut: 'e',
|
||||||
href: getDashboardUrl({
|
onClick: () => reportInteraction('dashboards_panelheader_menu', { item: 'edit' }),
|
||||||
uid: dashboard.state.uid,
|
href: getDashboardUrl({
|
||||||
subPath: `/panel-edit/${panelId}`,
|
uid: dashboard.state.uid,
|
||||||
currentQueryParams: location.search,
|
subPath: `/panel-edit/${panelId}`,
|
||||||
}),
|
currentQueryParams: location.search,
|
||||||
});
|
useExperimentalURL: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
text: t('panel.header-menu.share', `Share`),
|
text: t('panel.header-menu.share', `Share`),
|
||||||
|
|||||||
@@ -203,18 +203,23 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const controls: SceneObject[] = [
|
let controls: SceneObject[] = [
|
||||||
new VariableValueSelectors({}),
|
new VariableValueSelectors({}),
|
||||||
...filtersSets,
|
...filtersSets,
|
||||||
new SceneDataLayerControls(),
|
new SceneDataLayerControls(),
|
||||||
new SceneControlsSpacer(),
|
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({
|
return new DashboardScene({
|
||||||
title: oldModel.title,
|
title: oldModel.title,
|
||||||
uid: oldModel.uid,
|
uid: oldModel.uid,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ describe('ShareLinkTab', () => {
|
|||||||
config.appUrl = 'http://dashboards.grafana.com/grafana/';
|
config.appUrl = 'http://dashboards.grafana.com/grafana/';
|
||||||
config.rendererAvailable = true;
|
config.rendererAvailable = true;
|
||||||
config.bootData.user.orgId = 1;
|
config.bootData.user.orgId = 1;
|
||||||
|
config.featureToggles.dashboardSceneForViewers = true;
|
||||||
locationService.push('/scenes/dashboard/dash-1?from=now-6h&to=now');
|
locationService.push('/scenes/dashboard/dash-1?from=now-6h&to=now');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,6 +99,9 @@ function buildAndRenderScenario(options: ScenarioOptions) {
|
|||||||
const dashboard = new DashboardScene({
|
const dashboard = new DashboardScene({
|
||||||
title: 'hello',
|
title: 'hello',
|
||||||
uid: 'dash-1',
|
uid: 'dash-1',
|
||||||
|
meta: {
|
||||||
|
canEdit: true,
|
||||||
|
},
|
||||||
$timeRange: new SceneTimeRange({}),
|
$timeRange: new SceneTimeRange({}),
|
||||||
body: new SceneGridLayout({
|
body: new SceneGridLayout({
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export class ShareLinkTab extends SceneObjectBase<ShareLinkTabState> {
|
|||||||
currentQueryParams: location.search,
|
currentQueryParams: location.search,
|
||||||
updateQuery: urlParamsUpdate,
|
updateQuery: urlParamsUpdate,
|
||||||
absolute: true,
|
absolute: true,
|
||||||
|
useExperimentalURL: Boolean(config.featureToggles.dashboardSceneForViewers && dashboard.state.meta.canEdit),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (useShortUrl) {
|
if (useShortUrl) {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import { DashboardDTO } from 'app/types';
|
|||||||
import { PanelRepeaterGridItem, RepeatDirection } from '../scene/PanelRepeaterGridItem';
|
import { PanelRepeaterGridItem, RepeatDirection } from '../scene/PanelRepeaterGridItem';
|
||||||
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
|
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
|
||||||
|
|
||||||
export function setupLoadDashboardMock(rsp: DeepPartial<DashboardDTO>) {
|
export function setupLoadDashboardMock(rsp: DeepPartial<DashboardDTO>, spy?: jest.Mock) {
|
||||||
const loadDashboardMock = jest.fn().mockResolvedValue(rsp);
|
const loadDashboardMock = (spy || jest.fn()).mockResolvedValue(rsp);
|
||||||
setDashboardLoaderSrv({
|
setDashboardLoaderSrv({
|
||||||
loadDashboard: loadDashboardMock,
|
loadDashboard: loadDashboardMock,
|
||||||
// disabling type checks since this is a test util
|
// disabling type checks since this is a test util
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ import { getDashboardUrl } from './urlBuilders';
|
|||||||
|
|
||||||
describe('dashboard utils', () => {
|
describe('dashboard utils', () => {
|
||||||
it('Can getUrl', () => {
|
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');
|
expect(url).toBe('/scenes/dashboard/dash-1?orgId=1&filter=A');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Can getUrl with subpath', () => {
|
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');
|
expect(url).toBe('/scenes/dashboard/dash-1/panel-edit/2?orgId=1&filter=A');
|
||||||
});
|
});
|
||||||
@@ -18,6 +23,7 @@ describe('dashboard utils', () => {
|
|||||||
uid: 'dash-1',
|
uid: 'dash-1',
|
||||||
currentQueryParams: '?orgId=1&filter=A',
|
currentQueryParams: '?orgId=1&filter=A',
|
||||||
updateQuery: { filter: null, new: 'A' },
|
updateQuery: { filter: null, new: 'A' },
|
||||||
|
useExperimentalURL: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(url).toBe('/scenes/dashboard/dash-1?orgId=1&new=A');
|
expect(url).toBe('/scenes/dashboard/dash-1?orgId=1&new=A');
|
||||||
|
|||||||
@@ -20,10 +20,15 @@ export interface DashboardUrlOptions {
|
|||||||
absolute?: boolean;
|
absolute?: boolean;
|
||||||
// Add tz to query params
|
// Add tz to query params
|
||||||
timeZone?: string;
|
timeZone?: string;
|
||||||
|
|
||||||
|
// Add tz to query params
|
||||||
|
useExperimentalURL?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDashboardUrl(options: DashboardUrlOptions) {
|
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) {
|
if (options.soloRoute) {
|
||||||
path = `/d-solo/${options.uid}${options.subPath ?? ''}`;
|
path = `/d-solo/${options.uid}${options.subPath ?? ''}`;
|
||||||
|
|||||||
@@ -40,27 +40,7 @@ import { cleanUpDashboardAndVariables } from '../state/actions';
|
|||||||
import { initDashboard } from '../state/initDashboard';
|
import { initDashboard } from '../state/initDashboard';
|
||||||
import { calculateNewPanelGridPos } from '../utils/panel';
|
import { calculateNewPanelGridPos } from '../utils/panel';
|
||||||
|
|
||||||
export interface DashboardPageRouteParams {
|
import { DashboardPageRouteParams, DashboardPageRouteSearchParams } from './types';
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const mapStateToProps = (state: StoreState) => ({
|
export const mapStateToProps = (state: StoreState) => ({
|
||||||
initPhase: state.dashboard.initPhase,
|
initPhase: state.dashboard.initPhase,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
21
public/app/features/dashboard/containers/types.ts
Normal file
21
public/app/features/dashboard/containers/types.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import { getBackendSrv, locationService } from '@grafana/runtime';
|
|||||||
import { backendSrv } from 'app/core/services/backend_srv';
|
import { backendSrv } from 'app/core/services/backend_srv';
|
||||||
import impressionSrv from 'app/core/services/impression_srv';
|
import impressionSrv from 'app/core/services/impression_srv';
|
||||||
import kbn from 'app/core/utils/kbn';
|
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 { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||||
import { DashboardDataDTO, DashboardDTO, DashboardMeta } from 'app/types';
|
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> {
|
loadDashboard(type: UrlQueryValue, slug: string | undefined, uid: string | undefined): Promise<DashboardDTO> {
|
||||||
|
const stateManager = getDashboardScenePageStateManager();
|
||||||
let promise;
|
let promise;
|
||||||
|
|
||||||
if (type === 'script' && slug) {
|
if (type === 'script' && slug) {
|
||||||
@@ -71,6 +73,11 @@ export class DashboardLoaderSrv {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
} else if (uid) {
|
} else if (uid) {
|
||||||
|
const cachedDashboard = stateManager.getFromCache(uid);
|
||||||
|
if (cachedDashboard) {
|
||||||
|
return Promise.resolve(cachedDashboard);
|
||||||
|
}
|
||||||
|
|
||||||
promise = backendSrv
|
promise = backendSrv
|
||||||
.getDashboardByUid(uid)
|
.getDashboardByUid(uid)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { newBrowseDashboardsEnabled } from 'app/features/browse-dashboards/featu
|
|||||||
import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
|
import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
|
||||||
import { DashboardSrv, getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
import { DashboardSrv, getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||||
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
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 { getFolderByUid } from 'app/features/folders/state/actions';
|
||||||
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
||||||
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
|
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
|
||||||
@@ -62,6 +63,13 @@ async function fetchDashboard(
|
|||||||
try {
|
try {
|
||||||
switch (args.routeName) {
|
switch (args.routeName) {
|
||||||
case DashboardRoutes.Home: {
|
case DashboardRoutes.Home: {
|
||||||
|
const stateManager = getDashboardScenePageStateManager();
|
||||||
|
const cachedDashboard = stateManager.getFromCache(DashboardRoutes.Home);
|
||||||
|
|
||||||
|
if (cachedDashboard) {
|
||||||
|
return cachedDashboard;
|
||||||
|
}
|
||||||
|
|
||||||
// load home dash
|
// load home dash
|
||||||
const dashDTO: DashboardDTO = await backendSrv.get('/api/dashboards/home');
|
const dashDTO: DashboardDTO = await backendSrv.get('/api/dashboards/home');
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function getAppRoutes(): RouteDescriptor[] {
|
|||||||
pageClass: 'page-dashboard',
|
pageClass: 'page-dashboard',
|
||||||
routeName: DashboardRoutes.Home,
|
routeName: DashboardRoutes.Home,
|
||||||
component: SafeDynamicImport(
|
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',
|
pageClass: 'page-dashboard',
|
||||||
routeName: DashboardRoutes.Normal,
|
routeName: DashboardRoutes.Normal,
|
||||||
component: SafeDynamicImport(
|
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',
|
pageClass: 'page-dashboard',
|
||||||
routeName: DashboardRoutes.New,
|
routeName: DashboardRoutes.New,
|
||||||
component: SafeDynamicImport(
|
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',
|
pageClass: 'page-dashboard',
|
||||||
routeName: DashboardRoutes.Normal,
|
routeName: DashboardRoutes.Normal,
|
||||||
component: SafeDynamicImport(
|
component: SafeDynamicImport(
|
||||||
() => import(/* webpackChunkName: "DashboardPage" */ '../features/dashboard/containers/DashboardPage')
|
() => import(/* webpackChunkName: "DashboardPage" */ '../features/dashboard/containers/DashboardPageProxy')
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,16 +23,18 @@
|
|||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 4
|
"y": 4
|
||||||
},
|
},
|
||||||
"headings": true,
|
|
||||||
"id": 3,
|
"id": 3,
|
||||||
"limit": 30,
|
|
||||||
"links": [],
|
"links": [],
|
||||||
"options": {},
|
"options": {
|
||||||
"query": "",
|
"showStarred": true,
|
||||||
"recent": true,
|
"showRecentlyViewed": true,
|
||||||
"search": false,
|
"showSearch": false,
|
||||||
"starred": true,
|
"showHeadings": true,
|
||||||
"tags": [],
|
"folderId": 0,
|
||||||
|
"maxItems": 30,
|
||||||
|
"tags":[],
|
||||||
|
"query": ""
|
||||||
|
},
|
||||||
"title": "Dashboards",
|
"title": "Dashboards",
|
||||||
"type": "dashlist"
|
"type": "dashlist"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user