From c42bd1902c586c6bd574e1b081e2c6f21bedd350 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 12 Jul 2026 23:05:53 -0400 Subject: [PATCH] feat: Add local system override with 24h timeout support --- drb-c2-core/app/internal/mqtt_handler.py | 34 +++++++ drb-c2-core/app/internal/node_sweeper.py | 32 +++++++ drb-c2-core/app/models.py | 5 ++ drb-c2-core/app/routers/nodes.py | 98 ++++++++++++++++++++- drb-frontend/app/nodes/[id]/page.tsx | 50 +++++++++++ drb-frontend/components/NodeCard.tsx | 12 +++ drb-frontend/components/NodeConfigModal.tsx | 39 +++++++- drb-frontend/lib/c2api.ts | 6 ++ drb-frontend/lib/types.ts | 5 ++ 9 files changed, 274 insertions(+), 7 deletions(-) diff --git a/drb-c2-core/app/internal/mqtt_handler.py b/drb-c2-core/app/internal/mqtt_handler.py index 5839385..881dfe9 100644 --- a/drb-c2-core/app/internal/mqtt_handler.py +++ b/drb-c2-core/app/internal/mqtt_handler.py @@ -94,6 +94,11 @@ class MQTTHandler: "last_seen": now.isoformat(), "assigned_system_id": None, "approval_status": "pending", + "node_type": payload.get("node_type", "fixed"), + "enforce_override_timeout": payload.get("enforce_override_timeout", True), + "is_overridden": False, + "override_system_id": None, + "override_timeout_at": None, } await fstore.doc_set("nodes", node_id, doc, merge=False) logger.info(f"New node registered: {node_id} — pending admin approval.") @@ -111,6 +116,35 @@ class MQTTHandler: elif existing.get("approval_status") == "approved": # Approved but not yet configured — restore reachable status after reboot updates["status"] = "unconfigured" + + node_type = payload.get("node_type", existing.get("node_type", "fixed")) + enforce_timeout = payload.get("enforce_override_timeout", existing.get("enforce_override_timeout", True)) + is_overridden = payload.get("is_overridden", False) + override_system_id = payload.get("override_system_id") + + updates["node_type"] = node_type + updates["enforce_override_timeout"] = enforce_timeout + + if node_type == "portable": + updates["is_overridden"] = False + updates["override_system_id"] = None + updates["override_timeout_at"] = None + else: + updates["is_overridden"] = is_overridden + updates["override_system_id"] = override_system_id + + if is_overridden: + existing_timeout = existing.get("override_timeout_at") + existing_override_id = existing.get("override_system_id") + if enforce_timeout: + if not existing_timeout or existing_override_id != override_system_id: + from datetime import timedelta + updates["override_timeout_at"] = (now + timedelta(hours=24)).isoformat() + else: + updates["override_timeout_at"] = None + else: + updates["override_timeout_at"] = None + await fstore.doc_update("nodes", node_id, updates) # NOTE: discord_connected in checkins is informational only — do NOT release the diff --git a/drb-c2-core/app/internal/node_sweeper.py b/drb-c2-core/app/internal/node_sweeper.py index 145c54b..330ece7 100644 --- a/drb-c2-core/app/internal/node_sweeper.py +++ b/drb-c2-core/app/internal/node_sweeper.py @@ -55,3 +55,35 @@ async def _sweep(): logger.info(f"Node {node_id} marked offline (last seen: {last_seen.isoformat()})") from app.routers.tokens import release_token await release_token(node_id) + continue + + # Check for expired system overrides (only for fixed nodes with timeout enforced) + override_timeout_raw = node.get("override_timeout_at") + enforce_timeout = node.get("enforce_override_timeout", True) + node_type = node.get("node_type", "fixed") + + if override_timeout_raw and enforce_timeout and node_type != "portable": + if isinstance(override_timeout_raw, str): + override_timeout = datetime.fromisoformat(override_timeout_raw) + else: + override_timeout = override_timeout_raw + if override_timeout.tzinfo is None: + override_timeout = override_timeout.replace(tzinfo=timezone.utc) + + if datetime.now(timezone.utc) > override_timeout: + node_id = node.get("node_id") + assigned_system_id = node.get("assigned_system_id") + logger.info(f"Node {node_id} override has expired. Reverting to system {assigned_system_id}.") + + # Push the original assigned config if it exists + if assigned_system_id: + system_doc = await fstore.doc_get("systems", assigned_system_id) + if system_doc: + from app.internal.mqtt_handler import mqtt_handler + mqtt_handler.push_config(node_id, system_doc) + + await fstore.doc_update("nodes", node_id, { + "is_overridden": False, + "override_system_id": None, + "override_timeout_at": None, + }) diff --git a/drb-c2-core/app/models.py b/drb-c2-core/app/models.py index acbbf89..e237008 100644 --- a/drb-c2-core/app/models.py +++ b/drb-c2-core/app/models.py @@ -16,6 +16,11 @@ class NodeRecord(BaseModel): configured: bool = False last_seen: Optional[datetime] = None assigned_system_id: Optional[str] = None + node_type: str = "fixed" # fixed or portable + enforce_override_timeout: bool = True + is_overridden: bool = False + override_system_id: Optional[str] = None + override_timeout_at: Optional[datetime] = None class CommandPayload(BaseModel): diff --git a/drb-c2-core/app/routers/nodes.py b/drb-c2-core/app/routers/nodes.py index a4936a7..99422d3 100644 --- a/drb-c2-core/app/routers/nodes.py +++ b/drb-c2-core/app/routers/nodes.py @@ -1,6 +1,7 @@ import secrets from typing import Optional from fastapi import APIRouter, HTTPException, Depends, Query +from pydantic import BaseModel from app.models import CommandPayload from app.internal import firestore as fstore from app.internal.mqtt_handler import mqtt_handler @@ -126,10 +127,13 @@ async def assign_system( if not system: raise HTTPException(404, f"System '{system_id}' not found.") - # Include hardware preset in the push so the edge node applies it when - # generating the OP25 config. Strip it from the system doc first so it - # doesn't collide with SystemConfig field validation on the node side. - push_payload = {**system, "hardware_preset": hardware_preset} + # Include hardware preset, node type, and enforce timeout in the push + push_payload = { + **system, + "hardware_preset": hardware_preset, + "node_type": node.get("node_type", "fixed"), + "enforce_override_timeout": node.get("enforce_override_timeout", True), + } if ppm_override is not None: push_payload["ppm_override"] = ppm_override mqtt_handler.push_config(node_id, push_payload) @@ -145,3 +149,89 @@ async def assign_system( await fstore.doc_update("nodes", node_id, node_updates) return {"ok": True} + + +class NodeUpdateBody(BaseModel): + node_type: Optional[str] = None + enforce_override_timeout: Optional[bool] = None + + +@router.patch("/{node_id}") +async def update_node( + node_id: str, + body: NodeUpdateBody, + _: dict = Depends(require_admin_token), +): + node = await fstore.doc_get("nodes", node_id) + if not node: + raise HTTPException(404, f"Node '{node_id}' not found.") + + updates = body.model_dump(exclude_unset=True) + if not updates: + return {"ok": True} + + await fstore.doc_update("nodes", node_id, updates) + + # Re-push config to apply new node settings locally + updated_node = await fstore.doc_get("nodes", node_id) + assigned_system_id = updated_node.get("assigned_system_id") + if assigned_system_id: + system = await fstore.doc_get("systems", assigned_system_id) + if system: + push_payload = { + **system, + "hardware_preset": updated_node.get("hardware_preset", "rtl-sdr-v3"), + "node_type": updated_node.get("node_type", "fixed"), + "enforce_override_timeout": updated_node.get("enforce_override_timeout", True), + } + if updated_node.get("ppm_override") is not None: + push_payload["ppm_override"] = updated_node["ppm_override"] + mqtt_handler.push_config(node_id, push_payload) + + return {"ok": True} + + +class AckOverrideBody(BaseModel): + timeout_minutes: int = 1440 + + +@router.post("/{node_id}/override/ack") +async def ack_override( + node_id: str, + body: AckOverrideBody, + _: dict = Depends(require_service_key_or_admin), +): + node = await fstore.doc_get("nodes", node_id) + if not node: + raise HTTPException(404, f"Node '{node_id}' not found.") + + from datetime import datetime, timezone, timedelta + new_timeout = datetime.now(timezone.utc) + timedelta(minutes=body.timeout_minutes) + + await fstore.doc_update("nodes", node_id, { + "override_timeout_at": new_timeout.isoformat() + }) + return {"ok": True, "override_timeout_at": new_timeout.isoformat()} + + +@router.post("/{node_id}/override/reset") +async def reset_override( + node_id: str, + _: dict = Depends(require_service_key_or_admin), +): + node = await fstore.doc_get("nodes", node_id) + if not node: + raise HTTPException(404, f"Node '{node_id}' not found.") + + assigned_system_id = node.get("assigned_system_id") + if assigned_system_id: + system = await fstore.doc_get("systems", assigned_system_id) + if system: + mqtt_handler.push_config(node_id, system) + + await fstore.doc_update("nodes", node_id, { + "is_overridden": False, + "override_system_id": None, + "override_timeout_at": None, + }) + return {"ok": True} diff --git a/drb-frontend/app/nodes/[id]/page.tsx b/drb-frontend/app/nodes/[id]/page.tsx index 320e098..0e1a740 100644 --- a/drb-frontend/app/nodes/[id]/page.tsx +++ b/drb-frontend/app/nodes/[id]/page.tsx @@ -192,6 +192,54 @@ export default function NodeDetailPage() { + {/* Override Warning */} + {node.is_overridden && ( +
+
+ + Local System Override Active +
+

