tia/src/app/api/admin/health/route.ts
Mannu 7a60132bb2 Add admin observability: error tracking, audit viewer, AI metrics, health
Turns the admin panel into a real monitoring tool so production bugs are
visible instead of silent.

- Error & crash tracking: error_events table (migration 0010) + logError()
  helper + /api/errors ingest; global-error.tsx and (app)/error.tsx report
  crashes automatically; /admin/errors viewer (recent + grouped, filters).
- Full audit-log viewer at /admin/audit over the existing audit_log (all
  actions, not just auth) with action/resource/family/user/text filters.
- AI observability at /admin/ai over ai_usage: per-intent latency (avg/p95),
  tokens, cost, daily trend, slowest calls, medical-redirect count.
- System health at /admin/health: DB latency, migration status, recent error
  volume, and integration config presence.
- Sidebar updated with Health / Errors / Audit Log / AI Usage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 00:27:07 +05:30

77 lines
3.4 KiB
TypeScript

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() });
}