Просмотр исходного кода

feat: checkpoint ui refactor work

undefined
Jan Svabenik 1 месяц назад
Родитель
Сommit
5d0962796c
9 измененных файлов: 932 добавлений и 178 удалений
  1. +41
    -0
      codex_webinterface_prompt.txt
  2. +11
    -9
      config.autosave.yaml
  3. +6
    -6
      config.yaml
  4. +29
    -0
      fix_mojibake.py
  5. +74
    -0
      web/api-client.js
  6. +350
    -138
      web/app.js
  7. +61
    -0
      web/index.html
  8. +137
    -0
      web/operator-panel.js
  9. +223
    -25
      web/style.css

+ 41
- 0
codex_webinterface_prompt.txt Просмотреть файл

@@ -0,0 +1,41 @@
Arbeite im Repository C:\Users\jan\Downloads\sdr-wideband-suite auf dem bereits angelegten Branch refactor/webinterface.

WICHTIG:
- NICHT committen.
- NICHT pushen.
- Keine Branches wechseln oder neu anlegen.
- config.yaml, config.autosave.yaml, Debug-Dumps und lokale Laufzeitartefakte nicht absichtlich verändern, außer es ist für das Frontend unbedingt nötig — dann nur minimal und uncommitted.
- Vor dem Start zuerst das Repo einlesen: AGENTS.md, README.md, docs/known-issues.md, relevante API-/WS-Serverstellen in cmd/sdrd/http_handlers.go und cmd/sdrd/ws_handlers.go, sowie das Frontend unter web/.

Kontext / Ziel:
- Das Webinterface soll erweitert und verbessert werden.
- Fokus auf Webinterface-Refactor mit besserer Struktur, besserer UX und klarer Ausrichtung an der existierenden API.
- Aktuell ist das Frontend ein großes Vanilla-JS-Monolith-Skript in web/app.js. Bitte den aktuellen Zustand zuerst analysieren und dann gezielt verbessern, ohne unnötig die gesamte App umzubauen.

Bitte liefere:
1. Eine kurze Analyse des aktuellen UI/API-Zuschnitts und der größten Schwachstellen.
2. Einen konkreten Umsetzungsplan für einen sinnvollen ersten Refactor-Schritt.
3. Dann die Umsetzung direkt im Branch: verbessere/erweitere das Webinterface spürbar.