+ This node is operating on a local system override. + {node.override_timeout_at ? ( + <> Resets automatically on: {new Date(node.override_timeout_at).toLocaleString()}. + ) : ( + <> No timeout is currently enforced (permanent override). + )} +

+
+ {node.override_timeout_at && ( + + )} + +
+
+ )} + {/* Info */}
{[ @@ -199,6 +247,8 @@ export default function NodeDetailPage() { ["Location", `${node.lat}, ${node.lon}`], ["Last Seen", node.last_seen ? new Date(node.last_seen).toLocaleString() : "never"], ["Configured", node.configured ? "Yes" : "No"], + ["Node Type", node.node_type ?? "fixed"], + ...(node.node_type !== "portable" ? [["Enforce Timeout", node.enforce_override_timeout ? "Yes" : "No"]] : []), ].map(([label, value]) => (
{label} diff --git a/drb-frontend/components/NodeCard.tsx b/drb-frontend/components/NodeCard.tsx index d6f4eb4..3d2335f 100644 --- a/drb-frontend/components/NodeCard.tsx +++ b/drb-frontend/components/NodeCard.tsx @@ -45,6 +45,18 @@ export function NodeCard({ node, system }: Props) { ⚠ Needs configuration
)} + {node.is_overridden && ( +
+ ⚠ Local Override + {node.override_timeout_at ? ( + + Resets: {new Date(node.override_timeout_at).toLocaleTimeString()} + + ) : ( + Permanent + )} +
+ )}
); diff --git a/drb-frontend/components/NodeConfigModal.tsx b/drb-frontend/components/NodeConfigModal.tsx index 7a5cba6..f5789f4 100644 --- a/drb-frontend/components/NodeConfigModal.tsx +++ b/drb-frontend/components/NodeConfigModal.tsx @@ -17,9 +17,11 @@ const PRESETS = [ ]; export function NodeConfigModal({ node, systems, onClose }: Props) { - const [systemId, setSystemId] = useState(""); - const [preset, setPreset] = useState("rtl-sdr-v3"); - const [ppm, setPpm] = useState("0"); + const [systemId, setSystemId] = useState(node.assigned_system_id ?? ""); + const [preset, setPreset] = useState(node.hardware_preset ?? "rtl-sdr-v3"); + const [ppm, setPpm] = useState(node.ppm_override ? String(node.ppm_override) : "0"); + const [nodeType, setNodeType] = useState(node.node_type ?? "fixed"); + const [enforceTimeout, setEnforceTimeout] = useState(node.enforce_override_timeout ?? true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -32,6 +34,10 @@ export function NodeConfigModal({ node, systems, onClose }: Props) { setSaving(true); setError(null); try { + await c2api.updateNode(node.node_id, { + node_type: nodeType, + enforce_override_timeout: enforceTimeout, + }); await c2api.assignSystem(node.node_id, systemId, preset, ppmOverride); onClose(); } catch (err) { @@ -100,6 +106,33 @@ export function NodeConfigModal({ node, systems, onClose }: Props) { /> +
+ + +
+ + {nodeType === "fixed" && ( +
+ setEnforceTimeout(e.target.checked)} + className="rounded bg-gray-800 border-gray-700 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900" + /> + +
+ )} + {error &&

{error}

}
diff --git a/drb-frontend/lib/c2api.ts b/drb-frontend/lib/c2api.ts index 0aea23d..240ea20 100644 --- a/drb-frontend/lib/c2api.ts +++ b/drb-frontend/lib/c2api.ts @@ -30,6 +30,12 @@ export const c2api = { if (ppmOverride !== undefined) params.set("ppm_override", String(ppmOverride)); return request(`/nodes/${nodeId}/config/${systemId}?${params}`, { method: "POST" }); }, + ackOverride: (nodeId: string, timeoutMinutes: number = 1440) => + request(`/nodes/${nodeId}/override/ack`, { method: "POST", body: JSON.stringify({ timeout_minutes: timeoutMinutes }) }), + resetOverride: (nodeId: string) => + request(`/nodes/${nodeId}/override/reset`, { method: "POST" }), + updateNode: (id: string, body: { node_type?: string; enforce_override_timeout?: boolean }) => + request(`/nodes/${id}`, { method: "PATCH", body: JSON.stringify(body) }), // Systems getSystems: () => request("/systems"), diff --git a/drb-frontend/lib/types.ts b/drb-frontend/lib/types.ts index bc17a9c..517ba5a 100644 --- a/drb-frontend/lib/types.ts +++ b/drb-frontend/lib/types.ts @@ -52,6 +52,11 @@ export interface NodeRecord { approval_status: ApprovalStatus | null; hardware_preset?: string; ppm_override?: number | null; + node_type?: string; + enforce_override_timeout?: boolean; + is_overridden?: boolean; + override_system_id?: string | null; + override_timeout_at?: string | null; } export interface VocabularyPendingTerm {