From 29c5cb7b1cdf4e0f237b951352b79afd25585537 Mon Sep 17 00:00:00 2001 From: Aleksandar Petrov <8142643+aleks-p@users.noreply.github.com> Date: Tue, 2 Dec 2025 17:17:42 -0400 Subject: [PATCH] Add component to be rendered by the assistant --- .../features/explore-map/ExploreMapPage.tsx | 49 +++++- .../AssistantComponents/AddPanelAction.tsx | 140 ++++++++++++++++++ .../DebugAssistantContext.tsx | 31 ++++ .../components/AssistantComponents/index.ts | 5 + .../components/ExploreMapFloatingToolbar.tsx | 65 +++++++- public/app/features/explore-map/crdt/state.ts | 21 +-- public/app/features/explore-map/crdt/types.ts | 1 + .../features/explore-map/state/crdtSlice.ts | 28 +++- 8 files changed, 325 insertions(+), 15 deletions(-) create mode 100644 public/app/features/explore-map/components/AssistantComponents/AddPanelAction.tsx create mode 100644 public/app/features/explore-map/components/AssistantComponents/DebugAssistantContext.tsx create mode 100644 public/app/features/explore-map/components/AssistantComponents/index.ts diff --git a/public/app/features/explore-map/ExploreMapPage.tsx b/public/app/features/explore-map/ExploreMapPage.tsx index 868ae9f3e2f..ad89d488e48 100644 --- a/public/app/features/explore-map/ExploreMapPage.tsx +++ b/public/app/features/explore-map/ExploreMapPage.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useParams } from 'react-router-dom-v5-compat'; import { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch'; +import { providePageContext, createAssistantContextItem } from '@grafana/assistant'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans } from '@grafana/i18n'; import { ErrorBoundaryAlert, useStyles2 } from '@grafana/ui'; @@ -10,6 +11,7 @@ import { useGrafana } from 'app/core/context/GrafanaContext'; import { useNavModel } from 'app/core/hooks/useNavModel'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { AddPanelAction, DebugAssistantContext } from './components/AssistantComponents'; import { ExploreMapCanvas } from './components/ExploreMapCanvas'; import { ExploreMapFloatingToolbar } from './components/ExploreMapFloatingToolbar'; import { ExploreMapToolbar } from './components/ExploreMapToolbar'; @@ -17,6 +19,48 @@ import { TransformProvider } from './context/TransformContext'; import { useCanvasPersistence } from './hooks/useCanvasPersistence'; import { useRealtimeSync } from './realtime/useRealtimeSync'; +// Register custom components for the Grafana Assistant using providePageContext +// This ensures the component context is properly sent to the assistant +providePageContext(/.*/, [ + createAssistantContextItem('component', { + components: { + AddPanelAction, + }, + namespace: 'exploreMap', + prompt: `You have access to an interactive component that helps users add panels to the explore map canvas with pre-configured datasources and queries. + +Component name: exploreMap_AddPanelAction + +Whitelisted props: +- type: string - MUST ALWAYS BE "explore" (only explore panels are supported currently) +- description: string (optional custom button label) +- name: string (optional display name) +- namespace: string (optional datasource UID - use this to specify which datasource the panel should use) +- metric: string (optional query expression - PromQL for metrics, LogQL for logs, TraceQL for traces, etc.) + IMPORTANT: Query must be URL-encoded to handle special characters like parentheses, quotes, braces, etc. + +Usage examples (place directly in response, NEVER in code blocks): + + + + + + +CRITICAL RULES: +- Components must NEVER be wrapped in code blocks (no backticks or \`\`\`). +- **ALWAYS set type="explore"** - this is the only supported panel type currently. +- Use namespace prop to specify datasource UID when you know which datasource to use. +- Use metric prop to provide initial query expressions. +- **ALWAYS URL-encode the metric prop value** to handle special characters: ( ) [ ] { } " ' = < > etc. + Examples: + - '{job="varlogs"}' becomes '%7Bjob%3D%22varlogs%22%7D' + - 'rate(http_requests[5m])' becomes 'rate%28http_requests%5B5m%5D%29' +- Place components directly in your response text, not in code blocks. +- When users ask to add panels with specific queries, provide the component with namespace and URL-encoded metric props. +- You can provide multiple components in a single response for adding multiple panels.`, + }), +]); + export default function ExploreMapPage(props: GrafanaRouteComponentProps<{ uid?: string }>) { const styles = useStyles2(getStyles); const { chrome } = useGrafana(); @@ -58,7 +102,9 @@ export default function ExploreMapPage(props: GrafanaRouteComponentProps<{ uid?: if (loading) { return (
-

Loading explore map...

+

+ Loading explore map... +

); } @@ -67,6 +113,7 @@ export default function ExploreMapPage(props: GrafanaRouteComponentProps<{ uid?:
+

Explore Map

diff --git a/public/app/features/explore-map/components/AssistantComponents/AddPanelAction.tsx b/public/app/features/explore-map/components/AssistantComponents/AddPanelAction.tsx new file mode 100644 index 00000000000..63a4c663277 --- /dev/null +++ b/public/app/features/explore-map/components/AssistantComponents/AddPanelAction.tsx @@ -0,0 +1,140 @@ +import { css } from '@emotion/css'; +import React, { useCallback } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, useStyles2 } from '@grafana/ui'; +import { contextSrv } from 'app/core/services/context_srv'; +import { useDispatch } from 'app/types/store'; + +import { addPanel } from '../../state/crdtSlice'; + +/** + * Whitelisted props that the Grafana Assistant allows for custom components. + * We creatively map these to our needs: + * - type: Panel type ('explore', 'metrics', 'logs', 'traces', 'profiles') + * - description: Custom button label (optional) + * - name: Display name (optional) + * - namespace: Datasource UID + * - metric: Query expression (PromQL, LogQL, etc.) + */ +interface AddPanelActionProps { + name?: string; // Display name + type?: string; // Panel type: 'explore' | 'metrics' | 'logs' | 'traces' | 'profiles' + description?: string; // Custom button label + properties?: string; // Reserved + env?: string; // Reserved + site?: string; // Reserved + namespace?: string; // Datasource UID + metric?: string; // Query expression + value?: string; // Reserved + unit?: string; // Reserved + status?: string; // Reserved +} + +/** + * Custom component that the Grafana Assistant can use to add panels to the explore map canvas. + * The assistant can render this component in its responses to provide interactive "Add Panel" buttons. + * + * Uses whitelisted props only: + * - type: Panel type ('explore', 'metrics', 'logs', 'traces', 'profiles') + * - description: Custom button label (optional) + * - name: Display name (optional) + * - namespace: Datasource UID (optional) + * - metric: Query expression (optional) + */ +export const AddPanelAction: React.FC = ({ + type = 'explore', + description, + name, + namespace, // datasourceUid + metric, // query expression +}) => { + const styles = useStyles2(getStyles); + const dispatch = useDispatch(); + const currentUsername = contextSrv.user.name || contextSrv.user.login || 'Unknown'; + + // Map type to the internal mode format + const getMode = (): 'explore' | 'traces-drilldown' | 'metrics-drilldown' | 'profiles-drilldown' | 'logs-drilldown' => { + switch (type?.toLowerCase()) { + case 'traces': + return 'traces-drilldown'; + case 'metrics': + return 'metrics-drilldown'; + case 'profiles': + return 'profiles-drilldown'; + case 'logs': + return 'logs-drilldown'; + default: + return 'explore'; + } + }; + + const handleAddPanel = useCallback(() => { + // Decode the metric if it's URL-encoded + const decodedQuery = metric ? decodeURIComponent(metric) : undefined; + const mode = getMode(); + + dispatch( + addPanel({ + viewportSize: { + width: window.innerWidth, + height: window.innerHeight, + }, + kind: mode, + createdBy: currentUsername, + datasourceUid: namespace, // Use namespace prop for datasource UID + query: decodedQuery, // Decode URL-encoded query expression + }) + ); + }, [dispatch, currentUsername, type, namespace, metric, getMode]); + + const getButtonLabel = () => { + if (description) { + return description; + } + if (name) { + return name; + } + switch (getMode()) { + case 'traces-drilldown': + return 'Add Traces Panel'; + case 'metrics-drilldown': + return 'Add Metrics Panel'; + case 'profiles-drilldown': + return 'Add Profiles Panel'; + case 'logs-drilldown': + return 'Add Logs Panel'; + default: + return 'Add Explore Panel'; + } + }; + + const getButtonIcon = () => { + switch (getMode()) { + case 'traces-drilldown': + case 'metrics-drilldown': + case 'profiles-drilldown': + case 'logs-drilldown': + return 'plus'; + default: + return 'compass'; + } + }; + + return ( +
+ +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css({ + display: 'inline-block', + margin: theme.spacing(1, 0), + }), + }; +}; diff --git a/public/app/features/explore-map/components/AssistantComponents/DebugAssistantContext.tsx b/public/app/features/explore-map/components/AssistantComponents/DebugAssistantContext.tsx new file mode 100644 index 00000000000..4d2256ee6d0 --- /dev/null +++ b/public/app/features/explore-map/components/AssistantComponents/DebugAssistantContext.tsx @@ -0,0 +1,31 @@ +import React, { useEffect } from 'react'; + +import { usePageComponents, usePageContext } from '@grafana/assistant'; + +/** + * Debug component to verify assistant context and component registration. + * Add this temporarily to ExploreMapPage to see what's registered. + */ +export const DebugAssistantContext: React.FC = () => { + const pageComponents = usePageComponents(); + const pageContext = usePageContext(); + + useEffect(() => { + // eslint-disable-next-line no-console + console.group('🔍 Assistant Debug Info'); + // eslint-disable-next-line no-console + console.log('Current URL:', window.location.pathname); + // eslint-disable-next-line no-console + console.log('Registered Components:', Object.keys(pageComponents)); + // eslint-disable-next-line no-console + console.log('Component Details:', pageComponents); + // eslint-disable-next-line no-console + console.log('Page Context Items:', pageContext.length); + // eslint-disable-next-line no-console + console.log('Page Context:', pageContext); + // eslint-disable-next-line no-console + console.groupEnd(); + }, [pageComponents, pageContext]); + + return null; +}; diff --git a/public/app/features/explore-map/components/AssistantComponents/index.ts b/public/app/features/explore-map/components/AssistantComponents/index.ts new file mode 100644 index 00000000000..0bd9daacdd0 --- /dev/null +++ b/public/app/features/explore-map/components/AssistantComponents/index.ts @@ -0,0 +1,5 @@ +// Re-export components (eslint exception for assistant components barrel file) +// eslint-disable-next-line no-barrel-files/no-barrel-files +export { AddPanelAction } from './AddPanelAction'; +// eslint-disable-next-line no-barrel-files/no-barrel-files +export { DebugAssistantContext } from './DebugAssistantContext'; diff --git a/public/app/features/explore-map/components/ExploreMapFloatingToolbar.tsx b/public/app/features/explore-map/components/ExploreMapFloatingToolbar.tsx index 322e99071ff..5757efec3b7 100644 --- a/public/app/features/explore-map/components/ExploreMapFloatingToolbar.tsx +++ b/public/app/features/explore-map/components/ExploreMapFloatingToolbar.tsx @@ -15,6 +15,7 @@ import { useDispatch, useSelector } from 'app/types/store'; import { addPanel } from '../state/crdtSlice'; import { selectPanels, selectMapUid } from '../state/selectors'; +import { AddPanelAction } from './AssistantComponents'; export function ExploreMapFloatingToolbar() { const styles = useStyles2(getStyles); @@ -103,7 +104,7 @@ export function ExploreMapFloatingToolbar() { const panelsArray = Object.values(panels); return createAssistantContextItem('structured', { - title: 'Explore Canvas', + title: t('explore-map.assistant.canvas-title', 'Explore Canvas'), data: { canvasId: mapUid, panelCount: panelsArray.length, @@ -121,6 +122,59 @@ export function ExploreMapFloatingToolbar() { }); }, [panels, mapUid]); + // Provide component context and additional instructions to the assistant + const componentContext = useMemo(() => { + return createAssistantContextItem('component', { + components: { + AddPanelAction, + }, + namespace: 'exploreMap', + hidden: false, // Make visible so we can debug + prompt: `IMPORTANT: You have an interactive component that can add pre-configured panels. + +Component: exploreMap_AddPanelAction + +Whitelisted props (ONLY these are allowed): +- type: MUST ALWAYS BE "explore" (only explore panels supported) +- namespace: datasource UID (optional - use to specify which datasource) +- metric: query expression (optional - PromQL, LogQL, TraceQL, etc.) - MUST BE URL-ENCODED +- description: custom button text (optional) +- name: display name (optional) + +Usage (place directly in response, NEVER in code blocks): + + + + + +CRITICAL: +- **ALWAYS use type="explore"** - no other types are supported. +- Never wrap components in backticks or code blocks. +- Always URL-encode the metric prop to handle special characters like ( ) [ ] { } " ' etc.`, + }); + }, []); + + // Provide additional instructions to the assistant (hidden from UI) + const assistantInstructions = useMemo(() => { + return createAssistantContextItem('structured', { + hidden: true, + title: t('explore-map.assistant.capabilities-title', 'Explore Map Capabilities'), + data: { + capabilities: [ + t('explore-map.assistant.capability-add-panels', 'You can help users add new panels to the canvas using the exploreMap_AddPanelAction component'), + t('explore-map.assistant.capability-analyze', 'Analyze the current panels and suggest additional panels that would complement the existing ones'), + t('explore-map.assistant.capability-gaps', 'Identify gaps in observability coverage (missing logs, traces, metrics, or profiles)'), + t('explore-map.assistant.capability-layouts', 'Suggest panel layouts or organization strategies'), + t('explore-map.assistant.capability-relationships', 'Help users understand relationships between panels based on their queries and datasources'), + ], + instructions: t( + 'explore-map.assistant.instructions', + 'When users ask to add panels, use the exploreMap_AddPanelAction component to provide interactive buttons. You can suggest multiple panels at once by providing multiple component instances. Always explain why you are suggesting specific panel types based on the current canvas state.' + ), + }, + }); + }, []); + const handleOpenAssistant = useCallback(() => { if (!openAssistant) { return; @@ -134,11 +188,14 @@ export function ExploreMapFloatingToolbar() { openAssistant({ origin: 'grafana/explore-map', mode: 'assistant', - prompt: 'Analyze this explore canvas and summarize what queries and data are being visualized. Identify any patterns or relationships between the panels.', - context: [canvasContext], + prompt: + 'Analyze this explore canvas and summarize what queries and data are being visualized. ' + + 'Identify any patterns or relationships between the panels. ' + + 'If you notice gaps in observability coverage, suggest additional panels I could add using the interactive component.', + context: [canvasContext, componentContext, assistantInstructions], autoSend: true, }); - }, [openAssistant, panels, mapUid, canvasContext]); + }, [openAssistant, panels, mapUid, canvasContext, componentContext, assistantInstructions]); const MenuActions = () => ( diff --git a/public/app/features/explore-map/crdt/state.ts b/public/app/features/explore-map/crdt/state.ts index b1f3cecc3f6..a444923bc86 100644 --- a/public/app/features/explore-map/crdt/state.ts +++ b/public/app/features/explore-map/crdt/state.ts @@ -7,7 +7,7 @@ import { v4 as uuidv4 } from 'uuid'; -import { SerializedExploreState } from '../state/types'; +import { ExploreMapPanel, SerializedExploreState } from '../state/types'; import { HybridLogicalClock } from './hlc'; import { LWWRegister, createLWWRegister } from './lwwregister'; @@ -30,6 +30,7 @@ import { OperationResult, CRDTExploreMapStateJSON, CommentData, + BatchOperation, } from './types'; export class CRDTStateManager { @@ -129,8 +130,8 @@ export class CRDTStateManager { /** * Get all panels for UI rendering */ - getAllPanelsForUI() { - const panels: Record = {}; + getAllPanelsForUI(): Record { + const panels: Record = {}; for (const panelId of this.getPanelIds()) { const panel = this.getPanelForUI(panelId); if (panel) { @@ -148,7 +149,8 @@ export class CRDTStateManager { exploreId: string, position: { x: number; y: number; width: number; height: number }, mode: 'explore' | 'traces-drilldown' | 'metrics-drilldown' | 'profiles-drilldown' | 'logs-drilldown' = 'explore', - createdBy?: string + createdBy?: string, + initialExploreState?: SerializedExploreState ): AddPanelOperation { const timestamp = this.clock.tick(); return { @@ -163,6 +165,7 @@ export class CRDTStateManager { position, mode, createdBy, + initialExploreState, }, }; } @@ -441,7 +444,7 @@ export class CRDTStateManager { return { success: false, applied: false, - error: `Unknown operation type: ${(operation as any).type}`, + error: `Unknown operation type: ${(operation as CRDTOperation).type}`, }; } } catch (error) { @@ -454,7 +457,7 @@ export class CRDTStateManager { } private applyAddPanel(operation: AddPanelOperation): OperationResult { - const { panelId, exploreId, position, mode, createdBy } = operation.payload; + const { panelId, exploreId, position, mode, createdBy, initialExploreState } = operation.payload; const panelMode = mode || 'explore'; // Add to OR-Set with operation ID as tag @@ -472,7 +475,7 @@ export class CRDTStateManager { width: new LWWRegister(position.width, operation.timestamp), height: new LWWRegister(position.height, operation.timestamp), zIndex: new LWWRegister(zIndex, operation.timestamp), - exploreState: new LWWRegister(undefined, operation.timestamp), + exploreState: new LWWRegister(initialExploreState, operation.timestamp), mode: new LWWRegister(panelMode, operation.timestamp), iframeUrl: new LWWRegister(undefined, operation.timestamp), createdBy: new LWWRegister(createdBy, operation.timestamp), @@ -643,7 +646,7 @@ export class CRDTStateManager { }; } - private applyBatchOperation(operation: any): OperationResult { + private applyBatchOperation(operation: BatchOperation): OperationResult { let anyApplied = false; const errors: string[] = []; @@ -726,7 +729,7 @@ export class CRDTStateManager { * Serialize state to JSON */ toJSON(): CRDTExploreMapStateJSON { - const panelData: Record = {}; + const panelData: CRDTExploreMapStateJSON['panelData'] = {}; for (const [panelId, data] of this.state.panelData.entries()) { panelData[panelId] = { diff --git a/public/app/features/explore-map/crdt/types.ts b/public/app/features/explore-map/crdt/types.ts index 0a788a5d143..c5f0b2b9c47 100644 --- a/public/app/features/explore-map/crdt/types.ts +++ b/public/app/features/explore-map/crdt/types.ts @@ -172,6 +172,7 @@ export interface AddPanelOperation extends CRDTOperationBase { }; mode?: 'explore' | 'traces-drilldown' | 'metrics-drilldown' | 'profiles-drilldown' | 'logs-drilldown'; createdBy?: string; + initialExploreState?: SerializedExploreState; // Optional initial datasource/query configuration }; } diff --git a/public/app/features/explore-map/state/crdtSlice.ts b/public/app/features/explore-map/state/crdtSlice.ts index 0e3638711df..f4c559d2b5f 100644 --- a/public/app/features/explore-map/state/crdtSlice.ts +++ b/public/app/features/explore-map/state/crdtSlice.ts @@ -8,6 +8,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { v4 as uuidv4 } from 'uuid'; +import { dateTime, DataQuery } from '@grafana/data'; import { generateExploreId } from 'app/core/utils/explore'; import { CRDTStateManager } from '../crdt/state'; @@ -218,6 +219,8 @@ const crdtSlice = createSlice({ position?: { x: number; y: number; width: number; height: number }; kind?: 'explore' | 'traces-drilldown' | 'metrics-drilldown' | 'profiles-drilldown' | 'logs-drilldown'; createdBy?: string; + datasourceUid?: string; + query?: string; }>) => { const manager = getCRDTManager(state); @@ -254,7 +257,30 @@ const crdtSlice = createSlice({ const panelId = uuidv4(); const exploreId = generateExploreId(); - const operation = manager.createAddPanelOperation(panelId, exploreId, position, mode, action.payload.createdBy); + // Build initial explore state if datasource/query provided + let initialExploreState: SerializedExploreState | undefined; + if (action.payload.datasourceUid || action.payload.query) { + initialExploreState = { + queries: action.payload.query + ? [ + { + refId: 'A', + datasource: { uid: action.payload.datasourceUid, type: 'prometheus' }, + // Using index signature to add query-specific field + ...{ expr: action.payload.query }, + } as DataQuery, + ] + : [], + datasourceUid: action.payload.datasourceUid, + range: { + from: dateTime('now-1h'), + to: dateTime('now'), + raw: { from: 'now-1h', to: 'now' }, + }, + }; + } + + const operation = manager.createAddPanelOperation(panelId, exploreId, position, mode, action.payload.createdBy, initialExploreState); // Apply locally manager.applyOperation(operation);