From 7d1987c4f48eda5a14f935087e5d778873f66925 Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 21 Aug 2026 21:09:15 +0200 Subject: [PATCH] feat: add NVIDIA Graphics tab with GPU monitoring via nvidia-smi and configurable binary path Add graphics_snapshot() function to query GPU details using nvidia-smi with 5s timeout. Parse GPU metrics including model, driver, UUID, serial, temperature, utilization, VRAM, power, clocks, and compute processes. Add NVIDIA_SMI_BIN environment variable with default "nvidia-smi". Add /api/graphics endpoint returning GPU data with error handling. Add Graphics navigation tab with purple expansion-card icon --- README.md | 26 ++++++++++++++++++++++++++ app.py | 43 +++++++++++++++++++++++++++++++++++++++++++ templates/index.html | 10 +++++----- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7bb4346..c65fe44 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Media Max ist ein kompaktes Media-Control-Dashboard für Radarr, Sonarr und SABn - Download-Sprachen aus üblichen Release-Kürzeln wie `Eng`, `Ger`, `Fre`, `Ita` und `Spa` - Dashboard mit Diensten, CPU, RAM, Speicher, I/O Pressure und Bibliotheksstatistiken - Datastore mit Kategorien, Gesamtauslastung, I/O Pressure und RAID-Status +- Graphics-Tab mit NVIDIA-GPU-Modell, Treiber, UUID, Seriennummer, Temperatur, Auslastung, VRAM, Leistung, Clocks und GPU-Prozessen - RAID-Rebuild-Fortschritt, Geschwindigkeit, Restzeit, Plattenmodell, SMART, Temperatur und Hersteller-Seriennummer - Parallele Hintergrundscans mit konfigurierbarer Worker-Anzahl - PWA-Unterstützung für HTTPS-fähige Installationen auf Desktop und Android @@ -109,6 +110,31 @@ Für SMART, Temperaturen und echte Hersteller-Seriennummern benötigt der Contai Die I/O Pressure basiert auf Linux PSI (`/proc/pressure/io`). Wenn der Kernel PSI nicht unterstützt, wird die Anzeige als nicht verfügbar markiert. +### NVIDIA Graphics + +Der Graphics-Tab verwendet `nvidia-smi` im Container. Optional kann der Pfad über `NVIDIA_SMI_BIN` gesetzt werden: + +```env +NVIDIA_SMI_BIN=nvidia-smi +``` + +Für NVIDIA Container Toolkit kann der Service zusätzlich mit GPU-Zugriff gestartet werden: + +```yaml +deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] +environment: + NVIDIA_VISIBLE_DEVICES: all + NVIDIA_DRIVER_CAPABILITIES: compute,utility +``` + +Wenn `nvidia-smi` nicht erreichbar ist, bleibt der Tab verfügbar und zeigt den Grund an. + ## Logging ```env diff --git a/app.py b/app.py index 49a08e0..0969c80 100644 --- a/app.py +++ b/app.py @@ -43,6 +43,7 @@ DATASTORE_PATH = os.environ.get("DATASTORE_PATH", "/nesflix").rstrip("/") or "/" RAID_DEVICE = os.environ.get("RAID_DEVICE", "/dev/md127") HOST_DEVICE_PATH = os.environ.get("HOST_DEVICE_PATH", "/host-dev") HOST_SYS_PATH = os.environ.get("HOST_SYS_PATH", "/host-sys") +NVIDIA_SMI_BIN = os.environ.get("NVIDIA_SMI_BIN", "nvidia-smi") STORAGE_CACHE_SECONDS = int(os.environ.get("STORAGE_CACHE_SECONDS", "900")) SMART_ENABLED = os.environ.get("SMART_ENABLED", "false").lower() in {"1", "true", "yes", "on"} LOG_FILE = os.environ.get("LOG_FILE", "/data/media-max.log") @@ -353,6 +354,40 @@ def io_pressure(): except (OSError, ValueError): return {"some": {}, "full": {}, "available": False} +def graphics_snapshot(): + """Read NVIDIA GPU details with short, non-blocking subprocess timeouts.""" + query = "index,name,uuid,serial,pci.bus_id,driver_version,pstate,temperature.gpu,utilization.gpu,utilization.memory,memory.total,memory.used,memory.free,power.draw,power.limit,fan.speed,clocks.current.graphics,clocks.current.memory,clocks.max.graphics,clocks.max.memory,compute_mode" + try: + result = subprocess.run([NVIDIA_SMI_BIN, f"--query-gpu={query}", "--format=csv,noheader,nounits"], capture_output=True, text=True, timeout=5, check=False) + except (OSError, subprocess.SubprocessError): + return {"available": False, "gpus": [], "processes": [], "error": "NVIDIA SMI nicht verfügbar"} + if result.returncode != 0 or not result.stdout.strip(): + return {"available": False, "gpus": [], "processes": [], "error": "Keine NVIDIA-GPU erkannt"} + fields = query.split(",") + gpus = [] + for line in result.stdout.splitlines(): + values = [value.strip() for value in line.split(",")] + if len(values) < len(fields): + continue + gpu = dict(zip(fields, values)) + for key in ("index", "temperature.gpu", "utilization.gpu", "utilization.memory", "memory.total", "memory.used", "memory.free", "clocks.current.graphics", "clocks.current.memory", "clocks.max.graphics", "clocks.max.memory"): + try: gpu[key] = int(float(gpu[key])) + except (TypeError, ValueError): gpu[key] = None + for key in ("power.draw", "power.limit"): + try: gpu[key] = round(float(gpu[key]), 1) + except (TypeError, ValueError): gpu[key] = None + gpus.append(gpu) + processes = [] + try: + proc = subprocess.run([NVIDIA_SMI_BIN, "--query-compute-apps=pid,process_name,used_gpu_memory", "--format=csv,noheader,nounits"], capture_output=True, text=True, timeout=5, check=False) + for line in proc.stdout.splitlines(): + values = [value.strip() for value in line.split(",")] + if len(values) >= 3: + processes.append({"pid": values[0], "name": values[1], "memory": values[2]}) + except (OSError, subprocess.SubprocessError): + pass + return {"available": bool(gpus), "gpus": gpus, "processes": processes, "error": None if gpus else "Keine NVIDIA-GPU erkannt", "updated_at": time.time()} + def smart_status(device): if not SMART_ENABLED: return "Nicht aktiviert" @@ -992,6 +1027,14 @@ def datastore(): log.error("Datastore konnte nicht abgefragt werden: %s", error_summary(exc)) return jsonify({"storage": {"error": "Datastore momentan nicht verfügbar", "path": DATASTORE_PATH, "categories": []}, "raid": {"status": "failed", "label": "FAILED", "error": "RAID-Status momentan nicht verfügbar"}}), 500 +@app.get("/api/graphics") +def graphics(): + try: + return jsonify(graphics_snapshot()) + except Exception as exc: + log.error("GPU-Status konnte nicht abgefragt werden: %s", error_summary(exc)) + return jsonify({"available": False, "gpus": [], "processes": [], "error": "GPU-Status momentan nicht verfügbar"}) + @app.get("/health") def health(): return jsonify({"ok":True, "radarr_url":RADARR_URL, "sonarr_enabled":bool(SONARR_URL and SONARR_API_KEY), "sab_enabled":bool(SAB_URL and SAB_API_KEY)}) diff --git a/templates/index.html b/templates/index.html index 39331a3..c2e8758 100644 --- a/templates/index.html +++ b/templates/index.html @@ -60,10 +60,10 @@ if ('serviceWorker' in navigator) { .settings-modal .setting-help{display:block;margin:-3px 0 12px;color:var(--muted);font-size:11px}.settings-modal .setting-select{width:100%;height:38px}.pwa-install{display:none} @media(max-width:700px){body{font-size:12px;padding-bottom:env(safe-area-inset-bottom)}header{height:auto;min-height:62px;padding:9px 12px;gap:9px;align-items:center}.brand-mark,.brand-mark svg{width:30px;height:30px}.brand-name{font-size:14px}.head-title{font-size:12px}.head-sub{font-size:10px}.actions{gap:4px}.actions button{height:34px;padding:0 8px}.settings-button{font-size:15px}.actions #rescan{font-size:0}.actions #rescan::after{content:'↻';font-size:16px}main{padding:10px 10px 24px}.nav{margin-bottom:10px;padding:4px;overflow-x:auto;scrollbar-width:none}.nav::-webkit-scrollbar{display:none}.nav button{flex:0 0 auto;white-space:nowrap;padding:8px 10px;font-size:11px}.toolbar{grid-template-columns:1fr;gap:7px;padding:8px}.toolbar input,.toolbar select{width:100%;height:38px}.stats{gap:6px}.stat{padding:6px 8px}.tablewrap{overflow-x:auto;border-radius:8px}table{min-width:860px}.dashboard-hero{margin-bottom:10px;padding:18px;border-radius:11px}.dashboard-hero h1{font-size:21px}.dashboard-hero p{font-size:11px;line-height:1.4}.overview-grid{grid-template-columns:1fr 1fr;gap:7px}.overview-card{min-height:84px;padding:12px 10px;gap:8px}.overview-icon{width:32px;height:32px;font-size:18px}.overview-card strong{font-size:18px}.overview-card span:not(.overview-icon){font-size:9px}.overview-card small{font-size:9px}.dashboard-columns{gap:8px}.system-panel,.service-panel{padding:14px;border-radius:10px}.system-meters{gap:12px}.panel-heading{margin-bottom:13px}.panel-heading h2{font-size:14px}.panel-heading p{font-size:10px}.disk-row{grid-template-columns:82px 1fr auto;gap:7px}.disk-values{font-size:10px}.download-hero{padding:18px;margin-bottom:10px}.download-hero h2{font-size:19px}.download-hero p{font-size:11px}.download-stats{grid-template-columns:1fr 1fr;gap:7px}.download-card{min-height:78px;padding:10px;gap:8px}.card-icon{width:30px;height:30px;font-size:17px}.card-label{font-size:9px}.download-card strong{font-size:17px}.download-card small{font-size:9px}.settings-modal{max-height:calc(100dvh - 24px);overflow:auto;padding:18px}.settings-head{margin-bottom:15px}.settings-modal label{margin-top:13px}} @media(max-width:390px){.brand-name{display:none}.head-sub{display:none}.overview-grid{grid-template-columns:1fr}.dashboard-hero{padding:15px}.download-stats{grid-template-columns:1fr}} - +
MEDIA
Media Max
Audio- und Untertitelspuren aus den Mediendateien
- +
MEDIA MAX

