From b024fc52d77931a85e32d91805fb0f8b33ede783 Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Thu, 17 Sep 2026 21:36:33 -0500 Subject: [PATCH] Add the staff stats dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ROADMAP.md | 11 +- backend/apps/core/stats.py | 237 +++++++++++++ backend/apps/core/urls.py | 3 +- backend/apps/core/views.py | 28 +- backend/apps/library/tools.py | 101 +++--- backend/config/settings.py | 34 +- backend/requirements.txt | 1 + frontend/src/App.tsx | 9 + frontend/src/components/AppShell.tsx | 5 +- frontend/src/features/stats/StatsPage.tsx | 385 ++++++++++++++++++++++ frontend/src/lib/types.ts | 56 ++++ 11 files changed, 808 insertions(+), 62 deletions(-) create mode 100644 backend/apps/core/stats.py create mode 100644 frontend/src/features/stats/StatsPage.tsx diff --git a/ROADMAP.md b/ROADMAP.md index 896bee2..a6b265c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/backend/apps/core/stats.py b/backend/apps/core/stats.py new file mode 100644 index 0000000..76c948f --- /dev/null +++ b/backend/apps/core/stats.py @@ -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} diff --git a/backend/apps/core/urls.py b/backend/apps/core/urls.py index 7035eb1..f9bcf04 100644 --- a/backend/apps/core/urls.py +++ b/backend/apps/core/urls.py @@ -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"), ] diff --git a/backend/apps/core/views.py b/backend/apps/core/views.py index 3debcfb..507610a 100644 --- a/backend/apps/core/views.py +++ b/backend/apps/core/views.py @@ -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(), + } + ) diff --git a/backend/apps/library/tools.py b/backend/apps/library/tools.py index 733c458..ed93b3c 100644 --- a/backend/apps/library/tools.py +++ b/backend/apps/library/tools.py @@ -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()) diff --git a/backend/config/settings.py b/backend/config/settings.py index 3a1c68a..0579c34 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -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", }, } diff --git a/backend/requirements.txt b/backend/requirements.txt index 9bc1e3b..d3dcda7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,3 +7,4 @@ requests>=2.32 imagehash>=4.3 PyMySQL>=1.1 redis>=5.0 +psutil>=6.0 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 04abc0c..af201da 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> + + + + } + /> = 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 ( +
+
+
+ ); +} + +function Panel({ + title, + icon, + right, + children, + className, +}: { + title: string; + icon?: ReactNode; + right?: ReactNode; + children: ReactNode; + className?: string; +}) { + return ( +
+
+

+ {icon} + {title} +

+ {right} +
+
{children}
+
+ ); +} + +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 ( +
  • +
    + + {job.label} + + {job.status} + +
    + {showSummary ? ( +

    + {job.summary ?? job.detail} +

    + ) : ( + <> + +

    + {job.progress}% · {job.detail} +

    + + )} +
  • + ); +} + +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("/api/stats/"), + enabled: staff, + refetchInterval: staff ? REFRESH_MS : false, + }); + const [autoScroll, setAutoScroll] = useState(true); + const logRef = useRef(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 ( + + ); + } + + if (query.isPending) { + return ( +
    + +
    + ); + } + + if (query.isError || !data) { + return ( + + ); + } + + const { system, gpus, disk, jobs, log } = data; + const memoryPercent = system.memory.percent; + const cores = system.cpu.per_cpu ?? []; + + return ( +
    +
    +
    +

    Stats

    +

    + Host telemetry, background jobs and the application log. Refreshing + every {REFRESH_MS / 1000} s. +

    +
    +

    + up {formatUptime(system.uptime_seconds)} +

    +
    + +
    + } + right={ + + {system.cpu.percent.toFixed(1)}% + + } + > +

    + {system.cpu.cores} logical cores + {system.cpu.physical_cores + ? ` · ${system.cpu.physical_cores} physical` + : ""} + {system.cpu.load.length > 0 + ? ` · load ${system.cpu.load.join(" / ")}` + : ""} +

    + +
    + {cores.map((value, index) => ( + + + + ))} +
    +
    + + } + right={ + + {memoryPercent.toFixed(1)}% + + } + > +

    + {formatBytes(system.memory.used)} /{" "} + {formatBytes(system.memory.total)} · {formatBytes(system.memory.available)} free +

    + + {system.memory.swap_total > 0 ? ( + <> +

    + swap {formatBytes(system.memory.swap_used)} /{" "} + {formatBytes(system.memory.swap_total)} +

    + + + ) : null} +
    + + {gpus.length > 0 ? ( + gpus.map((gpu) => ( + } + right={ + + {gpu.utilization ?? "?"}% + + } + > +

    + {gpu.name} +

    + +

    + {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` : ""} +

    +
    + )) + ) : ( + } + > +

    + No NVIDIA GPU reported by nvidia-smi. +

    +
    + )} + + } + right={ + + {disk.watched_folder.percent_used.toFixed(1)}% + + } + > +

    + {formatBytes(disk.watched_folder.used)} /{" "} + {formatBytes(disk.watched_folder.total)} · {formatBytes(disk.watched_folder.free)} free +

    + +

    + library {formatBytes(disk.watched_folder.library_size)} ·{" "} + {disk.library_items} items +

    +

    + media {formatBytes(disk.media.size)} · temp{" "} + {formatBytes(disk.temp.size)} ({disk.temp.files} files) +

    +
    +
    + +
    + } + right={ + + {jobs.active_count} running + + } + > + {jobs.active.length === 0 ? ( +

    + Nothing running — downloads and match scans appear here. +

    + ) : ( +
      + {jobs.active.map((job) => ( + + ))} +
    + )} +
    + + } + > + {jobs.recent.length === 0 ? ( +

    No finished jobs yet.

    + ) : ( +
      + {jobs.recent.map((job) => ( + + ))} +
    + )} +
    +
    + + } + right={ +
    + + + {log.path} + +
    + } + > + {log.missing ? ( +

    + No log file yet — it is created on the next log write. +

    + ) : ( +
    + {logLines.map((line, index) => ( +
    + {line} +
    + ))} +
    + )} +
    +
    + ); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 89ddef2..d6c478b 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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 }; +}