diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentCommandController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentCommandController.java index 41186e0b..a1e5e9ea 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentCommandController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentCommandController.java @@ -191,8 +191,9 @@ public class AgentCommandController { case "replay" -> CommandType.REPLAY; case "set-traced-processors" -> CommandType.SET_TRACED_PROCESSORS; case "test-expression" -> CommandType.TEST_EXPRESSION; + case "route-control" -> CommandType.ROUTE_CONTROL; default -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST, - "Invalid command type: " + typeStr + ". Valid: config-update, deep-trace, replay, set-traced-processors, test-expression"); + "Invalid command type: " + typeStr + ". Valid: config-update, deep-trace, replay, set-traced-processors, test-expression, route-control"); }; } } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/CommandType.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/CommandType.java index 0e0000d8..02baff74 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/CommandType.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/CommandType.java @@ -8,5 +8,6 @@ public enum CommandType { DEEP_TRACE, REPLAY, SET_TRACED_PROCESSORS, - TEST_EXPRESSION + TEST_EXPRESSION, + ROUTE_CONTROL } diff --git a/ui/src/api/queries/commands.ts b/ui/src/api/queries/commands.ts index e1cdde30..9ce502ad 100644 --- a/ui/src/api/queries/commands.ts +++ b/ui/src/api/queries/commands.ts @@ -154,22 +154,53 @@ export function useTestExpression() { }) } +// ── Route Control ──────────────────────────────────────────────────────── + +export function useSendRouteCommand() { + return useMutation({ + mutationFn: async ({ application, action, routeId }: { + application: string + action: 'start' | 'stop' | 'suspend' | 'resume' + routeId: string + }) => { + const { data, error } = await api.POST('/agents/groups/{group}/commands', { + params: { path: { group: application } }, + body: { type: 'route-control', payload: { routeId, action, nonce: crypto.randomUUID() } } as any, + }) + if (error) throw new Error('Failed to send route command') + return data! + }, + }) +} + // ── Replay Exchange ─────────────────────────────────────────────────────── export function useReplayExchange() { return useMutation({ mutationFn: async ({ agentId, + routeId, headers, body, + originalExchangeId, }: { agentId: string - headers: Record + routeId: string + headers?: Record body: string + originalExchangeId?: string }) => { const { data, error } = await api.POST('/agents/{id}/commands', { params: { path: { id: agentId } }, - body: { type: 'replay', payload: { headers, body } } as any, + body: { + type: 'replay', + payload: { + routeId, + exchange: { body, headers: headers ?? {} }, + originalExchangeId, + nonce: crypto.randomUUID(), + }, + } as any, }) if (error) throw new Error('Failed to send replay command') return data! diff --git a/ui/src/pages/Exchanges/ExchangeHeader.tsx b/ui/src/pages/Exchanges/ExchangeHeader.tsx index 746745ff..9d13fda9 100644 --- a/ui/src/pages/Exchanges/ExchangeHeader.tsx +++ b/ui/src/pages/Exchanges/ExchangeHeader.tsx @@ -4,8 +4,10 @@ import { GitBranch, Server } from 'lucide-react'; import { StatusDot, MonoText, Badge } from '@cameleer/design-system'; import { useCorrelationChain } from '../../api/queries/correlation'; import { useAgents } from '../../api/queries/agents'; +import { useAuthStore } from '../../auth/auth-store'; import type { ExecutionDetail } from '../../components/ExecutionDiagram/types'; import { attributeBadgeColor } from '../../utils/attribute-color'; +import { RouteControlBar } from './RouteControlBar'; import styles from './ExchangeHeader.module.css'; interface ExchangeHeaderProps { @@ -47,14 +49,22 @@ export function ExchangeHeader({ detail, onCorrelatedSelect, onClearSelection }: const showChain = chain && chain.length > 1; const attrs = Object.entries(detail.attributes ?? {}); - // Look up agent state for icon coloring + // Look up agent state for icon coloring + route control capability const { data: agents } = useAgents(undefined, detail.applicationName); - const agentState = useMemo(() => { - if (!agents || !detail.agentId) return undefined; - const agent = (agents as any[]).find((a: any) => a.id === detail.agentId); - return agent?.state?.toLowerCase() as 'live' | 'stale' | 'dead' | undefined; + const { agentState, hasRouteControl, hasReplay } = useMemo(() => { + if (!agents) return { agentState: undefined, hasRouteControl: false, hasReplay: false }; + const agentList = agents as any[]; + const agent = detail.agentId ? agentList.find((a: any) => a.id === detail.agentId) : undefined; + return { + agentState: agent?.state?.toLowerCase() as 'live' | 'stale' | 'dead' | undefined, + hasRouteControl: agentList.some((a: any) => a.capabilities?.routeControl === true), + hasReplay: agentList.some((a: any) => a.capabilities?.replay === true), + }; }, [agents, detail.agentId]); + const roles = useAuthStore((s) => s.roles); + const canControl = roles.some(r => r === 'OPERATOR' || r === 'ADMIN'); + return (
{/* Exchange info — always shown */} @@ -92,6 +102,20 @@ export function ExchangeHeader({ detail, onCorrelatedSelect, onClearSelection }: {formatDuration(detail.durationMs)}
+ {/* Route control / replay — only if agent supports it AND user has operator+ role */} + {canControl && (hasRouteControl || hasReplay) && ( + + )} + {/* Correlation chain */}
Correlated diff --git a/ui/src/pages/Exchanges/RouteControlBar.module.css b/ui/src/pages/Exchanges/RouteControlBar.module.css new file mode 100644 index 00000000..709b7ea8 --- /dev/null +++ b/ui/src/pages/Exchanges/RouteControlBar.module.css @@ -0,0 +1,81 @@ +.bar { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.75rem; + border-bottom: 1px solid var(--border-subtle); +} + +.label { + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--text-muted); + margin-right: 0.25rem; + flex-shrink: 0; +} + +.group { + display: inline-flex; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: var(--bg-surface); + overflow: hidden; +} + +.group.sending { + opacity: 0.5; + pointer-events: none; +} + +.segment { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border: none; + background: none; + font: inherit; + font-size: 11px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + white-space: nowrap; + transition: background 0.12s, color 0.12s; +} + +.segment:hover:not(:disabled) { + background: var(--bg-hover); + color: var(--text-primary); +} + +.segment:disabled { + cursor: not-allowed; +} + +.divider { + width: 1px; + height: 14px; + background: var(--border); + flex-shrink: 0; +} + +/* Icon semantic colors */ +.success svg { color: var(--success); } +.danger svg { color: var(--error); } +.warning svg { color: var(--amber); } + +/* Preserve icon color on hover */ +.success:hover:not(:disabled) svg { color: var(--success); } +.danger:hover:not(:disabled) svg { color: var(--error); } +.warning:hover:not(:disabled) svg { color: var(--amber); } + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.spinner { + animation: spin 0.8s linear infinite; + color: var(--text-muted); +} diff --git a/ui/src/pages/Exchanges/RouteControlBar.tsx b/ui/src/pages/Exchanges/RouteControlBar.tsx new file mode 100644 index 00000000..c66e2529 --- /dev/null +++ b/ui/src/pages/Exchanges/RouteControlBar.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react'; +import { Play, Square, Pause, PlayCircle, RotateCcw, Loader2 } from 'lucide-react'; +import { useToast } from '@cameleer/design-system'; +import { useSendRouteCommand, useReplayExchange } from '../../api/queries/commands'; +import styles from './RouteControlBar.module.css'; + +interface RouteControlBarProps { + application: string; + routeId: string; + hasRouteControl: boolean; + hasReplay: boolean; + agentId?: string; + exchangeId?: string; + inputHeaders?: string; + inputBody?: string; +} + +type RouteAction = 'start' | 'stop' | 'suspend' | 'resume'; + +const ROUTE_ACTIONS: { action: RouteAction; label: string; icon: typeof Play; colorClass: string }[] = [ + { action: 'start', label: 'Start', icon: Play, colorClass: styles.success }, + { action: 'stop', label: 'Stop', icon: Square, colorClass: styles.danger }, + { action: 'suspend', label: 'Suspend', icon: Pause, colorClass: styles.warning }, + { action: 'resume', label: 'Resume', icon: PlayCircle, colorClass: styles.success }, +]; + +export function RouteControlBar({ application, routeId, hasRouteControl, hasReplay, agentId, exchangeId, inputHeaders, inputBody }: RouteControlBarProps) { + const { toast } = useToast(); + const sendRouteCommand = useSendRouteCommand(); + const replayExchange = useReplayExchange(); + const [sendingAction, setSendingAction] = useState(null); + + const busy = sendingAction !== null; + + function handleRouteAction(action: RouteAction) { + setSendingAction(action); + sendRouteCommand.mutate( + { application, action, routeId }, + { + onSuccess: () => { + toast({ title: `Route ${action} sent`, description: `${routeId} on ${application}`, variant: 'success' }); + setSendingAction(null); + }, + onError: (err) => { + toast({ title: `Route ${action} failed`, description: err.message, variant: 'error' }); + setSendingAction(null); + }, + }, + ); + } + + function handleReplay() { + if (!agentId) return; + let headers: Record = {}; + try { headers = inputHeaders ? JSON.parse(inputHeaders) : {}; } catch { /* empty */ } + setSendingAction('replay'); + replayExchange.mutate( + { agentId, routeId, headers, body: inputBody ?? '', originalExchangeId: exchangeId }, + { + onSuccess: () => { + toast({ title: 'Replay sent', description: `${routeId} on ${agentId}`, variant: 'success' }); + setSendingAction(null); + }, + onError: (err) => { + toast({ title: 'Replay failed', description: err.message, variant: 'error' }); + setSendingAction(null); + }, + }, + ); + } + + return ( +
+ Route + {hasRouteControl && ( +
+ {ROUTE_ACTIONS.map(({ action, label, icon: Icon, colorClass }) => ( + + ))} +
+ )} + {hasRouteControl && hasReplay && } + {hasReplay && ( +
+ +
+ )} +
+ ); +}