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