Check source panel for updates in Dashboard DS panel (#85655)

* Check source panel for updates in Dashboard DS panel

* Test

* made it better

* cleanup

* cleanup and tests

* fix failing tests

* find the correct dashboard query

* revert mixed check in dashboard behaviour

* Dashboard data source: Return error when used in mixed data source (#85765)

* MixedDS: Inform about dashboard ds not being supported

* lint fix

---------

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
Victor Marin 2024-04-09 20:59:37 +03:00 committed by GitHub
parent f79dd7c7f9
commit b6249d6a50
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 620 additions and 0 deletions

View File

@ -0,0 +1,547 @@
import { map, of } from 'rxjs';
import {
DataQuery,
DataQueryRequest,
DataSourceApi,
DataSourceJsonData,
DataSourceRef,
LoadingState,
PanelData,
} from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { setPluginImportUtils } from '@grafana/runtime';
import { SceneGridLayout, SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types';
import { VizPanelManager } from '../panel-edit/VizPanelManager';
import { activateFullSceneTree } from '../utils/test-utils';
import { DashboardDatasourceBehaviour } from './DashboardDatasourceBehaviour';
import { DashboardGridItem } from './DashboardGridItem';
import { DashboardScene } from './DashboardScene';
const grafanaDs = {
id: 1,
uid: '-- Grafana --',
name: 'grafana',
type: 'grafana',
meta: {
id: 'grafana',
},
getRef: () => {
return { type: 'grafana', uid: '-- Grafana --' };
},
};
const dashboardDs: DataSourceApi = {
meta: {
id: DASHBOARD_DATASOURCE_PLUGIN_ID,
},
name: SHARED_DASHBOARD_QUERY,
type: SHARED_DASHBOARD_QUERY,
uid: SHARED_DASHBOARD_QUERY,
getRef: () => {
return { type: SHARED_DASHBOARD_QUERY, uid: SHARED_DASHBOARD_QUERY };
},
} as DataSourceApi<DataQuery, DataSourceJsonData, {}>;
setPluginImportUtils({
importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})),
getPanelPluginFromCache: (id: string) => undefined,
});
const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => {
const result: PanelData = {
state: LoadingState.Loading,
series: [],
timeRange: request.range,
request,
};
return of([]).pipe(
map(() => {
result.state = LoadingState.Done;
result.series = [];
return result;
})
);
});
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => {
return runRequestMock(ds, request);
},
getDataSourceSrv: () => {
return {
get: async (ref: DataSourceRef) => {
if (ref.uid === 'grafana') {
return grafanaDs;
}
if (ref.uid === SHARED_DASHBOARD_QUERY) {
return dashboardDs;
}
return null;
},
getInstanceSettings: jest.fn().mockResolvedValue({ uid: 'ds1' }),
};
},
}));
describe('DashboardDatasourceBehaviour', () => {
describe('Given scene with a dashboard DS panel and a source panel', () => {
let scene: DashboardScene, sourcePanel: VizPanel, dashboardDSPanel: VizPanel, sceneDeactivate: () => void;
beforeEach(async () => {
({ scene, sourcePanel, dashboardDSPanel, sceneDeactivate } = await buildTestScene());
});
it('Should re-run query of dashboardDS panel when source query re-runs', async () => {
// spy on runQueries that will be called by the behaviour
const spy = jest.spyOn(dashboardDSPanel.state.$data as SceneQueryRunner, 'runQueries');
// deactivate scene to mimic going into panel edit
sceneDeactivate();
// run source panel queries and update request ID
(sourcePanel.state.$data as SceneQueryRunner).runQueries();
await new Promise((r) => setTimeout(r, 1));
// activate scene to mimic coming back from panel edit
activateFullSceneTree(scene);
expect(spy).toHaveBeenCalledTimes(1);
});
it('Should not run query of dashboardDS panel when source panel queries do not change', async () => {
// spy on runQueries
const spy = jest.spyOn(dashboardDSPanel.state.$data as SceneQueryRunner, 'runQueries');
// deactivate scene to mimic going into panel edit
sceneDeactivate();
await new Promise((r) => setTimeout(r, 1));
// activate scene to mimic coming back from panel edit
activateFullSceneTree(scene);
expect(spy).not.toHaveBeenCalled();
});
it('Should not re-run queries in behaviour when adding a dashboardDS panel to the scene', async () => {
const sourcePanel = new VizPanel({
title: 'Panel A',
pluginId: 'table',
key: 'panel-1',
$data: new SceneQueryRunner({
datasource: { uid: 'grafana' },
queries: [{ refId: 'A', queryType: 'randomWalk' }],
}),
});
const behaviour = new DashboardDatasourceBehaviour({});
const dashboardDSPanel = new VizPanel({
title: 'Panel B',
pluginId: 'table',
key: 'panel-2',
$data: new SceneQueryRunner({
datasource: { uid: SHARED_DASHBOARD_QUERY },
queries: [{ refId: 'A', panelId: 1 }],
$behaviors: [behaviour],
}),
});
const scene = new DashboardScene({
title: 'hello',
uid: 'dash-1',
meta: {
canEdit: true,
},
body: new SceneGridLayout({
children: [
new DashboardGridItem({
key: 'griditem-1',
x: 0,
y: 0,
width: 10,
height: 12,
body: sourcePanel,
}),
],
}),
});
activateFullSceneTree(scene);
await new Promise((r) => setTimeout(r, 1));
const spy = jest.spyOn(dashboardDSPanel.state.$data as SceneQueryRunner, 'runQueries');
const layout = scene.state.body as SceneGridLayout;
// we add the new panel, it should run it's query as usual
layout.setState({
children: [
...layout.state.children,
new DashboardGridItem({
key: 'griditem-2',
x: 0,
y: 0,
width: 10,
height: 12,
body: dashboardDSPanel,
}),
],
});
dashboardDSPanel.activate();
expect(spy).toHaveBeenCalledTimes(1);
// since there is no previous request ID on dashboard load, the behaviour should not re-run queries
expect(behaviour['prevRequestId']).toBeUndefined();
});
it('Should not re-run queries in behaviour on scene load', async () => {
const sourcePanel = new VizPanel({
title: 'Panel A',
pluginId: 'table',
key: 'panel-1',
$data: new SceneQueryRunner({
datasource: { uid: 'grafana' },
queries: [{ refId: 'A', queryType: 'randomWalk' }],
}),
});
const behaviour = new DashboardDatasourceBehaviour({});
const dashboardDSPanel = new VizPanel({
title: 'Panel B',
pluginId: 'table',
key: 'panel-2',
$data: new SceneQueryRunner({
datasource: { uid: SHARED_DASHBOARD_QUERY },
queries: [{ refId: 'A', panelId: 1 }],
$behaviors: [behaviour],
}),
});
const scene = new DashboardScene({
title: 'hello',
uid: 'dash-1',
meta: {
canEdit: true,
},
body: new SceneGridLayout({
children: [
new DashboardGridItem({
key: 'griditem-1',
x: 0,
y: 0,
width: 10,
height: 12,
body: sourcePanel,
}),
new DashboardGridItem({
key: 'griditem-2',
x: 0,
y: 0,
width: 10,
height: 12,
body: dashboardDSPanel,
}),
],
}),
});
const spy = jest.spyOn(dashboardDSPanel.state.$data as SceneQueryRunner, 'runQueries');
activateFullSceneTree(scene);
await new Promise((r) => setTimeout(r, 1));
expect(spy).toHaveBeenCalledTimes(1);
// since there is no previous request ID on dashboard load, the behaviour should not re-run queries
expect(behaviour['prevRequestId']).toBeUndefined();
});
it('Should exit behaviour early if not in a dashboard scene', async () => {
// spy on runQueries
const spy = jest.spyOn(dashboardDSPanel.state.$data as SceneQueryRunner, 'runQueries');
const vizPanelManager = new VizPanelManager({
panel: dashboardDSPanel.clone({ $data: undefined }),
$data: dashboardDSPanel.state.$data?.clone(),
sourcePanel: dashboardDSPanel.getRef(),
});
vizPanelManager.activate();
expect(spy).not.toHaveBeenCalled();
});
it('Should not re-run queries if dashboard DS panel references an invalid source panel', async () => {
const sourcePanel = new VizPanel({
title: 'Panel A',
pluginId: 'table',
key: 'panel-1',
$data: new SceneQueryRunner({
datasource: { uid: 'grafana' },
queries: [{ refId: 'A', queryType: 'randomWalk' }],
}),
});
// query references inexistent panel
const dashboardDSPanel = new VizPanel({
title: 'Panel B',
pluginId: 'table',
key: 'panel-2',
$data: new SceneQueryRunner({
datasource: { uid: SHARED_DASHBOARD_QUERY },
queries: [{ refId: 'A', panelId: 10 }],
$behaviors: [new DashboardDatasourceBehaviour({})],
}),
});
const scene = new DashboardScene({
title: 'hello',
uid: 'dash-1',
meta: {
canEdit: true,
},
body: new SceneGridLayout({
children: [
new DashboardGridItem({
key: 'griditem-1',
x: 0,
y: 0,
width: 10,
height: 12,
body: sourcePanel,
}),
new DashboardGridItem({
key: 'griditem-2',
x: 0,
y: 0,
width: 10,
height: 12,
body: dashboardDSPanel,
}),
],
}),
});
const sceneDeactivate = activateFullSceneTree(scene);
await new Promise((r) => setTimeout(r, 1));
// spy on runQueries
const spy = jest.spyOn(dashboardDSPanel.state.$data as SceneQueryRunner, 'runQueries');
// deactivate scene to mimic going into panel edit
sceneDeactivate();
await new Promise((r) => setTimeout(r, 1));
// activate scene to mimic coming back from panel edit
activateFullSceneTree(scene);
expect(spy).not.toHaveBeenCalled();
});
});
describe('Given scene with no DashboardDS panel', () => {
it('Should not re-run queries and exit early in behaviour', async () => {
const sourcePanel = new VizPanel({
title: 'Panel A',
pluginId: 'table',
key: 'panel-1',
$data: new SceneQueryRunner({
datasource: { uid: 'grafana' },
queries: [{ refId: 'A', queryType: 'randomWalk' }],
}),
});
const anotherPanel = new VizPanel({
title: 'Panel B',
pluginId: 'table',
key: 'panel-2',
$data: new SceneQueryRunner({
datasource: { uid: 'grafana' },
queries: [{ refId: 'A', queryType: 'randomWalk' }],
}),
});
const scene = new DashboardScene({
title: 'hello',
uid: 'dash-1',
meta: {
canEdit: true,
},
body: new SceneGridLayout({
children: [
new DashboardGridItem({
key: 'griditem-1',
x: 0,
y: 0,
width: 10,
height: 12,
body: sourcePanel,
}),
new DashboardGridItem({
key: 'griditem-2',
x: 0,
y: 0,
width: 10,
height: 12,
body: anotherPanel,
}),
],
}),
});
const sceneDeactivate = activateFullSceneTree(scene);
await new Promise((r) => setTimeout(r, 1));
// spy on runQueries
const spy = jest.spyOn(anotherPanel.state.$data as SceneQueryRunner, 'runQueries');
// deactivate scene to mimic going into panel edit
sceneDeactivate();
// run source panel queries and update request ID
(sourcePanel.state.$data as SceneQueryRunner).runQueries();
await new Promise((r) => setTimeout(r, 1));
// activate scene to mimic coming back from panel edit
activateFullSceneTree(scene);
expect(spy).not.toHaveBeenCalled();
});
});
describe('Given an invalid state', () => {
it('Should throw an error if behaviour is not attached to a SceneQueryRunner', () => {
const behaviour = new DashboardDatasourceBehaviour({});
expect(() => behaviour.activate()).toThrow('DashboardDatasourceBehaviour must be attached to a SceneQueryRunner');
});
it('Should throw an error if source panel does not have a SceneQueryRunner', async () => {
const sourcePanel = new VizPanel({
title: 'Panel A',
pluginId: 'table',
key: 'panel-1',
$data: undefined,
});
const dashboardDSPanel = new VizPanel({
title: 'Panel B',
pluginId: 'table',
key: 'panel-2',
$data: new SceneQueryRunner({
datasource: { uid: SHARED_DASHBOARD_QUERY },
queries: [{ refId: 'A', panelId: 1 }],
$behaviors: [new DashboardDatasourceBehaviour({})],
}),
});
const scene = new DashboardScene({
title: 'hello',
uid: 'dash-1',
meta: {
canEdit: true,
},
body: new SceneGridLayout({
children: [
new DashboardGridItem({
key: 'griditem-1',
x: 0,
y: 0,
width: 10,
height: 12,
body: sourcePanel,
}),
new DashboardGridItem({
key: 'griditem-2',
x: 0,
y: 0,
width: 10,
height: 12,
body: dashboardDSPanel,
}),
],
}),
});
try {
activateFullSceneTree(scene);
} catch (e) {
expect(e).toEqual(new Error('Could not find SceneQueryRunner for panel'));
}
});
});
});
async function buildTestScene() {
const sourcePanel = new VizPanel({
title: 'Panel A',
pluginId: 'table',
key: 'panel-1',
$data: new SceneQueryRunner({
datasource: { uid: 'grafana' },
queries: [{ refId: 'A', queryType: 'randomWalk' }],
}),
});
const dashboardDSPanel = new VizPanel({
title: 'Panel B',
pluginId: 'table',
key: 'panel-2',
$data: new SceneQueryRunner({
datasource: { uid: SHARED_DASHBOARD_QUERY },
queries: [{ refId: 'A', panelId: 1 }],
$behaviors: [new DashboardDatasourceBehaviour({})],
}),
});
const scene = new DashboardScene({
title: 'hello',
uid: 'dash-1',
meta: {
canEdit: true,
},
body: new SceneGridLayout({
children: [
new DashboardGridItem({
key: 'griditem-1',
x: 0,
y: 0,
width: 10,
height: 12,
body: sourcePanel,
}),
new DashboardGridItem({
key: 'griditem-2',
x: 0,
y: 0,
width: 10,
height: 12,
body: dashboardDSPanel,
}),
],
}),
});
const sceneDeactivate = activateFullSceneTree(scene);
await new Promise((r) => setTimeout(r, 1));
return { scene, sourcePanel, dashboardDSPanel, sceneDeactivate };
}

