diff --git a/packages/grafana-runtime/src/index.ts b/packages/grafana-runtime/src/index.ts index 58b30be8542..be0c279afad 100644 --- a/packages/grafana-runtime/src/index.ts +++ b/packages/grafana-runtime/src/index.ts @@ -77,3 +77,12 @@ export { getCorrelationsService, setCorrelationsService, } from './services/CorrelationsService'; +export { + getDashboardMutationAPI, + setDashboardMutationAPI, + type DashboardMutationAPI, + type MutationResult, + type MutationChange, + type MutationRequest, + type MCPToolDefinition, +} from './services/dashboardMutationAPI'; diff --git a/packages/grafana-runtime/src/services/dashboardMutationAPI.ts b/packages/grafana-runtime/src/services/dashboardMutationAPI.ts new file mode 100644 index 00000000000..03219233915 --- /dev/null +++ b/packages/grafana-runtime/src/services/dashboardMutationAPI.ts @@ -0,0 +1,160 @@ +/** + * Dashboard Mutation API Service + * + * Provides a stable interface for programmatic dashboard modifications. + * + * The API is registered by DashboardScene when a dashboard is loaded and + * cleared when the dashboard is deactivated. + */ + +/** + * MCP Tool Definition - describes a tool that can be invoked + * @see https://spec.modelcontextprotocol.io/specification/server/tools/ + */ +export interface MCPToolDefinition { + name: string; + description: string; + inputSchema: { + type: 'object'; + properties: Record; + required?: string[]; + }; + annotations?: { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + confirmationHint?: boolean; + }; +} + +export interface MutationResult { + success: boolean; + /** ID of the affected panel (for panel operations) */ + panelId?: string; + /** Error message if success is false */ + error?: string; + /** List of changes made by the mutation */ + changes?: MutationChange[]; + /** Warnings (non-fatal issues) */ + warnings?: string[]; + /** Data returned by read-only operations (e.g., GET_DASHBOARD_INFO) */ + data?: unknown; +} + +export interface MutationChange { + /** JSON path to the changed value */ + path: string; + /** Value before the change */ + previousValue: unknown; + /** Value after the change */ + newValue: unknown; +} + +export interface MutationRequest { + /** Type of mutation (e.g., 'ADD_PANEL', 'REMOVE_PANEL', 'UPDATE_PANEL') */ + type: string; + /** Payload specific to the mutation type */ + payload: unknown; +} + +/** + * Dashboard info returned by getDashboardMutationAPI().getDashboardInfo() + */ +export interface DashboardMutationInfo { + available: boolean; + uid?: string; + title?: string; + canEdit: boolean; + isEditing: boolean; + availableTools: string[]; +} + +export interface DashboardMutationAPI { + /** + * Execute a mutation on the dashboard + */ + execute(mutation: MutationRequest): Promise; + + /** + * Check if the current user can edit the dashboard + */ + canEdit(): boolean; + + /** + * Get the UID of the currently loaded dashboard + */ + getDashboardUID(): string | undefined; + + /** + * Get the title of the currently loaded dashboard + */ + getDashboardTitle(): string | undefined; + + /** + * Check if the dashboard is in edit mode + */ + isEditing(): boolean; + + /** + * Enter edit mode if not already editing + */ + enterEditMode(): void; + + /** + * Get the available MCP tool definitions for this dashboard + */ + getTools(): MCPToolDefinition[]; + + /** + * Get comprehensive dashboard info in a single call + */ + getDashboardInfo(): DashboardMutationInfo; +} + +// Singleton instance +let _dashboardMutationAPI: DashboardMutationAPI | null = null; + +// Expose on window for cross-bundle access (plugins use different bundle) +declare global { + interface Window { + __grafanaDashboardMutationAPI?: DashboardMutationAPI | null; + } +} + +/** + * Set the dashboard mutation API instance. + * Called by DashboardScene when a dashboard is activated. + * + * @param api - The mutation API instance, or null to clear + * @internal + */ +export function setDashboardMutationAPI(api: DashboardMutationAPI | null): void { + _dashboardMutationAPI = api; + // Also expose on window for plugins that use a different @grafana/runtime bundle + if (typeof window !== 'undefined') { + window.__grafanaDashboardMutationAPI = api; + } +} + +/** + * Get the dashboard mutation API for the currently loaded dashboard. + * + * @returns The mutation API, or null if no dashboard is loaded + * + * @example + * ```typescript + * import { getDashboardMutationAPI } from '@grafana/runtime'; + * + * const api = getDashboardMutationAPI(); + * if (api && api.canEdit()) { + * await api.execute({ + * type: 'ADD_PANEL', + * payload: { ... } + * }); + * } + * ``` + */ +export function getDashboardMutationAPI(): DashboardMutationAPI | null { + return _dashboardMutationAPI; +} diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts index 42ef1c3f655..2f275cbe201 100644 --- a/packages/grafana-runtime/src/services/index.ts +++ b/packages/grafana-runtime/src/services/index.ts @@ -42,3 +42,13 @@ export { export { setCurrentUser } from './user'; export { RuntimeDataSource } from './RuntimeDataSource'; export { ScopesContext, type ScopesContextValueState, type ScopesContextValue, useScopes } from './ScopesContext'; +export { + getDashboardMutationAPI, + setDashboardMutationAPI, + type DashboardMutationAPI, + type DashboardMutationInfo, + type MutationResult, + type MutationChange, + type MutationRequest, + type MCPToolDefinition, +} from './dashboardMutationAPI'; diff --git a/public/app/features/dashboard-scene/mutation-api/MutationExecutor.ts b/public/app/features/dashboard-scene/mutation-api/MutationExecutor.ts new file mode 100644 index 00000000000..39e822c5b37 --- /dev/null +++ b/public/app/features/dashboard-scene/mutation-api/MutationExecutor.ts @@ -0,0 +1,288 @@ +/** + * Mutation Executor + * + * Executes dashboard mutations with transaction support and event emission. + */ + +import { v4 as uuidv4 } from 'uuid'; + +import type { DashboardScene } from '../scene/DashboardScene'; + +import { + handleAddPanel, + handleRemovePanel, + handleUpdatePanel, + handleMovePanel, + handleAddVariable, + handleRemoveVariable, + handleAddRow, + handleUpdateTimeSettings, + handleUpdateDashboardMeta, + handleGetDashboardInfo, + type MutationContext, + type MutationTransactionInternal, + type MutationHandler, +} from './handlers'; +import { + type Mutation, + type MutationType, + type MutationResult, + type MutationEvent, + type MutationPayloadMap, +} from './types'; + +// ============================================================================ +// Event Bus +// ============================================================================ + +type MutationEventListener = (event: MutationEvent) => void; + +class MutationEventBus { + private listeners: Set = new Set(); + + subscribe(listener: MutationEventListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + emit(event: MutationEvent): void { + this.listeners.forEach((listener) => { + try { + listener(event); + } catch (error) { + console.error('Event listener error:', error); + } + }); + } +} + +// ============================================================================ +// Mutation Executor +// ============================================================================ + +export class MutationExecutor { + private scene!: DashboardScene; + private handlers: Map = new Map(); + private eventBus = new MutationEventBus(); + private _currentTransaction: MutationTransactionInternal | null = null; + + constructor() { + this.registerDefaultHandlers(); + } + + /** + * Set the dashboard scene to operate on + */ + setScene(scene: DashboardScene): void { + this.scene = scene; + } + + /** + * Subscribe to mutation events + */ + onMutation(listener: MutationEventListener): () => void { + return this.eventBus.subscribe(listener); + } + + /** + * Execute a single mutation + */ + async execute(mutation: Mutation): Promise { + const results = await this.executeBatch([mutation]); + return results[0]; + } + + /** + * Execute multiple mutations atomically + */ + async executeBatch(mutations: Mutation[]): Promise { + if (!this.scene) { + throw new Error('No scene set. Call setScene() first.'); + } + + // Create transaction + const transaction: MutationTransactionInternal = { + id: uuidv4(), + mutations, + status: 'pending', + startedAt: Date.now(), + changes: [], + }; + + this._currentTransaction = transaction; + + const results: MutationResult[] = []; + const context: MutationContext = { scene: this.scene, transaction }; + + try { + // Execute each mutation + for (const mutation of mutations) { + const handler = this.handlers.get(mutation.type); + if (!handler) { + throw new Error(`No handler registered for mutation type: ${mutation.type}`); + } + + const result = await handler(mutation.payload, context); + results.push(result); + + if (!result.success) { + throw new Error(result.error || `Mutation ${mutation.type} failed`); + } + + // Emit success event + this.eventBus.emit({ + type: 'mutation_applied', + mutation, + result, + transaction, + timestamp: Date.now(), + source: 'assistant', + }); + } + + // Commit transaction + transaction.status = 'committed'; + transaction.completedAt = Date.now(); + + // Trigger scene refresh + this.scene.forceRender(); + + return results; + } catch (error) { + // Probably need a rollback mechanism here... but skipping this for POC + console.error('Mutation batch failed:', error); + + transaction.status = 'rolled_back'; + transaction.completedAt = Date.now(); + + // Emit failure event + this.eventBus.emit({ + type: 'mutation_rolled_back', + mutation: mutations[0], + result: { success: false, error: String(error), changes: [] }, + transaction, + timestamp: Date.now(), + source: 'assistant', + }); + + // Return error results for remaining mutations + const errorMessage = error instanceof Error ? error.message : String(error); + while (results.length < mutations.length) { + results.push({ + success: false, + error: errorMessage, + changes: [], + }); + } + + return results; + } finally { + this._currentTransaction = null; + } + } + + /** + * Get current transaction (for debugging) + */ + get currentTransaction(): MutationTransactionInternal | null { + return this._currentTransaction; + } + + // ========================================================================== + // Handler Registration + // ========================================================================== + + private registerDefaultHandlers(): void { + // Panel operations + this.registerHandler('ADD_PANEL', handleAddPanel); + this.registerHandler('REMOVE_PANEL', handleRemovePanel); + this.registerHandler('UPDATE_PANEL', handleUpdatePanel); + this.registerHandler('MOVE_PANEL', handleMovePanel); + this.registerHandler('DUPLICATE_PANEL', this.notImplemented('DUPLICATE_PANEL')); + + // Variable operations + this.registerHandler('ADD_VARIABLE', handleAddVariable); + this.registerHandler('REMOVE_VARIABLE', handleRemoveVariable); + this.registerHandler('UPDATE_VARIABLE', this.notImplemented('UPDATE_VARIABLE')); + + // Row operations + this.registerHandler('ADD_ROW', handleAddRow); + this.registerHandler('REMOVE_ROW', this.notImplemented('REMOVE_ROW')); + this.registerHandler('COLLAPSE_ROW', this.notImplemented('COLLAPSE_ROW')); + + // Tab operations + this.registerHandler('ADD_TAB', this.notImplemented('ADD_TAB')); + this.registerHandler('REMOVE_TAB', this.notImplemented('REMOVE_TAB')); + + // Library panel operations + this.registerHandler('ADD_LIBRARY_PANEL', this.notImplemented('ADD_LIBRARY_PANEL')); + this.registerHandler('UNLINK_LIBRARY_PANEL', this.notImplemented('UNLINK_LIBRARY_PANEL')); + this.registerHandler('SAVE_AS_LIBRARY_PANEL', this.notImplemented('SAVE_AS_LIBRARY_PANEL')); + + // Repeat configuration + this.registerHandler('CONFIGURE_PANEL_REPEAT', this.notImplemented('CONFIGURE_PANEL_REPEAT')); + this.registerHandler('CONFIGURE_ROW_REPEAT', this.notImplemented('CONFIGURE_ROW_REPEAT')); + + // Conditional rendering + this.registerHandler('SET_CONDITIONAL_RENDERING', this.notImplemented('SET_CONDITIONAL_RENDERING')); + + // Layout + this.registerHandler('CHANGE_LAYOUT_TYPE', this.notImplemented('CHANGE_LAYOUT_TYPE')); + + // Annotation operations + this.registerHandler('ADD_ANNOTATION', this.notImplemented('ADD_ANNOTATION')); + this.registerHandler('UPDATE_ANNOTATION', this.notImplemented('UPDATE_ANNOTATION')); + this.registerHandler('REMOVE_ANNOTATION', this.notImplemented('REMOVE_ANNOTATION')); + + // Link operations + this.registerHandler('ADD_DASHBOARD_LINK', this.notImplemented('ADD_DASHBOARD_LINK')); + this.registerHandler('REMOVE_DASHBOARD_LINK', this.notImplemented('REMOVE_DASHBOARD_LINK')); + this.registerHandler('ADD_PANEL_LINK', this.notImplemented('ADD_PANEL_LINK')); + this.registerHandler('ADD_DATA_LINK', this.notImplemented('ADD_DATA_LINK')); + + // Field configuration + this.registerHandler('ADD_FIELD_OVERRIDE', this.notImplemented('ADD_FIELD_OVERRIDE')); + this.registerHandler('ADD_VALUE_MAPPING', this.notImplemented('ADD_VALUE_MAPPING')); + this.registerHandler('ADD_TRANSFORMATION', this.notImplemented('ADD_TRANSFORMATION')); + + // Dashboard settings + this.registerHandler('UPDATE_TIME_SETTINGS', handleUpdateTimeSettings); + this.registerHandler('UPDATE_DASHBOARD_META', handleUpdateDashboardMeta); + + // Dashboard management (backend operations) + this.registerHandler('MOVE_TO_FOLDER', this.notImplemented('MOVE_TO_FOLDER')); + this.registerHandler('TOGGLE_FAVORITE', this.notImplemented('TOGGLE_FAVORITE')); + + // Version management (backend operations) + this.registerHandler('LIST_VERSIONS', this.notImplemented('LIST_VERSIONS')); + this.registerHandler('COMPARE_VERSIONS', this.notImplemented('COMPARE_VERSIONS')); + this.registerHandler('RESTORE_VERSION', this.notImplemented('RESTORE_VERSION')); + + // Read-only operations + this.registerHandler('GET_DASHBOARD_INFO', handleGetDashboardInfo); + } + + /** + * Create a stub handler for not-yet-implemented mutations + */ + private notImplemented(mutationType: string): MutationHandler { + return async (): Promise => { + return { + success: false, + changes: [], + error: `${mutationType} is not fully implemented in POC`, + }; + }; + } + + /** + * Register a mutation handler + */ + private registerHandler( + type: T, + handler: (payload: MutationPayloadMap[T], context: MutationContext) => Promise + ): void { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + this.handlers.set(type, handler as MutationHandler); + } +} diff --git a/public/app/features/dashboard-scene/mutation-api/handlers/dashboardHandlers.ts b/public/app/features/dashboard-scene/mutation-api/handlers/dashboardHandlers.ts new file mode 100644 index 00000000000..2746f071605 --- /dev/null +++ b/public/app/features/dashboard-scene/mutation-api/handlers/dashboardHandlers.ts @@ -0,0 +1,159 @@ +/** + * Dashboard settings mutation handlers + */ + +import type { TimeSettingsSpec } from '@grafana/schema/src/schema/dashboard/v2beta1/types.spec.gen'; + +import { DASHBOARD_MCP_TOOLS } from '../mcpTools'; +import type { MutationResult, MutationChange, UpdateDashboardMetaPayload, AddRowPayload } from '../types'; + +import type { MutationContext } from './types'; + +/** + * Add a row (stub - not fully implemented) + */ +export async function handleAddRow(_payload: AddRowPayload, _context: MutationContext): Promise { + return { + success: true, + changes: [], + warnings: ['Add row is not fully implemented in POC - requires RowsLayout'], + }; +} + +/** + * Update dashboard time settings + */ +export async function handleUpdateTimeSettings( + payload: Partial, + context: MutationContext +): Promise { + const { scene, transaction } = context; + const { from, to, timezone, autoRefresh } = payload; + + try { + const timeRange = scene.state.$timeRange; + if (!timeRange) { + throw new Error('Dashboard has no time range'); + } + + const previousState = { ...timeRange.state }; + + // Apply updates based on TimeSettingsSpec fields + const updates: Record = {}; + if (from !== undefined) { + updates.from = from; + } + if (to !== undefined) { + updates.to = to; + } + if (timezone !== undefined) { + updates.timeZone = timezone; + } + if (autoRefresh !== undefined) { + // autoRefresh would be applied to the dashboard refresh interval + updates.refreshInterval = autoRefresh; + } + + timeRange.setState(updates); + + const changes: MutationChange[] = [{ path: '/timeSettings', previousValue: previousState, newValue: updates }]; + transaction.changes.push(...changes); + + return { + success: true, + inverseMutation: { + type: 'UPDATE_TIME_SETTINGS', + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + payload: previousState as Partial, + }, + changes, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + changes: [], + }; + } +} + +/** + * Update dashboard metadata (title, description, tags, etc.) + */ +export async function handleUpdateDashboardMeta( + payload: UpdateDashboardMetaPayload, + context: MutationContext +): Promise { + const { scene, transaction } = context; + const { title, description, tags, editable } = payload; + + try { + const previousState = { + title: scene.state.title, + description: scene.state.description, + tags: scene.state.tags, + editable: scene.state.editable, + }; + + // Apply updates + const updates: Partial = {}; + if (title !== undefined) { + updates.title = title; + } + if (description !== undefined) { + updates.description = description; + } + if (tags !== undefined) { + updates.tags = tags; + } + if (editable !== undefined) { + updates.editable = editable; + } + + scene.setState(updates); + + const changes: MutationChange[] = [{ path: '/meta', previousValue: previousState, newValue: updates }]; + transaction.changes.push(...changes); + + return { + success: true, + inverseMutation: { + type: 'UPDATE_DASHBOARD_META', + payload: previousState, + }, + changes, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + changes: [], + }; + } +} + +/** + * Get dashboard info (read-only operation) + */ +export async function handleGetDashboardInfo( + _payload: Record, + context: MutationContext +): Promise { + const { scene } = context; + + // Return dashboard info in the result's data field + const info = { + available: true, + uid: scene.state.uid, + title: scene.state.title, + canEdit: scene.canEditDashboard(), + isEditing: scene.state.isEditing ?? false, + availableTools: DASHBOARD_MCP_TOOLS.map((t) => t.name), + }; + + return { + success: true, + changes: [], + data: info, + }; +} diff --git a/public/app/features/dashboard-scene/mutation-api/handlers/index.ts b/public/app/features/dashboard-scene/mutation-api/handlers/index.ts new file mode 100644 index 00000000000..42a81f230da --- /dev/null +++ b/public/app/features/dashboard-scene/mutation-api/handlers/index.ts @@ -0,0 +1,27 @@ +/** + * Mutation Handlers + * + * Pure functions that implement dashboard mutations. + * Each handler receives a payload and context, and returns a MutationResult. + */ + +// Types +// eslint-disable-next-line no-barrel-files/no-barrel-files +export type { MutationContext, MutationTransactionInternal, MutationHandler } from './types'; + +// Panel handlers +// eslint-disable-next-line no-barrel-files/no-barrel-files +export { handleAddPanel, handleRemovePanel, handleUpdatePanel, handleMovePanel } from './panelHandlers'; + +// Variable handlers +// eslint-disable-next-line no-barrel-files/no-barrel-files +export { handleAddVariable, handleRemoveVariable } from './variableHandlers'; + +// Dashboard handlers +// eslint-disable-next-line no-barrel-files/no-barrel-files +export { + handleAddRow, + handleUpdateTimeSettings, + handleUpdateDashboardMeta, + handleGetDashboardInfo, +} from './dashboardHandlers'; diff --git a/public/app/features/dashboard-scene/mutation-api/handlers/panelHandlers.ts b/public/app/features/dashboard-scene/mutation-api/handlers/panelHandlers.ts new file mode 100644 index 00000000000..f9e73eeca5d --- /dev/null +++ b/public/app/features/dashboard-scene/mutation-api/handlers/panelHandlers.ts @@ -0,0 +1,224 @@ +/** + * Panel mutation handlers + */ + +import type { MutationResult, MutationChange, AddPanelPayload, RemovePanelPayload, UpdatePanelPayload } from '../types'; + +import type { MutationContext } from './types'; + +/** + * Add a new panel to the dashboard + */ +export async function handleAddPanel(payload: AddPanelPayload, context: MutationContext): Promise { + const { scene, transaction } = context; + + try { + // Extract values with defaults + // Top-level fields take precedence, then spec fields, then defaults + const title = payload.title ?? payload.spec?.title ?? 'New Panel'; + // VizConfigKind.group contains the plugin ID + const vizType = payload.vizType ?? payload.spec?.vizConfig?.group ?? 'timeseries'; + const description = payload.description ?? payload.spec?.description ?? ''; + + // Position is for future layout placement (not yet implemented) + const _position = payload.position; + void _position; // Suppress unused variable warning until layout positioning is implemented + + // Generate unique element name + const elementName = `panel-${title.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${Date.now()}`; + + // Use scene's addPanel method (simplified for POC) + const body = scene.state.body; + if (!body) { + throw new Error('Dashboard has no body'); + } + + // For POC: Create a basic panel using VizPanel directly + // Real implementation would use proper panel building utilities + const { VizPanel } = await import('@grafana/scenes'); + + const vizPanel = new VizPanel({ + title, + pluginId: vizType, + description, + options: {}, + fieldConfig: { defaults: {}, overrides: [] }, + key: elementName, + }); + + // Add panel to scene + scene.addPanel(vizPanel); + + const changes: MutationChange[] = [ + { path: `/elements/${elementName}`, previousValue: undefined, newValue: { title, vizType } }, + ]; + transaction.changes.push(...changes); + + return { + success: true, + inverseMutation: { + type: 'REMOVE_PANEL', + payload: { elementName }, + }, + changes, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + changes: [], + }; + } +} + +/** + * Remove a panel from the dashboard + */ +export async function handleRemovePanel( + payload: RemovePanelPayload, + context: MutationContext +): Promise { + const { scene, transaction } = context; + const { elementName, panelId } = payload; + + try { + // Find the panel + const body = scene.state.body; + if (!body) { + throw new Error('Dashboard has no body'); + } + + // Find panel by element name or ID + const { VizPanel } = await import('@grafana/scenes'); + let panelToRemove: InstanceType | null = null; + let panelState: Record = {}; + + // Search through the scene's panels + const panels = body.getVizPanels?.() || []; + for (const panel of panels) { + const state = panel.state; + if (elementName && state.key === elementName) { + panelToRemove = panel; + panelState = { ...state }; + break; + } + // panelId is stored internally, use key for matching + if (panelId !== undefined && state.key && String(state.key).includes(String(panelId))) { + panelToRemove = panel; + panelState = { ...state }; + break; + } + } + + if (!panelToRemove) { + throw new Error(`Panel not found: ${elementName || panelId}`); + } + + // Remove the panel + scene.removePanel(panelToRemove); + + const changes: MutationChange[] = [ + { path: `/elements/${elementName || panelId}`, previousValue: panelState, newValue: undefined }, + ]; + transaction.changes.push(...changes); + + return { + success: true, + inverseMutation: { + type: 'REMOVE_PANEL', + payload: { elementName: String(panelState.key) }, + }, + changes, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + changes: [], + }; + } +} + +/** + * Update an existing panel + */ +export async function handleUpdatePanel( + payload: UpdatePanelPayload, + context: MutationContext +): Promise { + const { scene, transaction } = context; + const { elementName, panelId, updates } = payload; + + try { + // Find the panel + const body = scene.state.body; + if (!body) { + throw new Error('Dashboard has no body'); + } + + const { VizPanel } = await import('@grafana/scenes'); + const panels = body.getVizPanels?.() || []; + let panelToUpdate: InstanceType | null = null; + + for (const panel of panels) { + const state = panel.state; + if (elementName && state.key === elementName) { + panelToUpdate = panel; + break; + } + // panelId is stored internally, use key for matching + if (panelId !== undefined && state.key && String(state.key).includes(String(panelId))) { + panelToUpdate = panel; + break; + } + } + + if (!panelToUpdate) { + throw new Error(`Panel not found: ${elementName || panelId}`); + } + + // Store previous state for rollback + const previousState = { ...panelToUpdate.state }; + + // Apply updates from PanelSpec + if (updates.title !== undefined) { + panelToUpdate.setState({ title: updates.title }); + } + if (updates.description !== undefined) { + panelToUpdate.setState({ description: updates.description }); + } + // More updates would be handled here based on PanelSpec fields + + const changes: MutationChange[] = [ + { path: `/elements/${elementName || panelId}`, previousValue: previousState, newValue: updates }, + ]; + transaction.changes.push(...changes); + + return { + success: true, + inverseMutation: { + type: 'UPDATE_PANEL', + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + payload: { elementName, panelId, updates: previousState as UpdatePanelPayload['updates'] }, + }, + changes, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + changes: [], + }; + } +} + +/** + * Move a panel (stub - not fully implemented) + */ +export async function handleMovePanel(): Promise { + return { + success: true, + changes: [], + warnings: ['Move panel is not fully implemented in POC'], + }; +} diff --git a/public/app/features/dashboard-scene/mutation-api/handlers/types.ts b/public/app/features/dashboard-scene/mutation-api/handlers/types.ts new file mode 100644 index 00000000000..82af126d530 --- /dev/null +++ b/public/app/features/dashboard-scene/mutation-api/handlers/types.ts @@ -0,0 +1,29 @@ +/** + * Shared types for mutation handlers + */ + +import type { DashboardScene } from '../../scene/DashboardScene'; +import type { MutationResult, MutationChange, MutationType, MutationPayloadMap, MutationTransaction } from '../types'; + +/** + * Context passed to all mutation handlers + */ +export interface MutationContext { + scene: DashboardScene; + transaction: MutationTransactionInternal; +} + +/** + * Internal transaction type with mutable changes array + */ +export interface MutationTransactionInternal extends MutationTransaction { + changes: MutationChange[]; +} + +/** + * A mutation handler function + */ +export type MutationHandler = ( + payload: MutationPayloadMap[T], + context: MutationContext +) => Promise; diff --git a/public/app/features/dashboard-scene/mutation-api/handlers/variableHandlers.ts b/public/app/features/dashboard-scene/mutation-api/handlers/variableHandlers.ts new file mode 100644 index 00000000000..b9bbf4cea7a --- /dev/null +++ b/public/app/features/dashboard-scene/mutation-api/handlers/variableHandlers.ts @@ -0,0 +1,72 @@ +/** + * Variable mutation handlers + */ + +import type { MutationResult, MutationChange, AddVariablePayload, RemoveVariablePayload } from '../types'; + +import type { MutationContext } from './types'; + +/** + * Add a variable (stub - not fully implemented) + */ +export async function handleAddVariable( + _payload: AddVariablePayload, + _context: MutationContext +): Promise { + // TODO: Variable creation requires access to internal serialization functions + // (createSceneVariableFromVariableModel) which are not currently exported. + // This needs to be addressed by exporting the function or creating a public API. + return { + success: true, + changes: [], + warnings: ['Add variable is not fully implemented in POC - requires exported variable factory'], + }; +} + +/** + * Remove a variable from the dashboard + */ +export async function handleRemoveVariable( + payload: RemoveVariablePayload, + context: MutationContext +): Promise { + const { scene, transaction } = context; + const { name } = payload; + + try { + const variables = scene.state.$variables; + if (!variables) { + throw new Error('Dashboard has no variable set'); + } + + const variable = variables.getByName(name); + if (!variable) { + throw new Error(`Variable '${name}' not found`); + } + + const previousState = variable.state; + + // Remove variable + variables.setState({ + variables: variables.state.variables.filter((v: { state: { name: string } }) => v.state.name !== name), + }); + + const changes: MutationChange[] = [ + { path: `/variables/${name}`, previousValue: previousState, newValue: undefined }, + ]; + transaction.changes.push(...changes); + + // inverse mutation would need to reconstruct the VariableKind from SceneVariable state + // This is simplified for POC + return { + success: true, + changes, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + changes: [], + }; + } +} diff --git a/public/app/features/dashboard-scene/mutation-api/index.ts b/public/app/features/dashboard-scene/mutation-api/index.ts new file mode 100644 index 00000000000..ee696fca2dd --- /dev/null +++ b/public/app/features/dashboard-scene/mutation-api/index.ts @@ -0,0 +1,76 @@ +/** + * Dashboard Mutation API + * + * This module provides a stable API for programmatic dashboard modifications. + * It is designed for use by Grafana Assistant and other tools that need to modify dashboards. + * + * @example + * ```typescript + * import { getDashboardMutationAPI } from '@grafana/runtime'; + * + * const api = getDashboardMutationAPI(); + * if (api && api.canEdit()) { + * // Simple: just title and vizType + * const result = await api.execute({ + * type: 'ADD_PANEL', + * payload: { title: 'CPU Usage', vizType: 'timeseries' }, + * }); + * + * // Advanced: with full spec + * const result2 = await api.execute({ + * type: 'ADD_PANEL', + * payload: { + * title: 'Memory Usage', + * spec: { + * vizConfig: { kind: 'VizConfig', spec: { pluginId: 'stat' } }, + * data: { kind: 'QueryGroup', spec: { queries: [] } }, + * }, + * }, + * }); + * } + * ``` + */ + +// Types - intentionally re-exported as public API surface +// eslint-disable-next-line no-barrel-files/no-barrel-files +export type { + // Mutation types + MutationType, + Mutation, + MutationPayloadMap, + MutationResult, + MutationChange, + MutationTransaction, + MutationEvent, + + // Payload types (use schema types directly where possible) + AddPanelPayload, + RemovePanelPayload, + UpdatePanelPayload, + MovePanelPayload, + DuplicatePanelPayload, + AddVariablePayload, + RemoveVariablePayload, + UpdateVariablePayload, + AddRowPayload, + RemoveRowPayload, + CollapseRowPayload, + UpdateTimeSettingsPayload, + UpdateDashboardMetaPayload, + + // Supporting types + LayoutPosition, + + // MCP types + MCPToolDefinition, + MCPResourceDefinition, + MCPPromptDefinition, +} from './types'; + +// Mutation Executor +// eslint-disable-next-line no-barrel-files/no-barrel-files +export { MutationExecutor } from './MutationExecutor'; + +// MCP Tool Definitions +// eslint-disable-next-line no-barrel-files/no-barrel-files +export { DASHBOARD_MCP_TOOLS, DASHBOARD_MCP_RESOURCES, DASHBOARD_MCP_PROMPTS } from './mcpTools'; diff --git a/public/app/features/dashboard-scene/mutation-api/mcpTools.ts b/public/app/features/dashboard-scene/mutation-api/mcpTools.ts new file mode 100644 index 00000000000..b13cc24d204 --- /dev/null +++ b/public/app/features/dashboard-scene/mutation-api/mcpTools.ts @@ -0,0 +1,1429 @@ +/** + * Dashboard MCP Tool Definitions + * + * Defines the MCP tools exposed by the Dashboard MCP Server. + * These tools allow AI assistants to modify dashboards programmatically. + */ + +import { MCPToolDefinition, MCPResourceDefinition, MCPPromptDefinition } from './types'; + +// ============================================================================ +// Tool Definitions +// ============================================================================ + +export const DASHBOARD_MCP_TOOLS: MCPToolDefinition[] = [ + // Dashboard Info (read-only) + { + name: 'get_dashboard_info', + description: `Get information about the currently loaded dashboard and available mutation tools. + +Returns dashboard metadata including: +- uid: Dashboard unique identifier +- title: Dashboard title +- canEdit: Whether the current user has edit permissions +- isEditing: Whether the dashboard is currently in edit mode +- availableTools: List of available mutation tool names + +Use this tool to check the current dashboard state before making mutations.`, + inputSchema: { + type: 'object', + properties: {}, + required: [], + }, + annotations: { + title: 'Get Dashboard Info', + readOnlyHint: true, + }, + }, + + // Panel Operations + { + name: 'add_panel', + description: + 'Add a new panel to the dashboard. Creates both the panel definition in elements and a layout item. Automatically generates a unique element name and positions the panel.', + inputSchema: { + type: 'object', + properties: { + title: { + type: 'string', + description: 'Panel title displayed at the top of the panel', + }, + vizType: { + type: 'string', + description: 'Visualization type (e.g., "timeseries", "stat", "gauge", "table", "text", "logs")', + enum: ['timeseries', 'stat', 'gauge', 'table', 'text', 'logs', 'barchart', 'piechart', 'heatmap'], + }, + description: { + type: 'string', + description: 'Optional panel description shown in panel info', + }, + queries: { + type: 'array', + description: 'Data queries for the panel', + items: { + type: 'object', + properties: { + refId: { type: 'string', description: 'Query reference ID (e.g., "A", "B")' }, + datasource: { + type: 'object', + properties: { + uid: { type: 'string' }, + type: { type: 'string' }, + }, + }, + expr: { type: 'string', description: 'PromQL expression (for Prometheus)' }, + query: { type: 'string', description: 'Query string (generic)' }, + }, + required: ['refId'], + }, + }, + position: { + type: 'object', + description: 'Panel position in the layout', + properties: { + x: { type: 'number', description: 'X position (0-23 in 24-column grid)' }, + y: { type: 'number', description: 'Y position (row number)' }, + width: { type: 'number', description: 'Width in grid units (1-24)' }, + height: { type: 'number', description: 'Height in grid units' }, + targetRow: { type: 'string', description: 'Row name to place panel in (for RowsLayout)' }, + targetTab: { type: 'string', description: 'Tab name to place panel in (for TabsLayout)' }, + }, + }, + options: { + type: 'object', + description: 'Visualization-specific options', + }, + fieldConfig: { + type: 'object', + description: 'Field configuration (units, thresholds, mappings)', + }, + }, + required: ['title', 'vizType'], + }, + annotations: { + title: 'Add Panel', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }, + + { + name: 'remove_panel', + description: + 'Remove a panel from the dashboard. Deletes both the element definition and all layout items referencing it.', + inputSchema: { + type: 'object', + properties: { + elementName: { + type: 'string', + description: 'Element name in the elements map (e.g., "panel-cpu-usage")', + }, + panelId: { + type: 'number', + description: 'Alternative: Panel ID (numeric)', + }, + }, + }, + annotations: { + title: 'Remove Panel', + readOnlyHint: false, + destructiveHint: true, + confirmationHint: true, + }, + }, + + { + name: 'update_panel', + description: "Update an existing panel's properties, queries, or configuration.", + inputSchema: { + type: 'object', + properties: { + elementName: { + type: 'string', + description: 'Element name to update', + }, + panelId: { + type: 'number', + description: 'Alternative: Panel ID', + }, + updates: { + type: 'object', + description: 'Properties to update', + properties: { + title: { type: 'string' }, + description: { type: 'string' }, + vizType: { type: 'string' }, + queries: { type: 'array' }, + options: { type: 'object' }, + fieldConfig: { type: 'object' }, + }, + }, + }, + required: ['updates'], + }, + annotations: { + title: 'Update Panel', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + { + name: 'move_panel', + description: 'Move a panel to a new position or container (row/tab).', + inputSchema: { + type: 'object', + properties: { + elementName: { + type: 'string', + description: 'Element name to move', + }, + targetPosition: { + type: 'object', + description: 'Target position', + properties: { + x: { type: 'number' }, + y: { type: 'number' }, + width: { type: 'number' }, + height: { type: 'number' }, + targetRow: { type: 'string' }, + targetTab: { type: 'string' }, + }, + }, + }, + required: ['elementName', 'targetPosition'], + }, + annotations: { + title: 'Move Panel', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + { + name: 'duplicate_panel', + description: 'Create a copy of an existing panel.', + inputSchema: { + type: 'object', + properties: { + elementName: { + type: 'string', + description: 'Element name to duplicate', + }, + newTitle: { + type: 'string', + description: 'Title for the new panel (defaults to "Copy of {original}")', + }, + }, + required: ['elementName'], + }, + annotations: { + title: 'Duplicate Panel', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + // Variable Operations + { + name: 'add_variable', + description: 'Add a template variable to the dashboard.', + inputSchema: { + type: 'object', + properties: { + kind: { + type: 'string', + description: 'Variable type', + enum: [ + 'QueryVariable', + 'CustomVariable', + 'DatasourceVariable', + 'IntervalVariable', + 'TextVariable', + 'ConstantVariable', + ], + }, + spec: { + type: 'object', + description: 'Variable specification', + properties: { + name: { type: 'string', description: 'Variable name (used in queries as $name)' }, + label: { type: 'string', description: 'Display label' }, + description: { type: 'string' }, + hide: { type: 'string', enum: ['dontHide', 'hideLabel', 'hideVariable'] }, + multi: { type: 'boolean', description: 'Allow multiple selections' }, + includeAll: { type: 'boolean', description: 'Include "All" option' }, + query: { type: 'string', description: 'Query for QueryVariable' }, + regex: { type: 'string', description: 'Regex filter' }, + options: { + type: 'array', + description: 'Options for CustomVariable', + items: { + type: 'object', + properties: { + text: { type: 'string' }, + value: { type: 'string' }, + }, + }, + }, + }, + required: ['name'], + }, + }, + required: ['kind', 'spec'], + }, + annotations: { + title: 'Add Variable', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + { + name: 'remove_variable', + description: + 'Remove a template variable from the dashboard. Warning: This may break panels or other variables that reference it.', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Variable name to remove', + }, + }, + required: ['name'], + }, + annotations: { + title: 'Remove Variable', + readOnlyHint: false, + destructiveHint: true, + confirmationHint: true, + }, + }, + + { + name: 'update_variable', + description: 'Update an existing template variable.', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Variable name to update', + }, + updates: { + type: 'object', + description: 'Properties to update', + properties: { + label: { type: 'string' }, + description: { type: 'string' }, + hide: { type: 'string' }, + multi: { type: 'boolean' }, + includeAll: { type: 'boolean' }, + query: { type: 'string' }, + regex: { type: 'string' }, + }, + }, + }, + required: ['name', 'updates'], + }, + annotations: { + title: 'Update Variable', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + // Row Operations + { + name: 'add_row', + description: 'Add a row container to organize panels. Requires RowsLayout.', + inputSchema: { + type: 'object', + properties: { + title: { + type: 'string', + description: 'Row title', + }, + collapsed: { + type: 'boolean', + description: 'Whether the row is initially collapsed', + }, + position: { + type: 'number', + description: 'Row index (0 = first)', + }, + }, + required: ['title'], + }, + annotations: { + title: 'Add Row', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + { + name: 'remove_row', + description: 'Remove a row from the dashboard.', + inputSchema: { + type: 'object', + properties: { + rowTitle: { + type: 'string', + description: 'Row title to remove', + }, + rowIndex: { + type: 'number', + description: 'Alternative: Row index', + }, + panelHandling: { + type: 'string', + description: 'What to do with panels in the row', + enum: ['delete', 'moveToRoot'], + }, + }, + }, + annotations: { + title: 'Remove Row', + readOnlyHint: false, + destructiveHint: true, + confirmationHint: true, + }, + }, + + // Dashboard Settings + { + name: 'update_time_settings', + description: 'Update dashboard time range and refresh settings.', + inputSchema: { + type: 'object', + properties: { + from: { + type: 'string', + description: 'Start time (e.g., "now-6h", "now-1d")', + }, + to: { + type: 'string', + description: 'End time (e.g., "now")', + }, + timezone: { + type: 'string', + description: 'Timezone (e.g., "browser", "utc", "America/New_York")', + }, + autoRefresh: { + type: 'string', + description: 'Auto-refresh interval (e.g., "5s", "1m", "5m", "" to disable)', + }, + hideTimepicker: { + type: 'boolean', + description: 'Hide the time picker', + }, + }, + }, + annotations: { + title: 'Update Time Settings', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + { + name: 'update_dashboard_meta', + description: 'Update dashboard metadata (title, description, tags).', + inputSchema: { + type: 'object', + properties: { + title: { + type: 'string', + description: 'Dashboard title', + }, + description: { + type: 'string', + description: 'Dashboard description', + }, + tags: { + type: 'array', + description: 'Dashboard tags', + items: { type: 'string' }, + }, + editable: { + type: 'boolean', + description: 'Whether the dashboard is editable', + }, + }, + }, + annotations: { + title: 'Update Dashboard Metadata', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + // Batch Operations + { + name: 'batch_mutations', + description: 'Apply multiple mutations atomically. All succeed or all fail with rollback.', + inputSchema: { + type: 'object', + properties: { + mutations: { + type: 'array', + description: 'Array of mutations to apply', + items: { + type: 'object', + properties: { + type: { + type: 'string', + description: 'Mutation type', + enum: [ + 'ADD_PANEL', + 'REMOVE_PANEL', + 'UPDATE_PANEL', + 'MOVE_PANEL', + 'ADD_VARIABLE', + 'REMOVE_VARIABLE', + 'ADD_ROW', + 'UPDATE_TIME_SETTINGS', + ], + }, + payload: { + type: 'object', + description: 'Mutation payload', + }, + }, + required: ['type', 'payload'], + }, + }, + }, + required: ['mutations'], + }, + annotations: { + title: 'Batch Mutations', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + // Read Operations (for context) + { + name: 'get_panel_info', + description: 'Get information about a specific panel.', + inputSchema: { + type: 'object', + properties: { + elementName: { type: 'string' }, + panelId: { type: 'number' }, + }, + }, + annotations: { + title: 'Get Panel Info', + readOnlyHint: true, + destructiveHint: false, + }, + }, + + { + name: 'list_panels', + description: 'List all panels in the current dashboard with their element names and titles.', + inputSchema: { + type: 'object', + properties: {}, + }, + annotations: { + title: 'List Panels', + readOnlyHint: true, + destructiveHint: false, + }, + }, + + { + name: 'list_variables', + description: 'List all template variables in the current dashboard.', + inputSchema: { + type: 'object', + properties: {}, + }, + annotations: { + title: 'List Variables', + readOnlyHint: true, + destructiveHint: false, + }, + }, + + // ============================================================================ + // Tab Operations + // ============================================================================ + + { + name: 'add_tab', + description: 'Add a tab container to the dashboard. Requires TabsLayout.', + inputSchema: { + type: 'object', + properties: { + title: { + type: 'string', + description: 'Tab title', + }, + position: { + type: 'number', + description: 'Tab index (0 = first)', + }, + }, + required: ['title'], + }, + annotations: { + title: 'Add Tab', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + { + name: 'remove_tab', + description: 'Remove a tab from the dashboard.', + inputSchema: { + type: 'object', + properties: { + tabTitle: { + type: 'string', + description: 'Tab title to remove', + }, + tabIndex: { + type: 'number', + description: 'Alternative: Tab index', + }, + panelHandling: { + type: 'string', + description: 'What to do with panels in the tab', + enum: ['delete', 'moveToRoot'], + }, + }, + }, + annotations: { + title: 'Remove Tab', + readOnlyHint: false, + destructiveHint: true, + confirmationHint: true, + }, + }, + + // ============================================================================ + // Library Panel Operations + // ============================================================================ + + { + name: 'add_library_panel', + description: 'Add a library panel to the dashboard by its UID or name.', + inputSchema: { + type: 'object', + properties: { + libraryPanelUid: { + type: 'string', + description: 'Library panel UID', + }, + libraryPanelName: { + type: 'string', + description: 'Alternative: Library panel name', + }, + position: { + type: 'object', + description: 'Position in layout', + properties: { + x: { type: 'number' }, + y: { type: 'number' }, + width: { type: 'number' }, + height: { type: 'number' }, + targetRow: { type: 'string' }, + targetTab: { type: 'string' }, + }, + }, + }, + }, + annotations: { + title: 'Add Library Panel', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + { + name: 'unlink_library_panel', + description: 'Convert a library panel to a regular panel (unlink from library).', + inputSchema: { + type: 'object', + properties: { + elementName: { + type: 'string', + description: 'Element name of the library panel to unlink', + }, + }, + required: ['elementName'], + }, + annotations: { + title: 'Unlink Library Panel', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + { + name: 'save_as_library_panel', + description: 'Save an existing panel as a library panel.', + inputSchema: { + type: 'object', + properties: { + elementName: { + type: 'string', + description: 'Element name of the panel to save', + }, + libraryPanelName: { + type: 'string', + description: 'Name for the new library panel', + }, + folderUid: { + type: 'string', + description: 'Folder UID to save the library panel in', + }, + }, + required: ['elementName', 'libraryPanelName'], + }, + annotations: { + title: 'Save as Library Panel', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + // ============================================================================ + // Repeat Configuration + // ============================================================================ + + { + name: 'configure_panel_repeat', + description: 'Configure a panel to repeat based on a variable.', + inputSchema: { + type: 'object', + properties: { + elementName: { + type: 'string', + description: 'Element name of the panel', + }, + variableName: { + type: 'string', + description: 'Variable to repeat by (or null to disable repeat)', + }, + direction: { + type: 'string', + description: 'Repeat direction', + enum: ['h', 'v'], + }, + maxPerRow: { + type: 'number', + description: 'Maximum panels per row (for horizontal repeat)', + }, + }, + required: ['elementName'], + }, + annotations: { + title: 'Configure Panel Repeat', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + { + name: 'configure_row_repeat', + description: 'Configure a row to repeat based on a variable.', + inputSchema: { + type: 'object', + properties: { + rowTitle: { + type: 'string', + description: 'Row title', + }, + rowIndex: { + type: 'number', + description: 'Alternative: Row index', + }, + variableName: { + type: 'string', + description: 'Variable to repeat by (or null to disable repeat)', + }, + }, + required: ['variableName'], + }, + annotations: { + title: 'Configure Row Repeat', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + // ============================================================================ + // Conditional Rendering (Show/Hide) + // ============================================================================ + + { + name: 'set_conditional_rendering', + description: 'Configure conditional rendering (show/hide) for a panel, row, or tab.', + inputSchema: { + type: 'object', + properties: { + elementName: { + type: 'string', + description: 'Element name (panel, row, or tab)', + }, + visibility: { + type: 'string', + description: 'Show or hide when conditions match', + enum: ['show', 'hide'], + }, + condition: { + type: 'string', + description: 'How to combine multiple conditions', + enum: ['and', 'or'], + }, + rules: { + type: 'array', + description: 'Conditional rules', + items: { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['variable', 'data', 'timeRangeSize'], + }, + variable: { type: 'string' }, + operator: { + type: 'string', + enum: ['equals', 'notEquals', 'matches', 'notMatches'], + }, + value: { type: 'string' }, + }, + }, + }, + }, + required: ['elementName', 'visibility'], + }, + annotations: { + title: 'Set Conditional Rendering', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + // ============================================================================ + // Layout Type + // ============================================================================ + + { + name: 'change_layout_type', + description: 'Switch the dashboard layout type (e.g., from GridLayout to RowsLayout or AutoGridLayout).', + inputSchema: { + type: 'object', + properties: { + layoutType: { + type: 'string', + description: 'Target layout type', + enum: ['GridLayout', 'RowsLayout', 'AutoGridLayout', 'TabsLayout'], + }, + options: { + type: 'object', + description: 'Layout-specific options', + properties: { + // AutoGridLayout options + maxColumnCount: { type: 'number' }, + columnWidthMode: { type: 'string', enum: ['narrow', 'standard', 'wide', 'custom'] }, + rowHeightMode: { type: 'string', enum: ['short', 'standard', 'tall', 'custom'] }, + }, + }, + }, + required: ['layoutType'], + }, + annotations: { + title: 'Change Layout Type', + readOnlyHint: false, + destructiveHint: false, + confirmationHint: true, + }, + }, + + // ============================================================================ + // Annotations + // ============================================================================ + + { + name: 'add_annotation', + description: 'Add an annotation query to the dashboard.', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Annotation name', + }, + datasource: { + type: 'object', + properties: { + uid: { type: 'string' }, + type: { type: 'string' }, + }, + }, + query: { + type: 'object', + description: 'Datasource-specific query', + }, + iconColor: { + type: 'string', + description: 'Annotation icon color', + }, + enable: { + type: 'boolean', + description: 'Whether the annotation is enabled', + }, + hide: { + type: 'boolean', + description: 'Whether to hide the annotation', + }, + filter: { + type: 'object', + description: 'Panel filter', + properties: { + exclude: { type: 'boolean' }, + ids: { type: 'array', items: { type: 'number' } }, + }, + }, + }, + required: ['name', 'datasource'], + }, + annotations: { + title: 'Add Annotation', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + { + name: 'update_annotation', + description: 'Update an existing annotation query.', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Annotation name to update', + }, + updates: { + type: 'object', + description: 'Properties to update', + properties: { + name: { type: 'string' }, + iconColor: { type: 'string' }, + enable: { type: 'boolean' }, + hide: { type: 'boolean' }, + query: { type: 'object' }, + }, + }, + }, + required: ['name', 'updates'], + }, + annotations: { + title: 'Update Annotation', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + { + name: 'remove_annotation', + description: 'Remove an annotation query from the dashboard.', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Annotation name to remove', + }, + }, + required: ['name'], + }, + annotations: { + title: 'Remove Annotation', + readOnlyHint: false, + destructiveHint: true, + }, + }, + + // ============================================================================ + // Dashboard Links + // ============================================================================ + + { + name: 'add_dashboard_link', + description: 'Add a link to the dashboard (to another dashboard or external URL).', + inputSchema: { + type: 'object', + properties: { + title: { + type: 'string', + description: 'Link title', + }, + type: { + type: 'string', + description: 'Link type', + enum: ['link', 'dashboards'], + }, + url: { + type: 'string', + description: 'URL (for type "link")', + }, + tags: { + type: 'array', + description: 'Dashboard tags filter (for type "dashboards")', + items: { type: 'string' }, + }, + targetBlank: { + type: 'boolean', + description: 'Open in new tab', + }, + includeVars: { + type: 'boolean', + description: 'Include template variables in URL', + }, + keepTime: { + type: 'boolean', + description: 'Include time range in URL', + }, + asDropdown: { + type: 'boolean', + description: 'Show as dropdown (for type "dashboards")', + }, + }, + required: ['title', 'type'], + }, + annotations: { + title: 'Add Dashboard Link', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + { + name: 'remove_dashboard_link', + description: 'Remove a dashboard link.', + inputSchema: { + type: 'object', + properties: { + title: { + type: 'string', + description: 'Link title to remove', + }, + index: { + type: 'number', + description: 'Alternative: Link index', + }, + }, + }, + annotations: { + title: 'Remove Dashboard Link', + readOnlyHint: false, + destructiveHint: true, + }, + }, + + // ============================================================================ + // Panel Links and Data Links + // ============================================================================ + + { + name: 'add_panel_link', + description: 'Add a link to a panel.', + inputSchema: { + type: 'object', + properties: { + elementName: { + type: 'string', + description: 'Panel element name', + }, + title: { + type: 'string', + description: 'Link title', + }, + url: { + type: 'string', + description: 'Link URL (can include variables like ${__data.fields.name})', + }, + targetBlank: { + type: 'boolean', + description: 'Open in new tab', + }, + }, + required: ['elementName', 'title', 'url'], + }, + annotations: { + title: 'Add Panel Link', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + { + name: 'add_data_link', + description: 'Add a data link to a panel (links that appear when clicking on data points).', + inputSchema: { + type: 'object', + properties: { + elementName: { + type: 'string', + description: 'Panel element name', + }, + title: { + type: 'string', + description: 'Link title', + }, + url: { + type: 'string', + description: 'Link URL (can include field variables)', + }, + targetBlank: { + type: 'boolean', + description: 'Open in new tab', + }, + }, + required: ['elementName', 'title', 'url'], + }, + annotations: { + title: 'Add Data Link', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + // ============================================================================ + // Field Configuration (Transformations, Overrides) + // ============================================================================ + + { + name: 'add_field_override', + description: 'Add a field override to a panel (customize display for specific fields).', + inputSchema: { + type: 'object', + properties: { + elementName: { + type: 'string', + description: 'Panel element name', + }, + matcher: { + type: 'object', + description: 'Field matcher', + properties: { + id: { + type: 'string', + enum: ['byName', 'byRegexp', 'byType', 'byFrameRefID'], + }, + options: { type: 'string' }, + }, + }, + properties: { + type: 'array', + description: 'Properties to override', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Property ID (e.g., "displayName", "unit", "color")' }, + value: { description: 'Property value' }, + }, + }, + }, + }, + required: ['elementName', 'matcher', 'properties'], + }, + annotations: { + title: 'Add Field Override', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + { + name: 'add_value_mapping', + description: 'Add a value mapping to a panel (map values to text/colors).', + inputSchema: { + type: 'object', + properties: { + elementName: { + type: 'string', + description: 'Panel element name', + }, + mappingType: { + type: 'string', + description: 'Mapping type', + enum: ['value', 'range', 'regex', 'special'], + }, + options: { + type: 'object', + description: 'Mapping options (depends on type)', + }, + }, + required: ['elementName', 'mappingType', 'options'], + }, + annotations: { + title: 'Add Value Mapping', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + { + name: 'add_transformation', + description: 'Add a data transformation to a panel.', + inputSchema: { + type: 'object', + properties: { + elementName: { + type: 'string', + description: 'Panel element name', + }, + transformationId: { + type: 'string', + description: 'Transformation ID (e.g., "reduce", "merge", "filterFieldsByName")', + }, + options: { + type: 'object', + description: 'Transformation options', + }, + }, + required: ['elementName', 'transformationId'], + }, + annotations: { + title: 'Add Transformation', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + // ============================================================================ + // Dashboard Management (requires backend) + // ============================================================================ + + { + name: 'move_to_folder', + description: 'Move the dashboard to a different folder.', + inputSchema: { + type: 'object', + properties: { + folderUid: { + type: 'string', + description: 'Target folder UID', + }, + folderTitle: { + type: 'string', + description: 'Alternative: Target folder title', + }, + }, + }, + annotations: { + title: 'Move to Folder', + readOnlyHint: false, + destructiveHint: false, + confirmationHint: true, + }, + }, + + { + name: 'toggle_favorite', + description: 'Mark or unmark the dashboard as a favorite.', + inputSchema: { + type: 'object', + properties: { + favorite: { + type: 'boolean', + description: 'True to mark as favorite, false to unmark', + }, + }, + required: ['favorite'], + }, + annotations: { + title: 'Toggle Favorite', + readOnlyHint: false, + destructiveHint: false, + }, + }, + + // ============================================================================ + // Version Management (requires backend) + // ============================================================================ + + { + name: 'list_versions', + description: 'List dashboard version history.', + inputSchema: { + type: 'object', + properties: { + limit: { + type: 'number', + description: 'Maximum number of versions to return', + }, + }, + }, + annotations: { + title: 'List Versions', + readOnlyHint: true, + destructiveHint: false, + }, + }, + + { + name: 'compare_versions', + description: 'Compare two dashboard versions.', + inputSchema: { + type: 'object', + properties: { + baseVersion: { + type: 'number', + description: 'Base version number', + }, + newVersion: { + type: 'number', + description: 'New version number', + }, + }, + required: ['baseVersion', 'newVersion'], + }, + annotations: { + title: 'Compare Versions', + readOnlyHint: true, + destructiveHint: false, + }, + }, + + { + name: 'restore_version', + description: 'Restore the dashboard to a previous version.', + inputSchema: { + type: 'object', + properties: { + version: { + type: 'number', + description: 'Version number to restore', + }, + }, + required: ['version'], + }, + annotations: { + title: 'Restore Version', + readOnlyHint: false, + destructiveHint: true, + confirmationHint: true, + }, + }, +]; + +// ============================================================================ +// Resource Definitions +// ============================================================================ + +export const DASHBOARD_MCP_RESOURCES: MCPResourceDefinition[] = [ + { + uri: 'dashboard://current', + name: 'Current Dashboard', + description: 'The currently active dashboard in the browser', + mimeType: 'application/json', + }, + { + uri: 'dashboard://{uid}', + uriTemplate: true, + name: 'Dashboard by UID', + description: 'Fetch a specific dashboard by its UID', + mimeType: 'application/json', + }, + { + uri: 'dashboard://{uid}/panels', + uriTemplate: true, + name: 'Dashboard Panels', + description: 'List of panels in a dashboard with element names, titles, and basic info', + mimeType: 'application/json', + }, + { + uri: 'dashboard://{uid}/variables', + uriTemplate: true, + name: 'Dashboard Variables', + description: 'Template variables configured in the dashboard', + mimeType: 'application/json', + }, + { + uri: 'dashboard://{uid}/layout', + uriTemplate: true, + name: 'Dashboard Layout', + description: 'Layout structure (rows, tabs, panel positions)', + mimeType: 'application/json', + }, + { + uri: 'schema://v2beta1/dashboard', + name: 'Dashboard V2 Schema', + description: 'JSON Schema for v2beta1 dashboard spec', + mimeType: 'application/json', + }, + { + uri: 'schema://v2beta1/panel', + name: 'Panel Schema', + description: 'JSON Schema for panel definitions', + mimeType: 'application/json', + }, +]; + +// ============================================================================ +// Prompt Definitions +// ============================================================================ + +export const DASHBOARD_MCP_PROMPTS: MCPPromptDefinition[] = [ + { + name: 'add_monitoring_panel', + description: 'Add a panel to monitor a specific metric with recommended visualization defaults', + arguments: [ + { name: 'metric', description: 'The metric to monitor (e.g., cpu_usage, memory_percent)', required: true }, + { name: 'datasource', description: 'Datasource UID to query', required: true }, + ], + }, + { + name: 'create_sre_dashboard', + description: 'Create a standard SRE dashboard with RED metrics (Rate, Errors, Duration)', + arguments: [ + { name: 'service', description: 'Service name to monitor', required: true }, + { name: 'datasource', description: 'Prometheus datasource UID', required: true }, + ], + }, + { + name: 'add_alert_annotations', + description: 'Add annotation queries for alerts related to this dashboard', + arguments: [{ name: 'alertmanager', description: 'Alertmanager datasource UID', required: true }], + }, + { + name: 'organize_panels_into_rows', + description: 'Reorganize existing panels into logical row groupings', + arguments: [ + { name: 'grouping', description: 'How to group panels (e.g., "by-datasource", "by-type")', required: true }, + ], + }, +]; + +// ============================================================================ +// Helper to get tool by name +// ============================================================================ + +export function getToolDefinition(name: string): MCPToolDefinition | undefined { + return DASHBOARD_MCP_TOOLS.find((tool) => tool.name === name); +} + +export function getResourceDefinition(uri: string): MCPResourceDefinition | undefined { + return DASHBOARD_MCP_RESOURCES.find((resource) => { + if (resource.uriTemplate) { + // Simple template matching + const pattern = resource.uri.replace(/\{[^}]+\}/g, '[^/]+'); + return new RegExp(`^${pattern}$`).test(uri); + } + return resource.uri === uri; + }); +} diff --git a/public/app/features/dashboard-scene/mutation-api/types.ts b/public/app/features/dashboard-scene/mutation-api/types.ts new file mode 100644 index 00000000000..3f554e52258 --- /dev/null +++ b/public/app/features/dashboard-scene/mutation-api/types.ts @@ -0,0 +1,420 @@ +/** + * Dashboard Mutation API - Core Types + * + * This module defines the types for the MCP-based dashboard mutation API. + * It provides a standardized interface for programmatic dashboard modifications. + */ + +/** + * Import v2 schema types - these are the source of truth. + * + * The mutation API uses these types directly to ensure compatibility with the dashboard schema. + * No custom payload types are created - we use schema types with Omit for auto-generated fields. + */ +import type { + // Panel types + PanelSpec, + DataLink, + // Variable types + VariableKind, + // Layout types + GridLayoutItemSpec, + RowsLayoutRowSpec, + TabsLayoutTabSpec, + AutoGridLayoutSpec, + RepeatOptions, + ConditionalRenderingGroupSpec, + // Annotation types + AnnotationQuerySpec, + // Dashboard types + DashboardLink, + TimeSettingsSpec, + // Field config types + DynamicConfigValue, + MatcherConfig, + ValueMapping, + DataTransformerConfig, +} from '@grafana/schema/src/schema/dashboard/v2beta1/types.spec.gen'; + +// ============================================================================ +// Mutation Types +// ============================================================================ + +export type MutationType = + // Panel operations + | 'ADD_PANEL' + | 'REMOVE_PANEL' + | 'UPDATE_PANEL' + | 'MOVE_PANEL' + | 'DUPLICATE_PANEL' + // Variable operations + | 'ADD_VARIABLE' + | 'REMOVE_VARIABLE' + | 'UPDATE_VARIABLE' + // Row operations + | 'ADD_ROW' + | 'REMOVE_ROW' + | 'COLLAPSE_ROW' + // Tab operations + | 'ADD_TAB' + | 'REMOVE_TAB' + // Library panel operations + | 'ADD_LIBRARY_PANEL' + | 'UNLINK_LIBRARY_PANEL' + | 'SAVE_AS_LIBRARY_PANEL' + // Repeat configuration + | 'CONFIGURE_PANEL_REPEAT' + | 'CONFIGURE_ROW_REPEAT' + // Conditional rendering + | 'SET_CONDITIONAL_RENDERING' + // Layout + | 'CHANGE_LAYOUT_TYPE' + // Annotation operations + | 'ADD_ANNOTATION' + | 'UPDATE_ANNOTATION' + | 'REMOVE_ANNOTATION' + // Link operations + | 'ADD_DASHBOARD_LINK' + | 'REMOVE_DASHBOARD_LINK' + | 'ADD_PANEL_LINK' + | 'ADD_DATA_LINK' + // Field configuration + | 'ADD_FIELD_OVERRIDE' + | 'ADD_VALUE_MAPPING' + | 'ADD_TRANSFORMATION' + // Dashboard settings + | 'UPDATE_TIME_SETTINGS' + | 'UPDATE_DASHBOARD_META' + // Dashboard management (backend) + | 'MOVE_TO_FOLDER' + | 'TOGGLE_FAVORITE' + // Version management (backend) + | 'LIST_VERSIONS' + | 'COMPARE_VERSIONS' + | 'RESTORE_VERSION' + // Read-only operations + | 'GET_DASHBOARD_INFO'; + +// ============================================================================ +// Mutation Payloads +// ============================================================================ + +/** + * Payload for adding a panel. + * + * Uses Partial so callers can provide just the fields they care about. + * Missing fields are filled with sensible defaults (title defaults to "New Panel", etc.) + * The `id` field is always auto-generated by the system. + * + * Minimal example: { title: "My Panel" } + * Full example: { title: "My Panel", description: "...", vizConfig: {...}, data: {...} } + */ +export interface AddPanelPayload { + /** Panel title (required for meaningful panels) */ + title?: string; + /** Visualization type shorthand (e.g., "timeseries", "stat", "table") */ + vizType?: string; + /** Panel description */ + description?: string; + /** Full panel spec - for advanced use cases. Fields here override top-level fields. */ + spec?: Partial>; + /** Position in the layout */ + position?: LayoutPosition; +} + +export interface RemovePanelPayload { + /** Element name in the elements map */ + elementName?: string; + /** Alternative: Panel ID */ + panelId?: number; +} + +export interface UpdatePanelPayload { + /** Element name or panel ID to update */ + elementName?: string; + panelId?: number; + /** Updates to apply - partial PanelSpec (id cannot be changed) */ + updates: Partial>; +} + +export interface MovePanelPayload { + /** Element name to move */ + elementName: string; + /** Target position */ + targetPosition: LayoutPosition; +} + +export interface DuplicatePanelPayload { + /** Element name to duplicate */ + elementName: string; + /** New title (optional, defaults to "Copy of {original}") */ + newTitle?: string; +} + +/** + * Payload for adding a variable. + * Uses VariableKind from schema directly - the union of all variable types. + */ +export interface AddVariablePayload { + /** The complete variable definition from v2 schema */ + variable: VariableKind; + /** Position in the variables array (optional, appends if not specified) */ + position?: number; +} + +export interface RemoveVariablePayload { + /** Variable name to remove */ + name: string; +} + +export interface UpdateVariablePayload { + /** Variable name to update */ + name: string; + /** The updated variable definition - replaces the existing one */ + variable: VariableKind; +} + +/** + * Payload for adding a row. + * Uses RowsLayoutRowSpec from schema, but layout is optional (created empty). + */ +export interface AddRowPayload { + /** Row spec - uses schema type. Layout is created empty if not provided. */ + spec: Omit & { + layout?: RowsLayoutRowSpec['layout']; + }; + /** Position index (0 = first) */ + position?: number; +} + +export interface RemoveRowPayload { + /** Row title or index to identify the row */ + rowTitle?: string; + rowIndex?: number; + /** What to do with panels in the row */ + panelHandling?: 'delete' | 'moveToRoot'; +} + +export interface CollapseRowPayload { + /** Row title or index to identify the row */ + rowTitle?: string; + rowIndex?: number; + /** Whether to collapse or expand */ + collapsed: boolean; +} + +/** + * Payload for updating time settings. + * Uses TimeSettingsSpec from schema. + */ +export type UpdateTimeSettingsPayload = Partial; + +/** + * Payload for updating dashboard metadata. + * These are top-level DashboardV2Spec fields. + */ +export interface UpdateDashboardMetaPayload { + title?: string; + description?: string; + tags?: string[]; + editable?: boolean; + preload?: boolean; + liveNow?: boolean; +} + +// ============================================================================ +// Supporting Types - derived from schema types +// ============================================================================ + +/** + * Layout position for placing elements. + * Combines GridLayoutItemSpec position fields with container targeting. + */ +export type LayoutPosition = Pick & { + /** Target row title (for RowsLayout) */ + targetRow?: string; + /** Target tab title (for TabsLayout) */ + targetTab?: string; +}; + +// ============================================================================ +// Mutation Definition +// ============================================================================ + +export interface Mutation { + type: T; + payload: MutationPayloadMap[T]; +} + +export interface MutationPayloadMap { + // Panel operations + ADD_PANEL: AddPanelPayload; + REMOVE_PANEL: RemovePanelPayload; + UPDATE_PANEL: UpdatePanelPayload; + MOVE_PANEL: MovePanelPayload; + DUPLICATE_PANEL: DuplicatePanelPayload; + + // Variable operations + ADD_VARIABLE: AddVariablePayload; + REMOVE_VARIABLE: RemoveVariablePayload; + UPDATE_VARIABLE: UpdateVariablePayload; + + // Row operations + ADD_ROW: AddRowPayload; + REMOVE_ROW: RemoveRowPayload; + COLLAPSE_ROW: CollapseRowPayload; + + // Tab operations - uses TabsLayoutTabSpec from schema + ADD_TAB: { + spec: Omit & { layout?: TabsLayoutTabSpec['layout'] }; + position?: number; + }; + REMOVE_TAB: { tabTitle?: string; tabIndex?: number; panelHandling?: 'delete' | 'moveToRoot' }; + + // Library panel operations + ADD_LIBRARY_PANEL: { libraryPanelUid?: string; libraryPanelName?: string; position?: LayoutPosition }; + UNLINK_LIBRARY_PANEL: { elementName: string }; + SAVE_AS_LIBRARY_PANEL: { elementName: string; libraryPanelName: string; folderUid?: string }; + + // Repeat configuration - uses RepeatOptions from schema + CONFIGURE_PANEL_REPEAT: { elementName: string; repeat: RepeatOptions | null }; + CONFIGURE_ROW_REPEAT: { rowTitle?: string; rowIndex?: number; repeat: RowsLayoutRowSpec['repeat'] | null }; + + // Conditional rendering - uses ConditionalRenderingGroupSpec from schema + SET_CONDITIONAL_RENDERING: { + elementName: string; + conditionalRendering: ConditionalRenderingGroupSpec | null; + }; + + // Layout - uses AutoGridLayoutSpec for options + CHANGE_LAYOUT_TYPE: { + layoutType: 'GridLayout' | 'RowsLayout' | 'AutoGridLayout' | 'TabsLayout'; + options?: Partial; + }; + + // Annotation operations - uses AnnotationQuerySpec from schema + ADD_ANNOTATION: Omit & { query?: AnnotationQuerySpec['query'] }; + UPDATE_ANNOTATION: { name: string; updates: Partial }; + REMOVE_ANNOTATION: { name: string }; + + // Link operations - uses DashboardLink from schema + ADD_DASHBOARD_LINK: DashboardLink; + REMOVE_DASHBOARD_LINK: { title?: string; index?: number }; + + // Panel link operations - uses DataLink from schema + ADD_PANEL_LINK: { elementName: string; link: DataLink }; + ADD_DATA_LINK: { elementName: string; link: DataLink }; + + // Field configuration - uses schema types + ADD_FIELD_OVERRIDE: { + elementName: string; + matcher: MatcherConfig; + properties: DynamicConfigValue[]; + }; + ADD_VALUE_MAPPING: { elementName: string; mapping: ValueMapping }; + ADD_TRANSFORMATION: { elementName: string; transformation: Omit & { id: string } }; + + // Dashboard settings + UPDATE_TIME_SETTINGS: UpdateTimeSettingsPayload; + UPDATE_DASHBOARD_META: UpdateDashboardMetaPayload; + + // Dashboard management (backend) + MOVE_TO_FOLDER: { folderUid?: string; folderTitle?: string }; + TOGGLE_FAVORITE: { favorite: boolean }; + + // Version management (backend) + LIST_VERSIONS: { limit?: number }; + COMPARE_VERSIONS: { baseVersion: number; newVersion: number }; + RESTORE_VERSION: { version: number }; + + // Read-only operations (no payload required) + GET_DASHBOARD_INFO: Record; +} + +// ============================================================================ +// Mutation Result +// ============================================================================ + +export interface MutationResult { + success: boolean; + /** Mutation to apply to undo this change */ + inverseMutation?: Mutation; + /** Changes that were applied */ + changes: MutationChange[]; + /** Error message if failed */ + error?: string; + /** Warnings (non-fatal issues) */ + warnings?: string[]; + /** Data returned by read-only operations (e.g., GET_DASHBOARD_INFO) */ + data?: unknown; +} + +export interface MutationChange { + path: string; + previousValue: unknown; + newValue: unknown; +} + +// ============================================================================ +// Transaction +// ============================================================================ + +export interface MutationTransaction { + id: string; + mutations: Mutation[]; + status: 'pending' | 'committed' | 'rolled_back'; + startedAt: number; + completedAt?: number; +} + +// ============================================================================ +// Event Types +// ============================================================================ + +export interface MutationEvent { + type: 'mutation_applied' | 'mutation_failed' | 'mutation_rolled_back'; + mutation: Mutation; + result: MutationResult; + transaction?: MutationTransaction; + timestamp: number; + source: 'assistant' | 'ui' | 'api'; +} + +// ============================================================================ +// MCP Tool Types +// ============================================================================ + +export interface MCPToolDefinition { + name: string; + description: string; + inputSchema: { + type: 'object'; + properties: Record; + required?: string[]; + }; + annotations?: { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + confirmationHint?: boolean; + }; +} + +export interface MCPResourceDefinition { + uri: string; + uriTemplate?: boolean; + name: string; + description: string; + mimeType: string; +} + +export interface MCPPromptDefinition { + name: string; + description: string; + arguments: Array<{ + name: string; + description: string; + required: boolean; + }>; +} diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 91adc3660a8..4dc62ac1667 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -2,7 +2,13 @@ import * as H from 'history'; import { CoreApp, DataQueryRequest, locationUtil, NavIndex, NavModelItem } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { config, locationService, RefreshEvent } from '@grafana/runtime'; +import { + config, + locationService, + RefreshEvent, + setDashboardMutationAPI, + type DashboardMutationAPI, +} from '@grafana/runtime'; import { sceneGraph, SceneObject, @@ -45,6 +51,9 @@ import { } from '../../apiserver/types'; import { DashboardEditPane } from '../edit-pane/DashboardEditPane'; import { dashboardEditActions } from '../edit-pane/shared'; +import { MutationExecutor } from '../mutation-api/MutationExecutor'; +import { DASHBOARD_MCP_TOOLS } from '../mutation-api/mcpTools'; +import type { Mutation } from '../mutation-api/types'; import { PanelEditor } from '../panel-edit/PanelEditor'; import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker'; import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer'; @@ -93,6 +102,11 @@ import { clearClipboard } from './layouts-shared/paste'; import { DashboardLayoutManager } from './types/DashboardLayoutManager'; import { LayoutParent } from './types/LayoutParent'; +// Type for window with mutation API (for cross-bundle access with plugins) +interface WindowWithMutationAPI extends Window { + __grafanaDashboardMutationAPI?: DashboardMutationAPI | null; +} + export const PERSISTED_PROPS = ['title', 'description', 'tags', 'editable', 'graphTooltip', 'links', 'meta', 'preload']; export const PANEL_SEARCH_VAR = 'systemPanelFilterVar'; export const PANELS_PER_ROW_VAR = 'systemDynamicRowSizeVar'; @@ -219,6 +233,9 @@ export class DashboardScene extends SceneObjectBase impleme window.__grafanaSceneContext = this; + // Register Dashboard Mutation API for Grafana Assistant and other tools + this._registerMutationAPI(); + this._initializePanelSearch(); if (this.state.isEditing) { @@ -247,6 +264,10 @@ export class DashboardScene extends SceneObjectBase impleme // Deactivation logic return () => { window.__grafanaSceneContext = prevSceneContext; + // Clear mutation API + setDashboardMutationAPI(null); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + (window as WindowWithMutationAPI).__grafanaDashboardMutationAPI = null; clearKeyBindings(); this._changeTracker.terminate(); oldDashboardWrapper.destroy(); @@ -254,6 +275,49 @@ export class DashboardScene extends SceneObjectBase impleme }; } + /** + * Register the Dashboard Mutation API for use by Grafana Assistant and other tools. + * This provides a stable interface for programmatic dashboard modifications. + * + * The API is exposed on window.__grafanaDashboardMutationAPI for cross-bundle access, + * since plugins use a different @grafana/runtime bundle. + */ + private _registerMutationAPI() { + const dashboard = this; + const executor = new MutationExecutor(); + executor.setScene(this); + + const api: DashboardMutationAPI = { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + execute: (mutation) => executor.executeMutation(mutation as Mutation), + canEdit: () => dashboard.canEditDashboard(), + getDashboardUID: () => dashboard.state.uid, + getDashboardTitle: () => dashboard.state.title, + isEditing: () => dashboard.state.isEditing ?? false, + enterEditMode: () => { + if (!dashboard.state.isEditing) { + dashboard.onEnterEditMode(); + } + }, + getTools: () => DASHBOARD_MCP_TOOLS, + getDashboardInfo: () => ({ + available: true, + uid: dashboard.state.uid, + title: dashboard.state.title, + canEdit: dashboard.canEditDashboard(), + isEditing: dashboard.state.isEditing ?? false, + availableTools: DASHBOARD_MCP_TOOLS.map((t) => t.name), + }), + }; + + // Register via @grafana/runtime for same-bundle access + setDashboardMutationAPI(api); + + // Also expose on window for cross-bundle access (plugins use different bundle) + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + (window as WindowWithMutationAPI).__grafanaDashboardMutationAPI = api; + } + private _initializePanelSearch() { const systemPanelFilter = sceneGraph.lookupVariable(PANEL_SEARCH_VAR, this)?.getValue(); if (typeof systemPanelFilter === 'string') {