Dashboard

Deine gesamte Medienbibliothek und Systemauslastung auf einen Blick.

Live
Filme{{ rows|length }}Radarr Library
Serien{{ series|length }}Sonarr Shows
Ohne Deutsch{{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }}Audio oder Untertitel prüfen
DownloadsSABnzbd wird geladen …

Systemauslastung

Aktuelle Werte aus dem Dashboard-Container

Wird geladen …
CPU
RAM
Laufwerke werden geladen …

Dienste

Verbindung und Bibliotheksstatus

RadarrFilme & Qualität
Aktiv
SonarrSerien & Episoden
{% if sonarr_enabled %}Aktiv{% else %}Nicht konfiguriert{% endif %}
SABnzbdDownloads & Queue
{% if sab_enabled %}Wird geprüft …{% else %}Nicht konfiguriert{% endif %}
@@ -79,7 +79,7 @@ if ('serviceWorker' in navigator) { {% for group in missing_german_series %}
{{ group.title }} ({{ group.year or '—' }}) · {{ group.episodes|length }} Episoden ohne Deutsch{% for r in group.episodes %}{% endfor %}
EpisodeQualitätStatusAudioUntertitelVideoGröße
{{ r.title }}{{ r.quality }}Ohne Deutsch
{% for l in r.audio_languages %}{{ l.flag }} {{ l.name }}{% else %}{% endfor %}
{% for l in r.subtitle_languages %}{{ l.flag }} {{ l.name }}{% else %}{% endfor %}
{{ r.video_codec }}{{ r.size }}
{% endfor %} {% if not missing_german_movies and not missing_german_series %}
Alle vorhandenen Filme und Episoden haben deutsches Audio oder deutsche Untertitel.
{% endif %}
{% if sab_enabled %}
SABNZBD · DOWNLOAD CENTER

Download-Übersicht

Queue, Fortschritt und verbleibender Speicherplatz auf einen Blick.

Verbinde mit SABnzbd …
Noch zu ladenWird berechnet …
Aktive DownloadsVerlauf: —
GeschwindigkeitAktuelle Queue
Status
DownloadStatusFortschrittGröße / RestRestzeitQuelleSprache
Downloads werden geladen …
{% endif %} -
MEDIA MAX · DATASTORE

Datastore

Speicherbelegung, Medienkategorien und RAID-Status von /nesflix.

Wird geladen …
Gesamtspeicher
Belegt
Frei
%
Auslastung

Gesamtauslastung

/nesflix

Speicher nach Kategorie

Anhand der tatsächlichen Verzeichnisse

Wird geladen …

RAID-Status

/dev/md127

Wird geladen …
Wird geladen …
+
MEDIA MAX · GRAPHICS

NVIDIA Graphics

GPU-Auslastung, Speicher, Temperatur, Leistung und laufende Prozesse.

Wird geladen …
GPUs
GPU-Auslastung
VRAM
°
Temperatur
GPU-Daten werden geladen …

GPU-Prozesse

Aktuelle Compute-Prozesse laut NVIDIA SMI

Keine Prozesse erkannt
MEDIA MAX · DATASTORE

Datastore

Speicherbelegung, Medienkategorien und RAID-Status von /nesflix.

Wird geladen …
Gesamtspeicher
Belegt
Frei
%
Auslastung

Gesamtauslastung

/nesflix

Speicher nach Kategorie

Anhand der tatsächlichen Verzeichnisse

Wird geladen …

RAID-Status

/dev/md127

Wird geladen …
Wird geladen …