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__)
# ---------------------------------------------------------------------------
# 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,39 +278,37 @@ 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",
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",
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
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}/api/cpu", headers=headers)
resp = await client.get(f"{base}{endpoint}", 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)
parser(resp.json())
result["online"] = True
fetched_any = True
except Exception:
@ -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: