Nodes: store and show host disk free, warn when low (node-26#41) #295
@@ -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:
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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() {
|
||||
<span className="text-ink">{value}</span>
|
||||
</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">
|
||||
<span className="text-ink-muted">Approval</span>
|
||||
<ApprovalBadge status={node.approval_status ?? null} />
|
||||
|
||||
@@ -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) {
|
||||
<span className="text-ink-2">{lastSeen}</span>
|
||||
</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 && (
|
||||
<div className="mt-3 text-xs text-indigo-400 font-mono border-t border-line pt-2">
|
||||
⚠ Needs configuration
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user