diff --git a/server/services/unraid_service.py b/server/services/unraid_service.py index 576c70a..abd6e06 100644 --- a/server/services/unraid_service.py +++ b/server/services/unraid_service.py @@ -8,6 +8,29 @@ from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# GraphQL query for the Unraid built-in API (6.12+) +# --------------------------------------------------------------------------- + +_GRAPHQL_QUERY = """ +{ + online + info { + os { hostname uptime } + cpu { model cores threads } + memory { layout { size type } } + } + docker { + containers { names state status image } + } + array { + state + capacity { kilobytes { free used total } } + } + shares { name free size } +} +""".strip() + @dataclass class ServerConfig: @@ -34,6 +57,144 @@ def _empty_stats(server: ServerConfig) -> Dict[str, Any]: } +# --------------------------------------------------------------------------- +# GraphQL parser (Unraid 6.12+ built-in API) +# --------------------------------------------------------------------------- + +def _parse_graphql_response(data: Dict[str, Any], result: Dict[str, Any]) -> None: + """Parse a successful GraphQL response into the standard result dict.""" + result["online"] = data.get("online", True) + + # --- Info --- + info = data.get("info", {}) + os_info = info.get("os", {}) + result["uptime"] = os_info.get("uptime", "") + + 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) + + # Memory: sum layout slots for total GB + mem_layout = info.get("memory", {}).get("layout", []) + total_bytes = sum(slot.get("size", 0) for slot in mem_layout) + result["ram"]["total_gb"] = round(total_bytes / (1024 ** 3), 1) + # GraphQL API doesn't expose used memory — keep 0 + + # --- Docker --- + docker_data = data.get("docker", {}) + containers_raw: List[Dict[str, Any]] = docker_data.get("containers", []) + containers: List[Dict[str, Any]] = [] + running_count = 0 + for c in containers_raw: + names = c.get("names", []) + name = names[0].lstrip("/") if names else "unknown" + state = c.get("state", "unknown") + is_running = state == "RUNNING" + if is_running: + running_count += 1 + containers.append({ + "name": name, + "status": c.get("status", ""), + "image": c.get("image", ""), + "running": is_running, + }) + result["docker"]["running"] = running_count + result["docker"]["containers"] = containers + + # --- Array --- + array_data = data.get("array", {}) + result["array"]["status"] = array_data.get("state", "unknown").lower() + + cap = array_data.get("capacity", {}).get("kilobytes", {}) + total_kb = int(cap.get("total", 0)) + used_kb = int(cap.get("used", 0)) + if total_kb > 0: + result["array"]["total_tb"] = round(total_kb / (1024 ** 2), 1) # KB → TB + result["array"]["used_tb"] = round(used_kb / (1024 ** 2), 1) + + # --- Shares (expose as top-level) --- + shares_raw = data.get("shares", []) + shares: List[Dict[str, Any]] = [] + for s in shares_raw: + free_kb = s.get("free", 0) + shares.append({ + "name": s.get("name", ""), + "free_gb": round(free_kb / (1024 ** 2), 1), + }) + result["shares"] = shares + + +async def _try_graphql_endpoint( + client: httpx.AsyncClient, + server: ServerConfig, + result: Dict[str, Any], +) -> bool: + """Attempt to fetch stats via the Unraid GraphQL API (6.12+). + + Returns True if successful, False otherwise. + """ + if not server.api_key: + return False + + base = f"http://{server.host}:{server.port}" + headers = { + "x-api-key": server.api_key, + "Content-Type": "application/json", + "Origin": base, + } + + try: + resp = await client.post( + f"{base}/graphql", + headers=headers, + json={"query": _GRAPHQL_QUERY}, + ) + + if resp.status_code == 403: + # 403 means the endpoint exists but auth failed + logger.warning("[UNRAID] %s (%s): GraphQL 403 — invalid API key?", + server.name, server.host) + return False + + if resp.status_code != 200: + return False + + body = resp.json() + + # Check for GraphQL-level errors + errors = body.get("errors") + if errors and not body.get("data"): + first_msg = errors[0].get("message", "") if errors else "" + logger.warning("[UNRAID] %s (%s): GraphQL error: %s", + server.name, server.host, first_msg) + return False + + data = body.get("data") + if not data: + return False + + _parse_graphql_response(data, result) + logger.info( + "[UNRAID] %s (%s): GraphQL OK — %d containers (%d running), %s cores", + server.name, server.host, + len(result["docker"]["containers"]), + result["docker"]["running"], + result["cpu"]["cores"], + ) + return True + + except Exception as exc: + logger.debug("[UNRAID] %s (%s): GraphQL failed: %s", + server.name, server.host, exc) + return False + + +# --------------------------------------------------------------------------- +# Legacy REST parser (custom Unraid API plugins) +# --------------------------------------------------------------------------- + def _parse_system_info(data: Dict[str, Any], result: Dict[str, Any]) -> None: """Populate *result* from a generic ``/api/system`` JSON response.""" result["online"] = True @@ -95,12 +256,12 @@ def _parse_docker_info(data: Dict[str, Any], result: Dict[str, Any]) -> None: result["docker"]["containers"] = containers -async def _try_api_endpoint( +async def _try_rest_endpoint( client: httpx.AsyncClient, server: ServerConfig, result: Dict[str, Any], ) -> bool: - """Attempt to fetch stats via the Unraid OS API. + """Attempt to fetch stats via legacy REST API endpoints. Returns True if successful, False otherwise. """ @@ -117,43 +278,41 @@ async def _try_api_endpoint( _parse_system_info(data, result) _parse_array_info(data, result) _parse_docker_info(data, result) - logger.info("[UNRAID] %s (%s): API OK", server.name, server.host) + logger.info("[UNRAID] %s (%s): REST API OK", server.name, server.host) return True else: - logger.warning("[UNRAID] %s (%s): /api/system returned HTTP %d", - server.name, server.host, resp.status_code) + logger.debug("[UNRAID] %s (%s): /api/system returned HTTP %d", + server.name, server.host, resp.status_code) except Exception as exc: - logger.warning("[UNRAID] %s (%s): /api/system failed: %s", - server.name, server.host, exc) + logger.debug("[UNRAID] %s (%s): /api/system failed: %s", + server.name, server.host, exc) # Try individual endpoints if the combined one failed fetched_any = False - try: - resp = await client.get(f"{base}/api/cpu", headers=headers) - if resp.status_code == 200: - cpu_data = resp.json() - result["cpu"]["usage_pct"] = cpu_data.get("usage_pct", cpu_data.get("usage", 0)) - result["cpu"]["cores"] = cpu_data.get("cores", 0) - result["cpu"]["temp_c"] = cpu_data.get("temp_c", None) - result["online"] = True - fetched_any = True - except Exception: - pass - - try: - resp = await client.get(f"{base}/api/memory", headers=headers) - if resp.status_code == 200: - ram_data = resp.json() - result["ram"]["used_gb"] = round(ram_data.get("used_gb", ram_data.get("used", 0)), 2) - result["ram"]["total_gb"] = round(ram_data.get("total_gb", ram_data.get("total", 0)), 2) - total = result["ram"]["total_gb"] - if total > 0: - result["ram"]["pct"] = round(result["ram"]["used_gb"] / total * 100, 1) - result["online"] = True - fetched_any = True - except Exception: - pass + for endpoint, parser in [ + ("/api/cpu", lambda d: ( + result["cpu"].update({ + "usage_pct": d.get("usage_pct", d.get("usage", 0)), + "cores": d.get("cores", 0), + "temp_c": d.get("temp_c", None), + }), + )), + ("/api/memory", lambda d: ( + result["ram"].update({ + "used_gb": round(d.get("used_gb", d.get("used", 0)), 2), + "total_gb": round(d.get("total_gb", d.get("total", 0)), 2), + }), + )), + ]: + try: + resp = await client.get(f"{base}{endpoint}", headers=headers) + if resp.status_code == 200: + parser(resp.json()) + result["online"] = True + fetched_any = True + except Exception: + pass try: resp = await client.get(f"{base}/api/array", headers=headers) @@ -176,6 +335,10 @@ async def _try_api_endpoint( return fetched_any +# --------------------------------------------------------------------------- +# Connectivity fallback +# --------------------------------------------------------------------------- + async def _try_connectivity_check( client: httpx.AsyncClient, server: ServerConfig, @@ -192,11 +355,17 @@ async def _try_connectivity_check( result["online"] = False +# --------------------------------------------------------------------------- +# Main fetch function +# --------------------------------------------------------------------------- + async def fetch_server_stats(server: ServerConfig) -> Dict[str, Any]: """Fetch system stats from an Unraid server. - Tries the Unraid API first (if ``api_key`` is configured), then falls back - to a simple HTTP connectivity check. + Strategy: + 1. Try Unraid GraphQL API (built-in since 6.12, uses ``x-api-key`` header) + 2. Fall back to legacy REST API (custom plugins, uses ``Bearer`` token) + 3. Fall back to simple HTTP connectivity check Args: server: A :class:`ServerConfig` describing the target server. @@ -213,10 +382,16 @@ async def fetch_server_stats(server: ServerConfig) -> Dict[str, Any]: try: async with httpx.AsyncClient(timeout=10, verify=False) as client: - api_ok = await _try_api_endpoint(client, server, result) + # 1) Try GraphQL first (modern Unraid 6.12+) + api_ok = await _try_graphql_endpoint(client, server, result) + # 2) Fall back to REST + if not api_ok: + api_ok = await _try_rest_endpoint(client, server, result) + + # 3) Fall back to connectivity check if not api_ok and not result["online"]: - logger.info("[UNRAID] %s: API failed, trying connectivity check", server.name) + logger.info("[UNRAID] %s: APIs failed, trying connectivity check", server.name) await _try_connectivity_check(client, server, result) except Exception as exc: