From c47a449534a4b373fdfbf2e89dd1acce435863a4 Mon Sep 17 00:00:00 2001 From: Ivan Necas Date: Mon, 2 Mar 2026 14:09:03 +0100 Subject: [PATCH] mcpToolUI basic implementation Provides a hook to visualize an mcp-tool call with Perses elements. It also adds an ability to add the panel from the OLS chat into opened dashboards. --- config/perses-dashboards.patch.json | 13 ++++ web/package.json | 3 +- .../dashboards/perses/PersesWrapper.tsx | 12 +--- .../dashboards/perses/dashboard-app.tsx | 4 ++ .../perses/useExternalPanelAddition.ts | 67 +++++++++++++++++++ .../ols-tool-ui/AddToDashboardButton.tsx | 56 ++++++++++++++++ .../ols-tool-ui/ExecuteRangeQuery.tsx | 65 ++++++++++++++++++ .../ols-tool-ui/OlsToolUIPersesWrapper.tsx | 47 +++++++++++++ web/src/components/ols-tool-ui/index.ts | 1 + web/src/store/actions.ts | 13 ++++ web/src/store/reducers.ts | 15 +++++ web/src/store/store.ts | 3 + 12 files changed, 289 insertions(+), 10 deletions(-) create mode 100644 web/src/components/dashboards/perses/useExternalPanelAddition.ts create mode 100644 web/src/components/ols-tool-ui/AddToDashboardButton.tsx create mode 100644 web/src/components/ols-tool-ui/ExecuteRangeQuery.tsx create mode 100644 web/src/components/ols-tool-ui/OlsToolUIPersesWrapper.tsx create mode 100644 web/src/components/ols-tool-ui/index.ts diff --git a/config/perses-dashboards.patch.json b/config/perses-dashboards.patch.json index 2b90e7ac9..a93f9dacf 100644 --- a/config/perses-dashboards.patch.json +++ b/config/perses-dashboards.patch.json @@ -119,5 +119,18 @@ "component": { "$codeRef": "DashboardPage" } } } + }, + { + "op": "add", + "path": "/extensions/1", + "value": { + "type": "ols.tool-ui", + "properties": { + "id": "mcp-obs/execute-range-query", + "component": { + "$codeRef": "ols-tool-ui.ExecuteRangeQuery" + } + } + } } ] diff --git a/web/package.json b/web/package.json index f6906b0e5..7778f8cab 100644 --- a/web/package.json +++ b/web/package.json @@ -182,7 +182,8 @@ "TargetsPage": "./components/targets-page", "PrometheusRedirectPage": "./components/redirects/prometheus-redirect-page", "DevRedirects": "./components/redirects/dev-redirects", - "MonitoringContext": "./contexts/MonitoringContext" + "MonitoringContext": "./contexts/MonitoringContext", + "ols-tool-ui": "./components/ols-tool-ui" }, "dependencies": { "@console/pluginAPI": "*" diff --git a/web/src/components/dashboards/perses/PersesWrapper.tsx b/web/src/components/dashboards/perses/PersesWrapper.tsx index 47589122a..0491ea382 100644 --- a/web/src/components/dashboards/perses/PersesWrapper.tsx +++ b/web/src/components/dashboards/perses/PersesWrapper.tsx @@ -343,7 +343,6 @@ export function useRemotePluginLoader(): PluginLoader { export function PersesWrapper({ children, project }: PersesWrapperProps) { const { theme } = usePatternFlyTheme(); - const [dashboardName] = useQueryParam(QueryParams.Dashboard, StringParam); const muiTheme = getTheme(theme, { shape: { borderRadius: 6, @@ -371,13 +370,7 @@ export function PersesWrapper({ children, project }: PersesWrapperProps) { variant="default" > - {!project ? ( - <>{children} - ) : ( - - {children} - - )} + {!project ? <>{children} : {children}} @@ -385,7 +378,8 @@ export function PersesWrapper({ children, project }: PersesWrapperProps) { ); } -function InnerWrapper({ children, project, dashboardName }) { +function InnerWrapper({ children, project }) { + const [dashboardName] = useQueryParam(QueryParams.Dashboard, StringParam); const { data } = usePluginBuiltinVariableDefinitions(); const { persesDashboard, persesDashboardLoading } = useFetchPersesDashboard( project, diff --git a/web/src/components/dashboards/perses/dashboard-app.tsx b/web/src/components/dashboards/perses/dashboard-app.tsx index 62b9e4616..a7981ea2c 100644 --- a/web/src/components/dashboards/perses/dashboard-app.tsx +++ b/web/src/components/dashboards/perses/dashboard-app.tsx @@ -24,11 +24,13 @@ import { useDiscardChangesConfirmationDialog, useEditMode, } from '@perses-dev/dashboards'; + import { OCPDashboardToolbar } from './dashboard-toolbar'; import { useUpdateDashboardMutation } from './dashboard-api'; import { useTranslation } from 'react-i18next'; import { useToast } from './ToastProvider'; import { useSearchParams } from 'react-router-dom-v5-compat'; +import { useExternalPanelAddition } from './useExternalPanelAddition'; export interface DashboardAppProps { dashboardResource: DashboardResource | EphemeralDashboardResource; @@ -124,6 +126,8 @@ export const OCPDashboardApp = (props: DashboardAppProps): ReactElement => { } }; + useExternalPanelAddition({ isEditMode, onEditButtonClick }); + const updateDashboardMutation = useUpdateDashboardMutation(); const onSave = useCallback( diff --git a/web/src/components/dashboards/perses/useExternalPanelAddition.ts b/web/src/components/dashboards/perses/useExternalPanelAddition.ts new file mode 100644 index 000000000..715bda792 --- /dev/null +++ b/web/src/components/dashboards/perses/useExternalPanelAddition.ts @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useDashboardActions, useDashboardStore } from '@perses-dev/dashboards'; +import { dashboardsOpened, dashboardsPersesPanelExternallyAdded } from '../../../store/actions'; + +interface UseExternalPanelAdditionOptions { + isEditMode: boolean; + onEditButtonClick: () => void; +} + +export function useExternalPanelAddition({ + isEditMode, + onEditButtonClick, +}: UseExternalPanelAdditionOptions) { + const dispatch = useDispatch(); + const addPersesPanelExternally: any = useSelector( + (s: any) => s.plugins?.mp?.dashboards?.addPersesPanelExternally, + ); + const { openAddPanel } = useDashboardActions(); + const dashboardStore = useDashboardStore(); + const [externallyAddedPanel, setExternallyAddedPanel] = useState(null); + + const addPanelExternally = (panelDefinition: any): void => { + // Simulate opening a panel to add the pane so that we can use it to programatically + // add a panel to the dashboard from an external source (AI assistant). + if (!isEditMode) { + onEditButtonClick(); + } + openAddPanel(); + // Wrap the panelDefinition with the groupId structure + const change = { + groupId: 0, + panelDefinition, + }; + setExternallyAddedPanel(change); + }; + + useEffect(() => { + // Listen for external panel addition requests + if (addPersesPanelExternally) { + addPanelExternally(addPersesPanelExternally); + dispatch(dashboardsPersesPanelExternallyAdded()); + } + + // Apply externally added panel + if (externallyAddedPanel) { + const groupId = dashboardStore.panelGroupOrder[0]; + externallyAddedPanel.groupId = groupId; + + // Use the temporary panelEditor to add changes to the dashboard. + const panelEditor = dashboardStore.panelEditor; + panelEditor.applyChanges(externallyAddedPanel); + panelEditor.close(); + + // Clear the externally added panel after applying changes + setExternallyAddedPanel(null); + } + }, [externallyAddedPanel, addPersesPanelExternally]); + + // Advertise when custom dashboard is opened/closed + useEffect(() => { + dispatch(dashboardsOpened(true)); + return () => { + dispatch(dashboardsOpened(false)); + }; + }, [dispatch]); +} diff --git a/web/src/components/ols-tool-ui/AddToDashboardButton.tsx b/web/src/components/ols-tool-ui/AddToDashboardButton.tsx new file mode 100644 index 000000000..12f5b335e --- /dev/null +++ b/web/src/components/ols-tool-ui/AddToDashboardButton.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import type { PanelDefinition } from '@perses-dev/core'; +import { Button } from '@patternfly/react-core'; +import { dashboardsAddPersesPanelExternally } from '../../store/actions'; + +function createPanelDefinition(query: string): PanelDefinition { + return { + kind: 'Panel', + spec: { + display: { + name: '', + }, + plugin: { + kind: 'TimeSeriesChart', + spec: {}, + }, + queries: [ + { + kind: 'TimeSeriesQuery', + spec: { + plugin: { + kind: 'PrometheusTimeSeriesQuery', + spec: { + query: query, + }, + }, + }, + }, + ], + }, + }; +} + +type AddToDashboardButtonProps = { + query: string; +}; + +export const AddToDashboardButton: React.FC = ({ query }) => { + const dispatch = useDispatch(); + + const isCustomDashboardOpen: boolean = useSelector( + (s: any) => s.plugins?.mp?.dashboards?.isOpened, + ); + + const addToPersesDashboard = React.useCallback(() => { + const panelDefinition = createPanelDefinition(query); + dispatch(dashboardsAddPersesPanelExternally(panelDefinition)); + }, [query, dispatch]); + + if (!isCustomDashboardOpen) { + return null; + } + + return ; +}; diff --git a/web/src/components/ols-tool-ui/ExecuteRangeQuery.tsx b/web/src/components/ols-tool-ui/ExecuteRangeQuery.tsx new file mode 100644 index 000000000..eabd82f83 --- /dev/null +++ b/web/src/components/ols-tool-ui/ExecuteRangeQuery.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { DataQueriesProvider } from '@perses-dev/plugin-system'; +import type { DurationString } from '@perses-dev/prometheus-plugin'; +import { Panel } from '@perses-dev/dashboards'; + +import { OlsToolUIPersesWrapper } from './OlsToolUIPersesWrapper'; +import { AddToDashboardButton } from './AddToDashboardButton'; + +type ExecuteRangeQueryTool = { + name: 'execute_range_query'; + args: { + query: string; + }; +}; + +const persesTimeRange = { + pastDuration: '1h' as DurationString, +}; + +export const ExecuteRangeQuery: React.FC<{ tool: ExecuteRangeQueryTool }> = ({ tool }) => { + const query = tool.args.query; + const definitions = [ + { + kind: 'PrometheusTimeSeriesQuery', + spec: { + query: query, + }, + }, + ]; + + return ( + <> + + + + + + + + ); +}; + +export default ExecuteRangeQuery; diff --git a/web/src/components/ols-tool-ui/OlsToolUIPersesWrapper.tsx b/web/src/components/ols-tool-ui/OlsToolUIPersesWrapper.tsx new file mode 100644 index 000000000..eb4d2bd24 --- /dev/null +++ b/web/src/components/ols-tool-ui/OlsToolUIPersesWrapper.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { VariableProvider } from '@perses-dev/dashboards'; +import { TimeRangeProviderBasic } from '@perses-dev/plugin-system'; +import type { DurationString } from '@perses-dev/prometheus-plugin'; + +import { + PersesWrapper, + PersesPrometheusDatasourceWrapper, +} from '../dashboards/perses/PersesWrapper'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + }, + }, +}); + +interface OlsToolUIPersesWrapperProps { + children: React.ReactNode; + height?: string; + initialTimeRange?: { + pastDuration: DurationString; + }; +} + +export const OlsToolUIPersesWrapper: React.FC = ({ + children, + initialTimeRange = { pastDuration: '1h' as DurationString }, + height = '300px', +}) => { + return ( + + + + + +
{children}
+
+
+
+
+
+ ); +}; diff --git a/web/src/components/ols-tool-ui/index.ts b/web/src/components/ols-tool-ui/index.ts new file mode 100644 index 000000000..eae5b93f4 --- /dev/null +++ b/web/src/components/ols-tool-ui/index.ts @@ -0,0 +1 @@ +export { ExecuteRangeQuery } from './ExecuteRangeQuery'; diff --git a/web/src/store/actions.ts b/web/src/store/actions.ts index ef45c45cf..2169b1193 100644 --- a/web/src/store/actions.ts +++ b/web/src/store/actions.ts @@ -1,3 +1,4 @@ +import type { PanelDefinition } from '@perses-dev/core'; import { action, ActionType as Action } from 'typesafe-actions'; import { Alert, Rule, Silence } from '@openshift-console/dynamic-plugin-sdk'; @@ -17,6 +18,9 @@ export enum ActionType { DashboardsSetPollInterval = 'v2/dashboardsSetPollInterval', DashboardsSetTimespan = 'v2/dashboardsSetTimespan', DashboardsVariableOptionsLoaded = 'v2/dashboardsVariableOptionsLoaded', + DashboardsOpened = 'dashboardsPersesDashboardsOpened', + DashboardsAddPersesPanelExternally = 'dashboardsAddPersesPanelExternally', + DashboardsPersesPanelExternallyAdded = 'dashboardsPersesPanelExternallyAdded', QueryBrowserAddQuery = 'queryBrowserAddQuery', QueryBrowserDuplicateQuery = 'queryBrowserDuplicateQuery', QueryBrowserDeleteAllQueries = 'queryBrowserDeleteAllQueries', @@ -68,6 +72,15 @@ export const dashboardsSetTimespan = (timespan: number) => export const dashboardsVariableOptionsLoaded = (key: string, newOptions: string[]) => action(ActionType.DashboardsVariableOptionsLoaded, { key, newOptions }); +export const dashboardsOpened = (isOpened: boolean) => + action(ActionType.DashboardsOpened, { isOpened }); + +export const dashboardsPersesPanelExternallyAdded = () => + action(ActionType.DashboardsPersesPanelExternallyAdded, {}); + +export const dashboardsAddPersesPanelExternally = (panelDefinition: PanelDefinition) => + action(ActionType.DashboardsAddPersesPanelExternally, { panelDefinition }); + export const alertingSetLoading = (datasource: string, identifier: string) => action(ActionType.AlertingSetLoading, { datasource, diff --git a/web/src/store/reducers.ts b/web/src/store/reducers.ts index 852fd2dbe..fe9b7588e 100644 --- a/web/src/store/reducers.ts +++ b/web/src/store/reducers.ts @@ -81,6 +81,21 @@ const monitoringReducer = produce((draft: ObserveState, action: ObserveAction): break; } + case ActionType.DashboardsOpened: { + draft.dashboards.isOpened = action.payload.isOpened; + break; + } + + case ActionType.DashboardsAddPersesPanelExternally: { + draft.dashboards.addPersesPanelExternally = action.payload.panelDefinition; + break; + } + + case ActionType.DashboardsPersesPanelExternallyAdded: { + draft.dashboards.addPersesPanelExternally = null; + break; + } + case ActionType.AlertingSetRulesLoaded: { const { datasource, identifier, rules, alerts } = action.payload; diff --git a/web/src/store/store.ts b/web/src/store/store.ts index 9dfd6040e..974a1f4d0 100644 --- a/web/src/store/store.ts +++ b/web/src/store/store.ts @@ -1,5 +1,6 @@ import * as _ from 'lodash-es'; +import type { PanelDefinition } from '@perses-dev/core'; import { MONITORING_DASHBOARDS_DEFAULT_TIMESPAN } from '../components/dashboards/legacy/utils'; import { Alert, PrometheusLabels, Rule } from '@openshift-console/dynamic-plugin-sdk'; import { Silences } from '../components/types'; @@ -27,6 +28,8 @@ export type ObserveState = { pollInterval: number; timespan: number; variables: Record; + isOpened: boolean; + addPersesPanelExternally: PanelDefinition; }; incidentsData: { incidents: Array;