mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Dashboard: New EmbeddedDashboard runtime component (#78916)
* Embedding dashboards exploratino * Update * Update * Added e2e test * Update * initial state, and onStateChange, only explore panel menu action and other fixes and tests * fix e2e spec * Fix url * fixing test
This commit is contained in:
parent
9da3db1ddf
commit
e08700c1b5
24
e2e/dashboards-suite/embedded-dashboard.spec.ts
Normal file
24
e2e/dashboards-suite/embedded-dashboard.spec.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
|
|
||||||
|
import { e2e } from '../utils';
|
||||||
|
import { fromBaseUrl } from '../utils/support/url';
|
||||||
|
|
||||||
|
describe('Embedded dashboard', function () {
|
||||||
|
beforeEach(() => {
|
||||||
|
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('open test page', function () {
|
||||||
|
cy.visit(fromBaseUrl('/dashboards/embedding-test'));
|
||||||
|
|
||||||
|
// Verify pie charts are rendered
|
||||||
|
cy.get(
|
||||||
|
`[data-viz-panel-key="panel-11"] [data-testid^="${selectors.components.Panels.Visualization.PieChart.svgSlice}"]`
|
||||||
|
).should('have.length', 5);
|
||||||
|
|
||||||
|
// Verify no url sync
|
||||||
|
e2e.components.TimePicker.openButton().click();
|
||||||
|
cy.get('label:contains("Last 1 hour")').click();
|
||||||
|
cy.url().should('eq', fromBaseUrl('/dashboards/embedding-test'));
|
||||||
|
});
|
||||||
|
});
|
@ -40,6 +40,7 @@
|
|||||||
"@grafana/data": "10.4.0-pre",
|
"@grafana/data": "10.4.0-pre",
|
||||||
"@grafana/e2e-selectors": "10.4.0-pre",
|
"@grafana/e2e-selectors": "10.4.0-pre",
|
||||||
"@grafana/faro-web-sdk": "^1.3.6",
|
"@grafana/faro-web-sdk": "^1.3.6",
|
||||||
|
"@grafana/schema": "10.4.0-pre",
|
||||||
"@grafana/ui": "10.4.0-pre",
|
"@grafana/ui": "10.4.0-pre",
|
||||||
"history": "4.10.1",
|
"history": "4.10.1",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
|
@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface EmbeddedDashboardProps {
|
||||||
|
uid?: string;
|
||||||
|
/**
|
||||||
|
* Use this property to override initial time and variable state.
|
||||||
|
* Example: ?from=now-5m&to=now&var-varname=value1
|
||||||
|
*/
|
||||||
|
initialState?: string;
|
||||||
|
/**
|
||||||
|
* Is called when ever the internal embedded dashboards url state changes.
|
||||||
|
* Can be used to sync the internal url state (Which is not synced to URL) with the external context, or to
|
||||||
|
* preserve some of the state when moving to other embedded dashboards.
|
||||||
|
*/
|
||||||
|
onStateChange?: (state: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a React component that renders an embedded dashboard.
|
||||||
|
* @alpha
|
||||||
|
*/
|
||||||
|
export let EmbeddedDashboard: React.ComponentType<EmbeddedDashboardProps> = () => {
|
||||||
|
throw new Error('EmbeddedDashboard requires runtime initialization');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function setEmbeddedDashboard(component: React.ComponentType<EmbeddedDashboardProps>) {
|
||||||
|
EmbeddedDashboard = component;
|
||||||
|
}
|
@ -55,3 +55,4 @@ export {
|
|||||||
createDataSourcePluginEventProperties,
|
createDataSourcePluginEventProperties,
|
||||||
} from './analytics/plugins/eventProperties';
|
} from './analytics/plugins/eventProperties';
|
||||||
export { usePluginInteractionReporter } from './analytics/plugins/usePluginInteractionReporter';
|
export { usePluginInteractionReporter } from './analytics/plugins/usePluginInteractionReporter';
|
||||||
|
export { type EmbeddedDashboardProps, EmbeddedDashboard, setEmbeddedDashboard } from './components/EmbeddedDashboard';
|
||||||
|
@ -33,6 +33,7 @@ import {
|
|||||||
setRunRequest,
|
setRunRequest,
|
||||||
setPluginImportUtils,
|
setPluginImportUtils,
|
||||||
setPluginExtensionGetter,
|
setPluginExtensionGetter,
|
||||||
|
setEmbeddedDashboard,
|
||||||
setAppEvents,
|
setAppEvents,
|
||||||
type GetPluginExtensions,
|
type GetPluginExtensions,
|
||||||
} from '@grafana/runtime';
|
} from '@grafana/runtime';
|
||||||
@ -72,6 +73,7 @@ import { startMeasure, stopMeasure } from './core/utils/metrics';
|
|||||||
import { initDevFeatures } from './dev';
|
import { initDevFeatures } from './dev';
|
||||||
import { initAuthConfig } from './features/auth-config';
|
import { initAuthConfig } from './features/auth-config';
|
||||||
import { getTimeSrv } from './features/dashboard/services/TimeSrv';
|
import { getTimeSrv } from './features/dashboard/services/TimeSrv';
|
||||||
|
import { EmbeddedDashboardLazy } from './features/dashboard-scene/embedding/EmbeddedDashboardLazy';
|
||||||
import { initGrafanaLive } from './features/live';
|
import { initGrafanaLive } from './features/live';
|
||||||
import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView';
|
import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView';
|
||||||
import { PanelRenderer } from './features/panel/components/PanelRenderer';
|
import { PanelRenderer } from './features/panel/components/PanelRenderer';
|
||||||
@ -134,6 +136,7 @@ export class GrafanaApp {
|
|||||||
setPluginPage(PluginPage);
|
setPluginPage(PluginPage);
|
||||||
setPanelDataErrorView(PanelDataErrorView);
|
setPanelDataErrorView(PanelDataErrorView);
|
||||||
setLocationSrv(locationService);
|
setLocationSrv(locationService);
|
||||||
|
setEmbeddedDashboard(EmbeddedDashboardLazy);
|
||||||
setTimeZoneResolver(() => config.bootData.user.timezone);
|
setTimeZoneResolver(() => config.bootData.user.timezone);
|
||||||
initGrafanaLive();
|
initGrafanaLive();
|
||||||
|
|
||||||
|
@ -0,0 +1,130 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { GrafanaTheme2, urlUtil } from '@grafana/data';
|
||||||
|
import { EmbeddedDashboardProps } from '@grafana/runtime';
|
||||||
|
import { SceneObjectStateChangedEvent, sceneUtils } from '@grafana/scenes';
|
||||||
|
import { Spinner, Alert, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager';
|
||||||
|
import { DashboardScene } from '../scene/DashboardScene';
|
||||||
|
|
||||||
|
export function EmbeddedDashboard(props: EmbeddedDashboardProps) {
|
||||||
|
const stateManager = getDashboardScenePageStateManager();
|
||||||
|
const { dashboard, loadError } = stateManager.useState();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
stateManager.loadDashboard({ uid: props.uid!, isEmbedded: true });
|
||||||
|
return () => {
|
||||||
|
stateManager.clearState();
|
||||||
|
};
|
||||||
|
}, [stateManager, props.uid]);
|
||||||
|
|
||||||
|
if (loadError) {
|
||||||
|
return (
|
||||||
|
<Alert severity="error" title="Failed to load dashboard">
|
||||||
|
{loadError}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dashboard) {
|
||||||
|
return <Spinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <EmbeddedDashboardRenderer model={dashboard} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RendererProps extends EmbeddedDashboardProps {
|
||||||
|
model: DashboardScene;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmbeddedDashboardRenderer({ model, initialState, onStateChange }: RendererProps) {
|
||||||
|
const [isActive, setIsActive] = useState(false);
|
||||||
|
const { controls, body } = model.useState();
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsActive(true);
|
||||||
|
|
||||||
|
if (initialState) {
|
||||||
|
const searchParms = new URLSearchParams(initialState);
|
||||||
|
sceneUtils.syncStateFromSearchParams(model, searchParms);
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.activate();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [model]);
|
||||||
|
|
||||||
|
useSubscribeToEmbeddedUrlState(onStateChange, model);
|
||||||
|
|
||||||
|
if (!isActive) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.canvas}>
|
||||||
|
{controls && (
|
||||||
|
<div className={styles.controls}>
|
||||||
|
{controls.map((control) => (
|
||||||
|
<control.Component key={control.state.key} model={control} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.body}>
|
||||||
|
<body.Component model={body} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSubscribeToEmbeddedUrlState(onStateChange: ((state: string) => void) | undefined, model: DashboardScene) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onStateChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastState = '';
|
||||||
|
const sub = model.subscribeToEvent(SceneObjectStateChangedEvent, (evt) => {
|
||||||
|
if (evt.payload.changedObject.urlSync) {
|
||||||
|
const state = sceneUtils.getUrlState(model);
|
||||||
|
const stateAsString = urlUtil.renderUrl('', state);
|
||||||
|
|
||||||
|
if (lastState !== stateAsString) {
|
||||||
|
lastState = stateAsString;
|
||||||
|
onStateChange(stateAsString);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => sub.unsubscribe();
|
||||||
|
}, [model, onStateChange]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) {
|
||||||
|
return {
|
||||||
|
canvas: css({
|
||||||
|
label: 'canvas-content',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flexBasis: '100%',
|
||||||
|
flexGrow: 1,
|
||||||
|
}),
|
||||||
|
body: css({
|
||||||
|
label: 'body',
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
}),
|
||||||
|
controls: css({
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
top: 0,
|
||||||
|
zIndex: theme.zIndex.navbarFixed,
|
||||||
|
padding: theme.spacing(0, 0, 2, 0),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { EmbeddedDashboardProps } from '@grafana/runtime';
|
||||||
|
|
||||||
|
export function EmbeddedDashboardLazy(props: EmbeddedDashboardProps) {
|
||||||
|
return <Component {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Component = React.lazy(async () => {
|
||||||
|
const { EmbeddedDashboard } = await import(/* webpackChunkName: "EmbeddedDashboard" */ './EmbeddedDashboard');
|
||||||
|
return { default: EmbeddedDashboard };
|
||||||
|
});
|
@ -0,0 +1,22 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import { Box } from '@grafana/ui';
|
||||||
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
|
|
||||||
|
import { EmbeddedDashboard } from './EmbeddedDashboard';
|
||||||
|
|
||||||
|
export function EmbeddedDashboardTestPage() {
|
||||||
|
const [state, setState] = useState('?from=now-5m&to=now');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page
|
||||||
|
navId="dashboards/browse"
|
||||||
|
pageNav={{ text: 'Embedding dashboard', subTitle: 'Showing dashboard: Panel Tests - Pie chart' }}
|
||||||
|
>
|
||||||
|
<Box paddingY={2}>Internal url state: {state}</Box>
|
||||||
|
<EmbeddedDashboard uid="lVE-2YFMz" initialState={state} onStateChange={setState} />
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmbeddedDashboardTestPage;
|
@ -18,9 +18,9 @@ export function DashboardScenePage({ match, route }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (route.routeName === DashboardRoutes.Home) {
|
if (route.routeName === DashboardRoutes.Home) {
|
||||||
stateManager.loadDashboard(route.routeName);
|
stateManager.loadDashboard({ uid: route.routeName });
|
||||||
} else {
|
} else {
|
||||||
stateManager.loadDashboard(match.params.uid!);
|
stateManager.loadDashboard({ uid: match.params.uid! });
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
@ -14,12 +14,12 @@ describe('DashboardScenePageStateManager', () => {
|
|||||||
const loadDashboardMock = setupLoadDashboardMock({ dashboard: { uid: 'fake-dash', editable: true }, 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({ uid: 'fake-dash' });
|
||||||
|
|
||||||
expect(loadDashboardMock).toHaveBeenCalledWith('db', '', 'fake-dash');
|
expect(loadDashboardMock).toHaveBeenCalledWith('db', '', 'fake-dash');
|
||||||
|
|
||||||
// should use cache second time
|
// should use cache second time
|
||||||
await loader.loadDashboard('fake-dash');
|
await loader.loadDashboard({ uid: 'fake-dash' });
|
||||||
expect(loadDashboardMock.mock.calls.length).toBe(1);
|
expect(loadDashboardMock.mock.calls.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -27,7 +27,7 @@ describe('DashboardScenePageStateManager', () => {
|
|||||||
setupLoadDashboardMock({ dashboard: undefined, meta: {} });
|
setupLoadDashboardMock({ dashboard: undefined, meta: {} });
|
||||||
|
|
||||||
const loader = new DashboardScenePageStateManager({});
|
const loader = new DashboardScenePageStateManager({});
|
||||||
await loader.loadDashboard('fake-dash');
|
await loader.loadDashboard({ uid: 'fake-dash' });
|
||||||
|
|
||||||
expect(loader.state.dashboard).toBeUndefined();
|
expect(loader.state.dashboard).toBeUndefined();
|
||||||
expect(loader.state.isLoading).toBe(false);
|
expect(loader.state.isLoading).toBe(false);
|
||||||
@ -38,7 +38,7 @@ describe('DashboardScenePageStateManager', () => {
|
|||||||
setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
|
setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
|
||||||
|
|
||||||
const loader = new DashboardScenePageStateManager({});
|
const loader = new DashboardScenePageStateManager({});
|
||||||
await loader.loadDashboard('fake-dash');
|
await loader.loadDashboard({ uid: 'fake-dash' });
|
||||||
|
|
||||||
expect(loader.state.dashboard?.state.uid).toBe('fake-dash');
|
expect(loader.state.dashboard?.state.uid).toBe('fake-dash');
|
||||||
expect(loader.state.loadError).toBe(undefined);
|
expect(loader.state.loadError).toBe(undefined);
|
||||||
@ -49,7 +49,7 @@ describe('DashboardScenePageStateManager', () => {
|
|||||||
setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
|
setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
|
||||||
|
|
||||||
const loader = new DashboardScenePageStateManager({});
|
const loader = new DashboardScenePageStateManager({});
|
||||||
await loader.loadDashboard('fake-dash');
|
await loader.loadDashboard({ uid: 'fake-dash' });
|
||||||
|
|
||||||
expect(loader.state.dashboard).toBeInstanceOf(DashboardScene);
|
expect(loader.state.dashboard).toBeInstanceOf(DashboardScene);
|
||||||
expect(loader.state.isLoading).toBe(false);
|
expect(loader.state.isLoading).toBe(false);
|
||||||
@ -61,7 +61,7 @@ describe('DashboardScenePageStateManager', () => {
|
|||||||
locationService.partial({ from: 'now-5m', to: 'now' });
|
locationService.partial({ from: 'now-5m', to: 'now' });
|
||||||
|
|
||||||
const loader = new DashboardScenePageStateManager({});
|
const loader = new DashboardScenePageStateManager({});
|
||||||
await loader.loadDashboard('fake-dash');
|
await loader.loadDashboard({ uid: 'fake-dash' });
|
||||||
const dash = loader.state.dashboard;
|
const dash = loader.state.dashboard;
|
||||||
|
|
||||||
expect(dash!.state.$timeRange?.state.from).toEqual('now-5m');
|
expect(dash!.state.$timeRange?.state.from).toEqual('now-5m');
|
||||||
@ -71,12 +71,24 @@ describe('DashboardScenePageStateManager', () => {
|
|||||||
// try loading again (and hitting cache)
|
// try loading again (and hitting cache)
|
||||||
locationService.partial({ from: 'now-10m', to: 'now' });
|
locationService.partial({ from: 'now-10m', to: 'now' });
|
||||||
|
|
||||||
await loader.loadDashboard('fake-dash');
|
await loader.loadDashboard({ uid: 'fake-dash' });
|
||||||
const dash2 = loader.state.dashboard;
|
const dash2 = loader.state.dashboard;
|
||||||
|
|
||||||
expect(dash2!.state.$timeRange?.state.from).toEqual('now-10m');
|
expect(dash2!.state.$timeRange?.state.from).toEqual('now-10m');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not initialize url sync for embedded dashboards', async () => {
|
||||||
|
setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
|
||||||
|
|
||||||
|
locationService.partial({ from: 'now-5m', to: 'now' });
|
||||||
|
|
||||||
|
const loader = new DashboardScenePageStateManager({});
|
||||||
|
await loader.loadDashboard({ uid: 'fake-dash', isEmbedded: true });
|
||||||
|
const dash = loader.state.dashboard;
|
||||||
|
|
||||||
|
expect(dash!.state.$timeRange?.state.from).toEqual('now-6h');
|
||||||
|
});
|
||||||
|
|
||||||
describe('caching', () => {
|
describe('caching', () => {
|
||||||
it('should cache the dashboard DTO', async () => {
|
it('should cache the dashboard DTO', async () => {
|
||||||
setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
|
setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
|
||||||
@ -85,7 +97,7 @@ describe('DashboardScenePageStateManager', () => {
|
|||||||
|
|
||||||
expect(loader.getFromCache('fake-dash')).toBeNull();
|
expect(loader.getFromCache('fake-dash')).toBeNull();
|
||||||
|
|
||||||
await loader.loadDashboard('fake-dash');
|
await loader.loadDashboard({ uid: 'fake-dash' });
|
||||||
|
|
||||||
expect(loader.getFromCache('fake-dash')).toBeDefined();
|
expect(loader.getFromCache('fake-dash')).toBeDefined();
|
||||||
});
|
});
|
||||||
@ -98,15 +110,15 @@ describe('DashboardScenePageStateManager', () => {
|
|||||||
|
|
||||||
expect(loader.getFromCache('fake-dash')).toBeNull();
|
expect(loader.getFromCache('fake-dash')).toBeNull();
|
||||||
|
|
||||||
await loader.fetchDashboard('fake-dash');
|
await loader.fetchDashboard({ uid: 'fake-dash' });
|
||||||
expect(loadDashSpy).toHaveBeenCalledTimes(1);
|
expect(loadDashSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
advanceBy(DASHBOARD_CACHE_TTL / 2);
|
advanceBy(DASHBOARD_CACHE_TTL / 2);
|
||||||
await loader.fetchDashboard('fake-dash');
|
await loader.fetchDashboard({ uid: 'fake-dash' });
|
||||||
expect(loadDashSpy).toHaveBeenCalledTimes(1);
|
expect(loadDashSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
advanceBy(DASHBOARD_CACHE_TTL / 2 + 1);
|
advanceBy(DASHBOARD_CACHE_TTL / 2 + 1);
|
||||||
await loader.fetchDashboard('fake-dash');
|
await loader.fetchDashboard({ uid: 'fake-dash' });
|
||||||
expect(loadDashSpy).toHaveBeenCalledTimes(2);
|
expect(loadDashSpy).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -26,6 +26,12 @@ interface DashboardCacheEntry {
|
|||||||
dashboard: DashboardDTO;
|
dashboard: DashboardDTO;
|
||||||
ts: number;
|
ts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LoadDashboardOptions {
|
||||||
|
uid: string;
|
||||||
|
isEmbedded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
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.
|
// This is a simplistic, short-term cache for DashboardDTOs to avoid fetching the same dashboard multiple times across a short time span.
|
||||||
@ -33,7 +39,7 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
|
|||||||
|
|
||||||
// To eventualy replace the fetchDashboard function from Dashboard redux state management.
|
// 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.
|
// For now it's a simplistic version to support Home and Normal dashboard routes.
|
||||||
public async fetchDashboard(uid: string) {
|
public async fetchDashboard({ uid, isEmbedded }: LoadDashboardOptions) {
|
||||||
const cachedDashboard = this.getFromCache(uid);
|
const cachedDashboard = this.getFromCache(uid);
|
||||||
|
|
||||||
if (cachedDashboard) {
|
if (cachedDashboard) {
|
||||||
@ -63,7 +69,7 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (rsp) {
|
if (rsp) {
|
||||||
if (rsp.meta.url) {
|
if (rsp.meta.url && !isEmbedded) {
|
||||||
const dashboardUrl = locationUtil.stripBaseFromUrl(rsp.meta.url);
|
const dashboardUrl = locationUtil.stripBaseFromUrl(rsp.meta.url);
|
||||||
const currentPath = locationService.getLocation().pathname;
|
const currentPath = locationService.getLocation().pathname;
|
||||||
if (dashboardUrl !== currentPath) {
|
if (dashboardUrl !== currentPath) {
|
||||||
@ -94,10 +100,13 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
|
|||||||
return rsp;
|
return rsp;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async loadDashboard(uid: string) {
|
public async loadDashboard(options: LoadDashboardOptions) {
|
||||||
try {
|
try {
|
||||||
const dashboard = await this.loadScene(uid);
|
const dashboard = await this.loadScene(options);
|
||||||
|
|
||||||
|
if (!options.isEmbedded) {
|
||||||
dashboard.startUrlSync();
|
dashboard.startUrlSync();
|
||||||
|
}
|
||||||
|
|
||||||
this.setState({ dashboard: dashboard, isLoading: false });
|
this.setState({ dashboard: dashboard, isLoading: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -105,20 +114,26 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadScene(uid: string): Promise<DashboardScene> {
|
private async loadScene(options: LoadDashboardOptions): Promise<DashboardScene> {
|
||||||
const fromCache = this.cache[uid];
|
const fromCache = this.cache[options.uid];
|
||||||
if (fromCache) {
|
if (fromCache) {
|
||||||
|
// Need to update this in case we cached an embedded but now opening it standard mode
|
||||||
|
fromCache.state.meta.isEmbedded = options.isEmbedded;
|
||||||
return fromCache;
|
return fromCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ isLoading: true });
|
this.setState({ isLoading: true });
|
||||||
|
|
||||||
const rsp = await this.fetchDashboard(uid);
|
const rsp = await this.fetchDashboard(options);
|
||||||
|
|
||||||
if (rsp?.dashboard) {
|
if (rsp?.dashboard) {
|
||||||
|
if (options.isEmbedded) {
|
||||||
|
rsp.meta.isEmbedded = true;
|
||||||
|
}
|
||||||
|
|
||||||
const scene = transformSaveModelToScene(rsp);
|
const scene = transformSaveModelToScene(rsp);
|
||||||
|
|
||||||
this.cache[uid] = scene;
|
this.cache[options.uid] = scene;
|
||||||
return scene;
|
return scene;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -468,10 +468,28 @@ describe('panelMenuBehavior', () => {
|
|||||||
])
|
])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should only contain explore when embedded', async () => {
|
||||||
|
const { menu, panel } = await buildTestScene({ isEmbedded: true });
|
||||||
|
|
||||||
|
panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false });
|
||||||
|
|
||||||
|
mocks.contextSrv.hasAccessToExplore.mockReturnValue(true);
|
||||||
|
mocks.getExploreUrl.mockReturnValue(Promise.resolve('/explore'));
|
||||||
|
|
||||||
|
menu.activate();
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 1));
|
||||||
|
|
||||||
|
expect(menu.state.items?.length).toBe(1);
|
||||||
|
expect(menu.state.items?.[0].text).toBe('Explore');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
interface SceneOptions {}
|
interface SceneOptions {
|
||||||
|
isEmbedded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
async function buildTestScene(options: SceneOptions) {
|
async function buildTestScene(options: SceneOptions) {
|
||||||
const menu = new VizPanelMenu({
|
const menu = new VizPanelMenu({
|
||||||
@ -503,6 +521,7 @@ async function buildTestScene(options: SceneOptions) {
|
|||||||
}),
|
}),
|
||||||
meta: {
|
meta: {
|
||||||
canEdit: true,
|
canEdit: true,
|
||||||
|
isEmbedded: options.isEmbedded ?? false,
|
||||||
},
|
},
|
||||||
body: new SceneGridLayout({
|
body: new SceneGridLayout({
|
||||||
children: [
|
children: [
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
InterpolateFunction,
|
InterpolateFunction,
|
||||||
PanelMenuItem,
|
PanelMenuItem,
|
||||||
|
PanelPlugin,
|
||||||
PluginExtensionPanelContext,
|
PluginExtensionPanelContext,
|
||||||
PluginExtensionPoints,
|
PluginExtensionPoints,
|
||||||
getTimeZone,
|
getTimeZone,
|
||||||
@ -18,7 +19,7 @@ import { addDataTrailPanelAction } from 'app/features/trails/dashboardIntegratio
|
|||||||
import { ShareModal } from '../sharing/ShareModal';
|
import { ShareModal } from '../sharing/ShareModal';
|
||||||
import { DashboardInteractions } from '../utils/interactions';
|
import { DashboardInteractions } from '../utils/interactions';
|
||||||
import { getEditPanelUrl, getInspectUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders';
|
import { getEditPanelUrl, getInspectUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders';
|
||||||
import { getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils';
|
import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils';
|
||||||
|
|
||||||
import { DashboardScene } from './DashboardScene';
|
import { DashboardScene } from './DashboardScene';
|
||||||
import { LibraryVizPanel } from './LibraryVizPanel';
|
import { LibraryVizPanel } from './LibraryVizPanel';
|
||||||
@ -36,11 +37,20 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
|
|||||||
|
|
||||||
const items: PanelMenuItem[] = [];
|
const items: PanelMenuItem[] = [];
|
||||||
const moreSubMenu: PanelMenuItem[] = [];
|
const moreSubMenu: PanelMenuItem[] = [];
|
||||||
const inspectSubMenu: PanelMenuItem[] = [];
|
|
||||||
const panelId = getPanelIdForVizPanel(panel);
|
const panelId = getPanelIdForVizPanel(panel);
|
||||||
const dashboard = panel.getRoot();
|
const dashboard = getDashboardSceneFor(panel);
|
||||||
|
const { isEmbedded } = dashboard.state.meta;
|
||||||
|
|
||||||
|
const exploreMenuItem = await getExploreMenuItem(panel);
|
||||||
|
|
||||||
|
// For embedded dashboards we only have explore action for now
|
||||||
|
if (isEmbedded) {
|
||||||
|
if (exploreMenuItem) {
|
||||||
|
menu.setState({ items: [exploreMenuItem] });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (dashboard instanceof DashboardScene) {
|
|
||||||
items.push({
|
items.push({
|
||||||
text: t('panel.header-menu.view', `View`),
|
text: t('panel.header-menu.view', `View`),
|
||||||
iconClassName: 'eye',
|
iconClassName: 'eye',
|
||||||
@ -93,18 +103,67 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
|
|||||||
if (config.featureToggles.datatrails) {
|
if (config.featureToggles.datatrails) {
|
||||||
addDataTrailPanelAction(dashboard, panel, items);
|
addDataTrailPanelAction(dashboard, panel, items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exploreMenuItem) {
|
||||||
|
items.push(exploreMenuItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
const exploreUrl = await tryGetExploreUrlForPanel(panel);
|
items.push(getInspectMenuItem(plugin, panel, dashboard));
|
||||||
if (exploreUrl) {
|
|
||||||
|
const { extensions } = getPluginLinkExtensions({
|
||||||
|
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
|
||||||
|
context: createExtensionContext(panel, dashboard),
|
||||||
|
limitPerPlugin: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (extensions.length > 0 && !dashboard.state.isEditing) {
|
||||||
items.push({
|
items.push({
|
||||||
|
text: 'Extensions',
|
||||||
|
iconClassName: 'plug',
|
||||||
|
type: 'submenu',
|
||||||
|
subMenu: createExtensionSubMenu(extensions),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moreSubMenu.length) {
|
||||||
|
items.push({
|
||||||
|
type: 'submenu',
|
||||||
|
text: t('panel.header-menu.more', `More...`),
|
||||||
|
iconClassName: 'cube',
|
||||||
|
subMenu: moreSubMenu,
|
||||||
|
onClick: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
menu.setState({ items });
|
||||||
|
};
|
||||||
|
|
||||||
|
asyncFunc();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getExploreMenuItem(panel: VizPanel): Promise<PanelMenuItem | undefined> {
|
||||||
|
const exploreUrl = await tryGetExploreUrlForPanel(panel);
|
||||||
|
if (!exploreUrl) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
text: t('panel.header-menu.explore', `Explore`),
|
text: t('panel.header-menu.explore', `Explore`),
|
||||||
iconClassName: 'compass',
|
iconClassName: 'compass',
|
||||||
shortcut: 'p x',
|
shortcut: 'p x',
|
||||||
onClick: () => DashboardInteractions.panelMenuItemClicked('explore'),
|
onClick: () => DashboardInteractions.panelMenuItemClicked('explore'),
|
||||||
href: exploreUrl,
|
href: exploreUrl,
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getInspectMenuItem(
|
||||||
|
plugin: PanelPlugin | undefined,
|
||||||
|
panel: VizPanel,
|
||||||
|
dashboard: DashboardScene
|
||||||
|
): PanelMenuItem {
|
||||||
|
const inspectSubMenu: PanelMenuItem[] = [];
|
||||||
|
|
||||||
if (plugin && !plugin.meta.skipDataQuery) {
|
if (plugin && !plugin.meta.skipDataQuery) {
|
||||||
inspectSubMenu.push({
|
inspectSubMenu.push({
|
||||||
@ -140,7 +199,7 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
items.push({
|
return {
|
||||||
text: t('panel.header-menu.inspect', `Inspect`),
|
text: t('panel.header-menu.inspect', `Inspect`),
|
||||||
iconClassName: 'info-circle',
|
iconClassName: 'info-circle',
|
||||||
shortcut: 'i',
|
shortcut: 'i',
|
||||||
@ -152,41 +211,7 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
subMenu: inspectSubMenu.length > 0 ? inspectSubMenu : undefined,
|
subMenu: inspectSubMenu.length > 0 ? inspectSubMenu : undefined,
|
||||||
});
|
|
||||||
|
|
||||||
if (dashboard instanceof DashboardScene) {
|
|
||||||
const { extensions } = getPluginLinkExtensions({
|
|
||||||
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
|
|
||||||
context: createExtensionContext(panel, dashboard),
|
|
||||||
limitPerPlugin: 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (extensions.length > 0 && !dashboard.state.isEditing) {
|
|
||||||
items.push({
|
|
||||||
text: 'Extensions',
|
|
||||||
iconClassName: 'plug',
|
|
||||||
type: 'submenu',
|
|
||||||
subMenu: createExtensionSubMenu(extensions),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (moreSubMenu.length) {
|
|
||||||
items.push({
|
|
||||||
type: 'submenu',
|
|
||||||
text: t('panel.header-menu.more', `More...`),
|
|
||||||
iconClassName: 'cube',
|
|
||||||
subMenu: moreSubMenu,
|
|
||||||
onClick: (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
menu.setState({ items });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
asyncFunc();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -62,6 +62,10 @@ export interface DashboardLoaderState {
|
|||||||
loadError?: string;
|
loadError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SaveModelToSceneOptions {
|
||||||
|
isEmbedded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function transformSaveModelToScene(rsp: DashboardDTO): DashboardScene {
|
export function transformSaveModelToScene(rsp: DashboardDTO): DashboardScene {
|
||||||
// Just to have migrations run
|
// Just to have migrations run
|
||||||
const oldModel = new DashboardModel(rsp.dashboard, rsp.meta, {
|
const oldModel = new DashboardModel(rsp.dashboard, rsp.meta, {
|
||||||
|
@ -82,7 +82,7 @@ export class SupportSnapshotService extends StateManagerBase<SupportSnapshotStat
|
|||||||
|
|
||||||
if (!panel.isAngularPlugin()) {
|
if (!panel.isAngularPlugin()) {
|
||||||
try {
|
try {
|
||||||
const oldModel = new DashboardModel(snapshot);
|
const oldModel = new DashboardModel(snapshot, { isEmbedded: true });
|
||||||
const dash = createDashboardSceneFromDashboardModel(oldModel);
|
const dash = createDashboardSceneFromDashboardModel(oldModel);
|
||||||
scene = dash.state.body; // skip the wrappers
|
scene = dash.state.body; // skip the wrappers
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
|
@ -38,7 +38,7 @@ function DashboardPageProxy(props: DashboardPageProxyProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return stateManager.fetchDashboard(dashToFetch);
|
return stateManager.fetchDashboard({ uid: dashToFetch });
|
||||||
}, [props.match.params.uid, props.route.routeName]);
|
}, [props.match.params.uid, props.route.routeName]);
|
||||||
|
|
||||||
if (!config.featureToggles.dashboardSceneForViewers) {
|
if (!config.featureToggles.dashboardSceneForViewers) {
|
||||||
|
@ -70,6 +70,16 @@ export function getAppRoutes(): RouteDescriptor[] {
|
|||||||
() => import(/* webpackChunkName: "DashboardPage" */ '../features/dashboard/containers/DashboardPageProxy')
|
() => import(/* webpackChunkName: "DashboardPage" */ '../features/dashboard/containers/DashboardPageProxy')
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// We currently have no core usage of the embedded dashboard so is to have a page for e2e to test
|
||||||
|
path: '/dashboards/embedding-test',
|
||||||
|
component: SafeDynamicImport(
|
||||||
|
() =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "DashboardPage"*/ 'app/features/dashboard-scene/embedding/EmbeddedDashboardTestPage'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/d-solo/:uid/:slug',
|
path: '/d-solo/:uid/:slug',
|
||||||
pageClass: 'dashboard-solo',
|
pageClass: 'dashboard-solo',
|
||||||
|
@ -50,6 +50,7 @@ export interface DashboardMeta {
|
|||||||
publicDashboardUid?: string;
|
publicDashboardUid?: string;
|
||||||
publicDashboardEnabled?: boolean;
|
publicDashboardEnabled?: boolean;
|
||||||
dashboardNotFound?: boolean;
|
dashboardNotFound?: boolean;
|
||||||
|
isEmbedded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnnotationActions {
|
export interface AnnotationActions {
|
||||||
|
Loading…
Reference in New Issue
Block a user