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:
parent
0d68933b85
commit
94727ebe70
1 changed files with 211 additions and 36 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue