Merge pull request 'Nodes: Update All Nodes button (fleet-wide update request)' (#226) from feat/update-all-nodes into main
This commit was merged in pull request #226.
This commit is contained in:
@@ -97,10 +97,40 @@ async def request_node_update(node_id: str, _: dict = Depends(require_admin_toke
|
|||||||
if not node:
|
if not node:
|
||||||
raise HTTPException(404, f"Node '{node_id}' not found.")
|
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||||
requested_at = datetime.now(timezone.utc).isoformat()
|
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})
|
await fstore.doc_update("nodes", node_id, {"update_requested_at": requested_at})
|
||||||
mqtt_sent = mqtt_handler.send_command(node_id, {"action": "node_update"})
|
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})")
|
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)
|
@router.delete("/{node_id}", status_code=204)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -7,6 +7,7 @@ import { useSystems } from "@/lib/useSystems";
|
|||||||
import { NodeCard } from "@/components/NodeCard";
|
import { NodeCard } from "@/components/NodeCard";
|
||||||
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
import type { NodeRecord } from "@/lib/types";
|
import type { NodeRecord } from "@/lib/types";
|
||||||
|
|
||||||
export default function NodesPage() {
|
export default function NodesPage() {
|
||||||
@@ -20,6 +21,32 @@ export default function NodesPage() {
|
|||||||
}, [authLoading, isAdmin, isOperator, router]);
|
}, [authLoading, isAdmin, isOperator, router]);
|
||||||
|
|
||||||
const [configNode, setConfigNode] = useState<NodeRecord | null>(null);
|
const [configNode, setConfigNode] = useState<NodeRecord | null>(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,
|
// 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
|
// so returning early on the first render and then reaching a useState on the
|
||||||
@@ -32,7 +59,18 @@ export default function NodesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-xl font-bold text-white font-mono">Nodes</h1>
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<h1 className="text-xl font-bold text-white font-mono">Nodes</h1>
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={handleUpdateAll}
|
||||||
|
disabled={updatingAll}
|
||||||
|
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updatingAll ? "Requesting…" : "Update All Nodes"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{pending.length > 0 && (
|
{pending.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ export const c2api = {
|
|||||||
`/nodes/${id}/request-update`,
|
`/nodes/${id}/request-update`,
|
||||||
{ method: "POST" },
|
{ 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) =>
|
rejectNode: (id: string) =>
|
||||||
request(`/nodes/${id}/reject`, { method: "POST" }),
|
request(`/nodes/${id}/reject`, { method: "POST" }),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user