Skip to content

Commit d90089b

Browse files
Implement interactive Audit Log viewer
Replace the placeholder Audit Log page with a full interactive viewer. Adds API integration (api.audit.list) to load entries, filter controls (stage, from/to datetime, limit), Refresh/Apply buttons, and loading/error states. Implements table UI with stage/status badges, expandable rows showing detailed fields (scores, completeness, findings, errors), timestamp formatting, and sensible defaults. Keeps backwards compatibility with AuditEntry types and preserves styling.
1 parent e348b09 commit d90089b

1 file changed

Lines changed: 199 additions & 19 deletions

File tree

web/src/pages/AuditLog.tsx

Lines changed: 199 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,207 @@
1-
// Audit Log Viewer
2-
// The gateway writes JSON audit entries to .local/audit/*.jsonl.
3-
// Until the REST API exposes an /api/v1/audit endpoint this page
4-
// shows a placeholder explaining how to access the raw logs.
5-
//
6-
// Phase 3 deliverable: the page structure + placeholder is ready;
7-
// the backend endpoint will be wired in a follow-up.
1+
import { useEffect, useState } from 'react'
2+
import { api } from '../api/client'
3+
import type { AuditEntry } from '../types'
4+
5+
const STAGES = ['', 'ingest', 'parse', 'map', 'score', 'route']
6+
7+
const STAGE_BADGE: Record<string, string> = {
8+
ingest: 'bg-blue-100 text-blue-700',
9+
parse: 'bg-purple-100 text-purple-700',
10+
map: 'bg-indigo-100 text-indigo-700',
11+
score: 'bg-yellow-100 text-yellow-700',
12+
route: 'bg-teal-100 text-teal-700',
13+
}
14+
15+
const STATUS_BADGE: Record<string, string> = {
16+
ok: 'bg-green-100 text-green-700',
17+
error: 'bg-red-100 text-red-700',
18+
}
19+
20+
function StageBadge({ stage }: { stage: string }) {
21+
return (
22+
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${STAGE_BADGE[stage] ?? 'bg-gray-100 text-gray-600'}`}>
23+
{stage}
24+
</span>
25+
)
26+
}
27+
28+
function StatusBadge({ status }: { status: string }) {
29+
return (
30+
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[status] ?? 'bg-gray-100 text-gray-600'}`}>
31+
{status}
32+
</span>
33+
)
34+
}
35+
36+
// ---- expandable row --------------------------------------------------------
37+
38+
function EntryRow({ e }: { e: AuditEntry }) {
39+
const [open, setOpen] = useState(false)
40+
return (
41+
<>
42+
<tr
43+
className="hover:bg-gray-50 cursor-pointer"
44+
onClick={() => setOpen(o => !o)}
45+
>
46+
<td className="px-4 py-2 text-xs text-gray-500 whitespace-nowrap font-mono">
47+
{new Date(e.ts).toLocaleString()}
48+
</td>
49+
<td className="px-4 py-2">
50+
<StageBadge stage={e.stage} />
51+
</td>
52+
<td className="px-4 py-2">
53+
<StatusBadge status={e.status} />
54+
</td>
55+
<td className="px-4 py-2 font-mono text-xs text-gray-600 truncate max-w-[160px]">
56+
{e.msg_id}
57+
</td>
58+
<td className="px-4 py-2 text-xs text-gray-500">{e.duration_ms ?? '—'} ms</td>
59+
<td className="px-4 py-2 text-xs text-red-600 truncate max-w-[200px]">{e.error ?? ''}</td>
60+
<td className="px-4 py-2 text-xs text-gray-400 text-center">{open ? '▲' : '▼'}</td>
61+
</tr>
62+
{open && (
63+
<tr className="bg-gray-50">
64+
<td colSpan={7} className="px-6 py-3">
65+
<dl className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-1 text-xs">
66+
{e.resource_type && <><dt className="text-gray-400">Resource</dt><dd>{e.resource_type}</dd></>}
67+
{e.event_type && <><dt className="text-gray-400">Event</dt><dd>{e.event_type}</dd></>}
68+
{e.segments && <><dt className="text-gray-400">Segments</dt><dd>{e.segments}</dd></>}
69+
{e.score != null && <><dt className="text-gray-400">Score</dt><dd>{e.score.toFixed(3)}</dd></>}
70+
{e.completeness != null && <><dt className="text-gray-400">Completeness</dt><dd>{e.completeness.toFixed(3)}</dd></>}
71+
{e.conformity != null && <><dt className="text-gray-400">Conformity</dt><dd>{e.conformity.toFixed(3)}</dd></>}
72+
{e.confidence != null && <><dt className="text-gray-400">Confidence</dt><dd>{e.confidence.toFixed(3)}</dd></>}
73+
{e.findings != null && <><dt className="text-gray-400">Findings</dt><dd>{e.findings}</dd></>}
74+
{e.error && <><dt className="text-gray-400">Error</dt><dd className="text-red-600 break-all col-span-2">{e.error}</dd></>}
75+
</dl>
76+
</td>
77+
</tr>
78+
)}
79+
</>
80+
)
81+
}
82+
83+
// ---- page ------------------------------------------------------------------
884

