Nodes: store and show host disk free, warn when low (node-26#41) #295

Merged
logan merged 1 commits from feat/41-node-disk into main 2026-10-26 02:28:11 -04:00
6 changed files with 86 additions and 1 deletions
+6 -1
View File
@@ -128,6 +128,8 @@ class MQTTHandler:
"sdr_devices": payload.get("sdr_devices"), "sdr_devices": payload.get("sdr_devices"),
"op25_sdr_serial": payload.get("op25_sdr_serial"), "op25_sdr_serial": payload.get("op25_sdr_serial"),
"sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1 "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), "enforce_override_timeout": payload.get("enforce_override_timeout", True),
"is_overridden": False, "is_overridden": False,
"override_system_id": None, "override_system_id": None,
@@ -161,8 +163,11 @@ class MQTTHandler:
# radio_channels (node-26#24): a conventional node's channels and # radio_channels (node-26#24): a conventional node's channels and
# whether each is encrypted — the one decode failure the owner has # whether each is encrypted — the one decode failure the owner has
# to hear about from C2, since nothing on the node can fix it. # 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", 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: if key in payload:
updates[key] = payload[key] updates[key] = payload[key]
if "sdr_count" in payload: if "sdr_count" in payload:
+16
View File
@@ -118,6 +118,22 @@ async def test_checkin_updates_existing_configured_node(handler):
assert "last_seen" in updates 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 @pytest.mark.asyncio
async def test_checkin_does_not_promote_unconfigured_to_online(handler): async def test_checkin_does_not_promote_unconfigured_to_online(handler):
existing = { existing = {
+12
View File
@@ -16,6 +16,7 @@ import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { errorMessage } from "@/lib/c2error"; import { errorMessage } from "@/lib/c2error";
import { diskState, DISK_TEXT_CLASS } from "@/lib/nodeDisk";
import type { NodeRecord } from "@/lib/types"; import type { NodeRecord } from "@/lib/types";
function ApprovalBadge({ status }: { status: string | null }) { function ApprovalBadge({ status }: { status: string | null }) {
@@ -218,6 +219,17 @@ export default function NodeDetailPage() {
<span className="text-ink">{value}</span> <span className="text-ink">{value}</span>
</div> </div>
))} ))}
{(() => {
const disk = diskState(node);
return (
<div className="flex justify-between px-4 py-2.5">
<span className="text-ink-muted">Disk</span>
<span className={disk ? DISK_TEXT_CLASS[disk.level] : "text-ink-muted"}>
{disk ? `${disk.level !== "ok" ? "⚠ " : ""}${disk.label}` : "not reported (node software too old)"}
</span>
</div>
);
})()}
<div className="flex justify-between px-4 py-2.5"> <div className="flex justify-between px-4 py-2.5">
<span className="text-ink-muted">Approval</span> <span className="text-ink-muted">Approval</span>
<ApprovalBadge status={node.approval_status ?? null} /> <ApprovalBadge status={node.approval_status ?? null} />
+10
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import type { NodeRecord, SystemRecord } from "@/lib/types"; import type { NodeRecord, SystemRecord } from "@/lib/types";
import { diskState } from "@/lib/nodeDisk";
interface Props { interface Props {
node: NodeRecord; node: NodeRecord;
@@ -45,6 +46,15 @@ export function NodeCard({ node, system, linkToDetail = true }: Props) {
<span className="text-ink-2">{lastSeen}</span> <span className="text-ink-2">{lastSeen}</span>
</div> </div>
</div> </div>
{(() => {
const disk = diskState(node);
if (!disk || disk.level === "ok") return null;
return (
<div className={`mt-3 text-xs font-mono border-t border-line pt-2 ${disk.level === "critical" ? "text-sev-major" : "text-sev-moderate"}`}>
⚠ Disk {disk.level === "critical" ? "nearly full" : "low"}: {disk.label}
</div>
);
})()}
{!node.configured && ( {!node.configured && (
<div className="mt-3 text-xs text-indigo-400 font-mono border-t border-line pt-2"> <div className="mt-3 text-xs text-indigo-400 font-mono border-t border-line pt-2">
⚠ Needs configuration ⚠ Needs configuration
+39
View File
@@ -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<NodeRecord, "disk_free_bytes" | "disk_total_bytes">): 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<DiskLevel, string> = {
ok: "text-ink-2",
low: "text-sev-moderate",
critical: "text-sev-major",
};
+3
View File
@@ -82,6 +82,9 @@ export interface NodeRecord {
/** The dongle OP25 is actually using, per the node. */ /** The dongle OP25 is actually using, per the node. */
op25_sdr_serial?: string | null; op25_sdr_serial?: string | null;
sdr_count?: number | null; // null = never reported 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. */ /** node-26#24: conventional channels from the last checkin; [] on a trunked system. */
radio_channels?: RadioChannel[] | null; radio_channels?: RadioChannel[] | null;
enforce_override_timeout?: boolean; enforce_override_timeout?: boolean;