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:
parent
94d9b234f8
commit
7a60132bb2
16 changed files with 1093 additions and 0 deletions
22
drizzle/0010_error_events.sql
Normal file
22
drizzle/0010_error_events.sql
Normal 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);
|
||||||
|
|
@ -71,6 +71,13 @@
|
||||||
"when": 1748880000000,
|
"when": 1748880000000,
|
||||||
"tag": "0009_notifications",
|
"tag": "0009_notifications",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1749139200000,
|
||||||
|
"tag": "0010_error_events",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
48
src/app/(app)/error.tsx
Normal file
48
src/app/(app)/error.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,12 +12,16 @@ interface NavItem {
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ name: "Dashboard", href: "/admin", icon: "📊" },
|
{ name: "Dashboard", href: "/admin", icon: "📊" },
|
||||||
|
{ name: "Health", href: "/admin/health", icon: "❤️🩹" },
|
||||||
|
{ name: "Errors", href: "/admin/errors", icon: "🐞" },
|
||||||
{ name: "Activity", href: "/admin/activity", icon: "🔍" },
|
{ name: "Activity", href: "/admin/activity", icon: "🔍" },
|
||||||
|
{ name: "Audit Log", href: "/admin/audit", icon: "📜" },
|
||||||
{ name: "Families", href: "/admin/families", icon: "🏠" },
|
{ name: "Families", href: "/admin/families", icon: "🏠" },
|
||||||
{ name: "Users", href: "/admin/users", icon: "👥" },
|
{ name: "Users", href: "/admin/users", icon: "👥" },
|
||||||
{ name: "Children", href: "/admin/children", icon: "👶" },
|
{ name: "Children", href: "/admin/children", icon: "👶" },
|
||||||
{ name: "Revenue", href: "/admin/revenue", icon: "💰" },
|
{ name: "Revenue", href: "/admin/revenue", icon: "💰" },
|
||||||
{ name: "Analytics", href: "/admin/analytics", icon: "📈" },
|
{ name: "Analytics", href: "/admin/analytics", icon: "📈" },
|
||||||
|
{ name: "AI Usage", href: "/admin/ai", icon: "🤖" },
|
||||||
{ name: "Support", href: "/admin/support", icon: "🎫" },
|
{ name: "Support", href: "/admin/support", icon: "🎫" },
|
||||||
{ name: "Settings", href: "/admin/settings", icon: "⚙️" },
|
{ name: "Settings", href: "/admin/settings", icon: "⚙️" },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
147
src/app/admin/ai/page.tsx
Normal file
147
src/app/admin/ai/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/app/admin/audit/page.tsx
Normal file
134
src/app/admin/audit/page.tsx
Normal 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`;
|
||||||
|
}
|
||||||
192
src/app/admin/errors/page.tsx
Normal file
192
src/app/admin/errors/page.tsx
Normal 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 & Crashes</h1>
|
||||||
|
<p className="text-gray-400">Client & 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`;
|
||||||
|
}
|
||||||
68
src/app/admin/health/page.tsx
Normal file
68
src/app/admin/health/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
src/app/api/admin/ai/route.ts
Normal file
101
src/app/api/admin/ai/route.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/app/api/admin/audit/route.ts
Normal file
57
src/app/api/admin/audit/route.ts
Normal 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) });
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/app/api/admin/errors/route.ts
Normal file
71
src/app/api/admin/errors/route.ts
Normal 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) });
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/app/api/admin/health/route.ts
Normal file
77
src/app/api/admin/health/route.ts
Normal 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() });
|
||||||
|
}
|
||||||
53
src/app/api/errors/route.ts
Normal file
53
src/app/api/errors/route.ts
Normal 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
54
src/app/global-error.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -56,5 +56,31 @@ export const logCorrections = pgTable(
|
||||||
(table) => [index("log_corrections_dose_idx").on(table.doseId)]
|
(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 AuditLog = typeof auditLog.$inferSelect;
|
||||||
export type LogCorrection = typeof logCorrections.$inferSelect;
|
export type LogCorrection = typeof logCorrections.$inferSelect;
|
||||||
|
export type ErrorEvent = typeof errorEvents.$inferSelect;
|
||||||
|
|
|
||||||
32
src/lib/error-log.ts
Normal file
32
src/lib/error-log.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue