feat: add live H1-H7 trace explorer

This commit is contained in:
thanhnv
2026-07-10 23:17:23 +09:00
parent 7cb592a32f
commit 1345d4c930
11 changed files with 496 additions and 28 deletions
@@ -0,0 +1,135 @@
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api, 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={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>}
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>
);
}
@@ -23,8 +23,9 @@ export function StatTile({ label, value, sub }: { label: string; value: ReactNod
}
const TONE: Record<string, string> = {
ok: 'border-emerald-200 bg-emerald-50 text-emerald-700', pass: 'border-emerald-200 bg-emerald-50 text-emerald-700', success: 'border-emerald-200 bg-emerald-50 text-emerald-700', allow: 'border-emerald-200 bg-emerald-50 text-emerald-700', approved: 'border-emerald-200 bg-emerald-50 text-emerald-700', answered: 'border-emerald-200 bg-emerald-50 text-emerald-700', certified: 'border-emerald-200 bg-emerald-50 text-emerald-700', verified: 'border-emerald-200 bg-emerald-50 text-emerald-700',
warn: 'border-amber-200 bg-amber-50 text-amber-700', stale: 'border-amber-200 bg-amber-50 text-amber-700', draft: 'border-amber-200 bg-amber-50 text-amber-700', pending: 'border-amber-200 bg-amber-50 text-amber-700', attention: 'border-amber-200 bg-amber-50 text-amber-700',
fail: 'border-rose-200 bg-rose-50 text-rose-700', failed: 'border-rose-200 bg-rose-50 text-rose-700', denied: 'border-rose-200 bg-rose-50 text-rose-700', blocked: 'border-rose-200 bg-rose-50 text-rose-700', deny: 'border-rose-200 bg-rose-50 text-rose-700', block: 'border-rose-200 bg-rose-50 text-rose-700', crit: 'border-rose-200 bg-rose-50 text-rose-700', breach: 'border-rose-200 bg-rose-50 text-rose-700', halted: 'border-rose-200 bg-rose-50 text-rose-700',
running: 'border-blue-200 bg-blue-50 text-blue-700', streaming: 'border-blue-200 bg-blue-50 text-blue-700',
warn: 'border-amber-200 bg-amber-50 text-amber-700', warning: 'border-amber-200 bg-amber-50 text-amber-700', stale: 'border-amber-200 bg-amber-50 text-amber-700', draft: 'border-amber-200 bg-amber-50 text-amber-700', pending: 'border-amber-200 bg-amber-50 text-amber-700', attention: 'border-amber-200 bg-amber-50 text-amber-700',
fail: 'border-rose-200 bg-rose-50 text-rose-700', failed: 'border-rose-200 bg-rose-50 text-rose-700', error: 'border-rose-200 bg-rose-50 text-rose-700', denied: 'border-rose-200 bg-rose-50 text-rose-700', blocked: 'border-rose-200 bg-rose-50 text-rose-700', deny: 'border-rose-200 bg-rose-50 text-rose-700', block: 'border-rose-200 bg-rose-50 text-rose-700', crit: 'border-rose-200 bg-rose-50 text-rose-700', breach: 'border-rose-200 bg-rose-50 text-rose-700', halted: 'border-rose-200 bg-rose-50 text-rose-700',
};
export function StatusBadge({ value }: { value: string }) {
const tone = TONE[String(value).toLowerCase()] ?? 'border-slate-200 bg-slate-50 text-slate-600';
@@ -27,3 +27,37 @@ button, input, select, textarea { font: inherit; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: #c8d0dd; border: 3px solid transparent; border-radius: 999px; background-clip: padding-box; }
::-webkit-scrollbar-track { background: transparent; }
@keyframes trace-flow {
from { transform: translateX(-150%); }
to { transform: translateX(350%); }
}
.trace-edge {
position: relative;
width: 2.75rem;
height: 2px;
flex: 0 0 2.75rem;
overflow: hidden;
border-radius: 999px;
background: #cbd5e1;
}
.trace-edge-active {
background: #93c5fd;
}
.trace-edge-active::after {
position: absolute;
inset-block: 0;
left: 0;
width: 35%;
border-radius: 999px;
background: linear-gradient(90deg, transparent, #2563eb, transparent);
content: "";
animation: trace-flow 1.2s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.trace-edge-active::after { animation: none; }
}
@@ -23,6 +23,17 @@ async function post<T>(path: string, body: unknown, headers: Record<string, stri
export interface Freshness { stale: boolean; age_s: number | null; stale_after_s: number }
export interface HarnessRunRecord {
timestamp?: string;
trace_id?: string;
harness?: string;
step?: string;
status?: string;
latency_ms?: number;
total_tokens?: number;
cost_estimate?: number;
}
export interface Overview extends Freshness {
totals: {
runs: number; total_cost: number; avg_latency_ms: number; failures: number;
@@ -217,6 +228,38 @@ export interface ProviderAuthStatus {
authMethod: string;
}
export type HarnessGateStatus = 'queued' | 'running' | 'pass' | 'warning' | 'blocked' | 'error' | 'skipped';
export interface HarnessTraceEvent {
timestamp: string;
trace_id: string;
gate_id: string;
status: HarnessGateStatus;
reason: string;
evidence: Record<string, unknown>;
}
export interface HarnessGateNode {
id: string;
title: string;
description: string;
status: HarnessGateStatus;
reason: string;
updated_at: string | null;
evidence: Record<string, unknown>;
events: HarnessTraceEvent[];
}
export interface HarnessTraceGraph {
found: boolean;
trace_id: string;
updated_at: string | null;
terminal: boolean;
progress: number;
nodes: HarnessGateNode[];
events: HarnessTraceEvent[];
}
export interface ChatReplay {
ok: boolean;
decision: 'MATCH' | 'DRIFT' | 'BREAK' | string;
@@ -278,7 +321,22 @@ function actorHeaders(actor: SettingsActor): Record<string, string> {
export const api = {
overview: () => get<Overview>('overview'),
runs: (limit = 50) => get<Freshness & { count: number; runs: any[] }>(`runs?limit=${limit}`),
runs: (limit = 50) => get<Freshness & { count: number; runs: HarnessRunRecord[] }>(`runs?limit=${limit}`),
traceGraph: (traceId: string) => get<HarnessTraceGraph>(`runs/${encodeURIComponent(traceId)}/graph`),
watchTrace: (traceId: string, onGraph: (graph: HarnessTraceGraph) => void, onError?: () => void) => {
const base = import.meta.env.VITE_API_BASE_URL ?? '/api/v1';
const stream = new EventSource(`${base}/runs/${encodeURIComponent(traceId)}/events`, { withCredentials: true });
const handler = (event: MessageEvent<string>) => {
try {
onGraph(JSON.parse(event.data) as HarnessTraceGraph);
} catch {
onError?.();
}
};
stream.addEventListener('trace', handler as EventListener);
stream.onerror = () => onError?.();
return () => stream.close();
},
governance: () => get<Freshness & { records: number; by_decision: Record<string, number>; head: string | null; recent: any[] }>('governance'),
security: () => get<Freshness & { verdicts: number; by_status: Record<string, number>; benign_fp: any; recent: any[] }>('security'),
incidents: () => get<Freshness & { total: number; kill_switch_scopes: string[]; incidents: any[] }>('incidents'),
@@ -1,5 +1,6 @@
import { type KeyboardEvent, useMemo, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { api, ChatAction, ChatAgent, ChatAnswer, ChatHistoryTurn, ChatModelProvider, ChatStreamPhase, SettingsActor } from '../lib/api';
import { Card, StatusBadge } from '../components/ui/Card';
import { ModelConnectionPanel } from '../components/chat/ModelConnectionPanel';
@@ -295,7 +296,7 @@ export function Chat() {
<div className="mt-5 text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Latest verification</div>
<div className="mt-2 space-y-2">
<div className="rounded-xl border border-slate-200 bg-white p-3"><div className="text-xs text-slate-400">Audit anchor</div><div className="mt-1 break-all font-mono text-[11px] text-slate-700">{auditHash(last).slice(0, 22)}{auditHash(last) !== 'n/a' ? '…' : ''}</div></div>
{last && <><div className="rounded-xl border border-slate-200 bg-white p-3"><div className="flex flex-wrap gap-1.5"><StatusBadge value={last.mode} /><StatusBadge value={last.risk} /><StatusBadge value={last.decision} /><StatusBadge value={last.certified ? 'certified' : 'held'} /></div><div className="mt-2 text-xs text-slate-500">{last.router?.reason ?? 'Policy route verified'}{last.synthesis?.fallback_from ? ` · fell back from ${last.synthesis.fallback_from} to local` : ''}</div></div><Card title="Evidence" className="p-3.5"><div className="space-y-2">{last.sources.slice(0, 3).map((source) => <div key={`${source.path}-${source.line ?? ''}`} className="rounded-lg bg-slate-50 p-2.5"><div className="truncate text-xs font-medium text-slate-700">{source.title || source.path}</div><div className="mt-1 text-[11px] leading-4 text-slate-500">{source.preview || source.excerpt || 'Verified source'}</div></div>)}{last.sources.length === 0 && <div className="text-xs text-slate-500">No source was returned for this decision.</div>}</div></Card></>}
{last && <><div className="rounded-xl border border-slate-200 bg-white p-3"><div className="flex flex-wrap gap-1.5"><StatusBadge value={last.mode} /><StatusBadge value={last.risk} /><StatusBadge value={last.decision} /><StatusBadge value={last.certified ? 'certified' : 'held'} /></div><div className="mt-2 text-xs text-slate-500">{last.router?.reason ?? 'Policy route verified'}{last.synthesis?.fallback_from ? ` · fell back from ${last.synthesis.fallback_from} to local` : ''}</div>{last.trace_id && <Link to={`/runs?trace=${encodeURIComponent(last.trace_id)}`} className="mt-3 flex w-full items-center justify-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs font-semibold text-blue-700 transition-colors hover:bg-blue-100">Open live H1–H7 trace <Glyph name="chevron" /></Link>}</div><Card title="Evidence" className="p-3.5"><div className="space-y-2">{last.sources.slice(0, 3).map((source) => <div key={`${source.path}-${source.line ?? ''}`} className="rounded-lg bg-slate-50 p-2.5"><div className="truncate text-xs font-medium text-slate-700">{source.title || source.path}</div><div className="mt-1 text-[11px] leading-4 text-slate-500">{source.preview || source.excerpt || 'Verified source'}</div></div>)}{last.sources.length === 0 && <div className="text-xs text-slate-500">No source was returned for this decision.</div>}</div></Card></>}
</div>
<div className="mt-5 text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Registered actions</div>
@@ -1,31 +1,53 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api';
import { useSearchParams } from 'react-router-dom';
import { api, type HarnessRunRecord } from '../lib/api';
import { Card, StatusBadge } from '../components/ui/Card';
import { TraceExplorer } from '../components/trace/TraceExplorer';
export function Runs() {
const { data, isLoading } = useQuery({ queryKey: ['runs'], queryFn: () => api.runs(100) });
if (isLoading || !data) return <div className="text-gray-500">Loading…</div>;
const [searchParams, setSearchParams] = useSearchParams();
const selectedTrace = searchParams.get('trace') ?? '';
if (isLoading || !data) return <div className="text-slate-500">Loading…</div>;
return (
<Card title={`Recent runs (${data.count})`}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="text-left text-gray-500 border-b border-gray-200">
<th className="py-2">time</th><th>step</th><th>status</th><th>latency</th><th>tokens</th><th>cost</th>
</tr></thead>
<tbody>
{data.runs.map((r: any, i: number) => (
<tr key={i} className="border-b border-gray-100">
<td className="py-2 text-gray-500">{r.timestamp?.replace('T', ' ').replace('Z', '')}</td>
<td className="text-gray-700">{r.step ?? r.harness}</td>
<td><StatusBadge value={r.status ?? '—'} /></td>
<td>{r.latency_ms ?? '—'} ms</td>
<td>{r.total_tokens ?? '—'}</td>
<td>${Number(r.cost_estimate ?? 0).toFixed(5)}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<div className="space-y-5">
{selectedTrace && <TraceExplorer traceId={selectedTrace} onClose={() => setSearchParams({})} />}
<Card
title={`Recent runs (${data.count})`}
right={<span className="text-xs font-normal normal-case tracking-normal text-slate-400">Select a run to inspect H1–H7</span>}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="border-b border-slate-200 text-left text-slate-500">
<th className="py-2">time</th><th>step</th><th>status</th><th>latency</th><th>tokens</th><th>cost</th><th className="text-right">trace</th>
</tr></thead>
<tbody>
{data.runs.map((run: HarnessRunRecord, index: number) => (
<tr key={`${run.trace_id ?? 'run'}-${index}`} className="border-b border-slate-100 transition-colors hover:bg-slate-50/80">
<td className="py-2 text-slate-500">{run.timestamp?.replace('T', ' ').replace('Z', '')}</td>
<td className="text-slate-700">{run.step ?? run.harness}</td>
<td><StatusBadge value={run.status ?? '—'} /></td>
<td>{run.latency_ms ?? '—'} ms</td>
<td>{run.total_tokens ?? '—'}</td>
<td>${Number(run.cost_estimate ?? 0).toFixed(5)}</td>
<td className="py-2 text-right">
{run.trace_id ? (
<button
type="button"
onClick={() => setSearchParams({ trace: run.trace_id ?? '' })}
className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-semibold text-blue-700 transition-colors hover:bg-blue-100"
>
Open H1–H7
</button>
) : <span className="text-slate-300">—</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
);
}