spb/wp-spboucher-ai
Public
1import { sql } from "@/db";2import { requireUserPage } from "@/lib/auth/guard";3import { csrfToken } from "@/lib/auth/session";4import { systemAction } from "@/lib/admin/actions";5import { Csrf, Notice, PageTitle } from "@/components/admin-ui";6import { embeddingsEnabled, verifyEmbeddingModel } from "@/lib/ai/embeddings";7import { STORAGE_DIR } from "@/lib/env";8export default async function System({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {9 const sp = await searchParams; const u = await requireUserPage(); const csrf = csrfToken(u);10 const [jobs, backups, [storage], ai] = await Promise.all([sql`SELECT * FROM jobs ORDER BY created_at DESC LIMIT 60`, sql`SELECT * FROM backups ORDER BY created_at DESC LIMIT 15`, sql`SELECT count(*)::int AS files, coalesce(sum(bytes), 0)::bigint AS bytes FROM files`, embeddingsEnabled() ? verifyEmbeddingModel() : Promise.resolve({ ok: false, model: "—", reason: "AI provider disabled (AI_PROVIDER=none)" })]);11 const Btn = ({ op, label }: { op: string; label: string }) => <form action={systemAction}><Csrf token={csrf} /><input type="hidden" name="op" value={op} /><button className="btn btn-sm">{label}</button></form>;12 return (<div><PageTitle title="System" /><Notice sp={sp} />13 <div className="flex flex-wrap gap-2 mb-6"><Btn op="backup" label="Run backup now" /><Btn op="relations" label="Rebuild relations" /><Btn op="stats" label="Aggregate stats now" /><Btn op="embed_all" label="Re-embed all papers" /><Btn op="retry" label="Retry failed jobs" /></div>14 <div className="grid md:grid-cols-3 gap-3 mb-6"><div className="card p-4 text-sm"><p className="label">Storage</p><p>{storage.files} files · {(Number(storage.bytes) / 1e6).toFixed(1)} MB</p><p className="text-xs text-fg-3 break-all">{STORAGE_DIR}</p></div><div className="card p-4 text-sm"><p className="label">AI embeddings</p><p>{ai.ok ? <span className="text-ok">OK · {ai.model}</span> : <span className="text-warn">off · {ai.reason}</span>}</p><p className="text-xs text-fg-3">Optional. Search and relations degrade to lexical signals without it.</p></div><div className="card p-4 text-sm"><p className="label">Worker schedule</p><p>stats 00:10 · relations 03:00 · backup 04:40 (node local time)</p></div></div>15 <section className="card p-4 mb-6"><h2 className="text-lg mb-2">Backups</h2><table className="data"><thead><tr><th>When</th><th>OK</th><th>Remote</th><th>Size</th><th>Log</th></tr></thead><tbody>{backups.map((b) => <tr key={b.id}><td className="whitespace-nowrap text-xs">{new Date(b.created_at).toLocaleString("en-CA")}</td><td>{b.ok ? "✓" : <span className="text-bad">✗</span>}</td><td>{b.remote_ok === null ? "—" : b.remote_ok ? "✓" : <span className="text-bad">✗</span>}</td><td>{(Number(b.bytes) / 1e6).toFixed(1)} MB</td><td className="text-xs text-fg-3 whitespace-pre-wrap">{b.log}</td></tr>)}</tbody></table></section>16 <section className="card p-4"><h2 className="text-lg mb-2">Jobs (latest 60)</h2><table className="data"><thead><tr><th>ID</th><th>Kind</th><th>Status</th><th>Attempts</th><th>Created</th><th>Finished</th><th>Error</th></tr></thead><tbody>{jobs.map((j) => <tr key={j.id}><td>{j.id}</td><td className="font-mono text-xs">{j.kind}</td><td><span className={`chip ${j.status === "failed" ? "chip-burgundy" : j.status === "done" ? "" : "chip-navy"}`}>{j.status}</span></td><td>{j.attempts}</td><td className="text-xs whitespace-nowrap">{new Date(j.created_at).toLocaleString("en-CA")}</td><td className="text-xs whitespace-nowrap">{j.finished_at ? new Date(j.finished_at).toLocaleString("en-CA") : ""}</td><td className="text-xs text-bad max-w-md truncate">{j.last_error ?? ""}</td></tr>)}</tbody></table></section></div>);17}18