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
+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")
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)})