140 lines
7.1 KiB
TypeScript
140 lines
7.1 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { api, runReportExportUrl, type HarnessGateNode, type HarnessGateStatus, type HarnessTraceGraph } from '../../lib/api';
|
||
import { Card, StatusBadge } from '../ui/Card';
|
||
|
||
const NODE_TONE: Record<HarnessGateStatus, string> = {
|
||
queued: 'border-slate-200 bg-slate-50 text-slate-500',
|
||
running: 'border-blue-300 bg-blue-50 text-blue-800 shadow-[0_0_0_4px_rgba(59,130,246,0.1)]',
|
||
pass: 'border-emerald-300 bg-emerald-50 text-emerald-800',
|
||
warning: 'border-amber-300 bg-amber-50 text-amber-800',
|
||
blocked: 'border-rose-400 bg-rose-50 text-rose-800 shadow-[0_0_0_4px_rgba(244,63,94,0.09)]',
|
||
error: 'border-rose-400 bg-rose-50 text-rose-800 shadow-[0_0_0_4px_rgba(244,63,94,0.09)]',
|
||
skipped: 'border-slate-200 bg-slate-100 text-slate-400',
|
||
};
|
||
|
||
const STATUS_MARK: Record<HarnessGateStatus, string> = {
|
||
queued: '○',
|
||
running: '●',
|
||
pass: '✓',
|
||
warning: '!',
|
||
blocked: '×',
|
||
error: '×',
|
||
skipped: '–',
|
||
};
|
||
|
||
function Evidence({ node }: { node: HarnessGateNode }) {
|
||
const entries = Object.entries(node.evidence);
|
||
return (
|
||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,0.8fr)]">
|
||
<div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h3 className="text-lg font-semibold text-slate-900">{node.title}</h3>
|
||
<StatusBadge value={node.status} />
|
||
</div>
|
||
<p className="mt-1 text-sm text-slate-500">{node.description}</p>
|
||
<div className="mt-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-400">Latest decision</div>
|
||
<p className="mt-2 text-sm leading-6 text-slate-700">{node.reason}</p>
|
||
{node.updated_at && <p className="mt-2 text-xs text-slate-400">{node.updated_at.replace('T', ' ').replace('Z', '')}</p>}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-xl border border-slate-800 bg-slate-950 p-4 text-slate-100">
|
||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-400">Safe evidence</div>
|
||
{entries.length > 0 ? (
|
||
<dl className="mt-3 space-y-2 text-xs">
|
||
{entries.map(([key, value]) => (
|
||
<div key={key} className="grid grid-cols-[8rem_minmax(0,1fr)] gap-3 border-b border-slate-800 pb-2 last:border-0">
|
||
<dt className="font-medium text-slate-400">{key}</dt>
|
||
<dd className="break-all font-mono text-slate-200">{typeof value === 'string' ? value : JSON.stringify(value)}</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
) : <p className="mt-3 text-sm text-slate-500">No evidence has been emitted for this gate yet.</p>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function TraceExplorer({ traceId, onClose }: { traceId: string; onClose?: () => void }) {
|
||
const query = useQuery({
|
||
queryKey: ['trace-graph', traceId],
|
||
queryFn: () => api.traceGraph(traceId),
|
||
enabled: Boolean(traceId),
|
||
});
|
||
const [graph, setGraph] = useState<HarnessTraceGraph | null>(null);
|
||
const [selectedId, setSelectedId] = useState('H1-context');
|
||
|
||
useEffect(() => {
|
||
if (query.data) setGraph(query.data);
|
||
}, [query.data]);
|
||
|
||
useEffect(() => {
|
||
if (!traceId) return undefined;
|
||
return api.watchTrace(traceId, setGraph);
|
||
}, [traceId]);
|
||
|
||
const selected = useMemo(
|
||
() => graph?.nodes.find((node) => node.id === selectedId) ?? graph?.nodes[0] ?? null,
|
||
[graph, selectedId],
|
||
);
|
||
|
||
return (
|
||
<Card
|
||
title="Harness trace explorer"
|
||
right={<div className="flex items-center gap-2">
|
||
<a href={`/reports/h6?run=${encodeURIComponent(traceId)}`} className="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-1.5 text-xs font-semibold text-indigo-700 transition hover:bg-indigo-100">Open H6</a>
|
||
<a href={runReportExportUrl(traceId, 'html')} className="rounded-lg bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-slate-700">Export evidence</a>
|
||
{onClose && <button type="button" onClick={onClose} className="rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-50">Close</button>}
|
||
</div>}
|
||
className="overflow-hidden"
|
||
>
|
||
<div className="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||
<div>
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.13em] text-slate-400">Trace ID</p>
|
||
<p className="mt-1 break-all font-mono text-sm text-slate-700">{traceId}</p>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||
<span className={`h-2 w-2 rounded-full ${graph?.terminal ? 'bg-emerald-500' : 'animate-pulse bg-blue-500'}`} />
|
||
{graph?.terminal ? 'Completed' : 'Live · waiting for events'}
|
||
<span className="text-slate-300">·</span>
|
||
{graph?.progress ?? 0}/7 gates
|
||
</div>
|
||
</div>
|
||
|
||
{query.isLoading && !graph ? <div className="py-10 text-center text-sm text-slate-500">Loading trace…</div> : (
|
||
<>
|
||
<div className="overflow-x-auto pb-5">
|
||
<div className="flex min-w-max items-center px-1 py-3">
|
||
{graph?.nodes.map((node, index) => {
|
||
const next = graph.nodes[index + 1];
|
||
const edgeActive = node.status === 'pass' || node.status === 'running' || node.status === 'warning' || next?.status === 'running';
|
||
return (
|
||
<div key={node.id} className="flex items-center">
|
||
<button
|
||
type="button"
|
||
aria-pressed={selectedId === node.id}
|
||
onClick={() => setSelectedId(node.id)}
|
||
className={`w-40 rounded-xl border p-3 text-left transition duration-200 hover:-translate-y-0.5 hover:shadow-md ${NODE_TONE[node.status]} ${selectedId === node.id ? 'ring-2 ring-slate-900/10' : ''}`}
|
||
>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="text-xs font-bold tracking-wide">{node.title.split(' · ')[0]}</span>
|
||
<span className={`flex h-6 w-6 items-center justify-center rounded-full border border-current text-xs ${node.status === 'running' ? 'animate-pulse' : ''}`}>{STATUS_MARK[node.status]}</span>
|
||
</div>
|
||
<div className="mt-3 text-sm font-semibold">{node.title.split(' · ')[1]}</div>
|
||
<div className="mt-1 truncate text-[11px] opacity-70">{node.reason}</div>
|
||
</button>
|
||
{next && <div className={`trace-edge mx-2 ${edgeActive ? 'trace-edge-active' : ''}`} aria-hidden="true" />}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
{selected && <Evidence node={selected} />}
|
||
{!graph?.found && <div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">This is a legacy or not-yet-started trace. New chat turns emit detailed H1–H7 events automatically.</div>}
|
||
</>
|
||
)}
|
||
</Card>
|
||
);
|
||
}
|