Add Unraid GraphQL API support (6.12+) for server monitoring

The Unraid built-in API uses GraphQL at /graphql with x-api-key auth
instead of REST endpoints. Service now tries GraphQL first, then falls
back to legacy REST, then connectivity check.

Fetches: hostname, uptime, CPU cores/threads, RAM total, Docker
containers with status, array state, and share free space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sam 2026-03-02 22:30:05 +01:00
parent 0d68933b85
commit 94727ebe70

View file

@ -8,6 +8,29 @@ from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__) 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 @dataclass
class ServerConfig: 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: def _parse_system_info(data: Dict[str, Any], result: Dict[str, Any]) -> None:
"""Populate *result* from a generic ``/api/system`` JSON response.""" """Populate *result* from a generic ``/api/system`` JSON response."""
result["online"] = True result["online"] = True
@ -95,12 +256,12 @@ def _parse_docker_info(data: Dict[str, Any], result: Dict[str, Any]) -> None:
result["docker"]["containers"] = containers result["docker"]["containers"] = containers
async def _try_api_endpoint( async def _try_rest_endpoint(
client: httpx.AsyncClient, client: httpx.AsyncClient,
server: ServerConfig, server: ServerConfig,
result: Dict[str, Any], result: Dict[str, Any],
) -> bool: ) -> 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. Returns True if successful, False otherwise.
""" """
@ -117,43 +278,41 @@ async def _try_api_endpoint(
_parse_system_info(data, result) _parse_system_info(data, result)
_parse_array_info(data, result) _parse_array_info(data, result)
_parse_docker_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 return True
else: else:
logger.warning("[UNRAID] %s (%s): /api/system returned HTTP %d", logger.debug("[UNRAID] %s (%s): /api/system returned HTTP %d",
server.name, server.host, resp.status_code) server.name, server.host, resp.status_code)
except Exception as exc: except Exception as exc:
logger.warning("[UNRAID] %s (%s): /api/system failed: %s", logger.debug("[UNRAID] %s (%s): /api/system failed: %s",
server.name, server.host, exc) server.name, server.host, exc)
# Try individual endpoints if the combined one failed # Try individual endpoints if the combined one failed
fetched_any = False fetched_any = False
try: for endpoint, parser in [
resp = await client.get(f"{base}/api/cpu", headers=headers) ("/api/cpu", lambda d: (
if resp.status_code == 200: result["cpu"].update({
cpu_data = resp.json() "usage_pct": d.get("usage_pct", d.get("usage", 0)),
result["cpu"]["usage_pct"] = cpu_data.get("usage_pct", cpu_data.get("usage", 0)) "cores": d.get("cores", 0),
result["cpu"]["cores"] = cpu_data.get("cores", 0) "temp_c": d.get("temp_c", None),
result["cpu"]["temp_c"] = cpu_data.get("temp_c", None) }),
result["online"] = True )),
fetched_any = True ("/api/memory", lambda d: (
except Exception: result["ram"].update({
pass "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}/api/memory", headers=headers) )),
if resp.status_code == 200: ]:
ram_data = resp.json() try:
result["ram"]["used_gb"] = round(ram_data.get("used_gb", ram_data.get("used", 0)), 2) resp = await client.get(f"{base}{endpoint}", headers=headers)
result["ram"]["total_gb"] = round(ram_data.get("total_gb", ram_data.get("total", 0)), 2) if resp.status_code == 200:
total = result["ram"]["total_gb"] parser(resp.json())
if total > 0: result["online"] = True
result["ram"]["pct"] = round(result["ram"]["used_gb"] / total * 100, 1) fetched_any = True
result["online"] = True except Exception:
fetched_any = True pass
except Exception:
pass
try: try:
resp = await client.get(f"{base}/api/array", headers=headers) resp = await client.get(f"{base}/api/array", headers=headers)
@ -176,6 +335,10 @@ async def _try_api_endpoint(
return fetched_any return fetched_any
# ---------------------------------------------------------------------------
# Connectivity fallback
# ---------------------------------------------------------------------------
async def _try_connectivity_check( async def _try_connectivity_check(
client: httpx.AsyncClient, client: httpx.AsyncClient,
server: ServerConfig, server: ServerConfig,
@ -192,11 +355,17 @@ async def _try_connectivity_check(
result["online"] = False result["online"] = False
# ---------------------------------------------------------------------------
# Main fetch function
# ---------------------------------------------------------------------------
async def fetch_server_stats(server: ServerConfig) -> Dict[str, Any]: async def fetch_server_stats(server: ServerConfig) -> Dict[str, Any]:
"""Fetch system stats from an Unraid server. """Fetch system stats from an Unraid server.
Tries the Unraid API first (if ``api_key`` is configured), then falls back Strategy:
to a simple HTTP connectivity check. 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: Args:
server: A :class:`ServerConfig` describing the target server. server: A :class:`ServerConfig` describing the target server.
@ -213,10 +382,16 @@ async def fetch_server_stats(server: ServerConfig) -> Dict[str, Any]:
try: try:
async with httpx.AsyncClient(timeout=10, verify=False) as client: 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"]: 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) await _try_connectivity_check(client, server, result)
except Exception as exc: except Exception as exc: