Dashboard datasource: Fix library panels not tracked in mixed queries (#112959)

* Dashboard datasource: Fix library panels not tracked in mixed queries

* Remove unnecessary code and add unit tests

* Add relevant comment
This commit is contained in:
Alexa Vargas
2025-11-03 18:01:29 +01:00
committed by GitHub
parent 06fb3fef43
commit ca5cf5fe7c
2 changed files with 465 additions and 50 deletions
@@ -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 () => {
@@ -18,7 +18,7 @@ import { LibraryPanelBehaviorState } from './LibraryPanelBehavior';
interface DashboardDatasourceBehaviourState extends SceneObjectState {}
export class DashboardDatasourceBehaviour extends SceneObjectBase<DashboardDatasourceBehaviourState> {
private prevRequestId: string | undefined;
private prevRequestIds: Map<number, string> = new Map();
public constructor(state: DashboardDatasourceBehaviourState) {
super(state);
@@ -27,9 +27,8 @@ export class DashboardDatasourceBehaviour extends SceneObjectBase<DashboardDatas
private _activationHandler() {
const queryRunner = this.parent;
let libraryPanelSub: Unsubscribable;
let transformerSub: Unsubscribable;
let dashboard: DashboardScene;
if (!(queryRunner instanceof SceneQueryRunner)) {
throw new Error('DashboardDatasourceBehaviour must be attached to a SceneQueryRunner');
}
@@ -44,64 +43,123 @@ export class DashboardDatasourceBehaviour extends SceneObjectBase<DashboardDatas
return;
}
const dashboardQuery = queryRunner.state.queries.find((query) => 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());
};
}