Skip to content

Commit 640fc4b

Browse files
authored
feat: Support add to dashboard (#746)
* feat: Support add to dashboard * Tidy up * Fix local add to dash * Fix ds interpolate issue * Translations * PR fixes
1 parent d5f4fdc commit 640fc4b

8 files changed

Lines changed: 474 additions & 12 deletions

File tree

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { sceneGraph, SceneQueryRunner, type VizPanel } from '@grafana/scenes';
2+
3+
import pluginJson from '../../../plugin.json';
4+
import { getDataSource, getDatasourceVariable, getTraceExplorationScene } from 'utils/utils';
5+
import {
6+
ADD_TO_DASHBOARD_COMPONENT_ID,
7+
EventOpenAddToDashboard,
8+
getPanelData,
9+
type PanelDataRequestPayload,
10+
} from './addToDashboard';
11+
12+
jest.mock('utils/utils', () => ({
13+
getTraceExplorationScene: jest.fn(),
14+
getDataSource: jest.fn(),
15+
getDatasourceVariable: jest.fn(),
16+
}));
17+
18+
describe('addToDashboard constants', () => {
19+
it('declares the add-to-dashboard exposed component in plugin.json dependencies', () => {
20+
expect(pluginJson.dependencies?.extensions?.exposedComponents).toContain(ADD_TO_DASHBOARD_COMPONENT_ID);
21+
});
22+
23+
it('exposes the add-to-dashboard extension component id', () => {
24+
expect(ADD_TO_DASHBOARD_COMPONENT_ID).toBe('grafana/add-to-dashboard-form/v1');
25+
});
26+
});
27+
28+
describe('EventOpenAddToDashboard', () => {
29+
it('uses a fixed event type', () => {
30+
expect(EventOpenAddToDashboard.type).toBe('open-add-to-dashboard');
31+
});
32+
33+
it('carries panel data on the payload', () => {
34+
const panelData = {
35+
panel: { type: 'timeseries', title: 'T', targets: [] },
36+
range: { from: 'now-1h', to: 'now', raw: { from: 'now-1h', to: 'now' } },
37+
} as unknown as PanelDataRequestPayload;
38+
39+
const evt = new EventOpenAddToDashboard({ panelData });
40+
41+
expect(evt.payload.panelData).toBe(panelData);
42+
});
43+
});
44+
45+
describe('getPanelData', () => {
46+
const mockRange = { from: 'now-6h', to: 'now', raw: { from: 'now-6h', to: 'now' } };
47+
48+
const baseVizPanel = (): VizPanel =>
49+
({
50+
state: {
51+
pluginId: 'timeseries',
52+
title: '{{interval}} — RED',
53+
options: { legend: { displayMode: 'list' } },
54+
fieldConfig: { defaults: {}, overrides: [] },
55+
},
56+
}) as unknown as VizPanel;
57+
58+
beforeEach(() => {
59+
jest.spyOn(sceneGraph, 'getTimeRange').mockReturnValue({
60+
state: { value: mockRange },
61+
} as unknown as ReturnType<typeof sceneGraph.getTimeRange>);
62+
63+
jest.spyOn(sceneGraph, 'interpolate').mockImplementation((_scene, value) =>
64+
typeof value === 'string' ? `[interp:${value}]` : String(value ?? '')
65+
);
66+
67+
jest.spyOn(sceneGraph, 'findDescendents').mockReturnValue([]);
68+
});
69+
70+
afterEach(() => {
71+
jest.restoreAllMocks();
72+
});
73+
74+
it('returns time range from the scene graph', () => {
75+
const vizPanel = baseVizPanel();
76+
const dataRef = {};
77+
jest.spyOn(sceneGraph, 'getData').mockReturnValue(dataRef as ReturnType<typeof sceneGraph.getData>);
78+
jest.spyOn(sceneGraph, 'findObject').mockReturnValue(null);
79+
80+
const { range } = getPanelData(vizPanel);
81+
82+
expect(range).toBe(mockRange);
83+
expect(sceneGraph.getTimeRange).toHaveBeenCalledWith(vizPanel);
84+
});
85+
86+
it('maps viz panel state onto the exported panel and clears targets when no query runner is found', () => {
87+
const vizPanel = baseVizPanel();
88+
jest.spyOn(sceneGraph, 'getData').mockReturnValue({} as ReturnType<typeof sceneGraph.getData>);
89+
jest.spyOn(sceneGraph, 'findObject').mockReturnValue(null);
90+
91+
const { panel } = getPanelData(vizPanel);
92+
93+
expect(panel.type).toBe('timeseries');
94+
expect(panel.title).toBe('[interp:{{interval}} — RED]');
95+
expect(panel.targets).toEqual([]);
96+
expect(panel.options).toEqual(vizPanel.state.options);
97+
expect(panel.fieldConfig).toEqual(vizPanel.state.fieldConfig);
98+
expect(sceneGraph.findObject).toHaveBeenCalled();
99+
});
100+
101+
it('includes optional description when present', () => {
102+
const vizPanel = {
103+
...baseVizPanel(),
104+
state: {
105+
...baseVizPanel().state,
106+
description: 'Panel help text',
107+
},
108+
} as unknown as VizPanel;
109+
110+
jest.spyOn(sceneGraph, 'getData').mockReturnValue({} as ReturnType<typeof sceneGraph.getData>);
111+
jest.spyOn(sceneGraph, 'findObject').mockReturnValue(null);
112+
113+
expect(getPanelData(vizPanel).panel.description).toBe('Panel help text');
114+
});
115+
116+
it('interpolates TraceQL targets and resolves datasource from exploration variable', () => {
117+
const vizPanel = baseVizPanel();
118+
const exploration = {};
119+
const runner = new SceneQueryRunner({
120+
datasource: { uid: '${ds}', type: 'tempo' },
121+
queries: [
122+
{
123+
refId: 'A',
124+
query: '{ resource.service.name =~ "$service" }',
125+
datasource: { uid: '${ds}', type: 'tempo' },
126+
},
127+
],
128+
maxDataPoints: 240,
129+
});
130+
131+
jest.mocked(getTraceExplorationScene).mockReturnValue(exploration as ReturnType<typeof getTraceExplorationScene>);
132+
jest.mocked(getDatasourceVariable).mockReturnValue({
133+
getValue: () => 'tempo-uid-from-var',
134+
} as ReturnType<typeof getDatasourceVariable>);
135+
136+
const dataRef = {};
137+
jest.spyOn(sceneGraph, 'getData').mockReturnValue(dataRef as ReturnType<typeof sceneGraph.getData>);
138+
jest.spyOn(sceneGraph, 'findObject').mockImplementation((_root, predicate) => {
139+
expect(predicate(runner)).toBe(true);
140+
return runner;
141+
});
142+
jest.spyOn(sceneGraph, 'findDescendents').mockReturnValue([]);
143+
144+
const { panel } = getPanelData(vizPanel);
145+
146+
expect(panel.targets).toEqual([
147+
{
148+
refId: 'A',
149+
query: '[interp:{ resource.service.name =~ "$service" }]',
150+
},
151+
]);
152+
expect(panel.datasource).toEqual({
153+
uid: 'tempo-uid-from-var',
154+
type: 'tempo',
155+
});
156+
expect(panel.maxDataPoints).toBe(240);
157+
});
158+
159+
it('leaves an empty query string unmodified', () => {
160+
const vizPanel = baseVizPanel();
161+
const runner = new SceneQueryRunner({
162+
datasource: { uid: 'fixed-uid', type: 'tempo' },
163+
queries: [{ refId: 'A', query: '' }],
164+
});
165+
166+
jest.spyOn(sceneGraph, 'getData').mockReturnValue({} as ReturnType<typeof sceneGraph.getData>);
167+
jest.spyOn(sceneGraph, 'findObject').mockReturnValue(runner);
168+
169+
const { panel } = getPanelData(vizPanel);
170+
171+
expect(panel.targets?.[0]?.query).toBe('');
172+
expect(sceneGraph.interpolate).not.toHaveBeenCalledWith(vizPanel, '');
173+
});
174+
175+
it('prefers explicit alertTargets over layout query runner (breakdown tile scope)', () => {
176+
const vizPanel = baseVizPanel();
177+
const exploration = {};
178+
const runner = new SceneQueryRunner({
179+
datasource: { uid: 'tempo-runner', type: 'tempo' },
180+
queries: [{ refId: 'A', query: '{ aggregate }' }],
181+
maxDataPoints: 64,
182+
});
183+
184+
jest.mocked(getTraceExplorationScene).mockReturnValue(exploration as ReturnType<typeof getTraceExplorationScene>);
185+
jest.mocked(getDatasourceVariable).mockReturnValue({
186+
getValue: () => 'resolved-tempo-uid',
187+
} as ReturnType<typeof getDatasourceVariable>);
188+
jest.mocked(getDataSource).mockReturnValue('resolved-tempo-uid');
189+
jest.spyOn(sceneGraph, 'getData').mockReturnValue({} as ReturnType<typeof sceneGraph.getData>);
190+
jest.spyOn(sceneGraph, 'findObject').mockReturnValue(runner);
191+
jest.spyOn(sceneGraph, 'interpolate').mockImplementation((_scene, value) => {
192+
if (typeof value === 'string') {
193+
return value.replace('${service}', 'checkout');
194+
}
195+
return String(value ?? '');
196+
});
197+
198+
const { panel } = getPanelData(vizPanel, [{ refId: 'A', query: '{ resource.service.name="${service}" }' }]);
199+
200+
expect(getTraceExplorationScene).toHaveBeenCalledWith(vizPanel);
201+
expect(getDatasourceVariable).toHaveBeenCalledWith(exploration);
202+
expect(panel.targets).toEqual([{ refId: 'A', query: '{ resource.service.name="checkout" }' }]);
203+
expect(panel.datasource).toEqual({ uid: 'resolved-tempo-uid', type: 'tempo' });
204+
expect(panel.maxDataPoints).toBe(64);
205+
});
206+
207+
it('does not add uid when query runner datasource omits it', () => {
208+
const vizPanel = baseVizPanel();
209+
const runner = new SceneQueryRunner({
210+
datasource: { type: 'tempo' },
211+
queries: [{ refId: 'A', query: 'count()' }],
212+
});
213+
214+
jest.spyOn(sceneGraph, 'getData').mockReturnValue({} as ReturnType<typeof sceneGraph.getData>);
215+
jest.spyOn(sceneGraph, 'findObject').mockReturnValue(runner);
216+
217+
const { panel } = getPanelData(vizPanel);
218+
219+
expect(panel.datasource).toEqual({ type: 'tempo' });
220+
});
221+
222+
it('omits panel datasource when there are no query targets', () => {
223+
const vizPanel = baseVizPanel();
224+
225+
jest.spyOn(sceneGraph, 'getData').mockReturnValue({} as ReturnType<typeof sceneGraph.getData>);
226+
jest.spyOn(sceneGraph, 'findObject').mockReturnValue(null);
227+
jest.spyOn(sceneGraph, 'findDescendents').mockReturnValue([]);
228+
229+
const { panel } = getPanelData(vizPanel);
230+
231+
expect(panel.datasource).toBeUndefined();
232+
});
233+
});
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { BusEventWithPayload, type TimeRange } from '@grafana/data';
2+
import { sceneGraph, SceneQueryRunner, type SceneObject, type VizPanel } from '@grafana/scenes';
3+
import { type Panel } from '@grafana/schema';
4+
5+
import {
6+
getPanelDataForAlert,
7+
type AlertPanelTarget,
8+
} from './createAlert/getPanelDataForAlert';
9+
import { getDataSource, getDatasourceVariable, getTraceExplorationScene } from 'utils/utils';
10+
11+
export const ADD_TO_DASHBOARD_COMPONENT_ID = 'grafana/add-to-dashboard-form/v1';
12+
13+
export interface PanelDataRequestPayload {
14+
panel: Panel;
15+
range: TimeRange;
16+
}
17+
18+
interface EventOpenAddToDashboardPayload {
19+
panelData: PanelDataRequestPayload;
20+
}
21+
22+
export class EventOpenAddToDashboard extends BusEventWithPayload<EventOpenAddToDashboardPayload> {
23+
public static readonly type = 'open-add-to-dashboard';
24+
}
25+
26+
export interface AddToDashboardFormProps {
27+
onClose: () => void;
28+
buildPanel: () => Panel;
29+
timeRange?: TimeRange;
30+
options?: { useAbsolutePath: boolean };
31+
}
32+
33+
/** Tempo metrics panels use TraceQL on `query`; interpolate so dashboard panels get resolved filters/vars. */
34+
function interpolateTraceQueryTarget(vizPanel: VizPanel, target: Record<string, unknown>): Record<string, unknown> {
35+
const next: Record<string, unknown> = { ...target };
36+
if (next.query != null) {
37+
const q = next.query as string;
38+
next.query = q ? sceneGraph.interpolate(vizPanel, q) : q;
39+
}
40+
// Dashboard panels use panel-level datasource; scene `${ds}` on targets breaks new dashboards.
41+
delete next.datasource;
42+
return next;
43+
}
44+
45+
/** Resolved Tempo UID for dashboard panels (not the `${ds}` scene variable). */
46+
function resolveDashboardDatasource(vizPanel: VizPanel): NonNullable<Panel['datasource']> {
47+
const exploration = getTraceExplorationScene(vizPanel);
48+
const uid = getDatasourceVariable(exploration).getValue()?.toString() || getDataSource(exploration);
49+
return {
50+
uid,
51+
type: 'tempo',
52+
};
53+
}
54+
55+
/** Preserve runner datasource shape when uid is absent; resolve scene `${ds}` when uid is set. */
56+
function mapPanelDatasource(vizPanel: VizPanel, ds?: Panel['datasource']): Panel['datasource'] {
57+
if (!ds || typeof ds !== 'object') {
58+
return resolveDashboardDatasource(vizPanel);
59+
}
60+
if (Object.prototype.hasOwnProperty.call(ds, 'uid') && ds.uid) {
61+
return { ...ds, ...resolveDashboardDatasource(vizPanel) };
62+
}
63+
return { ...ds };
64+
}
65+
66+
function findQueryRunner(data: SceneObject | undefined): SceneQueryRunner | undefined {
67+
if (!data) {
68+
return undefined;
69+
}
70+
const direct = sceneGraph.findObject(data, (o) => o instanceof SceneQueryRunner);
71+
if (direct instanceof SceneQueryRunner) {
72+
return direct;
73+
}
74+
return sceneGraph.findDescendents(data, SceneQueryRunner)[0];
75+
}
76+
77+
export function getPanelData(
78+
vizPanel: VizPanel,
79+
/** Per-tile TraceQL targets (e.g. breakdown with attribute=value). Same as create alert. */
80+
alertTargets?: AlertPanelTarget[]
81+
): PanelDataRequestPayload {
82+
if (alertTargets?.length) {
83+
const fromTargets = getPanelDataForAlert(vizPanel, alertTargets);
84+
if (fromTargets) {
85+
return {
86+
...fromTargets,
87+
panel: {
88+
...fromTargets.panel,
89+
datasource: resolveDashboardDatasource(vizPanel),
90+
},
91+
};
92+
}
93+
}
94+
95+
const range = sceneGraph.getTimeRange(vizPanel).state.value;
96+
const data = sceneGraph.getData(vizPanel);
97+
const found = findQueryRunner(data);
98+
99+
let targets: Panel['targets'] = [];
100+
let maxDataPoints: number | undefined;
101+
102+
if (found) {
103+
targets = (found.state.queries ?? []).map((q: Record<string, unknown>) =>
104+
interpolateTraceQueryTarget(vizPanel, q)
105+
);
106+
maxDataPoints = found.state.maxDataPoints;
107+
}
108+
109+
const vs = vizPanel.state;
110+
const panel: Panel = {
111+
type: vs.pluginId,
112+
title: vs.title ? sceneGraph.interpolate(vizPanel, vs.title) : vs.title,
113+
targets,
114+
...(targets.length > 0 && {
115+
datasource: mapPanelDatasource(vizPanel, found?.state.datasource as Panel['datasource']),
116+
}),
117+
options: vs.options,
118+
fieldConfig: vs.fieldConfig as Panel['fieldConfig'],
119+
...(vs.description && { description: vs.description }),
120+
...(maxDataPoints !== undefined && { maxDataPoints }),
121+
};
122+
123+
return { panel, range };
124+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import React, { createElement } from 'react';
2+
3+
import { t } from '@grafana/i18n';
4+
import { usePluginComponent } from '@grafana/runtime';
5+
import { Modal } from '@grafana/ui';
6+
7+
import {
8+
ADD_TO_DASHBOARD_COMPONENT_ID,
9+
type AddToDashboardFormProps,
10+
type PanelDataRequestPayload,
11+
} from '../addToDashboard';
12+
13+
interface AddToDashboardModalProps {
14+
panelData: PanelDataRequestPayload;
15+
onClose: () => void;
16+
}
17+
18+
/** Loads the Grafana core form only while the modal is open (avoids usePluginComponent on every scene render). */
19+
export function AddToDashboardModal({ panelData, onClose }: AddToDashboardModalProps) {
20+
const { component: AddToDashboardComponent, isLoading } =
21+
usePluginComponent<AddToDashboardFormProps>(ADD_TO_DASHBOARD_COMPONENT_ID);
22+
23+
if (isLoading || !AddToDashboardComponent) {
24+
return null;
25+
}
26+
27+
return (
28+
<Modal title={t('panel-menu.add-to-dashboard', 'Add to dashboard')} isOpen onDismiss={onClose}>
29+
{createElement(AddToDashboardComponent as React.ComponentType<AddToDashboardFormProps>, {
30+
onClose,
31+
buildPanel: () => panelData.panel,
32+
timeRange: panelData.range,
33+
options: { useAbsolutePath: true },
34+
})}
35+
</Modal>
36+
);
37+
}

0 commit comments

Comments
 (0)