const state = { result: null, frequencies: null, health: null, config: null, writeToken: "", activeSection: "overview", selectedReceiverIndex: 0, receiverDrafts: [], }; const HZ_IN_MHZ = 1_000_000; function byId(id) { return document.getElementById(id); } function fmt(value, digits = 6) { if (value === null || value === undefined) return "-"; if (typeof value !== "number") return String(value); return Number.isFinite(value) ? value.toFixed(digits) : String(value); } function hzToMhz(value) { if (value === null || value === undefined) return null; const numeric = Number(value); if (!Number.isFinite(numeric)) return null; return numeric / HZ_IN_MHZ; } function authHeaders() { const token = state.writeToken || ""; if (!token) return {}; return { "X-API-Token": token, Authorization: `Bearer ${token}`, }; } function setActiveSection(section) { state.activeSection = section; document.querySelectorAll(".panel").forEach((panel) => { panel.classList.toggle("panel-active", panel.id === `section-${section}`); }); document.querySelectorAll(".menu-item").forEach((item) => { item.classList.toggle("menu-item-active", item.dataset.section === section); }); } function setMenuOpen(isOpen) { byId("menu-list").classList.toggle("menu-list-open", isOpen); } function normalizeReceiverDraft(receiver) { const filter = receiver?.input_filter || {}; return { receiver_id: receiver?.receiver_id || "", source_url: receiver?.source_url || "", input_filter: { enabled: Boolean(filter.enabled), min_frequency_mhz: filter.min_frequency_mhz ?? hzToMhz(filter.min_frequency_hz) ?? 0, max_frequency_mhz: filter.max_frequency_mhz ?? hzToMhz(filter.max_frequency_hz) ?? 0, min_rssi_dbm: filter.min_rssi_dbm ?? -200, max_rssi_dbm: filter.max_rssi_dbm ?? 50, }, }; } function saveCurrentReceiverDraftFromInputs() { const idx = state.selectedReceiverIndex; if (!state.receiverDrafts[idx]) return; state.receiverDrafts[idx] = { ...state.receiverDrafts[idx], source_url: byId("rx-url").value.trim(), input_filter: { enabled: byId("rx-filter-enabled").value === "true", min_frequency_mhz: Number(byId("rx-min-freq").value), max_frequency_mhz: Number(byId("rx-max-freq").value), min_rssi_dbm: Number(byId("rx-min-rssi").value), max_rssi_dbm: Number(byId("rx-max-rssi").value), }, }; } function renderSelectedReceiverDraft() { const draft = state.receiverDrafts[state.selectedReceiverIndex]; if (!draft) return; byId("rx-url").value = draft.source_url; byId("rx-filter-enabled").value = String(Boolean(draft.input_filter.enabled)); byId("rx-min-freq").value = draft.input_filter.min_frequency_mhz; byId("rx-max-freq").value = draft.input_filter.max_frequency_mhz; byId("rx-min-rssi").value = draft.input_filter.min_rssi_dbm; byId("rx-max-rssi").value = draft.input_filter.max_rssi_dbm; } function fillReceiverSelect() { const select = byId("receiver-select"); select.innerHTML = ""; state.receiverDrafts.forEach((draft, index) => { const option = document.createElement("option"); option.value = String(index); option.textContent = draft.receiver_id || `receiver_${index + 1}`; select.appendChild(option); }); if (state.selectedReceiverIndex >= state.receiverDrafts.length) { state.selectedReceiverIndex = 0; } select.value = String(state.selectedReceiverIndex); } async function getJson(url) { const res = await fetch(url); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(data.error || data.status || `HTTP ${res.status}`); } return data; } async function postJson(url, payload) { const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify(payload), }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(data.error || data.status || `HTTP ${res.status}`); } return data; } function render() { const data = state.result?.data; const delivery = state.result?.output_delivery || state.frequencies?.output_delivery; byId("updated-at").textContent = `updated: ${state.result?.updated_at_utc || "n/a"}`; byId("health-status").textContent = `health: ${state.health?.status || "n/a"}`; byId("delivery-status").textContent = `delivery: ${delivery?.status || "n/a"}`; if (!data) { byId("selected-freq").textContent = "-"; byId("pos-x").textContent = "-"; byId("pos-y").textContent = "-"; byId("pos-z").textContent = "-"; byId("rmse").textContent = "-"; byId("receivers-list").textContent = "Нет данных"; byId("delivery-details").textContent = JSON.stringify(delivery || {}, null, 2); byId("freq-table").querySelector("tbody").innerHTML = ""; return; } const selectedMhz = data.selected_frequency_mhz ?? hzToMhz(data.selected_frequency_hz); byId("selected-freq").textContent = selectedMhz === null ? "-" : `${fmt(selectedMhz, 3)} MHz`; byId("pos-x").textContent = fmt(data.position?.x); byId("pos-y").textContent = fmt(data.position?.y); byId("pos-z").textContent = fmt(data.position?.z); byId("rmse").textContent = fmt(data.rmse_m); const receivers = data.receivers || []; byId("receivers-list").textContent = JSON.stringify(receivers, null, 2); byId("delivery-details").textContent = JSON.stringify(delivery || {}, null, 2); const rows = data.frequency_table || []; const tbody = byId("freq-table").querySelector("tbody"); tbody.innerHTML = rows .map( (row) => ` ${fmt(row.frequency_mhz ?? hzToMhz(row.frequency_hz), 3)} ${fmt(row.position?.x)} ${fmt(row.position?.y)} ${fmt(row.position?.z)} ${fmt(row.rmse_m)} ${row.exact ? "yes" : "no"} ` ) .join(""); } async function loadAll() { const [healthRes, resultRes, freqRes] = await Promise.allSettled([ getJson("/health"), getJson("/result"), getJson("/frequencies"), ]); state.health = healthRes.status === "fulfilled" ? healthRes.value : { status: "error" }; state.result = resultRes.status === "fulfilled" ? resultRes.value : null; state.frequencies = freqRes.status === "fulfilled" ? freqRes.value : null; render(); } async function refreshNow() { await postJson("/refresh", {}); await loadAll(); } async function loadConfig() { try { const config = await getJson("/config"); state.config = config.config || null; byId("config-editor").value = JSON.stringify(config.config, null, 2); fillServerForm(); byId("config-state").textContent = "config: loaded"; byId("servers-state").textContent = "servers: loaded"; } catch (err) { byId("config-state").textContent = `config: ${err.message}`; byId("servers-state").textContent = `servers: ${err.message}`; } } async function saveConfig() { const raw = byId("config-editor").value.trim(); try { const parsed = JSON.parse(raw); const result = await postJson("/config", parsed); state.config = parsed; byId("config-state").textContent = result.restart_required ? "config: saved, restart required" : "config: saved"; } catch (err) { byId("config-state").textContent = `config: ${err.message}`; } } function fillServerForm() { const cfg = state.config; if (!cfg) return; const receivers = cfg.input?.receivers || []; state.receiverDrafts = receivers.map((receiver) => normalizeReceiverDraft(receiver)); fillReceiverSelect(); renderSelectedReceiverDraft(); const out = cfg.runtime?.output_server || {}; byId("write-token").value = ""; byId("out-enabled").value = String(Boolean(out.enabled)); byId("out-freq-filter-enabled").value = String(Boolean(out.frequency_filter_enabled)); const minMhz = out.min_frequency_mhz ?? hzToMhz(out.min_frequency_hz) ?? 0; const maxMhz = out.max_frequency_mhz ?? hzToMhz(out.max_frequency_hz) ?? 0; byId("out-min-freq").value = minMhz; byId("out-max-freq").value = maxMhz; byId("out-ip").value = out.ip || ""; byId("out-port").value = out.port ?? 8080; byId("out-path").value = out.path || "/triangulation"; byId("out-timeout").value = out.timeout_s ?? 3.0; } async function saveServers() { try { if (!state.config) { await loadConfig(); } saveCurrentReceiverDraftFromInputs(); const cfg = structuredClone(state.config); cfg.input = cfg.input || {}; cfg.input.receivers = cfg.input.receivers || [{}, {}, {}]; cfg.runtime = cfg.runtime || {}; cfg.runtime.output_server = cfg.runtime.output_server || {}; for (let i = 0; i < cfg.input.receivers.length; i += 1) { const draft = state.receiverDrafts[i] || normalizeReceiverDraft(cfg.input.receivers[i]); cfg.input.receivers[i].source_url = draft.source_url; cfg.input.receivers[i].input_filter = { ...draft.input_filter }; } cfg.runtime.output_server.enabled = byId("out-enabled").value === "true"; cfg.runtime.output_server.frequency_filter_enabled = byId("out-freq-filter-enabled").value === "true"; cfg.runtime.output_server.min_frequency_mhz = Number(byId("out-min-freq").value); cfg.runtime.output_server.max_frequency_mhz = Number(byId("out-max-freq").value); cfg.runtime.output_server.ip = byId("out-ip").value.trim(); cfg.runtime.output_server.port = Number(byId("out-port").value); cfg.runtime.output_server.path = byId("out-path").value.trim() || "/triangulation"; cfg.runtime.output_server.timeout_s = Number(byId("out-timeout").value); const result = await postJson("/config", cfg); state.config = cfg; byId("config-editor").value = JSON.stringify(cfg, null, 2); byId("servers-state").textContent = result.restart_required ? "servers: saved, restart required" : "servers: saved"; } catch (err) { byId("servers-state").textContent = `servers: ${err.message}`; } } function bindUi() { byId("refresh-now").addEventListener("click", refreshNow); byId("load-config").addEventListener("click", loadConfig); byId("save-config").addEventListener("click", saveConfig); byId("load-servers").addEventListener("click", loadConfig); byId("save-servers").addEventListener("click", saveServers); byId("write-token").addEventListener("input", (event) => { state.writeToken = event.target.value; }); byId("receiver-select").addEventListener("change", (event) => { saveCurrentReceiverDraftFromInputs(); state.selectedReceiverIndex = Number(event.target.value); renderSelectedReceiverDraft(); }); byId("menu-toggle").addEventListener("click", () => { const open = !byId("menu-list").classList.contains("menu-list-open"); setMenuOpen(open); }); document.querySelectorAll(".menu-item").forEach((item) => { item.addEventListener("click", () => { setActiveSection(item.dataset.section); setMenuOpen(false); }); }); document.addEventListener("click", (event) => { const target = event.target; if (!(target instanceof Element)) return; if ( target.closest("#menu-toggle") || target.closest("#menu-list") ) { return; } setMenuOpen(false); }); } async function boot() { bindUi(); setActiveSection(state.activeSection); await loadConfig(); await loadAll(); setInterval(loadAll, 2000); } boot().catch((err) => { byId("health-status").textContent = `health: ${err.message}`; });