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>
This commit is contained in:
Manohar Gupta 2026-05-30 00:27:07 +05:30
parent 94d9b234f8
commit 7a60132bb2
16 changed files with 1093 additions and 0 deletions

View file

@ -0,0 +1,22 @@
-- Error / crash tracking. Captures both client-side React errors (via the
-- error boundaries that POST to /api/errors) and server-side failures (via
-- logError() in src/lib/error-log.ts). Surfaced in the admin panel at
-- /admin/errors so production bugs are visible instead of silent.
CREATE TABLE IF NOT EXISTS error_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
level varchar(20) NOT NULL DEFAULT 'error', -- error | warn | fatal
source varchar(20) NOT NULL DEFAULT 'client', -- client | server
message text NOT NULL,
stack text,
url text, -- route / pathname where it happened
digest varchar(120), -- Next.js error digest (server)
user_id uuid,
family_id uuid,
user_agent text,
metadata jsonb DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_error_events_created ON error_events (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_error_events_source ON error_events (source);
CREATE INDEX IF NOT EXISTS idx_error_events_message ON error_events (message);

View file

@ -71,6 +71,13 @@
"when": 1748880000000,
"tag": "0009_notifications",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1749139200000,
"tag": "0010_error_events",
"breakpoints": true
}
]
}

48
src/app/(app)/error.tsx Normal file
View file

@ -0,0 +1,48 @@
"use client";
import { useEffect } from "react";
// Error boundary for the authenticated (app) segment. Catches render/runtime
// errors in any /(app) page (home, ai, growth, …), shows a friendly recovery
// UI, and reports the crash to /api/errors for the admin error tracker.
export default function AppError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
try {
fetch("/api/errors", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: error?.message || "Unknown app error",
stack: error?.stack,
digest: error?.digest,
url: typeof window !== "undefined" ? window.location.pathname : undefined,
level: "error",
metadata: { boundary: "app" },
}),
keepalive: true,
}).catch(() => {});
} catch {}
}, [error]);
return (
<div className="min-h-screen flex flex-col items-center justify-center gap-4 p-6 text-center bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
<div className="text-5xl">😵</div>
<h1 className="text-xl font-bold dark:text-white">Something went wrong</h1>
<p className="text-gray-500 dark:text-gray-400 max-w-sm text-sm">
This screen hit an unexpected error. It&apos;s been reported automatically.
</p>
<button
onClick={() => reset()}
className="bg-rose-400 text-white px-5 py-2.5 rounded-xl font-semibold"
>
Try again
</button>
</div>
);
}

View file

