DashboardScene: First step to loading the current dashboard model and rendering it as a scene (#57012)

* Initial dashboard loading start

* loading dashboard works and shows something

* loading dashboard works and shows something

* Minor tweaks

* Add starred dashboards to scene list page

* Use new SceneGridLayout

* Allow switching directly from dashboard to a scene

* Migrate basic dashboard rows to scene based dashboard

* Review nit

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
Torkel Ödegaard 2022-11-17 16:15:51 +01:00 committed by GitHub
parent c093a471e6
commit 0c4aa6d0d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 302 additions and 8 deletions

View File

@ -338,6 +338,16 @@ export const DashNav = React.memo<Props>((props) => {
buttons.push(renderTimeControls());
buttons.push(tvButton);
if (config.featureToggles.scenes) {
buttons.push(
<ToolbarButton
tooltip={'View as Scene'}
icon="apps"
onClick={() => locationService.push(`/scenes/dashboard/${dashboard.uid}`)}
/>
);
}
return buttons;
};

View File

@ -32,7 +32,7 @@ export class DashboardLoaderSrv {
};
}
loadDashboard(type: UrlQueryValue, slug: any, uid: any) {
loadDashboard(type: UrlQueryValue, slug: any, uid: any): Promise<DashboardDTO> {
let promise;
if (type === 'script') {

View File

@ -1,27 +1,48 @@
// Libraries
import React, { FC } from 'react';
import { useAsync } from 'react-use';
import { Stack } from '@grafana/experimental';
import { Card } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
// Types
import { getGrafanaSearcher } from '../search/service';
import { getScenes } from './scenes';
export interface Props {}
export const SceneListPage: FC<Props> = ({}) => {
const scenes = getScenes();
const results = useAsync(() => {
return getGrafanaSearcher().starred({ starred: true });
}, []);
return (
<Page navId="scenes">
<Page navId="scenes" subTitle="Experimental new runtime and state model for dashboards">
<Page.Contents>
<Stack direction="column">
{scenes.map((scene) => (
<Card href={`/scenes/${scene.state.title}`} key={scene.state.title}>
<Card.Heading>{scene.state.title}</Card.Heading>
</Card>
))}
<Stack direction="column" gap={1}>
<h5>Test scenes</h5>
<Stack direction="column" gap={0}>
{scenes.map((scene) => (
<Card href={`/scenes/${scene.state.title}`} key={scene.state.title}>
<Card.Heading>{scene.state.title}</Card.Heading>
</Card>
))}
</Stack>
{results.value && (
<>
<h5>Starred dashboards</h5>
<Stack direction="column" gap={0}>
{results.value!.view.map((dash) => (
<Card href={`/scenes/dashboard/${dash.uid}`} key={dash.uid}>
<Card.Heading>{dash.name}</Card.Heading>
</Card>
))}
</Stack>
</>
)}
</Stack>
</Page.Contents>
</Page>

View File

@ -0,0 +1,44 @@
import React from 'react';
import { PageLayoutType } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
import { PageToolbar, ToolbarButton } from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { Page } from 'app/core/components/Page/Page';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneComponentProps, SceneLayout, SceneObject, SceneObjectStatePlain } from '../core/types';
interface DashboardSceneState extends SceneObjectStatePlain {
title: string;
uid: string;
layout: SceneLayout;
actions?: SceneObject[];
}
export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
public static Component = DashboardSceneRenderer;
}
function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) {
const { title, layout, actions = [], uid } = model.useState();
const toolbarActions = (actions ?? []).map((action) => <action.Component key={action.state.key} model={action} />);
toolbarActions.push(
<ToolbarButton icon="apps" onClick={() => locationService.push(`/d/${uid}`)} tooltip="View as Dashboard" />
);
const pageToolbar = config.featureToggles.topnav ? (
<AppChromeUpdate actions={toolbarActions} />
) : (
<PageToolbar title={title}>{toolbarActions}</PageToolbar>
);
return (
<Page navId="scenes" pageNav={{ text: title }} layout={PageLayoutType.Canvas} toolbar={pageToolbar}>
<div style={{ flexGrow: 1, display: 'flex', gap: '8px', overflow: 'auto' }}>
<layout.Component model={layout} />
</div>
</Page>
);
}

View File

@ -0,0 +1,32 @@
// Libraries
import React, { FC, useEffect } from 'react';
import { Page } from 'app/core/components/Page/Page';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { getDashboardLoader } from './DashboardsLoader';
export interface Props extends GrafanaRouteComponentProps<{ uid: string }> {}
export const DashboardScenePage: FC<Props> = ({ match }) => {
const loader = getDashboardLoader();
const { dashboard, isLoading } = loader.useState();
useEffect(() => {
loader.load(match.params.uid);
}, [loader, match.params.uid]);
if (!dashboard) {
return (
<Page navId="dashboards/browse">
{isLoading && <PageLoader />}
{!isLoading && <h2>Dashboard not found</h2>}
</Page>
);
}
return <dashboard.Component model={dashboard} />;
};
export default DashboardScenePage;

View File

@ -0,0 +1,181 @@
import { getDefaultTimeRange } from '@grafana/data';
import { StateManagerBase } from 'app/core/services/StateManagerBase';
import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { DashboardDTO } from 'app/types';
import { SceneTimePicker } from '../components/SceneTimePicker';
import { VizPanel } from '../components/VizPanel';
import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout';
import { SceneTimeRange } from '../core/SceneTimeRange';
import { SceneObject } from '../core/types';
import { SceneQueryRunner } from '../querying/SceneQueryRunner';
import { DashboardScene } from './DashboardScene';
export interface DashboardLoaderState {
dashboard?: DashboardScene;
isLoading?: boolean;
loadError?: string;
}
export class DashboardLoader extends StateManagerBase<DashboardLoaderState> {
private cache: Record<string, DashboardScene> = {};
public async load(uid: string) {
const fromCache = this.cache[uid];
if (fromCache) {
this.setState({ dashboard: fromCache });
return;
}
this.setState({ isLoading: true });
try {
const rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid);
if (rsp.dashboard) {
this.initDashboard(rsp);
} else {
throw new Error('No dashboard returned');
}
} catch (err) {
this.setState({ isLoading: false, loadError: String(err) });
}
}
private initDashboard(rsp: DashboardDTO) {
// Just to have migrations run
const oldModel = new DashboardModel(rsp.dashboard, rsp.meta);
const dashboard = new DashboardScene({
title: oldModel.title,
uid: oldModel.uid,
layout: new SceneGridLayout({
children: this.buildSceneObjectsFromDashboard(oldModel),
}),
$timeRange: new SceneTimeRange(getDefaultTimeRange()),
actions: [new SceneTimePicker({})],
});
this.cache[rsp.dashboard.uid] = dashboard;
this.setState({ dashboard, isLoading: false });
}
private buildSceneObjectsFromDashboard(dashboard: DashboardModel) {
// collects all panels and rows
const panels: SceneObject[] = [];
// indicates expanded row that's currently processed
let currentRow: PanelModel | null = null;
// collects panels in the currently processed, expanded row
let currentRowPanels: SceneObject[] = [];
for (const panel of dashboard.panels) {
if (panel.type === 'row') {
if (!currentRow) {
if (Boolean(panel.collapsed)) {
// collapsed rows contain their panels within the row model
panels.push(
new SceneGridRow({
title: panel.title,
isCollapsed: true,
size: {
y: panel.gridPos.y,
},
children: panel.panels
? panel.panels.map(
(p) =>
new VizPanel({
title: p.title,
pluginId: p.type,
size: {
x: p.gridPos.x,
y: p.gridPos.y,
width: p.gridPos.w,
height: p.gridPos.h,
},
options: p.options,
fieldConfig: p.fieldConfig,
$data: new SceneQueryRunner({
queries: p.targets,
}),
})
)
: [],
})
);
} else {
// indicate new row to be processed
currentRow = panel;
}
} else {
// when a row has been processed, and we hit a next one for processing
if (currentRow.id !== panel.id) {
// commit previous row panels
panels.push(
new SceneGridRow({
title: currentRow!.title,
size: {
y: currentRow.gridPos.y,
},
children: currentRowPanels,
})
);
currentRow = panel;
currentRowPanels = [];
}
}
} else {
const panelObject = new VizPanel({
title: panel.title,
pluginId: panel.type,
size: {
x: panel.gridPos.x,
y: panel.gridPos.y,
width: panel.gridPos.w,
height: panel.gridPos.h,
},
options: panel.options,
fieldConfig: panel.fieldConfig,
$data: new SceneQueryRunner({
queries: panel.targets,
}),
});
// when processing an expanded row, collect its panels
if (currentRow) {
currentRowPanels.push(panelObject);
} else {
panels.push(panelObject);
}
}
}
// commit a row if it's the last one
if (currentRow) {
panels.push(
new SceneGridRow({
title: currentRow!.title,
size: {
y: currentRow.gridPos.y,
},
children: currentRowPanels,
})
);
}
return panels;
}
}
let loader: DashboardLoader | null = null;
export function getDashboardLoader(): DashboardLoader {
if (!loader) {
loader = new DashboardLoader({});
}
return loader;
}

View File

@ -523,6 +523,12 @@ export function getDynamicDashboardRoutes(cfg = config): RouteDescriptor[] {
path: '/scenes',
component: SafeDynamicImport(() => import(/* webpackChunkName: "scenes"*/ 'app/features/scenes/SceneListPage')),
},
{
path: '/scenes/dashboard/:uid',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "scenes"*/ 'app/features/scenes/dashboard/DashboardScenePage')
),
},
{
path: '/scenes/:name',
component: SafeDynamicImport(() => import(/* webpackChunkName: "scenes"*/ 'app/features/scenes/ScenePage')),