diff --git a/drb-c2-core/app/routers/nodes.py b/drb-c2-core/app/routers/nodes.py index ed9e965..3707366 100644 --- a/drb-c2-core/app/routers/nodes.py +++ b/drb-c2-core/app/routers/nodes.py @@ -97,10 +97,40 @@ async def request_node_update(node_id: str, _: dict = Depends(require_admin_toke if not node: raise HTTPException(404, f"Node '{node_id}' not found.") requested_at = datetime.now(timezone.utc).isoformat() + mqtt_sent = await _flag_update(node_id, requested_at) + return {"ok": True, "update_requested_at": requested_at, "mqtt_sent": mqtt_sent} + + +async def _flag_update(node_id: str, requested_at: str) -> bool: await fstore.doc_update("nodes", node_id, {"update_requested_at": requested_at}) mqtt_sent = mqtt_handler.send_command(node_id, {"action": "node_update"}) logger.info(f"Update requested for {node_id!r} at {requested_at} (mqtt_sent={mqtt_sent})") - return {"ok": True, "update_requested_at": requested_at, "mqtt_sent": mqtt_sent} + return mqtt_sent + + +@router.post("/request-update-all") +async def request_update_all_nodes(_: dict = Depends(require_admin_token)): + """Fleet-wide "Update Node": the same two-channel request as + request_node_update, for every approved node. Pending/rejected nodes are + skipped — a rejected node must not be nudged (server-26#195), and a pending + one has no key to check in with.""" + requested_at = datetime.now(timezone.utc).isoformat() + nodes = await fstore.collection_list("nodes") + requested, mqtt_sent, skipped = [], [], [] + for n in nodes: + nid = n.get("node_id") + if not nid: + continue + if n.get("approval_status") != "approved": + skipped.append(nid) + continue + requested.append(nid) + if await _flag_update(nid, requested_at): + mqtt_sent.append(nid) + logger.info(f"Fleet update requested at {requested_at}: {len(requested)} nodes, " + f"{len(mqtt_sent)} via MQTT, {len(skipped)} skipped") + return {"ok": True, "update_requested_at": requested_at, "requested": requested, + "mqtt_sent": mqtt_sent, "skipped": skipped} @router.delete("/{node_id}", status_code=204) diff --git a/drb-c2-core/tests/test_node_update_all.py b/drb-c2-core/tests/test_node_update_all.py new file mode 100644 index 0000000..b16eb09 --- /dev/null +++ b/drb-c2-core/tests/test_node_update_all.py @@ -0,0 +1,56 @@ +""" +POST /nodes/request-update-all — the fleet-wide "Update Node": flags every +approved node (HTTPS) and nudges it over MQTT; pending/rejected nodes are skipped. +""" +from unittest.mock import AsyncMock, MagicMock, patch + +from fastapi.testclient import TestClient + +from app.main import app +from app.internal.auth import require_admin_token, require_service_or_firebase_token +from app.routers import nodes + +client = TestClient(app) + + +def setup_function(): + app.dependency_overrides[require_admin_token] = lambda: {"admin": True} + app.dependency_overrides[require_service_or_firebase_token] = lambda: {"admin": True} + + +def teardown_function(): + app.dependency_overrides.pop(require_admin_token, None) + app.dependency_overrides.pop(require_service_or_firebase_token, None) + + +def test_update_all_flags_only_approved_nodes(): + fleet = [ + {"node_id": "a", "approval_status": "approved"}, + {"node_id": "b", "approval_status": "approved"}, + {"node_id": "p", "approval_status": "pending"}, + {"node_id": "r", "approval_status": "rejected"}, + ] + doc_update = AsyncMock() + send = MagicMock(side_effect=lambda nid, cmd: nid == "a") + with patch.object(nodes.fstore, "collection_list", AsyncMock(return_value=fleet)), \ + patch.object(nodes.fstore, "doc_update", doc_update), \ + patch.object(nodes.mqtt_handler, "send_command", send): + r = client.post("/nodes/request-update-all") + + assert r.status_code == 200 + body = r.json() + assert body["requested"] == ["a", "b"] + assert body["mqtt_sent"] == ["a"] + assert sorted(body["skipped"]) == ["p", "r"] + flagged = {c.args[1] for c in doc_update.await_args_list} + assert flagged == {"a", "b"} + for c in doc_update.await_args_list: + assert c.args[2] == {"update_requested_at": body["update_requested_at"]} + assert {c.args[0] for c in send.call_args_list} == {"a", "b"} + assert all(c.args[1] == {"action": "node_update"} for c in send.call_args_list) + + +def test_update_all_requires_admin(): + app.dependency_overrides.pop(require_admin_token, None) + r = client.post("/nodes/request-update-all") + assert r.status_code in (401, 403) diff --git a/drb-frontend/app/nodes/page.tsx b/drb-frontend/app/nodes/page.tsx index 7215834..c72a3d7 100644 --- a/drb-frontend/app/nodes/page.tsx +++ b/drb-frontend/app/nodes/page.tsx @@ -7,6 +7,7 @@ import { useSystems } from "@/lib/useSystems"; import { NodeCard } from "@/components/NodeCard"; import { NodeConfigModal } from "@/components/NodeConfigModal"; import { useAuth } from "@/components/AuthProvider"; +import { c2api } from "@/lib/c2api"; import type { NodeRecord } from "@/lib/types"; export default function NodesPage() { @@ -20,6 +21,32 @@ export default function NodesPage() { }, [authLoading, isAdmin, isOperator, router]); const [configNode, setConfigNode] = useState(null); + const [updatingAll, setUpdatingAll] = useState(false); + + async function handleUpdateAll() { + if ( + !confirm( + "Update every approved node? Each one pulls the latest release it tracks and recreates its " + + "containers (radio capture pauses for about a minute per node). Online nodes start within " + + "seconds; offline nodes pick it up on their next check-in (up to ~15 minutes).", + ) + ) + return; + setUpdatingAll(true); + try { + const r = await c2api.requestUpdateAllNodes(); + const offline = r.requested.filter((n) => !r.mqtt_sent.includes(n)); + alert( + `Update requested for ${r.requested.length} node(s).` + + (offline.length ? ` Not reachable over MQTT right now (will update on next check-in): ${offline.join(", ")}.` : "") + + (r.skipped.length ? ` Skipped (not approved): ${r.skipped.join(", ")}.` : ""), + ); + } catch (err) { + alert(err instanceof Error ? err.message : "Update request failed."); + } finally { + setUpdatingAll(false); + } + } // Every hook must run before this guard. React tracks hooks by call order, // so returning early on the first render and then reaching a useState on the @@ -32,7 +59,18 @@ export default function NodesPage() { return (
-

Nodes

+
+

Nodes

+ {isAdmin && ( + + )} +
{pending.length > 0 && (
diff --git a/drb-frontend/lib/c2api.ts b/drb-frontend/lib/c2api.ts index bfb3067..7b38f5f 100644 --- a/drb-frontend/lib/c2api.ts +++ b/drb-frontend/lib/c2api.ts @@ -114,6 +114,12 @@ export const c2api = { `/nodes/${id}/request-update`, { method: "POST" }, ), + /** Fleet-wide Update Node: every approved node; pending/rejected are skipped. */ + requestUpdateAllNodes: () => + request<{ ok: boolean; update_requested_at: string; requested: string[]; mqtt_sent: string[]; skipped: string[] }>( + `/nodes/request-update-all`, + { method: "POST" }, + ), rejectNode: (id: string) => request(`/nodes/${id}/reject`, { method: "POST" }),