View File

@ -0,0 +1,64 @@
import { SceneObjectBase, SceneObjectState, SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
import { findVizPanelByKey, getDashboardSceneFor, getQueryRunnerFor, getVizPanelKeyForPanelId } from '../utils/utils';
import { DashboardScene } from './DashboardScene';
interface DashboardDatasourceBehaviourState extends SceneObjectState {}
export class DashboardDatasourceBehaviour extends SceneObjectBase<DashboardDatasourceBehaviourState> {
private prevRequestId: string | undefined;
public constructor(state: DashboardDatasourceBehaviourState) {
super(state);
this.addActivationHandler(() => this._activationHandler());
}
private _activationHandler() {
const queryRunner = this.parent;
let dashboard: DashboardScene;
if (!(queryRunner instanceof SceneQueryRunner)) {
throw new Error('DashboardDatasourceBehaviour must be attached to a SceneQueryRunner');
}
if (queryRunner.state.datasource?.uid !== SHARED_DASHBOARD_QUERY) {
return;
}
try {
dashboard = getDashboardSceneFor(queryRunner);
} catch {
return;
}
const dashboardQuery = queryRunner.state.queries.find((query) => query.panelId !== undefined);
if (!dashboardQuery) {
return;
}
const panelId = dashboardQuery.panelId;
const vizKey = getVizPanelKeyForPanelId(panelId);
const panel = findVizPanelByKey(dashboard, vizKey);
if (!(panel instanceof VizPanel)) {
return;
}
const sourcePanelQueryRunner = getQueryRunnerFor(panel);
if (!(sourcePanelQueryRunner instanceof SceneQueryRunner)) {
throw new Error('Could not find SceneQueryRunner for panel');
}
if (this.prevRequestId && this.prevRequestId !== sourcePanelQueryRunner.state.data?.request?.requestId) {
queryRunner.runQueries();
}
return () => {
this.prevRequestId = sourcePanelQueryRunner.state.data?.request?.requestId;
};
}
}

View File

@ -2,6 +2,8 @@ import { config } from '@grafana/runtime';
import { SceneDataProvider, SceneDataTransformer, SceneQueryRunner } from '@grafana/scenes';
import { PanelModel } from 'app/features/dashboard/state';
import { DashboardDatasourceBehaviour } from '../scene/DashboardDatasourceBehaviour';
export function createPanelDataProvider(panel: PanelModel): SceneDataProvider | undefined {
// Skip setting query runner for panels without queries
if (!panel.targets?.length) {
@ -25,6 +27,7 @@ export function createPanelDataProvider(panel: PanelModel): SceneDataProvider |
dataLayerFilter: {
panelId: panel.id,
},
$behaviors: [new DashboardDatasourceBehaviour({})],
});
// Wrap inner data provider in a data transformer

View File

@ -13,6 +13,7 @@ import {
} from '@grafana/scenes';
import { initialIntervalVariableModelState } from 'app/features/variables/interval/reducer';
import { DashboardDatasourceBehaviour } from '../scene/DashboardDatasourceBehaviour';
import { DashboardScene } from '../scene/DashboardScene';
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
@ -222,6 +223,7 @@ export function getDefaultVizPanel(dashboard: DashboardScene): VizPanel {
$data: new SceneQueryRunner({
queries: [{ refId: 'A' }],
datasource: getDataSourceRef(getDataSourceSrv().getInstanceSettings(null)!),
$behaviors: [new DashboardDatasourceBehaviour({})],
}),
transformations: [],
}),

View File

@ -27,6 +27,10 @@ export class DashboardDatasource extends DataSourceApi<DashboardQuery> {
query(options: DataQueryRequest<DashboardQuery>): Observable<DataQueryResponse> {
const scene: SceneObject | undefined = options.scopedVars?.__sceneObject?.value;
if (options.requestId.indexOf('mixed') > -1) {
throw new Error('Dashboard data source cannot be used with Mixed data source.');
}
if (!scene) {
throw new Error('Can only be called from a scene');
}