Turns the admin panel into a real monitoring tool so production bugs are visible instead of silent. - Error & crash tracking: error_events table (migration 0010) + logError() helper + /api/errors ingest; global-error.tsx and (app)/error.tsx report crashes automatically; /admin/errors viewer (recent + grouped, filters). - Full audit-log viewer at /admin/audit over the existing audit_log (all actions, not just auth) with action/resource/family/user/text filters. - AI observability at /admin/ai over ai_usage: per-intent latency (avg/p95), tokens, cost, daily trend, slowest calls, medical-redirect count. - System health at /admin/health: DB latency, migration status, recent error volume, and integration config presence. - Sidebar updated with Health / Errors / Audit Log / AI Usage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
134 lines
6 KiB
TypeScript
134 lines
6 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
|
|
interface AuditRow {
|
|
id: string;
|
|
action: string;
|
|
resource_type: string | null;
|
|
resource_id: string | null;
|
|
ip_address: string | null;
|
|
user_agent: string | null;
|
|
metadata: Record<string, unknown> | null;
|
|
created_at: string;
|
|
user_email: string | null;
|
|
user_name: string | null;
|
|
family_name: string | null;
|
|
}
|
|
interface Data { events: AuditRow[]; actions: string[]; resourceTypes: string[]; error?: string }
|
|
|
|
const WINDOWS = [
|
|
{ label: "24h", hours: 24 },
|
|
{ label: "7d", hours: 168 },
|
|
{ label: "30d", hours: 720 },
|
|
{ label: "90d", hours: 2160 },
|
|
];
|
|
|
|
export default function AdminAudit() {
|
|
const [data, setData] = useState<Data | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [action, setAction] = useState("");
|
|
const [resourceType, setResourceType] = useState("");
|
|
const [hours, setHours] = useState(168);
|
|
const [q, setQ] = useState("");
|
|
const [expanded, setExpanded] = useState<string | null>(null);
|
|
|
|
const load = useCallback(() => {
|
|
setLoading(true);
|
|
const params = new URLSearchParams({ sinceHours: String(hours) });
|
|
if (action) params.set("action", action);
|
|
if (resourceType) params.set("resourceType", resourceType);
|
|
if (q.trim()) params.set("q", q.trim());
|
|
fetch(`/api/admin/audit?${params}`, { credentials: "include" })
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
setData({
|
|
events: Array.isArray(d?.events) ? d.events : [],
|
|
actions: Array.isArray(d?.actions) ? d.actions : [],
|
|
resourceTypes: Array.isArray(d?.resourceTypes) ? d.resourceTypes : [],
|
|
error: d?.error,
|
|
});
|
|
setLoading(false);
|
|
})
|
|
.catch(() => setLoading(false));
|
|
}, [hours, action, resourceType, q]);
|
|
|
|
useEffect(() => { load(); }, [hours, action, resourceType]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
<div className="flex justify-between items-start flex-wrap gap-3">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">Audit Log</h1>
|
|
<p className="text-gray-400">Every recorded action across the platform</p>
|
|
</div>
|
|
<button onClick={load} className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm">↻ Refresh</button>
|
|
</div>
|
|
|
|
<div className="flex gap-2 flex-wrap items-center">
|
|
<select value={action} onChange={e => setAction(e.target.value)} className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm">
|
|
<option value="">All actions</option>
|
|
{data?.actions.map(a => <option key={a} value={a}>{a}</option>)}
|
|
</select>
|
|
<select value={resourceType} onChange={e => setResourceType(e.target.value)} className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm">
|
|
<option value="">All resources</option>
|
|
{data?.resourceTypes.map(r => <option key={r} value={r}>{r}</option>)}
|
|
</select>
|
|
<div className="flex gap-1 bg-gray-800 p-1 rounded-lg">
|
|
{WINDOWS.map(w => (
|
|
<button key={w.hours} onClick={() => setHours(w.hours)} className={`px-3 py-1.5 rounded-md text-sm ${hours === w.hours ? "bg-gray-600" : "text-gray-400 hover:text-white"}`}>{w.label}</button>
|
|
))}
|
|
</div>
|
|
<form onSubmit={e => { e.preventDefault(); load(); }} className="flex-1 min-w-[180px]">
|
|
<input value={q} onChange={e => setQ(e.target.value)} placeholder="Search user / family / action…" className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm" />
|
|
</form>
|
|
</div>
|
|
|
|
{data?.error && <div className="bg-rose-500/10 text-rose-400 text-sm p-3 rounded-lg">Feed error: {data.error}</div>}
|
|
|
|
{loading ? (
|
|
<div className="text-gray-400">Loading…</div>
|
|
) : (
|
|
<div className="bg-gray-800 rounded-xl overflow-hidden">
|
|
{(data?.events.length ?? 0) === 0 ? (
|
|
<div className="p-8 text-center text-gray-500">No audit events match these filters</div>
|
|
) : (
|
|
<div className="divide-y divide-gray-700">
|
|
{data!.events.map(e => {
|
|
const hasMeta = e.metadata && Object.keys(e.metadata).length > 0;
|
|
return (
|
|
<div key={e.id} className="p-4 hover:bg-gray-750">
|
|
<div className="flex items-start gap-3 cursor-pointer" onClick={() => hasMeta && setExpanded(expanded === e.id ? null : e.id)}>
|
|
<span className="text-xs px-2 py-0.5 rounded bg-gray-700 text-gray-200 font-medium whitespace-nowrap">{e.action}</span>
|
|
{e.resource_type && <span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">{e.resource_type}</span>}
|
|
<div className="flex-1 min-w-0 text-xs text-gray-500 flex gap-2 flex-wrap">
|
|
{(e.user_email || e.user_name) && <span>👤 {e.user_name || e.user_email}</span>}
|
|
{e.family_name && <span>🏠 {e.family_name}</span>}
|
|
{e.ip_address && <span>🌐 {e.ip_address}</span>}
|
|
<span>🕒 {timeAgo(e.created_at)}</span>
|
|
</div>
|
|
{hasMeta && <span className="text-gray-500 text-xs">{expanded === e.id ? "▲" : "▼"}</span>}
|
|
</div>
|
|
{hasMeta && expanded === e.id && (
|
|
<pre className="mt-3 bg-gray-900 p-3 rounded-lg text-xs text-gray-400 overflow-x-auto whitespace-pre-wrap break-words">{JSON.stringify(e.metadata, null, 2)}</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function timeAgo(iso: string) {
|
|
const diff = Date.now() - new Date(iso).getTime();
|
|
const m = Math.floor(diff / 60000);
|
|
if (m < 1) return "just now";
|
|
if (m < 60) return `${m}m ago`;
|
|
const h = Math.floor(m / 60);
|
|
if (h < 24) return `${h}h ago`;
|
|
return `${Math.floor(h / 24)}d ago`;
|
|
}
|