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 ( +