spb/wp-spboucher-ai
Public
1import Link from "next/link";2import { dashboard } from "@/lib/admin/queries";3import { PageTitle } from "@/components/admin-ui";4export default async function Dashboard() {5 const d = await dashboard(); const q = Object.fromEntries(d.queue.map((x) => [x.status, x.n])); const j = Object.fromEntries(d.jobs.map((x) => [x.status, x.n]));6 const tile = (l: string, v: string | number, href?: string) => <div className="card px-4 py-3"><p className="label">{l}</p><p className="serif text-2xl mt-1">{href ? <Link href={href} className="no-underline text-fg hover:underline">{v}</Link> : v}</p></div>;7 return (<div><PageTitle title="Dashboard"><Link href="/admin/papers/new" className="btn btn-primary btn-sm no-underline">New paper</Link><Link href="/admin/ingest" className="btn btn-sm no-underline">Ingestion queue</Link></PageTitle>8 <div className="grid grid-cols-2 md:grid-cols-4 gap-3">{tile("Published", d.counts.published, "/admin/papers?status=published")}{tile("Drafts", d.counts.draft, "/admin/papers?status=draft")}{tile("Reserved", d.counts.reserved, "/admin/papers?status=reserved")}{tile("Withdrawn", d.counts.withdrawn, "/admin/papers?status=withdrawn")}9 {tile("Ingest: ready for review", q.ready ?? 0, "/admin/ingest")}{tile("Ingest: processing", (q.queued ?? 0) + (q.processing ?? 0))}{tile("Jobs failed (7 d)", j.failed ?? 0, "/admin/system")}{tile("Jobs queued", (j.queued ?? 0) + (j.running ?? 0), "/admin/system")}10 {tile("Embeddings", `${d.ai.embedded}/${d.ai.total} papers · ${d.ai.chunks_embedded}/${d.ai.chunks} chunks`)}{tile("Last backup", d.backup ? `${d.backup.ok ? "OK" : "FAILED"} · ${new Date(d.backup.created_at).toLocaleString("en-CA")}${d.backup.remote_ok === false ? " · remote failed" : ""}` : "none", "/admin/system")}</div>11 <div className="grid lg:grid-cols-2 gap-6 mt-8">12 <section className="card p-4"><h2 className="text-lg mb-2">Views and downloads, last 30 days</h2><svg viewBox="0 0 600 140" className="w-full"><g>{d.views.map((v, i) => { const max = Math.max(1, ...d.views.map((x) => x.views + x.downloads)); const w = 600 / Math.max(1, d.views.length); return <g key={v.day} transform={`translate(${i * w},0)`}><rect x={1} y={120 - (v.views / max) * 110} width={w - 2} height={(v.views / max) * 110} fill="var(--navy)" /><rect x={1} y={120 - ((v.views + v.downloads) / max) * 110} width={w - 2} height={(v.downloads / max) * 110} fill="var(--burgundy)" opacity={0.7} /><title>{v.day}: {v.views} views, {v.downloads} downloads</title></g>; })}</g></svg><p className="meta">{d.views.length ? `${d.views.reduce((a, x) => a + x.views, 0)} views · ${d.views.reduce((a, x) => a + x.downloads, 0)} downloads` : "No aggregated data yet (daily job runs after midnight)."}</p></section>13 <section className="card p-4"><h2 className="text-lg mb-2">Top searches, 30 days</h2>{d.searches.length ? <table className="data"><thead><tr><th>Query</th><th>Count</th><th>Avg results</th></tr></thead><tbody>{d.searches.map((s) => <tr key={s.query}><td>{s.query}</td><td>{s.n}</td><td>{s.avg_results}</td></tr>)}</tbody></table> : <p className="meta">No searches yet.</p>}</section>14 </div>15 <section className="card p-4 mt-6"><h2 className="text-lg mb-2">Recent audit entries</h2><table className="data"><thead><tr><th>When</th><th>Actor</th><th>Action</th><th>Entity</th></tr></thead><tbody>{d.audit.map((a) => <tr key={a.id}><td className="whitespace-nowrap">{new Date(a.at).toLocaleString("en-CA")}</td><td>{a.actor_label}</td><td className="font-mono text-xs">{a.action}</td><td className="text-fg-2">{a.entity_type} {a.entity_id ?? ""}</td></tr>)}</tbody></table><p className="mt-2 text-sm"><Link href="/admin/audit" className="text-navy">Full audit log →</Link></p></section>16 </div>);17}18