Snapshots: Fix V2 Snapshot data coupling (#115278)

* Snapshots: Potential fix for rendering V2 snaps

* removing comments

* Added unit test
This commit is contained in:
Collin Fingar
2025-12-19 11:30:24 -05:00
committed by GitHub
parent 0e4b1c7b1e
commit fa73caf6c8
2 changed files with 160 additions and 14 deletions
@@ -1,4 +1,4 @@
import { VariableRefresh } from '@grafana/data';
import { VariableRefresh, PanelData, LoadingState, toDataFrame, FieldType, getDefaultTimeRange } from '@grafana/data';
import { config } from '@grafana/runtime';
import {
AdHocFiltersVariable,
@@ -18,6 +18,8 @@ import {
VizPanel,
SceneDataQuery,
SceneQueryRunner,
SceneDataTransformer,
SceneDataNode,
sceneUtils,
dataLayers,
} from '@grafana/scenes';
@@ -33,6 +35,7 @@ import {
TabsLayoutSpec,
defaultDataQueryKind,
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
import { DashboardEditPane } from '../edit-pane/DashboardEditPane';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
@@ -954,6 +957,103 @@ describe('getVizPanelQueries', () => {
expect(result[1].spec.query.group).toBe('prometheus');
expect(result[1].spec.query.version).toBe('v0');
});
describe('snapshot mode', () => {
it('should return empty queries when isSnapshot is true but panel has no data provider', () => {
const vizPanel = new VizPanel({
key: 'panel-1',
pluginId: 'timeseries',
// No $data provider
});
const result = getVizPanelQueries(vizPanel, undefined, true);
expect(result).toEqual([]);
});
it('should create snapshot query from SceneQueryRunner data when isSnapshot is true', () => {
const mockDataFrame = toDataFrame({
name: 'test-series',
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000] },
{ name: 'value', type: FieldType.number, values: [1, 2, 3] },
],
});
const panelData: PanelData = {
series: [mockDataFrame],
state: LoadingState.Done,
timeRange: getDefaultTimeRange(),
};
const queryRunner = new SceneQueryRunner({
queries: [],
data: panelData,
});
const vizPanel = new VizPanel({
key: 'panel-1',
pluginId: 'timeseries',
$data: queryRunner,
});
const result = getVizPanelQueries(vizPanel, undefined, true);
expect(result).toHaveLength(1);
expect(result[0].kind).toBe('PanelQuery');
expect(result[0].spec.refId).toBe('A');
expect(result[0].spec.hidden).toBe(false);
expect(result[0].spec.query.kind).toBe('DataQuery');
expect(result[0].spec.query.version).toBe(defaultDataQueryKind().version);
expect(result[0].spec.query.group).toBe('grafana');
expect(result[0].spec.query.datasource).toEqual({ name: 'grafana' });
expect(result[0].spec.query.spec.queryType).toBe(GrafanaQueryType.Snapshot);
expect(result[0].spec.query.spec.snapshot).toBeDefined();
expect(result[0].spec.query.spec.snapshot).toHaveLength(1);
expect(result[0].spec.query.spec.snapshot[0].schema?.fields).toBeDefined();
});
it('should create snapshot query from SceneDataTransformer data when isSnapshot is true', () => {
const mockDataFrame = toDataFrame({
name: 'transformed-series',
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000] },
{ name: 'transformed', type: FieldType.number, values: [10, 20] },
],
});
const panelData: PanelData = {
series: [mockDataFrame],
state: LoadingState.Done,
timeRange: getDefaultTimeRange(),
};
const dataNode = new SceneDataNode({
data: panelData,
});
const dataTransformer = new SceneDataTransformer({
$data: dataNode,
transformations: [],
});
const vizPanel = new VizPanel({
key: 'panel-1',
pluginId: 'timeseries',
$data: dataTransformer,
});
const result = getVizPanelQueries(vizPanel, undefined, true);
expect(result).toHaveLength(1);
expect(result[0].kind).toBe('PanelQuery');
expect(result[0].spec.query.kind).toBe('DataQuery');
expect(result[0].spec.query.spec.queryType).toBe(GrafanaQueryType.Snapshot);
expect(result[0].spec.query.spec.snapshot).toBeDefined();
expect(result[0].spec.query.spec.snapshot).toHaveLength(1);
// Verify it gets data from the nested $data (SceneDataNode) not the transformer
expect(result[0].spec.query.spec.snapshot[0].schema?.fields).toBeDefined();
});
});
});
function getMinimalSceneState(body: DashboardLayoutManager): Partial<DashboardSceneState> {
@@ -13,8 +13,10 @@ import {
SceneVariableSet,
VizPanel,
} from '@grafana/scenes';
import { DataSourceRef, VariableRefresh } from '@grafana/schema';
import { DataSourceRef } from '@grafana/schema';
import { sortedDeepCloneWithoutNulls } from 'app/core/utils/object';
import { getPanelDataFrames } from 'app/features/dashboard/components/HelpWizard/utils';
import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
import {
Spec as DashboardV2Spec,
@@ -127,7 +129,7 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps
// EOF variables
// elements
elements: getElements(scene, dsReferencesMapping),
elements: getElements(scene, dsReferencesMapping, isSnapshot),
// EOF elements
// annotations
@@ -170,17 +172,18 @@ function getLiveNow(state: DashboardSceneState) {
return Boolean(liveNow);
}
function getElements(scene: DashboardScene, dsReferencesMapping?: DSReferencesMapping) {
function getElements(scene: DashboardScene, dsReferencesMapping?: DSReferencesMapping, isSnapshot = false) {
const panels = scene.state.body.getVizPanels() ?? [];
const panelsArray = panels.map((vizPanel) => {
return vizPanelToSchemaV2(vizPanel, dsReferencesMapping);
return vizPanelToSchemaV2(vizPanel, dsReferencesMapping, isSnapshot);
});
return createElements(panelsArray, scene);
}
export function vizPanelToSchemaV2(
vizPanel: VizPanel,
dsReferencesMapping?: DSReferencesMapping
dsReferencesMapping?: DSReferencesMapping,
isSnapshot = false
): PanelKind | LibraryPanelKind {
if (isLibraryPanel(vizPanel)) {
const behavior = getLibraryPanelBehavior(vizPanel)!;
@@ -216,7 +219,7 @@ export function vizPanelToSchemaV2(
data: {
kind: 'QueryGroup',
spec: {
queries: getVizPanelQueries(vizPanel, dsReferencesMapping),
queries: getVizPanelQueries(vizPanel, dsReferencesMapping, isSnapshot),
transformations: getVizPanelTransformations(vizPanel),
queryOptions: getVizPanelQueryOptions(vizPanel),
},
@@ -290,9 +293,51 @@ function getPanelLinks(panel: VizPanel): DataLink[] {
return [];
}
export function getVizPanelQueries(vizPanel: VizPanel, dsReferencesMapping?: DSReferencesMapping): PanelQueryKind[] {
export function getVizPanelQueries(
vizPanel: VizPanel,
dsReferencesMapping?: DSReferencesMapping,
isSnapshot = false
): PanelQueryKind[] {
const queries: PanelQueryKind[] = [];
const queryRunner = getQueryRunnerFor(vizPanel);
if (isSnapshot) {
const dataProvider = vizPanel.state.$data;
if (!dataProvider) {
return queries;
}
let snapshotData = getPanelDataFrames(dataProvider.state.data);
if (dataProvider instanceof SceneDataTransformer) {
snapshotData = getPanelDataFrames(dataProvider.state.$data!.state.data);
}
const snapshotQuery: DataQueryKind = {
kind: 'DataQuery',
version: defaultDataQueryKind().version,
group: 'grafana',
datasource: {
name: 'grafana',
},
spec: {
queryType: GrafanaQueryType.Snapshot,
snapshot: snapshotData,
},
};
queries.push({
kind: 'PanelQuery',
spec: {
query: snapshotQuery,
refId: 'A',
hidden: false,
},
});
return queries;
}
// Regular query handling (non-snapshot)
const vizPanelQueries = queryRunner?.state.queries;
if (vizPanelQueries) {
@@ -631,15 +676,16 @@ export function trimDashboardForSnapshot(title: string, time: TimeRange, dash: D
if (spec.variables) {
spec.variables.forEach((variable) => {
if ('query' in variable) {
variable.query = '';
if ('query' in variable.spec) {
variable.spec.query = '';
}
if ('options' in variable && 'current' in variable) {
variable.options = variable.current && !isEmptyObject(variable.current) ? [variable.current] : [];
if ('options' in variable.spec && 'current' in variable.spec) {
variable.spec.options =
variable.spec.current && !isEmptyObject(variable.spec.current) ? [variable.spec.current] : [];
}
if ('refresh' in variable) {
variable.refresh = VariableRefresh.never;
if ('refresh' in variable.spec) {
variable.spec.refresh = 'never';
}
});
}