Nodes: Update All Nodes — fleet-wide update request for every approved node

POST /nodes/request-update-all (admin) does what Update Node does for each
approved node: flags update_requested_at for the HTTPS updater and nudges it
over MQTT. Pending and rejected nodes are skipped. Admin-only button on the
Nodes page reports which nodes weren't reachable over MQTT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-05 00:29:30 -04:00
co-authored by Claude Opus 5.5
parent 16a609e35b
commit f18f7a49f6
4 changed files with 132 additions and 2 deletions
+39 -1
View File
@@ -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<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,
// so returning early on the first render and then reaching a useState on the
@@ -32,7 +59,18 @@ export default function NodesPage() {
return (
<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 && (
<div className="space-y-2">
+6
View File
@@ -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" }),