diff --git a/codex_webinterface_prompt.txt b/codex_webinterface_prompt.txt new file mode 100644 index 0000000..1fd0971 --- /dev/null +++ b/codex_webinterface_prompt.txt @@ -0,0 +1,41 @@ +Arbeite im Repository C:\Users\jan\Downloads\sdr-wideband-suite auf dem bereits angelegten Branch refactor/webinterface. + +WICHTIG: +- NICHT committen. +- NICHT pushen. +- Keine Branches wechseln oder neu anlegen. +- config.yaml, config.autosave.yaml, Debug-Dumps und lokale Laufzeitartefakte nicht absichtlich verändern, außer es ist für das Frontend unbedingt nötig — dann nur minimal und uncommitted. +- Vor dem Start zuerst das Repo einlesen: AGENTS.md, README.md, docs/known-issues.md, relevante API-/WS-Serverstellen in cmd/sdrd/http_handlers.go und cmd/sdrd/ws_handlers.go, sowie das Frontend unter web/. + +Kontext / Ziel: +- Das Webinterface soll erweitert und verbessert werden. +- Fokus auf Webinterface-Refactor mit besserer Struktur, besserer UX und klarer Ausrichtung an der existierenden API. +- Aktuell ist das Frontend ein großes Vanilla-JS-Monolith-Skript in web/app.js. Bitte den aktuellen Zustand zuerst analysieren und dann gezielt verbessern, ohne unnötig die gesamte App umzubauen. + +Bitte liefere: +1. Eine kurze Analyse des aktuellen UI/API-Zuschnitts und der größten Schwachstellen. +2. Einen konkreten Umsetzungsplan für einen sinnvollen ersten Refactor-Schritt. +3. Dann die Umsetzung direkt im Branch: verbessere/erweitere das Webinterface spürbar. + +Bevorzugte Richtungen für die Umsetzung (du darfst priorisieren): +- bessere Struktur/Modularisierung innerhalb von web/app.js bzw. Aufteilung in mehrere Web-Dateien, wenn das ohne riesigen Umbau sauber machbar ist +- klarere Zustandsdarstellung für API/WS-Verbindung, Config-Status, Source/GPU/Health +- bessere Nutzbarkeit der vorhandenen APIs (/api/refinement, /api/events, /api/recordings, /api/debug/telemetry/*) im UI +- UI-Verbesserungen für Signal-/Event-/Recording-Ansichten +- bessere leichtere Debug-/Operator-Ansicht statt nur verstreuter Informationen +- kleine, robuste UX-Verbesserungen > rein kosmetische Änderungen + +Grenzen: +- Kein Full-Rewrite in Frameworks. +- Keine unnötigen Backend-API-Änderungen, außer klar sinnvoll und klein. +- Nicht versuchen, das gesamte Projekt zu lösen. Lieber ein sauberer, wertvoller erster Refactor. +- Wenn Builds/Checks sinnvoll sind, nutze für dieses Projekt bevorzugt die vorhandenen Projekt-Skripte/Workflow-Regeln aus AGENTS.md; für kleine Syntax-/Sanity-Checks sind gezielte Checks ok. + +Wenn du fertig bist, berichte knapp: +- was du analysiert hast +- welche Dateien du geändert hast +- was funktional verbessert wurde +- ob es offene Punkte / Risiken gibt + +Wenn komplett fertig, run this command: +openclaw system event --text "Done: webinterface refactor first pass completed on refactor/webinterface (no commit)" --mode now diff --git a/config.autosave.yaml b/config.autosave.yaml index 03dbd1c..8c70b9a 100644 --- a/config.autosave.yaml +++ b/config.autosave.yaml @@ -4,7 +4,7 @@ bands: end_hz: 1.08e+08 center_hz: 1.02e+08 sample_rate: 4096000 -fft_size: 512 +fft_size: 4096 gain_db: 32 tuner_bw_khz: 5000 use_gpu_fft: true @@ -51,7 +51,7 @@ pipeline: - WFM_STEREO - RDS surveillance: - analysis_fft_size: 512 + analysis_fft_size: 4096 frame_rate: 12 strategy: multi-resolution display_bins: 2048 @@ -267,9 +267,9 @@ profiles: decision_hold_ms: 2500 detector: threshold_db: -60 - min_duration_ms: 500 + min_duration_ms: 5000 hold_ms: 1500 - ema_alpha: 0.025 + ema_alpha: 0.35 hysteresis_db: 10 min_stable_frames: 4 gap_tolerance_ms: 2000 @@ -279,7 +279,7 @@ detector: cfar_guard_cells: 3 cfar_train_cells: 24 cfar_rank: 36 - cfar_scale_db: 23 + cfar_scale_db: 27 cfar_wrap_around: true edge_margin_db: 6 max_signal_bw_hz: 260000 @@ -295,10 +295,12 @@ recorder: record_iq: false record_audio: true auto_demod: true - auto_decode: false + auto_decode: true max_disk_mb: 0 output_dir: data/recordings - class_filter: [] + class_filter: + - WFM + - WFM_STEREO ring_seconds: 12 deemphasis_us: 50 extraction_fir_taps: 101 @@ -315,14 +317,14 @@ debug: audio_dump_enabled: false cpu_monitoring: false telemetry: - enabled: true + enabled: false heavy_enabled: false heavy_sample_every: 12 metric_sample_every: 8 metric_history_max: 6000 event_history_max: 1500 retention_seconds: 900 - persist_enabled: false + persist_enabled: true persist_dir: debug/telemetry rotate_mb: 16 keep_files: 8 diff --git a/config.yaml b/config.yaml index 53cdb0b..592d701 100644 --- a/config.yaml +++ b/config.yaml @@ -224,15 +224,15 @@ detector: class_history_size: 10 class_switch_ratio: 0.6 recorder: - enabled: true + enabled: false min_snr_db: 0 min_duration: 500ms max_duration: 300s preroll_ms: 500 record_iq: false - record_audio: true - auto_demod: true - auto_decode: true + record_audio: false + auto_demod: false + auto_decode:false max_disk_mb: 0 output_dir: data/recordings class_filter: ["WFM", "WFM_STEREO"] @@ -252,14 +252,14 @@ debug: audio_dump_enabled: false cpu_monitoring: false telemetry: - enabled: true + enabled: false heavy_enabled: false heavy_sample_every: 12 metric_sample_every: 8 metric_history_max: 6000 event_history_max: 1500 retention_seconds: 900 - persist_enabled: true + persist_enabled: false persist_dir: debug/telemetry rotate_mb: 16 keep_files: 8 diff --git a/fix_mojibake.py b/fix_mojibake.py new file mode 100644 index 0000000..3eac37b --- /dev/null +++ b/fix_mojibake.py @@ -0,0 +1,29 @@ +from pathlib import Path + +files = [ + Path(r"C:\Users\jan\Downloads\sdr-wideband-suite\web\app.js"), + Path(r"C:\Users\jan\Downloads\sdr-wideband-suite\web\index.html"), + Path(r"C:\Users\jan\Downloads\sdr-wideband-suite\web\style.css"), +] + +repl = { + '·': '·', + '…': '…', + '—': '—', + '–': '–', + '→': '→', + 'â†\x90': '←', + 'â– ': '■', + 'â– ': '■', + '×': '×', + 'â•\x90': '═', + '─': '─', +} + +for p in files: + s = p.read_text(encoding='utf-8') + o = s + for a, b in repl.items(): + s = s.replace(a, b) + if s != o: + p.write_text(s, encoding='utf-8', newline='') diff --git a/web/api-client.js b/web/api-client.js new file mode 100644 index 0000000..172a76e --- /dev/null +++ b/web/api-client.js @@ -0,0 +1,74 @@ +(function (global) { + const DEFAULT_TIMEOUT_MS = 5000; + + function toErrorMessage(err) { + if (!err) return 'request failed'; + if (typeof err === 'string') return err; + return err.message || 'request failed'; + } + + function createClient(opts = {}) { + const baseUrl = opts.baseUrl || ''; + const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_TIMEOUT_MS; + + async function request(path, options = {}) { + const controller = new AbortController(); + const start = performance.now(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const init = { + method: options.method || 'GET', + headers: { ...(options.headers || {}) }, + signal: controller.signal, + }; + if (options.body !== undefined) { + init.headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(options.body); + } + + try { + const res = await fetch(`${baseUrl}${path}`, init); + const durationMs = Math.round(performance.now() - start); + const contentType = res.headers.get('content-type') || ''; + let data = null; + if (contentType.includes('application/json')) data = await res.json(); + else data = await res.text(); + if (!res.ok) { + const error = typeof data === 'string' ? data : (data?.error || `http ${res.status}`); + return { ok: false, status: res.status, error, data, meta: { duration_ms: durationMs } }; + } + return { ok: true, status: res.status, data, meta: { duration_ms: durationMs } }; + } catch (err) { + const durationMs = Math.round(performance.now() - start); + return { ok: false, status: 0, error: toErrorMessage(err), data: null, meta: { duration_ms: durationMs } }; + } finally { + clearTimeout(timer); + } + } + + return { + getConfig: () => request('/api/config'), + postConfig: (payload) => request('/api/config', { method: 'POST', body: payload }), + postSettings: (payload) => request('/api/sdr/settings', { method: 'POST', body: payload }), + getSignals: () => request('/api/signals'), + getStats: () => request('/api/stats'), + getGPU: () => request('/api/gpu'), + getPolicy: () => request('/api/pipeline/policy'), + getRecommendations: () => request('/api/pipeline/recommendations'), + getRefinement: () => request('/api/refinement'), + getEvents: ({ limit, since } = {}) => { + const params = new URLSearchParams(); + if (Number.isFinite(limit) && limit > 0) params.set('limit', String(limit)); + if (Number.isFinite(since) && since > 0) params.set('since', String(since)); + const suffix = params.toString() ? `?${params.toString()}` : ''; + return request(`/api/events${suffix}`); + }, + getRecordings: () => request('/api/recordings'), + getRecording: (id) => request(`/api/recordings/${encodeURIComponent(id)}`), + decodeRecording: (id, mode) => request(`/api/recordings/${encodeURIComponent(id)}/decode?mode=${encodeURIComponent(mode || '')}`), + getDecoders: () => request('/api/decoders'), + getTelemetryLive: () => request('/api/debug/telemetry/live'), + }; + } + + global.SpectreApi = { createClient }; +})(window); diff --git a/web/app.js b/web/app.js index 80866b0..6f6bf95 100644 --- a/web/app.js +++ b/web/app.js @@ -69,14 +69,17 @@ const refineMaxSpan = qs('refineMaxSpan'); const resMaxRefine = qs('resMaxRefine'); const resMaxRecord = qs('resMaxRecord'); const resMaxDecode = qs('resMaxDecode'); +const resDecisionHold = qs('resDecisionHold'); const signalList = qs('signalList'); const signalDecisionSummary = qs('signalDecisionSummary'); +const signalQueueSummary = qs('signalQueueSummary'); const eventList = qs('eventList'); const recordingList = qs('recordingList'); const signalCountBadge = qs('signalCountBadge'); const eventCountBadge = qs('eventCountBadge'); const recordingCountBadge = qs('recordingCountBadge'); +const signalSummaryLine = qs('signalSummaryLine'); const healthBuffer = qs('healthBuffer'); const healthDropped = qs('healthDropped'); @@ -86,6 +89,16 @@ const healthGpu = qs('healthGpu'); const healthFps = qs('healthFps'); const healthRefinePlan = qs('healthRefinePlan'); const healthRefineWindows = qs('healthRefineWindows'); +const healthWs = qs('healthWs'); +const healthApi = qs('healthApi'); +const healthConfig = qs('healthConfig'); +const healthRefine = qs('healthRefine'); +const healthTelemetry = qs('healthTelemetry'); +const healthSource = qs('healthSource'); +const refineDetails = qs('refineDetails'); +const telemetryEventList = qs('telemetryEventList'); +const policySummaryList = qs('policySummaryList'); +const policyRecommendationList = qs('policyRecommendationList'); const drawerEl = qs('eventDrawer'); const drawerCloseBtn = qs('drawerClose'); @@ -139,6 +152,27 @@ let liveListenInfo = null; let stats = { buffer_samples: 0, dropped: 0, resets: 0, last_sample_ago_ms: -1 }; let refinementInfo = {}; let decisionIndex = new Map(); +let telemetryLive = null; +let policyInfo = null; +let policyRecommendations = null; +let wsState = 'init'; +let wsLastMessageTs = 0; +let apiState = { ok: false, latencyMs: null, lastOkTs: 0, lastError: '' }; +const apiClient = window.SpectreApi?.createClient + ? window.SpectreApi.createClient({ timeoutMs: 4500 }) + : null; +const operatorPanel = window.OperatorPanel?.create + ? window.OperatorPanel.create({ + healthWs, + healthApi, + healthConfig, + healthRefine, + healthTelemetry, + healthSource, + refineDetails, + telemetryEvents: telemetryEventList + }) + : null; // --------------------------------------------------------------------------- // LiveListenWS — WebSocket-based gapless audio streaming via /ws/audio @@ -578,6 +612,7 @@ function setConfigStatus(text) { } function setWsBadge(text, kind = 'neutral') { + wsState = kind === 'ok' ? 'live' : (kind === 'bad' ? 'retrying' : 'connecting'); wsBadge.textContent = text; wsBadge.style.borderColor = kind === 'ok' ? 'rgba(124, 251, 131, 0.35)' @@ -586,6 +621,68 @@ function setWsBadge(text, kind = 'neutral') { : 'rgba(112, 150, 207, 0.18)'; } +function updateApiState(result) { + if (!result) return; + const latency = result.meta?.duration_ms; + if (Number.isFinite(latency)) apiState.latencyMs = latency; + if (result.ok) { + apiState.ok = true; + apiState.lastOkTs = Date.now(); + apiState.lastError = ''; + } else { + apiState.ok = false; + apiState.lastError = result.error || (result.status ? `HTTP ${result.status}` : 'request failed'); + } +} + +function fmtAgeShort(ms) { + if (!Number.isFinite(ms) || ms < 0) return '-'; + if (ms < 1000) return `${Math.round(ms)} ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)} s`; + return `${Math.round(ms / 60000)} min`; +} + +function updateOperatorStatus() { + if (operatorPanel) { + operatorPanel.updateStatus({ + wsState, + wsLastMessageTs, + apiState, + configStatusText: configStatusEl?.textContent || '-', + refinementInfo, + telemetryLive, + sourceAgeMs: stats?.last_sample_ago_ms + }); + return; + } + if (healthWs) { + const age = wsLastMessageTs > 0 ? fmtAgeShort(Date.now() - wsLastMessageTs) : '-'; + healthWs.textContent = `${wsState} | last ${age}`; + } + if (healthApi) { + const latency = Number.isFinite(apiState.latencyMs) ? `${apiState.latencyMs} ms` : 'n/a'; + healthApi.textContent = apiState.ok ? `ok | ${latency}` : `degraded | ${apiState.lastError || 'n/a'}`; + } + if (healthConfig) { + healthConfig.textContent = configStatusEl?.textContent || '-'; + } + if (healthRefine) { + const plan = refinementInfo.plan || {}; + const queue = refinementInfo.arbitration?.queue || {}; + healthRefine.textContent = `${plan.selected?.length || 0}/${plan.budget || 0} | q ${queue.record_queued || 0}/${queue.decode_queued || 0}`; + } + if (healthTelemetry) { + if (!telemetryLive) { + healthTelemetry.textContent = 'unavailable'; + } else { + const enabled = telemetryLive.enabled === false ? 'off' : 'on'; + const collector = telemetryLive.collector || {}; + const recent = Array.isArray(telemetryLive.recent_events) ? telemetryLive.recent_events.length : 0; + const heavy = collector.heavy_enabled ? 'heavy' : 'light'; + healthTelemetry.textContent = `${enabled} | ${heavy} | events ${recent}`; + } + } +} function toMHz(hz) { return hz / 1e6; } function fromMHz(mhz) { return mhz * 1e6; } function fmtMHz(hz, digits = 3) { return `${(hz / 1e6).toFixed(digits)} MHz`; } @@ -883,78 +980,139 @@ function applyConfigToUI(cfg) { } async function loadConfig() { - try { - const res = await fetch('/api/config'); - if (!res.ok) throw new Error('config'); - currentConfig = await res.json(); + if (!apiClient) return; + const res = await apiClient.getConfig(); + updateApiState(res); + if (res.ok && res.data) { + currentConfig = res.data; applyConfigToUI(currentConfig); setConfigStatus('Config synced'); - } catch { + } else { setConfigStatus('Config offline'); } + updateOperatorStatus(); } async function loadSignals() { - try { - const res = await fetch('/api/signals'); - if (!res.ok) return; - const sigs = await res.json(); - if (Array.isArray(sigs)) { - latest = latest || {}; - latest.signals = sigs; - renderLists(); - } - } catch {} + if (!apiClient) return; + const res = await apiClient.getSignals(); + updateApiState(res); + if (!res.ok || !Array.isArray(res.data)) return; + latest = latest || {}; + latest.signals = res.data; + renderLists(); } async function loadDecoders() { - if (!decodeModeSelect) return; - try { - const res = await fetch('/api/decoders'); - if (!res.ok) return; - const list = await res.json(); - if (!Array.isArray(list)) return; - const current = decodeModeSelect.value; - decodeModeSelect.innerHTML = ''; - list.forEach((mode) => { - const opt = document.createElement('option'); - opt.value = mode; - opt.textContent = mode; - decodeModeSelect.appendChild(opt); - }); - if (current) decodeModeSelect.value = current; - } catch {} + if (!decodeModeSelect || !apiClient) return; + const res = await apiClient.getDecoders(); + updateApiState(res); + if (!res.ok || !Array.isArray(res.data)) return; + const current = decodeModeSelect.value; + decodeModeSelect.innerHTML = ''; + res.data.forEach((mode) => { + const opt = document.createElement('option'); + opt.value = mode; + opt.textContent = mode; + decodeModeSelect.appendChild(opt); + }); + if (current) decodeModeSelect.value = current; } async function loadStats() { - try { - const res = await fetch('/api/stats'); - if (!res.ok) return; - stats = await res.json(); - } catch {} + if (!apiClient) return; + const res = await apiClient.getStats(); + updateApiState(res); + if (res.ok && res.data) stats = res.data; + updateOperatorStatus(); } async function loadGPU() { - try { - const res = await fetch('/api/gpu'); - if (!res.ok) return; - gpuInfo = await res.json(); - } catch {} + if (!apiClient) return; + const res = await apiClient.getGPU(); + updateApiState(res); + if (res.ok && res.data) gpuInfo = res.data; + updateOperatorStatus(); } async function loadRefinement() { - try { - const res = await fetch('/api/refinement'); - if (!res.ok) return; - refinementInfo = await res.json(); - decisionIndex = new Map(); - const items = Array.isArray(refinementInfo.arbitration?.decision_items) ? refinementInfo.arbitration.decision_items : []; - items.forEach(item => { - if (item && item.id != null) decisionIndex.set(String(item.id), item); - }); - updateSignalDecisionSummary(window._selectedSignal?.id); - updateSignalQueueSummary(); - } catch {} + if (!apiClient) return; + const res = await apiClient.getRefinement(); + updateApiState(res); + if (!res.ok || !res.data) return; + refinementInfo = res.data; + decisionIndex = new Map(); + const items = Array.isArray(refinementInfo.arbitration?.decision_items) ? refinementInfo.arbitration.decision_items : []; + items.forEach(item => { + if (item && item.id != null) decisionIndex.set(String(item.id), item); + }); + updateSignalDecisionSummary(window._selectedSignal?.id); + updateSignalQueueSummary(); + updateOperatorStatus(); +} + +async function loadTelemetryLive() { + if (!apiClient) return; + const res = await apiClient.getTelemetryLive(); + updateApiState(res); + if (!res.ok) { + telemetryLive = null; + updateOperatorStatus(); + return; + } + telemetryLive = res.data; + updateOperatorStatus(); +} + +function renderKvList(root, rows, emptyText) { + if (!root) return; + if (!rows || !rows.length) { + root.innerHTML = `