985
export default function AuditLog() {
86+
const [entries, setEntries] = useState<AuditEntry[]>([])
87+
const [loading, setLoading] = useState(true)
88+
const [error, setError] = useState<string | null>(null)
89+
const [stage, setStage] = useState('')
90+
const [from, setFrom] = useState('')
91+
const [to, setTo] = useState('')
92+
const [limit, setLimit] = useState(200)
93+
94+
function load() {
95+
setLoading(true)
96+
setError(null)
97+
api.audit
98+
.list({ stage: stage || undefined, from: from || undefined, to: to || undefined, limit })
99+
.then(r => setEntries(r ?? []))
100+
.catch((e: Error) => setError(e.message))
101+
.finally(() => setLoading(false))
102+
}
103+
104+
useEffect(() => { load() }, []) // eslint-disable-line react-hooks/exhaustive-deps
105+
10106
return (
11107
<div className="p-6 space-y-4">
12-
<h1 className="text-xl font-semibold text-gray-800">Audit Log</h1>
13-
14-
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 text-center space-y-3">
15-
<p className="text-4xl"></p>
16-
<p className="font-medium text-gray-700">Audit log viewer coming soon</p>
17-
<p className="text-sm text-gray-500 max-w-sm mx-auto">
18-
The gateway writes a 5-stage JSON audit trail to{' '}
19-
<code className="bg-gray-100 px-1 rounded text-xs">.local/audit/</code>.
20-
A searchable viewer with per-message detail and PDF export will be
21-
available once the <code className="bg-gray-100 px-1 rounded text-xs">/api/v1/audit</code>{' '}
22-
endpoint is implemented in Phase 3.5.
23-
</p>
108+
{/* header */}
109+
<div className="flex items-center justify-between flex-wrap gap-3">
110+
<h1 className="text-xl font-semibold text-gray-800">Audit Log</h1>
111+
<button
112+
onClick={load}
113+
className="px-3 py-1.5 rounded-md border border-gray-300 text-sm hover:bg-gray-50"
114+
>
115+
Refresh
116+
</button>
24117
</div>
118+
119+
{/* filters */}
120+
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 flex flex-wrap gap-3 items-end">
121+
<label className="flex flex-col gap-1 text-xs text-gray-500">
122+
Stage
123+
<select
124+
value={stage}
125+
onChange={e => setStage(e.target.value)}
126+
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
127+
>
128+
{STAGES.map(s => <option key={s} value={s}>{s || 'All stages'}</option>)}
129+
</select>
130+
</label>
131+
132+
<label className="flex flex-col gap-1 text-xs text-gray-500">
133+
From
134+
<input
135+
type="datetime-local"
136+
value={from}
137+
onChange={e => setFrom(e.target.value ? new Date(e.target.value).toISOString() : '')}
138+
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
139+
/>
140+
</label>
141+
142+
<label className="flex flex-col gap-1 text-xs text-gray-500">
143+
To
144+
<input
145+
type="datetime-local"
146+
value={to}
147+
onChange={e => setTo(e.target.value ? new Date(e.target.value).toISOString() : '')}
148+
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
149+
/>
150+
</label>
151+
152+
<label className="flex flex-col gap-1 text-xs text-gray-500">
153+
Limit
154+
<select
155+
value={limit}
156+
onChange={e => setLimit(Number(e.target.value))}
157+
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
158+
>
159+
{[50, 100, 200, 500, 1000].map(n => <option key={n} value={n}>{n}</option>)}
160+
</select>
161+
</label>
162+
163+
<button
164+
onClick={load}
165+
className="px-4 py-1.5 rounded-md bg-brand-600 text-white text-sm hover:bg-brand-700"
166+
>
167+
Apply
168+
</button>
169+
</div>
170+
171+
{/* table */}
172+
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-x-auto">
173+
{loading && (
174+
<div className="p-8 text-center text-gray-400 text-sm">Loading…</div>
175+
)}
176+
{!loading && error && (
177+
<div className="p-8 text-center text-red-500 text-sm">{error}</div>
178+
)}
179+
{!loading && !error && entries.length === 0 && (
180+
<div className="p-8 text-center text-gray-400 text-sm">No audit entries found.</div>
181+
)}
182+
{!loading && !error && entries.length > 0 && (
183+
<table className="min-w-full text-sm">
184+
<thead className="bg-gray-50 border-b border-gray-200">
185+
<tr>
186+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500">Time</th>
187+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500">Stage</th>
188+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500">Status</th>
189+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500">Message ID</th>
190+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500">Duration</th>
191+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500">Error</th>
192+
<th className="px-4 py-2" />
193+
</tr>
194+
</thead>
195+
<tbody className="divide-y divide-gray-100">
196+
{entries.map((e, i) => <EntryRow key={`${e.msg_id}-${e.stage}-${i}`} e={e} />)}
197+
</tbody>
198+
</table>
199+
)}
200+
</div>
201+
202+
<p className="text-xs text-gray-400">
203+
Showing {entries.length} entries · click a row to expand detail
204+
</p>
25205
</div>
26206
)
27207
}

0 commit comments

Comments
 (0)