@ -12,12 +12,16 @@ interface NavItem {
const navItems: NavItem[] = [
{ name: "Dashboard", href: "/admin", icon: "📊" },
{ name: "Health", href: "/admin/health", icon: "❤️‍🩹" },
{ name: "Errors", href: "/admin/errors", icon: "🐞" },
{ name: "Activity", href: "/admin/activity", icon: "🔍" },
{ name: "Audit Log", href: "/admin/audit", icon: "📜" },
{ name: "Families", href: "/admin/families", icon: "🏠" },
{ name: "Users", href: "/admin/users", icon: "👥" },
{ name: "Children", href: "/admin/children", icon: "👶" },
{ name: "Revenue", href: "/admin/revenue", icon: "💰" },
{ name: "Analytics", href: "/admin/analytics", icon: "📈" },
{ name: "AI Usage", href: "/admin/ai", icon: "🤖" },
{ name: "Support", href: "/admin/support", icon: "🎫" },
{ name: "Settings", href: "/admin/settings", icon: "⚙️" },
];

147
src/app/admin/ai/page.tsx Normal file
View file

@ -0,0 +1,147 @@
"use client";
import { useEffect, useState } from "react";
interface Summary { totalCalls: number; totalTokens: number; totalCostPaise: number; familiesUsingAI: number; avgMs: number; p95Ms: number; redirects: number }
interface IntentRow { intent: string; count: number; avgMs: number; p95Ms: number; tokens: number; costPaise: number }
interface DayRow { date: string; count: number; costPaise: number }
interface SlowRow { id: string; intent: string; durationMs: number; model: string; createdAt: string; familyId: string | null }
interface Data { days: number; summary: Summary; byIntent: IntentRow[]; byDay: DayRow[]; slowest: SlowRow[]; error?: string }
const EMPTY: Data = { days: 30, summary: { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0, avgMs: 0, p95Ms: 0, redirects: 0 }, byIntent: [], byDay: [], slowest: [] };
const rupees = (paise: number) => `${(paise / 100).toFixed(2)}`;
export default function AdminAI() {
const [data, setData] = useState<Data>(EMPTY);
const [loading, setLoading] = useState(true);
const [days, setDays] = useState(30);
useEffect(() => {
setLoading(true);
fetch(`/api/admin/ai?days=${days}`, { credentials: "include" })
.then(r => r.json())
.then(d => {
setData({
days: d?.days || days,
summary: { ...EMPTY.summary, ...(d?.summary || {}) },
byIntent: Array.isArray(d?.byIntent) ? d.byIntent : [],
byDay: Array.isArray(d?.byDay) ? d.byDay : [],
slowest: Array.isArray(d?.slowest) ? d.slowest : [],
error: d?.error,
});
setLoading(false);
})
.catch(() => setLoading(false));
}, [days]);
const { summary, byIntent, byDay, slowest } = data;
const maxDay = Math.max(...byDay.map(d => d.count), 1);
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">AI Observability</h1>
<p className="text-gray-400">Latency, cost, and intent breakdown over the last {days} days</p>
</div>
<select value={days} onChange={e => setDays(Number(e.target.value))} className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm">
<option value={7}>Last 7 days</option>
<option value={30}>Last 30 days</option>
<option value={90}>Last 90 days</option>
</select>
</div>
{data.error && <div className="bg-rose-500/10 text-rose-400 text-sm p-3 rounded-lg">Feed error: {data.error}</div>}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card label="API Calls" value={summary.totalCalls} color="text-blue-400" />
<Card label="Avg latency" value={`${summary.avgMs}ms`} color={summary.avgMs > 4000 ? "text-rose-400" : "text-emerald-400"} />
<Card label="p95 latency" value={`${summary.p95Ms}ms`} color={summary.p95Ms > 8000 ? "text-rose-400" : "text-amber-400"} />
<Card label="Cost" value={rupees(summary.totalCostPaise)} color="text-amber-400" />
<Card label="Total tokens" value={summary.totalTokens.toLocaleString()} color="text-purple-400" />
<Card label="Families using AI" value={summary.familiesUsingAI} color="text-emerald-400" />
<Card label="Medical redirects" value={summary.redirects} color={summary.redirects > 0 ? "text-rose-400" : "text-gray-500"} />
<Card label="Avg cost / call" value={summary.totalCalls ? rupees(summary.totalCostPaise / summary.totalCalls) : "₹0.00"} color="text-gray-300" />
</div>
{loading ? <div className="text-gray-400">Loading</div> : (
<>
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Calls per day</h3>
<div className="h-32 flex items-end gap-1">
{byDay.length === 0 ? <div className="w-full text-center text-gray-500 text-sm self-center">No AI calls in this window</div> :
byDay.slice(-30).map((d, i) => (
<div key={i} title={`${d.date}: ${d.count} calls · ${rupees(d.costPaise)}`} className="flex-1 group">
<div className="w-full bg-blue-500 group-hover:bg-blue-400 rounded-t" style={{ height: `${Math.max((d.count / maxDay) * 100, d.count > 0 ? 4 : 0)}%` }} />
</div>
))}
</div>
</div>
<div className="bg-gray-800 rounded-xl overflow-hidden">
<h3 className="text-lg font-semibold p-4 pb-3">By intent</h3>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-700"><tr>
<th className="px-4 py-2 text-left text-sm font-medium">Intent</th>
<th className="px-4 py-2 text-right text-sm font-medium">Calls</th>
<th className="px-4 py-2 text-right text-sm font-medium">Avg ms</th>
<th className="px-4 py-2 text-right text-sm font-medium">p95 ms</th>
<th className="px-4 py-2 text-right text-sm font-medium">Tokens</th>
<th className="px-4 py-2 text-right text-sm font-medium">Cost</th>
</tr></thead>
<tbody className="divide-y divide-gray-700">
{byIntent.length === 0 ? <tr><td colSpan={6} className="px-4 py-6 text-center text-gray-500">No data</td></tr> :
byIntent.map(r => (
<tr key={r.intent} className="hover:bg-gray-750">
<td className="px-4 py-2 text-sm font-medium">{r.intent}</td>
<td className="px-4 py-2 text-sm text-right">{r.count}</td>
<td className="px-4 py-2 text-sm text-right text-gray-300">{r.avgMs}</td>
<td className={`px-4 py-2 text-sm text-right ${r.p95Ms > 8000 ? "text-rose-400" : "text-gray-300"}`}>{r.p95Ms}</td>
<td className="px-4 py-2 text-sm text-right text-gray-400">{r.tokens.toLocaleString()}</td>
<td className="px-4 py-2 text-sm text-right text-amber-400">{rupees(r.costPaise)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="bg-gray-800 rounded-xl overflow-hidden">
<h3 className="text-lg font-semibold p-4 pb-3">Slowest calls</h3>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-700"><tr>
<th className="px-4 py-2 text-left text-sm font-medium">When</th>
<th className="px-4 py-2 text-left text-sm font-medium">Intent</th>
<th className="px-4 py-2 text-left text-sm font-medium">Model</th>
<th className="px-4 py-2 text-right text-sm font-medium">Duration</th>
</tr></thead>
<tbody className="divide-y divide-gray-700">
{slowest.length === 0 ? <tr><td colSpan={4} className="px-4 py-6 text-center text-gray-500">No data</td></tr> :
slowest.map(r => (
<tr key={r.id} className="hover:bg-gray-750">
<td className="px-4 py-2 text-sm text-gray-400 whitespace-nowrap">{new Date(r.createdAt).toLocaleString()}</td>
<td className="px-4 py-2 text-sm">{r.intent}</td>
<td className="px-4 py-2 text-sm text-gray-400">{r.model}</td>
<td className={`px-4 py-2 text-sm text-right font-medium ${r.durationMs > 8000 ? "text-rose-400" : "text-amber-400"}`}>{r.durationMs}ms</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
);
}
function Card({ label, value, color }: { label: string; value: string | number; color: string }) {
return (
<div className="bg-gray-800 p-4 rounded-xl">
<div className={`text-2xl font-bold ${color}`}>{value}</div>
<div className="text-sm text-gray-300 mt-0.5">{label}</div>
</div>
);
}

View file

@ -0,0 +1,134 @@
"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`;
}

View file

@ -0,0 +1,192 @@
"use client";
import { useCallback, useEffect, useState } from "react";
interface ErrorRow {
id: string;
level: string;
source: string;
message: string;
stack: string | null;
url: string | null;
digest: string | null;
user_email: string | null;
family_name: string | null;
user_agent: string | null;
created_at: string;
}
interface GroupRow { message: string; source: string; count: number; last_seen: string; first_seen: string }
interface Stats { last24h: number; last7d: number; client7d: number; server7d: number }
interface Data { events: ErrorRow[]; grouped: GroupRow[]; stats: Stats; error?: string }
const WINDOWS = [
{ label: "24h", hours: 24 },
{ label: "7d", hours: 168 },
{ label: "30d", hours: 720 },
];
export default function AdminErrors() {
const [data, setData] = useState<Data | null>(null);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<"recent" | "grouped">("recent");
const [source, setSource] = useState<"" | "client" | "server">("");
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 (source) params.set("source", source);
if (q.trim()) params.set("q", q.trim());
fetch(`/api/admin/errors?${params}`, { credentials: "include" })
.then(r => r.json())
.then(d => {
setData({
events: Array.isArray(d?.events) ? d.events : [],
grouped: Array.isArray(d?.grouped) ? d.grouped : [],
stats: d?.stats || { last24h: 0, last7d: 0, client7d: 0, server7d: 0 },
error: d?.error,
});
setLoading(false);
})
.catch(() => setLoading(false));
}, [hours, source, q]);
useEffect(() => { load(); }, [hours, source]); // eslint-disable-line react-hooks/exhaustive-deps
const stats = data?.stats || { last24h: 0, last7d: 0, client7d: 0, server7d: 0 };
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">Errors &amp; Crashes</h1>
<p className="text-gray-400">Client &amp; server errors captured automatically</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="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card label="Last 24h" value={stats.last24h} color={stats.last24h > 0 ? "text-rose-400" : "text-emerald-400"} />
<Card label="Last 7d" value={stats.last7d} color="text-amber-400" />
<Card label="Client (7d)" value={stats.client7d} color="text-blue-400" />
<Card label="Server (7d)" value={stats.server7d} color="text-purple-400" />
</div>
<div className="flex gap-2 flex-wrap items-center">
<div className="flex gap-1 bg-gray-800 p-1 rounded-lg">
{(["recent", "grouped"] as const).map(t => (
<button key={t} onClick={() => setTab(t)} className={`px-3 py-1.5 rounded-md text-sm font-medium capitalize ${tab === t ? "bg-gray-600" : "text-gray-400 hover:text-white"}`}>{t}</button>
))}
</div>
<select value={source} onChange={e => setSource(e.target.value as "" | "client" | "server")} className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm">
<option value="">All sources</option>
<option value="client">Client</option>
<option value="server">Server</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 message…" 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>
) : tab === "recent" ? (
<div className="bg-gray-800 rounded-xl overflow-hidden">
{(data?.events.length ?? 0) === 0 ? (
<div className="p-8 text-center text-gray-500">🎉 No errors in this window</div>
) : (
<div className="divide-y divide-gray-700">
{data!.events.map(e => (
<div key={e.id} className="p-4 hover:bg-gray-750">
<div className="flex items-start gap-3 cursor-pointer" onClick={() => setExpanded(expanded === e.id ? null : e.id)}>
<LevelBadge level={e.level} />
<span className={`text-xs px-1.5 py-0.5 rounded ${e.source === "client" ? "bg-blue-500/20 text-blue-400" : "bg-purple-500/20 text-purple-400"}`}>{e.source}</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium break-words">{e.message}</div>
<div className="text-xs text-gray-500 mt-0.5 flex gap-2 flex-wrap">
{e.url && <span>📍 {e.url}</span>}
{e.user_email && <span>👤 {e.user_email}</span>}
{e.family_name && <span>🏠 {e.family_name}</span>}
<span>🕒 {timeAgo(e.created_at)}</span>
</div>
</div>
<span className="text-gray-500 text-xs">{expanded === e.id ? "▲" : "▼"}</span>
</div>
{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 max-h-72">
{e.stack || "(no stack trace)"}
{e.user_agent ? `\n\nUser-Agent: ${e.user_agent}` : ""}
{e.digest ? `\nDigest: ${e.digest}` : ""}
</pre>
)}
</div>
))}
</div>
)}
</div>
) : (
<div className="bg-gray-800 rounded-xl overflow-hidden">
{(data?.grouped.length ?? 0) === 0 ? (
<div className="p-8 text-center text-gray-500">No errors to group</div>
) : (
<table className="w-full">
<thead className="bg-gray-700"><tr>
<th className="px-4 py-3 text-left text-sm font-medium">Count</th>
<th className="px-4 py-3 text-left text-sm font-medium">Source</th>
<th className="px-4 py-3 text-left text-sm font-medium">Message</th>
<th className="px-4 py-3 text-left text-sm font-medium">Last seen</th>
</tr></thead>
<tbody className="divide-y divide-gray-700">
{data!.grouped.map((g, i) => (
<tr key={i} className="hover:bg-gray-750">
<td className="px-4 py-3"><span className="bg-rose-500/20 text-rose-400 px-2 py-0.5 rounded font-bold text-sm">{g.count}</span></td>
<td className="px-4 py-3 text-sm text-gray-400">{g.source}</td>
<td className="px-4 py-3 text-sm break-words">{g.message}</td>
<td className="px-4 py-3 text-sm text-gray-500 whitespace-nowrap">{timeAgo(g.last_seen)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
);
}
function Card({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className="bg-gray-800 p-4 rounded-xl">
<div className={`text-2xl font-bold ${color}`}>{value}</div>
<div className="text-sm text-gray-300 mt-0.5">{label}</div>
</div>
);
}
function LevelBadge({ level }: { level: string }) {
const map: Record<string, string> = {
fatal: "bg-rose-600/30 text-rose-300",
error: "bg-rose-500/20 text-rose-400",
warn: "bg-amber-500/20 text-amber-400",
};
return <span className={`text-xs px-1.5 py-0.5 rounded font-medium ${map[level] || "bg-gray-700 text-gray-400"}`}>{level}</span>;
}
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`;
}

View file

@ -0,0 +1,68 @@
"use client";
import { useCallback, useEffect, useState } from "react";
interface Check { name: string; status: "ok" | "warn" | "down"; detail: string }
interface Data { overall: "ok" | "warn" | "down"; checks: Check[]; checkedAt: string; error?: string }
const STATUS = {
ok: { dot: "bg-emerald-400", text: "text-emerald-400", label: "Healthy" },
warn: { dot: "bg-amber-400", text: "text-amber-400", label: "Degraded" },
down: { dot: "bg-rose-500", text: "text-rose-400", label: "Down" },
};
export default function AdminHealth() {
const [data, setData] = useState<Data | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(() => {
setLoading(true);
fetch("/api/admin/health", { credentials: "include" })
.then(r => r.json())
.then(d => { setData(d); setLoading(false); })
.catch(() => { setData(null); setLoading(false); });
}, []);
useEffect(() => { load(); }, [load]);
const overall = data?.overall || "warn";
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">System Health</h1>
<p className="text-gray-400">{data?.checkedAt ? `Checked ${new Date(data.checkedAt).toLocaleTimeString()}` : "Live status of core services"}</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="bg-gray-800 p-6 rounded-xl flex items-center gap-4">
<span className={`w-4 h-4 rounded-full ${STATUS[overall].dot} ${overall !== "ok" ? "animate-pulse" : ""}`} />
<div>
<div className={`text-xl font-bold ${STATUS[overall].text}`}>{STATUS[overall].label}</div>
<div className="text-sm text-gray-400">Overall system status</div>
</div>
</div>
{loading ? (
<div className="text-gray-400">Running checks</div>
) : !data ? (
<div className="bg-rose-500/10 text-rose-400 p-4 rounded-xl">Failed to load health checks.</div>
) : (
<div className="bg-gray-800 rounded-xl divide-y divide-gray-700">
{data.checks.map(c => (
<div key={c.name} className="flex items-center gap-4 p-4">
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${STATUS[c.status].dot}`} />
<div className="flex-1 min-w-0">
<div className="font-medium">{c.name}</div>
<div className="text-sm text-gray-400 break-words">{c.detail}</div>
</div>
<span className={`text-xs font-medium uppercase ${STATUS[c.status].text}`}>{c.status}</span>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,101 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { sql } from "@/db";
// AI observability feed over ai_usage: headline totals, per-intent latency &
// cost breakdown (incl. p95), daily trend, and the slowest recent calls.
export async function GET(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const { searchParams } = new URL(request.url);
const days = Math.min(Math.max(parseInt(searchParams.get("days") || "30", 10) || 30, 1), 365);
try {
const summaryRows = await sql`
SELECT
COUNT(*)::int AS total_calls,
COALESCE(SUM(total_tokens), 0)::int AS total_tokens,
COALESCE(SUM(cost_estimate_paise), 0)::numeric AS total_cost_paise,
COUNT(DISTINCT family_id)::int AS families_using_ai,
COALESCE(AVG(duration_ms), 0)::int AS avg_ms,
COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms), 0)::int AS p95_ms,
COUNT(*) FILTER (WHERE intent = 'medical_redirect')::int AS redirects
FROM ai_usage
WHERE created_at > NOW() - make_interval(days => ${days})
`;
const byIntent = await sql`
SELECT
COALESCE(intent, 'unknown') AS intent,
COUNT(*)::int AS count,
COALESCE(AVG(duration_ms), 0)::int AS avg_ms,
COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms), 0)::int AS p95_ms,
COALESCE(SUM(total_tokens), 0)::int AS tokens,
COALESCE(SUM(cost_estimate_paise), 0)::numeric AS cost_paise
FROM ai_usage
WHERE created_at > NOW() - make_interval(days => ${days})
GROUP BY intent
ORDER BY count DESC
`;
const byDay = await sql`
SELECT DATE(created_at) AS date, COUNT(*)::int AS count,
COALESCE(SUM(cost_estimate_paise), 0)::numeric AS cost_paise
FROM ai_usage
WHERE created_at > NOW() - make_interval(days => ${days})
GROUP BY DATE(created_at)
ORDER BY date
`;
const slowest = await sql`
SELECT id, COALESCE(intent, 'unknown') AS intent, duration_ms, model_used, created_at, family_id
FROM ai_usage
WHERE created_at > NOW() - make_interval(days => ${days}) AND duration_ms IS NOT NULL
ORDER BY duration_ms DESC
LIMIT 10
`;
const s = summaryRows[0] || {};
return NextResponse.json({
days,
summary: {
totalCalls: Number(s.total_calls) || 0,
totalTokens: Number(s.total_tokens) || 0,
totalCostPaise: Number(s.total_cost_paise) || 0,
familiesUsingAI: Number(s.families_using_ai) || 0,
avgMs: Number(s.avg_ms) || 0,
p95Ms: Number(s.p95_ms) || 0,
redirects: Number(s.redirects) || 0,
},
byIntent: byIntent.map((r: Record<string, unknown>) => ({
intent: r.intent,
count: Number(r.count) || 0,
avgMs: Number(r.avg_ms) || 0,
p95Ms: Number(r.p95_ms) || 0,
tokens: Number(r.tokens) || 0,
costPaise: Number(r.cost_paise) || 0,
})),
byDay: byDay.map((r: Record<string, unknown>) => ({
date: r.date instanceof Date ? r.date.toISOString().split("T")[0] : String(r.date).split("T")[0],
count: Number(r.count) || 0,
costPaise: Number(r.cost_paise) || 0,
})),
slowest: slowest.map((r: Record<string, unknown>) => ({
id: r.id,
intent: r.intent,
durationMs: Number(r.duration_ms) || 0,
model: r.model_used || "—",
createdAt: r.created_at instanceof Date ? (r.created_at as Date).toISOString() : String(r.created_at),
familyId: r.family_id,
})),
});
} catch (error) {
console.error("Admin AI feed error:", error);
return NextResponse.json({
days,
summary: { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0, avgMs: 0, p95Ms: 0, redirects: 0 },
byIntent: [], byDay: [], slowest: [], error: String(error),
});
}
}

View file

@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { sql } from "@/db";
// Full audit-log viewer feed — every action (not just auth), filterable by
// action / resource type / family / user / free-text, with the joined user
// email and family name for context.
export async function GET(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const { searchParams } = new URL(request.url);
const action = searchParams.get("action")?.trim();
const resourceType = searchParams.get("resourceType")?.trim();
const familyId = searchParams.get("familyId")?.trim();
const userId = searchParams.get("userId")?.trim();
const q = searchParams.get("q")?.trim();
const sinceHours = Math.min(Math.max(parseInt(searchParams.get("sinceHours") || "168", 10) || 168, 1), 24 * 90);
const limit = Math.min(Math.max(parseInt(searchParams.get("limit") || "100", 10) || 100, 1), 500);
try {
const where: string[] = [`al.created_at > NOW() - make_interval(hours => $1)`];
const params: unknown[] = [sinceHours];
if (action) { params.push(action); where.push(`al.action = $${params.length}`); }
if (resourceType) { params.push(resourceType); where.push(`al.resource_type = $${params.length}`); }
if (familyId) { params.push(familyId); where.push(`al.family_id = $${params.length}::uuid`); }
if (userId) { params.push(userId); where.push(`al.user_id = $${params.length}::uuid`); }
if (q) { params.push(`%${q}%`); where.push(`(u.email ILIKE $${params.length} OR f.name ILIKE $${params.length} OR al.action ILIKE $${params.length})`); }
const whereSql = where.join(" AND ");
const events = await sql.unsafe(
`SELECT al.id, al.action, al.resource_type, al.resource_id, al.ip_address,
al.user_agent, al.metadata, al.created_at, al.user_id, al.family_id,
u.email AS user_email, u.name AS user_name, f.name AS family_name
FROM audit_log al
LEFT JOIN users u ON u.id = al.user_id
LEFT JOIN families f ON f.id = al.family_id
WHERE ${whereSql}
ORDER BY al.created_at DESC
LIMIT ${limit}`,
params as never[]
);
// Distinct action + resource-type values for the filter dropdowns.
const actions = await sql`SELECT DISTINCT action FROM audit_log ORDER BY action`;
const resourceTypes = await sql`SELECT DISTINCT resource_type FROM audit_log WHERE resource_type IS NOT NULL ORDER BY resource_type`;
return NextResponse.json({
events,
actions: actions.map(r => (r as { action: string }).action),
resourceTypes: resourceTypes.map(r => (r as { resource_type: string }).resource_type),
});
} catch (error) {
console.error("Admin audit feed error:", error);
return NextResponse.json({ events: [], actions: [], resourceTypes: [], error: String(error) });
}
}

View file

@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { sql } from "@/db";
// Admin error tracker feed. Returns recent error events (filterable), a grouped
// "top errors" rollup, and headline counts.
export async function GET(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const { searchParams } = new URL(request.url);
const source = searchParams.get("source"); // client | server | null
const level = searchParams.get("level"); // error | warn | fatal | null
const q = searchParams.get("q")?.trim();
const sinceHours = Math.min(Math.max(parseInt(searchParams.get("sinceHours") || "168", 10) || 168, 1), 24 * 90);
const limit = Math.min(Math.max(parseInt(searchParams.get("limit") || "100", 10) || 100, 1), 500);
try {
// Build dynamic WHERE with positional params.
const where: string[] = [`e.created_at > NOW() - make_interval(hours => $1)`];
const params: unknown[] = [sinceHours];
if (source === "client" || source === "server") { params.push(source); where.push(`e.source = $${params.length}`); }
if (level === "error" || level === "warn" || level === "fatal") { params.push(level); where.push(`e.level = $${params.length}`); }
if (q) { params.push(`%${q}%`); where.push(`e.message ILIKE $${params.length}`); }
const whereSql = where.join(" AND ");
const events = await sql.unsafe(
`SELECT e.id, e.level, e.source, e.message, e.stack, e.url, e.digest,
e.user_id, e.family_id, e.user_agent, e.created_at,
u.email AS user_email, f.name AS family_name
FROM error_events e
LEFT JOIN users u ON u.id = e.user_id
LEFT JOIN families f ON f.id = e.family_id
WHERE ${whereSql}
ORDER BY e.created_at DESC
LIMIT ${limit}`,
params as never[]
);
const grouped = await sql.unsafe(
`SELECT message, source,
COUNT(*)::int AS count,
MAX(created_at) AS last_seen,
MIN(created_at) AS first_seen
FROM error_events e
WHERE ${whereSql}
GROUP BY message, source
ORDER BY count DESC
LIMIT 50`,
params as never[]
);
const stats = await sql`
SELECT
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '24 hours')::int AS last24h,
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '7 days')::int AS last7d,
COUNT(*) FILTER (WHERE source = 'client' AND created_at > NOW() - INTERVAL '7 days')::int AS client7d,
COUNT(*) FILTER (WHERE source = 'server' AND created_at > NOW() - INTERVAL '7 days')::int AS server7d
FROM error_events
`;
return NextResponse.json({
events,
grouped,
stats: stats[0] || { last24h: 0, last7d: 0, client7d: 0, server7d: 0 },
});
} catch (error) {
console.error("Admin errors feed error:", error);
return NextResponse.json({ events: [], grouped: [], stats: { last24h: 0, last7d: 0, client7d: 0, server7d: 0 }, error: String(error) });
}
}

View file

@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { sql } from "@/db";
type Check = { name: string; status: "ok" | "warn" | "down"; detail: string };
// System health snapshot: DB connectivity + latency, migration status, recent
// error volume, and which integrations are configured. Read-only and cheap —
// no external round-trips, just config presence + DB queries.
export async function GET(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const checks: Check[] = [];
// 1. Database connectivity + latency
let dbOk = false;
try {
const t0 = Date.now();
await sql`SELECT 1`;
const ms = Date.now() - t0;
dbOk = true;
checks.push({ name: "Database", status: ms < 500 ? "ok" : "warn", detail: `Connected — ${ms}ms` });
} catch (e) {
checks.push({ name: "Database", status: "down", detail: String(e).slice(0, 200) });
}
// 2. Migrations applied (drizzle stores its journal in drizzle.__drizzle_migrations)
if (dbOk) {
try {
const rows = await sql`
SELECT COUNT(*)::int AS count, MAX(created_at) AS latest
FROM drizzle.__drizzle_migrations
`;
const count = Number(rows[0]?.count) || 0;
const latest = rows[0]?.latest ? new Date(Number(rows[0].latest)).toISOString().split("T")[0] : "—";
checks.push({ name: "Migrations", status: count > 0 ? "ok" : "warn", detail: `${count} applied (latest ${latest})` });
} catch {
checks.push({ name: "Migrations", status: "warn", detail: "Could not read migration journal" });
}
}
// 3. Recent error volume (from the error tracker)
let recentErrors = { last24h: 0, last1h: 0 };
if (dbOk) {
try {
const rows = await sql`
SELECT
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '24 hours')::int AS last24h,
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '1 hour')::int AS last1h
FROM error_events
`;
recentErrors = { last24h: Number(rows[0]?.last24h) || 0, last1h: Number(rows[0]?.last1h) || 0 };
checks.push({
name: "Errors (24h)",
status: recentErrors.last1h > 5 ? "down" : recentErrors.last24h > 0 ? "warn" : "ok",
detail: `${recentErrors.last24h} in 24h · ${recentErrors.last1h} in last hour`,
});
} catch {
checks.push({ name: "Errors (24h)", status: "warn", detail: "error_events table unavailable" });
}
}
// 4. Integration config presence (no live calls — just whether env is wired)
const configCheck = (name: string, present: boolean, missingHint: string): Check =>
({ name, status: present ? "ok" : "warn", detail: present ? "Configured" : missingHint });
checks.push(configCheck("AI Gateway", !!(process.env.LITELLM_BASE_URL && process.env.LITELLM_API_KEY), "LITELLM_BASE_URL / LITELLM_API_KEY not set"));
checks.push(configCheck("R2 Storage", !!(process.env.R2_ACCOUNT_ID && process.env.R2_ACCESS_KEY_ID && process.env.R2_BUCKET_NAME), "R2_* env vars incomplete"));
checks.push(configCheck("Email (Resend)", !!process.env.RESEND_API_KEY, "RESEND_API_KEY not set"));
const overall: "ok" | "warn" | "down" = checks.some(c => c.status === "down")
? "down"
: checks.some(c => c.status === "warn") ? "warn" : "ok";
return NextResponse.json({ overall, checks, recentErrors, checkedAt: new Date().toISOString() });
}

View file

@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { logError } from "@/lib/error-log";
import { validateSession } from "@/lib/auth";
// Ingest endpoint for client-side error reports (from the error boundaries).
// Auth-optional: we attach the user/family if a session exists, but we never
// reject an error report for being unauthenticated — crashes can happen on
// public/logged-out screens too.
export async function POST(request: Request) {
try {
const body = await request.json().catch(() => ({}));
const { message, stack, url, digest, level, metadata } = body as {
message?: string;
stack?: string;
url?: string;
digest?: string;
level?: "error" | "warn" | "fatal";
metadata?: Record<string, unknown>;
};
if (!message || typeof message !== "string") {
return NextResponse.json({ error: "message required" }, { status: 400 });
}
let userId: string | null = null;
let familyId: string | null = null;
try {
const auth = await validateSession();
userId = auth.session?.userId ?? null;
familyId = auth.session?.familyId ?? null;
} catch {
// best-effort only
}
await logError({
source: "client",
level: level === "warn" || level === "fatal" ? level : "error",
message,
stack,
url,
digest,
userId,
familyId,
userAgent: request.headers.get("user-agent"),
metadata: metadata && typeof metadata === "object" ? metadata : {},
});
return NextResponse.json({ ok: true });
} catch {
// Swallow — the reporter must never surface its own error to the user.
return NextResponse.json({ ok: false });
}
}

54
src/app/global-error.tsx Normal file
View file

@ -0,0 +1,54 @@
"use client";
import { useEffect } from "react";
// Root-level error boundary. Catches errors thrown in the root layout / during
// rendering that no nested error.tsx caught. Must render its own <html>/<body>.
// Reports the crash to /api/errors so it shows up in the admin error tracker.
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
try {
const body = JSON.stringify({
message: error?.message || "Unknown global error",
stack: error?.stack,
digest: error?.digest,
url: typeof window !== "undefined" ? window.location.pathname : undefined,
level: "fatal",
metadata: { boundary: "global" },
});
// keepalive so the report survives the page being torn down
fetch("/api/errors", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
keepalive: true,
}).catch(() => {});
} catch {}
}, [error]);
return (
<html lang="en">
<body style={{ fontFamily: "system-ui, sans-serif", background: "#fdf2f2", color: "#1a1a1a" }}>
<div style={{ minHeight: "100vh", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 16, padding: 24, textAlign: "center" }}>
<div style={{ fontSize: 40 }}>😵</div>
<h1 style={{ fontSize: 20, fontWeight: 700 }}>Something went wrong</h1>
<p style={{ color: "#6b7280", maxWidth: 360 }}>
The app hit an unexpected error. It&apos;s been reported automatically please try again.
</p>
<button
onClick={() => reset()}
style={{ background: "#fb7185", color: "white", border: "none", padding: "10px 20px", borderRadius: 12, fontWeight: 600, cursor: "pointer" }}
>
Try again
</button>
</div>
</body>
</html>
);
}

View file

@ -56,5 +56,31 @@ export const logCorrections = pgTable(
(table) => [index("log_corrections_dose_idx").on(table.doseId)]
);
// Error / crash events (client + server). Written by src/lib/error-log.ts and
// the error boundaries; surfaced in the admin panel at /admin/errors.
export const errorEvents = pgTable(
"error_events",
{
id: uuid("id").defaultRandom().primaryKey(),
level: varchar("level", { length: 20 }).notNull().default("error"),
source: varchar("source", { length: 20 }).notNull().default("client"),
message: text("message").notNull(),
stack: text("stack"),
url: text("url"),
digest: varchar("digest", { length: 120 }),
userId: uuid("user_id"),
familyId: uuid("family_id"),
userAgent: text("user_agent"),
metadata: jsonb("metadata").default({}),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("idx_error_events_created").on(table.createdAt),
index("idx_error_events_source").on(table.source),
index("idx_error_events_message").on(table.message),
]
);
export type AuditLog = typeof auditLog.$inferSelect;
export type LogCorrection = typeof logCorrections.$inferSelect;
export type ErrorEvent = typeof errorEvents.$inferSelect;

32
src/lib/error-log.ts Normal file
View file

@ -0,0 +1,32 @@
import { sql } from "@/db";
interface LogErrorOpts {
message: string;
stack?: string | null;
source?: "client" | "server";
level?: "error" | "warn" | "fatal";
url?: string | null;
digest?: string | null;
userId?: string | null;
familyId?: string | null;
userAgent?: string | null;
metadata?: Record<string, unknown>;
}
// Persist an error event. Mirrors logAudit(): best-effort, never throws — a
// failure to record an error must never cascade into another error.
export async function logError(opts: LogErrorOpts) {
try {
const message = (opts.message || "Unknown error").slice(0, 2000);
const stack = opts.stack ? opts.stack.slice(0, 8000) : null;
await sql`
INSERT INTO error_events (level, source, message, stack, url, digest, user_id, family_id, user_agent, metadata)
VALUES (
${opts.level || "error"}, ${opts.source || "server"}, ${message}, ${stack},
${opts.url || null}, ${opts.digest || null}, ${opts.userId || null}, ${opts.familyId || null},
${opts.userAgent || null}, ${JSON.stringify(opts.metadata || {})}
)`;
} catch (e) {
console.error("logError failed:", e);
}
}