diff --git a/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx index 10989531ef4..2d2245d2be8 100644 --- a/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx @@ -198,7 +198,7 @@ describe('DashboardDatasourceBehaviour', () => { 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(); + expect(behaviour['prevRequestIds'].size).toBe(0); }); it('Should not re-run queries in behaviour on scene load', async () => { @@ -242,7 +242,7 @@ describe('DashboardDatasourceBehaviour', () => { 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(); + expect(behaviour['prevRequestIds'].size).toBe(0); }); it('Should exit behaviour early if not in a dashboard scene', async () => { @@ -593,6 +593,363 @@ describe('DashboardDatasourceBehaviour', () => { expect(spy).toHaveBeenCalled(); }); + + it('Should re-run query when ANY source panel changes with multiple dashboardDS queries', async () => { + jest.spyOn(console, 'error').mockImplementation(); + + // Create two source panels + const sourcePanel1 = new VizPanel({ + title: 'Panel A', + pluginId: 'table', + key: 'panel-1', + $data: new SceneDataTransformer({ + transformations: [], + $data: new SceneQueryRunner({ + datasource: { uid: 'grafana' }, + queries: [{ refId: 'A', queryType: 'randomWalk' }], + }), + }), + }); + + const sourcePanel2 = new VizPanel({ + title: 'Panel B', + pluginId: 'table', + key: 'panel-2', + $data: new SceneDataTransformer({ + transformations: [], + $data: new SceneQueryRunner({ + datasource: { uid: 'grafana' }, + queries: [{ refId: 'A', queryType: 'randomWalk' }], + }), + }), + }); + + // Create a mixed DS panel that references BOTH source panels + const mixedDSPanel = new VizPanel({ + title: 'Panel C - Mixed', + pluginId: 'table', + key: 'panel-3', + $data: new SceneDataTransformer({ + transformations: [], + $data: new SceneQueryRunner({ + datasource: { uid: MIXED_DATASOURCE_NAME }, + queries: [ + { + datasource: { uid: SHARED_DASHBOARD_QUERY }, + refId: 'A', + panelId: 1, + }, + { + datasource: { uid: SHARED_DASHBOARD_QUERY }, + refId: 'B', + panelId: 2, + }, + ], + $behaviors: [new DashboardDatasourceBehaviour({})], + }), + }), + }); + + const scene = new DashboardScene({ + title: 'hello', + uid: 'dash-1', + meta: { + canEdit: true, + }, + body: DefaultGridLayoutManager.fromVizPanels([sourcePanel1, sourcePanel2, mixedDSPanel]), + }); + + const sceneDeactivate = activateFullSceneTree(scene); + + await new Promise((r) => setTimeout(r, 1)); + + const spy = jest + .spyOn(mixedDSPanel.state.$data!.state.$data as SceneQueryRunner, 'runQueries') + .mockImplementation(); + + // deactivate scene + sceneDeactivate(); + + // Only change the SECOND source panel + (sourcePanel2.state.$data!.state.$data as SceneQueryRunner).runQueries(); + + await new Promise((r) => setTimeout(r, 1)); + + // activate scene again + activateFullSceneTree(scene); + + // Should re-run because the second panel changed + expect(spy).toHaveBeenCalled(); + }); + + it('Should track multiple dashboardDS queries independently', async () => { + jest.spyOn(console, 'error').mockImplementation(); + + const sourcePanel1 = new VizPanel({ + title: 'Panel A', + pluginId: 'table', + key: 'panel-1', + $data: new SceneDataTransformer({ + transformations: [], + $data: new SceneQueryRunner({ + datasource: { uid: 'grafana' }, + queries: [{ refId: 'A', queryType: 'randomWalk' }], + }), + }), + }); + + const sourcePanel2 = new VizPanel({ + title: 'Panel B', + pluginId: 'table', + key: 'panel-2', + $data: new SceneDataTransformer({ + transformations: [], + $data: new SceneQueryRunner({ + datasource: { uid: 'grafana' }, + queries: [{ refId: 'A', queryType: 'randomWalk' }], + }), + }), + }); + + const mixedDSPanel = new VizPanel({ + title: 'Panel C - Mixed', + pluginId: 'table', + key: 'panel-3', + $data: new SceneDataTransformer({ + transformations: [], + $data: new SceneQueryRunner({ + datasource: { uid: MIXED_DATASOURCE_NAME }, + queries: [ + { + datasource: { uid: SHARED_DASHBOARD_QUERY }, + refId: 'A', + panelId: 1, + }, + { + datasource: { uid: SHARED_DASHBOARD_QUERY }, + refId: 'B', + panelId: 2, + }, + ], + $behaviors: [new DashboardDatasourceBehaviour({})], + }), + }), + }); + + const scene = new DashboardScene({ + title: 'hello', + uid: 'dash-1', + meta: { + canEdit: true, + }, + body: DefaultGridLayoutManager.fromVizPanels([sourcePanel1, sourcePanel2, mixedDSPanel]), + }); + + const sceneDeactivate = activateFullSceneTree(scene); + + await new Promise((r) => setTimeout(r, 1)); + + const spy = jest + .spyOn(mixedDSPanel.state.$data!.state.$data as SceneQueryRunner, 'runQueries') + .mockImplementation(); + + // First cycle: change panel 1 + sceneDeactivate(); + (sourcePanel1.state.$data!.state.$data as SceneQueryRunner).runQueries(); + await new Promise((r) => setTimeout(r, 1)); + const deactivate2 = activateFullSceneTree(scene); + expect(spy).toHaveBeenCalledTimes(1); + + // Second cycle: change panel 2 + deactivate2(); + (sourcePanel2.state.$data!.state.$data as SceneQueryRunner).runQueries(); + await new Promise((r) => setTimeout(r, 1)); + activateFullSceneTree(scene); + + // Should have been called again for panel 2 + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('Should handle multiple dashboardDS queries with library panels', async () => { + jest.spyOn(console, 'error').mockImplementation(); + + const libPanelBehavior1 = new LibraryPanelBehavior({ + isLoaded: false, + uid: 'lib-panel-1', + name: 'Library Panel 1', + _loadedPanel: undefined, + }); + + const libPanelBehavior2 = new LibraryPanelBehavior({ + isLoaded: false, + uid: 'lib-panel-2', + name: 'Library Panel 2', + _loadedPanel: undefined, + }); + + const sourcePanel1 = new VizPanel({ + title: 'Panel A', + pluginId: 'table', + key: 'panel-1', + $behaviors: [libPanelBehavior1], + $data: new SceneQueryRunner({ + datasource: { uid: 'grafana' }, + queries: [{ refId: 'A', queryType: 'randomWalk' }], + }), + }); + + const sourcePanel2 = new VizPanel({ + title: 'Panel B', + pluginId: 'table', + key: 'panel-2', + $behaviors: [libPanelBehavior2], + $data: new SceneQueryRunner({ + datasource: { uid: 'grafana' }, + queries: [{ refId: 'A', queryType: 'randomWalk' }], + }), + }); + + const mixedDSPanel = new VizPanel({ + title: 'Panel C - Mixed', + pluginId: 'table', + key: 'panel-3', + $data: new SceneQueryRunner({ + datasource: { uid: MIXED_DATASOURCE_NAME }, + queries: [ + { + datasource: { uid: SHARED_DASHBOARD_QUERY }, + refId: 'A', + panelId: 1, + }, + { + datasource: { uid: SHARED_DASHBOARD_QUERY }, + refId: 'B', + panelId: 2, + }, + ], + $behaviors: [new DashboardDatasourceBehaviour({})], + }), + }); + + const scene = new DashboardScene({ + title: 'hello', + uid: 'dash-1', + meta: { + canEdit: true, + }, + body: DefaultGridLayoutManager.fromVizPanels([sourcePanel1, sourcePanel2, mixedDSPanel]), + }); + + activateFullSceneTree(scene); + + const spy = jest.spyOn(mixedDSPanel.state.$data as SceneQueryRunner, 'runQueries'); + + await new Promise((r) => setTimeout(r, 1)); + + // Should not run queries until library panels are loaded + expect(spy).not.toHaveBeenCalled(); + + // Load first library panel + libPanelBehavior1.setState({ + isLoaded: true, + uid: 'lib-panel-1', + name: 'Library Panel 1', + _loadedPanel: undefined, + }); + + expect(spy).toHaveBeenCalledTimes(1); + + // Load second library panel + libPanelBehavior2.setState({ + isLoaded: true, + uid: 'lib-panel-2', + name: 'Library Panel 2', + _loadedPanel: undefined, + }); + + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('Should handle multiple queries with transformers on all source panels', async () => { + jest.spyOn(console, 'error').mockImplementation(); + + const sourcePanel1 = new VizPanel({ + title: 'Panel A', + pluginId: 'table', + key: 'panel-1', + $data: new SceneDataTransformer({ + transformations: [{ id: 'transformA', options: {} }], + $data: new SceneQueryRunner({ + datasource: { uid: 'grafana' }, + queries: [{ refId: 'A', queryType: 'randomWalk' }], + }), + }), + }); + + const sourcePanel2 = new VizPanel({ + title: 'Panel B', + pluginId: 'table', + key: 'panel-2', + $data: new SceneDataTransformer({ + transformations: [{ id: 'transformB', options: {} }], + $data: new SceneQueryRunner({ + datasource: { uid: 'grafana' }, + queries: [{ refId: 'A', queryType: 'randomWalk' }], + }), + }), + }); + + const mixedDSPanel = new VizPanel({ + title: 'Panel C - Mixed', + pluginId: 'table', + key: 'panel-3', + $data: new SceneQueryRunner({ + datasource: { uid: MIXED_DATASOURCE_NAME }, + queries: [ + { + datasource: { uid: SHARED_DASHBOARD_QUERY }, + refId: 'A', + panelId: 1, + }, + { + datasource: { uid: SHARED_DASHBOARD_QUERY }, + refId: 'B', + panelId: 2, + }, + ], + $behaviors: [new DashboardDatasourceBehaviour({})], + }), + }); + + const scene = new DashboardScene({ + title: 'hello', + uid: 'dash-1', + meta: { + canEdit: true, + }, + body: DefaultGridLayoutManager.fromVizPanels([sourcePanel1, sourcePanel2, mixedDSPanel]), + }); + + activateFullSceneTree(scene); + + await new Promise((r) => setTimeout(r, 1)); + + const spy = jest.spyOn(mixedDSPanel.state.$data as SceneQueryRunner, 'runQueries').mockImplementation(); + + // Trigger transformer reprocessing on panel 1 + (sourcePanel1.state.$data as SceneDataTransformer).setState({ + data: { state: LoadingState.Done, series: [], timeRange: getDefaultTimeRange() }, + }); + + expect(spy).toHaveBeenCalledTimes(1); + + // Trigger transformer reprocessing on panel 2 + (sourcePanel2.state.$data as SceneDataTransformer).setState({ + data: { state: LoadingState.Done, series: [], timeRange: getDefaultTimeRange() }, + }); + + expect(spy).toHaveBeenCalledTimes(2); + }); }); it('Should re-run query after transformations reprocess', async () => { diff --git a/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.tsx b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.tsx index 69fd3472c0b..ceac100b7b8 100644 --- a/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.tsx @@ -18,7 +18,7 @@ import { LibraryPanelBehaviorState } from './LibraryPanelBehavior'; interface DashboardDatasourceBehaviourState extends SceneObjectState {} export class DashboardDatasourceBehaviour extends SceneObjectBase { - private prevRequestId: string | undefined; + private prevRequestIds: Map = new Map(); public constructor(state: DashboardDatasourceBehaviourState) { super(state); @@ -27,9 +27,8 @@ export class DashboardDatasourceBehaviour extends SceneObjectBase query.panelId !== undefined); + /** Get all "Dashboard datasource" queries + * panelId prop is the way we identify Dashboard datasource queries + * { + * datasource: { uid: "-- Dashboard --" }, + * panelId: 12, // ← Points to panel 12 + * refId: "A" + * } + */ + const dashboardDsQueries = queryRunner.state.queries.filter((query) => query.panelId !== undefined); - if (!dashboardQuery) { + if (dashboardDsQueries.length === 0) { return; } - // find the source panel referenced in the the dashboard ds query - const panelId = dashboardQuery.panelId; - const vizKey = getVizPanelKeyForPanelId(panelId); - // We're trying to find the original panel, not a cloned one, since `panelId` alone cannot resolve clones - const sourcePanel = findVizPanelByKey(dashboard, vizKey); + return this._handleQueries(dashboardDsQueries, queryRunner, dashboard); + } - if (!(sourcePanel instanceof VizPanel)) { - return; + /** + * Handles dashboard datasource queries by tracking all referenced panels. + * Supports single or multiple queries with library panels and transformers. + */ + private _handleQueries( + dashboardQueries: Array<{ panelId?: number; [key: string]: unknown }>, + queryRunner: SceneQueryRunner, + dashboard: DashboardScene + ): () => void { + const libraryPanelSubs: Unsubscribable[] = []; + const transformerSubs: Unsubscribable[] = []; + let shouldRunQueries = false; + + // Loop through ALL dashboard queries to track each panel + for (const dashboardQuery of dashboardQueries) { + const panelId = dashboardQuery.panelId; + if (panelId === undefined) { + continue; + } + + const vizKey = getVizPanelKeyForPanelId(panelId); + const sourcePanel = findVizPanelByKey(dashboard, vizKey); + + if (!(sourcePanel instanceof VizPanel)) { + continue; + } + + // Check if the source panel is a library panel and wait for it to load + const libraryPanelBehaviour = getLibraryPanelBehavior(sourcePanel); + + if (libraryPanelBehaviour && !libraryPanelBehaviour.state.isLoaded) { + const sub = libraryPanelBehaviour.subscribeToState((newLibPanel) => { + this.handleLibPanelStateUpdates(newLibPanel, queryRunner, sourcePanel); + }); + libraryPanelSubs.push(sub); + continue; // Don't process transformers until library panel is loaded + } + + // Subscribe to transformer changes for this panel + const sourcePanelQueryRunner = getQueryRunnerFor(sourcePanel); + + if (!sourcePanelQueryRunner) { + continue; // Skip panels without query runners instead of throwing + } + + // Check if this panel's requestId changed since last activation + const currentRequestId = sourcePanelQueryRunner.state.data?.request?.requestId; + const prevRequestId = this.prevRequestIds.get(panelId); + + if (prevRequestId && currentRequestId && prevRequestId !== currentRequestId) { + shouldRunQueries = true; + } + + const dataTransformer = sourcePanelQueryRunner.parent; + + if (dataTransformer instanceof SceneDataTransformer && dataTransformer.state.transformations.length) { + // In mixed DS scenario we complete the observable and merge data, so on a variable change + // the data transformer will emit but there will be no subscription and thus no visual update + // on the panel. Similar thing happens when going to edit mode and back, where we unsubscribe and + // since we never re-run the query, only reprocess the transformations, the panel will not update. + const transformerSub = dataTransformer.subscribeToState((newState, oldState) => { + if (newState.data !== oldState.data) { + queryRunner.runQueries(); + } + }); + transformerSubs.push(transformerSub); + } } - //check if the source panel is a library panel and wait for it to load - const libraryPanelBehaviour = getLibraryPanelBehavior(sourcePanel); - if (libraryPanelBehaviour && !libraryPanelBehaviour.state.isLoaded) { - libraryPanelSub = libraryPanelBehaviour.subscribeToState((newLibPanel) => { - this.handleLibPanelStateUpdates(newLibPanel, queryRunner, sourcePanel); - }); - return; - } - - const sourcePanelQueryRunner = getQueryRunnerFor(sourcePanel); - - if (!sourcePanelQueryRunner) { - throw new Error('Could not find SceneQueryRunner for panel'); - } - - const dataTransformer = sourcePanelQueryRunner.parent; - - if (dataTransformer instanceof SceneDataTransformer && dataTransformer.state.transformations.length) { - // in mixed DS scenario we complete the observable and merge data, so on a variable change - // the data transformer will emit but there will be no subscription and thus not visual update - // on the panel. Similar thing happens when going to edit mode and back, where we unsubscribe and - // since we never re-run the query, only reprocess the transformations, the panel will not update. - transformerSub = dataTransformer.subscribeToState((newState, oldState) => { - if (newState.data !== oldState.data) { - queryRunner.runQueries(); - } - }); - } - - if (this.prevRequestId && this.prevRequestId !== sourcePanelQueryRunner.state.data?.request?.requestId) { + // If any panel's data changed since last activation, run queries + if (shouldRunQueries) { queryRunner.runQueries(); } + // Return cleanup function that unsubscribes from ALL subscriptions return () => { - this.prevRequestId = sourcePanelQueryRunner?.state.data?.request?.requestId; - if (libraryPanelSub) { - libraryPanelSub.unsubscribe(); + // Store all current requestIds before cleanup + for (const dashboardQuery of dashboardQueries) { + const panelId = dashboardQuery.panelId; + if (panelId === undefined) { + continue; + } + + const vizKey = getVizPanelKeyForPanelId(panelId); + const sourcePanel = findVizPanelByKey(dashboard, vizKey); + + if (!(sourcePanel instanceof VizPanel)) { + continue; + } + + const sourcePanelQueryRunner = getQueryRunnerFor(sourcePanel); + const requestId = sourcePanelQueryRunner?.state.data?.request?.requestId; + + if (requestId) { + this.prevRequestIds.set(panelId, requestId); + } } - if (transformerSub) { - transformerSub.unsubscribe(); - } + libraryPanelSubs.forEach((sub) => sub.unsubscribe()); + transformerSubs.forEach((sub) => sub.unsubscribe()); }; }