feat(ui): add replay modal to ExchangeDetail page

Add a Replay button in the exchange header that opens a modal allowing
users to re-send the exchange to a live agent. The modal pre-populates
headers and body from the original exchange input, provides an agent
selector filtered to live agents for the application, and supports
editable header key-value rows with add/remove.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-26 18:35:00 +01:00
parent a3706cf7c2
commit eb796f531f
2 changed files with 260 additions and 0 deletions

View File

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

View File

@@ -92,6 +92,13 @@ export default function ExchangeDetail() {
const [logSearch, setLogSearch] = useState('')
const [logLevels, setLogLevels] = useState<Set<string>>(new Set())
// Replay modal state
const [replayOpen, setReplayOpen] = useState(false)
const [replayHeaders, setReplayHeaders] = useState<Array<{ key: string; value: string }>>([])
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<string, string> = {}
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() {
<div className={styles.headerStatLabel}>Processors</div>
<div className={styles.headerStatValue}>{countProcessors(procList)}</div>
</div>
<Button variant="primary" size="sm" onClick={() => setReplayOpen(true)}>
&#8635; Replay
</Button>
</div>
</div>
@@ -694,6 +740,113 @@ export default function ExchangeDetail() {
</div>
)}
{/* Replay Modal */}
<Modal open={replayOpen} onClose={() => setReplayOpen(false)} title="Replay Exchange" size="lg">
<div className={styles.replayWarning}>
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.
</div>
<div className={styles.replayAgentRow}>
<span className={styles.replayFieldLabel}>Target Agent</span>
<Select
value={replayAgent}
onChange={(e) => setReplayAgent(e.target.value)}
options={(liveAgents ?? []).map((a: any) => ({
value: a.id,
label: a.name || a.id,
}))}
/>
</div>
<Tabs
tabs={[
{ label: 'Headers', value: 'headers', count: replayHeaders.filter((h) => h.key).length },
{ label: 'Body', value: 'body' },
]}
active={replayTab}
onChange={setReplayTab}
/>
{replayTab === 'headers' && (
<div className={styles.replayHeadersTable}>
<div className={styles.replayHeadersHead}>
<span>Key</span>
<span>Value</span>
<span />
</div>
{replayHeaders.map((h, i) => (
<div key={i} className={styles.replayHeaderRow}>
<Input
value={h.key}
placeholder="Header name"
onChange={(e) => {
const next = [...replayHeaders]
next[i] = { ...next[i], key: e.target.value }
setReplayHeaders(next)
}}
/>
<Input
value={h.value}
placeholder="Value"
onChange={(e) => {
const next = [...replayHeaders]
next[i] = { ...next[i], value: e.target.value }
setReplayHeaders(next)
}}
/>
<button
type="button"
className={styles.replayRemoveBtn}
onClick={() => {
const next = replayHeaders.filter((_, j) => j !== i)
setReplayHeaders(next.length > 0 ? next : [{ key: '', value: '' }])
}}
aria-label="Remove header"
>
&times;
</button>
</div>
))}
<button
type="button"
className={styles.replayAddHeader}
onClick={() => setReplayHeaders([...replayHeaders, { key: '', value: '' }])}
>
+ Add header
</button>
</div>
)}
{replayTab === 'body' && (
<div className={styles.replayBodyArea}>
<Textarea
className={styles.replayBodyTextarea}
value={replayBody}
onChange={(e) => setReplayBody(e.target.value)}
rows={12}
resize="vertical"
placeholder="Request body (JSON, XML, text, etc.)"
/>
</div>
)}
<div className={styles.replayFooter}>
<Button variant="secondary" size="sm" onClick={() => setReplayOpen(false)}>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={handleReplay}
loading={replay.isPending}
disabled={!replayAgent}
>
&#8635; Replay
</Button>
</div>
</Modal>
</div>
)
}