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:
+7
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -395,15 +395,11 @@ class TagCloudView(APIView):
|
||||
return Response(payload)
|
||||
|
||||
|
||||
class StorageView(APIView):
|
||||
"""Disk usage for the watched folder, media root and temp uploads."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
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 Response(cached)
|
||||
return cached
|
||||
|
||||
watched = Path(settings.WATCHED_FOLDER)
|
||||
media_root = Path(settings.MEDIA_ROOT)
|
||||
@@ -448,4 +444,13 @@ class StorageView(APIView):
|
||||
"library_items": MediaItem.objects.count(),
|
||||
}
|
||||
cache.set(STORAGE_CACHE_KEY, payload, STORAGE_CACHE_TTL)
|
||||
return Response(payload)
|
||||
return payload
|
||||
|
||||
|
||||
class StorageView(APIView):
|
||||
"""Disk usage for the watched folder, media root and temp uploads."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
return Response(storage_info())
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,3 +7,4 @@ requests>=2.32
|
||||
imagehash>=4.3
|
||||
PyMySQL>=1.1
|
||||
redis>=5.0
|
||||
psutil>=6.0
|
||||
|
||||
@@ -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