diff --git a/codex_webinterface_prompt.txt b/codex_webinterface_prompt.txt new file mode 100644 index 0000000..1fd0971 --- /dev/null +++ b/codex_webinterface_prompt.txt @@ -0,0 +1,41 @@ +Arbeite im Repository C:\Users\jan\Downloads\sdr-wideband-suite auf dem bereits angelegten Branch refactor/webinterface. + +WICHTIG: +- NICHT committen. +- NICHT pushen. +- Keine Branches wechseln oder neu anlegen. +- config.yaml, config.autosave.yaml, Debug-Dumps und lokale Laufzeitartefakte nicht absichtlich verändern, außer es ist für das Frontend unbedingt nötig — dann nur minimal und uncommitted. +- Vor dem Start zuerst das Repo einlesen: AGENTS.md, README.md, docs/known-issues.md, relevante API-/WS-Serverstellen in cmd/sdrd/http_handlers.go und cmd/sdrd/ws_handlers.go, sowie das Frontend unter web/. + +Kontext / Ziel: +- Das Webinterface soll erweitert und verbessert werden. +- Fokus auf Webinterface-Refactor mit besserer Struktur, besserer UX und klarer Ausrichtung an der existierenden API. +- Aktuell ist das Frontend ein großes Vanilla-JS-Monolith-Skript in web/app.js. Bitte den aktuellen Zustand zuerst analysieren und dann gezielt verbessern, ohne unnötig die gesamte App umzubauen. + +Bitte liefere: +1. Eine kurze Analyse des aktuellen UI/API-Zuschnitts und der größten Schwachstellen. +2. Einen konkreten Umsetzungsplan für einen sinnvollen ersten Refactor-Schritt. +3. Dann die Umsetzung direkt im Branch: verbessere/erweitere das Webinterface spürbar. + +Bevorzugte Richtungen für die Umsetzung (du darfst priorisieren): +- bessere Struktur/Modularisierung innerhalb von web/app.js bzw. Aufteilung in mehrere Web-Dateien, wenn das ohne riesigen Umbau sauber machbar ist +- klarere Zustandsdarstellung für API/WS-Verbindung, Config-Status, Source/GPU/Health +- bessere Nutzbarkeit der vorhandenen APIs (/api/refinement, /api/events, /api/recordings, /api/debug/telemetry/*) im UI +- UI-Verbesserungen für Signal-/Event-/Recording-Ansichten +- bessere leichtere Debug-/Operator-Ansicht statt nur verstreuter Informationen +- kleine, robuste UX-Verbesserungen > rein kosmetische Änderungen + +Grenzen: +- Kein Full-Rewrite in Frameworks. +- Keine unnötigen Backend-API-Änderungen, außer klar sinnvoll und klein. +- Nicht versuchen, das gesamte Projekt zu lösen. Lieber ein sauberer, wertvoller erster Refactor. +- Wenn Builds/Checks sinnvoll sind, nutze für dieses Projekt bevorzugt die vorhandenen Projekt-Skripte/Workflow-Regeln aus AGENTS.md; für kleine Syntax-/Sanity-Checks sind gezielte Checks ok. + +Wenn du fertig bist, berichte knapp: +- was du analysiert hast +- welche Dateien du geändert hast +- was funktional verbessert wurde +- ob es offene Punkte / Risiken gibt + +Wenn komplett fertig, run this command: +openclaw system event --text "Done: webinterface refactor first pass completed on refactor/webinterface (no commit)" --mode now diff --git a/config.autosave.yaml b/config.autosave.yaml index 03dbd1c..8c70b9a 100644 --- a/config.autosave.yaml +++ b/config.autosave.yaml @@ -4,7 +4,7 @@ bands: end_hz: 1.08e+08 center_hz: 1.02e+08 sample_rate: 4096000 -fft_size: 512 +fft_size: 4096 gain_db: 32 tuner_bw_khz: 5000 use_gpu_fft: true @@ -51,7 +51,7 @@ pipeline: - WFM_STEREO - RDS surveillance: - analysis_fft_size: 512 + analysis_fft_size: 4096 frame_rate: 12 strategy: multi-resolution display_bins: 2048 @@ -267,9 +267,9 @@ profiles: decision_hold_ms: 2500 detector: threshold_db: -60 - min_duration_ms: 500 + min_duration_ms: 5000 hold_ms: 1500 - ema_alpha: 0.025 + ema_alpha: 0.35 hysteresis_db: 10 min_stable_frames: 4 gap_tolerance_ms: 2000 @@ -279,7 +279,7 @@ detector: cfar_guard_cells: 3 cfar_train_cells: 24 cfar_rank: 36 - cfar_scale_db: 23 + cfar_scale_db: 27 cfar_wrap_around: true edge_margin_db: 6 max_signal_bw_hz: 260000 @@ -295,10 +295,12 @@ recorder: record_iq: false record_audio: true auto_demod: true - auto_decode: false + auto_decode: true max_disk_mb: 0 output_dir: data/recordings - class_filter: [] + class_filter: + - WFM + - WFM_STEREO ring_seconds: 12 deemphasis_us: 50 extraction_fir_taps: 101 @@ -315,14 +317,14 @@ debug: audio_dump_enabled: false cpu_monitoring: false telemetry: - enabled: true + enabled: false heavy_enabled: false heavy_sample_every: 12 metric_sample_every: 8 metric_history_max: 6000 event_history_max: 1500 retention_seconds: 900 - persist_enabled: false + persist_enabled: true persist_dir: debug/telemetry rotate_mb: 16 keep_files: 8 diff --git a/config.yaml b/config.yaml index 53cdb0b..592d701 100644 --- a/config.yaml +++ b/config.yaml @@ -224,15 +224,15 @@ detector: class_history_size: 10 class_switch_ratio: 0.6 recorder: - enabled: true + enabled: false min_snr_db: 0 min_duration: 500ms max_duration: 300s preroll_ms: 500 record_iq: false - record_audio: true - auto_demod: true - auto_decode: true + record_audio: false + auto_demod: false + auto_decode:false max_disk_mb: 0 output_dir: data/recordings class_filter: ["WFM", "WFM_STEREO"] @@ -252,14 +252,14 @@ debug: audio_dump_enabled: false cpu_monitoring: false telemetry: - enabled: true + enabled: false heavy_enabled: false heavy_sample_every: 12 metric_sample_every: 8 metric_history_max: 6000 event_history_max: 1500 retention_seconds: 900 - persist_enabled: true + persist_enabled: false persist_dir: debug/telemetry rotate_mb: 16 keep_files: 8 diff --git a/fix_mojibake.py b/fix_mojibake.py new file mode 100644 index 0000000..3eac37b --- /dev/null +++ b/fix_mojibake.py @@ -0,0 +1,29 @@ +from pathlib import Path + +files = [ + Path(r"C:\Users\jan\Downloads\sdr-wideband-suite\web\app.js"), + Path(r"C:\Users\jan\Downloads\sdr-wideband-suite\web\index.html"), + Path(r"C:\Users\jan\Downloads\sdr-wideband-suite\web\style.css"), +] + +repl = { + '·': '·', + '…': '…', + '—': '—', + '–': '–', + '→': '→', + 'â†\x90': '←', + 'â– ': '■', + 'â– ': '■', + '×': '×', + 'â•\x90': '═', + '─': '─', +} + +for p in files: + s = p.read_text(encoding='utf-8') + o = s + for a, b in repl.items(): + s = s.replace(a, b) + if s != o: + p.write_text(s, encoding='utf-8', newline='') diff --git a/web/api-client.js b/web/api-client.js new file mode 100644 index 0000000..172a76e --- /dev/null +++ b/web/api-client.js @@ -0,0 +1,74 @@ +(function (global) { + const DEFAULT_TIMEOUT_MS = 5000; + + function toErrorMessage(err) { + if (!err) return 'request failed'; + if (typeof err === 'string') return err; + return err.message || 'request failed'; + } + + function createClient(opts = {}) { + const baseUrl = opts.baseUrl || ''; + const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_TIMEOUT_MS; + + async function request(path, options = {}) { + const controller = new AbortController(); + const start = performance.now(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const init = { + method: options.method || 'GET', + headers: { ...(options.headers || {}) }, + signal: controller.signal, + }; + if (options.body !== undefined) { + init.headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(options.body); + } + + try { + const res = await fetch(`${baseUrl}${path}`, init); + const durationMs = Math.round(performance.now() - start); + const contentType = res.headers.get('content-type') || ''; + let data = null; + if (contentType.includes('application/json')) data = await res.json(); + else data = await res.text(); + if (!res.ok) { + const error = typeof data === 'string' ? data : (data?.error || `http ${res.status}`); + return { ok: false, status: res.status, error, data, meta: { duration_ms: durationMs } }; + } + return { ok: true, status: res.status, data, meta: { duration_ms: durationMs } }; + } catch (err) { + const durationMs = Math.round(performance.now() - start); + return { ok: false, status: 0, error: toErrorMessage(err), data: null, meta: { duration_ms: durationMs } }; + } finally { + clearTimeout(timer); + } + } + + return { + getConfig: () => request('/api/config'), + postConfig: (payload) => request('/api/config', { method: 'POST', body: payload }), + postSettings: (payload) => request('/api/sdr/settings', { method: 'POST', body: payload }), + getSignals: () => request('/api/signals'), + getStats: () => request('/api/stats'), + getGPU: () => request('/api/gpu'), + getPolicy: () => request('/api/pipeline/policy'), + getRecommendations: () => request('/api/pipeline/recommendations'), + getRefinement: () => request('/api/refinement'), + getEvents: ({ limit, since } = {}) => { + const params = new URLSearchParams(); + if (Number.isFinite(limit) && limit > 0) params.set('limit', String(limit)); + if (Number.isFinite(since) && since > 0) params.set('since', String(since)); + const suffix = params.toString() ? `?${params.toString()}` : ''; + return request(`/api/events${suffix}`); + }, + getRecordings: () => request('/api/recordings'), + getRecording: (id) => request(`/api/recordings/${encodeURIComponent(id)}`), + decodeRecording: (id, mode) => request(`/api/recordings/${encodeURIComponent(id)}/decode?mode=${encodeURIComponent(mode || '')}`), + getDecoders: () => request('/api/decoders'), + getTelemetryLive: () => request('/api/debug/telemetry/live'), + }; + } + + global.SpectreApi = { createClient }; +})(window); diff --git a/web/app.js b/web/app.js index 80866b0..6f6bf95 100644 --- a/web/app.js +++ b/web/app.js @@ -69,14 +69,17 @@ const refineMaxSpan = qs('refineMaxSpan'); const resMaxRefine = qs('resMaxRefine'); const resMaxRecord = qs('resMaxRecord'); const resMaxDecode = qs('resMaxDecode'); +const resDecisionHold = qs('resDecisionHold'); const signalList = qs('signalList'); const signalDecisionSummary = qs('signalDecisionSummary'); +const signalQueueSummary = qs('signalQueueSummary'); const eventList = qs('eventList'); const recordingList = qs('recordingList'); const signalCountBadge = qs('signalCountBadge'); const eventCountBadge = qs('eventCountBadge'); const recordingCountBadge = qs('recordingCountBadge'); +const signalSummaryLine = qs('signalSummaryLine'); const healthBuffer = qs('healthBuffer'); const healthDropped = qs('healthDropped'); @@ -86,6 +89,16 @@ const healthGpu = qs('healthGpu'); const healthFps = qs('healthFps'); const healthRefinePlan = qs('healthRefinePlan'); const healthRefineWindows = qs('healthRefineWindows'); +const healthWs = qs('healthWs'); +const healthApi = qs('healthApi'); +const healthConfig = qs('healthConfig'); +const healthRefine = qs('healthRefine'); +const healthTelemetry = qs('healthTelemetry'); +const healthSource = qs('healthSource'); +const refineDetails = qs('refineDetails'); +const telemetryEventList = qs('telemetryEventList'); +const policySummaryList = qs('policySummaryList'); +const policyRecommendationList = qs('policyRecommendationList'); const drawerEl = qs('eventDrawer'); const drawerCloseBtn = qs('drawerClose'); @@ -139,6 +152,27 @@ let liveListenInfo = null; let stats = { buffer_samples: 0, dropped: 0, resets: 0, last_sample_ago_ms: -1 }; let refinementInfo = {}; let decisionIndex = new Map(); +let telemetryLive = null; +let policyInfo = null; +let policyRecommendations = null; +let wsState = 'init'; +let wsLastMessageTs = 0; +let apiState = { ok: false, latencyMs: null, lastOkTs: 0, lastError: '' }; +const apiClient = window.SpectreApi?.createClient + ? window.SpectreApi.createClient({ timeoutMs: 4500 }) + : null; +const operatorPanel = window.OperatorPanel?.create + ? window.OperatorPanel.create({ + healthWs, + healthApi, + healthConfig, + healthRefine, + healthTelemetry, + healthSource, + refineDetails, + telemetryEvents: telemetryEventList + }) + : null; // --------------------------------------------------------------------------- // LiveListenWS — WebSocket-based gapless audio streaming via /ws/audio @@ -578,6 +612,7 @@ function setConfigStatus(text) { } function setWsBadge(text, kind = 'neutral') { + wsState = kind === 'ok' ? 'live' : (kind === 'bad' ? 'retrying' : 'connecting'); wsBadge.textContent = text; wsBadge.style.borderColor = kind === 'ok' ? 'rgba(124, 251, 131, 0.35)' @@ -586,6 +621,68 @@ function setWsBadge(text, kind = 'neutral') { : 'rgba(112, 150, 207, 0.18)'; } +function updateApiState(result) { + if (!result) return; + const latency = result.meta?.duration_ms; + if (Number.isFinite(latency)) apiState.latencyMs = latency; + if (result.ok) { + apiState.ok = true; + apiState.lastOkTs = Date.now(); + apiState.lastError = ''; + } else { + apiState.ok = false; + apiState.lastError = result.error || (result.status ? `HTTP ${result.status}` : 'request failed'); + } +} + +function fmtAgeShort(ms) { + if (!Number.isFinite(ms) || ms < 0) return '-'; + if (ms < 1000) return `${Math.round(ms)} ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)} s`; + return `${Math.round(ms / 60000)} min`; +} + +function updateOperatorStatus() { + if (operatorPanel) { + operatorPanel.updateStatus({ + wsState, + wsLastMessageTs, + apiState, + configStatusText: configStatusEl?.textContent || '-', + refinementInfo, + telemetryLive, + sourceAgeMs: stats?.last_sample_ago_ms + }); + return; + } + if (healthWs) { + const age = wsLastMessageTs > 0 ? fmtAgeShort(Date.now() - wsLastMessageTs) : '-'; + healthWs.textContent = `${wsState} | last ${age}`; + } + if (healthApi) { + const latency = Number.isFinite(apiState.latencyMs) ? `${apiState.latencyMs} ms` : 'n/a'; + healthApi.textContent = apiState.ok ? `ok | ${latency}` : `degraded | ${apiState.lastError || 'n/a'}`; + } + if (healthConfig) { + healthConfig.textContent = configStatusEl?.textContent || '-'; + } + if (healthRefine) { + const plan = refinementInfo.plan || {}; + const queue = refinementInfo.arbitration?.queue || {}; + healthRefine.textContent = `${plan.selected?.length || 0}/${plan.budget || 0} | q ${queue.record_queued || 0}/${queue.decode_queued || 0}`; + } + if (healthTelemetry) { + if (!telemetryLive) { + healthTelemetry.textContent = 'unavailable'; + } else { + const enabled = telemetryLive.enabled === false ? 'off' : 'on'; + const collector = telemetryLive.collector || {}; + const recent = Array.isArray(telemetryLive.recent_events) ? telemetryLive.recent_events.length : 0; + const heavy = collector.heavy_enabled ? 'heavy' : 'light'; + healthTelemetry.textContent = `${enabled} | ${heavy} | events ${recent}`; + } + } +} function toMHz(hz) { return hz / 1e6; } function fromMHz(mhz) { return mhz * 1e6; } function fmtMHz(hz, digits = 3) { return `${(hz / 1e6).toFixed(digits)} MHz`; } @@ -883,78 +980,139 @@ function applyConfigToUI(cfg) { } async function loadConfig() { - try { - const res = await fetch('/api/config'); - if (!res.ok) throw new Error('config'); - currentConfig = await res.json(); + if (!apiClient) return; + const res = await apiClient.getConfig(); + updateApiState(res); + if (res.ok && res.data) { + currentConfig = res.data; applyConfigToUI(currentConfig); setConfigStatus('Config synced'); - } catch { + } else { setConfigStatus('Config offline'); } + updateOperatorStatus(); } async function loadSignals() { - try { - const res = await fetch('/api/signals'); - if (!res.ok) return; - const sigs = await res.json(); - if (Array.isArray(sigs)) { - latest = latest || {}; - latest.signals = sigs; - renderLists(); - } - } catch {} + if (!apiClient) return; + const res = await apiClient.getSignals(); + updateApiState(res); + if (!res.ok || !Array.isArray(res.data)) return; + latest = latest || {}; + latest.signals = res.data; + renderLists(); } async function loadDecoders() { - if (!decodeModeSelect) return; - try { - const res = await fetch('/api/decoders'); - if (!res.ok) return; - const list = await res.json(); - if (!Array.isArray(list)) return; - const current = decodeModeSelect.value; - decodeModeSelect.innerHTML = ''; - list.forEach((mode) => { - const opt = document.createElement('option'); - opt.value = mode; - opt.textContent = mode; - decodeModeSelect.appendChild(opt); - }); - if (current) decodeModeSelect.value = current; - } catch {} + if (!decodeModeSelect || !apiClient) return; + const res = await apiClient.getDecoders(); + updateApiState(res); + if (!res.ok || !Array.isArray(res.data)) return; + const current = decodeModeSelect.value; + decodeModeSelect.innerHTML = ''; + res.data.forEach((mode) => { + const opt = document.createElement('option'); + opt.value = mode; + opt.textContent = mode; + decodeModeSelect.appendChild(opt); + }); + if (current) decodeModeSelect.value = current; } async function loadStats() { - try { - const res = await fetch('/api/stats'); - if (!res.ok) return; - stats = await res.json(); - } catch {} + if (!apiClient) return; + const res = await apiClient.getStats(); + updateApiState(res); + if (res.ok && res.data) stats = res.data; + updateOperatorStatus(); } async function loadGPU() { - try { - const res = await fetch('/api/gpu'); - if (!res.ok) return; - gpuInfo = await res.json(); - } catch {} + if (!apiClient) return; + const res = await apiClient.getGPU(); + updateApiState(res); + if (res.ok && res.data) gpuInfo = res.data; + updateOperatorStatus(); } async function loadRefinement() { - try { - const res = await fetch('/api/refinement'); - if (!res.ok) return; - refinementInfo = await res.json(); - decisionIndex = new Map(); - const items = Array.isArray(refinementInfo.arbitration?.decision_items) ? refinementInfo.arbitration.decision_items : []; - items.forEach(item => { - if (item && item.id != null) decisionIndex.set(String(item.id), item); - }); - updateSignalDecisionSummary(window._selectedSignal?.id); - updateSignalQueueSummary(); - } catch {} + if (!apiClient) return; + const res = await apiClient.getRefinement(); + updateApiState(res); + if (!res.ok || !res.data) return; + refinementInfo = res.data; + decisionIndex = new Map(); + const items = Array.isArray(refinementInfo.arbitration?.decision_items) ? refinementInfo.arbitration.decision_items : []; + items.forEach(item => { + if (item && item.id != null) decisionIndex.set(String(item.id), item); + }); + updateSignalDecisionSummary(window._selectedSignal?.id); + updateSignalQueueSummary(); + updateOperatorStatus(); +} + +async function loadTelemetryLive() { + if (!apiClient) return; + const res = await apiClient.getTelemetryLive(); + updateApiState(res); + if (!res.ok) { + telemetryLive = null; + updateOperatorStatus(); + return; + } + telemetryLive = res.data; + updateOperatorStatus(); +} + +function renderKvList(root, rows, emptyText) { + if (!root) return; + if (!rows || !rows.length) { + root.innerHTML = `
${emptyText}
`; + return; + } + root.innerHTML = rows.map(({ key, value }) => `
${key}
${value}
`).join(''); +} + +function renderPolicyLists() { + if (policySummaryList) { + if (!policyInfo) { + renderKvList(policySummaryList, [], 'Policy unavailable.'); + } else { + renderKvList(policySummaryList, [ + { key: 'Profile', value: policyInfo.profile || 'n/a' }, + { key: 'Mode', value: policyInfo.mode || 'n/a' }, + { key: 'Intent', value: policyInfo.intent || 'n/a' }, + { key: 'Surveillance', value: policyInfo.surveillance_strategy || 'n/a' }, + { key: 'Refinement', value: policyInfo.refinement_strategy || 'n/a' } + ], 'Policy unavailable.'); + } + } + if (policyRecommendationList) { + if (!policyRecommendations) { + renderKvList(policyRecommendationList, [], 'Recommendations unavailable.'); + } else { + renderKvList(policyRecommendationList, [ + { key: 'Monitor span', value: Number.isFinite(policyRecommendations.monitor_span_hz) ? fmtHz(policyRecommendations.monitor_span_hz) : 'n/a' }, + { key: 'Refine jobs', value: policyRecommendations.refinement_jobs ?? 'n/a' }, + { key: 'Detail FFT', value: policyRecommendations.refinement_detail_fft ?? 'n/a' }, + { key: 'Auto span', value: policyRecommendations.refinement_auto_span ?? 'n/a' }, + { key: 'Auto record', value: Array.isArray(policyRecommendations.auto_record_classes) && policyRecommendations.auto_record_classes.length ? policyRecommendations.auto_record_classes.join(', ') : 'n/a' }, + { key: 'Auto decode', value: Array.isArray(policyRecommendations.auto_decode_classes) && policyRecommendations.auto_decode_classes.length ? policyRecommendations.auto_decode_classes.join(', ') : 'n/a' } + ], 'Recommendations unavailable.'); + } + } +} + +async function loadPolicy() { + if (!apiClient) return; + const [policyRes, recRes] = await Promise.all([ + apiClient.getPolicy(), + apiClient.getRecommendations() + ]); + updateApiState(policyRes.ok ? policyRes : recRes); + policyInfo = policyRes.ok ? policyRes.data : null; + policyRecommendations = recRes.ok ? recRes.data : null; + renderPolicyLists(); } function formatLevelSummary(level) { @@ -989,17 +1147,14 @@ async function sendConfigUpdate() { if (!pendingConfigUpdate) return; const payload = pendingConfigUpdate; pendingConfigUpdate = null; - try { - const res = await fetch('/api/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!res.ok) throw new Error('apply'); - currentConfig = await res.json(); + if (!apiClient) return; + const res = await apiClient.postConfig(payload); + updateApiState(res); + if (res.ok && res.data) { + currentConfig = res.data; applyConfigToUI(currentConfig); setConfigStatus('Config applied'); - } catch { + } else { setConfigStatus('Config apply failed'); } } @@ -1008,17 +1163,14 @@ async function sendSettingsUpdate() { if (!pendingSettingsUpdate) return; const payload = pendingSettingsUpdate; pendingSettingsUpdate = null; - try { - const res = await fetch('/api/sdr/settings', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!res.ok) throw new Error('apply'); - currentConfig = await res.json(); + if (!apiClient) return; + const res = await apiClient.postSettings(payload); + updateApiState(res); + if (res.ok && res.data) { + currentConfig = res.data; applyConfigToUI(currentConfig); setConfigStatus('Settings applied'); - } catch { + } else { setConfigStatus('Settings apply failed'); } } @@ -1570,6 +1722,25 @@ function updateSignalDecisionSummary(id) { signalDecisionSummary.textContent = `Decision: ${flags}${reason}`; } +function setSelectedSignal(sel) { + window._selectedSignal = sel || null; + signalList.querySelectorAll('.signal-item').forEach((el) => { + const active = !!sel && ((sel.key && el.dataset.key === sel.key) || (!sel.key && sel.id && el.dataset.id === String(sel.id))); + el.classList.toggle('active', active); + }); + updateSignalDecisionSummary(window._selectedSignal?.id); + updateSignalQueueSummary(); +} + +function getSignalDomKey(s) { + if (s?.id != null && s.id !== '') return `id:${s.id}`; + const center = Math.round(Number(s?.center_hz || 0)); + const bw = Math.round(Number(s?.bw_hz || 0)); + const mode = getSignalPrimaryMode(s) || s?.class?.mod_type || 'UNK'; + const rds = s?.class?.pll?.rds_station || ''; + return `sig:${center}:${bw}:${mode}:${rds}`; +} + function _createSignalItem(s) { const btn = document.createElement('button'); btn.className = 'list-item signal-item'; @@ -1577,7 +1748,8 @@ function _createSignalItem(s) { btn.dataset.center = s.center_hz; btn.dataset.bw = s.bw_hz || 0; btn.dataset.class = s.class?.mod_type || ''; - btn.dataset.id = s.id || 0; + btn.dataset.id = s.id ?? ''; + btn.dataset.key = getSignalDomKey(s); const primaryMode = getSignalPrimaryMode(s); const runtimeInfo = getSignalRuntimeSummary(s); const mc = modColor(primaryMode); @@ -1632,6 +1804,8 @@ function _patchSignalItem(el, s) { el.dataset.center = s.center_hz; el.dataset.bw = s.bw_hz || 0; el.dataset.class = mod; + el.dataset.id = s.id ?? ''; + el.dataset.key = getSignalDomKey(s); el.style.borderLeftColor = mc.label; el.classList.toggle('listening', matchesListenTarget(s)); } @@ -1643,40 +1817,51 @@ function renderLists() { metricSignals.textContent = String(signals.length); const displaySigs = signals.slice(0, 24); - const wantIds = new Set(displaySigs.map(s => String(s.id || 0))); - - // Remove empty-state placeholder if signals exist - const emptyEl = signalList.querySelector('.empty-state'); - if (emptyEl && displaySigs.length > 0) emptyEl.remove(); - - // Remove DOM items whose signal ID is no longer present - signalList.querySelectorAll('.signal-item').forEach(el => { - if (!wantIds.has(el.dataset.id)) el.remove(); - }); + const strongest = displaySigs[0] || null; + const selectedSignal = window._selectedSignal || null; + if (signalSummaryLine) { + const strongestText = strongest + ? `${fmtMHz(strongest.center_hz, 4)} · ${(strongest.snr_db || 0).toFixed(1)} dB` + : '-'; + const selectedText = selectedSignal && Number.isFinite(selectedSignal.freq) + ? `${fmtMHz(selectedSignal.freq, 4)}${selectedSignal.mode ? ` · ${selectedSignal.mode}` : ''}` + : '-'; + signalSummaryLine.textContent = `Visible: ${displaySigs.length} · Strongest: ${strongestText} · Selected: ${selectedText}`; + } if (displaySigs.length === 0) { - if (!signalList.querySelector('.empty-state')) { - signalList.innerHTML = '
No live signals yet.
'; - } + signalList.innerHTML = '
No live signals yet.
'; } else { - // Build map of existing DOM items - const domById = new Map(); - signalList.querySelectorAll('.signal-item').forEach(el => domById.set(el.dataset.id, el)); - - displaySigs.forEach(s => { - const id = String(s.id || 0); - const existing = domById.get(id); - if (existing) { - _patchSignalItem(existing, s); + const existing = new Map(); + signalList.querySelectorAll('.signal-item').forEach((el) => existing.set(el.dataset.key, el)); + const frag = document.createDocumentFragment(); + + displaySigs.forEach((s) => { + const key = getSignalDomKey(s); + let el = existing.get(key); + if (el) { + _patchSignalItem(el, s); } else { - const el = _createSignalItem(s); - // Auto-select if it matches the user's last selection - if (window._selectedSignal && Math.abs(s.center_hz - window._selectedSignal.freq) < 50000) { - el.classList.add('active'); - } - signalList.appendChild(el); + el = _createSignalItem(s); + } + + if (window._selectedSignal) { + const selectedKey = window._selectedSignal.key; + const selectedId = window._selectedSignal.id; + const sameKey = selectedKey && key === selectedKey; + const sameId = !selectedKey && selectedId && s.id != null && String(s.id) === String(selectedId); + const nearFreq = !selectedKey && !selectedId && Number.isFinite(window._selectedSignal.freq) && Math.abs(s.center_hz - window._selectedSignal.freq) < 2500; + el.classList.toggle('active', !!(sameKey || sameId || nearFreq)); + } else { + el.classList.remove('active'); } + + frag.appendChild(el); + existing.delete(key); }); + + signalList.innerHTML = ''; + signalList.appendChild(frag); } const recent = [...events].sort((a, b) => b.end_ms - a.end_ms); @@ -1760,29 +1945,28 @@ function upsertEvents(list, replace = false) { } async function fetchEvents(initial) { - if (eventsFetchInFlight || timelineFrozen) return; + if (eventsFetchInFlight || timelineFrozen || !apiClient) return; eventsFetchInFlight = true; try { - let url = '/api/events?limit=1000'; - if (!initial && lastEventEndMs > 0) url = `/api/events?since=${lastEventEndMs - 1}`; - const res = await fetch(url); - if (!res.ok) return; - const data = await res.json(); - if (Array.isArray(data)) upsertEvents(data, initial); + const query = initial + ? { limit: 1000 } + : (lastEventEndMs > 0 ? { since: lastEventEndMs - 1 } : { limit: 200 }); + const res = await apiClient.getEvents(query); + updateApiState(res); + if (res.ok && Array.isArray(res.data)) upsertEvents(res.data, initial); } finally { eventsFetchInFlight = false; } } async function fetchRecordings() { - if (recordingsFetchInFlight || !recordingList) return; + if (recordingsFetchInFlight || !recordingList || !apiClient) return; recordingsFetchInFlight = true; try { - const res = await fetch('/api/recordings'); - if (!res.ok) return; - const data = await res.json(); - if (Array.isArray(data)) { - recordings = data; + const res = await apiClient.getRecordings(); + updateApiState(res); + if (res.ok && Array.isArray(res.data)) { + recordings = res.data; renderLists(); } } finally { @@ -1891,7 +2075,11 @@ function connect() { ws.binaryType = 'arraybuffer'; setWsBadge('Connecting', 'neutral'); - ws.onopen = () => setWsBadge('Live', 'ok'); + ws.onopen = () => { + setWsBadge('Live', 'ok'); + wsLastMessageTs = Date.now(); + updateOperatorStatus(); + }; ws.onmessage = (ev) => { if (ev.data instanceof ArrayBuffer) { try { @@ -1904,6 +2092,8 @@ function connect() { } else { latest = JSON.parse(ev.data); } + wsLastMessageTs = Date.now(); + updateOperatorStatus(); markSpectrumDirty(); if (followLive) pan = 0; updateHeroMetrics(); @@ -1911,6 +2101,7 @@ function connect() { }; ws.onclose = () => { setWsBadge('Retrying', 'bad'); + updateOperatorStatus(); wsReconnectTimer = setTimeout(connect, 1000); }; ws.onerror = () => ws.close(); @@ -2003,11 +2194,12 @@ function handleSpectrumClick(ev) { const bw = sig.bw_hz || 12000; const mode = sig.class?.mod_type || ''; startLiveListen(freq, bw, mode); - window._selectedSignal = { freq, bw, mode }; - // Update selected signal in list - signalList.querySelectorAll('.signal-item').forEach(el => { - const elFreq = parseFloat(el.dataset.center || '0'); - el.classList.toggle('active', Math.abs(elFreq - freq) < Math.max(500, bw * 0.5)); + setSelectedSignal({ + key: getSignalDomKey(sig), + id: sig.id ?? null, + freq, + bw, + mode }); return; } @@ -2299,13 +2491,26 @@ presetButtons.forEach((btn) => { }); }); -railTabs.forEach((tab) => { - tab.addEventListener('click', () => { - railTabs.forEach(t => t.classList.toggle('active', t === tab)); - tabPanels.forEach(panel => panel.classList.toggle('active', panel.dataset.panel === tab.dataset.tab)); +function activateRailTab(tabName) { + railTabs.forEach((t) => { + const active = t.dataset.tab === tabName; + t.classList.toggle('active', active); + t.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + tabPanels.forEach((panel) => { + const active = panel.dataset.panel === tabName; + panel.classList.toggle('active', active); + panel.hidden = !active; + panel.setAttribute('aria-hidden', active ? 'false' : 'true'); }); +} + +railTabs.forEach((tab) => { + tab.addEventListener('click', () => activateRailTab(tab.dataset.tab)); }); +activateRailTab((railTabs.find((t) => t.classList.contains('active')) || railTabs[0])?.dataset.tab || 'radio'); + drawerCloseBtn.addEventListener('click', closeDrawer); exportEventBtn.addEventListener('click', exportSelectedEvent); @@ -2337,13 +2542,17 @@ if (decodeEventBtn) { return; } const mode = decodeModeSelect?.value || ev.class?.mod_type || 'FT8'; - const res = await fetch(`/api/recordings/${rec.id}/decode?mode=${mode}`); - if (!res.ok) { + if (!apiClient) { decodeResultEl.textContent = 'Decode: failed'; return; } - const data = await res.json(); - decodeResultEl.textContent = `Decode: ${String(data.stdout || '').slice(0, 80)}`; + const res = await apiClient.decodeRecording(rec.id, mode); + updateApiState(res); + if (!res.ok || !res.data) { + decodeResultEl.textContent = 'Decode: failed'; + return; + } + decodeResultEl.textContent = `Decode: ${String(res.data.stdout || '').slice(0, 80)}`; }); } jumpToEventBtn.addEventListener('click', () => { @@ -2369,18 +2578,13 @@ signalList.addEventListener('click', (ev) => { const target = ev.target.closest('.signal-item'); if (!target) return; // Select this signal for live listening — don't retune the SDR - const allItems = signalList.querySelectorAll('.signal-item'); - allItems.forEach(el => el.classList.remove('active')); - target.classList.add('active'); - // Store selected signal data for Live Listen button - window._selectedSignal = { + setSelectedSignal({ + key: target.dataset.key || null, id: target.dataset.id || null, freq: parseFloat(target.dataset.center), bw: parseFloat(target.dataset.bw || '12000'), mode: target.dataset.class || '' - }; - updateSignalDecisionSummary(window._selectedSignal.id); - updateSignalQueueSummary(); + }); }); if (liveListenBtn) { @@ -2431,9 +2635,11 @@ if (recordingList) { recordingAudioLink.href = `/api/recordings/${id}/audio`; } try { - const res = await fetch(`/api/recordings/${id}`); - if (!res.ok) return; - const meta = await res.json(); + if (!apiClient) return; + const res = await apiClient.getRecording(id); + updateApiState(res); + if (!res.ok || !res.data) return; + const meta = res.data; if (decodeResultEl) { const rds = meta.rds_ps ? `RDS: ${meta.rds_ps}` : ''; decodeResultEl.textContent = `Decode: ${rds}`; @@ -2486,11 +2692,14 @@ window.addEventListener('keydown', (ev) => { } }); +updateOperatorStatus(); loadConfig(); resetLiveListenMeta(); loadStats(); loadGPU(); loadRefinement(); +loadTelemetryLive(); +loadPolicy(); fetchEvents(true); fetchRecordings(); loadDecoders(); @@ -2499,6 +2708,8 @@ requestAnimationFrame(renderLoop); setInterval(loadStats, 1000); setInterval(loadGPU, 1000); setInterval(loadRefinement, 1500); +setInterval(loadTelemetryLive, 3000); +setInterval(loadPolicy, 10000); setInterval(() => fetchEvents(false), 2000); setInterval(fetchRecordings, 5000); setInterval(loadSignals, 1500); @@ -2508,3 +2719,4 @@ setInterval(loadDecoders, 10000); + diff --git a/web/index.html b/web/index.html index 6cc4546..adf642a 100644 --- a/web/index.html +++ b/web/index.html @@ -103,6 +103,10 @@
+ + + + @@ -174,8 +178,15 @@
+ + + + +
+
Classifier
+
Signal classification behavior and switching thresholds.
@@ -218,9 +230,15 @@
+ +
+ +
+
Recorder
+
Recording enablement, media types, decoding, and retention limits.
@@ -232,9 +250,15 @@
+
+
+ +
+
Resources
+
Concurrency and job-budget controls for refinement, recording, and decode.
@@ -246,6 +270,7 @@
Refinement Windows
+
Window sizing for refinement passes when candidate bandwidth is known or inferred.