| @@ -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 | |||
| 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 | |||
| @@ -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 | |||
| @@ -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 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 = `<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) { | |||
| @@ -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 = '<div class="empty-state">No live signals yet.</div>'; | |||
| } | |||
| signalList.innerHTML = '<div class="empty-state">No live signals yet.</div>'; | |||
| } 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); | |||
| @@ -103,6 +103,10 @@ | |||
| <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" 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="events" type="button">Events</button> | |||
| <button class="rail-tab" data-tab="health" type="button">Health</button> | |||
| @@ -174,8 +178,15 @@ | |||
| </div> | |||
| </div> | |||
| </section> | |||
| <!-- ── Detect Tab ── --> | |||
| <section class="tab-panel" data-panel="detect"> | |||
| <div class="config-grid"> | |||
| <div class="form-group"> | |||
| <div class="grp-title">Classifier</div> | |||
| <div class="form-hint">Signal classification behavior and switching thresholds.</div> | |||
| <label class="field"><span>Classifier</span> | |||
| <select id="classifierModeSelect"> | |||
| <option value="rule">Rule-Based</option> | |||
| @@ -187,6 +198,7 @@ | |||
| <div class="form-group"> | |||
| <div class="grp-title">Detector</div> | |||
| <div class="form-hint">Thresholding, CFAR, and temporal stability for live carrier detection.</div> | |||
| <div class="slider-field"> | |||
| <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> | |||
| @@ -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> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </section> | |||
| <!-- ── Record Tab ── --> | |||
| <section class="tab-panel" data-panel="record"> | |||
| <div class="config-grid"> | |||
| <div class="form-group"> | |||
| <div class="grp-title">Recorder</div> | |||
| <div class="form-hint">Recording enablement, media types, decoding, and retention limits.</div> | |||
| <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="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>Class Filter (CSV)</span><input id="recClassFilter" type="text" placeholder="e.g. NFM,USB" /></label> | |||
| </div> | |||
| </div> | |||
| </section> | |||
| <!-- ── Refine Tab ── --> | |||
| <section class="tab-panel" data-panel="refine"> | |||
| <div class="config-grid"> | |||
| <div class="form-group"> | |||
| <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"> | |||
| <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> | |||
| @@ -246,6 +270,7 @@ | |||
| <div class="form-group"> | |||
| <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> | |||
| <select id="refineAutoSpan" class="ctrl-select"> | |||
| <option value="true">Auto</option> | |||
| @@ -258,12 +283,26 @@ | |||
| </div> | |||
| <div class="panel-sub">Auto span uses modulation hints when candidate BW is missing. Min/Max clamp the final window.</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> | |||
| <!-- ── Signals Tab ── --> | |||
| <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="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="signalQueueSummary">Queue: -</div> | |||
| <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">Render rate</span><span class="health-val" id="healthFps">-</span></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 class="tab-panel" data-panel="recordings"> | |||
| @@ -391,6 +449,7 @@ | |||
| <div class="insp-note" id="recordingMeta">Recording: -</div> | |||
| <div class="insp-note" id="decodeResult">Decode: -</div> | |||
| <div class="insp-note" id="classifierScores">Classifier scores: -</div> | |||
| <div class="score-bars" id="classifierScoreBars"></div> | |||
| <div class="insp-note"> | |||
| <a id="recordingMetaLink" href="#" target="_blank">meta.json</a> · | |||
| <a id="recordingIQLink" href="#" target="_blank">IQ</a> · | |||
| @@ -417,6 +476,8 @@ | |||
| </div> | |||
| </div> | |||
| <script src="api-client.js"></script> | |||
| <script src="operator-panel.js"></script> | |||
| <script src="app.js"></script> | |||
| <script> | |||
| // 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); | |||
| gap: 10px; | |||
| 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 { 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 { | |||
| 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; | |||
| padding: 5px 8px; border-radius: 7px; | |||
| 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); | |||
| } | |||
| .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[data-panel="signals"] { | |||
| display: none; | |||
| height: 100%; | |||
| } | |||
| .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-width: 0; | |||
| } | |||
| /* ── 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); | |||
| padding: 10px; margin-bottom: 8px; | |||
| 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; } | |||
| .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 > 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; } | |||
| /* Inputs */ | |||
| @@ -286,9 +305,10 @@ select { | |||
| } | |||
| /* 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 { | |||
| 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); | |||
| 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; } | |||
| /* 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-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-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"] { | |||
| -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); } | |||
| /* 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 { | |||
| display: flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; | |||
| padding: 4px 6px; border-radius: var(--r-sm); | |||
| border: 1px solid transparent; transition: border-color 0.15s; | |||
| min-width: 0; | |||
| } | |||
| .pill-toggle:hover { border-color: var(--line); } | |||
| .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 .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); } | |||
| /* ═══════════ LISTS (Signals + Events) ═══════════ */ | |||
| @@ -352,7 +373,10 @@ input[type="range"]::-moz-range-thumb { | |||
| } | |||
| .signal-list, .event-list { display: grid; gap: 5px; } | |||
| .signal-list { | |||
| min-height: 0; | |||
| display: grid; | |||
| gap: 5px; | |||
| min-height: 140px; | |||
| max-height: 42vh; | |||
| overflow: auto; | |||
| align-content: start; | |||
| padding-right: 2px; | |||
| @@ -362,10 +386,21 @@ input[type="range"]::-moz-range-thumb { | |||
| /* List items — rendered by app.js */ | |||
| .list-item { | |||
| 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 { margin-bottom: 2px; } | |||
| .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; | |||
| background: rgba(15, 25, 40, 0.8); border: 1px solid var(--line); | |||
| } | |||
| #signalSummaryLine { | |||
| margin-top: -2px; | |||
| } | |||
| .listen-meta { | |||
| margin-top: 4px; | |||
| margin-top: 6px; | |||
| padding: 10px 12px; | |||
| border-radius: var(--r); | |||
| border: 1px solid rgba(50, 78, 116, 0.55); | |||
| @@ -450,14 +488,174 @@ input[type="range"]::-moz-range-thumb { | |||
| } | |||
| /* 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 { | |||
| 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-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 ═══════════ */ | |||
| .tl-panel { | |||