Add the staff stats dashboard

Backend: GET /api/stats/ (staff only) gathers psutil CPU/memory counters,
nvidia-smi GPU stats, the cached disk numbers and the running/finished
download + match jobs. Root logging now also writes a rotating file
(backend/logs/j621.log) so the dashboard can tail it, and psutil joins the
requirements. The storage payload computation is shared with the existing
storage endpoint.

Frontend: a /stats route + Stats nav entry for staff, polling every 2 s —
per-core CPU bars, memory and swap, GPUs (utilization, VRAM, temperature),
disk with the media/temp breakdown, active jobs with progress bars,
recently finished jobs with summaries, and the log tail with level colours
and an auto-scroll toggle. Section 4 of the roadmap is complete.
This commit is contained in:
2026-09-17 21:36:33 -05:00
parent 0fcc4e518a
commit b024fc52d7
11 changed files with 808 additions and 62 deletions
+9
View File
@@ -23,6 +23,7 @@ import OnlinePage from "@/features/online/OnlinePage";
import PoolDetailPage from "@/features/pools/PoolDetailPage";
import PoolsPage from "@/features/pools/PoolsPage";
import SimilarPage from "@/features/similar/SimilarPage";
import StatsPage from "@/features/stats/StatsPage";
import UploadPage from "@/features/upload/UploadPage";
import UsersPage from "@/features/users/UsersPage";
import { isAgeVerified, markAgeVerified } from "@/lib/age";
@@ -100,6 +101,14 @@ export default function App() {
</RequireAuth>
}
/>
<Route
path="/stats"
element={
<RequireAuth>
<StatsPage />
</RequireAuth>
}
/>
<Route
path="/users"
element={
+4 -1
View File
@@ -71,7 +71,10 @@ export function AppShell() {
]
: []),
...(user && (user.is_staff || user.is_superuser || user.role === "staff")
? [{ to: "/users", label: "Users" }]
? [
{ to: "/stats", label: "Stats" },
{ to: "/users", label: "Users" },
]
: []),
];
+385
View File
@@ -0,0 +1,385 @@
import { useQuery } from "@tanstack/react-query";
import { Activity, Download, Gauge, HardDrive, MemoryStick, ScanSearch, Terminal } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { EmptyState, Spinner } from "@/components/ui";
import { api } from "@/lib/api";
import { cn } from "@/lib/cn";
import { formatBytes } from "@/lib/format";
import type { StatJob, SystemStats } from "@/lib/types";
import { useAuth } from "@/store/auth";
const REFRESH_MS = 2000;
/** DESIGN.md capacity bars: blue, peach at 80%, red at 95%. */
function meterColor(percent: number): string {
if (percent >= 95) return "bg-ctp-red";
if (percent >= 80) return "bg-ctp-peach";
return "bg-ctp-blue";
}
function Meter({ percent, className }: { percent: number; className?: string }) {
return (
<div className={cn("h-1 overflow-hidden rounded-full bg-ctp-surface0", className)}>
<div
className={cn("h-full rounded-full transition-all", meterColor(percent))}
style={{ width: `${Math.min(100, Math.max(0, percent))}%` }}
/>
</div>
);
}
function Panel({
title,
icon,
right,
children,
className,
}: {
title: string;
icon?: ReactNode;
right?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section
className={cn(
"flex flex-col rounded-lg border border-ctp-surface0 bg-ctp-base p-4",
className,
)}
>
<div className="flex items-center justify-between gap-2">
<h2 className="flex items-center gap-2 text-sm font-semibold text-ctp-subtext1">
{icon}
{title}
</h2>
{right}
</div>
<div className="mt-3 flex flex-1 flex-col gap-3">{children}</div>
</section>
);
}
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days} d ${hours} h`;
if (hours > 0) return `${hours} h ${minutes} m`;
return `${minutes} m`;
}
function jobStatusClass(status: string): string {
if (status === "error") return "text-ctp-red";
if (status === "cancelled") return "text-ctp-overlay0";
if (status === "complete") return "text-ctp-green";
return "text-ctp-blue";
}
function JobRow({ job, showSummary }: { job: StatJob; showSummary?: boolean }) {
const Icon = job.kind === "download" ? Download : ScanSearch;
return (
<li className="flex flex-col gap-1">
<div className="flex items-center gap-2 text-xs">
<Icon className="h-3.5 w-3.5 shrink-0 text-ctp-overlay0" />
<span className="truncate font-mono text-ctp-subtext1">{job.label}</span>
<span className={cn("ml-auto shrink-0 font-mono text-[11px]", jobStatusClass(job.status))}>
{job.status}
</span>
</div>
{showSummary ? (
<p className="truncate pl-5 text-[11px] text-ctp-overlay0" title={job.summary ?? job.detail}>
{job.summary ?? job.detail}
</p>
) : (
<>
<Meter percent={job.progress} className="ml-5" />
<p className="truncate pl-5 font-mono text-[10px] text-ctp-overlay0">
{job.progress}% · {job.detail}
</p>
</>
)}
</li>
);
}
function logLineClass(line: string): string {
const level = /^(DEBUG|INFO|WARNING|ERROR|CRITICAL)\b/.exec(line)?.[1];
if (level === "ERROR" || level === "CRITICAL") return "text-ctp-red";
if (level === "WARNING") return "text-ctp-peach";
if (level === "DEBUG") return "text-ctp-overlay0";
return "text-ctp-subtext0";
}
export default function StatsPage() {
const user = useAuth((state) => state.user);
const staff = Boolean(
user &&
(user.is_staff || user.is_superuser || user.role === "staff"),
);
const query = useQuery({
queryKey: ["stats"],
queryFn: () => api<SystemStats>("/api/stats/"),
enabled: staff,
refetchInterval: staff ? REFRESH_MS : false,
});
const [autoScroll, setAutoScroll] = useState(true);
const logRef = useRef<HTMLDivElement>(null);
const data = query.data;
const logLines = data?.log.lines ?? [];
const logCount = logLines.length;
useEffect(() => {
if (!autoScroll || !logRef.current) return;
logRef.current.scrollTop = logRef.current.scrollHeight;
}, [logCount, autoScroll]);
if (!staff) {
return (
<EmptyState
title="Staff only"
description="This dashboard is limited to staff accounts."
/>
);
}
if (query.isPending) {
return (
<div className="flex justify-center py-24">
<Spinner className="h-6 w-6" />
</div>
);
}
if (query.isError || !data) {
return (
<EmptyState
title="Could not load statistics"
description="The backend did not answer the stats endpoint."
/>
);
}
const { system, gpus, disk, jobs, log } = data;
const memoryPercent = system.memory.percent;
const cores = system.cpu.per_cpu ?? [];
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<header className="flex flex-wrap items-end justify-between gap-2">
<div>
<h1 className="text-lg font-semibold">Stats</h1>
<p className="mt-1 text-sm text-ctp-overlay0">
Host telemetry, background jobs and the application log. Refreshing
every {REFRESH_MS / 1000} s.
</p>
</div>
<p className="font-mono text-[11px] text-ctp-overlay0">
up {formatUptime(system.uptime_seconds)}
</p>
</header>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Panel
title="CPU"
icon={<Gauge className="h-4 w-4 text-ctp-overlay0" />}
right={
<span className="font-mono text-xs text-ctp-subtext1">
{system.cpu.percent.toFixed(1)}%
</span>
}
>
<p className="text-[11px] text-ctp-overlay0">
{system.cpu.cores} logical cores
{system.cpu.physical_cores
? ` · ${system.cpu.physical_cores} physical`
: ""}
{system.cpu.load.length > 0
? ` · load ${system.cpu.load.join(" / ")}`
: ""}
</p>
<Meter percent={system.cpu.percent} />
<div className="flex flex-wrap gap-1">
{cores.map((value, index) => (
<span
key={index}
title={`Core ${index}: ${value}%`}
className="h-6 w-1.5 overflow-hidden rounded-sm bg-ctp-surface0"
>
<span
className={cn("block w-full", meterColor(value))}
style={{
height: `${Math.min(100, Math.max(0, value))}%`,
marginTop: `${100 - Math.min(100, Math.max(0, value))}%`,
}}
/>
</span>
))}
</div>
</Panel>
<Panel
title="Memory"
icon={<MemoryStick className="h-4 w-4 text-ctp-overlay0" />}
right={
<span className="font-mono text-xs text-ctp-subtext1">
{memoryPercent.toFixed(1)}%
</span>
}
>
<p className="font-mono text-[11px] text-ctp-overlay0">
{formatBytes(system.memory.used)} /{" "}
{formatBytes(system.memory.total)} · {formatBytes(system.memory.available)} free
</p>
<Meter percent={memoryPercent} />
{system.memory.swap_total > 0 ? (
<>
<p className="font-mono text-[11px] text-ctp-overlay0">
swap {formatBytes(system.memory.swap_used)} /{" "}
{formatBytes(system.memory.swap_total)}
</p>
<Meter percent={system.memory.swap_percent} />
</>
) : null}
</Panel>
{gpus.length > 0 ? (
gpus.map((gpu) => (
<Panel
key={gpu.name}
title="GPU"
icon={<Activity className="h-4 w-4 text-ctp-overlay0" />}
right={
<span className="font-mono text-xs text-ctp-subtext1">
{gpu.utilization ?? "?"}%
</span>
}
>
<p className="truncate text-[11px] text-ctp-overlay0" title={gpu.name}>
{gpu.name}
</p>
<Meter percent={gpu.utilization ?? 0} />
<p className="font-mono text-[11px] text-ctp-overlay0">
{gpu.memory_used !== null && gpu.memory_total !== null
? `vram ${formatBytes(gpu.memory_used)} / ${formatBytes(gpu.memory_total)}`
: "vram unknown"}
{gpu.temperature !== null ? ` · ${gpu.temperature}°C` : ""}
</p>
</Panel>
))
) : (
<Panel
title="GPU"
icon={<Activity className="h-4 w-4 text-ctp-overlay0" />}
>
<p className="text-[11px] text-ctp-overlay0">
No NVIDIA GPU reported by nvidia-smi.
</p>
</Panel>
)}
<Panel
title="Disk"
icon={<HardDrive className="h-4 w-4 text-ctp-overlay0" />}
right={
<span className="font-mono text-xs text-ctp-subtext1">
{disk.watched_folder.percent_used.toFixed(1)}%
</span>
}
>
<p className="font-mono text-[11px] text-ctp-overlay0">
{formatBytes(disk.watched_folder.used)} /{" "}
{formatBytes(disk.watched_folder.total)} · {formatBytes(disk.watched_folder.free)} free
</p>
<Meter percent={disk.watched_folder.percent_used} />
<p className="truncate font-mono text-[11px] text-ctp-overlay0" title={disk.watched_folder.path}>
library {formatBytes(disk.watched_folder.library_size)} ·{" "}
{disk.library_items} items
</p>
<p className="truncate font-mono text-[11px] text-ctp-overlay0" title={disk.media.path}>
media {formatBytes(disk.media.size)} · temp{" "}
{formatBytes(disk.temp.size)} ({disk.temp.files} files)
</p>
</Panel>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Panel
title="Active jobs"
icon={<Activity className="h-4 w-4 text-ctp-overlay0" />}
right={
<span className="font-mono text-[11px] text-ctp-overlay0">
{jobs.active_count} running
</span>
}
>
{jobs.active.length === 0 ? (
<p className="text-xs text-ctp-overlay0">
Nothing running — downloads and match scans appear here.
</p>
) : (
<ul className="flex flex-col gap-3">
{jobs.active.map((job) => (
<JobRow key={`${job.kind}-${job.id}`} job={job} />
))}
</ul>
)}
</Panel>
<Panel
title="Recently finished"
icon={<Download className="h-4 w-4 text-ctp-overlay0" />}
>
{jobs.recent.length === 0 ? (
<p className="text-xs text-ctp-overlay0">No finished jobs yet.</p>
) : (
<ul className="flex flex-col gap-3">
{jobs.recent.map((job) => (
<JobRow key={`${job.kind}-${job.id}`} job={job} showSummary />
))}
</ul>
)}
</Panel>
</div>
<Panel
title="Log"
icon={<Terminal className="h-4 w-4 text-ctp-overlay0" />}
right={
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-[11px] text-ctp-overlay0">
<input
type="checkbox"
checked={autoScroll}
onChange={(event) => setAutoScroll(event.target.checked)}
className="accent-ctp-mauve"
/>
auto-scroll
</label>
<span className="truncate font-mono text-[10px] text-ctp-overlay0" title={log.path}>
{log.path}
</span>
</div>
}
>
{log.missing ? (
<p className="text-xs text-ctp-overlay0">
No log file yet — it is created on the next log write.
</p>
) : (
<div
ref={logRef}
className="max-h-80 overflow-auto rounded-md bg-ctp-crust/60 p-3 font-mono text-[11px] leading-relaxed"
>
{logLines.map((line, index) => (
<div key={index} className={cn("whitespace-pre-wrap", logLineClass(line))}>
{line}
</div>
))}
</div>
)}
</Panel>
</div>
);
}
+56
View File
@@ -329,3 +329,59 @@ export interface TempUpload {
created_at: string;
updated_at: string;
}
export interface StatJob {
kind: "download" | "match";
id: string;
label: string;
status: string;
progress: number;
detail: string;
summary?: string;
created_at: string;
updated_at: string;
}
export interface SystemStats {
system: {
cpu: {
percent: number;
per_cpu: number[];
cores: number;
physical_cores: number;
load: number[];
};
memory: {
total: number;
used: number;
available: number;
percent: number;
swap_total: number;
swap_used: number;
swap_percent: number;
};
uptime_seconds: number;
};
gpus: {
name: string;
utilization: number | null;
memory_used: number | null;
memory_total: number | null;
temperature: number | null;
}[];
disk: {
watched_folder: {
path: string;
total: number;
used: number;
free: number;
library_size: number;
percent_used: number;
};
media: { path: string; size: number };
temp: { path: string; size: number; files: number };
library_items: number;
};
jobs: { active: StatJob[]; recent: StatJob[]; active_count: number };
log: { path: string; lines: string[]; missing: boolean };
}