From 7a60132bb29d25bb1ade8c5eb337b5bc60fda5c5 Mon Sep 17 00:00:00 2001 From: Mannu Date: Sat, 30 May 2026 00:27:07 +0530 Subject: [PATCH] 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 --- drizzle/0010_error_events.sql | 22 ++++ drizzle/meta/_journal.json | 7 ++ src/app/(app)/error.tsx | 48 ++++++++ src/app/admin/AdminSidebar.tsx | 4 + src/app/admin/ai/page.tsx | 147 +++++++++++++++++++++++ src/app/admin/audit/page.tsx | 134 +++++++++++++++++++++ src/app/admin/errors/page.tsx | 192 ++++++++++++++++++++++++++++++ src/app/admin/health/page.tsx | 68 +++++++++++ src/app/api/admin/ai/route.ts | 101 ++++++++++++++++ src/app/api/admin/audit/route.ts | 57 +++++++++ src/app/api/admin/errors/route.ts | 71 +++++++++++ src/app/api/admin/health/route.ts | 77 ++++++++++++ src/app/api/errors/route.ts | 53 +++++++++ src/app/global-error.tsx | 54 +++++++++ src/db/schema/audit.ts | 26 ++++ src/lib/error-log.ts | 32 +++++ 16 files changed, 1093 insertions(+) create mode 100644 drizzle/0010_error_events.sql create mode 100644 src/app/(app)/error.tsx create mode 100644 src/app/admin/ai/page.tsx create mode 100644 src/app/admin/audit/page.tsx create mode 100644 src/app/admin/errors/page.tsx create mode 100644 src/app/admin/health/page.tsx create mode 100644 src/app/api/admin/ai/route.ts create mode 100644 src/app/api/admin/audit/route.ts create mode 100644 src/app/api/admin/errors/route.ts create mode 100644 src/app/api/admin/health/route.ts create mode 100644 src/app/api/errors/route.ts create mode 100644 src/app/global-error.tsx create mode 100644 src/lib/error-log.ts diff --git a/drizzle/0010_error_events.sql b/drizzle/0010_error_events.sql new file mode 100644 index 0000000..c884785 --- /dev/null +++ b/drizzle/0010_error_events.sql @@ -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); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index de5268c..6bf24a7 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1748880000000, "tag": "0009_notifications", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1749139200000, + "tag": "0010_error_events", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/(app)/error.tsx b/src/app/(app)/error.tsx new file mode 100644 index 0000000..635090c --- /dev/null +++ b/src/app/(app)/error.tsx @@ -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 ( +
+
😵
+

Something went wrong

+

+ This screen hit an unexpected error. It's been reported automatically. +

+ +
+ ); +} diff --git a/src/app/admin/AdminSidebar.tsx b/src/app/admin/AdminSidebar.tsx index 2f74abb..d93c95e 100644 --- a/src/app/admin/AdminSidebar.tsx +++ b/src/app/admin/AdminSidebar.tsx @@ -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: "⚙️" }, ]; diff --git a/src/app/admin/ai/page.tsx b/src/app/admin/ai/page.tsx new file mode 100644 index 0000000..41e7440 --- /dev/null +++ b/src/app/admin/ai/page.tsx @@ -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(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 ( +
+
+
+

AI Observability

+

Latency, cost, and intent breakdown over the last {days} days

+
+ +
+ + {data.error &&
Feed error: {data.error}
} + +
+ + 4000 ? "text-rose-400" : "text-emerald-400"} /> + 8000 ? "text-rose-400" : "text-amber-400"} /> + + + + 0 ? "text-rose-400" : "text-gray-500"} /> + +
+ + {loading ?
Loading…
: ( + <> +
+

Calls per day

+
+ {byDay.length === 0 ?
No AI calls in this window
: + byDay.slice(-30).map((d, i) => ( +
+
0 ? 4 : 0)}%` }} /> +
+ ))} +
+
+ +
+

By intent

+
+ + + + + + + + + + + {byIntent.length === 0 ? : + byIntent.map(r => ( + + + + + + + + + ))} + +
IntentCallsAvg msp95 msTokensCost
No data
{r.intent}{r.count}{r.avgMs} 8000 ? "text-rose-400" : "text-gray-300"}`}>{r.p95Ms}{r.tokens.toLocaleString()}{rupees(r.costPaise)}
+
+
+ +
+

Slowest calls

+
+ + + + + + + + + {slowest.length === 0 ? : + slowest.map(r => ( + + + + + + + ))} + +
WhenIntentModelDuration
No data
{new Date(r.createdAt).toLocaleString()}{r.intent}{r.model} 8000 ? "text-rose-400" : "text-amber-400"}`}>{r.durationMs}ms
+
+
+ + )} +
+ ); +} + +function Card({ label, value, color }: { label: string; value: string | number; color: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} diff --git a/src/app/admin/audit/page.tsx b/src/app/admin/audit/page.tsx new file mode 100644 index 0000000..b47c217 --- /dev/null +++ b/src/app/admin/audit/page.tsx @@ -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 | 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(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(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 ( +
+
+
+

Audit Log

+

Every recorded action across the platform

+
+ +
+ +
+ + +
+ {WINDOWS.map(w => ( + + ))} +
+
{ e.preventDefault(); load(); }} className="flex-1 min-w-[180px]"> + 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" /> +
+
+ + {data?.error &&
Feed error: {data.error}
} + + {loading ? ( +
Loading…
+ ) : ( +
+ {(data?.events.length ?? 0) === 0 ? ( +
No audit events match these filters
+ ) : ( +
+ {data!.events.map(e => { + const hasMeta = e.metadata && Object.keys(e.metadata).length > 0; + return ( +
+
hasMeta && setExpanded(expanded === e.id ? null : e.id)}> + {e.action} + {e.resource_type && {e.resource_type}} +
+ {(e.user_email || e.user_name) && 👤 {e.user_name || e.user_email}} + {e.family_name && 🏠 {e.family_name}} + {e.ip_address && 🌐 {e.ip_address}} + 🕒 {timeAgo(e.created_at)} +
+ {hasMeta && {expanded === e.id ? "▲" : "▼"}} +
+ {hasMeta && expanded === e.id && ( +
{JSON.stringify(e.metadata, null, 2)}
+ )} +
+ ); + })} +
+ )} +
+ )} +
+ ); +} + +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`; +} diff --git a/src/app/admin/errors/page.tsx b/src/app/admin/errors/page.tsx new file mode 100644 index 0000000..8043aa5 --- /dev/null +++ b/src/app/admin/errors/page.tsx @@ -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(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(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 ( +
+
+
+

Errors & Crashes

+

Client & server errors captured automatically

+
+ +
+ +
+ 0 ? "text-rose-400" : "text-emerald-400"} /> + + + +
+ +
+
+ {(["recent", "grouped"] as const).map(t => ( + + ))} +
+ +
+ {WINDOWS.map(w => ( + + ))} +
+
{ e.preventDefault(); load(); }} className="flex-1 min-w-[180px]"> + 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" /> +
+
+ + {data?.error &&
Feed error: {data.error}
} + + {loading ? ( +
Loading…
+ ) : tab === "recent" ? ( +
+ {(data?.events.length ?? 0) === 0 ? ( +
🎉 No errors in this window
+ ) : ( +
+ {data!.events.map(e => ( +
+
setExpanded(expanded === e.id ? null : e.id)}> + + {e.source} +
+
{e.message}
+
+ {e.url && 📍 {e.url}} + {e.user_email && 👤 {e.user_email}} + {e.family_name && 🏠 {e.family_name}} + 🕒 {timeAgo(e.created_at)} +
+
+ {expanded === e.id ? "▲" : "▼"} +
+ {expanded === e.id && ( +
+                      {e.stack || "(no stack trace)"}
+                      {e.user_agent ? `\n\nUser-Agent: ${e.user_agent}` : ""}
+                      {e.digest ? `\nDigest: ${e.digest}` : ""}
+                    
+ )} +
+ ))} +
+ )} +
+ ) : ( +
+ {(data?.grouped.length ?? 0) === 0 ? ( +
No errors to group
+ ) : ( + + + + + + + + + {data!.grouped.map((g, i) => ( + + + + + + + ))} + +
CountSourceMessageLast seen
{g.count}{g.source}{g.message}{timeAgo(g.last_seen)}
+ )} +
+ )} +
+ ); +} + +function Card({ label, value, color }: { label: string; value: number; color: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +function LevelBadge({ level }: { level: string }) { + const map: Record = { + 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 {level}; +} + +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`; +} diff --git a/src/app/admin/health/page.tsx b/src/app/admin/health/page.tsx new file mode 100644 index 0000000..5c71d5f --- /dev/null +++ b/src/app/admin/health/page.tsx @@ -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(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 ( +
+
+
+

System Health

+

{data?.checkedAt ? `Checked ${new Date(data.checkedAt).toLocaleTimeString()}` : "Live status of core services"}

+
+ +
+ +
+ +
+
{STATUS[overall].label}
+
Overall system status
+
+
+ + {loading ? ( +
Running checks…
+ ) : !data ? ( +
Failed to load health checks.
+ ) : ( +
+ {data.checks.map(c => ( +
+ +
+
{c.name}
+
{c.detail}
+
+ {c.status} +
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/api/admin/ai/route.ts b/src/app/api/admin/ai/route.ts new file mode 100644 index 0000000..f8ad7c6 --- /dev/null +++ b/src/app/api/admin/ai/route.ts @@ -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) => ({ + 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) => ({ + 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) => ({ + 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), + }); + } +} diff --git a/src/app/api/admin/audit/route.ts b/src/app/api/admin/audit/route.ts new file mode 100644 index 0000000..e57e7b3 --- /dev/null +++ b/src/app/api/admin/audit/route.ts @@ -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) }); + } +} diff --git a/src/app/api/admin/errors/route.ts b/src/app/api/admin/errors/route.ts new file mode 100644 index 0000000..7f6b091 --- /dev/null +++ b/src/app/api/admin/errors/route.ts @@ -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) }); + } +} diff --git a/src/app/api/admin/health/route.ts b/src/app/api/admin/health/route.ts new file mode 100644 index 0000000..6aea23b --- /dev/null +++ b/src/app/api/admin/health/route.ts @@ -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() }); +} diff --git a/src/app/api/errors/route.ts b/src/app/api/errors/route.ts new file mode 100644 index 0000000..2ca7144 --- /dev/null +++ b/src/app/api/errors/route.ts @@ -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; + }; + + 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 }); + } +} diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 0000000..0d20c2a --- /dev/null +++ b/src/app/global-error.tsx @@ -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 /. +// 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 ( + + +
+
😵
+

Something went wrong

+

+ The app hit an unexpected error. It's been reported automatically — please try again. +

+ +
+ + + ); +} diff --git a/src/db/schema/audit.ts b/src/db/schema/audit.ts index f6fc935..e035238 100644 --- a/src/db/schema/audit.ts +++ b/src/db/schema/audit.ts @@ -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; diff --git a/src/lib/error-log.ts b/src/lib/error-log.ts new file mode 100644 index 0000000..6d125e1 --- /dev/null +++ b/src/lib/error-log.ts @@ -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; +} + +// 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); + } +}