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
|
- [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
|
the file(s) and recomputes MD5/size/perceptual hashes (409 when the
|
||||||
result matches another item, 400 when it is identical)
|
result matches another item, 400 when it is identical)
|
||||||
- [ ] **Stats dashboard**
|
- [x] **Stats dashboard** (`/stats`, staff)
|
||||||
- [ ] CPU / RAM / GPU / disk usage
|
- [x] CPU / RAM / GPU / disk usage (`psutil` + `nvidia-smi`; capacity bars
|
||||||
- [ ] Active jobs list (this is what makes the footer "Active Workers" real)
|
follow the design system's 80% / 95% colour thresholds)
|
||||||
- [ ] Live log tail
|
- [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
|
## 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 django.urls import path
|
||||||
|
|
||||||
from .views import StatusView
|
from .views import StatsView, StatusView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("status/", StatusView.as_view(), name="status"),
|
path("status/", StatusView.as_view(), name="status"),
|
||||||
|
path("stats/", StatsView.as_view(), name="stats"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from django.conf import settings
|
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.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from . import stats
|
||||||
from .system_info import get_os_info
|
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)
|
payload["server_time_ms"] = round((time.perf_counter() - started) * 1000, 1)
|
||||||
return Response(payload)
|
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)
|
return Response(payload)
|
||||||
|
|
||||||
|
|
||||||
class StorageView(APIView):
|
def storage_info():
|
||||||
"""Disk usage for the watched folder, media root and temp uploads."""
|
"""Disk usage payload, cached briefly (also used by the stats dashboard)."""
|
||||||
|
|
||||||
permission_classes = [IsAuthenticated]
|
|
||||||
|
|
||||||
def get(self, request):
|
|
||||||
cached = cache.get(STORAGE_CACHE_KEY)
|
cached = cache.get(STORAGE_CACHE_KEY)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return Response(cached)
|
return cached
|
||||||
|
|
||||||
watched = Path(settings.WATCHED_FOLDER)
|
watched = Path(settings.WATCHED_FOLDER)
|
||||||
media_root = Path(settings.MEDIA_ROOT)
|
media_root = Path(settings.MEDIA_ROOT)
|
||||||
@@ -448,4 +444,13 @@ class StorageView(APIView):
|
|||||||
"library_items": MediaItem.objects.count(),
|
"library_items": MediaItem.objects.count(),
|
||||||
}
|
}
|
||||||
cache.set(STORAGE_CACHE_KEY, payload, STORAGE_CACHE_TTL)
|
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"
|
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 = {
|
LOGGING = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"disable_existing_loggers": False,
|
"disable_existing_loggers": False,
|
||||||
@@ -212,14 +237,9 @@ LOGGING = {
|
|||||||
"style": "{",
|
"style": "{",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"handlers": {
|
"handlers": _log_handlers,
|
||||||
"console": {
|
|
||||||
"class": "logging.StreamHandler",
|
|
||||||
"formatter": "simple",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"root": {
|
"root": {
|
||||||
"handlers": ["console"],
|
"handlers": _root_handlers,
|
||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ requests>=2.32
|
|||||||
imagehash>=4.3
|
imagehash>=4.3
|
||||||
PyMySQL>=1.1
|
PyMySQL>=1.1
|
||||||
redis>=5.0
|
redis>=5.0
|
||||||
|
psutil>=6.0
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import OnlinePage from "@/features/online/OnlinePage";
|
|||||||
import PoolDetailPage from "@/features/pools/PoolDetailPage";
|
import PoolDetailPage from "@/features/pools/PoolDetailPage";
|
||||||
import PoolsPage from "@/features/pools/PoolsPage";
|
import PoolsPage from "@/features/pools/PoolsPage";
|
||||||
import SimilarPage from "@/features/similar/SimilarPage";
|
import SimilarPage from "@/features/similar/SimilarPage";
|
||||||
|
import StatsPage from "@/features/stats/StatsPage";
|
||||||
import UploadPage from "@/features/upload/UploadPage";
|
import UploadPage from "@/features/upload/UploadPage";
|
||||||
import UsersPage from "@/features/users/UsersPage";
|
import UsersPage from "@/features/users/UsersPage";
|
||||||
import { isAgeVerified, markAgeVerified } from "@/lib/age";
|
import { isAgeVerified, markAgeVerified } from "@/lib/age";
|
||||||
@@ -100,6 +101,14 @@ export default function App() {
|
|||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/stats"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<StatsPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/users"
|
path="/users"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -71,7 +71,10 @@ export function AppShell() {
|
|||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(user && (user.is_staff || user.is_superuser || user.role === "staff")
|
...(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;
|
created_at: string;
|
||||||
updated_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