diff --git a/ui/src/api/queries/commands.ts b/ui/src/api/queries/commands.ts new file mode 100644 index 00000000..08fdb93b --- /dev/null +++ b/ui/src/api/queries/commands.ts @@ -0,0 +1,21 @@ +import { useMutation } from '@tanstack/react-query' +import { api } from '../client' + +interface SendGroupCommandParams { + group: string + type: string + payload: Record +} + +export function useSendGroupCommand() { + return useMutation({ + mutationFn: async ({ group, type, payload }: SendGroupCommandParams) => { + const { data, error } = await api.POST('/agents/groups/{group}/commands', { + params: { path: { group } }, + body: { type, payload } as any, + }) + if (error) throw new Error('Failed to send command') + return data! + }, + }) +} diff --git a/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx b/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx index 274004d1..3ef7c3b2 100644 --- a/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx +++ b/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx @@ -1,14 +1,16 @@ -import { useState, useMemo } from 'react' +import { useState, useMemo, useCallback } from 'react' import { useParams, useNavigate } from 'react-router' import { Badge, StatusDot, MonoText, CodeBlock, InfoCallout, - ProcessorTimeline, Breadcrumb, Spinner, RouteFlow, + ProcessorTimeline, Breadcrumb, Spinner, RouteFlow, useToast, } from '@cameleer/design-system' import type { ProcessorStep, RouteNode } from '@cameleer/design-system' import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions' import { useCorrelationChain } from '../../api/queries/correlation' import { useDiagramLayout } from '../../api/queries/diagrams' import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping' +import { useTracingStore } from '../../stores/tracing-store' +import { useSendGroupCommand } from '../../api/queries/commands' import styles from './ExchangeDetail.module.css' // ── Helpers ────────────────────────────────────────────────────────────────── @@ -130,6 +132,59 @@ export default function ExchangeDetail() { })) }, [diagram, processors, procList]) + // ProcessorId lookup: timeline index → processorId + const processorIds: string[] = useMemo(() => { + const ids: string[] = [] + function walk(node: any) { + ids.push(node.processorId || '') + if (node.children) node.children.forEach(walk) + } + procList.forEach(walk) + return ids + }, [procList]) + + // ProcessorId lookup: flow node index → processorId (diagram order) + const flowProcessorIds: string[] = useMemo(() => { + if (!diagram?.nodes) return processorIds + const flatProcs: Array<{ diagramNodeId?: string; processorId?: string }> = [] + function flatten(nodes: any[]) { + for (const n of nodes) { + flatProcs.push(n) + if (n.children) flatten(n.children) + } + } + flatten(procList) + const lookup = new Map(flatProcs + .filter(p => p.diagramNodeId && p.processorId) + .map(p => [p.diagramNodeId!, p.processorId!])) + return diagram.nodes.map(node => lookup.get(node.id ?? '') ?? '') + }, [diagram, procList, processorIds]) + + // ── Tracing toggle ────────────────────────────────────────────────────── + const { toast } = useToast() + const tracingStore = useTracingStore() + const sendCommand = useSendGroupCommand() + const appRoute = detail ? `${detail.applicationName}:${detail.routeId}` : '' + + const handleToggleTracing = useCallback((processorId: string) => { + if (!processorId || !detail?.applicationName || !detail?.routeId) return + const newSet = tracingStore.toggleProcessor(appRoute, processorId) + sendCommand.mutate({ + group: detail.applicationName, + type: 'set-traced-processors', + payload: { routeId: detail.routeId, processorIds: Array.from(newSet) }, + }, { + onSuccess: (data) => { + const action = newSet.has(processorId) ? 'enabled' : 'disabled' + toast({ title: `Tracing ${action}`, description: `${processorId} — sent to ${data?.targetCount ?? 0} agent(s)`, variant: 'success' }) + }, + onError: () => { + tracingStore.toggleProcessor(appRoute, processorId) + toast({ title: 'Command failed', description: 'Could not send tracing command', variant: 'error' }) + }, + }) + }, [detail, appRoute, tracingStore, sendCommand, toast]) + // Correlation chain const correlatedExchanges = useMemo(() => { if (!correlationData?.data || correlationData.data.length <= 1) return [] @@ -287,6 +342,15 @@ export default function ExchangeDetail() { totalMs={detail.durationMs} onProcessorClick={(_proc, index) => setSelectedProcessorIndex(index)} selectedIndex={activeIndex} + getActions={(_proc, index) => { + const pid = processorIds[index] + if (!pid || !detail?.applicationName) return [] + return [{ + label: tracingStore.isTraced(appRoute, pid) ? 'Disable Tracing' : 'Enable Tracing', + onClick: () => handleToggleTracing(pid), + disabled: sendCommand.isPending, + }] + }} /> ) : ( No processor data available @@ -297,6 +361,15 @@ export default function ExchangeDetail() { nodes={routeNodes} onNodeClick={(_node, index) => setSelectedProcessorIndex(index)} selectedIndex={activeIndex} + getActions={(_node, index) => { + const pid = flowProcessorIds[index] + if (!pid || !detail?.applicationName) return [] + return [{ + label: tracingStore.isTraced(appRoute, pid) ? 'Disable Tracing' : 'Enable Tracing', + onClick: () => handleToggleTracing(pid), + disabled: sendCommand.isPending, + }] + }} /> ) : ( diff --git a/ui/src/stores/tracing-store.ts b/ui/src/stores/tracing-store.ts new file mode 100644 index 00000000..d0791bbe --- /dev/null +++ b/ui/src/stores/tracing-store.ts @@ -0,0 +1,24 @@ +import { create } from 'zustand' + +interface TracingState { + tracedProcessors: Record> + isTraced: (appRoute: string, processorId: string) => boolean + toggleProcessor: (appRoute: string, processorId: string) => Set +} + +export const useTracingStore = create((set, get) => ({ + tracedProcessors: {}, + + isTraced: (appRoute, processorId) => + get().tracedProcessors[appRoute]?.has(processorId) ?? false, + + toggleProcessor: (appRoute, processorId) => { + const current = new Set(get().tracedProcessors[appRoute] ?? []) + if (current.has(processorId)) current.delete(processorId) + else current.add(processorId) + set((state) => ({ + tracedProcessors: { ...state.tracedProcessors, [appRoute]: current }, + })) + return current + }, +}))