"use strict"; /* ------------------------------------------------------------------ * * Radar System web client. * Streams the live Qt plot as an image (latest-wins) + REST controls + * the processor live-settings panel, synced both ways with the desktop. * ------------------------------------------------------------------ */ /* ---- DOM handles ------------------------------------------------- */ const plotImg = document.getElementById("plot"); const btnStart = document.getElementById("btn-start"); const btnSingle = document.getElementById("btn-single"); const btnStop = document.getElementById("btn-stop"); const btnTmpRef = document.getElementById("btn-tmp-ref"); const btnApply = document.getElementById("btn-apply"); const btnResetHistory = document.getElementById("btn-reset-history"); const btnLoadConfig = document.getElementById("btn-load-config"); const configSelect = document.getElementById("config-select"); const configActiveEl = document.getElementById("config-active"); const savePathInput = document.getElementById("save-path"); const saveNameInput = document.getElementById("save-name"); const btnSaveDataset = document.getElementById("btn-save-dataset"); const settingsToggle = document.getElementById("settings-toggle"); const sidePanel = document.querySelector(".side-panel"); const settingsFields = document.getElementById("settings-fields"); const settingsNote = document.getElementById("settings-note"); const runningEl = document.getElementById("stat-running"); const processorEl = document.getElementById("stat-processor"); const ringEl = document.getElementById("stat-ring"); const scansEl = document.getElementById("stat-scans"); const staleEl = document.getElementById("stat-stale"); const toastEl = document.getElementById("toast"); /* ---- state ------------------------------------------------------- */ let pendingPng = null; // newest PNG (base64), shown on the next animation frame let lastFrameTs = 0; // performance.now() of the last received frame let pipelineRunning = false; // gates the config loader (loading requires a stopped pipeline) let saveFieldsPrefilled = false; // seed the save path/name fields once, then leave the operator's edits /* ---- helpers ----------------------------------------------------- */ function toast(message, isError) { toastEl.textContent = message; toastEl.classList.toggle("error", !!isError); toastEl.classList.add("show"); clearTimeout(toast._t); toast._t = setTimeout(() => toastEl.classList.remove("show"), 2600); } async function api(path, body) { const opts = { method: body === undefined ? "GET" : "POST" }; if (body !== undefined) { opts.headers = { "Content-Type": "application/json" }; opts.body = JSON.stringify(body); } const res = await fetch(path, opts); let data = null; try { data = await res.json(); } catch (_) { /* empty body */ } if (!res.ok) { const detail = (data && data.detail) || `HTTP ${res.status}`; throw new Error(detail); } return data; } /* ---- controls ---------------------------------------------------- */ function bindControl(button, path, body) { button.addEventListener("click", async () => { button.disabled = true; try { await api(path, body); } catch (err) { toast(err.message, true); } finally { button.disabled = false; } }); } bindControl(btnStart, "/api/start", {}); bindControl(btnSingle, "/api/single_capture", {}); bindControl(btnStop, "/api/stop", {}); bindControl(btnTmpRef, "/api/tmp_reference", {}); /* ---- settings panel --------------------------------------------- */ settingsToggle.addEventListener("click", () => sidePanel.classList.toggle("collapsed")); // The form is built ENTIRELY from the schema the server derives from the Qt widgets // (field, group, kind, options, ranges, value, enabled). The web hardcodes nothing // and shows only the active mode's fields, so it always mirrors the desktop. const fieldInputs = {}; // field name -> { el, kind, dirty } let formSignature = ""; // field names currently in the form (detect mode/structure change) function makeFieldRow(entry) { const row = document.createElement("div"); row.className = "field"; const label = document.createElement("label"); label.textContent = entry.name; label.htmlFor = "f_" + entry.name; if (entry.applies_on_start) { // Stable run_config field: editing it here takes effect on the next pipeline start. const hint = document.createElement("span"); hint.className = "on-start-hint"; hint.textContent = " (on Start)"; hint.title = "Applied when the pipeline next starts"; label.appendChild(hint); } row.appendChild(label); let el; if (entry.kind === "bool") { el = document.createElement("input"); el.type = "checkbox"; el.checked = !!entry.value; } else if (entry.kind === "select") { el = document.createElement("select"); for (const opt of entry.options || []) { const o = document.createElement("option"); o.value = opt; o.textContent = opt; el.appendChild(o); } el.value = String(entry.value); } else if (entry.kind === "int" || entry.kind === "float") { el = document.createElement("input"); el.type = "number"; if (entry.min != null) el.min = entry.min; if (entry.max != null) el.max = entry.max; el.step = entry.kind === "float" ? (entry.step || "any") : (entry.step || 1); el.value = entry.value; } else if (entry.kind === "textarea") { el = document.createElement("textarea"); el.rows = 3; el.value = entry.value == null ? "" : String(entry.value); } else { el = document.createElement("input"); el.type = "text"; el.value = entry.value == null ? "" : String(entry.value); } el.id = "f_" + entry.name; if (entry.enabled === false) el.disabled = true; // Mark dirty while edited so a live push never overwrites a half-entered value. const markDirty = () => { fieldInputs[entry.name].dirty = true; }; el.addEventListener("input", markDirty); el.addEventListener("change", markDirty); // Switching mode changes which settings are shown — apply it immediately. if (entry.name === "processor_mode") { el.addEventListener("change", () => applyOne("processor_mode", el.value)); } row.appendChild(el); fieldInputs[entry.name] = { el, kind: entry.kind, dirty: false }; return row; } function buildSettingsForm(schema) { settingsFields.innerHTML = ""; for (const key in fieldInputs) delete fieldInputs[key]; let lastGroup = null; for (const entry of schema) { if (entry.group !== lastGroup) { lastGroup = entry.group; const heading = document.createElement("div"); heading.className = "group-title"; heading.textContent = entry.group; settingsFields.appendChild(heading); } settingsFields.appendChild(makeFieldRow(entry)); } formSignature = schema.map((e) => e.name).join(","); } function collectFields() { const out = {}; for (const key in fieldInputs) { const { el, kind } = fieldInputs[key]; if (kind === "bool") out[key] = el.checked; else if (kind === "int") out[key] = parseInt(el.value, 10); else if (kind === "float") out[key] = parseFloat(el.value); else out[key] = el.value; // select + text (positions are sent as CSV text) } return out; } function setFieldValue(input, value) { const { el, kind } = input; if (kind === "bool") el.checked = !!value; else if (kind === "select") el.value = String(value); else el.value = value == null ? "" : value; } function clearDirty() { for (const key in fieldInputs) fieldInputs[key].dirty = false; } async function applyOne(field, value) { try { await api("/api/live_settings", { [field]: value }); } catch (err) { toast(err.message, true); } } // Update the form from a schema pushed by the desktop. Rebuild if the field set // changed (e.g. the mode switched); otherwise update values in place without // clobbering a field the operator is editing here. function applySettings(schema) { if (schema.map((e) => e.name).join(",") !== formSignature) { buildSettingsForm(schema); return; } for (const entry of schema) { const input = fieldInputs[entry.name]; if (!input || input.el === document.activeElement || input.dirty) continue; setFieldValue(input, entry.value); if (entry.enabled !== undefined) input.el.disabled = entry.enabled === false; } } btnApply.addEventListener("click", async () => { btnApply.disabled = true; try { await api("/api/live_settings", collectFields()); clearDirty(); // applied; let live pushes update the form again settingsNote.textContent = "Applied."; } catch (err) { settingsNote.textContent = err.message; toast(err.message, true); } finally { btnApply.disabled = false; } }); btnResetHistory.addEventListener("click", async () => { btnResetHistory.disabled = true; try { await api("/api/live_settings", { history_command: "clear_all" }); settingsNote.textContent = "History reset requested."; } catch (err) { settingsNote.textContent = err.message; toast(err.message, true); } finally { btnResetHistory.disabled = false; } }); async function loadSettings() { try { const cfg = await api("/api/live_settings"); buildSettingsForm(cfg); } catch (err) { settingsNote.textContent = "Could not load settings: " + err.message; } } /* ---- config profile --------------------------------------------- */ // Loading a config does exactly what the desktop "Load Config" button does. The file // list comes from the server's run_configs/ directory (the browser has no file access), // and loading is gated to a stopped pipeline — mirroring the desktop precondition. function updateLoadButtonState() { btnLoadConfig.disabled = pipelineRunning || configSelect.options.length === 0; } async function loadConfigList() { try { const { names = [] } = await api("/api/configs"); const previous = configSelect.value; configSelect.innerHTML = ""; for (const name of names) { const opt = document.createElement("option"); opt.value = name; opt.textContent = name; configSelect.appendChild(opt); } if (names.includes(previous)) configSelect.value = previous; // keep the operator's choice updateLoadButtonState(); } catch (err) { toast("Could not load config list: " + err.message, true); } } btnLoadConfig.addEventListener("click", async () => { const name = configSelect.value; if (!name) return; btnLoadConfig.disabled = true; try { await api("/api/load_config", { name }); toast(`Config loaded: ${name}`); // the new settings schema arrives via the live status push } catch (err) { toast(err.message, true); } finally { updateLoadButtonState(); } }); /* ---- save dataset ----------------------------------------------- */ // Saves to the path in the field, mirroring the desktop "Save Dataset" button exactly. btnSaveDataset.addEventListener("click", async () => { btnSaveDataset.disabled = true; try { await api("/api/save_dataset", { path: savePathInput.value.trim(), name: saveNameInput.value.trim(), }); toast("Save requested"); // a radar-config prefix is prepended to the name server-side } catch (err) { toast(err.message, true); } finally { btnSaveDataset.disabled = false; } }); /* ---- status ------------------------------------------------------ */ function setStat(el, label, value, cls) { el.className = "stat" + (cls ? " " + cls : ""); el.innerHTML = label + ": "; el.querySelector("b").textContent = value; } function applyStatus(s) { if ("running" in s) { setStat(runningEl, "running", s.running ? "yes" : "no", s.running ? "ok" : "off"); pipelineRunning = !!s.running; updateLoadButtonState(); } if ("active_config" in s) configActiveEl.textContent = s.active_config ? `current: ${s.active_config}` : ""; if ("save_path" in s && !saveFieldsPrefilled) { savePathInput.value = s.save_path || ""; // seed once; don't clobber later edits saveNameInput.value = s.save_name || ""; saveFieldsPrefilled = true; } if ("processor_running" in s) setStat(processorEl, "processor", s.processor_running ? "yes" : "no", s.processor_running ? "ok" : "off"); if ("ring_name" in s) setStat(ringEl, "ring", s.ring_name || "—", s.ring_name ? "" : "off"); if ("raw_count" in s) setStat(scansEl, "scans", `raw ${s.raw_count} / preprocessed ${s.preprocessed_count} / results ${s.result_count}`); } /* ---- frame rendering (latest-wins; the frame IS the Qt plot image) - */ function renderLoop() { if (pendingPng !== null) { // Show exactly what the desktop draws; the browser scales it to fit (CSS). plotImg.src = "data:image/png;base64," + pendingPng; pendingPng = null; } // Stale indicator (>2s without a frame). const stale = performance.now() - lastFrameTs > 2000; staleEl.classList.toggle("stale", stale && lastFrameTs > 0); staleEl.textContent = lastFrameTs === 0 ? "no data" : stale ? "stale" : "live"; requestAnimationFrame(renderLoop); } /* ---- WebSocket --------------------------------------------------- */ function connectWs() { const proto = location.protocol === "https:" ? "wss:" : "ws:"; const ws = new WebSocket(`${proto}//${location.host}/ws`); ws.onmessage = (ev) => { let msg; try { msg = JSON.parse(ev.data); } catch (_) { return; } if (msg.type === "frame") { pendingPng = msg.png_b64; // latest-wins; rAF swaps the image lastFrameTs = performance.now(); } else if (msg.type === "status") { applyStatus(msg); } else if (msg.type === "settings") { applySettings(msg.schema); // live desktop schema mirrors into the form } }; ws.onclose = () => setTimeout(connectWs, 1500); ws.onerror = () => ws.close(); } /* ---- boot -------------------------------------------------------- */ async function init() { requestAnimationFrame(renderLoop); try { applyStatus(await api("/api/status")); } catch (err) { settingsNote.textContent = "Status unavailable: " + err.message; } await loadSettings(); await loadConfigList(); connectWs(); } init();