Secondary SDR priority: C2 field, node command, dashboard section
Pairs with node-26 feat/secondary-sdr-priority. NodeRecord gains
secondary_sdr_priority (ordered; SDRs beyond OP25's run it top-down) and
secondary_sdr_running, both mirrored from the node's checkin.
PATCH /nodes/{id} accepts the priority, validates it, and sends it as a
'set_secondary_priority' MQTT command. A priority-only change never
re-pushes system config, because that restarts OP25. The node detail page
gets a 'Secondary SDRs' section (admin-editable) with enable, reorder,
save, and live Running / Waiting-for-SDR state from the checkin.
Verified: c2-core pytest 482 passed; frontend tsc --noEmit clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fa207e494d
commit
3cbb0828ae
@@ -8,6 +8,7 @@ import { useSystems } from "@/lib/useSystems";
|
||||
import { useCalls } from "@/lib/useCalls";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
||||
import { SecondarySdrPriority } from "@/components/SecondarySdrPriority";
|
||||
import { CallRow } from "@/components/CallRow";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
@@ -336,6 +337,8 @@ export default function NodeDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SecondarySdrPriority node={node} canEdit={isAdmin} />
|
||||
|
||||
{/* Recent calls */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { NodeRecord } from "@/lib/types";
|
||||
|
||||
// node-26#9. OP25 always keeps its own SDR; every other SDR on the node runs
|
||||
// the next enabled item here, top first — so a 3-SDR node runs both.
|
||||
const MODES: { mode: string; name: string; hint: string }[] = [
|
||||
{ mode: "adsb", name: "ADS-B", hint: "Aircraft · 1090 MHz" },
|
||||
{ mode: "ais", name: "AIS", hint: "Vessels · 162 MHz" },
|
||||
];
|
||||
|
||||
type Row = { mode: string; enabled: boolean };
|
||||
|
||||
function rowsFrom(priority: string[]): Row[] {
|
||||
return [
|
||||
...priority.filter((m) => MODES.some((x) => x.mode === m)).map((mode) => ({ mode, enabled: true })),
|
||||
...MODES.filter((x) => !priority.includes(x.mode)).map((x) => ({ mode: x.mode, enabled: false })),
|
||||
];
|
||||
}
|
||||
|
||||
export function SecondarySdrPriority({ node, canEdit }: { node: NodeRecord; canEdit: boolean }) {
|
||||
const priority = node.secondary_sdr_priority ?? [];
|
||||
const running = node.secondary_sdr_running ?? [];
|
||||
const [rows, setRows] = useState<Row[]>(() => rowsFrom(priority));
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
// Follow the node's live checkin unless there are unsaved edits.
|
||||
const priorityKey = priority.join(",");
|
||||
useEffect(() => {
|
||||
if (!dirty) setRows(rowsFrom(priorityKey ? priorityKey.split(",") : []));
|
||||
}, [priorityKey, dirty]);
|
||||
|
||||
function edit(next: Row[]) {
|
||||
setRows(next);
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function move(i: number, delta: number) {
|
||||
const next = [...rows];
|
||||
[next[i], next[i + delta]] = [next[i + delta], next[i]];
|
||||
edit(next);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await c2api.updateNode(node.node_id, {
|
||||
secondary_sdr_priority: rows.filter((r) => r.enabled).map((r) => r.mode),
|
||||
});
|
||||
setDirty(false);
|
||||
setMessage("Sent to the node. Status updates when it checks in.");
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "Save failed.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const sdrCount = node.sdr_count ?? 1;
|
||||
const spare = Math.max(sdrCount - 1, 0);
|
||||
let rank = 0;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-1">Secondary SDRs</h2>
|
||||
<p className="text-xs text-gray-500 font-mono mb-3">
|
||||
OP25 always keeps its own SDR. Every other SDR runs the next enabled item, top first.
|
||||
{" "}This node reports {sdrCount} SDR{sdrCount === 1 ? "" : "s"} ({spare} spare).
|
||||
</p>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm">
|
||||
{rows.map((row, i) => {
|
||||
const meta = MODES.find((x) => x.mode === row.mode)!;
|
||||
const isRunning = running.includes(row.mode);
|
||||
const state = !row.enabled ? "Off" : dirty ? "Unsaved" : isRunning ? "Running" : "Waiting for SDR";
|
||||
return (
|
||||
<div key={row.mode} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<span className="w-4 text-right text-gray-600 text-xs">{row.enabled ? ++rank : ""}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.enabled}
|
||||
disabled={!canEdit}
|
||||
aria-label={`Enable ${meta.name}`}
|
||||
onChange={(e) => edit(rows.map((r, j) => (j === i ? { ...r, enabled: e.target.checked } : r)))}
|
||||
className="rounded bg-gray-800 border-gray-700 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-gray-200">{meta.name}</div>
|
||||
<div className="text-xs text-gray-500">{meta.hint}</div>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(i, -1)}
|
||||
disabled={i === 0}
|
||||
aria-label={`Move ${meta.name} up`}
|
||||
className="w-7 h-7 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 disabled:opacity-30"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(i, 1)}
|
||||
disabled={i === rows.length - 1}
|
||||
aria-label={`Move ${meta.name} down`}
|
||||
className="w-7 h-7 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 disabled:opacity-30"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<span className={`w-28 text-right text-xs ${state === "Running" ? "text-green-400" : "text-gray-500"}`}>
|
||||
{state}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={!dirty || saving}
|
||||
className="px-4 py-2 bg-indigo-700 hover:bg-indigo-600 disabled:opacity-50 text-white rounded-lg text-sm font-mono transition-colors"
|
||||
>
|
||||
{saving ? "Saving…" : "Save priority"}
|
||||
</button>
|
||||
{message && <span className="text-xs text-gray-500 font-mono">{message}</span>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,10 @@ export const c2api = {
|
||||
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 }) =>
|
||||
updateNode: (
|
||||
id: string,
|
||||
body: { node_type?: string; enforce_override_timeout?: boolean; secondary_sdr_priority?: string[] },
|
||||
) =>
|
||||
request(`/nodes/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
|
||||
// Systems
|
||||
|
||||
@@ -53,7 +53,11 @@ export interface NodeRecord {
|
||||
hardware_preset?: string;
|
||||
ppm_override?: number | null;
|
||||
node_type?: string;
|
||||
secondary_sdr_mode?: string;
|
||||
secondary_sdr_mode?: string; // legacy; priority[0] on current nodes
|
||||
/** Ordered decoders for the SDRs beyond OP25's, run top-down (node-26#9). */
|
||||
secondary_sdr_priority?: string[];
|
||||
/** What the node's last checkin reported actually running. */
|
||||
secondary_sdr_running?: string[] | null;
|
||||
sdr_count?: number;
|
||||
enforce_override_timeout?: boolean;
|
||||
is_overridden?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user