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
+7 -4
View File
@@ -113,10 +113,13 @@ Files now stage first and are resolved before entering the library.
- [x] Apply overwrites the same J-ID: `POST /api/files/J-x/optimize/` replaces
the file(s) and recomputes MD5/size/perceptual hashes (409 when the
result matches another item, 400 when it is identical)
- [ ] **Stats dashboard**
- [ ] CPU / RAM / GPU / disk usage
- [ ] Active jobs list (this is what makes the footer "Active Workers" real)
- [ ] Live log tail
- [x] **Stats dashboard** (`/stats`, staff)
- [x] CPU / RAM / GPU / disk usage (`psutil` + `nvidia-smi`; capacity bars
follow the design system's 80% / 95% colour thresholds)
- [x] Active jobs list (downloads + match scans with progress) plus the
recently finished ones
- [x] Live log tail with level highlighting and an auto-scroll toggle
(root logging now also writes a rotating `backend/logs/j621.log`)
## 5. Shell & polish
+237
View File
@@ -0,0 +1,237 @@
"""Runtime statistics for the staff dashboard.
Everything here is read-only and cheap enough to poll every couple of
seconds: psutil counters, an optional nvidia-smi query, the cached storage
numbers and the tail of the application log file.
"""
import logging
import os
import subprocess
import time
from pathlib import Path
import psutil
from django.conf import settings
logger = logging.getLogger(__name__)
NVIDIA_QUERY = (
"name,utilization.gpu,memory.used,memory.total,temperature.gpu"
)
LOG_TAIL_LINES = 140
RECENT_LIMIT = 6
# Prime the non-blocking CPU counters so the first request already has a delta.
psutil.cpu_percent(interval=None)
psutil.cpu_percent(interval=None, percpu=True)
def _as_int(value):
try:
return int(float(str(value).strip()))
except (TypeError, ValueError):
return None
def system_stats():
"""CPU, memory and uptime counters (non-blocking)."""
memory = psutil.virtual_memory()
swap = psutil.swap_memory()
try:
load = [round(value, 2) for value in psutil.getloadavg()]
except (AttributeError, OSError):
load = []
return {
"cpu": {
"percent": psutil.cpu_percent(interval=None),
"per_cpu": psutil.cpu_percent(interval=None, percpu=True),
"cores": psutil.cpu_count(logical=True),
"physical_cores": psutil.cpu_count(logical=False),
"load": load,
},
"memory": {
"total": memory.total,
"used": memory.used,
"available": memory.available,
"percent": memory.percent,
"swap_total": swap.total,
"swap_used": swap.used,
"swap_percent": swap.percent,
},
"uptime_seconds": int(time.time() - psutil.boot_time()),
}
def gpu_stats():
"""NVIDIA GPUs via nvidia-smi, or an empty list when unavailable."""
try:
result = subprocess.run(
[
"nvidia-smi",
f"--query-gpu={NVIDIA_QUERY}",
"--format=csv,noheader,nounits",
],
capture_output=True,
text=True,
timeout=4,
check=False,
)
except (OSError, subprocess.SubprocessError) as exc:
logger.debug("nvidia-smi unavailable: %s", exc)
return []
if result.returncode != 0:
return []
gpus = []
for line in result.stdout.strip().splitlines():
parts = [part.strip() for part in line.split(",")]
if len(parts) != 5:
continue
name, utilization, memory_used, memory_total, temperature = parts
used_mib = _as_int(memory_used)
total_mib = _as_int(memory_total)
gpus.append(
{
"name": name,
"utilization": _as_int(utilization),
"memory_used": used_mib * 1024 * 1024
if used_mib is not None
else None,
"memory_total": total_mib * 1024 * 1024
if total_mib is not None
else None,
"temperature": _as_int(temperature),
}
)
return gpus
def _download_job(task):
label = (
f"Post #{task.post_id}"
if task.post_id
else (task.filename or "Download")
)
detail = f"{task.downloaded} / {task.total}" if task.total else task.filename
return {
"kind": "download",
"id": str(task.id),
"label": label,
"status": task.status,
"progress": task.progress,
"downloaded": task.downloaded,
"total": task.total,
"detail": detail,
"created_at": task.created_at,
"updated_at": task.updated_at,
}
def _match_job(task):
progress = (
int(task.processed * 100 / task.total) if task.total else 0
)
return {
"kind": "match",
"id": str(task.id),
"label": f"e621 match scan ({task.scope})",
"status": task.status,
"progress": progress,
"processed": task.processed,
"total": task.total,
"detail": (
f"{task.processed}/{task.total} · {task.matched} matched · "
f"{task.not_found} not found · {task.deleted} deleted"
),
"created_at": task.created_at,
"updated_at": task.updated_at,
}
def jobs_stats():
"""Running/pending jobs plus the most recently finished ones."""
from apps.library.models import DownloadTask, MatchTask
active_statuses_download = [
DownloadTask.STATUS_PENDING,
DownloadTask.STATUS_DOWNLOADING,
]
active_statuses_match = [
MatchTask.STATUS_PENDING,
MatchTask.STATUS_RUNNING,
]
finished_download = [
DownloadTask.STATUS_COMPLETE,
DownloadTask.STATUS_ERROR,
DownloadTask.STATUS_CANCELLED,
]
finished_match = [
MatchTask.STATUS_COMPLETE,
MatchTask.STATUS_ERROR,
MatchTask.STATUS_CANCELLED,
]
active = [
_download_job(task)
for task in DownloadTask.objects.filter(
status__in=active_statuses_download
).order_by("created_at")[:RECENT_LIMIT]
]
active += [
_match_job(task)
for task in MatchTask.objects.filter(
status__in=active_statuses_match
).order_by("created_at")[:RECENT_LIMIT]
]
recent = []
for task in DownloadTask.objects.filter(
status__in=finished_download
).order_by("-updated_at")[:RECENT_LIMIT]:
entry = _download_job(task)
entry["summary"] = (
f"J-{task.library_item_id}"
if task.library_item_id
else (task.error[:160] if task.error else task.status)
)
recent.append(entry)
for task in MatchTask.objects.filter(
status__in=finished_match
).order_by("-updated_at")[:RECENT_LIMIT]:
entry = _match_job(task)
entry["summary"] = task.error[:160] if task.error else entry["detail"]
recent.append(entry)
recent.sort(key=lambda entry: entry["updated_at"], reverse=True)
return {
"active": active,
"recent": recent[:RECENT_LIMIT],
"active_count": len(active),
}
def tail_lines(path, count=LOG_TAIL_LINES):
"""Last `count` lines of a file without reading all of it."""
with open(path, "rb") as handle:
handle.seek(0, os.SEEK_END)
size = handle.tell()
block = 16384
data = b""
while size > 0 and data.count(b"\n") <= count:
step = min(block, size)
size -= step
handle.seek(size)
data = handle.read(step) + data
return data.decode("utf-8", errors="replace").splitlines()[-count:]
def log_tail():
path = Path(getattr(settings, "LOG_FILE", ""))
if not path or not path.exists():
return {"path": str(path), "lines": [], "missing": True}
try:
lines = tail_lines(path)
except OSError as exc:
logger.warning("Could not read the log file: %s", exc)
return {"path": str(path), "lines": [], "missing": True}
return {"path": str(path), "lines": lines, "missing": False}
+2 -1
View File
@@ -1,7 +1,8 @@
from django.urls import path
from .views import StatusView
from .views import StatsView, StatusView
urlpatterns = [
path("status/", StatusView.as_view(), name="status"),
path("stats/", StatsView.as_view(), name="stats"),
]
+27 -1
View File
@@ -1,10 +1,12 @@
import time
from django.conf import settings
from rest_framework.permissions import AllowAny
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from . import stats
from .system_info import get_os_info
@@ -40,3 +42,27 @@ class StatusView(APIView):
}
payload["server_time_ms"] = round((time.perf_counter() - started) * 1000, 1)
return Response(payload)
class StatsView(APIView):
"""Staff-only runtime statistics for the dashboard."""
permission_classes = [IsAuthenticated]
def get(self, request):
user = request.user
if not (
user.is_staff or user.is_superuser or user.role == user.ROLE_STAFF
):
raise PermissionDenied("Staff only.")
from apps.library.tools import storage_info
return Response(
{
"system": stats.system_stats(),
"gpus": stats.gpu_stats(),
"disk": storage_info(),
"jobs": stats.jobs_stats(),
"log": stats.log_tail(),
}
)
+53 -48
View File
@@ -395,57 +395,62 @@ class TagCloudView(APIView):
return Response(payload)
def storage_info():
"""Disk usage payload, cached briefly (also used by the stats dashboard)."""
cached = cache.get(STORAGE_CACHE_KEY)
if cached is not None:
return cached
watched = Path(settings.WATCHED_FOLDER)
media_root = Path(settings.MEDIA_ROOT)
temp_dir = media_root / "uploads" / "temp"
usage = shutil.disk_usage(
str(watched) if watched.exists() else str(Path(settings.BASE_DIR))
)
library_size = (
sum(file.stat().st_size for file in watched.rglob("*") if file.is_file())
if watched.exists()
else 0
)
media_size = (
sum(file.stat().st_size for file in media_root.rglob("*") if file.is_file())
if media_root.exists()
else 0
)
temp_files = (
[file for file in temp_dir.rglob("*") if file.is_file()]
if temp_dir.exists()
else []
)
payload = {
"watched_folder": {
"path": str(watched),
"total": usage.total,
"used": usage.used,
"free": usage.free,
"library_size": library_size,
"percent_used": (
round(usage.used / usage.total * 100, 1) if usage.total else 0
),
},
"media": {"path": str(media_root), "size": media_size},
"temp": {
"path": str(temp_dir),
"size": sum(file.stat().st_size for file in temp_files),
"files": len(temp_files),
},
"library_items": MediaItem.objects.count(),
}
cache.set(STORAGE_CACHE_KEY, payload, STORAGE_CACHE_TTL)
return payload
class StorageView(APIView):
"""Disk usage for the watched folder, media root and temp uploads."""
permission_classes = [IsAuthenticated]
def get(self, request):
cached = cache.get(STORAGE_CACHE_KEY)
if cached is not None:
return Response(cached)
watched = Path(settings.WATCHED_FOLDER)
media_root = Path(settings.MEDIA_ROOT)
temp_dir = media_root / "uploads" / "temp"
usage = shutil.disk_usage(
str(watched) if watched.exists() else str(Path(settings.BASE_DIR))
)
library_size = (
sum(file.stat().st_size for file in watched.rglob("*") if file.is_file())
if watched.exists()
else 0
)
media_size = (
sum(file.stat().st_size for file in media_root.rglob("*") if file.is_file())
if media_root.exists()
else 0
)
temp_files = (
[file for file in temp_dir.rglob("*") if file.is_file()]
if temp_dir.exists()
else []
)
payload = {
"watched_folder": {
"path": str(watched),
"total": usage.total,
"used": usage.used,
"free": usage.free,
"library_size": library_size,
"percent_used": (
round(usage.used / usage.total * 100, 1) if usage.total else 0
),
},
"media": {"path": str(media_root), "size": media_size},
"temp": {
"path": str(temp_dir),
"size": sum(file.stat().st_size for file in temp_files),
"files": len(temp_files),
},
"library_items": MediaItem.objects.count(),
}
cache.set(STORAGE_CACHE_KEY, payload, STORAGE_CACHE_TTL)
return Response(payload)
return Response(storage_info())
+27 -7
View File
@@ -203,6 +203,31 @@ REST_FRAMEWORK = {
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGS_DIR = BASE_DIR / "logs"
LOG_FILE = LOGS_DIR / "j621.log"
try:
LOGS_DIR.mkdir(parents=True, exist_ok=True)
except OSError: # read-only checkout: keep console logging only
pass
_log_handlers = {
"console": {
"class": "logging.StreamHandler",
"formatter": "simple",
},
}
_root_handlers = ["console"]
if LOGS_DIR.exists():
_log_handlers["file"] = {
"class": "logging.handlers.RotatingFileHandler",
"filename": str(LOG_FILE),
"maxBytes": 5 * 1024 * 1024,
"backupCount": 3,
"encoding": "utf-8",
"formatter": "simple",
}
_root_handlers.append("file")
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
@@ -212,14 +237,9 @@ LOGGING = {
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "simple",
},
},
"handlers": _log_handlers,
"root": {
"handlers": ["console"],
"handlers": _root_handlers,
"level": "INFO",
},
}
+1
View File
@@ -7,3 +7,4 @@ requests>=2.32
imagehash>=4.3
PyMySQL>=1.1
redis>=5.0
psutil>=6.0
+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 };
}