feat: add route control bar and fix replay protocol compliance
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:
@@ -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");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,6 @@ public enum CommandType {
|
||||
DEEP_TRACE,
|
||||
REPLAY,
|
||||
SET_TRACED_PROCESSORS,
|
||||
TEST_EXPRESSION
|
||||
TEST_EXPRESSION,
|
||||
ROUTE_CONTROL
|
||||
}
|
||||
|
||||
@@ -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<string, string>
|
||||
routeId: string
|
||||
headers?: Record<string, string>
|
||||
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!
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.header}>
|
||||
{/* Exchange info — always shown */}
|
||||
@@ -92,6 +102,20 @@ export function ExchangeHeader({ detail, onCorrelatedSelect, onClearSelection }:
|
||||
<span className={styles.duration}>{formatDuration(detail.durationMs)}</span>
|
||||
</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 */}
|
||||
<div className={styles.chain}>
|
||||
<span className={styles.chainLabel}>Correlated</span>
|
||||
|
||||
81
ui/src/pages/Exchanges/RouteControlBar.module.css
Normal file
81
ui/src/pages/Exchanges/RouteControlBar.module.css
Normal 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);
|
||||
}
|
||||
111
ui/src/pages/Exchanges/RouteControlBar.tsx
Normal file
111
ui/src/pages/Exchanges/RouteControlBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user