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:
@@ -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={
|
||||
|
||||
@@ -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" },
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user