""" Nodes API — list nodes, get/set node roles + policies, probe agents. """ from fastapi import APIRouter, HTTPException from pydantic import BaseModel from typing import Optional, Dict, Any from backend.nodes.roles_store import load_roles, save_roles, get_role, DEFAULT_ROLE from backend.nodes.roles import NodeRole from backend.nodes.probe import probe_async, normalize from backend.config import CONFIG router = APIRouter(prefix="/api/nodes", tags=["nodes"]) class PolicyBody(BaseModel): policy: Optional[str] = None # preferred | avoid | neutral compile_weight: Optional[float] = None cache_writer: Optional[bool] = None cache_reader: Optional[bool] = None max_thermal_state: Optional[float] = None distcc_enabled: Optional[bool] = None def _registry(): """Lazy import of the singleton registry from api.api.""" from backend.api.api import registry return registry def _node_view(n: dict, role: NodeRole) -> dict: """Combine config + role + live registry state into a single view.""" name = n["name"] reg = _registry().get(name) return { "name": name, "host": n.get("host"), "max_jobs": n.get("max_jobs", 1), "state": "online" if (reg is None or not getattr(reg, "offline", False)) else "offline", "policy": _policy_from_role(role), "role": _role_summary(role), # Live metrics from the registry (may be None if no agent has reported) "heat": getattr(reg, "temp", None) if reg else None, "jobs": getattr(reg, "active_jobs", None) if reg else None, "cpu_load": getattr(reg, "cpu_load", None) if reg else None, "memory_load": getattr(reg, "memory_load", None) if reg else None, "instability": getattr(reg, "instability", None) if reg else None, "last_seen": getattr(reg, "last_seen", None) if reg else None, } # ----------------------------- # LIST NODES # ----------------------------- @router.get("") def list_nodes(): """List configured nodes with their current role + policy + live metrics.""" roles = load_roles() nodes = [_node_view(n, roles.get(n["name"], DEFAULT_ROLE)) for n in CONFIG.get("nodes", [])] return {"master": CONFIG.get("master"), "nodes": nodes} # ----------------------------- # GET ONE NODE # ----------------------------- @router.get("/{name}") def get_node(name: str): roles = load_roles() for n in CONFIG.get("nodes", []): if n["name"] == name: return _node_view(n, roles.get(name, DEFAULT_ROLE)) return {"error": "not found"} # ----------------------------- # SET NODE POLICY # ----------------------------- @router.post("/{name}/policy") def set_node_policy(name: str, body: PolicyBody): roles = load_roles() role = roles.get(name, NodeRole(name=name)) if body.policy is not None: # Translate policy keyword into compile_weight + thermal cap if body.policy == "preferred": role.compile_weight = 2.0 role.max_thermal_state = 0.95 elif body.policy == "avoid": role.compile_weight = 0.2 role.max_thermal_state = 0.5 elif body.policy == "neutral": role.compile_weight = 1.0 role.max_thermal_state = 0.85 if body.compile_weight is not None: role.compile_weight = body.compile_weight if body.cache_writer is not None: role.cache_writer = body.cache_writer if body.cache_reader is not None: role.cache_reader = body.cache_reader if body.max_thermal_state is not None: role.max_thermal_state = body.max_thermal_state if body.distcc_enabled is not None: role.distcc_enabled = body.distcc_enabled roles[name] = role save_roles(roles) return {"status": "ok", "node": name, "role": role.__dict__} # ----------------------------- # PROBE A NODE (manual) # ----------------------------- @router.post("/{name}/probe") async def probe_node_endpoint(name: str): """Manually trigger a probe of the named node's agent. Updates the registry and returns the normalized state.""" # Find the node config cfg = None for n in CONFIG.get("nodes", []): if n["name"] == name: cfg = n break if not cfg: raise HTTPException(404, f"node {name} not configured") host = cfg.get("host") if not host: return {"error": "node has no host configured", "node": name} raw = await probe_async(host) state = normalize(raw, name) if raw is not None: # Update the registry with the fresh probe data reg = _registry() reg.update( name, cpu_load=state["cpu_load"], memory_load=state["memory_load"], temp=state["temp"], instability=state["instability"], active_jobs=state["active_jobs"], last_seen=state["last_seen"], ) return {"status": "ok", "node": name, "source": "agent", "state": state, "raw": raw} else: return {"status": "unreachable", "node": name, "host": host, "state": state} # ----------------------------- # PROBE ALL NODES # ----------------------------- @router.post("/probe-all") async def probe_all_nodes_endpoint(): """Probe every configured node in parallel.""" import asyncio cfg_nodes = CONFIG.get("nodes", []) async def probe_one(n): host = n.get("host") if not host: return {"node": n["name"], "host": None, "reachable": False, "state": normalize(None, n["name"])} raw = await probe_async(host) state = normalize(raw, n["name"]) if raw is not None: reg = _registry() reg.update( n["name"], cpu_load=state["cpu_load"], memory_load=state["memory_load"], temp=state["temp"], instability=state["instability"], active_jobs=state["active_jobs"], last_seen=state["last_seen"], ) return {"node": n["name"], "host": host, "reachable": raw is not None, "state": state} results = await asyncio.gather(*[probe_one(n) for n in cfg_nodes]) return {"results": list(results)} # ----------------------------- # HELPERS # ----------------------------- def _policy_from_role(role: NodeRole) -> str: if role.compile_weight >= 2.0: return "preferred" if role.compile_weight <= 0.2: return "avoid" return "neutral" def _role_summary(role: NodeRole) -> Dict[str, Any]: return { "compile_weight": role.compile_weight, "cache_writer": role.cache_writer, "cache_reader": role.cache_reader, "distcc_enabled": role.distcc_enabled, "max_thermal_state": role.max_thermal_state, } # ----------------------------- # Legacy wrapper for api.py facade # ----------------------------- def nodes_endpoint(registry=None): """Legacy entrypoint — returns the same data as GET /api/nodes.""" return list_nodes()