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.
386 lines
13 KiB
TypeScript
386 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|