| @@ -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 | |||||
| @@ -4,7 +4,7 @@ bands: | |||||
| end_hz: 1.08e+08 | end_hz: 1.08e+08 | ||||
| center_hz: 1.02e+08 | center_hz: 1.02e+08 | ||||
| sample_rate: 4096000 | sample_rate: 4096000 | ||||
| fft_size: 512 | |||||
| fft_size: 4096 | |||||
| gain_db: 32 | gain_db: 32 | ||||
| tuner_bw_khz: 5000 | tuner_bw_khz: 5000 | ||||
| use_gpu_fft: true | use_gpu_fft: true | ||||
| @@ -51,7 +51,7 @@ pipeline: | |||||
| - WFM_STEREO | - WFM_STEREO | ||||
| - RDS | - RDS | ||||
| surveillance: | surveillance: | ||||
| analysis_fft_size: 512 | |||||
| analysis_fft_size: 4096 | |||||
| frame_rate: 12 | frame_rate: 12 | ||||
| strategy: multi-resolution | strategy: multi-resolution | ||||
| display_bins: 2048 | display_bins: 2048 | ||||
| @@ -267,9 +267,9 @@ profiles: | |||||
| decision_hold_ms: 2500 | decision_hold_ms: 2500 | ||||
| detector: | detector: | ||||
| threshold_db: -60 | threshold_db: -60 | ||||
| min_duration_ms: 500 | |||||
| min_duration_ms: 5000 | |||||
| hold_ms: 1500 | hold_ms: 1500 | ||||
| ema_alpha: 0.025 | |||||
| ema_alpha: 0.35 | |||||
| hysteresis_db: 10 | hysteresis_db: 10 | ||||
| min_stable_frames: 4 | min_stable_frames: 4 | ||||
| gap_tolerance_ms: 2000 | gap_tolerance_ms: 2000 | ||||
| @@ -279,7 +279,7 @@ detector: | |||||
| cfar_guard_cells: 3 | cfar_guard_cells: 3 | ||||
| cfar_train_cells: 24 | cfar_train_cells: 24 | ||||
| cfar_rank: 36 | cfar_rank: 36 | ||||
| cfar_scale_db: 23 | |||||
| cfar_scale_db: 27 | |||||
| cfar_wrap_around: true | cfar_wrap_around: true | ||||
| edge_margin_db: 6 | edge_margin_db: 6 | ||||
| max_signal_bw_hz: 260000 | max_signal_bw_hz: 260000 | ||||
| @@ -295,10 +295,12 @@ recorder: | |||||
| record_iq: false | record_iq: false | ||||
| record_audio: true | record_audio: true | ||||
| auto_demod: true | auto_demod: true | ||||
| auto_decode: false | |||||
| auto_decode: true | |||||
| max_disk_mb: 0 | max_disk_mb: 0 | ||||
| output_dir: data/recordings | output_dir: data/recordings | ||||
| class_filter: [] | |||||
| class_filter: | |||||
| - WFM | |||||
| - WFM_STEREO | |||||
| ring_seconds: 12 | ring_seconds: 12 | ||||
| deemphasis_us: 50 | deemphasis_us: 50 | ||||
| extraction_fir_taps: 101 | extraction_fir_taps: 101 | ||||
| @@ -315,14 +317,14 @@ debug: | |||||
| audio_dump_enabled: false | audio_dump_enabled: false | ||||
| cpu_monitoring: false | cpu_monitoring: false | ||||
| telemetry: | telemetry: | ||||
| enabled: true | |||||
| enabled: false | |||||
| heavy_enabled: false | heavy_enabled: false | ||||
| heavy_sample_every: 12 | heavy_sample_every: 12 | ||||
| metric_sample_every: 8 | metric_sample_every: 8 | ||||
| metric_history_max: 6000 | metric_history_max: 6000 | ||||
| event_history_max: 1500 | event_history_max: 1500 | ||||
| retention_seconds: 900 | retention_seconds: 900 | ||||
| persist_enabled: false | |||||
| persist_enabled: true | |||||
| persist_dir: debug/telemetry | persist_dir: debug/telemetry | ||||
| rotate_mb: 16 | rotate_mb: 16 | ||||
| keep_files: 8 | keep_files: 8 | ||||
| @@ -224,15 +224,15 @@ detector: | |||||
| class_history_size: 10 | class_history_size: 10 | ||||
| class_switch_ratio: 0.6 | class_switch_ratio: 0.6 | ||||
| recorder: | recorder: | ||||
| enabled: true | |||||
| enabled: false | |||||
| min_snr_db: 0 | min_snr_db: 0 | ||||
| min_duration: 500ms | min_duration: 500ms | ||||
| max_duration: 300s | max_duration: 300s | ||||
| preroll_ms: 500 | preroll_ms: 500 | ||||
| record_iq: false | 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 | max_disk_mb: 0 | ||||
| output_dir: data/recordings | output_dir: data/recordings | ||||
| class_filter: ["WFM", "WFM_STEREO"] | class_filter: ["WFM", "WFM_STEREO"] | ||||
| @@ -252,14 +252,14 @@ debug: | |||||
| audio_dump_enabled: false | audio_dump_enabled: false | ||||
| cpu_monitoring: false | cpu_monitoring: false | ||||
| telemetry: | telemetry: | ||||
| enabled: true | |||||
| enabled: false | |||||
| heavy_enabled: false | heavy_enabled: false | ||||
| heavy_sample_every: 12 | heavy_sample_every: 12 | ||||
| metric_sample_every: 8 | metric_sample_every: 8 | ||||
| metric_history_max: 6000 | metric_history_max: 6000 | ||||
| event_history_max: 1500 | event_history_max: 1500 | ||||
| retention_seconds: 900 | retention_seconds: 900 | ||||
| persist_enabled: true | |||||
| persist_enabled: false | |||||
| persist_dir: debug/telemetry | persist_dir: debug/telemetry | ||||
| rotate_mb: 16 | rotate_mb: 16 | ||||
| keep_files: 8 | keep_files: 8 | ||||
| @@ -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='') | |||||
| @@ -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); | |||||
| @@ -69,14 +69,17 @@ const refineMaxSpan = qs('refineMaxSpan'); | |||||
| const resMaxRefine = qs('resMaxRefine'); | const resMaxRefine = qs('resMaxRefine'); | ||||
| const resMaxRecord = qs('resMaxRecord'); | const resMaxRecord = qs('resMaxRecord'); | ||||
| const resMaxDecode = qs('resMaxDecode'); | const resMaxDecode = qs('resMaxDecode'); | ||||
| const resDecisionHold = qs('resDecisionHold'); | |||||
| const signalList = qs('signalList'); | const signalList = qs('signalList'); | ||||
| const signalDecisionSummary = qs('signalDecisionSummary'); | const signalDecisionSummary = qs('signalDecisionSummary'); | ||||
| const signalQueueSummary = qs('signalQueueSummary'); | |||||
| const eventList = qs('eventList'); | const eventList = qs('eventList'); | ||||
| const recordingList = qs('recordingList'); | const recordingList = qs('recordingList'); | ||||
| const signalCountBadge = qs('signalCountBadge'); | const signalCountBadge = qs('signalCountBadge'); | ||||
| const eventCountBadge = qs('eventCountBadge'); | const eventCountBadge = qs('eventCountBadge'); | ||||
| const recordingCountBadge = qs('recordingCountBadge'); | const recordingCountBadge = qs('recordingCountBadge'); | ||||
| const signalSummaryLine = qs('signalSummaryLine'); | |||||
| const healthBuffer = qs('healthBuffer'); | const healthBuffer = qs('healthBuffer'); | ||||
| const healthDropped = qs('healthDropped'); | const healthDropped = qs('healthDropped'); | ||||
| @@ -86,6 +89,16 @@ const healthGpu = qs('healthGpu'); | |||||
| const healthFps = qs('healthFps'); | const healthFps = qs('healthFps'); | ||||
| const healthRefinePlan = qs('healthRefinePlan'); | const healthRefinePlan = qs('healthRefinePlan'); | ||||
| const healthRefineWindows = qs('healthRefineWindows'); | 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 drawerEl = qs('eventDrawer'); | ||||
| const drawerCloseBtn = qs('drawerClose'); | 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 stats = { buffer_samples: 0, dropped: 0, resets: 0, last_sample_ago_ms: -1 }; | ||||
| let refinementInfo = {}; | let refinementInfo = {}; | ||||
| let decisionIndex = new Map(); | 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 | // LiveListenWS — WebSocket-based gapless audio streaming via /ws/audio | ||||
| @@ -578,6 +612,7 @@ function setConfigStatus(text) { | |||||
| } | } | ||||
| function setWsBadge(text, kind = 'neutral') { | function setWsBadge(text, kind = 'neutral') { | ||||
| wsState = kind === 'ok' ? 'live' : (kind === 'bad' ? 'retrying' : 'connecting'); | |||||
| wsBadge.textContent = text; | wsBadge.textContent = text; | ||||
| wsBadge.style.borderColor = kind === 'ok' | wsBadge.style.borderColor = kind === 'ok' | ||||
| ? 'rgba(124, 251, 131, 0.35)' | ? 'rgba(124, 251, 131, 0.35)' | ||||
| @@ -586,6 +621,68 @@ function setWsBadge(text, kind = 'neutral') { | |||||
| : 'rgba(112, 150, 207, 0.18)'; | : '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 toMHz(hz) { return hz / 1e6; } | ||||
| function fromMHz(mhz) { return mhz * 1e6; } | function fromMHz(mhz) { return mhz * 1e6; } | ||||
| function fmtMHz(hz, digits = 3) { return `${(hz / 1e6).toFixed(digits)} MHz`; } | function fmtMHz(hz, digits = 3) { return `${(hz / 1e6).toFixed(digits)} MHz`; } | ||||
| @@ -883,78 +980,139 @@ function applyConfigToUI(cfg) { | |||||
| } | } | ||||
| async function loadConfig() { | 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); | applyConfigToUI(currentConfig); | ||||
| setConfigStatus('Config synced'); | setConfigStatus('Config synced'); | ||||
| } catch { | |||||
| } else { | |||||
| setConfigStatus('Config offline'); | setConfigStatus('Config offline'); | ||||
| } | } | ||||
| updateOperatorStatus(); | |||||
| } | } | ||||
| async function loadSignals() { | 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() { | 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() { | 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() { | 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() { | 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 = `<div class="ops-line ops-line--muted">${emptyText}</div>`; | |||||
| return; | |||||
| } | |||||
| root.innerHTML = rows.map(({ key, value }) => `<div class="kv-row"><div class="kv-key">${key}</div><div class="kv-val">${value}</div></div>`).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) { | function formatLevelSummary(level) { | ||||
| @@ -989,17 +1147,14 @@ async function sendConfigUpdate() { | |||||
| if (!pendingConfigUpdate) return; | if (!pendingConfigUpdate) return; | ||||
| const payload = pendingConfigUpdate; | const payload = pendingConfigUpdate; | ||||
| pendingConfigUpdate = null; | 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); | applyConfigToUI(currentConfig); | ||||
| setConfigStatus('Config applied'); | setConfigStatus('Config applied'); | ||||
| } catch { | |||||
| } else { | |||||
| setConfigStatus('Config apply failed'); | setConfigStatus('Config apply failed'); | ||||
| } | } | ||||
| } | } | ||||
| @@ -1008,17 +1163,14 @@ async function sendSettingsUpdate() { | |||||
| if (!pendingSettingsUpdate) return; | if (!pendingSettingsUpdate) return; | ||||
| const payload = pendingSettingsUpdate; | const payload = pendingSettingsUpdate; | ||||
| pendingSettingsUpdate = null; | 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); | applyConfigToUI(currentConfig); | ||||
| setConfigStatus('Settings applied'); | setConfigStatus('Settings applied'); | ||||
| } catch { | |||||
| } else { | |||||
| setConfigStatus('Settings apply failed'); | setConfigStatus('Settings apply failed'); | ||||
| } | } | ||||
| } | } | ||||
| @@ -1570,6 +1722,25 @@ function updateSignalDecisionSummary(id) { | |||||
| signalDecisionSummary.textContent = `Decision: ${flags}${reason}`; | 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) { | function _createSignalItem(s) { | ||||
| const btn = document.createElement('button'); | const btn = document.createElement('button'); | ||||
| btn.className = 'list-item signal-item'; | btn.className = 'list-item signal-item'; | ||||
| @@ -1577,7 +1748,8 @@ function _createSignalItem(s) { | |||||
| btn.dataset.center = s.center_hz; | btn.dataset.center = s.center_hz; | ||||
| btn.dataset.bw = s.bw_hz || 0; | btn.dataset.bw = s.bw_hz || 0; | ||||
| btn.dataset.class = s.class?.mod_type || ''; | 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 primaryMode = getSignalPrimaryMode(s); | ||||
| const runtimeInfo = getSignalRuntimeSummary(s); | const runtimeInfo = getSignalRuntimeSummary(s); | ||||
| const mc = modColor(primaryMode); | const mc = modColor(primaryMode); | ||||
| @@ -1632,6 +1804,8 @@ function _patchSignalItem(el, s) { | |||||
| el.dataset.center = s.center_hz; | el.dataset.center = s.center_hz; | ||||
| el.dataset.bw = s.bw_hz || 0; | el.dataset.bw = s.bw_hz || 0; | ||||
| el.dataset.class = mod; | el.dataset.class = mod; | ||||
| el.dataset.id = s.id ?? ''; | |||||
| el.dataset.key = getSignalDomKey(s); | |||||
| el.style.borderLeftColor = mc.label; | el.style.borderLeftColor = mc.label; | ||||
| el.classList.toggle('listening', matchesListenTarget(s)); | el.classList.toggle('listening', matchesListenTarget(s)); | ||||
| } | } | ||||
| @@ -1643,40 +1817,51 @@ function renderLists() { | |||||
| metricSignals.textContent = String(signals.length); | metricSignals.textContent = String(signals.length); | ||||
| const displaySigs = signals.slice(0, 24); | 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 (displaySigs.length === 0) { | ||||
| if (!signalList.querySelector('.empty-state')) { | |||||
| signalList.innerHTML = '<div class="empty-state">No live signals yet.</div>'; | |||||
| } | |||||
| signalList.innerHTML = '<div class="empty-state">No live signals yet.</div>'; | |||||
| } else { | } 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 { | } 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); | 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) { | async function fetchEvents(initial) { | ||||
| if (eventsFetchInFlight || timelineFrozen) return; | |||||
| if (eventsFetchInFlight || timelineFrozen || !apiClient) return; | |||||
| eventsFetchInFlight = true; | eventsFetchInFlight = true; | ||||
| try { | 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 { | } finally { | ||||
| eventsFetchInFlight = false; | eventsFetchInFlight = false; | ||||
| } | } | ||||
| } | } | ||||
| async function fetchRecordings() { | async function fetchRecordings() { | ||||
| if (recordingsFetchInFlight || !recordingList) return; | |||||
| if (recordingsFetchInFlight || !recordingList || !apiClient) return; | |||||
| recordingsFetchInFlight = true; | recordingsFetchInFlight = true; | ||||
| try { | 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(); | renderLists(); | ||||
| } | } | ||||
| } finally { | } finally { | ||||
| @@ -1891,7 +2075,11 @@ function connect() { | |||||
| ws.binaryType = 'arraybuffer'; | ws.binaryType = 'arraybuffer'; | ||||
| setWsBadge('Connecting', 'neutral'); | setWsBadge('Connecting', 'neutral'); | ||||
| ws.onopen = () => setWsBadge('Live', 'ok'); | |||||
| ws.onopen = () => { | |||||
| setWsBadge('Live', 'ok'); | |||||
| wsLastMessageTs = Date.now(); | |||||
| updateOperatorStatus(); | |||||
| }; | |||||
| ws.onmessage = (ev) => { | ws.onmessage = (ev) => { | ||||
| if (ev.data instanceof ArrayBuffer) { | if (ev.data instanceof ArrayBuffer) { | ||||
| try { | try { | ||||
| @@ -1904,6 +2092,8 @@ function connect() { | |||||
| } else { | } else { | ||||
| latest = JSON.parse(ev.data); | latest = JSON.parse(ev.data); | ||||
| } | } | ||||
| wsLastMessageTs = Date.now(); | |||||
| updateOperatorStatus(); | |||||
| markSpectrumDirty(); | markSpectrumDirty(); | ||||
| if (followLive) pan = 0; | if (followLive) pan = 0; | ||||
| updateHeroMetrics(); | updateHeroMetrics(); | ||||
| @@ -1911,6 +2101,7 @@ function connect() { | |||||
| }; | }; | ||||
| ws.onclose = () => { | ws.onclose = () => { | ||||
| setWsBadge('Retrying', 'bad'); | setWsBadge('Retrying', 'bad'); | ||||
| updateOperatorStatus(); | |||||
| wsReconnectTimer = setTimeout(connect, 1000); | wsReconnectTimer = setTimeout(connect, 1000); | ||||
| }; | }; | ||||
| ws.onerror = () => ws.close(); | ws.onerror = () => ws.close(); | ||||
| @@ -2003,11 +2194,12 @@ function handleSpectrumClick(ev) { | |||||
| const bw = sig.bw_hz || 12000; | const bw = sig.bw_hz || 12000; | ||||
| const mode = sig.class?.mod_type || ''; | const mode = sig.class?.mod_type || ''; | ||||
| startLiveListen(freq, bw, mode); | 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; | 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); | drawerCloseBtn.addEventListener('click', closeDrawer); | ||||
| exportEventBtn.addEventListener('click', exportSelectedEvent); | exportEventBtn.addEventListener('click', exportSelectedEvent); | ||||
| @@ -2337,13 +2542,17 @@ if (decodeEventBtn) { | |||||
| return; | return; | ||||
| } | } | ||||
| const mode = decodeModeSelect?.value || ev.class?.mod_type || 'FT8'; | 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'; | decodeResultEl.textContent = 'Decode: failed'; | ||||
| return; | 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', () => { | jumpToEventBtn.addEventListener('click', () => { | ||||
| @@ -2369,18 +2578,13 @@ signalList.addEventListener('click', (ev) => { | |||||
| const target = ev.target.closest('.signal-item'); | const target = ev.target.closest('.signal-item'); | ||||
| if (!target) return; | if (!target) return; | ||||
| // Select this signal for live listening — don't retune the SDR | // 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, | id: target.dataset.id || null, | ||||
| freq: parseFloat(target.dataset.center), | freq: parseFloat(target.dataset.center), | ||||
| bw: parseFloat(target.dataset.bw || '12000'), | bw: parseFloat(target.dataset.bw || '12000'), | ||||
| mode: target.dataset.class || '' | mode: target.dataset.class || '' | ||||
| }; | |||||
| updateSignalDecisionSummary(window._selectedSignal.id); | |||||
| updateSignalQueueSummary(); | |||||
| }); | |||||
| }); | }); | ||||
| if (liveListenBtn) { | if (liveListenBtn) { | ||||
| @@ -2431,9 +2635,11 @@ if (recordingList) { | |||||
| recordingAudioLink.href = `/api/recordings/${id}/audio`; | recordingAudioLink.href = `/api/recordings/${id}/audio`; | ||||
| } | } | ||||
| try { | 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) { | if (decodeResultEl) { | ||||
| const rds = meta.rds_ps ? `RDS: ${meta.rds_ps}` : ''; | const rds = meta.rds_ps ? `RDS: ${meta.rds_ps}` : ''; | ||||
| decodeResultEl.textContent = `Decode: ${rds}`; | decodeResultEl.textContent = `Decode: ${rds}`; | ||||
| @@ -2486,11 +2692,14 @@ window.addEventListener('keydown', (ev) => { | |||||
| } | } | ||||
| }); | }); | ||||
| updateOperatorStatus(); | |||||
| loadConfig(); | loadConfig(); | ||||
| resetLiveListenMeta(); | resetLiveListenMeta(); | ||||
| loadStats(); | loadStats(); | ||||
| loadGPU(); | loadGPU(); | ||||
| loadRefinement(); | loadRefinement(); | ||||
| loadTelemetryLive(); | |||||
| loadPolicy(); | |||||
| fetchEvents(true); | fetchEvents(true); | ||||
| fetchRecordings(); | fetchRecordings(); | ||||
| loadDecoders(); | loadDecoders(); | ||||
| @@ -2499,6 +2708,8 @@ requestAnimationFrame(renderLoop); | |||||
| setInterval(loadStats, 1000); | setInterval(loadStats, 1000); | ||||
| setInterval(loadGPU, 1000); | setInterval(loadGPU, 1000); | ||||
| setInterval(loadRefinement, 1500); | setInterval(loadRefinement, 1500); | ||||
| setInterval(loadTelemetryLive, 3000); | |||||
| setInterval(loadPolicy, 10000); | |||||
| setInterval(() => fetchEvents(false), 2000); | setInterval(() => fetchEvents(false), 2000); | ||||
| setInterval(fetchRecordings, 5000); | setInterval(fetchRecordings, 5000); | ||||
| setInterval(loadSignals, 1500); | setInterval(loadSignals, 1500); | ||||
| @@ -2508,3 +2719,4 @@ setInterval(loadDecoders, 10000); | |||||
| @@ -103,6 +103,10 @@ | |||||
| <div class="rail-tabs" role="tablist" aria-label="Control tabs"> | <div class="rail-tabs" role="tablist" aria-label="Control tabs"> | ||||
| <button class="rail-tab active" data-tab="radio" type="button">Radio</button> | <button class="rail-tab active" data-tab="radio" type="button">Radio</button> | ||||
| <button class="rail-tab" data-tab="detect" type="button">Detect</button> | |||||
| <button class="rail-tab" data-tab="record" type="button">Record</button> | |||||
| <button class="rail-tab" data-tab="refine" type="button">Refine</button> | |||||
| <button class="rail-tab" data-tab="policy" type="button">Policy</button> | |||||
| <button class="rail-tab" data-tab="signals" type="button">Signals</button> | <button class="rail-tab" data-tab="signals" type="button">Signals</button> | ||||
| <button class="rail-tab" data-tab="events" type="button">Events</button> | <button class="rail-tab" data-tab="events" type="button">Events</button> | ||||
| <button class="rail-tab" data-tab="health" type="button">Health</button> | <button class="rail-tab" data-tab="health" type="button">Health</button> | ||||
| @@ -174,8 +178,15 @@ | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </section> | |||||
| <!-- ── Detect Tab ── --> | |||||
| <section class="tab-panel" data-panel="detect"> | |||||
| <div class="config-grid"> | |||||
| <div class="form-group"> | <div class="form-group"> | ||||
| <div class="grp-title">Classifier</div> | <div class="grp-title">Classifier</div> | ||||
| <div class="form-hint">Signal classification behavior and switching thresholds.</div> | |||||
| <label class="field"><span>Classifier</span> | <label class="field"><span>Classifier</span> | ||||
| <select id="classifierModeSelect"> | <select id="classifierModeSelect"> | ||||
| <option value="rule">Rule-Based</option> | <option value="rule">Rule-Based</option> | ||||
| @@ -187,6 +198,7 @@ | |||||
| <div class="form-group"> | <div class="form-group"> | ||||
| <div class="grp-title">Detector</div> | <div class="grp-title">Detector</div> | ||||
| <div class="form-hint">Thresholding, CFAR, and temporal stability for live carrier detection.</div> | |||||
| <div class="slider-field"> | <div class="slider-field"> | ||||
| <span>Threshold</span> | <span>Threshold</span> | ||||
| <div class="slider-row"><input id="thresholdRange" type="range" min="-120" max="0" step="1" class="range--warn" /><input id="thresholdInput" type="number" min="-120" max="0" step="1" class="slider-num" /><em>dB</em></div> | <div class="slider-row"><input id="thresholdRange" type="range" min="-120" max="0" step="1" class="range--warn" /><input id="thresholdInput" type="number" min="-120" max="0" step="1" class="slider-num" /><em>dB</em></div> | ||||
| @@ -218,9 +230,15 @@ | |||||
| <label class="pill-toggle"><input id="cfarWrapToggle" type="checkbox" checked /><span class="pt"><span class="pk"></span></span><span class="pl">Wrap-Around</span></label> | <label class="pill-toggle"><input id="cfarWrapToggle" type="checkbox" checked /><span class="pt"><span class="pk"></span></span><span class="pl">Wrap-Around</span></label> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </div> | |||||
| </section> | |||||
| <!-- ── Record Tab ── --> | |||||
| <section class="tab-panel" data-panel="record"> | |||||
| <div class="config-grid"> | |||||
| <div class="form-group"> | <div class="form-group"> | ||||
| <div class="grp-title">Recorder</div> | <div class="grp-title">Recorder</div> | ||||
| <div class="form-hint">Recording enablement, media types, decoding, and retention limits.</div> | |||||
| <div class="toggle-grid"> | <div class="toggle-grid"> | ||||
| <label class="pill-toggle"><input id="recEnableToggle" type="checkbox" /><span class="pt"><span class="pk"></span></span><span class="pl">Enabled</span></label> | <label class="pill-toggle"><input id="recEnableToggle" type="checkbox" /><span class="pt"><span class="pk"></span></span><span class="pl">Enabled</span></label> | ||||
| <label class="pill-toggle"><input id="recIQToggle" type="checkbox" /><span class="pt"><span class="pk"></span></span><span class="pl">Record IQ</span></label> | <label class="pill-toggle"><input id="recIQToggle" type="checkbox" /><span class="pt"><span class="pk"></span></span><span class="pl">Record IQ</span></label> | ||||
| @@ -232,9 +250,15 @@ | |||||
| <label class="field"><span>Max Disk (MB)</span><input id="recMaxDisk" type="number" step="256" min="0" /></label> | <label class="field"><span>Max Disk (MB)</span><input id="recMaxDisk" type="number" step="256" min="0" /></label> | ||||
| <label class="field"><span>Class Filter (CSV)</span><input id="recClassFilter" type="text" placeholder="e.g. NFM,USB" /></label> | <label class="field"><span>Class Filter (CSV)</span><input id="recClassFilter" type="text" placeholder="e.g. NFM,USB" /></label> | ||||
| </div> | </div> | ||||
| </div> | |||||
| </section> | |||||
| <!-- ── Refine Tab ── --> | |||||
| <section class="tab-panel" data-panel="refine"> | |||||
| <div class="config-grid"> | |||||
| <div class="form-group"> | <div class="form-group"> | ||||
| <div class="grp-title">Resources</div> | <div class="grp-title">Resources</div> | ||||
| <div class="form-hint">Concurrency and job-budget controls for refinement, recording, and decode.</div> | |||||
| <div class="field-pair"> | <div class="field-pair"> | ||||
| <label class="field"><span>Max Refinement</span><input id="resMaxRefine" type="number" step="1" min="0" /></label> | <label class="field"><span>Max Refinement</span><input id="resMaxRefine" type="number" step="1" min="0" /></label> | ||||
| <label class="field"><span>Max Record</span><input id="resMaxRecord" type="number" step="1" min="0" /></label> | <label class="field"><span>Max Record</span><input id="resMaxRecord" type="number" step="1" min="0" /></label> | ||||
| @@ -246,6 +270,7 @@ | |||||
| <div class="form-group"> | <div class="form-group"> | ||||
| <div class="grp-title">Refinement Windows</div> | <div class="grp-title">Refinement Windows</div> | ||||
| <div class="form-hint">Window sizing for refinement passes when candidate bandwidth is known or inferred.</div> | |||||
| <label class="field"><span>Auto Span</span> | <label class="field"><span>Auto Span</span> | ||||
| <select id="refineAutoSpan" class="ctrl-select"> | <select id="refineAutoSpan" class="ctrl-select"> | ||||
| <option value="true">Auto</option> | <option value="true">Auto</option> | ||||
| @@ -258,12 +283,26 @@ | |||||
| </div> | </div> | ||||
| <div class="panel-sub">Auto span uses modulation hints when candidate BW is missing. Min/Max clamp the final window.</div> | <div class="panel-sub">Auto span uses modulation hints when candidate BW is missing. Min/Max clamp the final window.</div> | ||||
| </div> | </div> | ||||
| </div> | |||||
| </section> | |||||
| <!-- ── Policy Tab ── --> | |||||
| <section class="tab-panel" data-panel="policy"> | |||||
| <div class="form-group"> | |||||
| <div class="grp-title">Pipeline Policy</div> | |||||
| <div class="ops-list" id="policySummaryList"><div class="ops-line ops-line--muted">Loading policy…</div></div> | |||||
| </div> | |||||
| <div class="form-group"> | |||||
| <div class="grp-title">Recommendations</div> | |||||
| <div class="ops-list" id="policyRecommendationList"><div class="ops-line ops-line--muted">Loading recommendations…</div></div> | |||||
| </div> | |||||
| </section> | </section> | ||||
| <!-- ── Signals Tab ── --> | <!-- ── Signals Tab ── --> | ||||
| <section class="tab-panel" data-panel="signals"> | <section class="tab-panel" data-panel="signals"> | ||||
| <div class="list-head"><span class="grp-title">Detected carriers</span><span class="count-pill" id="signalCountBadge">0 live</span></div> | <div class="list-head"><span class="grp-title">Detected carriers</span><span class="count-pill" id="signalCountBadge">0 live</span></div> | ||||
| <div class="signal-list" id="signalList"><div class="empty-state">No live signals yet.</div></div> | <div class="signal-list" id="signalList"><div class="empty-state">No live signals yet.</div></div> | ||||
| <div class="panel-sub" id="signalSummaryLine">Visible: 0 · Strongest: - · Selected: -</div> | |||||
| <div class="panel-sub" id="signalDecisionSummary">Decision: -</div> | <div class="panel-sub" id="signalDecisionSummary">Decision: -</div> | ||||
| <div class="panel-sub" id="signalQueueSummary">Queue: -</div> | <div class="panel-sub" id="signalQueueSummary">Queue: -</div> | ||||
| <div class="field-pair"> | <div class="field-pair"> | ||||
| @@ -316,6 +355,25 @@ | |||||
| <div class="health-card health-card--wide"><span class="health-lbl">GPU status</span><span class="health-val" id="healthGpu">-</span></div> | <div class="health-card health-card--wide"><span class="health-lbl">GPU status</span><span class="health-val" id="healthGpu">-</span></div> | ||||
| <div class="health-card health-card--wide"><span class="health-lbl">Render rate</span><span class="health-val" id="healthFps">-</span></div> | <div class="health-card health-card--wide"><span class="health-lbl">Render rate</span><span class="health-val" id="healthFps">-</span></div> | ||||
| </div> | </div> | ||||
| <div class="health-status-card"> | |||||
| <div class="health-lbl">Operator Status</div> | |||||
| <div class="status-grid"> | |||||
| <div class="status-row"><span class="status-key">WS</span><span class="status-val" id="healthWs">-</span></div> | |||||
| <div class="status-row"><span class="status-key">API</span><span class="status-val" id="healthApi">-</span></div> | |||||
| <div class="status-row"><span class="status-key">Config</span><span class="status-val" id="healthConfig">-</span></div> | |||||
| <div class="status-row"><span class="status-key">Source</span><span class="status-val" id="healthSource">-</span></div> | |||||
| <div class="status-row"><span class="status-key">Refinement</span><span class="status-val" id="healthRefine">-</span></div> | |||||
| <div class="status-row"><span class="status-key">Telemetry</span><span class="status-val" id="healthTelemetry">-</span></div> | |||||
| </div> | |||||
| </div> | |||||
| <div class="health-status-card"> | |||||
| <div class="health-lbl">Refinement Snapshot</div> | |||||
| <div class="ops-list" id="refineDetails"><div class="ops-line ops-line--muted">Loading refinement snapshot...</div></div> | |||||
| </div> | |||||
| <div class="health-status-card"> | |||||
| <div class="health-lbl">Telemetry Recent Events</div> | |||||
| <div class="ops-list" id="telemetryEventList"><div class="ops-line ops-line--muted">Loading telemetry events...</div></div> | |||||
| </div> | |||||
| </section> | </section> | ||||
| <section class="tab-panel" data-panel="recordings"> | <section class="tab-panel" data-panel="recordings"> | ||||
| @@ -391,6 +449,7 @@ | |||||
| <div class="insp-note" id="recordingMeta">Recording: -</div> | <div class="insp-note" id="recordingMeta">Recording: -</div> | ||||
| <div class="insp-note" id="decodeResult">Decode: -</div> | <div class="insp-note" id="decodeResult">Decode: -</div> | ||||
| <div class="insp-note" id="classifierScores">Classifier scores: -</div> | <div class="insp-note" id="classifierScores">Classifier scores: -</div> | ||||
| <div class="score-bars" id="classifierScoreBars"></div> | |||||
| <div class="insp-note"> | <div class="insp-note"> | ||||
| <a id="recordingMetaLink" href="#" target="_blank">meta.json</a> · | <a id="recordingMetaLink" href="#" target="_blank">meta.json</a> · | ||||
| <a id="recordingIQLink" href="#" target="_blank">IQ</a> · | <a id="recordingIQLink" href="#" target="_blank">IQ</a> · | ||||
| @@ -417,6 +476,8 @@ | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <script src="api-client.js"></script> | |||||
| <script src="operator-panel.js"></script> | |||||
| <script src="app.js"></script> | <script src="app.js"></script> | ||||
| <script> | <script> | ||||
| // Keyboard overlay toggle (new feature) | // Keyboard overlay toggle (new feature) | ||||
| @@ -0,0 +1,137 @@ | |||||
| (function (global) { | |||||
| function fmtHz(value) { | |||||
| if (!Number.isFinite(value)) return 'n/a'; | |||||
| if (value >= 1e6) return `${(value / 1e6).toFixed(3)} MHz`; | |||||
| if (value >= 1e3) return `${(value / 1e3).toFixed(2)} kHz`; | |||||
| return `${Math.round(value)} Hz`; | |||||
| } | |||||
| 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 applyStatusClass(el, level) { | |||||
| if (!el) return; | |||||
| el.classList.remove('status-val--ok', 'status-val--warn', 'status-val--bad'); | |||||
| if (level === 'ok') el.classList.add('status-val--ok'); | |||||
| if (level === 'warn') el.classList.add('status-val--warn'); | |||||
| if (level === 'bad') el.classList.add('status-val--bad'); | |||||
| } | |||||
| function levelSummary(level) { | |||||
| if (!level || typeof level !== 'object') return 'n/a'; | |||||
| const bits = []; | |||||
| if (level.name) bits.push(level.name); | |||||
| if (Number.isFinite(level.fft_size) && level.fft_size > 0) bits.push(`${level.fft_size} bins`); | |||||
| if (Number.isFinite(level.span_hz) && level.span_hz > 0) bits.push(fmtHz(level.span_hz)); | |||||
| return bits.join(' · ') || 'n/a'; | |||||
| } | |||||
| function summarizeReason(reason) { | |||||
| if (!reason) return 'n/a'; | |||||
| const parts = String(reason).split(':').filter(Boolean); | |||||
| if (!parts.length) return 'n/a'; | |||||
| const tail = parts.slice(-3); | |||||
| const compact = tail.join(' › '); | |||||
| return compact.length > 64 ? compact.slice(0, 61) + '…' : compact; | |||||
| } | |||||
| function renderRefinementDetails(root, refinementInfo) { | |||||
| if (!root) return; | |||||
| const plan = refinementInfo?.plan || {}; | |||||
| const queue = refinementInfo?.arbitration?.queue || {}; | |||||
| const summary = refinementInfo?.arbitration?.decision_summary || {}; | |||||
| const reasons = summary?.reasons || {}; | |||||
| const topReason = Object.entries(reasons).sort((a, b) => Number(b[1]) - Number(a[1]))[0]; | |||||
| const primary = refinementInfo?.surveillance_level_set?.primary || refinementInfo?.surveillance_level; | |||||
| const display = refinementInfo?.surveillance_level_set?.presentation || refinementInfo?.display_level; | |||||
| const spans = refinementInfo?.window_summary?.refinement || refinementInfo?.window_stats || {}; | |||||
| const rows = [ | |||||
| `Budget: ${(plan.selected || []).length}/${plan.budget || 0}`, | |||||
| `Queue: rec ${queue.record_queued || 0} · dec ${queue.decode_queued || 0}`, | |||||
| `Drop: snr ${plan.dropped_by_snr || 0} · budget ${plan.dropped_by_budget || 0}`, | |||||
| `Reason: ${topReason ? `${summarizeReason(topReason[0])} (${topReason[1]})` : 'n/a'}`, | |||||
| `Primary: ${levelSummary(primary)}`, | |||||
| `Display: ${levelSummary(display)}`, | |||||
| `Windows: ${spans.count ? `${spans.count} · ${fmtHz(spans.min_span_hz || 0)}-${fmtHz(spans.max_span_hz || 0)}` : 'n/a'}` | |||||
| ]; | |||||
| root.innerHTML = rows.map((row) => `<div class="ops-line">${row}</div>`).join(''); | |||||
| } | |||||
| function renderTelemetryEvents(root, telemetryLive) { | |||||
| if (!root) return; | |||||
| const items = Array.isArray(telemetryLive?.recent_events) ? telemetryLive.recent_events.slice(0, 6) : []; | |||||
| if (!items.length) { | |||||
| root.innerHTML = '<div class="ops-line ops-line--muted">No recent telemetry events.</div>'; | |||||
| return; | |||||
| } | |||||
| root.innerHTML = items.map((item) => { | |||||
| const ts = item?.timestamp ? new Date(item.timestamp).toLocaleTimeString() : '--:--:--'; | |||||
| const level = item?.level || 'info'; | |||||
| const name = item?.name || item?.metric || item?.category || 'event'; | |||||
| const detail = item?.message || item?.detail || item?.summary || ''; | |||||
| const shortDetail = detail ? String(detail).slice(0, 72) : ''; | |||||
| return `<div class="ops-line"><span class="ops-level ops-level--${level}">${level}</span><span class="ops-ts">${ts}</span><span class="ops-name">${name}${shortDetail ? ` · ${shortDetail}` : ''}</span></div>`; | |||||
| }).join(''); | |||||
| } | |||||
| function create(elements) { | |||||
| function updateStatus(data) { | |||||
| const { | |||||
| wsState, wsLastMessageTs, apiState, configStatusText, refinementInfo, telemetryLive, sourceAgeMs | |||||
| } = data; | |||||
| if (elements.healthWs) { | |||||
| const age = wsLastMessageTs > 0 ? fmtAgeShort(Date.now() - wsLastMessageTs) : '-'; | |||||
| elements.healthWs.textContent = `${wsState} · last ${age}`; | |||||
| applyStatusClass(elements.healthWs, wsState === 'live' ? 'ok' : (wsState === 'retrying' ? 'bad' : 'warn')); | |||||
| } | |||||
| if (elements.healthApi) { | |||||
| const latency = Number.isFinite(apiState?.latencyMs) ? `${apiState.latencyMs} ms` : 'n/a'; | |||||
| const isOk = !!apiState?.ok; | |||||
| elements.healthApi.textContent = isOk ? `ok · ${latency}` : `degraded · ${apiState?.lastError || 'n/a'}`; | |||||
| applyStatusClass(elements.healthApi, isOk ? 'ok' : 'bad'); | |||||
| } | |||||
| if (elements.healthConfig) { | |||||
| elements.healthConfig.textContent = configStatusText || '-'; | |||||
| applyStatusClass(elements.healthConfig, /failed|offline/i.test(configStatusText || '') ? 'bad' : 'ok'); | |||||
| } | |||||
| if (elements.healthRefine) { | |||||
| const plan = refinementInfo?.plan || {}; | |||||
| const queue = refinementInfo?.arbitration?.queue || {}; | |||||
| const budget = Number(plan?.budget || 0); | |||||
| const selected = Number((plan?.selected || []).length || 0); | |||||
| elements.healthRefine.textContent = `${selected}/${budget} · q ${queue.record_queued || 0}/${queue.decode_queued || 0}`; | |||||
| applyStatusClass(elements.healthRefine, budget > 0 && selected >= budget ? 'warn' : 'ok'); | |||||
| } | |||||
| if (elements.healthTelemetry) { | |||||
| if (!telemetryLive) { | |||||
| elements.healthTelemetry.textContent = 'unavailable'; | |||||
| applyStatusClass(elements.healthTelemetry, 'bad'); | |||||
| } 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'; | |||||
| elements.healthTelemetry.textContent = `${enabled} · ${heavy} · events ${recent}`; | |||||
| applyStatusClass(elements.healthTelemetry, enabled === 'on' ? 'ok' : 'warn'); | |||||
| } | |||||
| } | |||||
| if (elements.healthSource) { | |||||
| const text = Number.isFinite(sourceAgeMs) && sourceAgeMs >= 0 ? `${sourceAgeMs} ms` : 'n/a'; | |||||
| elements.healthSource.textContent = text; | |||||
| applyStatusClass(elements.healthSource, Number.isFinite(sourceAgeMs) && sourceAgeMs < 1500 ? 'ok' : 'warn'); | |||||
| } | |||||
| renderRefinementDetails(elements.refineDetails, refinementInfo); | |||||
| renderTelemetryEvents(elements.telemetryEvents, telemetryLive); | |||||
| } | |||||
| return { updateStatus }; | |||||
| } | |||||
| global.OperatorPanel = { create }; | |||||
| })(window); | |||||
| @@ -222,14 +222,32 @@ canvas { display: block; width: 100%; height: 100%; border-radius: var(--r-sm); | |||||
| grid-template-rows: auto auto minmax(0, 1fr); | grid-template-rows: auto auto minmax(0, 1fr); | ||||
| gap: 10px; | gap: 10px; | ||||
| padding: 12px; | padding: 12px; | ||||
| min-width: 0; | |||||
| } | } | ||||
| .rail-head { display: flex; align-items: center; justify-content: space-between; } | |||||
| .rail-head { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: space-between; | |||||
| gap: 8px; | |||||
| min-width: 0; | |||||
| } | |||||
| /* Rail Tabs */ | /* Rail Tabs */ | ||||
| .rail-tabs { display: flex; gap: 3px; background: var(--bg2); border: 1px solid var(--line); border-radius: 10px; padding: 3px; } | |||||
| .rail-tabs { | |||||
| display: flex; | |||||
| flex-wrap: wrap; | |||||
| gap: 3px; | |||||
| background: var(--bg2); | |||||
| border: 1px solid var(--line); | |||||
| border-radius: 10px; | |||||
| padding: 3px; | |||||
| min-width: 0; | |||||
| } | |||||
| .rail-tab { | .rail-tab { | ||||
| flex: 1; text-align: center; | |||||
| flex: 1 1 72px; | |||||
| min-width: 0; | |||||
| text-align: center; | |||||
| font-family: var(--mono); font-size: 0.68rem; font-weight: 500; | font-family: var(--mono); font-size: 0.68rem; font-weight: 500; | ||||
| padding: 5px 8px; border-radius: 7px; | padding: 5px 8px; border-radius: 7px; | ||||
| background: transparent; border: 1px solid transparent; | background: transparent; border: 1px solid transparent; | ||||
| @@ -241,19 +259,18 @@ canvas { display: block; width: 100%; height: 100%; border-radius: var(--r-sm); | |||||
| color: var(--accent); | color: var(--accent); | ||||
| } | } | ||||
| .rail-body { min-height: 0; position: relative; } | |||||
| .rail-body { min-height: 0; min-width: 0; position: relative; } | |||||
| .tab-panel { display: none; height: 100%; overflow: auto; padding-right: 4px; } | |||||
| .tab-panel { display: none; height: 100%; min-width: 0; overflow: auto; padding-right: 4px; } | |||||
| .tab-panel.active { display: block; } | .tab-panel.active { display: block; } | ||||
| .tab-panel[data-panel="signals"] { | .tab-panel[data-panel="signals"] { | ||||
| display: none; | display: none; | ||||
| height: 100%; | height: 100%; | ||||
| } | } | ||||
| .tab-panel[data-panel="signals"].active { | .tab-panel[data-panel="signals"].active { | ||||
| display: grid; | |||||
| grid-template-rows: auto minmax(120px, 1fr) auto auto auto auto; | |||||
| gap: 8px; | |||||
| display: block; | |||||
| min-height: 0; | min-height: 0; | ||||
| min-width: 0; | |||||
| } | } | ||||
| /* ── Form Groups ── */ | /* ── Form Groups ── */ | ||||
| @@ -261,14 +278,16 @@ canvas { display: block; width: 100%; height: 100%; border-radius: var(--r-sm); | |||||
| border: 1px solid var(--line); border-radius: var(--r); | border: 1px solid var(--line); border-radius: var(--r); | ||||
| padding: 10px; margin-bottom: 8px; | padding: 10px; margin-bottom: 8px; | ||||
| background: rgba(255, 255, 255, 0.01); | background: rgba(255, 255, 255, 0.01); | ||||
| min-width: 0; | |||||
| overflow: hidden; | |||||
| } | } | ||||
| .grp-title { font-family: var(--mono); font-size: 0.6rem; font-weight: 600; letter-spacing: 0.12em; color: var(--accent); opacity: 0.7; margin-bottom: 8px; text-transform: uppercase; } | .grp-title { font-family: var(--mono); font-size: 0.6rem; font-weight: 600; letter-spacing: 0.12em; color: var(--accent); opacity: 0.7; margin-bottom: 8px; text-transform: uppercase; } | ||||
| .field { display: grid; gap: 3px; margin-bottom: 8px; } | |||||
| .field { display: grid; gap: 3px; margin-bottom: 8px; min-width: 0; } | |||||
| .field:last-child { margin-bottom: 0; } | .field:last-child { margin-bottom: 0; } | ||||
| .field > span { font-family: var(--mono); font-size: 0.64rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; } | .field > span { font-family: var(--mono); font-size: 0.64rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; } | ||||
| .field-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } | |||||
| .field-pair { display: grid; grid-template-columns: 1fr; gap: 8px; min-width: 0; } | |||||
| .field-pair .field { margin-bottom: 0; } | .field-pair .field { margin-bottom: 0; } | ||||
| /* Inputs */ | /* Inputs */ | ||||
| @@ -286,9 +305,10 @@ select { | |||||
| } | } | ||||
| /* Presets */ | /* Presets */ | ||||
| .preset-row { display: flex; gap: 4px; margin-bottom: 8px; } | |||||
| .preset-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; min-width: 0; } | |||||
| .preset-btn { | .preset-btn { | ||||
| flex: 1; display: flex; flex-direction: column; align-items: center; gap: 0; | |||||
| flex: 1 1 72px; display: flex; flex-direction: column; align-items: center; gap: 0; | |||||
| min-width: 0; | |||||
| font-family: var(--mono); background: var(--bg2); border: 1px solid var(--line); | font-family: var(--mono); background: var(--bg2); border: 1px solid var(--line); | ||||
| border-radius: var(--r-sm); padding: 5px 4px 4px; cursor: pointer; color: var(--text); transition: all 0.15s; | border-radius: var(--r-sm); padding: 5px 4px 4px; cursor: pointer; color: var(--text); transition: all 0.15s; | ||||
| } | } | ||||
| @@ -297,11 +317,11 @@ select { | |||||
| .preset-btn small { font-size: 0.58rem; color: var(--text-dim); line-height: 1.3; } | .preset-btn small { font-size: 0.58rem; color: var(--text-dim); line-height: 1.3; } | ||||
| /* Sliders */ | /* Sliders */ | ||||
| .slider-field { margin-bottom: 8px; } | |||||
| .slider-field { margin-bottom: 8px; min-width: 0; } | |||||
| .slider-field > span { display: block; font-family: var(--mono); font-size: 0.64rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; } | .slider-field > span { display: block; font-family: var(--mono); font-size: 0.64rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; } | ||||
| .slider-row { display: flex; align-items: center; gap: 6px; } | |||||
| .slider-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-width: 0; } | |||||
| .slider-row em { font-family: var(--mono); font-size: 0.58rem; color: var(--text-mute); font-style: normal; width: 16px; flex-shrink: 0; } | .slider-row em { font-family: var(--mono); font-size: 0.58rem; color: var(--text-mute); font-style: normal; width: 16px; flex-shrink: 0; } | ||||
| .slider-num { width: 50px; text-align: center; flex-shrink: 0; font-size: 0.78rem; padding: 4px 4px; } | |||||
| .slider-num { width: 50px; max-width: 100%; text-align: center; flex-shrink: 0; font-size: 0.78rem; padding: 4px 4px; } | |||||
| input[type="range"] { | input[type="range"] { | ||||
| -webkit-appearance: none; appearance: none; | -webkit-appearance: none; appearance: none; | ||||
| @@ -321,11 +341,12 @@ input[type="range"]::-moz-range-thumb { | |||||
| .range--warn::-moz-range-thumb { background: var(--warn); box-shadow: 0 0 8px rgba(255, 180, 84, 0.3); } | .range--warn::-moz-range-thumb { background: var(--warn); box-shadow: 0 0 8px rgba(255, 180, 84, 0.3); } | ||||
| /* Toggle Pills */ | /* Toggle Pills */ | ||||
| .toggle-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin-top: 4px; } | |||||
| .toggle-grid { display: grid; grid-template-columns: 1fr; gap: 5px; margin-top: 4px; min-width: 0; } | |||||
| .pill-toggle { | .pill-toggle { | ||||
| display: flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; | display: flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; | ||||
| padding: 4px 6px; border-radius: var(--r-sm); | padding: 4px 6px; border-radius: var(--r-sm); | ||||
| border: 1px solid transparent; transition: border-color 0.15s; | border: 1px solid transparent; transition: border-color 0.15s; | ||||
| min-width: 0; | |||||
| } | } | ||||
| .pill-toggle:hover { border-color: var(--line); } | .pill-toggle:hover { border-color: var(--line); } | ||||
| .pill-toggle input { display: none; } | .pill-toggle input { display: none; } | ||||
| @@ -339,7 +360,7 @@ input[type="range"]::-moz-range-thumb { | |||||
| } | } | ||||
| .pill-toggle input:checked ~ .pt { background: rgba(0, 255, 200, 0.2); } | .pill-toggle input:checked ~ .pt { background: rgba(0, 255, 200, 0.2); } | ||||
| .pill-toggle input:checked ~ .pt .pk { transform: translateX(13px); background: var(--accent); box-shadow: 0 0 6px rgba(0, 255, 200, 0.4); } | .pill-toggle input:checked ~ .pt .pk { transform: translateX(13px); background: var(--accent); box-shadow: 0 0 6px rgba(0, 255, 200, 0.4); } | ||||
| .pl { font-family: var(--mono); font-size: 0.68rem; font-weight: 500; color: var(--text-dim); letter-spacing: 0.03em; } | |||||
| .pl { font-family: var(--mono); font-size: 0.68rem; font-weight: 500; color: var(--text-dim); letter-spacing: 0.03em; min-width: 0; overflow-wrap: anywhere; word-break: break-word; } | |||||
| .pill-toggle input:checked ~ .pt ~ .pl { color: var(--accent); } | .pill-toggle input:checked ~ .pt ~ .pl { color: var(--accent); } | ||||
| /* ═══════════ LISTS (Signals + Events) ═══════════ */ | /* ═══════════ LISTS (Signals + Events) ═══════════ */ | ||||
| @@ -352,7 +373,10 @@ input[type="range"]::-moz-range-thumb { | |||||
| } | } | ||||
| .signal-list, .event-list { display: grid; gap: 5px; } | .signal-list, .event-list { display: grid; gap: 5px; } | ||||
| .signal-list { | .signal-list { | ||||
| min-height: 0; | |||||
| display: grid; | |||||
| gap: 5px; | |||||
| min-height: 140px; | |||||
| max-height: 42vh; | |||||
| overflow: auto; | overflow: auto; | ||||
| align-content: start; | align-content: start; | ||||
| padding-right: 2px; | padding-right: 2px; | ||||
| @@ -362,10 +386,21 @@ input[type="range"]::-moz-range-thumb { | |||||
| /* List items — rendered by app.js */ | /* List items — rendered by app.js */ | ||||
| .list-item { | .list-item { | ||||
| padding: 9px 10px; border-radius: var(--r); border: 1px solid var(--line); | padding: 9px 10px; border-radius: var(--r); border: 1px solid var(--line); | ||||
| background: var(--panel-2); cursor: pointer; transition: border-color 0.12s; | |||||
| background: var(--panel-2); cursor: pointer; transition: border-color 0.12s, box-shadow 0.12s, transform 0.12s; | |||||
| } | |||||
| .list-item:hover { border-color: rgba(0, 255, 200, 0.22); } | |||||
| .list-item.active { | |||||
| border-color: rgba(0, 255, 200, 0.45); | |||||
| box-shadow: 0 0 0 1px rgba(0, 255, 200, 0.22) inset, 0 0 18px rgba(0, 255, 200, 0.06); | |||||
| transform: translateY(-1px); | |||||
| } | |||||
| .list-item.listening { | |||||
| border-color: rgba(255, 92, 92, 0.55); | |||||
| box-shadow: 0 0 0 1px rgba(255, 92, 92, 0.35) inset; | |||||
| } | |||||
| .list-item.active.listening { | |||||
| box-shadow: 0 0 0 1px rgba(255, 92, 92, 0.35) inset, 0 0 0 1px rgba(0, 255, 200, 0.18); | |||||
| } | } | ||||
| .list-item:hover, .list-item.active { border-color: rgba(0, 255, 200, 0.28); } | |||||
| .list-item.listening { border-color: rgba(255, 92, 92, 0.55); box-shadow: 0 0 0 1px rgba(255, 92, 92, 0.35) inset; } | |||||
| .item-top, .item-bottom { display: flex; align-items: center; justify-content: space-between; gap: 8px; } | .item-top, .item-bottom { display: flex; align-items: center; justify-content: space-between; gap: 8px; } | ||||
| .item-top { margin-bottom: 2px; } | .item-top { margin-bottom: 2px; } | ||||
| .item-title { font-family: var(--mono); font-size: 0.82rem; font-weight: 700; color: #e7f1ff; } | .item-title { font-family: var(--mono); font-size: 0.82rem; font-weight: 700; color: #e7f1ff; } | ||||
| @@ -375,9 +410,12 @@ input[type="range"]::-moz-range-thumb { | |||||
| padding: 2px 7px; border-radius: 99px; | padding: 2px 7px; border-radius: 99px; | ||||
| background: rgba(15, 25, 40, 0.8); border: 1px solid var(--line); | background: rgba(15, 25, 40, 0.8); border: 1px solid var(--line); | ||||
| } | } | ||||
| #signalSummaryLine { | |||||
| margin-top: -2px; | |||||
| } | |||||
| .listen-meta { | .listen-meta { | ||||
| margin-top: 4px; | |||||
| margin-top: 6px; | |||||
| padding: 10px 12px; | padding: 10px 12px; | ||||
| border-radius: var(--r); | border-radius: var(--r); | ||||
| border: 1px solid rgba(50, 78, 116, 0.55); | border: 1px solid rgba(50, 78, 116, 0.55); | ||||
| @@ -450,14 +488,174 @@ input[type="range"]::-moz-range-thumb { | |||||
| } | } | ||||
| /* Health Grid */ | /* Health Grid */ | ||||
| .health-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } | |||||
| .health-grid { | |||||
| display: grid; | |||||
| grid-template-columns: 1fr; | |||||
| gap: 6px; | |||||
| min-width: 0; | |||||
| } | |||||
| .health-card { | .health-card { | ||||
| background: var(--panel-2); border: 1px solid var(--line); | background: var(--panel-2); border: 1px solid var(--line); | ||||
| border-radius: var(--r); padding: 10px; | |||||
| border-radius: var(--r); padding: 8px 9px; | |||||
| min-width: 0; | |||||
| overflow: hidden; | |||||
| } | } | ||||
| .health-card--wide { grid-column: 1 / -1; } | .health-card--wide { grid-column: 1 / -1; } | ||||
| .health-lbl { display: block; font-family: var(--mono); font-size: 0.58rem; font-weight: 600; letter-spacing: 0.1em; color: var(--text-mute); text-transform: uppercase; } | .health-lbl { display: block; font-family: var(--mono); font-size: 0.58rem; font-weight: 600; letter-spacing: 0.1em; color: var(--text-mute); text-transform: uppercase; } | ||||
| .health-val { display: block; margin-top: 4px; font-family: var(--mono); font-size: 1.05rem; font-weight: 700; } | |||||
| .health-val { | |||||
| display: block; | |||||
| margin-top: 4px; | |||||
| font-family: var(--mono); | |||||
| font-size: 0.9rem; | |||||
| font-weight: 700; | |||||
| min-width: 0; | |||||
| overflow-wrap: anywhere; | |||||
| word-break: break-word; | |||||
| white-space: normal; | |||||
| } | |||||
| .health-status-card { | |||||
| margin-top: 8px; | |||||
| border: 1px solid var(--line); | |||||
| border-radius: var(--r); | |||||
| padding: 10px; | |||||
| background: var(--panel-2); | |||||
| min-width: 0; | |||||
| overflow: hidden; | |||||
| } | |||||
| .status-grid { | |||||
| margin-top: 8px; | |||||
| display: grid; | |||||
| gap: 8px; | |||||
| min-width: 0; | |||||
| } | |||||
| .status-row { | |||||
| display: block; | |||||
| min-width: 0; | |||||
| } | |||||
| .status-key { | |||||
| display: block; | |||||
| font-family: var(--mono); | |||||
| font-size: 0.64rem; | |||||
| color: var(--text-dim); | |||||
| text-transform: uppercase; | |||||
| } | |||||
| .status-val { | |||||
| display: block; | |||||
| margin-top: 2px; | |||||
| font-family: var(--mono); | |||||
| font-size: 0.7rem; | |||||
| color: var(--text); | |||||
| text-align: left; | |||||
| min-width: 0; | |||||
| overflow-wrap: anywhere; | |||||
| word-break: break-word; | |||||
| white-space: normal; | |||||
| } | |||||
| .status-val--ok { color: var(--good); } | |||||
| .status-val--warn { color: var(--warn); } | |||||
| .status-val--bad { color: var(--danger); } | |||||
| .ops-list { | |||||
| margin-top: 8px; | |||||
| display: grid; | |||||
| gap: 6px; | |||||
| min-width: 0; | |||||
| } | |||||
| .ops-line { | |||||
| font-family: var(--mono); | |||||
| font-size: 0.66rem; | |||||
| color: var(--text); | |||||
| border: 1px solid var(--line); | |||||
| border-radius: var(--r-sm); | |||||
| padding: 6px 8px; | |||||
| background: rgba(8, 14, 23, 0.72); | |||||
| display: block; | |||||
| min-width: 0; | |||||
| overflow-wrap: anywhere; | |||||
| word-break: break-word; | |||||
| } | |||||
| .ops-line--muted { | |||||
| color: var(--text-dim); | |||||
| } | |||||
| .ops-level { | |||||
| display: inline-block; | |||||
| text-transform: uppercase; | |||||
| font-weight: 700; | |||||
| border-radius: 999px; | |||||
| padding: 2px 6px; | |||||
| font-size: 0.58rem; | |||||
| letter-spacing: 0.05em; | |||||
| border: 1px solid var(--line); | |||||
| margin-right: 6px; | |||||
| margin-bottom: 4px; | |||||
| } | |||||
| .ops-level--info { | |||||
| color: #9ed0ff; | |||||
| border-color: rgba(90, 160, 255, 0.35); | |||||
| } | |||||
| .ops-level--warn { | |||||
| color: var(--warn); | |||||
| border-color: rgba(255, 180, 84, 0.45); | |||||
| } | |||||
| .ops-level--error, | |||||
| .ops-level--bad, | |||||
| .ops-level--fatal { | |||||
| color: var(--danger); | |||||
| border-color: rgba(255, 107, 129, 0.45); | |||||
| } | |||||
| .ops-ts { | |||||
| display: inline-block; | |||||
| color: var(--text-dim); | |||||
| margin-right: 6px; | |||||
| } | |||||
| .ops-name { | |||||
| display: inline; | |||||
| overflow-wrap: anywhere; | |||||
| word-break: break-word; | |||||
| white-space: normal; | |||||
| } | |||||
| .kv-list { | |||||
| margin-top: 8px; | |||||
| display: grid; | |||||
| gap: 6px; | |||||
| min-width: 0; | |||||
| } | |||||
| .kv-row { | |||||
| display: grid; | |||||
| grid-template-columns: minmax(84px, auto) minmax(0, 1fr); | |||||
| gap: 8px; | |||||
| align-items: start; | |||||
| padding: 7px 8px; | |||||
| border: 1px solid var(--line); | |||||
| border-radius: var(--r-sm); | |||||
| background: rgba(8, 14, 23, 0.72); | |||||
| min-width: 0; | |||||
| } | |||||
| .kv-key { | |||||
| font-family: var(--mono); | |||||
| font-size: 0.6rem; | |||||
| text-transform: uppercase; | |||||
| letter-spacing: 0.08em; | |||||
| color: var(--text-mute); | |||||
| } | |||||
| .kv-val { | |||||
| font-family: var(--mono); | |||||
| font-size: 0.68rem; | |||||
| color: var(--text); | |||||
| min-width: 0; | |||||
| overflow-wrap: anywhere; | |||||
| word-break: break-word; | |||||
| } | |||||
| .form-hint { | |||||
| margin-top: 6px; | |||||
| font-size: 0.68rem; | |||||
| color: var(--text-mute); | |||||
| line-height: 1.35; | |||||
| } | |||||
| .config-grid { | |||||
| display: grid; | |||||
| gap: 8px; | |||||
| } | |||||
| /* ═══════════ TIMELINE PANEL ═══════════ */ | /* ═══════════ TIMELINE PANEL ═══════════ */ | ||||
| .tl-panel { | .tl-panel { | ||||