QA blocker: when the secondary-sdr container was down, the checkin omitted secondary_sdr_running / sdr_devices / op25_sdr_serial, and C2 only overwrites keys that are present, so the dashboard kept the last 'Running' forever. Send explicit nulls. The local card also says 'not reported' rather than 'not plugged in' for a pin it can't check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
684 lines
25 KiB
HTML
684 lines
25 KiB
HTML
<!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 Dashboard</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--bg: #0b0f19;
|
|
--glass-bg: rgba(20, 25, 40, 0.6);
|
|
--glass-border: rgba(255, 255, 255, 0.08);
|
|
--accent: #3b82f6;
|
|
--accent-hover: #2563eb;
|
|
--success: #10b981;
|
|
--warning: #f59e0b;
|
|
--danger: #ef4444;
|
|
--text-main: #f8fafc;
|
|
--text-muted: #94a3b8;
|
|
}
|
|
|
|
*, *::before, *::after {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Inter', sans-serif;
|
|
background: var(--bg);
|
|
background-image:
|
|
radial-gradient(circle at 15% 50%, rgba(59, 130, 246, 0.15), transparent 25%),
|
|
radial-gradient(circle at 85% 30%, rgba(139, 92, 246, 0.15), transparent 25%);
|
|
color: var(--text-main);
|
|
min-height: 100vh;
|
|
padding: 2rem;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
margin-bottom: 3rem;
|
|
}
|
|
|
|
.title-area h1 {
|
|
font-size: 2.5rem;
|
|
font-weight: 800;
|
|
background: linear-gradient(to right, #fff, #94a3b8);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
|
|
.title-area p {
|
|
color: var(--text-muted);
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 0.9rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0.75rem 1.5rem;
|
|
border-radius: 8px;
|
|
font-weight: 600;
|
|
font-size: 0.9rem;
|
|
text-decoration: none;
|
|
transition: all 0.2s ease;
|
|
cursor: pointer;
|
|
border: none;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: var(--accent);
|
|
color: white;
|
|
box-shadow: 0 4px 14px 0 rgba(59, 130, 246, 0.39);
|
|
}
|
|
.btn-primary:hover {
|
|
background: var(--accent-hover);
|
|
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.23);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: rgba(255, 255, 255, 0.05);
|
|
color: white;
|
|
border: 1px solid var(--glass-border);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
.btn-secondary:hover {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.sdr-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
padding: 0.55rem 0;
|
|
border-bottom: 1px solid var(--glass-border);
|
|
}
|
|
.sdr-row:last-child { border-bottom: none; }
|
|
.sdr-rank { width: 1.2rem; color: var(--text-muted); font-size: 0.8rem; text-align: right; }
|
|
.sdr-name { flex: 1; }
|
|
.sdr-name small { display: block; color: var(--text-muted); font-size: 0.75rem; }
|
|
.sdr-move {
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border: 1px solid var(--glass-border);
|
|
color: var(--text-main);
|
|
border-radius: 6px;
|
|
width: 1.9rem;
|
|
height: 1.9rem;
|
|
cursor: pointer;
|
|
}
|
|
.sdr-move:disabled { opacity: 0.3; cursor: default; }
|
|
.sdr-state { font-size: 0.75rem; min-width: 6.5rem; text-align: right; color: var(--text-muted); }
|
|
.sdr-state.on { color: var(--success); }
|
|
.sdr-select {
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border: 1px solid var(--glass-border);
|
|
color: var(--text-main);
|
|
border-radius: 6px;
|
|
padding: 0.3rem 0.4rem;
|
|
font-size: 0.8rem;
|
|
max-width: 13rem;
|
|
}
|
|
.sdr-select option { background: #111827; }
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
|
gap: 1.5rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.glass-card {
|
|
background: var(--glass-bg);
|
|
backdrop-filter: blur(12px);
|
|
-webkit-backdrop-filter: blur(12px);
|
|
border: 1px solid var(--glass-border);
|
|
border-radius: 16px;
|
|
padding: 1.5rem;
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
}
|
|
|
|
.glass-card:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
|
border-color: rgba(255,255,255,0.15);
|
|
}
|
|
|
|
.card-header {
|
|
display: flex;
|
|
align-items: center;
|
|
margin-bottom: 1.5rem;
|
|
padding-bottom: 0.75rem;
|
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
|
}
|
|
|
|
.card-title {
|
|
font-size: 0.85rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.1em;
|
|
font-weight: 700;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.data-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0.75rem 0;
|
|
}
|
|
|
|
.data-label {
|
|
font-size: 0.9rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.data-value {
|
|
font-size: 0.95rem;
|
|
font-weight: 500;
|
|
color: var(--text-main);
|
|
text-align: right;
|
|
}
|
|
|
|
.data-value.mono {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 9999px;
|
|
font-size: 0.75rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.badge-online { background: rgba(16, 185, 129, 0.15); color: var(--success); border: 1px solid rgba(16, 185, 129, 0.2); }
|
|
.badge-offline { background: rgba(239, 68, 68, 0.15); color: var(--danger); border: 1px solid rgba(239, 68, 68, 0.2); }
|
|
.badge-recording {
|
|
background: rgba(245, 158, 11, 0.15);
|
|
color: var(--warning);
|
|
border: 1px solid rgba(245, 158, 11, 0.2);
|
|
animation: pulse-border 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
}
|
|
.badge-unconfigured { background: rgba(148, 163, 184, 0.15); color: var(--text-muted); border: 1px solid rgba(148, 163, 184, 0.2); }
|
|
|
|
@keyframes pulse-border {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
|
|
/* Player Section */
|
|
.player-card {
|
|
grid-column: 1 / -1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.player-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
background: rgba(0,0,0,0.2);
|
|
padding: 1rem;
|
|
border-radius: 12px;
|
|
}
|
|
|
|
audio {
|
|
flex: 1;
|
|
height: 40px;
|
|
outline: none;
|
|
}
|
|
|
|
audio::-webkit-media-controls-panel {
|
|
background-color: var(--text-main);
|
|
}
|
|
|
|
.alert {
|
|
background: rgba(59, 130, 246, 0.1);
|
|
border: 1px solid var(--accent);
|
|
border-radius: 8px;
|
|
padding: 1rem 1.5rem;
|
|
color: #bfdbfe;
|
|
margin-bottom: 2rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
.alert code {
|
|
background: rgba(0,0,0,0.3);
|
|
padding: 0.2rem 0.4rem;
|
|
border-radius: 4px;
|
|
font-family: 'JetBrains Mono', monospace;
|
|
}
|
|
|
|
.footer {
|
|
margin-top: 3rem;
|
|
text-align: center;
|
|
font-size: 0.8rem;
|
|
color: var(--text-muted);
|
|
font-family: 'JetBrains Mono', monospace;
|
|
}
|
|
|
|
/* Active Call Highlights */
|
|
.active-call-highlight {
|
|
color: var(--accent);
|
|
font-size: 1.1rem;
|
|
font-weight: 700;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<div class="title-area">
|
|
<h1 id="node-name">DRB Edge Node</h1>
|
|
<p>ID: <span id="node-id">Loading…</span></p>
|
|
</div>
|
|
<div class="header-actions">
|
|
<a href="/scanner" target="_blank" class="btn btn-secondary">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:8px"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
|
|
Scanner Mode
|
|
</a>
|
|
<a href="/logout" class="btn btn-secondary">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:8px"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
|
|
Logout
|
|
</a>
|
|
</div>
|
|
</header>
|
|
|
|
<div id="unconfigured-banner" class="alert" style="display:none">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
|
|
<div>
|
|
<strong>Node Unconfigured</strong> — This node has not been assigned a system yet. Connect it to a C2 server or configure it manually via the API (<code>POST /api/config/system</code>).
|
|
</div>
|
|
</div>
|
|
|
|
<div id="override-banner" class="alert" style="display:none; background: rgba(245, 158, 11, 0.1); border-color: var(--warning); color: #fef08a;">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
|
|
<div style="flex: 1;">
|
|
<strong>Local Override Active</strong> — System overridden to: <span id="override-system-text"></span>.
|
|
<span id="timeout-timer-text">Timeout is active on C2 server.</span>
|
|
</div>
|
|
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
|
<button class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.8rem;" onclick="revertToServerConfig()">Revert</button>
|
|
<button id="ack-override-btn" class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.8rem; background: var(--warning); box-shadow: none;" onclick="ackOverrideC2()">Ack C2 Timer</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid">
|
|
<!-- Status Card -->
|
|
<div class="glass-card">
|
|
<div class="card-header">
|
|
<div class="card-title">System Status</div>
|
|
</div>
|
|
<div class="data-row">
|
|
<span class="data-label">State</span>
|
|
<span class="data-value"><span id="status-badge" class="badge badge-unconfigured">—</span></span>
|
|
</div>
|
|
<div class="data-row">
|
|
<span class="data-label">Discord</span>
|
|
<span class="data-value" id="discord-status">—</span>
|
|
</div>
|
|
<div class="data-row">
|
|
<span class="data-label">Recording</span>
|
|
<span class="data-value" id="recording-status">—</span>
|
|
</div>
|
|
<div class="data-row">
|
|
<span class="data-label">OP25 Core</span>
|
|
<span class="data-value" id="op25-status">—</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Call Card -->
|
|
<div class="glass-card" id="call-card">
|
|
<div class="card-header">
|
|
<div class="card-title">Live Activity</div>
|
|
</div>
|
|
<div class="data-row">
|
|
<span class="data-label">System</span>
|
|
<span class="data-value active-call-highlight" id="system-name">—</span>
|
|
</div>
|
|
<div class="data-row">
|
|
<span class="data-label">Talkgroup</span>
|
|
<span class="data-value active-call-highlight" id="tgid-name">—</span>
|
|
</div>
|
|
<div class="data-row">
|
|
<span class="data-label">TGID</span>
|
|
<span class="data-value mono" id="tgid">—</span>
|
|
</div>
|
|
<div class="data-row">
|
|
<span class="data-label">Call ID</span>
|
|
<span class="data-value mono" id="call-id">—</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Node Info Card -->
|
|
<div class="glass-card">
|
|
<div class="card-header">
|
|
<div class="card-title">Hardware & Config</div>
|
|
</div>
|
|
<div class="data-row">
|
|
<span class="data-label">Location</span>
|
|
<span class="data-value mono" id="location">—</span>
|
|
</div>
|
|
<div class="data-row">
|
|
<span class="data-label">Configured</span>
|
|
<span class="data-value" id="configured">—</span>
|
|
</div>
|
|
<div class="data-row">
|
|
<span class="data-label">System ID</span>
|
|
<span class="data-value mono" id="system-id">—</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- SDRs Card (node-26#9, #11) -->
|
|
<div class="glass-card">
|
|
<div class="card-header">
|
|
<div class="card-title">SDRs</div>
|
|
</div>
|
|
<div class="data-row" style="align-items:center;">
|
|
<span class="data-label">OP25 SDR</span>
|
|
<select id="sdr-op25" class="sdr-select" aria-label="OP25 SDR"></select>
|
|
</div>
|
|
<p id="sdr-op25-note" style="color: var(--text-muted); font-size: 0.75rem; margin: 0.25rem 0 0.75rem;"></p>
|
|
<p style="color: var(--text-muted); font-size: 0.8rem; margin: 0 0 0.5rem;">
|
|
Every other SDR runs the next enabled service, top first. Pin a service to the SDR
|
|
that has its antenna, or leave it on "Any spare SDR".
|
|
</p>
|
|
<div id="sdr-list"></div>
|
|
<p id="sdr-warning" style="color: var(--danger); font-size: 0.8rem; margin: 0.5rem 0 0; display:none;"></p>
|
|
<div style="display:flex; gap:0.75rem; align-items:center; margin-top:0.75rem;">
|
|
<button id="sdr-save" class="btn btn-primary" style="padding:0.5rem 1rem;" onclick="saveSdr()" disabled>Save</button>
|
|
<span id="sdr-msg" style="color: var(--text-muted); font-size: 0.8rem;"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Local Listening Card -->
|
|
<div class="glass-card player-card">
|
|
<div class="card-header" style="margin-bottom:0.5rem; border:none;">
|
|
<div class="card-title">Local Audio Stream</div>
|
|
</div>
|
|
<div class="player-controls">
|
|
<audio id="icecast-player" controls preload="none">
|
|
Your browser does not support the audio element.
|
|
</audio>
|
|
<a id="icecast-link" class="btn btn-primary" href="#" target="_blank" style="padding: 0.5rem 1rem;">Direct Link</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="footer">
|
|
Last updated: <span id="last-updated">—</span>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const player = document.getElementById('icecast-player');
|
|
let currentIcecastUrl = null;
|
|
|
|
async function refresh() {
|
|
try {
|
|
const r = await fetch('/api/status');
|
|
if (!r.ok) return;
|
|
const d = await r.json();
|
|
|
|
// Header
|
|
document.getElementById('node-name').textContent = d.node_name || 'DRB Edge Node';
|
|
document.getElementById('node-id').textContent = d.node_id || '';
|
|
|
|
// Status
|
|
const status = d.is_recording ? 'recording' : (d.configured ? 'online' : 'unconfigured');
|
|
const badge = document.getElementById('status-badge');
|
|
badge.textContent = status;
|
|
badge.className = 'badge badge-' + status;
|
|
|
|
document.getElementById('discord-status').textContent = d.discord_connected ? 'Connected' : 'Disconnected';
|
|
document.getElementById('recording-status').textContent = d.is_recording ? 'Active' : 'Idle';
|
|
document.getElementById('op25-status').textContent = d.op25?.status === 'running' ? 'Running' : 'Stopped';
|
|
|
|
// Call Info
|
|
document.getElementById('system-name').textContent = d.system_name || '—';
|
|
document.getElementById('tgid-name').textContent = d.active_tgid_name || (d.active_tgid ? `TG ${d.active_tgid}` : 'Scanning...');
|
|
document.getElementById('tgid').textContent = d.active_tgid ?? '—';
|
|
document.getElementById('call-id').textContent = d.active_call_id ? d.active_call_id.slice(0, 8) + '…' : '—';
|
|
|
|
// Highlight active call card
|
|
const callCard = document.getElementById('call-card');
|
|
if (d.is_recording) {
|
|
callCard.style.borderColor = 'var(--accent)';
|
|
callCard.style.boxShadow = '0 0 20px rgba(59, 130, 246, 0.2)';
|
|
} else {
|
|
callCard.style.borderColor = 'var(--glass-border)';
|
|
callCard.style.boxShadow = '0 4px 6px -1px rgba(0, 0, 0, 0.1)';
|
|
}
|
|
|
|
// Node Info
|
|
document.getElementById('location').textContent = `${d.lat}, ${d.lon}`;
|
|
document.getElementById('configured').textContent = d.configured ? 'Yes' : 'No';
|
|
document.getElementById('system-id').textContent = d.assigned_system_id ?? '—';
|
|
|
|
// Audio Player
|
|
if (d.icecast_url) {
|
|
const dynamicUrl = d.icecast_url.replace('localhost', window.location.hostname);
|
|
if (dynamicUrl !== currentIcecastUrl) {
|
|
currentIcecastUrl = dynamicUrl;
|
|
player.src = currentIcecastUrl;
|
|
document.getElementById('icecast-link').href = currentIcecastUrl;
|
|
}
|
|
}
|
|
|
|
// Override Banner
|
|
const overrideBanner = document.getElementById('override-banner');
|
|
if (d.is_overridden) {
|
|
overrideBanner.style.display = 'flex';
|
|
document.getElementById('override-system-text').textContent = d.system_name || 'Manual';
|
|
|
|
const ackBtn = document.getElementById('ack-override-btn');
|
|
const timerText = document.getElementById('timeout-timer-text');
|
|
|
|
if (d.enforce_override_timeout) {
|
|
timerText.textContent = "C2 reset timer active (24h default).";
|
|
ackBtn.style.display = 'inline-flex';
|
|
} else {
|
|
timerText.textContent = "No C2 timeout is enforced.";
|
|
ackBtn.style.display = 'none';
|
|
}
|
|
} else {
|
|
overrideBanner.style.display = 'none';
|
|
}
|
|
|
|
document.getElementById('unconfigured-banner').style.display = d.configured ? 'none' : 'flex';
|
|
document.getElementById('last-updated').textContent = new Date().toLocaleTimeString();
|
|
} catch (e) {
|
|
console.error('Status fetch failed:', e);
|
|
}
|
|
}
|
|
|
|
async function revertToServerConfig() {
|
|
try {
|
|
const r = await fetch('/api/config/revert', { method: 'POST' });
|
|
if (r.ok) {
|
|
alert('Config reverted successfully.');
|
|
refresh();
|
|
} else {
|
|
alert('Failed to revert config.');
|
|
}
|
|
} catch (e) {
|
|
console.error('Revert failed:', e);
|
|
}
|
|
}
|
|
|
|
async function ackOverrideC2() {
|
|
try {
|
|
const r = await fetch('/api/config/override/ack', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ timeout_minutes: 1440 })
|
|
});
|
|
if (r.ok) {
|
|
alert('Override acknowledged on C2.');
|
|
} else {
|
|
alert('Failed to send acknowledgement.');
|
|
}
|
|
} catch (e) {
|
|
console.error('Ack failed:', e);
|
|
}
|
|
}
|
|
|
|
// ── 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; // last GET /api/sdr
|
|
let sdrRows = []; // [{mode, enabled, pin}] in display order
|
|
let sdrOp25Pin = ''; // '' = automatic
|
|
let sdrDirty = false;
|
|
|
|
function esc(v) {
|
|
return String(v ?? '').replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c]));
|
|
}
|
|
|
|
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 op25 = document.getElementById('sdr-op25');
|
|
op25.innerHTML = deviceOptions(sdrOp25Pin, 'Automatic (first SDR)');
|
|
op25.onchange = () => { sdrOp25Pin = op25.value; markSdrDirty(); };
|
|
document.getElementById('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 = document.getElementById('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 = document.getElementById('sdr-warning');
|
|
const pinned = [sdrOp25Pin, ...sdrRows.map(r => r.pin)].filter(Boolean);
|
|
const problems = [];
|
|
if (sdr.devices === null) problems.push('The secondary SDR service is not 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.style.display = problems.length ? 'block' : 'none';
|
|
document.getElementById('sdr-save').disabled = !sdrDirty || new Set(pinned).size !== pinned.length;
|
|
}
|
|
|
|
function markSdrDirty() {
|
|
sdrDirty = true;
|
|
document.getElementById('sdr-msg').textContent = '';
|
|
renderSdr();
|
|
}
|
|
|
|
async function loadSdr() {
|
|
if (sdrDirty) return; // never clobber unsaved edits with a poll
|
|
try {
|
|
const r = await fetch('/api/sdr');
|
|
if (!r.ok) return;
|
|
sdr = await r.json();
|
|
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 = document.getElementById('sdr-save');
|
|
const msg = document.getElementById('sdr-msg');
|
|
btn.disabled = true;
|
|
msg.textContent = 'Applying…';
|
|
try {
|
|
const r = await fetch('/api/sdr', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ priority: sdrRows.filter(r => r.enabled).map(r => r.mode), pins }),
|
|
});
|
|
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
|
|
const d = await r.json();
|
|
sdrDirty = false;
|
|
msg.textContent = d.running === null ? 'Saved, but the secondary SDR service did not respond.' : 'Saved.';
|
|
await loadSdr();
|
|
} catch (e) {
|
|
console.error('SDR save failed:', e);
|
|
msg.textContent = `Save failed: ${e.message}`;
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
loadSdr();
|
|
setInterval(loadSdr, 10000);
|
|
|
|
refresh();
|
|
setInterval(refresh, 2000); // Polling every 2 seconds
|
|
</script>
|
|
</body>
|
|
</html>
|