Files
server-26/drb-frontend/components/SecondarySdrPriority.tsx
T
Logan CusanoandClaude Opus 5.5 3cbb0828ae 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>
2026-09-27 14:10:11 -04:00

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>
);
}