| @@ -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"> | |||
| <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 { | |||