diff --git a/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx new file mode 100644 index 00000000000..453b3e7983f --- /dev/null +++ b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx @@ -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; + +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 }; +} diff --git a/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.tsx b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.tsx new file mode 100644 index 00000000000..d39903a6ca0 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.tsx @@ -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 { + 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; + }; + } +} diff --git a/public/app/features/dashboard-scene/utils/createPanelDataProvider.ts b/public/app/features/dashboard-scene/utils/createPanelDataProvider.ts index 774ff4c31c3..e9754ebf206 100644 --- a/public/app/features/dashboard-scene/utils/createPanelDataProvider.ts +++ b/public/app/features/dashboard-scene/utils/createPanelDataProvider.ts @@ -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 diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts index 780c4b0445c..7a2bdf0fa02 100644 --- a/public/app/features/dashboard-scene/utils/utils.ts +++ b/public/app/features/dashboard-scene/utils/utils.ts @@ -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: [], }), diff --git a/public/app/plugins/datasource/dashboard/datasource.ts b/public/app/plugins/datasource/dashboard/datasource.ts index c90c36d6963..439e573dd47 100644 --- a/public/app/plugins/datasource/dashboard/datasource.ts +++ b/public/app/plugins/datasource/dashboard/datasource.ts @@ -27,6 +27,10 @@ export class DashboardDatasource extends DataSourceApi { query(options: DataQueryRequest): Observable { 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'); }