Files
node-26/drb-edge-node/app/templates/index.html
T
Logan CusanoandClaude Opus 5.5 10f7db0a2c Local dashboard: plain-language status checklist and C2-free listening
- /api/health: ordered checklist (password, C2 approval, MQTT, SDR, source,
  OP25, decoding, audio, Icecast) with what's wrong and what to do; pure
  build_checks() so every state (incl. Toughny's unapproved node) is tested
- /api/listen: pick P25 control channels / one analog freq / a cached C2
  system, validated in MHz, stored as a local override; OP25 restarted with
  the start failure reason surfaced
- /api/config/system no longer writes a raw blob OP25 can't read
- OP25 resumes after reboot on a locally chosen system, not only C2-assigned
- op25-container: analog (NBFM) config generation fixed (AttributeError +
  wrong field names), /op25/log tail, start refuses without a config,
  status detects a dead multi_rx ("crashed") -- node-26#12
- index.html rebuilt on UI_REDESIGN tokens; scanner drops DMR, shows System key

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:20:11 -04:00

571 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DRB Edge Node</title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
/* Tokens from UI_REDESIGN.md §2.2/§2.3. Status is triple-encoded
(glyph + word + colour) and green is not used. */
:root {
--page: #F4F6F9; --surface: #FFFFFF; --raised: #F7F9FB;
--line: rgba(11,17,27,.11); --line-strong: rgba(11,17,27,.22);
--ink: #101620; --ink-2: #46536A; --ink-muted: #6B788C;
--accent: #2A78D6; --moderate: #B07800; --major: #C0281F;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--page: #0B0E13; --surface: #141922; --raised: #1B2230;
--line: rgba(255,255,255,.09); --line-strong: rgba(255,255,255,.17);
--ink: #E8EBF0; --ink-2: #A5B0C2; --ink-muted: #77839A;
--accent: #3987E5; --moderate: #C98500; --major: #D03B3B;
}
}
:root[data-theme="dark"] {
--page: #0B0E13; --surface: #141922; --raised: #1B2230;
--line: rgba(255,255,255,.09); --line-strong: rgba(255,255,255,.17);
--ink: #E8EBF0; --ink-2: #A5B0C2; --ink-muted: #77839A;
--accent: #3987E5; --moderate: #C98500; --major: #D03B3B;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
[hidden] { display: none !important; }
a { color: var(--accent); }
a.btn { color: var(--ink); }
body {
font-family: 'IBM Plex Sans', system-ui, sans-serif;
background: var(--page); color: var(--ink);
line-height: 1.5; padding: 24px 16px 48px;
}
.mono { font-family: 'IBM Plex Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; }
.wrap { max-width: 980px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
h1 { font-size: 1.5rem; font-weight: 600; }
h2 { font-size: 1rem; font-weight: 600; margin-bottom: 4px; }
.sub { color: var(--ink-muted); font-size: .875rem; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 16px; }
.card > .sub { margin-bottom: 12px; }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.btn {
font: inherit; font-size: .875rem; font-weight: 500; cursor: pointer;
padding: 8px 14px; border-radius: 6px; border: 1px solid var(--line-strong);
background: var(--raised); color: var(--ink); text-decoration: none; display: inline-flex; align-items: center;
}
.btn:hover { border-color: var(--ink-muted); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn:disabled { opacity: .45; cursor: default; }
.msg { font-size: .875rem; color: var(--ink-2); }
.msg.err { color: var(--major); }
/* Checklist */
.summary { font-weight: 600; margin-bottom: 8px; }
.check { display: grid; grid-template-columns: 22px 1fr; gap: 2px 8px; padding: 10px 0; border-top: 1px solid var(--line); }
.check:first-of-type { border-top: none; }
.glyph { font-size: 1rem; line-height: 1.4; text-align: center; }
.check .label { font-weight: 500; }
.check .label .state { font-size: .75rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; margin-left: 6px; }
.check .text { grid-column: 2; color: var(--ink-2); font-size: .9rem; }
.check .action { grid-column: 2; color: var(--ink); font-size: .875rem; }
.check .action:empty { display: none; }
.s-ok .glyph, .s-ok .state { color: var(--ink-2); }
.s-warn .glyph, .s-warn .state { color: var(--moderate); }
.s-error .glyph, .s-error .state { color: var(--major); }
.s-off .glyph, .s-off .state { color: var(--ink-muted); }
.s-off .label { color: var(--ink-2); }
/* Now playing */
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 12px 0; font-size: .9rem; }
.kv dt { color: var(--ink-muted); }
.live { color: var(--accent); font-weight: 600; }
audio { width: 100%; margin-top: 4px; }
pre.log {
font-family: 'IBM Plex Mono', ui-monospace, monospace; font-size: .75rem; line-height: 1.45;
background: var(--raised); border: 1px solid var(--line); border-radius: 6px;
padding: 10px; max-height: 280px; overflow: auto; white-space: pre-wrap; word-break: break-word; margin-top: 10px;
}
/* Listen form */
.tabs { display: flex; border: 1px solid var(--line-strong); border-radius: 6px; overflow: hidden; width: fit-content; max-width: 100%; flex-wrap: wrap; }
.tabs label { padding: 7px 12px; font-size: .875rem; cursor: pointer; border-left: 1px solid var(--line); }
.tabs label:first-child { border-left: none; }
.tabs input { position: absolute; opacity: 0; pointer-events: none; }
.tabs label:has(input:checked) { background: var(--accent); color: #fff; }
.tabs label:has(input:disabled) { color: var(--ink-muted); cursor: default; }
.tabs label:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: -2px; }
.field { display: flex; flex-direction: column; gap: 4px; margin-top: 12px; max-width: 520px; }
.field label { font-size: .875rem; font-weight: 500; }
.field .hint { font-size: .8rem; color: var(--ink-muted); }
input[type=text], select, textarea {
font: inherit; font-size: .9rem; color: var(--ink); background: var(--raised);
border: 1px solid var(--line-strong); border-radius: 6px; padding: 7px 9px; width: 100%;
}
textarea { min-height: 84px; font-family: 'IBM Plex Mono', ui-monospace, monospace; font-size: .8rem; }
.current { padding: 10px 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--raised); margin-bottom: 12px; font-size: .9rem; }
/* SDRs */
.sdr-row { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-top: 1px solid var(--line); flex-wrap: wrap; }
.sdr-rank { width: 1.2rem; color: var(--ink-muted); font-size: .8rem; text-align: right; }
.sdr-name { flex: 1; min-width: 120px; }
.sdr-name small { display: block; color: var(--ink-muted); font-size: .75rem; }
.sdr-move { font: inherit; background: var(--raised); border: 1px solid var(--line-strong); color: var(--ink); border-radius: 6px; width: 30px; height: 30px; cursor: pointer; }
.sdr-move:disabled { opacity: .3; cursor: default; }
.sdr-state { font-size: .75rem; min-width: 6.5rem; text-align: right; color: var(--ink-muted); }
.sdr-state.on { color: var(--accent); }
.sdr-select { width: auto; max-width: 14rem; font-size: .8rem; padding: 4px 6px; }
footer { text-align: center; font-size: .8rem; color: var(--ink-muted); }
</style>
</head>
<body>
<div class="wrap">
<header>
<div>
<h1 id="node-name">DRB Edge Node</h1>
<div class="sub">Node ID <span class="mono" id="node-id">…</span></div>
</div>
<div class="row">
<a href="/scanner" target="_blank" class="btn">Scanner view</a>
<a href="/logout" class="btn">Log out</a>
</div>
</header>
<!-- 1. Status checklist (GET /api/health) -->
<section class="card" aria-labelledby="h-status">
<h2 id="h-status">Node status</h2>
<div class="summary" id="health-summary">Checking…</div>
<div id="checks"></div>
</section>
<!-- 2. Now playing + OP25 controls -->
<section class="card" aria-labelledby="h-now">
<h2 id="h-now">Listen</h2>
<p class="sub">The node's own live stream. It is a few seconds behind the radio, and silence between transmissions is normal.</p>
<audio id="player" controls preload="none">Your browser can't play the stream — use the direct link.</audio>
<dl class="kv">
<dt>Listening to</dt><dd id="now-system">—</dd>
<dt>Talkgroup</dt><dd id="now-tg">—</dd>
<dt>Last heard</dt><dd id="now-heard">—</dd>
<dt>Stream link</dt><dd><a id="stream-link" class="mono" href="#" target="_blank" rel="noopener">—</a></dd>
</dl>
<div class="row">
<button class="btn" id="op25-start" onclick="op25('start')">Start OP25</button>
<button class="btn" id="op25-stop" onclick="op25('stop')">Stop OP25</button>
<button class="btn" id="log-toggle" onclick="toggleLog()" aria-expanded="false">Show OP25 log</button>
<span class="msg" id="op25-msg" role="status"></span>
</div>
<pre class="log" id="op25-log" hidden></pre>
</section>
<!-- 3. Choose what to listen to (POST /api/listen) -->
<section class="card" aria-labelledby="h-choose">
<h2 id="h-choose">Listen locally</h2>
<p class="sub">Choose what this node's radio tunes to. Works with or without the DRB server. If the server assigns a system later, it takes over.</p>
<div class="current" id="current-source">Nothing chosen yet.</div>
<div class="row" id="source-actions" style="margin:-4px 0 12px" hidden>
<button class="btn" id="revert-btn" onclick="revert()"></button>
<button class="btn" id="ack-btn" onclick="ackOverride()" hidden>Keep for 24h on the server</button>
</div>
<div class="tabs" role="radiogroup" aria-label="Source type">
<label><input type="radio" name="mode" value="p25" checked> P25 trunked</label>
<label><input type="radio" name="mode" value="analog"> Analog FM</label>
<label><input type="radio" name="mode" value="system" id="mode-system"> Server's systems</label>
</div>
<div class="pane" data-pane="p25">
<div class="field">
<label for="p25-freqs">Control channel frequencies (MHz)</label>
<input type="text" id="p25-freqs" class="mono" placeholder="851.0125, 851.5125" inputmode="decimal">
<span class="hint">The system's control channels, comma-separated. Find them on RadioReference under your county's trunked system.</span>
</div>
<div class="field">
<label for="p25-name">Name <span class="hint">(optional)</span></label>
<input type="text" id="p25-name" placeholder="County P25">
</div>
<div class="field">
<label for="p25-tgs">Talkgroups to keep <span class="hint">(optional)</span></label>
<textarea id="p25-tgs" placeholder="1001 Fire Dispatch&#10;1002 Fireground"></textarea>
<span class="hint">One per line: decimal ID, then a name. Leave empty to hear every talkgroup.</span>
</div>
</div>
<div class="pane" data-pane="analog" hidden>
<div class="field">
<label for="an-freq">Frequency (MHz)</label>
<input type="text" id="an-freq" class="mono" placeholder="154.4300" inputmode="decimal">
<span class="hint">One conventional narrowband FM channel.</span>
</div>
<div class="field">
<label for="an-name">Name <span class="hint">(optional)</span></label>
<input type="text" id="an-name" placeholder="Fire dispatch">
</div>
</div>
<div class="pane" data-pane="system" hidden>
<div class="field">
<label for="sys-pick">System</label>
<select id="sys-pick"></select>
<span class="hint" id="sys-hint"></span>
</div>
</div>
<p class="sub" style="margin-top:12px">DMR isn't supported by this node's decoder yet.</p>
<div class="row" style="margin-top:8px">
<button class="btn primary" id="listen-btn" onclick="listen()">Start listening</button>
<span class="msg" id="listen-msg" role="status"></span>
</div>
</section>
<!-- 4. SDRs (node-26#9, #11) -->
<section class="card" aria-labelledby="h-sdr">
<h2 id="h-sdr">SDRs</h2>
<p class="sub">Only matters with more than one SDR plugged in. OP25 always gets one; every other SDR runs the next enabled service, top first.</p>
<div class="row">
<label for="sdr-op25" style="font-size:.875rem;font-weight:500">OP25 uses</label>
<select id="sdr-op25" class="sdr-select"></select>
</div>
<p id="sdr-op25-note" class="sub" style="margin:6px 0 4px"></p>
<div id="sdr-list"></div>
<p id="sdr-warning" class="msg err" style="margin-top:8px" hidden></p>
<div class="row" style="margin-top:12px">
<button id="sdr-save" class="btn primary" onclick="saveSdr()" disabled>Save SDR settings</button>
<span id="sdr-msg" class="msg"></span>
</div>
</section>
<footer>Updated <span id="last-updated">—</span></footer>
</div>
<script>
const $ = id => document.getElementById(id);
function esc(v) {
return String(v ?? '').replace(/[&<>"']/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
}
async function api(path, opts = {}) {
const r = await fetch(path, opts);
let body = null;
try { body = await r.json(); } catch (e) { /* non-JSON */ }
if (r.status === 401) { location.href = '/login'; throw new Error('Session expired'); }
if (!r.ok) throw new Error((body && body.detail) || r.statusText || `HTTP ${r.status}`);
return body;
}
function ago(epoch) {
const s = Math.max(0, Math.round(Date.now() / 1000 - epoch));
return s < 60 ? `${s}s ago` : s < 3600 ? `${Math.floor(s / 60)} min ago` : `${Math.floor(s / 3600)} h ago`;
}
function mhz(f) {
const n = Number(f);
if (!n) return '';
return (n > 1e5 ? n / 1e6 : n).toFixed(4) + ' MHz';
}
// ── Status checklist ───────────────────────────────────────────────────
const GLYPH = { ok: '✓', warn: '△', error: '▲', off: '–' };
const WORD = { ok: 'OK', warn: 'Attention', error: 'Problem', off: 'Not active' };
let lastHealth = null;
async function refreshHealth() {
try {
const d = await api('/api/health');
lastHealth = d;
const box = $('checks');
box.innerHTML = d.checks.map(c => `
<div class="check s-${c.state}">
<span class="glyph" aria-hidden="true">${GLYPH[c.state] || '?'}</span>
<div class="label">${esc(c.label)}<span class="state">${WORD[c.state] || esc(c.state)}</span></div>
<div class="text">${esc(c.summary)}</div>
<div class="action">${esc(c.action)}</div>
</div>`).join('');
const bad = d.checks.filter(c => c.state === 'error').length;
const warn = d.checks.filter(c => c.state === 'warn').length;
$('health-summary').textContent = bad
? `${bad} problem${bad > 1 ? 's' : ''}${warn ? ` and ${warn} thing${warn > 1 ? 's' : ''} to check` : ''} — see below.`
: warn ? `${warn} thing${warn > 1 ? 's' : ''} need${warn > 1 ? '' : 's'} attention — see below.`
: 'Everything is working.';
const heard = d.last_heard;
$('now-heard').innerHTML = heard
? `<span class="mono">TG ${esc(heard.tgid)}</span>${heard.name ? ' ' + esc(heard.name) : ''}` +
` · ${ago(heard.epoch)}${heard.freq ? ` · <span class="mono">${esc(mhz(heard.freq))}</span>` : ''}`
: 'Nothing heard since the node started.';
const op = d.checks.find(c => c.id === 'op25');
const src = d.checks.find(c => c.id === 'source');
const running = op && op.state === 'ok';
$('op25-start').disabled = running || (src && src.state !== 'ok');
$('op25-stop').disabled = !running;
if (op && op.state === 'error' && $('op25-log').hidden) loadLog();
} catch (e) {
$('health-summary').textContent = `Couldn't load status: ${e.message}`;
}
}
// ── Live status: system, talkgroup, stream URL ─────────────────────────
let streamUrl = null;
let status = null;
async function refreshStatus() {
try {
const d = await api('/api/status');
status = d;
$('node-name').textContent = d.node_name || 'DRB Edge Node';
$('node-id').textContent = d.node_id || '';
document.title = `${d.node_name || 'DRB Edge Node'} — DRB`;
$('now-system').textContent = d.system_name ? `${d.system_name} (${d.system_type})` : 'Nothing chosen yet';
$('now-tg').innerHTML = d.active_tgid
? `<span class="live">${esc(d.active_tgid_name || 'Talkgroup')}</span> <span class="mono">TG ${esc(d.active_tgid)}</span>`
: 'No call right now';
// The node calls Icecast "localhost"; the browser must use the node's address.
const url = `${location.protocol}//${location.hostname}:${d.icecast_port}${d.icecast_mount}`;
if (url !== streamUrl) {
streamUrl = url;
$('player').src = url;
$('stream-link').href = url;
$('stream-link').textContent = url;
}
renderSource(d);
$('last-updated').textContent = new Date().toLocaleTimeString();
} catch (e) {
console.error('Status fetch failed:', e);
}
}
function renderSource(d) {
const cur = $('current-source');
const actions = $('source-actions');
if (!d.system_name) {
cur.textContent = 'Nothing chosen yet. Pick a source below and press Start listening.';
actions.hidden = true;
return;
}
cur.innerHTML = `Now: <strong>${esc(d.system_name)}</strong> (${esc(d.system_type)}) — ` +
(d.local_choice ? 'chosen on this dashboard.' : 'assigned by the DRB server.');
actions.hidden = !d.local_choice;
$('revert-btn').textContent = d.has_c2_system ? "Go back to the server's system" : 'Clear and stop';
$('ack-btn').hidden = !(d.local_choice && d.has_c2_system && d.enforce_override_timeout);
}
// ── OP25 controls + log ────────────────────────────────────────────────
async function op25(action) {
$('op25-msg').className = 'msg';
$('op25-msg').textContent = action === 'start' ? 'Starting…' : 'Stopping…';
try {
await api(`/api/op25/${action}`, { method: 'POST' });
$('op25-msg').textContent = action === 'start' ? 'Started. Give it ~20 seconds to lock on.' : 'Stopped.';
} catch (e) {
$('op25-msg').className = 'msg err';
$('op25-msg').textContent = e.message;
}
refreshHealth();
}
async function loadLog() {
const pre = $('op25-log');
try {
const d = await api('/api/op25/log?lines=120');
pre.textContent = d.lines.length ? d.lines.join('\n') : 'OP25 has printed nothing since it last started.';
} catch (e) {
pre.textContent = `Couldn't load the log: ${e.message}`;
}
pre.hidden = false;
$('log-toggle').textContent = 'Hide OP25 log';
$('log-toggle').setAttribute('aria-expanded', 'true');
}
function toggleLog() {
const pre = $('op25-log');
if (pre.hidden) return loadLog();
pre.hidden = true;
$('log-toggle').textContent = 'Show OP25 log';
$('log-toggle').setAttribute('aria-expanded', 'false');
}
// ── Listen locally ─────────────────────────────────────────────────────
let systems = [];
function mode() { return document.querySelector('input[name=mode]:checked').value; }
document.querySelectorAll('input[name=mode]').forEach(r => r.addEventListener('change', () => {
document.querySelectorAll('.pane').forEach(p => { p.hidden = p.dataset.pane !== mode(); });
$('listen-msg').textContent = '';
}));
async function loadSystems() {
try { systems = await api('/api/systems'); } catch (e) { systems = []; }
const pick = $('sys-pick');
const usable = systems.filter(s => String(s.type).toUpperCase() !== 'DMR');
pick.innerHTML = usable.map(s => `<option value="${esc(s.system_id)}">${esc(s.name)} (${esc(s.type)})</option>`).join('');
const none = usable.length === 0;
$('mode-system').disabled = none;
pick.disabled = none;
$('sys-hint').textContent = none
? "The server's systems appear here once this node is approved and online."
: `${usable.length} system${usable.length > 1 ? 's' : ''} saved from the DRB server.`;
}
function parseFreqs(text) {
return text.split(/[\s,;]+/).filter(Boolean).map(Number);
}
async function listen() {
const m = mode();
const msg = $('listen-msg');
let body;
if (m === 'system') {
body = { mode: 'system', system_id: $('sys-pick').value };
} else if (m === 'p25') {
const freqs = parseFreqs($('p25-freqs').value);
if (!freqs.length || freqs.some(isNaN)) { msg.className = 'msg err'; msg.textContent = 'Enter control channel frequencies in MHz, e.g. 851.0125'; return; }
const tgs = [];
for (const line of $('p25-tgs').value.split('\n').map(l => l.trim()).filter(Boolean)) {
const mm = line.match(/^(\d+)\s*[,\t ]?\s*(.*)$/);
if (!mm) { msg.className = 'msg err'; msg.textContent = `Talkgroup line "${line}" should start with a decimal ID.`; return; }
tgs.push({ id: Number(mm[1]), name: mm[2] });
}
body = { mode: 'p25', frequencies: freqs, name: $('p25-name').value, talkgroups: tgs };
} else {
const freqs = parseFreqs($('an-freq').value);
if (freqs.length !== 1 || isNaN(freqs[0])) { msg.className = 'msg err'; msg.textContent = 'Enter one frequency in MHz, e.g. 154.4300'; return; }
body = { mode: 'analog', frequencies: freqs, name: $('an-name').value };
}
$('listen-btn').disabled = true;
msg.className = 'msg';
msg.textContent = 'Applying — restarting OP25…';
try {
const d = await api('/api/listen', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
msg.textContent = `Listening to ${d.name}. Give it ~20 seconds, then press play above.`;
} catch (e) {
msg.className = 'msg err';
msg.textContent = e.message;
} finally {
$('listen-btn').disabled = false;
refreshStatus(); refreshHealth();
}
}
async function revert() {
const toServer = status && status.has_c2_system;
if (!toServer && !confirm('Clear this choice and stop OP25?')) return;
const msg = $('listen-msg');
msg.className = 'msg';
msg.textContent = toServer ? "Switching back to the server's system…" : 'Stopping…';
try {
await api('/api/config/revert', { method: 'POST' });
msg.textContent = toServer ? "Back on the server's system." : 'Cleared. OP25 is stopped.';
} catch (e) {
msg.className = 'msg err';
msg.textContent = e.message;
}
refreshStatus(); refreshHealth();
}
async function ackOverride() {
const msg = $('listen-msg');
try {
await api('/api/config/override/ack', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ timeout_minutes: 1440 }) });
msg.className = 'msg';
msg.textContent = 'The server will keep this choice for 24 hours.';
} catch (e) {
msg.className = 'msg err';
msg.textContent = `Couldn't reach the server: ${e.message}`;
}
}
// ── SDRs: OP25's dongle, secondary priority and per-service pins ──────
const SDR_LABELS = { adsb: ['ADS-B', 'Aircraft · 1090 MHz'], ais: ['AIS', 'Vessels · 162 MHz'] };
let sdr = null, sdrRows = [], sdrOp25Pin = '', sdrDirty = false;
function deviceOptions(selected, autoLabel) {
const devs = sdr.devices || [];
const known = devs.some(d => d.serial === selected);
return `<option value="">${esc(autoLabel)}</option>` +
devs.map(d => `<option value="${esc(d.serial)}" ${d.serial === selected ? 'selected' : ''}>` +
`SDR ${d.index + 1} · serial ${esc(d.serial)}${d.duplicate_serial ? ' (shared serial!)' : ''}</option>`).join('') +
(selected && !known ? `<option value="${esc(selected)}" selected>serial ${esc(selected)} ` +
`(${sdr.devices === null ? 'not reported' : 'not plugged in'})</option>` : '');
}
function renderSdr() {
const op25sel = $('sdr-op25');
op25sel.innerHTML = deviceOptions(sdrOp25Pin, 'Automatic (first SDR)');
op25sel.onchange = () => { sdrOp25Pin = op25sel.value; markSdrDirty(); };
$('sdr-op25-note').textContent = sdrOp25Pin !== (sdr.pins.op25 || '')
? 'Saving will restart OP25 on the selected SDR.'
: (sdr.op25_serial ? `OP25 is using serial ${sdr.op25_serial}.` : '');
const list = $('sdr-list');
list.innerHTML = '';
let rank = 0;
const running = sdr.running || [];
sdrRows.forEach((row, i) => {
const [name, hint] = SDR_LABELS[row.mode] || [row.mode, ''];
const pinMissing = row.pin && sdr.devices !== null && !sdr.devices.some(d => d.serial === row.pin);
const state = !row.enabled ? 'Off' : sdrDirty ? 'Unsaved' : sdr.running === null ? 'Unknown'
: running.includes(row.mode) ? 'Running' : pinMissing ? 'Pinned SDR missing' : 'Waiting for SDR';
const el = document.createElement('div');
el.className = 'sdr-row';
el.innerHTML = `
<span class="sdr-rank">${row.enabled ? ++rank : ''}</span>
<input type="checkbox" ${row.enabled ? 'checked' : ''} aria-label="Enable ${name}">
<span class="sdr-name">${name}<small>${hint}</small></span>
<select class="sdr-select" aria-label="${name} SDR">${deviceOptions(row.pin, 'Any spare SDR')}</select>
<button class="sdr-move" aria-label="Move ${name} up" ${i === 0 ? 'disabled' : ''}>▲</button>
<button class="sdr-move" aria-label="Move ${name} down" ${i === sdrRows.length - 1 ? 'disabled' : ''}>▼</button>
<span class="sdr-state ${state === 'Running' ? 'on' : ''}">${state}</span>`;
const [box] = el.getElementsByTagName('input');
const [pick] = el.getElementsByTagName('select');
const [up, down] = el.getElementsByTagName('button');
box.onchange = () => { row.enabled = box.checked; markSdrDirty(); };
pick.onchange = () => { row.pin = pick.value; markSdrDirty(); };
up.onclick = () => { [sdrRows[i - 1], sdrRows[i]] = [sdrRows[i], sdrRows[i - 1]]; markSdrDirty(); };
down.onclick = () => { [sdrRows[i + 1], sdrRows[i]] = [sdrRows[i], sdrRows[i + 1]]; markSdrDirty(); };
list.appendChild(el);
});
const warn = $('sdr-warning');
const pinned = [sdrOp25Pin, ...sdrRows.map(r => r.pin)].filter(Boolean);
const problems = [];
if (sdr.devices === null) problems.push("The SDR service isn't responding, so SDRs can't be listed.");
if ((sdr.devices || []).some(d => d.duplicate_serial)) {
problems.push("Two SDRs share a serial number, so they can't be told apart. Give each a unique serial (rtl_eeprom -s) before pinning.");
}
if (new Set(pinned).size !== pinned.length) problems.push('Two services are pinned to the same SDR.');
warn.textContent = problems.join(' ');
warn.hidden = !problems.length;
$('sdr-save').disabled = !sdrDirty || new Set(pinned).size !== pinned.length;
}
function markSdrDirty() { sdrDirty = true; $('sdr-msg').textContent = ''; renderSdr(); }
async function loadSdr() {
if (sdrDirty) return; // never clobber unsaved edits with a poll
try {
sdr = await api('/api/sdr');
sdrOp25Pin = sdr.pins.op25 || '';
sdrRows = [
...sdr.priority.map(mode => ({ mode, enabled: true, pin: sdr.pins[mode] || '' })),
...sdr.modes.filter(m => !sdr.priority.includes(m)).map(mode => ({ mode, enabled: false, pin: sdr.pins[mode] || '' })),
];
renderSdr();
} catch (e) {
console.error('SDR load failed:', e);
}
}
async function saveSdr() {
const pins = { op25: sdrOp25Pin || null };
sdrRows.forEach(r => { pins[r.mode] = r.pin || null; });
const btn = $('sdr-save'), msg = $('sdr-msg');
btn.disabled = true;
msg.textContent = 'Applying…';
try {
const d = await api('/api/sdr', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priority: sdrRows.filter(r => r.enabled).map(r => r.mode), pins }),
});
sdrDirty = false;
msg.textContent = d.running === null ? "Saved, but the SDR service didn't respond." : 'Saved.';
await loadSdr();
} catch (e) {
msg.textContent = `Save failed: ${e.message}`;
btn.disabled = false;
}
}
refreshStatus(); setInterval(refreshStatus, 2000);
refreshHealth(); setInterval(refreshHealth, 5000);
loadSystems();
loadSdr(); setInterval(loadSdr, 10000);
</script>
</body>
</html>