add navigation and structure api
This commit is contained in:
@@ -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();
|
||||
|
||||
+134
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user