Explorar el Código

feat: improve web UI responsiveness

master
Jan Svabenik hace 1 mes
padre
commit
76cffe6656
Se han modificado 5 ficheros con 999 adiciones y 191 borrados
  1. +74
    -0
      web/api-client.js
  2. +504
    -166
      web/app.js
  3. +61
    -0
      web/index.html
  4. +137
    -0
      web/operator-panel.js
  5. +223
    -25
      web/style.css

+ 74
- 0
web/api-client.js Ver fichero

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

+ 504
- 166
web/app.js
La diferencia del archivo ha sido suprimido porque es demasiado grande
Ver fichero


+ 61
- 0
web/index.html Ver fichero

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


+ 137
- 0
web/operator-panel.js Ver fichero

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

+ 223
- 25
web/style.css Ver fichero

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


Cargando…
Cancelar
Guardar