add navigation and structure api

This commit is contained in:
oscarkilhed
2025-12-20 16:37:49 +01:00
parent 090078eb80
commit 7fec275695
7 changed files with 657 additions and 45 deletions
@@ -12,6 +12,74 @@ export interface DashboardSceneJsonApiV2 {
*/
getCurrentDashboardErrors(space?: number): string;
/**
* Read current dashboard variables (JSON string).
*
* This returns JSON shaped like:
* `{ variables: [{ name, value }] }`
* where `value` is `string | string[]`.
*/
getCurrentDashboardVariables(space?: number): string;
/**
* Apply dashboard variable values (JSON string).
*
* Accepts either:
* - `{ variables: [{ name, value }] }`
* - or a map `{ [name]: value }`
*
* where `value` is `string | string[]`.
*/
applyCurrentDashboardVariables(varsJson: string): void;
/**
* Read the current dashboard time range (JSON string).
*
* This returns JSON shaped like:
* `{ from, to, timezone? }`.
*/
getCurrentDashboardTimeRange(space?: number): string;
/**
* Apply the current dashboard time range (JSON string).
*
* Accepts JSON shaped like:
* `{ from, to, timezone? }` where `from/to` are Grafana raw strings (e.g. `now-6h`, `now`).
*/
applyCurrentDashboardTimeRange(timeRangeJson: string): void;
/**
* Select a tab within the current dashboard (JSON string).
*
* Accepts JSON shaped like:
* `{ title?: string, slug?: string }`.
*/
selectCurrentDashboardTab(tabJson: string): void;
/**
* Read current in-dashboard navigation state (JSON string).
*
* This returns JSON shaped like:
* `{ tab: { slug: string, title?: string } | null }`.
*/
getCurrentDashboardNavigation(space?: number): string;
/**
* Scroll/focus a row within the current dashboard (JSON string).
*
* Accepts JSON shaped like:
* `{ title?: string, rowKey?: string }`.
*/
focusCurrentDashboardRow(rowJson: string): void;
/**
* Scroll/focus a panel within the current dashboard (JSON string).
*
* Accepts JSON shaped like:
* `{ panelId: number }`.
*/
focusCurrentDashboardPanel(panelJson: string): void;
/**
* Apply a v2beta1 Dashboard kind JSON (JSON string).
*
@@ -45,30 +113,98 @@ export function getDashboardSceneJsonApiV2(): DashboardSceneJsonApiV2 {
}
/**
* Plugin-friendly helper to read the current dashboard as kind JSON (JSON string).
* A grouped, ergonomic API wrapper around the DashboardScene JSON API.
*
* This is purely a convenience layer: it calls the same underlying registered implementation
* as the top-level helper functions, but organizes functionality into namespaces like
* `navigation`, `variables`, and `timeRange`.
*
* @public
*/
export function getCurrentDashboard(space = 2): string {
return getDashboardSceneJsonApiV2().getCurrentDashboard(space);
}
export function getDashboardApi() {
const api = getDashboardSceneJsonApiV2();
return {
/**
* Prints/returns a quick reference for the grouped dashboard API, including expected JSON shapes.
*/
help: () => {
const text = [
'Dashboard API (DashboardScene JSON API, schema v2 kinds)',
'',
'All inputs/outputs are JSON strings.',
'Edits are spec-only: apiVersion/kind/metadata/status must not change.',
'',
'Read/apply dashboard:',
'- dashboard.getCurrent(space?): string',
'- dashboard.apply(resourceJson: string): void',
' - resourceJson must be a v2beta1 Dashboard kind object:',
' { apiVersion: "dashboard.grafana.app/v2beta1", kind: "Dashboard", metadata: {...}, spec: {...}, status: {...} }',
'',
'Errors:',
'- errors.getCurrent(space?): string',
' - returns JSON: { errors: [{ panelId, panelTitle, refId?, datasource?, message, severity }] }',
'',
'Variables:',
'- variables.getCurrent(space?): string',
' - returns JSON: { variables: [{ name, value }] } where value is string | string[]',
'- variables.apply(varsJson: string): void',
' - accepts JSON: { variables: [{ name, value }] } OR { [name]: value }',
'',
'Time range:',
'- timeRange.getCurrent(space?): string',
' - returns JSON: { from: string, to: string, timezone?: string }',
'- timeRange.apply(timeRangeJson: string): void',
' - accepts JSON: { from: "now-6h", to: "now", timezone?: "browser" | "utc" | ... }',
'',
'Navigation:',
'- navigation.getCurrent(space?): string',
' - returns JSON: { tab: { slug: string, title?: string } | null }',
'- navigation.selectTab(tabJson: string): void',
' - accepts JSON: { title?: string, slug?: string }',
'- navigation.focusRow(rowJson: string): void',
' - accepts JSON: { title?: string, rowKey?: string }',
'- navigation.focusPanel(panelJson: string): void',
' - accepts JSON: { panelId: number }',
'',
'Examples:',
'- window.dashboardApi.timeRange.apply(JSON.stringify({ from: "now-6h", to: "now", timezone: "browser" }))',
'- window.dashboardApi.navigation.selectTab(JSON.stringify({ title: "Overview" }))',
'- window.dashboardApi.navigation.focusPanel(JSON.stringify({ panelId: 12 }))',
].join('\n');
/**
* Plugin-friendly helper to read aggregated query errors for the current dashboard (JSON string).
*
* @public
*/
export function getCurrentDashboardErrors(space = 2): string {
return getDashboardSceneJsonApiV2().getCurrentDashboardErrors(space);
}
// Calling help is an explicit action; logging is useful in the browser console.
// Return the text as well so callers can print/store it as they prefer.
try {
// eslint-disable-next-line no-console
console.log(text);
} catch {
// ignore
}
/**
* JSON-string helper to apply a v2 Dashboard kind JSON (spec-only enforcement happens in the implementation).
*
* @public
*/
export function applyCurrentDashboard(resourceJson: string): void {
return getDashboardSceneJsonApiV2().applyCurrentDashboard(resourceJson);
return text;
},
dashboard: {
getCurrent: (space = 2) => api.getCurrentDashboard(space),
apply: (resourceJson: string) => api.applyCurrentDashboard(resourceJson),
},
errors: {
getCurrent: (space = 2) => JSON.stringify({ errors: JSON.parse(api.getCurrentDashboardErrors(space)) }, null, space),
},
variables: {
getCurrent: (space = 2) => api.getCurrentDashboardVariables(space),
apply: (varsJson: string) => api.applyCurrentDashboardVariables(varsJson),
},
timeRange: {
getCurrent: (space = 2) => api.getCurrentDashboardTimeRange(space),
apply: (timeRangeJson: string) => api.applyCurrentDashboardTimeRange(timeRangeJson),
},
navigation: {
getCurrent: (space = 2) => api.getCurrentDashboardNavigation(space),
selectTab: (tabJson: string) => api.selectCurrentDashboardTab(tabJson),
focusRow: (rowJson: string) => api.focusCurrentDashboardRow(rowJson),
focusPanel: (panelJson: string) => api.focusCurrentDashboardPanel(panelJson),
},
};
}
@@ -2,9 +2,9 @@ import { DataQueryError, DataQueryErrorType } from '@grafana/data';
import { config } from '@grafana/runtime';
import type { SceneDataQuery, SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils';
import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager';
export type DashboardPanelErrorSeverity = 'error' | 'warning';
@@ -58,11 +58,13 @@ describe('current dashboard spec apply API', () => {
isEditing: true,
},
getSaveModel: () => currentSpec,
getInitialSaveModel: () => currentSpec,
};
const nextScene = {
onEnterEditMode: jest.fn(),
setState: jest.fn(),
setInitialSaveModel: jest.fn(),
};
transformSaveModelSchemaV2ToScene.mockReturnValue(nextScene);
@@ -78,12 +80,65 @@ describe('current dashboard spec apply API', () => {
applyCurrentDashboardSpecV2(nextSpec);
expect(transformSaveModelSchemaV2ToScene).toHaveBeenCalledTimes(1);
expect(nextScene.setInitialSaveModel).toHaveBeenCalledWith(currentSpec, currentScene.state.meta.k8s, 'dashboard.grafana.app/v2beta1');
expect(nextScene.onEnterEditMode).toHaveBeenCalled();
expect(nextScene.setState).toHaveBeenCalledWith({ isDirty: true });
expect(mgr.setSceneCache).toHaveBeenCalledWith('dash-uid', nextScene);
expect(mgr.setState).toHaveBeenCalledWith({ dashboard: nextScene });
});
it('applyCurrentDashboardSpecV2 does not mark dirty when the applied spec matches the saved baseline', () => {
const currentSpec = defaultDashboardV2Spec();
const nextSpec = { ...currentSpec };
const currentScene = {
state: {
uid: 'dash-uid',
meta: {
url: '/d/dash-uid/slug',
slug: 'slug',
canSave: true,
canEdit: true,
canDelete: true,
canShare: true,
canStar: true,
canAdmin: true,
publicDashboardEnabled: false,
k8s: {
name: 'dash-uid',
resourceVersion: '1',
creationTimestamp: 'now',
annotations: {},
labels: {},
},
},
isEditing: true,
},
getSaveModel: () => currentSpec,
getInitialSaveModel: () => currentSpec,
};
const nextScene = {
onEnterEditMode: jest.fn(),
setState: jest.fn(),
setInitialSaveModel: jest.fn(),
};
transformSaveModelSchemaV2ToScene.mockReturnValue(nextScene);
const mgr = {
state: { dashboard: currentScene },
setSceneCache: jest.fn(),
setState: jest.fn(),
};
getDashboardScenePageStateManager.mockReturnValue(mgr);
applyCurrentDashboardSpecV2(nextSpec);
expect(nextScene.setState).toHaveBeenCalledWith({ isDirty: false });
});
it('applyCurrentDashboardKindV2 rejects metadata changes and applies only spec when unchanged', () => {
const spec = defaultDashboardV2Spec();
@@ -2,7 +2,6 @@ import { isEqual } from 'lodash';
import { config } from '@grafana/runtime';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { Status } from '@grafana/schema/src/schema/dashboard/v2';
import { Resource } from 'app/features/apiserver/types';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { isDashboardV2Spec } from 'app/features/dashboard/api/utils';
@@ -40,7 +39,7 @@ export function applyCurrentDashboardSpecV2(nextSpec: DashboardV2Spec): void {
assertDashboardV2Enabled();
// Validate (throws on error)
validateDashboardSchemaV2(nextSpec as unknown);
validateDashboardSchemaV2(nextSpec);
const { mgr, dashboard: currentScene } = getCurrentSceneOrThrow();
@@ -50,7 +49,8 @@ export function applyCurrentDashboardSpecV2(nextSpec: DashboardV2Spec): void {
throw new Error('Current dashboard is not using schema v2');
}
const k8sMeta = currentScene.state.meta.k8s;
const currentResource = getCurrentDashboardKindV2();
const k8sMeta = currentResource.metadata;
if (!k8sMeta) {
throw new Error('Current dashboard is missing Kubernetes metadata');
}
@@ -62,7 +62,7 @@ export function applyCurrentDashboardSpecV2(nextSpec: DashboardV2Spec): void {
kind: 'DashboardWithAccessInfo',
metadata: k8sMeta,
spec: nextSpec,
status: {} as Status,
status: {},
access: {
url: currentScene.state.meta.url,
slug: currentScene.state.meta.slug,
@@ -79,15 +79,24 @@ export function applyCurrentDashboardSpecV2(nextSpec: DashboardV2Spec): void {
const nextScene = transformSaveModelSchemaV2ToScene(dto);
// Keep edit mode semantics consistent with other JSON-apply flows:
// - Enter edit mode if needed
// - Mark dirty because the spec changed
// IMPORTANT: Preserve the *saved baseline* (initialSaveModel) from the currently loaded dashboard,
// otherwise the change tracker will treat the applied spec as the new baseline and the dashboard
// won't become saveable (Save button stays non-primary).
const initialSaveModel = currentScene.getInitialSaveModel();
const hasBaseline = Boolean(initialSaveModel && isDashboardV2Spec(initialSaveModel));
if (initialSaveModel && isDashboardV2Spec(initialSaveModel)) {
nextScene.setInitialSaveModel(initialSaveModel, k8sMeta, 'dashboard.grafana.app/v2beta1');
}
// Preserve edit/view mode. Don't force-enter edit mode; that's a UI side effect.
if (currentScene.state.isEditing) {
nextScene.onEnterEditMode();
} else {
nextScene.onEnterEditMode();
}
nextScene.setState({ isDirty: true });
// Set dirty based on the saved baseline (if present). This prevents the save button from being
// stuck "blue" when the applied spec matches the baseline.
const shouldBeDirty = hasBaseline ? !isEqual(nextSpec, initialSaveModel) : true;
nextScene.setState({ isDirty: shouldBeDirty });
// Keep cache coherent for the currently open dashboard
if (currentScene.state.uid) {
@@ -102,7 +111,7 @@ export function applyCurrentDashboardSpecV2(nextSpec: DashboardV2Spec): void {
*
* It rejects any attempt to change `apiVersion`, `kind`, `metadata`, or `status` from the currently open dashboard.
*/
export function applyCurrentDashboardKindV2(resource: Resource<DashboardV2Spec, Status, 'Dashboard'>): void {
export function applyCurrentDashboardKindV2(resource: Resource<DashboardV2Spec, unknown, 'Dashboard'>): void {
assertDashboardV2Enabled();
const current = getCurrentDashboardKindV2();
@@ -0,0 +1,134 @@
import { config, locationService } from '@grafana/runtime';
import { CustomVariable, SceneTimeRange, TextBoxVariable, sceneGraph } from '@grafana/scenes';
jest.mock('../pages/DashboardScenePageStateManager', () => ({
getDashboardScenePageStateManager: jest.fn(),
}));
jest.mock('../utils/dashboardSceneGraph', () => ({
dashboardSceneGraph: {
getVizPanels: jest.fn(() => []),
},
}));
jest.mock('../utils/utils', () => ({
getPanelIdForVizPanel: jest.fn(),
getQueryRunnerFor: jest.fn(),
}));
import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager';
import { RowItem } from '../scene/layout-rows/RowItem';
import { TabItem } from '../scene/layout-tabs/TabItem';
import { TabsLayoutManager } from '../scene/layout-tabs/TabsLayoutManager';
import { dashboardSceneJsonApiV2 } from './runtimeDashboardSceneJsonApiV2';
describe('dashboardSceneJsonApiV2 (navigation/variables/time)', () => {
beforeEach(() => {
jest.restoreAllMocks();
// Enable v2 API gate for tests.
config.featureToggles.kubernetesDashboards = true;
config.featureToggles.kubernetesDashboardsV2 = true;
jest.spyOn(locationService, 'partial').mockImplementation(() => {});
});
it('getCurrentDashboardVariables returns a stable JSON shape and applyCurrentDashboardVariables updates values', () => {
const customVar = new CustomVariable({ name: 'customVar', query: 'a,b,c', value: 'a', text: 'a' });
const textVar = new TextBoxVariable({ type: 'textbox', name: 'tb', value: 'x' });
const dashboard = {
state: {
$variables: { state: { variables: [customVar, textVar] } },
},
publishEvent: jest.fn(),
};
(getDashboardScenePageStateManager as jest.Mock).mockReturnValue({ state: { dashboard } });
const before = JSON.parse(dashboardSceneJsonApiV2.getCurrentDashboardVariables(0));
expect(before.variables).toEqual(
expect.arrayContaining([
{ name: 'customVar', value: 'a' },
{ name: 'tb', value: 'x' },
])
);
dashboardSceneJsonApiV2.applyCurrentDashboardVariables(JSON.stringify({ customVar: ['b', 'c'], tb: 'y' }));
expect(customVar.getValue()).toEqual(['b', 'c']);
expect(textVar.getValue()).toBe('y');
expect(locationService.partial).toHaveBeenCalledWith({ 'var-customVar': ['b', 'c'] }, true);
expect(locationService.partial).toHaveBeenCalledWith({ 'var-tb': 'y' }, true);
});
it('getCurrentDashboardTimeRange returns raw values and applyCurrentDashboardTimeRange calls SceneTimeRange APIs', () => {
const tr = new SceneTimeRange({ from: 'now-1h', to: 'now', timeZone: 'browser' });
const tzSpy = jest.spyOn(tr, 'onTimeZoneChange');
const trSpy = jest.spyOn(tr, 'onTimeRangeChange');
const refreshSpy = jest.spyOn(tr, 'onRefresh');
jest.spyOn(sceneGraph, 'getTimeRange').mockReturnValue(tr);
const dashboard = { state: {}, publishEvent: jest.fn() };
(getDashboardScenePageStateManager as jest.Mock).mockReturnValue({ state: { dashboard } });
const current = JSON.parse(dashboardSceneJsonApiV2.getCurrentDashboardTimeRange(0));
expect(current).toEqual({ from: 'now-1h', to: 'now', timezone: 'browser' });
dashboardSceneJsonApiV2.applyCurrentDashboardTimeRange(JSON.stringify({ from: 'now-6h', to: 'now', timezone: 'utc' }));
expect(tzSpy).toHaveBeenCalledWith('utc');
expect(trSpy).toHaveBeenCalled();
expect(refreshSpy).toHaveBeenCalled();
});
it('selectCurrentDashboardTab selects a matching tab by title', () => {
const tabA = new TabItem({ key: 'tab-a', title: 'A' });
const tabB = new TabItem({ key: 'tab-b', title: 'B' });
const tabs = new TabsLayoutManager({ tabs: [tabA, tabB] });
const dashboard = {
state: { body: tabs },
publishEvent: jest.fn(),
};
(getDashboardScenePageStateManager as jest.Mock).mockReturnValue({ state: { dashboard } });
const spy = jest.spyOn(tabs, 'switchToTab');
dashboardSceneJsonApiV2.selectCurrentDashboardTab(JSON.stringify({ title: 'B' }));
expect(spy).toHaveBeenCalledWith(tabB);
});
it('getCurrentDashboardNavigation returns the active tab', () => {
const tabA = new TabItem({ key: 'tab-a', title: 'Overview' });
const tabB = new TabItem({ key: 'tab-b', title: 'Explore' });
const tabs = new TabsLayoutManager({ tabs: [tabA, tabB], currentTabSlug: tabB.getSlug() });
const dashboard = {
state: { body: tabs },
publishEvent: jest.fn(),
};
(getDashboardScenePageStateManager as jest.Mock).mockReturnValue({ state: { dashboard } });
const nav = JSON.parse(dashboardSceneJsonApiV2.getCurrentDashboardNavigation(0));
expect(nav).toEqual({ tab: { slug: tabB.getSlug(), title: 'Explore' } });
});
it('focusCurrentDashboardRow expands a collapsed row and calls scrollIntoView', () => {
const row = new RowItem({ title: 'request duration', collapse: true });
const scrollSpy = jest.spyOn(row, 'scrollIntoView').mockImplementation(() => {});
jest.spyOn(sceneGraph, 'findAllObjects').mockReturnValue([row]);
const dashboard = { state: {}, publishEvent: jest.fn() };
(getDashboardScenePageStateManager as jest.Mock).mockReturnValue({ state: { dashboard } });
dashboardSceneJsonApiV2.focusCurrentDashboardRow(JSON.stringify({ title: 'request duration' }));
expect(row.getCollapsedState()).toBe(false);
expect(scrollSpy).toHaveBeenCalled();
});
});
@@ -1,6 +1,22 @@
import { isEqual } from 'lodash';
import type { DashboardSceneJsonApiV2 } from '@grafana/runtime';
import { RefreshEvent, config, locationService, type DashboardSceneJsonApiV2 } from '@grafana/runtime';
import {
MultiValueVariable,
TextBoxVariable,
sceneGraph,
type SceneObject,
type VariableValue,
} from '@grafana/scenes';
import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager';
import { AutoGridItem } from '../scene/layout-auto-grid/AutoGridItem';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { RowItem } from '../scene/layout-rows/RowItem';
import { TabItem } from '../scene/layout-tabs/TabItem';
import { TabsLayoutManager } from '../scene/layout-tabs/TabsLayoutManager';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils';
import { getCurrentDashboardErrors } from './currentDashboardErrors';
import { getCurrentDashboardKindV2 as getCurrentDashboardResourceV2 } from './currentDashboardKindV2';
@@ -20,6 +36,39 @@ type DashboardResourceV2 = ReturnType<typeof getCurrentDashboardResourceV2>;
*/
let lastKnownGoodResource: DashboardResourceV2 | undefined;
function assertDashboardV2Enabled() {
const isKubernetesDashboardsEnabled = Boolean(config.featureToggles.kubernetesDashboards);
const isV2Enabled = Boolean(config.featureToggles.kubernetesDashboardsV2 || config.featureToggles.dashboardNewLayouts);
if (!isKubernetesDashboardsEnabled || !isV2Enabled) {
throw new Error('V2 dashboard kinds API requires kubernetes dashboards v2 to be enabled');
}
}
function getCurrentDashboardSceneOrThrow() {
const mgr = getDashboardScenePageStateManager();
const dashboard = mgr.state.dashboard;
if (!dashboard) {
throw new Error('No dashboard is currently open');
}
return dashboard;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function findParent<T extends SceneObject>(start: SceneObject, isMatch: (obj: SceneObject) => obj is T): T | null {
let cur: SceneObject | undefined = start.parent ?? undefined;
while (cur) {
if (isMatch(cur)) {
return cur;
}
cur = cur.parent;
}
return null;
}
function getDashboardUidFromUrl(): string | undefined {
const pathname = globalThis.location?.pathname ?? '';
// Expected: /d/<uid>/<slug>
@@ -58,6 +107,223 @@ export const dashboardSceneJsonApiV2: DashboardSceneJsonApiV2 = {
return JSON.stringify(getCurrentDashboardErrors(), null, space);
},
getCurrentDashboardVariables: (space = 2) => {
assertDashboardV2Enabled();
const dashboard = getCurrentDashboardSceneOrThrow();
const vars = dashboard.state.$variables?.state.variables ?? [];
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
const variables = vars.map((v) => ({ name: v.state.name, value: v.getValue() })).sort((a, b) => collator.compare(a.name, b.name));
return JSON.stringify({ variables }, null, space);
},
applyCurrentDashboardVariables: (varsJson: string) => {
assertDashboardV2Enabled();
const dashboard = getCurrentDashboardSceneOrThrow();
const vars = dashboard.state.$variables?.state.variables ?? [];
const parsed: unknown = JSON.parse(varsJson);
const parsedObj: Record<string, unknown> = isRecord(parsed) ? parsed : {};
// Accept either { variables: [{ name, value }] } or { [name]: value }
const variablesProp = parsedObj['variables'];
const entries: Array<{ name: unknown; value: unknown }> = Array.isArray(variablesProp)
? variablesProp
: Object.entries(parsedObj).map(([name, value]) => ({ name, value }));
for (const entry of entries) {
const name = entry.name;
const value = entry.value;
if (typeof name !== 'string' || name.length === 0) {
continue;
}
if (!(typeof value === 'string' || Array.isArray(value))) {
continue;
}
const variable = vars.find((v) => v.state.name === name);
if (!variable) {
continue;
}
let varValue: VariableValue | undefined;
if (typeof value === 'string') {
varValue = value;
} else if (Array.isArray(value) && value.every((v) => typeof v === 'string')) {
varValue = value;
}
if (!varValue) {
continue;
}
if (variable instanceof MultiValueVariable) {
variable.changeValueTo(varValue, varValue, true);
} else if (variable instanceof TextBoxVariable && typeof varValue === 'string') {
variable.setValue(varValue);
} else {
// Unsupported variable type for programmatic update (for now).
continue;
}
locationService.partial({ [`var-${name}`]: varValue }, true);
}
// Force rerun queries and refresh panels.
dashboard.publishEvent(new RefreshEvent(), true);
},
getCurrentDashboardTimeRange: (space = 2) => {
assertDashboardV2Enabled();
const dashboard = getCurrentDashboardSceneOrThrow();
const tr = sceneGraph.getTimeRange(dashboard);
const timezone = tr.state.timeZone ?? tr.getTimeZone();
return JSON.stringify({ from: tr.state.from, to: tr.state.to, timezone }, null, space);
},
applyCurrentDashboardTimeRange: (timeRangeJson: string) => {
assertDashboardV2Enabled();
const dashboard = getCurrentDashboardSceneOrThrow();
const tr = sceneGraph.getTimeRange(dashboard);
const parsed: unknown = JSON.parse(timeRangeJson);
const parsedObj: Record<string, unknown> = isRecord(parsed) ? parsed : {};
const from = parsedObj['from'];
const to = parsedObj['to'];
const timezone = parsedObj['timezone'];
if (typeof timezone === 'string' && timezone.length) {
tr.onTimeZoneChange(timezone);
}
if (typeof from !== 'string' || typeof to !== 'string') {
throw new Error('Invalid time range JSON: expected { from: string, to: string, timezone?: string }');
}
tr.onTimeRangeChange({ ...tr.state.value, raw: { from, to } });
tr.onRefresh();
},
selectCurrentDashboardTab: (tabJson: string) => {
assertDashboardV2Enabled();
const dashboard = getCurrentDashboardSceneOrThrow();
const tabReq: unknown = JSON.parse(tabJson);
const tabObj: Record<string, unknown> = isRecord(tabReq) ? tabReq : {};
const title = typeof tabObj['title'] === 'string' ? tabObj['title'] : undefined;
const slug = typeof tabObj['slug'] === 'string' ? tabObj['slug'] : undefined;
const found = dashboard.state.body instanceof TabsLayoutManager ? dashboard.state.body : sceneGraph.findObject(dashboard, (o) => o instanceof TabsLayoutManager);
const tabsManager = found instanceof TabsLayoutManager ? found : null;
if (!tabsManager) {
throw new Error('No tab layout is active for the current dashboard');
}
const tabs = tabsManager.getTabsIncludingRepeats();
const tab = tabs.find((t) => (slug ? t.getSlug() === slug : false) || (title ? t.state.title === title : false));
if (!tab?.state.key) {
throw new Error('Tab not found');
}
// NOTE: `forceSelectTab` also selects the tab in the edit pane (opens the edit UI).
// The API must not trigger edit selection when navigating.
tabsManager.switchToTab(tab);
locationService.partial({ [tabsManager.getUrlKey()]: tab.getSlug() }, true);
},
getCurrentDashboardNavigation: (space = 2) => {
assertDashboardV2Enabled();
const dashboard = getCurrentDashboardSceneOrThrow();
const found = dashboard.state.body instanceof TabsLayoutManager ? dashboard.state.body : sceneGraph.findObject(dashboard, (o) => o instanceof TabsLayoutManager);
const tabsManager = found instanceof TabsLayoutManager ? found : null;
if (!tabsManager) {
return JSON.stringify({ tab: null }, null, space);
}
const currentTab = tabsManager.getCurrentTab();
if (!currentTab) {
return JSON.stringify({ tab: null }, null, space);
}
return JSON.stringify(
{
tab: {
slug: currentTab.getSlug(),
title: currentTab.state.title,
},
},
null,
space
);
},
focusCurrentDashboardRow: (rowJson: string) => {
assertDashboardV2Enabled();
const dashboard = getCurrentDashboardSceneOrThrow();
const req: unknown = JSON.parse(rowJson);
const reqObj: Record<string, unknown> = isRecord(req) ? req : {};
const title = typeof reqObj['title'] === 'string' ? reqObj['title'] : undefined;
const rowKey = typeof reqObj['rowKey'] === 'string' ? reqObj['rowKey'] : undefined;
const rows = sceneGraph.findAllObjects(dashboard, (o) => o instanceof RowItem).filter((o): o is RowItem => o instanceof RowItem);
const row = rows.find((r) => (rowKey ? r.state.key === rowKey : false) || (title ? r.state.title === title : false));
if (!row) {
throw new Error('Row not found');
}
const tab = findParent(row, (o): o is TabItem => o instanceof TabItem);
const tabsManager = tab ? findParent(tab, (o): o is TabsLayoutManager => o instanceof TabsLayoutManager) : null;
if (tab?.state.key && tabsManager) {
tabsManager.switchToTab(tab);
locationService.partial({ [tabsManager.getUrlKey()]: tab.getSlug() }, true);
}
if (row.state.collapse) {
row.setCollapsedState(false);
}
row.scrollIntoView();
},
focusCurrentDashboardPanel: (panelJson: string) => {
assertDashboardV2Enabled();
const dashboard = getCurrentDashboardSceneOrThrow();
const req: unknown = JSON.parse(panelJson);
const reqObj: Record<string, unknown> = isRecord(req) ? req : {};
const panelId = reqObj['panelId'];
if (typeof panelId !== 'number') {
throw new Error('Invalid panel JSON: expected { panelId: number }');
}
const vizPanel = dashboardSceneGraph.getVizPanels(dashboard).find((p) => getPanelIdForVizPanel(p) === panelId);
if (!vizPanel) {
throw new Error('Panel not found');
}
// Ensure containing tab is active, if any.
const tab = findParent(vizPanel, (o): o is TabItem => o instanceof TabItem);
const tabsManager = tab ? findParent(tab, (o): o is TabsLayoutManager => o instanceof TabsLayoutManager) : null;
if (tab?.state.key && tabsManager) {
tabsManager.switchToTab(tab);
locationService.partial({ [tabsManager.getUrlKey()]: tab.getSlug() }, true);
}
// Ensure containing row is expanded, if any.
const row = findParent(vizPanel, (o): o is RowItem => o instanceof RowItem);
if (row?.state.collapse) {
row.setCollapsedState(false);
}
// Scroll nearest known layout item that supports scrollIntoView.
const gridItem = findParent(vizPanel, (o): o is DashboardGridItem => o instanceof DashboardGridItem);
const autoGridItem = findParent(vizPanel, (o): o is AutoGridItem => o instanceof AutoGridItem);
(gridItem ?? autoGridItem)?.scrollIntoView();
// Best-effort rerun queries for this panel if it has a runner.
const runner = getQueryRunnerFor(vizPanel);
runner?.runQueries?.();
},
applyCurrentDashboard: (resourceJson: string) => {
const resource = JSON.parse(resourceJson);
let current: DashboardResourceV2 | undefined;
+25 -13
View File
@@ -1,9 +1,5 @@
import { PanelData, RawTimeRange } from '@grafana/data';
import {
applyCurrentDashboard,
getCurrentDashboard,
getCurrentDashboardErrors,
} from '@grafana/runtime';
import { getDashboardApi } from '@grafana/runtime';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
@@ -21,9 +17,28 @@ declare global {
* Intended for browser console usage.
*/
dashboardApi?: {
getCurrentDashboard: (space?: number) => string;
getCurrentDashboardErrors: (space?: number) => string;
applyCurrentDashboard: (resourceJson: string) => void;
help: () => string;
navigation: {
getCurrent: (space?: number) => string;
selectTab: (tabJson: string) => void;
focusRow: (rowJson: string) => void;
focusPanel: (panelJson: string) => void;
};
variables: {
getCurrent: (space?: number) => string;
apply: (varsJson: string) => void;
};
timeRange: {
getCurrent: (space?: number) => string;
apply: (timeRangeJson: string) => void;
};
errors: {
getCurrent: (space?: number) => string;
};
dashboard: {
getCurrent: (space?: number) => string;
apply: (resourceJson: string) => void;
};
};
}
}
@@ -70,9 +85,6 @@ export function initWindowRuntime() {
};
// Expose the same API that plugins use via @grafana/runtime, but on `window` for easy console access.
window.dashboardApi = {
getCurrentDashboard,
getCurrentDashboardErrors,
applyCurrentDashboard,
};
// Only the grouped API surface is exposed.
window.dashboardApi = getDashboardApi();
}