Bevorzugte Richtungen für die Umsetzung (du darfst priorisieren):
- bessere Struktur/Modularisierung innerhalb von web/app.js bzw. Aufteilung in mehrere Web-Dateien, wenn das ohne riesigen Umbau sauber machbar ist
- klarere Zustandsdarstellung für API/WS-Verbindung, Config-Status, Source/GPU/Health
- bessere Nutzbarkeit der vorhandenen APIs (/api/refinement, /api/events, /api/recordings, /api/debug/telemetry/*) im UI
- UI-Verbesserungen für Signal-/Event-/Recording-Ansichten
- bessere leichtere Debug-/Operator-Ansicht statt nur verstreuter Informationen
- kleine, robuste UX-Verbesserungen > rein kosmetische Änderungen

Grenzen:
- Kein Full-Rewrite in Frameworks.
- Keine unnötigen Backend-API-Änderungen, außer klar sinnvoll und klein.
- Nicht versuchen, das gesamte Projekt zu lösen. Lieber ein sauberer, wertvoller erster Refactor.
- Wenn Builds/Checks sinnvoll sind, nutze für dieses Projekt bevorzugt die vorhandenen Projekt-Skripte/Workflow-Regeln aus AGENTS.md; für kleine Syntax-/Sanity-Checks sind gezielte Checks ok.

Wenn du fertig bist, berichte knapp:
- was du analysiert hast
- welche Dateien du geändert hast
- was funktional verbessert wurde
- ob es offene Punkte / Risiken gibt

Wenn komplett fertig, run this command:
openclaw system event --text "Done: webinterface refactor first pass completed on refactor/webinterface (no commit)" --mode now

+ 11
- 9
config.autosave.yaml Просмотреть файл

@@ -4,7 +4,7 @@ bands:
end_hz: 1.08e+08 end_hz: 1.08e+08
center_hz: 1.02e+08 center_hz: 1.02e+08
sample_rate: 4096000 sample_rate: 4096000
fft_size: 512
fft_size: 4096
gain_db: 32 gain_db: 32
tuner_bw_khz: 5000 tuner_bw_khz: 5000
use_gpu_fft: true use_gpu_fft: true
@@ -51,7 +51,7 @@ pipeline:
- WFM_STEREO - WFM_STEREO
- RDS - RDS
surveillance: surveillance:
analysis_fft_size: 512
analysis_fft_size: 4096
frame_rate: 12 frame_rate: 12
strategy: multi-resolution strategy: multi-resolution
display_bins: 2048 display_bins: 2048
@@ -267,9 +267,9 @@ profiles:
decision_hold_ms: 2500 decision_hold_ms: 2500
detector: detector:
threshold_db: -60 threshold_db: -60
min_duration_ms: 500
min_duration_ms: 5000
hold_ms: 1500 hold_ms: 1500
ema_alpha: 0.025
ema_alpha: 0.35
hysteresis_db: 10 hysteresis_db: 10
min_stable_frames: 4 min_stable_frames: 4
gap_tolerance_ms: 2000 gap_tolerance_ms: 2000
@@ -279,7 +279,7 @@ detector:
cfar_guard_cells: 3 cfar_guard_cells: 3
cfar_train_cells: 24 cfar_train_cells: 24
cfar_rank: 36 cfar_rank: 36
cfar_scale_db: 23
cfar_scale_db: 27
cfar_wrap_around: true cfar_wrap_around: true
edge_margin_db: 6 edge_margin_db: 6
max_signal_bw_hz: 260000 max_signal_bw_hz: 260000
@@ -295,10 +295,12 @@ recorder:
record_iq: false record_iq: false
record_audio: true record_audio: true
auto_demod: true auto_demod: true
auto_decode: false
auto_decode: true
max_disk_mb: 0 max_disk_mb: 0
output_dir: data/recordings output_dir: data/recordings
class_filter: []
class_filter:
- WFM
- WFM_STEREO
ring_seconds: 12 ring_seconds: 12
deemphasis_us: 50 deemphasis_us: 50
extraction_fir_taps: 101 extraction_fir_taps: 101
@@ -315,14 +317,14 @@ debug:
audio_dump_enabled: false audio_dump_enabled: false
cpu_monitoring: false cpu_monitoring: false
telemetry: telemetry:
enabled: true
enabled: false
heavy_enabled: false heavy_enabled: false
heavy_sample_every: 12 heavy_sample_every: 12
metric_sample_every: 8 metric_sample_every: 8
metric_history_max: 6000 metric_history_max: 6000
event_history_max: 1500 event_history_max: 1500
retention_seconds: 900 retention_seconds: 900
persist_enabled: false
persist_enabled: true
persist_dir: debug/telemetry persist_dir: debug/telemetry
rotate_mb: 16 rotate_mb: 16
keep_files: 8 keep_files: 8


+ 6
- 6
config.yaml Просмотреть файл

@@ -224,15 +224,15 @@ detector:
class_history_size: 10 class_history_size: 10
class_switch_ratio: 0.6 class_switch_ratio: 0.6
recorder: recorder:
enabled: true
enabled: false
min_snr_db: 0 min_snr_db: 0
min_duration: 500ms min_duration: 500ms
max_duration: 300s max_duration: 300s
preroll_ms: 500 preroll_ms: 500
record_iq: false record_iq: false
record_audio: true
auto_demod: true
auto_decode: true
record_audio: false
auto_demod: false
auto_decode:false
max_disk_mb: 0 max_disk_mb: 0
output_dir: data/recordings output_dir: data/recordings
class_filter: ["WFM", "WFM_STEREO"] class_filter: ["WFM", "WFM_STEREO"]
@@ -252,14 +252,14 @@ debug:
audio_dump_enabled: false audio_dump_enabled: false
cpu_monitoring: false cpu_monitoring: false
telemetry: telemetry:
enabled: true
enabled: false
heavy_enabled: false heavy_enabled: false
heavy_sample_every: 12 heavy_sample_every: 12
metric_sample_every: 8 metric_sample_every: 8
metric_history_max: 6000 metric_history_max: 6000
event_history_max: 1500 event_history_max: 1500
retention_seconds: 900 retention_seconds: 900
persist_enabled: true
persist_enabled: false
persist_dir: debug/telemetry persist_dir: debug/telemetry
rotate_mb: 16 rotate_mb: 16
keep_files: 8 keep_files: 8


+ 29
- 0
fix_mojibake.py Просмотреть файл

@@ -0,0 +1,29 @@
from pathlib import Path

files = [
Path(r"C:\Users\jan\Downloads\sdr-wideband-suite\web\app.js"),
Path(r"C:\Users\jan\Downloads\sdr-wideband-suite\web\index.html"),
Path(r"C:\Users\jan\Downloads\sdr-wideband-suite\web\style.css"),
]

repl = {
'·': '·',
'…': '…',
'—': '—',
'–': '–',
'→': '→',
'â†\x90': '←',
'â– ': '■',
'â– ': '■',
'×': '×',
'â•\x90': '═',
'─': '─',
}

for p in files:
s = p.read_text(encoding='utf-8')
o = s
for a, b in repl.items():
s = s.replace(a, b)
if s != o:
p.write_text(s, encoding='utf-8', newline='')

+ 74
- 0
web/api-client.js Просмотреть файл

@@ -0,0 +1,74 @@
(function (global) {
const DEFAULT_TIMEOUT_MS = 5000;

function toErrorMessage(err) {
if (!err) return 'request failed';
if (typeof err === 'string') return err;
return err.message || 'request failed';
}

function createClient(opts = {}) {
const baseUrl = opts.baseUrl || '';
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;

async function request(path, options = {}) {
const controller = new AbortController();
const start = performance.now();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const init = {
method: options.method || 'GET',
headers: { ...(options.headers || {}) },
signal: controller.signal,
};
if (options.body !== undefined) {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(options.body);
}

try {
const res = await fetch(`${baseUrl}${path}`, init);
const durationMs = Math.round(performance.now() - start);
const contentType = res.headers.get('content-type') || '';
let data = null;
if (contentType.includes('application/json')) data = await res.json();
else data = await res.text();
if (!res.ok) {
const error = typeof data === 'string' ? data : (data?.error || `http ${res.status}`);
return { ok: false, status: res.status, error, data, meta: { duration_ms: durationMs } };
}
return { ok: true, status: res.status, data, meta: { duration_ms: durationMs } };
} catch (err) {
const durationMs = Math.round(performance.now() - start);
return { ok: false, status: 0, error: toErrorMessage(err), data: null, meta: { duration_ms: durationMs } };
} finally {
clearTimeout(timer);
}
}

return {
getConfig: () => request('/api/config'),
postConfig: (payload) => request('/api/config', { method: 'POST', body: payload }),
postSettings: (payload) => request('/api/sdr/settings', { method: 'POST', body: payload }),
getSignals: () => request('/api/signals'),
getStats: () => request('/api/stats'),
getGPU: () => request('/api/gpu'),
getPolicy: () => request('/api/pipeline/policy'),
getRecommendations: () => request('/api/pipeline/recommendations'),
getRefinement: () => request('/api/refinement'),
getEvents: ({ limit, since } = {}) => {
const params = new URLSearchParams();
if (Number.isFinite(limit) && limit > 0) params.set('limit', String(limit));
if (Number.isFinite(since) && since > 0) params.set('since', String(since));
const suffix = params.toString() ? `?${params.toString()}` : '';
return request(`/api/events${suffix}`);
},
getRecordings: () => request('/api/recordings'),
getRecording: (id) => request(`/api/recordings/${encodeURIComponent(id)}`),
decodeRecording: (id, mode) => request(`/api/recordings/${encodeURIComponent(id)}/decode?mode=${encodeURIComponent(mode || '')}`),
getDecoders: () => request('/api/decoders'),
getTelemetryLive: () => request('/api/debug/telemetry/live'),
};
}

global.SpectreApi = { createClient };
})(window);

+ 350
- 138
web/app.js Просмотреть файл

@@ -69,14 +69,17 @@ const refineMaxSpan = qs('refineMaxSpan');
const resMaxRefine = qs('resMaxRefine'); const resMaxRefine = qs('resMaxRefine');
const resMaxRecord = qs('resMaxRecord'); const resMaxRecord = qs('resMaxRecord');
const resMaxDecode = qs('resMaxDecode'); const resMaxDecode = qs('resMaxDecode');
const resDecisionHold = qs('resDecisionHold');


const signalList = qs('signalList'); const signalList = qs('signalList');
const signalDecisionSummary = qs('signalDecisionSummary'); const signalDecisionSummary = qs('signalDecisionSummary');
const signalQueueSummary = qs('signalQueueSummary');
const eventList = qs('eventList'); const eventList = qs('eventList');
const recordingList = qs('recordingList'); const recordingList = qs('recordingList');
const signalCountBadge = qs('signalCountBadge'); const signalCountBadge = qs('signalCountBadge');
const eventCountBadge = qs('eventCountBadge'); const eventCountBadge = qs('eventCountBadge');
const recordingCountBadge = qs('recordingCountBadge'); const recordingCountBadge = qs('recordingCountBadge');
const signalSummaryLine = qs('signalSummaryLine');


const healthBuffer = qs('healthBuffer'); const healthBuffer = qs('healthBuffer');
const healthDropped = qs('healthDropped'); const healthDropped = qs('healthDropped');
@@ -86,6 +89,16 @@ const healthGpu = qs('healthGpu');
const healthFps = qs('healthFps'); const healthFps = qs('healthFps');
const healthRefinePlan = qs('healthRefinePlan'); const healthRefinePlan = qs('healthRefinePlan');
const healthRefineWindows = qs('healthRefineWindows'); const healthRefineWindows = qs('healthRefineWindows');
const healthWs = qs('healthWs');
const healthApi = qs('healthApi');
const healthConfig = qs('healthConfig');
const healthRefine = qs('healthRefine');
const healthTelemetry = qs('healthTelemetry');
const healthSource = qs('healthSource');
const refineDetails = qs('refineDetails');
const telemetryEventList = qs('telemetryEventList');
const policySummaryList = qs('policySummaryList');
const policyRecommendationList = qs('policyRecommendationList');


const drawerEl = qs('eventDrawer'); const drawerEl = qs('eventDrawer');
const drawerCloseBtn = qs('drawerClose'); const drawerCloseBtn = qs('drawerClose');
@@ -139,6 +152,27 @@ let liveListenInfo = null;
let stats = { buffer_samples: 0, dropped: 0, resets: 0, last_sample_ago_ms: -1 }; let stats = { buffer_samples: 0, dropped: 0, resets: 0, last_sample_ago_ms: -1 };
let refinementInfo = {}; let refinementInfo = {};
let decisionIndex = new Map(); let decisionIndex = new Map();
let telemetryLive = null;
let policyInfo = null;
let policyRecommendations = null;
let wsState = 'init';
let wsLastMessageTs = 0;
let apiState = { ok: false, latencyMs: null, lastOkTs: 0, lastError: '' };
const apiClient = window.SpectreApi?.createClient
? window.SpectreApi.createClient({ timeoutMs: 4500 })
: null;
const operatorPanel = window.OperatorPanel?.create
? window.OperatorPanel.create({
healthWs,
healthApi,
healthConfig,
healthRefine,
healthTelemetry,
healthSource,
refineDetails,
telemetryEvents: telemetryEventList
})
: null;


// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// LiveListenWS — WebSocket-based gapless audio streaming via /ws/audio // LiveListenWS — WebSocket-based gapless audio streaming via /ws/audio
@@ -578,6 +612,7 @@ function setConfigStatus(text) {
} }


function setWsBadge(text, kind = 'neutral') { function setWsBadge(text, kind = 'neutral') {
wsState = kind === 'ok' ? 'live' : (kind === 'bad' ? 'retrying' : 'connecting');
wsBadge.textContent = text; wsBadge.textContent = text;
wsBadge.style.borderColor = kind === 'ok' wsBadge.style.borderColor = kind === 'ok'
? 'rgba(124, 251, 131, 0.35)' ? 'rgba(124, 251, 131, 0.35)'
@@ -586,6 +621,68 @@ function setWsBadge(text, kind = 'neutral') {
: 'rgba(112, 150, 207, 0.18)'; : 'rgba(112, 150, 207, 0.18)';
} }


function updateApiState(result) {
if (!result) return;
const latency = result.meta?.duration_ms;
if (Number.isFinite(latency)) apiState.latencyMs = latency;
if (result.ok) {
apiState.ok = true;
apiState.lastOkTs = Date.now();
apiState.lastError = '';
} else {
apiState.ok = false;
apiState.lastError = result.error || (result.status ? `HTTP ${result.status}` : 'request failed');
}
}

function fmtAgeShort(ms) {
if (!Number.isFinite(ms) || ms < 0) return '-';
if (ms < 1000) return `${Math.round(ms)} ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)} s`;
return `${Math.round(ms / 60000)} min`;
}

function updateOperatorStatus() {
if (operatorPanel) {
operatorPanel.updateStatus({
wsState,
wsLastMessageTs,
apiState,
configStatusText: configStatusEl?.textContent || '-',
refinementInfo,
telemetryLive,
sourceAgeMs: stats?.last_sample_ago_ms
});
return;
}
if (healthWs) {
const age = wsLastMessageTs > 0 ? fmtAgeShort(Date.now() - wsLastMessageTs) : '-';
healthWs.textContent = `${wsState} | last ${age}`;
}
if (healthApi) {
const latency = Number.isFinite(apiState.latencyMs) ? `${apiState.latencyMs} ms` : 'n/a';
healthApi.textContent = apiState.ok ? `ok | ${latency}` : `degraded | ${apiState.lastError || 'n/a'}`;
}
if (healthConfig) {
healthConfig.textContent = configStatusEl?.textContent || '-';
}
if (healthRefine) {
const plan = refinementInfo.plan || {};
const queue = refinementInfo.arbitration?.queue || {};
healthRefine.textContent = `${plan.selected?.length || 0}/${plan.budget || 0} | q ${queue.record_queued || 0}/${queue.decode_queued || 0}`;
}
if (healthTelemetry) {
if (!telemetryLive) {
healthTelemetry.textContent = 'unavailable';
} else {
const enabled = telemetryLive.enabled === false ? 'off' : 'on';
const collector = telemetryLive.collector || {};
const recent = Array.isArray(telemetryLive.recent_events) ? telemetryLive.recent_events.length : 0;
const heavy = collector.heavy_enabled ? 'heavy' : 'light';
healthTelemetry.textContent = `${enabled} | ${heavy} | events ${recent}`;
}
}
}
function toMHz(hz) { return hz / 1e6; } function toMHz(hz) { return hz / 1e6; }
function fromMHz(mhz) { return mhz * 1e6; } function fromMHz(mhz) { return mhz * 1e6; }
function fmtMHz(hz, digits = 3) { return `${(hz / 1e6).toFixed(digits)} MHz`; } function fmtMHz(hz, digits = 3) { return `${(hz / 1e6).toFixed(digits)} MHz`; }
@@ -883,78 +980,139 @@ function applyConfigToUI(cfg) {
} }


async function loadConfig() { async function loadConfig() {
try {
const res = await fetch('/api/config');
if (!res.ok) throw new Error('config');
currentConfig = await res.json();
if (!apiClient) return;
const res = await apiClient.getConfig();
updateApiState(res);
if (res.ok && res.data) {
currentConfig = res.data;
applyConfigToUI(currentConfig); applyConfigToUI(currentConfig);
setConfigStatus('Config synced'); setConfigStatus('Config synced');
} catch {
} else {
setConfigStatus('Config offline'); setConfigStatus('Config offline');
} }
updateOperatorStatus();
} }


async function loadSignals() { async function loadSignals() {
try {
const res = await fetch('/api/signals');
if (!res.ok) return;
const sigs = await res.json();
if (Array.isArray(sigs)) {
latest = latest || {};
latest.signals = sigs;
renderLists();
}
} catch {}
if (!apiClient) return;
const res = await apiClient.getSignals();
updateApiState(res);
if (!res.ok || !Array.isArray(res.data)) return;
latest = latest || {};
latest.signals = res.data;
renderLists();
} }


async function loadDecoders() { async function loadDecoders() {
if (!decodeModeSelect) return;
try {
const res = await fetch('/api/decoders');
if (!res.ok) return;
const list = await res.json();
if (!Array.isArray(list)) return;
const current = decodeModeSelect.value;
decodeModeSelect.innerHTML = '';
list.forEach((mode) => {
const opt = document.createElement('option');
opt.value = mode;
opt.textContent = mode;
decodeModeSelect.appendChild(opt);
});
if (current) decodeModeSelect.value = current;
} catch {}
if (!decodeModeSelect || !apiClient) return;
const res = await apiClient.getDecoders();
updateApiState(res);
if (!res.ok || !Array.isArray(res.data)) return;
const current = decodeModeSelect.value;
decodeModeSelect.innerHTML = '';
res.data.forEach((mode) => {
const opt = document.createElement('option');
opt.value = mode;
opt.textContent = mode;
decodeModeSelect.appendChild(opt);
});
if (current) decodeModeSelect.value = current;
} }


async function loadStats() { async function loadStats() {
try {
const res = await fetch('/api/stats');
if (!res.ok) return;
stats = await res.json();
} catch {}
if (!apiClient) return;
const res = await apiClient.getStats();
updateApiState(res);
if (res.ok && res.data) stats = res.data;
updateOperatorStatus();
} }


async function loadGPU() { async function loadGPU() {
try {
const res = await fetch('/api/gpu');
if (!res.ok) return;
gpuInfo = await res.json();
} catch {}
if (!apiClient) return;
const res = await apiClient.getGPU();
updateApiState(res);
if (res.ok && res.data) gpuInfo = res.data;
updateOperatorStatus();
} }


async function loadRefinement() { async function loadRefinement() {
try {
const res = await fetch('/api/refinement');
if (!res.ok) return;
refinementInfo = await res.json();
decisionIndex = new Map();
const items = Array.isArray(refinementInfo.arbitration?.decision_items) ? refinementInfo.arbitration.decision_items : [];
items.forEach(item => {
if (item && item.id != null) decisionIndex.set(String(item.id), item);
});
updateSignalDecisionSummary(window._selectedSignal?.id);
updateSignalQueueSummary();
} catch {}
if (!apiClient) return;
const res = await apiClient.getRefinement();
updateApiState(res);
if (!res.ok || !res.data) return;
refinementInfo = res.data;
decisionIndex = new Map();
const items = Array.isArray(refinementInfo.arbitration?.decision_items) ? refinementInfo.arbitration.decision_items : [];
items.forEach(item => {
if (item && item.id != null) decisionIndex.set(String(item.id), item);
});
updateSignalDecisionSummary(window._selectedSignal?.id);
updateSignalQueueSummary();
updateOperatorStatus();
}

async function loadTelemetryLive() {
if (!apiClient) return;
const res = await apiClient.getTelemetryLive();
updateApiState(res);
if (!res.ok) {
telemetryLive = null;
updateOperatorStatus();
return;
}
telemetryLive = res.data;
updateOperatorStatus();
}

function renderKvList(root, rows, emptyText) {
if (!root) return;
if (!rows || !rows.length) {
root.innerHTML = `<div class="ops-line ops-line--muted">${emptyText}</div>`;
return;
}
root.innerHTML = rows.map(({ key, value }) => `<div class="kv-row"><div class="kv-key">${key}</div><div class="kv-val">${value}</div></div>`).join('');
}

function renderPolicyLists() {
if (policySummaryList) {
if (!policyInfo) {
renderKvList(policySummaryList, [], 'Policy unavailable.');
} else {
renderKvList(policySummaryList, [
{ key: 'Profile', value: policyInfo.profile || 'n/a' },
{ key: 'Mode', value: policyInfo.mode || 'n/a' },
{ key: 'Intent', value: policyInfo.intent || 'n/a' },
{ key: 'Surveillance', value: policyInfo.surveillance_strategy || 'n/a' },
{ key: 'Refinement', value: policyInfo.refinement_strategy || 'n/a' }
], 'Policy unavailable.');
}
}
if (policyRecommendationList) {
if (!policyRecommendations) {
renderKvList(policyRecommendationList, [], 'Recommendations unavailable.');
} else {
renderKvList(policyRecommendationList, [
{ key: 'Monitor span', value: Number.isFinite(policyRecommendations.monitor_span_hz) ? fmtHz(policyRecommendations.monitor_span_hz) : 'n/a' },
{ key: 'Refine jobs', value: policyRecommendations.refinement_jobs ?? 'n/a' },
{ key: 'Detail FFT', value: policyRecommendations.refinement_detail_fft ?? 'n/a' },
{ key: 'Auto span', value: policyRecommendations.refinement_auto_span ?? 'n/a' },
{ key: 'Auto record', value: Array.isArray(policyRecommendations.auto_record_classes) && policyRecommendations.auto_record_classes.length ? policyRecommendations.auto_record_classes.join(', ') : 'n/a' },
{ key: 'Auto decode', value: Array.isArray(policyRecommendations.auto_decode_classes) && policyRecommendations.auto_decode_classes.length ? policyRecommendations.auto_decode_classes.join(', ') : 'n/a' }
], 'Recommendations unavailable.');
}
}
}

async function loadPolicy() {
if (!apiClient) return;
const [policyRes, recRes] = await Promise.all([
apiClient.getPolicy(),
apiClient.getRecommendations()
]);
updateApiState(policyRes.ok ? policyRes : recRes);
policyInfo = policyRes.ok ? policyRes.data : null;
policyRecommendations = recRes.ok ? recRes.data : null;
renderPolicyLists();
} }


function formatLevelSummary(level) { function formatLevelSummary(level) {
@@ -989,17 +1147,14 @@ async function sendConfigUpdate() {
if (!pendingConfigUpdate) return; if (!pendingConfigUpdate) return;
const payload = pendingConfigUpdate; const payload = pendingConfigUpdate;
pendingConfigUpdate = null; pendingConfigUpdate = null;
try {
const res = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('apply');
currentConfig = await res.json();
if (!apiClient) return;
const res = await apiClient.postConfig(payload);
updateApiState(res);
if (res.ok && res.data) {
currentConfig = res.data;
applyConfigToUI(currentConfig); applyConfigToUI(currentConfig);
setConfigStatus('Config applied'); setConfigStatus('Config applied');
} catch {
} else {
setConfigStatus('Config apply failed'); setConfigStatus('Config apply failed');
} }
} }
@@ -1008,17 +1163,14 @@ async function sendSettingsUpdate() {
if (!pendingSettingsUpdate) return; if (!pendingSettingsUpdate) return;
const payload = pendingSettingsUpdate; const payload = pendingSettingsUpdate;
pendingSettingsUpdate = null; pendingSettingsUpdate = null;
try {
const res = await fetch('/api/sdr/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('apply');
currentConfig = await res.json();
if (!apiClient) return;
const res = await apiClient.postSettings(payload);
updateApiState(res);
if (res.ok && res.data) {
currentConfig = res.data;
applyConfigToUI(currentConfig); applyConfigToUI(currentConfig);
setConfigStatus('Settings applied'); setConfigStatus('Settings applied');
} catch {
} else {
setConfigStatus('Settings apply failed'); setConfigStatus('Settings apply failed');
} }
} }
@@ -1570,6 +1722,25 @@ function updateSignalDecisionSummary(id) {
signalDecisionSummary.textContent = `Decision: ${flags}${reason}`; signalDecisionSummary.textContent = `Decision: ${flags}${reason}`;
} }


function setSelectedSignal(sel) {
window._selectedSignal = sel || null;
signalList.querySelectorAll('.signal-item').forEach((el) => {
const active = !!sel && ((sel.key && el.dataset.key === sel.key) || (!sel.key && sel.id && el.dataset.id === String(sel.id)));
el.classList.toggle('active', active);
});
updateSignalDecisionSummary(window._selectedSignal?.id);
updateSignalQueueSummary();
}

function getSignalDomKey(s) {
if (s?.id != null && s.id !== '') return `id:${s.id}`;
const center = Math.round(Number(s?.center_hz || 0));
const bw = Math.round(Number(s?.bw_hz || 0));
const mode = getSignalPrimaryMode(s) || s?.class?.mod_type || 'UNK';
const rds = s?.class?.pll?.rds_station || '';
return `sig:${center}:${bw}:${mode}:${rds}`;
}

function _createSignalItem(s) { function _createSignalItem(s) {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.className = 'list-item signal-item'; btn.className = 'list-item signal-item';
@@ -1577,7 +1748,8 @@ function _createSignalItem(s) {
btn.dataset.center = s.center_hz; btn.dataset.center = s.center_hz;
btn.dataset.bw = s.bw_hz || 0; btn.dataset.bw = s.bw_hz || 0;
btn.dataset.class = s.class?.mod_type || ''; btn.dataset.class = s.class?.mod_type || '';
btn.dataset.id = s.id || 0;
btn.dataset.id = s.id ?? '';
btn.dataset.key = getSignalDomKey(s);
const primaryMode = getSignalPrimaryMode(s); const primaryMode = getSignalPrimaryMode(s);
const runtimeInfo = getSignalRuntimeSummary(s); const runtimeInfo = getSignalRuntimeSummary(s);
const mc = modColor(primaryMode); const mc = modColor(primaryMode);
@@ -1632,6 +1804,8 @@ function _patchSignalItem(el, s) {
el.dataset.center = s.center_hz; el.dataset.center = s.center_hz;
el.dataset.bw = s.bw_hz || 0; el.dataset.bw = s.bw_hz || 0;
el.dataset.class = mod; el.dataset.class = mod;
el.dataset.id = s.id ?? '';
el.dataset.key = getSignalDomKey(s);
el.style.borderLeftColor = mc.label; el.style.borderLeftColor = mc.label;
el.classList.toggle('listening', matchesListenTarget(s)); el.classList.toggle('listening', matchesListenTarget(s));
} }
@@ -1643,40 +1817,51 @@ function renderLists() {
metricSignals.textContent = String(signals.length); metricSignals.textContent = String(signals.length);


const displaySigs = signals.slice(0, 24); const displaySigs = signals.slice(0, 24);
const wantIds = new Set(displaySigs.map(s => String(s.id || 0)));

// Remove empty-state placeholder if signals exist
const emptyEl = signalList.querySelector('.empty-state');
if (emptyEl && displaySigs.length > 0) emptyEl.remove();

// Remove DOM items whose signal ID is no longer present
signalList.querySelectorAll('.signal-item').forEach(el => {
if (!wantIds.has(el.dataset.id)) el.remove();
});
const strongest = displaySigs[0] || null;
const selectedSignal = window._selectedSignal || null;
if (signalSummaryLine) {
const strongestText = strongest
? `${fmtMHz(strongest.center_hz, 4)} · ${(strongest.snr_db || 0).toFixed(1)} dB`
: '-';
const selectedText = selectedSignal && Number.isFinite(selectedSignal.freq)
? `${fmtMHz(selectedSignal.freq, 4)}${selectedSignal.mode ? ` · ${selectedSignal.mode}` : ''}`
: '-';
signalSummaryLine.textContent = `Visible: ${displaySigs.length} · Strongest: ${strongestText} · Selected: ${selectedText}`;
}


if (displaySigs.length === 0) { if (displaySigs.length === 0) {
if (!signalList.querySelector('.empty-state')) {
signalList.innerHTML = '<div class="empty-state">No live signals yet.</div>';
}
signalList.innerHTML = '<div class="empty-state">No live signals yet.</div>';
} else { } else {
// Build map of existing DOM items
const domById = new Map();
signalList.querySelectorAll('.signal-item').forEach(el => domById.set(el.dataset.id, el));
displaySigs.forEach(s => {
const id = String(s.id || 0);
const existing = domById.get(id);
if (existing) {
_patchSignalItem(existing, s);
const existing = new Map();
signalList.querySelectorAll('.signal-item').forEach((el) => existing.set(el.dataset.key, el));
const frag = document.createDocumentFragment();
displaySigs.forEach((s) => {
const key = getSignalDomKey(s);
let el = existing.get(key);
if (el) {
_patchSignalItem(el, s);
} else { } else {
const el = _createSignalItem(s);
// Auto-select if it matches the user's last selection
if (window._selectedSignal && Math.abs(s.center_hz - window._selectedSignal.freq) < 50000) {
el.classList.add('active');
}
signalList.appendChild(el);
el = _createSignalItem(s);
}

if (window._selectedSignal) {
const selectedKey = window._selectedSignal.key;
const selectedId = window._selectedSignal.id;
const sameKey = selectedKey && key === selectedKey;
const sameId = !selectedKey && selectedId && s.id != null && String(s.id) === String(selectedId);
const nearFreq = !selectedKey && !selectedId && Number.isFinite(window._selectedSignal.freq) && Math.abs(s.center_hz - window._selectedSignal.freq) < 2500;
el.classList.toggle('active', !!(sameKey || sameId || nearFreq));
} else {
el.classList.remove('active');
} }

frag.appendChild(el);
existing.delete(key);
}); });

signalList.innerHTML = '';
signalList.appendChild(frag);
} }


const recent = [...events].sort((a, b) => b.end_ms - a.end_ms); const recent = [...events].sort((a, b) => b.end_ms - a.end_ms);
@@ -1760,29 +1945,28 @@ function upsertEvents(list, replace = false) {
} }


async function fetchEvents(initial) { async function fetchEvents(initial) {
if (eventsFetchInFlight || timelineFrozen) return;
if (eventsFetchInFlight || timelineFrozen || !apiClient) return;
eventsFetchInFlight = true; eventsFetchInFlight = true;
try { try {
let url = '/api/events?limit=1000';
if (!initial && lastEventEndMs > 0) url = `/api/events?since=${lastEventEndMs - 1}`;
const res = await fetch(url);
if (!res.ok) return;
const data = await res.json();
if (Array.isArray(data)) upsertEvents(data, initial);
const query = initial
? { limit: 1000 }
: (lastEventEndMs > 0 ? { since: lastEventEndMs - 1 } : { limit: 200 });
const res = await apiClient.getEvents(query);
updateApiState(res);
if (res.ok && Array.isArray(res.data)) upsertEvents(res.data, initial);
} finally { } finally {
eventsFetchInFlight = false; eventsFetchInFlight = false;
} }
} }


async function fetchRecordings() { async function fetchRecordings() {
if (recordingsFetchInFlight || !recordingList) return;
if (recordingsFetchInFlight || !recordingList || !apiClient) return;
recordingsFetchInFlight = true; recordingsFetchInFlight = true;
try { try {
const res = await fetch('/api/recordings');
if (!res.ok) return;
const data = await res.json();
if (Array.isArray(data)) {
recordings = data;
const res = await apiClient.getRecordings();
updateApiState(res);
if (res.ok && Array.isArray(res.data)) {
recordings = res.data;
renderLists(); renderLists();
} }
} finally { } finally {
@@ -1891,7 +2075,11 @@ function connect() {
ws.binaryType = 'arraybuffer'; ws.binaryType = 'arraybuffer';
setWsBadge('Connecting', 'neutral'); setWsBadge('Connecting', 'neutral');


ws.onopen = () => setWsBadge('Live', 'ok');
ws.onopen = () => {
setWsBadge('Live', 'ok');
wsLastMessageTs = Date.now();
updateOperatorStatus();
};
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) { if (ev.data instanceof ArrayBuffer) {
try { try {
@@ -1904,6 +2092,8 @@ function connect() {
} else { } else {
latest = JSON.parse(ev.data); latest = JSON.parse(ev.data);
} }
wsLastMessageTs = Date.now();
updateOperatorStatus();
markSpectrumDirty(); markSpectrumDirty();
if (followLive) pan = 0; if (followLive) pan = 0;
updateHeroMetrics(); updateHeroMetrics();
@@ -1911,6 +2101,7 @@ function connect() {
}; };
ws.onclose = () => { ws.onclose = () => {
setWsBadge('Retrying', 'bad'); setWsBadge('Retrying', 'bad');
updateOperatorStatus();
wsReconnectTimer = setTimeout(connect, 1000); wsReconnectTimer = setTimeout(connect, 1000);
}; };
ws.onerror = () => ws.close(); ws.onerror = () => ws.close();
@@ -2003,11 +2194,12 @@ function handleSpectrumClick(ev) {
const bw = sig.bw_hz || 12000; const bw = sig.bw_hz || 12000;
const mode = sig.class?.mod_type || ''; const mode = sig.class?.mod_type || '';
startLiveListen(freq, bw, mode); startLiveListen(freq, bw, mode);
window._selectedSignal = { freq, bw, mode };
// Update selected signal in list
signalList.querySelectorAll('.signal-item').forEach(el => {
const elFreq = parseFloat(el.dataset.center || '0');
el.classList.toggle('active', Math.abs(elFreq - freq) < Math.max(500, bw * 0.5));
setSelectedSignal({
key: getSignalDomKey(sig),
id: sig.id ?? null,
freq,
bw,
mode
}); });
return; return;
} }
@@ -2299,13 +2491,26 @@ presetButtons.forEach((btn) => {
}); });
}); });


railTabs.forEach((tab) => {
tab.addEventListener('click', () => {
railTabs.forEach(t => t.classList.toggle('active', t === tab));
tabPanels.forEach(panel => panel.classList.toggle('active', panel.dataset.panel === tab.dataset.tab));
function activateRailTab(tabName) {
railTabs.forEach((t) => {
const active = t.dataset.tab === tabName;
t.classList.toggle('active', active);
t.setAttribute('aria-selected', active ? 'true' : 'false');
});
tabPanels.forEach((panel) => {
const active = panel.dataset.panel === tabName;
panel.classList.toggle('active', active);
panel.hidden = !active;
panel.setAttribute('aria-hidden', active ? 'false' : 'true');
}); });
}

railTabs.forEach((tab) => {
tab.addEventListener('click', () => activateRailTab(tab.dataset.tab));
}); });


activateRailTab((railTabs.find((t) => t.classList.contains('active')) || railTabs[0])?.dataset.tab || 'radio');



drawerCloseBtn.addEventListener('click', closeDrawer); drawerCloseBtn.addEventListener('click', closeDrawer);
exportEventBtn.addEventListener('click', exportSelectedEvent); exportEventBtn.addEventListener('click', exportSelectedEvent);
@@ -2337,13 +2542,17 @@ if (decodeEventBtn) {
return; return;
} }
const mode = decodeModeSelect?.value || ev.class?.mod_type || 'FT8'; const mode = decodeModeSelect?.value || ev.class?.mod_type || 'FT8';
const res = await fetch(`/api/recordings/${rec.id}/decode?mode=${mode}`);
if (!res.ok) {
if (!apiClient) {
decodeResultEl.textContent = 'Decode: failed'; decodeResultEl.textContent = 'Decode: failed';
return; return;
} }
const data = await res.json();
decodeResultEl.textContent = `Decode: ${String(data.stdout || '').slice(0, 80)}`;
const res = await apiClient.decodeRecording(rec.id, mode);
updateApiState(res);
if (!res.ok || !res.data) {
decodeResultEl.textContent = 'Decode: failed';
return;
}
decodeResultEl.textContent = `Decode: ${String(res.data.stdout || '').slice(0, 80)}`;
}); });
} }
jumpToEventBtn.addEventListener('click', () => { jumpToEventBtn.addEventListener('click', () => {
@@ -2369,18 +2578,13 @@ signalList.addEventListener('click', (ev) => {
const target = ev.target.closest('.signal-item'); const target = ev.target.closest('.signal-item');
if (!target) return; if (!target) return;
// Select this signal for live listening — don't retune the SDR // Select this signal for live listening — don't retune the SDR
const allItems = signalList.querySelectorAll('.signal-item');
allItems.forEach(el => el.classList.remove('active'));
target.classList.add('active');
// Store selected signal data for Live Listen button
window._selectedSignal = {
setSelectedSignal({
key: target.dataset.key || null,
id: target.dataset.id || null, id: target.dataset.id || null,
freq: parseFloat(target.dataset.center), freq: parseFloat(target.dataset.center),
bw: parseFloat(target.dataset.bw || '12000'), bw: parseFloat(target.dataset.bw || '12000'),
mode: target.dataset.class || '' mode: target.dataset.class || ''
};
updateSignalDecisionSummary(window._selectedSignal.id);
updateSignalQueueSummary();
});
}); });


if (liveListenBtn) { if (liveListenBtn) {
@@ -2431,9 +2635,11 @@ if (recordingList) {
recordingAudioLink.href = `/api/recordings/${id}/audio`; recordingAudioLink.href = `/api/recordings/${id}/audio`;
} }
try { try {
const res = await fetch(`/api/recordings/${id}`);
if (!res.ok) return;
const meta = await res.json();
if (!apiClient) return;
const res = await apiClient.getRecording(id);
updateApiState(res);
if (!res.ok || !res.data) return;
const meta = res.data;
if (decodeResultEl) { if (decodeResultEl) {
const rds = meta.rds_ps ? `RDS: ${meta.rds_ps}` : ''; const rds = meta.rds_ps ? `RDS: ${meta.rds_ps}` : '';
decodeResultEl.textContent = `Decode: ${rds}`; decodeResultEl.textContent = `Decode: ${rds}`;
@@ -2486,11 +2692,14 @@ window.addEventListener('keydown', (ev) => {
} }
}); });


updateOperatorStatus();
loadConfig(); loadConfig();
resetLiveListenMeta(); resetLiveListenMeta();
loadStats(); loadStats();
loadGPU(); loadGPU();
loadRefinement(); loadRefinement();
loadTelemetryLive();
loadPolicy();
fetchEvents(true); fetchEvents(true);
fetchRecordings(); fetchRecordings();
loadDecoders(); loadDecoders();
@@ -2499,6 +2708,8 @@ requestAnimationFrame(renderLoop);
setInterval(loadStats, 1000); setInterval(loadStats, 1000);
setInterval(loadGPU, 1000); setInterval(loadGPU, 1000);
setInterval(loadRefinement, 1500); setInterval(loadRefinement, 1500);
setInterval(loadTelemetryLive, 3000);
setInterval(loadPolicy, 10000);
setInterval(() => fetchEvents(false), 2000); setInterval(() => fetchEvents(false), 2000);
setInterval(fetchRecordings, 5000); setInterval(fetchRecordings, 5000);
setInterval(loadSignals, 1500); setInterval(loadSignals, 1500);
@@ -2508,3 +2719,4 @@ setInterval(loadDecoders, 10000);








+ 61
- 0
web/index.html Просмотреть файл

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


+ 137
- 0
web/operator-panel.js Просмотреть файл

@@ -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 Просмотреть файл

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


Загрузка…
Отмена
Сохранить