| @@ -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); | |||||
| @@ -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 { | ||||