Repository Public
1import Link from "next/link";2import { AutoRefresh } from "@/components/studio/AutoRefresh";3import { A, Meter, Notice, PageHead, Panel, Stat, Status, ago, bytes, usd, when } from "@/components/studio/ui";4import { requireRole } from "@/lib/auth";5import { sql } from "@/lib/db";6import { queueDepth } from "@/lib/queue";7import { costSummary } from "@/lib/studio-ops";89export const metadata = { title: "Dashboard" };10const secondsSince = (iso: string) => (Date.now() - new Date(iso).getTime()) / 1000;11export default async function Dashboard({ searchParams }: { searchParams: Promise<{ ok?: string; err?: string; denied?: string }> }) {12 const sp = await searchParams;13 await requireRole("viewer");14 const [cost, depth, statuses, running, failures, heartbeat, top, upcoming, lastProvider, reports, backup] = await Promise.all([15 costSummary(), queueDepth(),16 sql<{ status: string; n: number }[]>`SELECT status, COUNT(*)::int AS n FROM projects WHERE status <> 'archived' GROUP BY status`,17 sql<{ id: string; project_id: string; title: string; stage: string; status: string; started_at: Date | null; queued_at: Date }[]>`18 SELECT r.id, r.project_id, p.title, r.stage, r.status, r.started_at, r.queued_at FROM stage_runs r JOIN projects p ON p.id = r.project_id WHERE r.status IN ('queued','running') ORDER BY r.queued_at`,19 sql<{ id: string; project_id: string; title: string; stage: string; status: string; error: string | null; finished_at: Date | null }[]>`20 SELECT r.id, r.project_id, p.title, r.stage, r.status, r.error, r.finished_at FROM stage_runs r JOIN projects p ON p.id = r.project_id WHERE r.status IN ('failed','blocked') AND r.finished_at > now() - interval '7 days' ORDER BY r.finished_at DESC LIMIT 6`,21 sql<{ at: string }[]>`SELECT value->>'at' AS at FROM settings WHERE key = 'worker_heartbeat'`,22 sql<{ slug: string; title_en: string; views: number; likes: number; completes: number; quality_score: number | null }[]>`23 SELECT f.slug, f.title_en, f.views, f.likes, f.quality_score, (SELECT COUNT(*)::int FROM analytics_events e WHERE e.film_id = f.id AND e.kind = 'complete') AS completes24 FROM films f WHERE f.status = 'published' ORDER BY f.views DESC, f.published_at DESC LIMIT 6`,25 sql<{ title_en: string; scheduled_for: Date }[]>`SELECT title_en, scheduled_for FROM films WHERE status = 'scheduled' ORDER BY scheduled_for LIMIT 5`,26 sql<{ kind: string; ok: number; failed: number; last_error: string | null; last_ok: Date | null }[]>`27 SELECT kind, COUNT(*) FILTER (WHERE status = 'done')::int AS ok, COUNT(*) FILTER (WHERE status = 'failed')::int AS failed,28 (ARRAY_AGG(error ORDER BY finished_at DESC) FILTER (WHERE status = 'failed'))[1] AS last_error, MAX(finished_at) FILTER (WHERE status = 'done') AS last_ok29 FROM provider_jobs WHERE created_at > now() - interval '7 days' GROUP BY kind ORDER BY kind`,30 sql<{ n: number }[]>`SELECT COUNT(*)::int AS n FROM reports WHERE status = 'open'`,31 sql<{ value: { at: string; ok: boolean } }[]>`SELECT value FROM settings WHERE key = 'last_backup'`,32 ]);33 const count = (...s: string[]) => statuses.filter((x) => s.includes(x.status)).reduce((n, x) => n + x.n, 0);34 const hbAge = heartbeat[0] ? secondsSince(heartbeat[0].at) : Infinity;35 const p = cost.policy;36 return (37 <>38 <AutoRefresh seconds={8} active={running.length > 0} />39 <PageHead title="Dashboard" sub="Production, spend and release state at a glance."><Link href="/studio/projects/new" className="btn btn-primary">New film</Link></PageHead>40 <Notice ok={sp.ok} err={sp.err ?? (sp.denied ? "Your role does not allow that page." : undefined)} />41 <div className="grid grid-cols-2 gap-3 lg:grid-cols-6">42 <Stat label="In production" value={count("draft", "pitched", "scripted", "storyboarded", "keyframes", "voiced", "filmed", "assembled")} />43 <Stat label="Awaiting approval" value={count("qa_passed", "qa_failed", "approved")} tone={count("qa_passed", "qa_failed", "approved") ? "warn" : undefined} />44 <Stat label="Published" value={count("published")} />45 <Stat label="Queue" value={depth ? depth.waiting + depth.active + depth.delayed : "—"} sub={depth ? `${depth.active} active · ${depth.waiting + depth.delayed} waiting` : "Redis unreachable"} tone={depth ? undefined : "bad"} />46 <Stat label="Spent today" value={usd(cost.totals.day)} sub={`cap ${usd(p.per_day_usd)}`} />47 <Stat label="Open reports" value={reports[0].n} tone={reports[0].n ? "warn" : undefined} />48 </div>4950 <div className="mt-5 grid gap-5 lg:grid-cols-3">51 <Panel title="Active jobs">52 {running.length ? <ul className="space-y-3 text-sm">{running.map((r) => <li key={r.id} className="flex items-center justify-between gap-3"><span className="min-w-0 truncate"><A href={`/studio/projects/${r.project_id}`}>{r.title}</A> <span className="text-[var(--color-faint)]">· {r.stage}</span></span><Status value={r.status} label={r.status === "running" ? `running ${ago(r.started_at)}` : "queued"} /></li>)}</ul>53 : <p className="text-sm text-[var(--color-faint)]">Nothing is running.</p>}54 </Panel>55 <Panel title="Budget">56 <div className="space-y-4"><Meter label="Today" spent={cost.totals.day} cap={Number(p.per_day_usd)} /><Meter label="This month" spent={cost.totals.month} cap={Number(p.per_month_usd)} /></div>57 <p className="mt-4 text-xs text-[var(--color-faint)]">Failed generations: {usd(cost.totals.failed)} · All time: {usd(cost.totals.all_time)}{cost.fx ? ` (${(cost.totals.all_time * cost.fx.rate).toFixed(2)} CAD at ${cost.fx.rate}, rate set ${when(cost.fx.at)})` : ""}</p>58 <p className="mt-1 text-xs text-[var(--color-faint)]">Storage: {bytes(cost.storage.bytes)} in {cost.storage.files} objects · <A href="/studio/costs">details</A></p>59 </Panel>60 <Panel title="System health">61 <ul className="space-y-2.5 text-sm">62 <li className="flex justify-between gap-3"><span>Worker</span><Status value={hbAge < 150 ? "online" : "failed"} label={hbAge === Infinity ? "no heartbeat" : `heartbeat ${ago(heartbeat[0].at)}`} /></li>63 <li className="flex justify-between gap-3"><span>Queue (Redis)</span><Status value={depth ? "online" : "failed"} label={depth ? "reachable" : "unreachable"} /></li>64 {lastProvider.map((x) => <li key={x.kind} className="flex justify-between gap-3"><span>xAI {x.kind}<span className="text-[var(--color-faint)]"> · 7 d</span></span><Status value={x.failed > x.ok ? "failed" : x.failed ? "blocked" : "online"} label={`${x.ok} ok · ${x.failed} failed`} /></li>)}65 {!lastProvider.length && <li className="text-[var(--color-faint)]">No provider call in the last 7 days.</li>}66 <li className="flex justify-between gap-3"><span>Last backup</span><Status value={backup[0] ? (backup[0].value.ok ? "online" : "failed") : "blocked"} label={backup[0] ? ago(backup[0].value.at) : "never"} /></li>67 <li className="flex justify-between gap-3"><span>Autonomy</span><span className="text-[var(--color-muted)]">{p.scheduler_enabled ? `scheduler on · ${p.films_per_day}/day` : "scheduler off"} · {p.auto_publish ? "auto-publish ON" : "human approval"}</span></li>68 </ul>69 </Panel>70 </div>7172 <div className="mt-5 grid gap-5 lg:grid-cols-2">73 <Panel title="Recent failures" action={<A href="/studio/jobs">All jobs</A>}>74 {failures.length ? <ul className="space-y-3 text-sm">{failures.map((r) => <li key={r.id}><div className="flex items-center justify-between gap-3"><span className="truncate"><A href={`/studio/projects/${r.project_id}`}>{r.title}</A> <span className="text-[var(--color-faint)]">· {r.stage} · {ago(r.finished_at)}</span></span><Status value={r.status} /></div><p className="mt-1 break-words text-xs text-[var(--color-muted)]">{r.error}</p></li>)}</ul>75 : <p className="text-sm text-[var(--color-faint)]">No failure in the last 7 days.</p>}76 </Panel>77 <Panel title="Content performance" action={<A href="/studio/films">All films</A>}>78 {top.length ? <table className="studio-table"><thead><tr><th>Film</th><th>Views</th><th>Completed</th><th>Likes</th><th>Critic</th></tr></thead><tbody>79 {top.map((f) => <tr key={f.slug}><td><a href={`/film/${f.slug}`} className="underline decoration-[var(--color-line)]">{f.title_en}</a></td><td className="tabular-nums">{f.views}</td><td className="tabular-nums">{f.views ? `${Math.round((f.completes / f.views) * 100)} %` : "—"}</td><td className="tabular-nums">{f.likes}</td><td className="tabular-nums">{f.quality_score?.toFixed(1) ?? "—"}</td></tr>)}80 </tbody></table> : <p className="text-sm text-[var(--color-faint)]">No published film yet.</p>}81 {upcoming.length > 0 && <div className="mt-4 border-t border-[var(--color-line-soft)] pt-3"><p className="label mb-2">Scheduled releases</p><ul className="space-y-1 text-sm">{upcoming.map((u) => <li key={u.title_en} className="flex justify-between"><span>{u.title_en}</span><span className="text-[var(--color-muted)]">{when(u.scheduled_for)}</span></li>)}</ul></div>}82 </Panel>83 </div>84 </>85 );86}87