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:
@@ -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,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)})
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user