diff --git a/ui/src/pages/ExchangeDetail/ExchangeDetail.module.css b/ui/src/pages/ExchangeDetail/ExchangeDetail.module.css index ff553ecb..5c977839 100644 --- a/ui/src/pages/ExchangeDetail/ExchangeDetail.module.css +++ b/ui/src/pages/ExchangeDetail/ExchangeDetail.module.css @@ -565,3 +565,110 @@ color: var(--text-faint); font-size: 12px; } + +/* ========================================================================== + REPLAY MODAL + ========================================================================== */ +.replayWarning { + background: var(--warning-bg, #3d3520); + border: 1px solid var(--warning-border, #6b5c2a); + border-radius: var(--radius-sm); + padding: 10px 14px; + font-size: 12px; + color: var(--warning, #e6b84f); + margin-bottom: 16px; + line-height: 1.5; +} + +.replayAgentRow { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.replayFieldLabel { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + white-space: nowrap; +} + +.replayHeadersTable { + margin-top: 12px; +} + +.replayHeadersHead { + display: grid; + grid-template-columns: 1fr 1fr 28px; + gap: 8px; + padding-bottom: 6px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); +} + +.replayHeaderRow { + display: grid; + grid-template-columns: 1fr 1fr 28px; + gap: 8px; + margin-bottom: 6px; + align-items: center; +} + +.replayRemoveBtn { + background: none; + border: none; + color: var(--text-muted); + font-size: 16px; + cursor: pointer; + padding: 0; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-sm); +} + +.replayRemoveBtn:hover { + color: var(--error); + background: var(--error-bg); +} + +.replayAddHeader { + background: none; + border: none; + color: var(--amber); + font-size: 12px; + cursor: pointer; + padding: 4px 0; + margin-top: 4px; +} + +.replayAddHeader:hover { + color: var(--amber-deep); + text-decoration: underline; +} + +.replayBodyArea { + margin-top: 12px; +} + +.replayBodyTextarea { + font-family: var(--font-mono); + font-size: 12px; + width: 100%; +} + +.replayFooter { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid var(--border-subtle); +} diff --git a/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx b/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx index be671c50..96e3da37 100644 --- a/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx +++ b/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx @@ -92,6 +92,13 @@ export default function ExchangeDetail() { const [logSearch, setLogSearch] = useState('') const [logLevels, setLogLevels] = useState>(new Set()) + // Replay modal state + const [replayOpen, setReplayOpen] = useState(false) + const [replayHeaders, setReplayHeaders] = useState>([]) + const [replayBody, setReplayBody] = useState('') + const [replayAgent, setReplayAgent] = useState('') + const [replayTab, setReplayTab] = useState('headers') + const procList = detail ? (detail.processors?.length ? detail.processors : (detail.children ?? [])) : [] @@ -263,6 +270,42 @@ export default function ExchangeDetail() { }) }, [detail, app, appConfig, tracingStore, updateConfig, toast]) + // ── Replay ───────────────────────────────────────────────────────────── + const { data: liveAgents } = useAgents('LIVE', detail?.applicationName) + const replay = useReplayExchange() + + // Pre-populate replay form when modal opens + useEffect(() => { + if (!replayOpen || !detail) return + try { + const parsed = JSON.parse(detail.inputHeaders ?? '{}') + const entries = Object.entries(parsed).map(([k, v]) => ({ + key: k, + value: typeof v === 'string' ? v : JSON.stringify(v), + })) + setReplayHeaders(entries.length > 0 ? entries : [{ key: '', value: '' }]) + } catch { + setReplayHeaders([{ key: '', value: '' }]) + } + setReplayBody(detail.inputBody ?? '') + // Default to current agent if it is live + const agentIds = (liveAgents ?? []).map((a: any) => a.id) + setReplayAgent(agentIds.includes(detail.agentId) ? detail.agentId : (agentIds[0] ?? '')) + setReplayTab('headers') + }, [replayOpen]) + + function handleReplay() { + const headers: Record = {} + replayHeaders.forEach((h) => { if (h.key) headers[h.key] = h.value }) + replay.mutate( + { agentId: replayAgent, headers, body: replayBody }, + { + onSuccess: () => { toast({ title: 'Replay command sent', variant: 'success' }); setReplayOpen(false) }, + onError: (err) => { toast({ title: `Replay failed: ${err.message}`, variant: 'error' }) }, + }, + ) + } + // Correlation chain const correlatedExchanges = useMemo(() => { if (!correlationData?.data || correlationData.data.length <= 1) return [] @@ -369,6 +412,9 @@ export default function ExchangeDetail() {
Processors
{countProcessors(procList)}
+ @@ -694,6 +740,113 @@ export default function ExchangeDetail() { )} + {/* Replay Modal */} + setReplayOpen(false)} title="Replay Exchange" size="lg"> +
+ This will re-send the exchange to a live agent. The agent will process + it as a new exchange. Use with caution in production environments. +
+ +
+ Target Agent + { + const next = [...replayHeaders] + next[i] = { ...next[i], key: e.target.value } + setReplayHeaders(next) + }} + /> + { + const next = [...replayHeaders] + next[i] = { ...next[i], value: e.target.value } + setReplayHeaders(next) + }} + /> + +
+ ))} + + + )} + + {replayTab === 'body' && ( +
+