|
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 ------------------------------------------------------------------ |
8 | 84 |
|
9 | 85 | 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 | + |
10 | 106 | return ( |
11 | 107 | <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> |
24 | 117 | </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> |
25 | 205 | </div> |
26 | 206 | ) |
27 | 207 | } |
0 commit comments