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
This commit is contained in:
2026-08-21 21:09:15 +02:00
parent 6aa4032134
commit 7d1987c4f4
3 changed files with 74 additions and 5 deletions
+26
View File
@@ -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` - 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 - Dashboard mit Diensten, CPU, RAM, Speicher, I/O Pressure und Bibliotheksstatistiken
- Datastore mit Kategorien, Gesamtauslastung, I/O Pressure und RAID-Status - 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 - RAID-Rebuild-Fortschritt, Geschwindigkeit, Restzeit, Plattenmodell, SMART, Temperatur und Hersteller-Seriennummer
- Parallele Hintergrundscans mit konfigurierbarer Worker-Anzahl - Parallele Hintergrundscans mit konfigurierbarer Worker-Anzahl
- PWA-Unterstützung für HTTPS-fähige Installationen auf Desktop und Android - 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. 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 ## Logging
```env ```env
+43
View File
@@ -43,6 +43,7 @@ DATASTORE_PATH = os.environ.get("DATASTORE_PATH", "/nesflix").rstrip("/") or "/"
RAID_DEVICE = os.environ.get("RAID_DEVICE", "/dev/md127") RAID_DEVICE = os.environ.get("RAID_DEVICE", "/dev/md127")
HOST_DEVICE_PATH = os.environ.get("HOST_DEVICE_PATH", "/host-dev") HOST_DEVICE_PATH = os.environ.get("HOST_DEVICE_PATH", "/host-dev")
HOST_SYS_PATH = os.environ.get("HOST_SYS_PATH", "/host-sys") 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")) 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"} 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") LOG_FILE = os.environ.get("LOG_FILE", "/data/media-max.log")
@@ -353,6 +354,40 @@ def io_pressure():
except (OSError, ValueError): except (OSError, ValueError):
return {"some": {}, "full": {}, "available": False} 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): def smart_status(device):
if not SMART_ENABLED: if not SMART_ENABLED:
return "Nicht aktiviert" return "Nicht aktiviert"
@@ -992,6 +1027,14 @@ def datastore():
log.error("Datastore konnte nicht abgefragt werden: %s", error_summary(exc)) 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 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") @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)}) 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)})
+5 -5
View File
File diff suppressed because one or more lines are too long