feat: Add local system override with 24h timeout support

This commit is contained in:
Logan Cusano
2026-07-12 23:05:53 -04:00
parent c6684ea61b
commit c42bd1902c
9 changed files with 274 additions and 7 deletions
+50
View File
@@ -192,6 +192,54 @@ export default function NodeDetailPage() {
<StatusBadge status={node.status} />
</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 */}
<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}`],
["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]) => (
<div key={label} className="flex justify-between px-4 py-2.5">
<span className="text-gray-500">{label}</span>
+12
View File
@@ -45,6 +45,18 @@ export function NodeCard({ node, system }: Props) {
Needs configuration
</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>
</Link>
);
+36 -3
View File
@@ -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<string | null>(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) {
/>
</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"
>
<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>}
<div className="flex gap-3 pt-1">
+6
View File
@@ -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<unknown[]>("/systems"),
+5
View File
@@ -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 {