Compare commits

...

2 Commits

Author SHA1 Message Date
Logan Cusano 2e3fde2448 refactor: Clean checkin override parsing and require node type in frontend configuration modal
Build & Deploy / Build & push images (push) Successful in 4m0s
Build & Deploy / Deploy to VM (push) Failing after 2m12s
2026-07-12 23:20:39 -04:00
Logan Cusano c42bd1902c feat: Add local system override with 24h timeout support 2026-07-12 23:05:53 -04:00
9 changed files with 280 additions and 9 deletions
+37 -1
View File
@@ -1,6 +1,6 @@
import asyncio import asyncio
import json import json
from datetime import datetime, timezone from datetime import datetime, timezone, timedelta
from typing import Optional from typing import Optional
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
from app.config import settings from app.config import settings
@@ -94,6 +94,11 @@ class MQTTHandler:
"last_seen": now.isoformat(), "last_seen": now.isoformat(),
"assigned_system_id": None, "assigned_system_id": None,
"approval_status": "pending", "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) await fstore.doc_set("nodes", node_id, doc, merge=False)
logger.info(f"New node registered: {node_id} — pending admin approval.") logger.info(f"New node registered: {node_id} — pending admin approval.")
@@ -111,6 +116,37 @@ class MQTTHandler:
elif existing.get("approval_status") == "approved": elif existing.get("approval_status") == "approved":
# Approved but not yet configured — restore reachable status after reboot # Approved but not yet configured — restore reachable status after reboot
updates["status"] = "unconfigured" updates["status"] = "unconfigured"
node_type = payload.get("node_type") or existing.get("node_type") or "fixed"
enforce_timeout = payload.get("enforce_override_timeout")
if enforce_timeout is None:
enforce_timeout = existing.get("enforce_override_timeout", True)
updates["node_type"] = node_type
updates["enforce_override_timeout"] = enforce_timeout
is_overridden = payload.get("is_overridden", False)
override_system_id = payload.get("override_system_id")
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:
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) await fstore.doc_update("nodes", node_id, updates)
# NOTE: discord_connected in checkins is informational only — do NOT release the # NOTE: discord_connected in checkins is informational only — do NOT release the
+32
View File
@@ -55,3 +55,35 @@ async def _sweep():
logger.info(f"Node {node_id} marked offline (last seen: {last_seen.isoformat()})") logger.info(f"Node {node_id} marked offline (last seen: {last_seen.isoformat()})")
from app.routers.tokens import release_token from app.routers.tokens import release_token
await release_token(node_id) 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,
})
+5
View File
@@ -16,6 +16,11 @@ class NodeRecord(BaseModel):
configured: bool = False configured: bool = False
last_seen: Optional[datetime] = None last_seen: Optional[datetime] = None
assigned_system_id: Optional[str] = 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): class CommandPayload(BaseModel):
+94 -4
View File
@@ -1,6 +1,7 @@
import secrets import secrets
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Depends, Query from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
from app.models import CommandPayload from app.models import CommandPayload
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.mqtt_handler import mqtt_handler from app.internal.mqtt_handler import mqtt_handler
@@ -126,10 +127,13 @@ async def assign_system(
if not system: if not system:
raise HTTPException(404, f"System '{system_id}' not found.") raise HTTPException(404, f"System '{system_id}' not found.")
# Include hardware preset in the push so the edge node applies it when # Include hardware preset, node type, and enforce timeout in the push
# generating the OP25 config. Strip it from the system doc first so it push_payload = {
# doesn't collide with SystemConfig field validation on the node side. **system,
push_payload = {**system, "hardware_preset": hardware_preset} "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: if ppm_override is not None:
push_payload["ppm_override"] = ppm_override push_payload["ppm_override"] = ppm_override
mqtt_handler.push_config(node_id, push_payload) 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) await fstore.doc_update("nodes", node_id, node_updates)
return {"ok": True} 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}
+50
View File
@@ -192,6 +192,54 @@ export default function NodeDetailPage() {
<StatusBadge status={node.status} /> <StatusBadge status={node.status} />
</div> </div>
{/* Override Warning */}
{node.is_overridden && (
<div className="bg-yellow-950/40 border border-yellow-800/60 rounded-lg p-4 font-mono text-sm space-y-3">
<div className="flex items-center gap-2 text-yellow-400 font-semibold">
<span className="text-base"></span>
<span>Local System Override Active</span>
</div>
<p className="text-gray-400 text-xs leading-relaxed">
This node is operating on a local system override.
{node.override_timeout_at ? (
<> Resets automatically on: <span className="text-white font-bold">{new Date(node.override_timeout_at).toLocaleString()}</span>.</>
) : (
<> No timeout is currently enforced (permanent override).</>
)}
</p>
<div className="flex gap-2">
{node.override_timeout_at && (
<button
onClick={async () => {
try {
await c2api.ackOverride(id, 1440);
} catch (e) {
alert("Failed to extend timer.");
}
}}
className="px-3 py-1 bg-yellow-800 hover:bg-yellow-700 text-white rounded text-xs transition-colors"
>
Ack (Reset 24h Timer)
</button>
)}
<button
onClick={async () => {
if (confirm("Force this node to revert back to its assigned system config?")) {
try {
await c2api.resetOverride(id);
} catch (e) {
alert("Failed to reset override.");
}
}
}}
className="px-3 py-1 bg-red-900 hover:bg-red-800 text-red-200 rounded text-xs transition-colors"
>
Force Revert Config
</button>
</div>
</div>
)}
{/* Info */} {/* Info */}
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm"> <div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm">
{[ {[
@@ -199,6 +247,8 @@ export default function NodeDetailPage() {
["Location", `${node.lat}, ${node.lon}`], ["Location", `${node.lat}, ${node.lon}`],
["Last Seen", node.last_seen ? new Date(node.last_seen).toLocaleString() : "never"], ["Last Seen", node.last_seen ? new Date(node.last_seen).toLocaleString() : "never"],
["Configured", node.configured ? "Yes" : "No"], ["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]) => ( ].map(([label, value]) => (
<div key={label} className="flex justify-between px-4 py-2.5"> <div key={label} className="flex justify-between px-4 py-2.5">
<span className="text-gray-500">{label}</span> <span className="text-gray-500">{label}</span>
+12
View File
@@ -45,6 +45,18 @@ export function NodeCard({ node, system }: Props) {
Needs configuration Needs configuration
</div> </div>
)} )}
{node.is_overridden && (
<div className="mt-3 text-xs text-yellow-500 font-mono border-t border-gray-800 pt-2 flex justify-between">
<span> Local Override</span>
{node.override_timeout_at ? (
<span className="text-gray-500">
Resets: {new Date(node.override_timeout_at).toLocaleTimeString()}
</span>
) : (
<span className="text-gray-500">Permanent</span>
)}
</div>
)}
</div> </div>
</Link> </Link>
); );
+39 -4
View File
@@ -17,9 +17,11 @@ const PRESETS = [
]; ];
export function NodeConfigModal({ node, systems, onClose }: Props) { export function NodeConfigModal({ node, systems, onClose }: Props) {
const [systemId, setSystemId] = useState(""); const [systemId, setSystemId] = useState(node.assigned_system_id ?? "");
const [preset, setPreset] = useState("rtl-sdr-v3"); const [preset, setPreset] = useState(node.hardware_preset ?? "rtl-sdr-v3");
const [ppm, setPpm] = useState("0"); const [ppm, setPpm] = useState(node.ppm_override ? String(node.ppm_override) : "0");
const [nodeType, setNodeType] = useState(node.node_type ?? "");
const [enforceTimeout, setEnforceTimeout] = useState(node.enforce_override_timeout ?? true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -32,6 +34,10 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
await c2api.updateNode(node.node_id, {
node_type: nodeType,
enforce_override_timeout: enforceTimeout,
});
await c2api.assignSystem(node.node_id, systemId, preset, ppmOverride); await c2api.assignSystem(node.node_id, systemId, preset, ppmOverride);
onClose(); onClose();
} catch (err) { } catch (err) {
@@ -100,12 +106,41 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
/> />
</div> </div>
<div>
<label className="block text-xs text-gray-400 mb-1">Node Type *</label>
<select
value={nodeType}
onChange={(e) => setNodeType(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
required
>
<option value="">Select node type...</option>
<option value="fixed">Fixed Node (Standard)</option>
<option value="portable">Portable Node (Handheld)</option>
</select>
</div>
{nodeType === "fixed" && (
<div className="flex items-center gap-2 py-1">
<input
type="checkbox"
id="enforceTimeout"
checked={enforceTimeout}
onChange={(e) => setEnforceTimeout(e.target.checked)}
className="rounded bg-gray-800 border-gray-700 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
/>
<label htmlFor="enforceTimeout" className="text-xs text-gray-400 cursor-pointer select-none">
Enforce Local Override Timeout (24 hours)
</label>
</div>
)}
{error && <p className="text-red-400 text-xs">{error}</p>} {error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 pt-1"> <div className="flex gap-3 pt-1">
<button <button
type="submit" type="submit"
disabled={saving || !systemId} disabled={saving || !systemId || !nodeType}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors" className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
> >
{saving ? "Saving…" : "Assign & Configure"} {saving ? "Saving…" : "Assign & Configure"}
+6
View File
@@ -30,6 +30,12 @@ export const c2api = {
if (ppmOverride !== undefined) params.set("ppm_override", String(ppmOverride)); if (ppmOverride !== undefined) params.set("ppm_override", String(ppmOverride));
return request(`/nodes/${nodeId}/config/${systemId}?${params}`, { method: "POST" }); 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 // Systems
getSystems: () => request<unknown[]>("/systems"), getSystems: () => request<unknown[]>("/systems"),
+5
View File
@@ -52,6 +52,11 @@ export interface NodeRecord {
approval_status: ApprovalStatus | null; approval_status: ApprovalStatus | null;
hardware_preset?: string; hardware_preset?: string;
ppm_override?: number | null; 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 { export interface VocabularyPendingTerm {