From 5d3d4f40150daf848842c409924ae952e3ae664b Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 2 Mar 2026 22:41:16 +0100 Subject: [PATCH] MQTT-based live CPU/RAM metrics + remove auth from HA control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enrich server data with MQTT system topics (cpu_usage_percent, ram_usage_percent, temp, model etc.) published by Unraid MQTT Agent - Works for both Daddelolymp and Adriahub topics - MQTT overlay runs on every request (even cached) for fresh metrics - Remove JWT auth from /api/ha/control — local dashboard doesn't need it - Add cpu brand field to GraphQL query Co-Authored-By: Claude Opus 4.6 --- server/routers/homeassistant.py | 4 +- server/routers/servers.py | 93 +++++++++++++++++++++++++++++++ server/services/unraid_service.py | 6 +- 3 files changed, 97 insertions(+), 6 deletions(-) diff --git a/server/routers/homeassistant.py b/server/routers/homeassistant.py index a99f31f..4fcfefb 100644 --- a/server/routers/homeassistant.py +++ b/server/routers/homeassistant.py @@ -5,10 +5,9 @@ from __future__ import annotations import logging from typing import Any, Dict, Optional -from fastapi import APIRouter, Depends +from fastapi import APIRouter from pydantic import BaseModel -from server.auth import require_admin from server.cache import cache from server.config import get_settings from server.services.ha_service import call_ha_service, fetch_ha_data @@ -58,7 +57,6 @@ class HAControlRequest(BaseModel): @router.post("/ha/control") async def control_ha( body: HAControlRequest, - admin_user: str = Depends(require_admin), # noqa: ARG001 ) -> Dict[str, Any]: """Control a Home Assistant entity (toggle light, open cover, etc.).""" diff --git a/server/routers/servers.py b/server/routers/servers.py index 4720f6b..3ecea6c 100644 --- a/server/routers/servers.py +++ b/server/routers/servers.py @@ -9,6 +9,7 @@ from fastapi import APIRouter from server.cache import cache from server.config import get_settings +from server.services.mqtt_service import mqtt_service from server.services.unraid_service import ServerConfig, fetch_all_servers logger = logging.getLogger(__name__) @@ -18,6 +19,93 @@ router = APIRouter(prefix="/api", tags=["servers"]) CACHE_KEY = "servers" +# --------------------------------------------------------------------------- +# MQTT enrichment — overlay live system metrics from MQTT topics +# --------------------------------------------------------------------------- + +def _enrich_from_mqtt(servers: List[Dict[str, Any]]) -> None: + """Merge live CPU/RAM data from MQTT ``/system`` topics. + + The Unraid MQTT Agent plugin publishes JSON payloads to topics like + ``unraid-daddelolymp/system`` or ``Adriahub/system`` every ~15 s. + These contain live ``cpu_usage_percent``, ``ram_usage_percent``, etc. + that the GraphQL API does not expose. + """ + + store = mqtt_service.store + + for srv in servers: + name = srv.get("name", "") + if not name: + continue + + # Try common topic patterns + system_data: Dict[str, Any] | None = None + for pattern in ( + f"{name}/system", # "Adriahub/system" + f"unraid-{name.lower()}/system", # "unraid-daddelolymp/system" + f"unraid-{name}/system", # "unraid-Daddelolymp/system" + ): + msg = store.get(pattern) + if msg is not None and isinstance(msg.payload, dict): + system_data = msg.payload + break + + if not system_data: + continue + + # --- CPU --- + cpu_pct = system_data.get("cpu_usage_percent") + if cpu_pct is not None: + srv["cpu"]["usage_pct"] = round(float(cpu_pct), 1) + + cpu_model = system_data.get("cpu_model") + if cpu_model: + srv["cpu"]["brand"] = cpu_model + + cpu_temp = system_data.get("cpu_temp_celsius") + if cpu_temp is not None: + srv["cpu"]["temp_c"] = cpu_temp + + cores = system_data.get("cpu_cores") + if cores: + srv["cpu"]["cores"] = cores + + threads = system_data.get("cpu_threads") + if threads: + srv["cpu"]["threads"] = threads + + # --- RAM --- + ram_pct = system_data.get("ram_usage_percent") + if ram_pct is not None: + srv["ram"]["pct"] = round(float(ram_pct), 1) + + ram_total = system_data.get("ram_total_bytes") + if ram_total: + srv["ram"]["total_gb"] = round(ram_total / (1024 ** 3), 1) + + ram_used = system_data.get("ram_used_bytes") + if ram_used: + srv["ram"]["used_gb"] = round(ram_used / (1024 ** 3), 1) + + # --- Uptime --- + uptime_secs = system_data.get("uptime_seconds") + if uptime_secs: + days = uptime_secs // 86400 + hours = (uptime_secs % 86400) // 3600 + srv["uptime"] = f"{days}d {hours}h" + + srv["online"] = True + + logger.debug( + "[UNRAID] %s: MQTT enriched — CPU %.1f%% %.0f°C, RAM %.1f%%", + name, + srv["cpu"].get("usage_pct", 0), + srv["cpu"].get("temp_c", 0) or 0, + srv["ram"].get("pct", 0), + ) + + @router.get("/servers") async def get_servers() -> Dict[str, Any]: """Return status information for all configured Unraid servers. @@ -32,6 +120,8 @@ async def get_servers() -> Dict[str, Any]: # --- cache hit? ----------------------------------------------------------- cached = await cache.get(CACHE_KEY) if cached is not None: + # Always overlay fresh MQTT data even on cache hits + _enrich_from_mqtt(cached.get("servers", [])) return cached # --- cache miss ----------------------------------------------------------- @@ -56,6 +146,9 @@ async def get_servers() -> Dict[str, Any]: "message": str(exc), } + # Overlay live MQTT system metrics + _enrich_from_mqtt(servers_data) + payload: Dict[str, Any] = { "servers": servers_data, } diff --git a/server/services/unraid_service.py b/server/services/unraid_service.py index abd6e06..85ebe6a 100644 --- a/server/services/unraid_service.py +++ b/server/services/unraid_service.py @@ -17,7 +17,7 @@ _GRAPHQL_QUERY = """ online info { os { hostname uptime } - cpu { model cores threads } + cpu { model cores threads manufacturer brand } memory { layout { size type } } } docker { @@ -72,9 +72,9 @@ def _parse_graphql_response(data: Dict[str, Any], result: Dict[str, Any]) -> Non cpu_info = info.get("cpu", {}) result["cpu"]["cores"] = cpu_info.get("cores", 0) - # GraphQL API doesn't expose CPU usage % — keep 0 - # Store threads for the frontend to show result["cpu"]["threads"] = cpu_info.get("threads", 0) + result["cpu"]["brand"] = cpu_info.get("brand", "") + # GraphQL API doesn't expose CPU usage % — keep 0 # Memory: sum layout slots for total GB mem_layout = info.get("memory", {}).get("layout", [])