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>
140 lines
5.4 KiB
TypeScript
140 lines
5.4 KiB
TypeScript
"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>
|
|
);
|
|
}
|