tia/src/app/admin/audit/page.tsx
Mannu 7a60132bb2 Add admin observability: error tracking, audit viewer, AI metrics, health
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>
2026-05-30 00:27:07 +05:30

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`;
}