diff --git a/packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts b/packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts index 805e3987d9d..d1049e27d4e 100644 --- a/packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts +++ b/packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts @@ -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), + }, + }; } diff --git a/public/app/features/dashboard-scene/api/currentDashboardErrors.ts b/public/app/features/dashboard-scene/api/currentDashboardErrors.ts index 6eb4923a932..0edce742fb3 100644 --- a/public/app/features/dashboard-scene/api/currentDashboardErrors.ts +++ b/public/app/features/dashboard-scene/api/currentDashboardErrors.ts @@ -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'; diff --git a/public/app/features/dashboard-scene/api/currentDashboardSpecApplyV2.test.ts b/public/app/features/dashboard-scene/api/currentDashboardSpecApplyV2.test.ts index 5057cf2a8fd..6bce509c109 100644 --- a/public/app/features/dashboard-scene/api/currentDashboardSpecApplyV2.test.ts +++ b/public/app/features/dashboard-scene/api/currentDashboardSpecApplyV2.test.ts @@ -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(); diff --git a/public/app/features/dashboard-scene/api/currentDashboardSpecApplyV2.ts b/public/app/features/dashboard-scene/api/currentDashboardSpecApplyV2.ts index 35cef236f00..a859ef84cd8 100644 --- a/public/app/features/dashboard-scene/api/currentDashboardSpecApplyV2.ts +++ b/public/app/features/dashboard-scene/api/currentDashboardSpecApplyV2.ts @@ -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): void { +export function applyCurrentDashboardKindV2(resource: Resource): void { assertDashboardV2Enabled(); const current = getCurrentDashboardKindV2(); diff --git a/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.navigation.test.ts b/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.navigation.test.ts new file mode 100644 index 00000000000..6796187ecdc --- /dev/null +++ b/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.navigation.test.ts @@ -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(); + }); +}); + + diff --git a/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.ts b/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.ts index 3454505b05f..670553ad138 100644 --- a/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.ts +++ b/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.ts @@ -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; */ 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 { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function findParent(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// @@ -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 = 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 = 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 = 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 = 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 = 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; diff --git a/public/app/features/runtime/init.ts b/public/app/features/runtime/init.ts index e881343151e..b3fad1ac222 100644 --- a/public/app/features/runtime/init.ts +++ b/public/app/features/runtime/init.ts @@ -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(); }