From 268470b6c0ee9147571b212551f34dd10566fc08 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Mon, 26 Oct 2026 02:24:10 -0400 Subject: [PATCH] nodes: store and show host disk free; warn when low (node-26#41) Checkin now carries disk_free_bytes/disk_total_bytes. Stored on the node doc (not overwritten by older nodes that don't send them). Node page shows a Disk row; node cards warn amber under 15% free, red under 5% or 2 GB. A full disk stopped node-002 recording for 17 days while it showed Online. Frontend unverified locally (no Node, #207); CI build typechecks. Co-Authored-By: Claude Opus 5.5 --- drb-c2-core/app/internal/mqtt_handler.py | 7 ++++- drb-c2-core/tests/test_mqtt_handler.py | 16 ++++++++++ drb-frontend/app/nodes/[id]/page.tsx | 12 ++++++++ drb-frontend/components/NodeCard.tsx | 10 ++++++ drb-frontend/lib/nodeDisk.ts | 39 ++++++++++++++++++++++++ drb-frontend/lib/types.ts | 3 ++ 6 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 drb-frontend/lib/nodeDisk.ts diff --git a/drb-c2-core/app/internal/mqtt_handler.py b/drb-c2-core/app/internal/mqtt_handler.py index 0dd672a..052e0d9 100644 --- a/drb-c2-core/app/internal/mqtt_handler.py +++ b/drb-c2-core/app/internal/mqtt_handler.py @@ -128,6 +128,8 @@ class MQTTHandler: "sdr_devices": payload.get("sdr_devices"), "op25_sdr_serial": payload.get("op25_sdr_serial"), "sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1 + "disk_free_bytes": payload.get("disk_free_bytes"), + "disk_total_bytes": payload.get("disk_total_bytes"), "enforce_override_timeout": payload.get("enforce_override_timeout", True), "is_overridden": False, "override_system_id": None, @@ -161,8 +163,11 @@ class MQTTHandler: # radio_channels (node-26#24): a conventional node's channels and # whether each is encrypted — the one decode failure the owner has # to hear about from C2, since nothing on the node can fix it. + # disk_* (node-26#41): free/total bytes of the host disk the node + # records to — a full disk silently stopped node-002 for 17 days. for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running", - "sdr_pins", "sdr_devices", "op25_sdr_serial", "radio_channels"): + "sdr_pins", "sdr_devices", "op25_sdr_serial", "radio_channels", + "disk_free_bytes", "disk_total_bytes"): if key in payload: updates[key] = payload[key] if "sdr_count" in payload: diff --git a/drb-c2-core/tests/test_mqtt_handler.py b/drb-c2-core/tests/test_mqtt_handler.py index cdbd063..897a6ee 100644 --- a/drb-c2-core/tests/test_mqtt_handler.py +++ b/drb-c2-core/tests/test_mqtt_handler.py @@ -118,6 +118,22 @@ async def test_checkin_updates_existing_configured_node(handler): assert "last_seen" in updates +@pytest.mark.asyncio +async def test_checkin_stores_disk_free_and_total(handler): + # node-26#41: a full disk stopped node-002 recording while it showed Online. + existing = {"node_id": "node-01", "status": "online", "configured": True} + with patch("app.internal.mqtt_handler.fstore") as mock_fstore: + mock_fstore.doc_get = AsyncMock(return_value=existing) + mock_fstore.doc_update = AsyncMock() + await handler._handle_checkin("node-01", {"disk_free_bytes": 5, "disk_total_bytes": 100}) + updates = mock_fstore.doc_update.call_args[0][2] + assert updates["disk_free_bytes"] == 5 and updates["disk_total_bytes"] == 100 + + # An older node that doesn't report disk must not wipe the fields. + await handler._handle_checkin("node-01", {"name": "x"}) + assert "disk_free_bytes" not in mock_fstore.doc_update.call_args[0][2] + + @pytest.mark.asyncio async def test_checkin_does_not_promote_unconfigured_to_online(handler): existing = { diff --git a/drb-frontend/app/nodes/[id]/page.tsx b/drb-frontend/app/nodes/[id]/page.tsx index 72aa159..467700e 100644 --- a/drb-frontend/app/nodes/[id]/page.tsx +++ b/drb-frontend/app/nodes/[id]/page.tsx @@ -16,6 +16,7 @@ import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { useAuth } from "@/components/AuthProvider"; import { c2api } from "@/lib/c2api"; import { errorMessage } from "@/lib/c2error"; +import { diskState, DISK_TEXT_CLASS } from "@/lib/nodeDisk"; import type { NodeRecord } from "@/lib/types"; function ApprovalBadge({ status }: { status: string | null }) { @@ -218,6 +219,17 @@ export default function NodeDetailPage() { {value} ))} + {(() => { + const disk = diskState(node); + return ( +
+ Disk + + {disk ? `${disk.level !== "ok" ? "⚠ " : ""}${disk.label}` : "not reported (node software too old)"} + +
+ ); + })()}
Approval diff --git a/drb-frontend/components/NodeCard.tsx b/drb-frontend/components/NodeCard.tsx index 9d2c0d6..6cf1a41 100644 --- a/drb-frontend/components/NodeCard.tsx +++ b/drb-frontend/components/NodeCard.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { StatusBadge } from "@/components/StatusBadge"; import type { NodeRecord, SystemRecord } from "@/lib/types"; +import { diskState } from "@/lib/nodeDisk"; interface Props { node: NodeRecord; @@ -45,6 +46,15 @@ export function NodeCard({ node, system, linkToDetail = true }: Props) { {lastSeen}
+ {(() => { + const disk = diskState(node); + if (!disk || disk.level === "ok") return null; + return ( +
+ ⚠ Disk {disk.level === "critical" ? "nearly full" : "low"}: {disk.label} +
+ ); + })()} {!node.configured && (
⚠ Needs configuration diff --git a/drb-frontend/lib/nodeDisk.ts b/drb-frontend/lib/nodeDisk.ts new file mode 100644 index 0000000..8274099 --- /dev/null +++ b/drb-frontend/lib/nodeDisk.ts @@ -0,0 +1,39 @@ +import type { NodeRecord } from "@/lib/types"; + +// Free disk on the node's host (node-26#41). A full disk stopped node-002 +// recording for 17 days while every surface still showed it Online, so low +// space is flagged wherever a node is shown. + +export type DiskLevel = "ok" | "low" | "critical"; + +export interface DiskState { + level: DiskLevel; + freePct: number; + label: string; // e.g. "43.2 GB free of 98 GB (44%)" +} + +const LOW_PCT = 15; +const CRITICAL_PCT = 5; +const CRITICAL_BYTES = 2 * 1024 ** 3; // a 2 GB floor regardless of disk size + +function gb(bytes: number): string { + const v = bytes / 1024 ** 3; + return `${v >= 10 ? v.toFixed(0) : v.toFixed(1)} GB`; +} + +/** null when the node has never reported (older node software). */ +export function diskState(node: Pick): DiskState | null { + const free = node.disk_free_bytes; + const total = node.disk_total_bytes; + if (free == null || !total) return null; + const freePct = Math.max(0, Math.min(100, (free / total) * 100)); + const level: DiskLevel = + freePct < CRITICAL_PCT || free < CRITICAL_BYTES ? "critical" : freePct < LOW_PCT ? "low" : "ok"; + return { level, freePct, label: `${gb(free)} free of ${gb(total)} (${Math.round(freePct)}%)` }; +} + +export const DISK_TEXT_CLASS: Record = { + ok: "text-ink-2", + low: "text-sev-moderate", + critical: "text-sev-major", +}; diff --git a/drb-frontend/lib/types.ts b/drb-frontend/lib/types.ts index d3c454c..a5ba7a5 100644 --- a/drb-frontend/lib/types.ts +++ b/drb-frontend/lib/types.ts @@ -82,6 +82,9 @@ export interface NodeRecord { /** The dongle OP25 is actually using, per the node. */ op25_sdr_serial?: string | null; sdr_count?: number | null; // null = never reported + /** Host disk the node records to (node-26#41); null/absent = not reported. */ + disk_free_bytes?: number | null; + disk_total_bytes?: number | null; /** node-26#24: conventional channels from the last checkin; [] on a trunked system. */ radio_channels?: RadioChannel[] | null; enforce_override_timeout?: boolean;