feat: add route control bar and fix replay protocol compliance
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m4s
CI / docker (push) Successful in 1m0s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Has been cancelled

Add ROUTE_CONTROL command type and route-control mapping in
AgentCommandController. New RouteControlBar component in the exchange
header shows Start/Stop/Suspend/Resume actions (grouped pill bar) and
a Replay button, gated by agent capabilities and OPERATOR/ADMIN role.

Fix useReplayExchange hook to match protocol section 16: payload now
uses { routeId, exchange: { body, headers }, originalExchangeId, nonce }
instead of the flat { headers, body } format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-30 21:42:06 +02:00
parent 30e9b55379
commit 8b0d473fcd
6 changed files with 258 additions and 9 deletions

View File

@@ -191,8 +191,9 @@ public class AgentCommandController {
case "replay" -> CommandType.REPLAY; case "replay" -> CommandType.REPLAY;
case "set-traced-processors" -> CommandType.SET_TRACED_PROCESSORS; case "set-traced-processors" -> CommandType.SET_TRACED_PROCESSORS;
case "test-expression" -> CommandType.TEST_EXPRESSION; case "test-expression" -> CommandType.TEST_EXPRESSION;
case "route-control" -> CommandType.ROUTE_CONTROL;
default -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST, 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");
}; };
} }
} }

View File

@@ -8,5 +8,6 @@ public enum CommandType {
DEEP_TRACE, DEEP_TRACE,
REPLAY, REPLAY,
SET_TRACED_PROCESSORS, SET_TRACED_PROCESSORS,
TEST_EXPRESSION TEST_EXPRESSION,
ROUTE_CONTROL
} }

View File

@@ -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 ─────────────────────────────────────────────────────── // ── Replay Exchange ───────────────────────────────────────────────────────
export function useReplayExchange() { export function useReplayExchange() {
return useMutation({ return useMutation({
mutationFn: async ({ mutationFn: async ({
agentId, agentId,
routeId,
headers, headers,
body, body,
originalExchangeId,
}: { }: {
agentId: string agentId: string
headers: Record<string, string> routeId: string
headers?: Record<string, string>
body: string body: string
originalExchangeId?: string
}) => { }) => {
const { data, error } = await api.POST('/agents/{id}/commands', { const { data, error } = await api.POST('/agents/{id}/commands', {
params: { path: { id: agentId } }, 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') if (error) throw new Error('Failed to send replay command')
return data! return data!

View File

@@ -4,8 +4,10 @@ import { GitBranch, Server } from 'lucide-react';
import { StatusDot, MonoText, Badge } from '@cameleer/design-system'; import { StatusDot, MonoText, Badge } from '@cameleer/design-system';
import { useCorrelationChain } from '../../api/queries/correlation'; import { useCorrelationChain } from '../../api/queries/correlation';
import { useAgents } from '../../api/queries/agents'; import { useAgents } from '../../api/queries/agents';
import { useAuthStore } from '../../auth/auth-store';
import type { ExecutionDetail } from '../../components/ExecutionDiagram/types'; import type { ExecutionDetail } from '../../components/ExecutionDiagram/types';
import { attributeBadgeColor } from '../../utils/attribute-color'; import { attributeBadgeColor } from '../../utils/attribute-color';
import { RouteControlBar } from './RouteControlBar';
import styles from './ExchangeHeader.module.css'; import styles from './ExchangeHeader.module.css';
interface ExchangeHeaderProps { interface ExchangeHeaderProps {
@@ -47,14 +49,22 @@ export function ExchangeHeader({ detail, onCorrelatedSelect, onClearSelection }:
const showChain = chain && chain.length > 1; const showChain = chain && chain.length > 1;
const attrs = Object.entries(detail.attributes ?? {}); 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 { data: agents } = useAgents(undefined, detail.applicationName);
const agentState = useMemo(() => { const { agentState, hasRouteControl, hasReplay } = useMemo(() => {
if (!agents || !detail.agentId) return undefined; if (!agents) return { agentState: undefined, hasRouteControl: false, hasReplay: false };
const agent = (agents as any[]).find((a: any) => a.id === detail.agentId); const agentList = agents as any[];
return agent?.state?.toLowerCase() as 'live' | 'stale' | 'dead' | undefined; 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]); }, [agents, detail.agentId]);
const roles = useAuthStore((s) => s.roles);
const canControl = roles.some(r => r === 'OPERATOR' || r === 'ADMIN');
return ( return (
<div className={styles.header}> <div className={styles.header}>
{/* Exchange info — always shown */} {/* Exchange info — always shown */}
@@ -92,6 +102,20 @@ export function ExchangeHeader({ detail, onCorrelatedSelect, onClearSelection }:
<span className={styles.duration}>{formatDuration(detail.durationMs)}</span> <span className={styles.duration}>{formatDuration(detail.durationMs)}</span>
</div> </div>
{/* Route control / replay — only if agent supports it AND user has operator+ role */}
{canControl && (hasRouteControl || hasReplay) && (
<RouteControlBar
application={detail.applicationName}
routeId={detail.routeId}
hasRouteControl={hasRouteControl}
hasReplay={hasReplay}
agentId={detail.agentId}
exchangeId={detail.exchangeId}
inputHeaders={detail.inputHeaders}
inputBody={detail.inputBody}
/>
)}
{/* Correlation chain */} {/* Correlation chain */}
<div className={styles.chain}> <div className={styles.chain}>
<span className={styles.chainLabel}>Correlated</span> <span className={styles.chainLabel}>Correlated</span>

View File

@@ -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);
}

View File

@@ -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<string | null>(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<string, string> = {};
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 (
<div className={styles.bar}>
<span className={styles.label}>Route</span>
{hasRouteControl && (
<div className={`${styles.group} ${busy ? styles.sending : ''}`}>
{ROUTE_ACTIONS.map(({ action, label, icon: Icon, colorClass }) => (
<button
key={action}
className={`${styles.segment} ${colorClass}`}
disabled={busy}
onClick={() => handleRouteAction(action)}
title={`${label} route ${routeId}`}
>
{sendingAction === action
? <Loader2 size={12} className={styles.spinner} />
: <Icon size={12} />}
{label}
</button>
))}
</div>
)}
{hasRouteControl && hasReplay && <span className={styles.divider} />}
{hasReplay && (
<div className={`${styles.group} ${busy ? styles.sending : ''}`}>
<button
className={`${styles.segment} ${styles.success}`}
disabled={busy || !agentId}
onClick={handleReplay}
title={`Replay exchange on ${agentId ?? 'agent'}`}
>
{sendingAction === 'replay'
? <Loader2 size={12} className={styles.spinner} />
: <RotateCcw size={12} />}
Replay
</button>
</div>
)}
</div>
);
}