const state = { result: null, frequencies: null, health: null, config: null, writeToken: "", activeSection: "overview", selectedReceiverIndex: 0, receiverDrafts: [], sharedFilterDraft: { enabled: false, min_frequency_mhz: 0, max_frequency_mhz: 0, min_rssi_dbm: -200, max_rssi_dbm: 50, }, selectedOutputIndex: 0, outputDrafts: [], }; const HZ_IN_MHZ = 1_000_000; function byId(id) { return document.getElementById(id); } function setTextWithPulse(id, value) { const el = byId(id); if (!el) return; const next = String(value); const changed = el.textContent !== next; el.textContent = next; if (!changed) return; el.classList.remove("value-updated"); void el.offsetWidth; el.classList.add("value-updated"); } 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 localizeStatus(value) { const status = String(value || "n/a"); const mapping = { ok: "ок", error: "ошибка", warming_up: "прогрев", partial: "частично", skipped: "пропущено", disabled: "отключено", not_found: "не найдено", n_a: "н/д", "n/a": "н/д", true: "включено", false: "отключено", }; return mapping[status] || status; } function localizeErrorMessage(message) { const text = String(message || "неизвестная ошибка"); const known = { "at least 3 input servers are required": "необходимо минимум 3 входных сервера", "at least 1 output server is required": "необходим минимум 1 выходной сервер", Unauthorized: "доступ запрещён (проверьте токен)", "unauthorized: missing or invalid API token": "доступ запрещён: отсутствует или неверный API-токен", warming_up: "прогрев", not_found: "не найдено", "no data yet": "данные пока не получены", }; if (known[text]) return known[text]; if (text.startsWith("HTTP ")) return `ошибка HTTP: ${text.slice(5)}`; if (text.startsWith("Config validation failed:")) { return `ошибка валидации конфига: ${text.replace("Config validation failed:", "").trim()}`; } return text; } function formatUpdatedTimestamp(value) { if (!value) { return { date: "дата: н/д", time: "время: н/д" }; } const text = String(value); const match = text.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2}:\d{2})(Z|[+-]\d{2}:\d{2})?$/); if (!match) { return { date: `дата: ${text}`, time: "время: н/д" }; } const datePart = `${match[3]}.${match[2]}.${match[1]}`; const zone = match[5] || ""; const zoneLabel = zone === "Z" || zone === "+00:00" ? " UTC" : zone ? ` ${zone}` : ""; return { date: `дата: ${datePart}`, time: `время: ${match[4]}${zoneLabel}` }; } 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 parseMhzList(raw) { const text = String(raw || "").trim(); if (!text) return []; const values = text .split(",") .map((part) => Number(part.trim())) .filter((value) => Number.isFinite(value) && value > 0); return Array.from(new Set(values)); } function formatMhzList(values) { if (!Array.isArray(values) || values.length === 0) return ""; return values .map((value) => Number(value)) .filter((value) => Number.isFinite(value) && value > 0) .map((value) => value.toFixed(3).replace(/\.?0+$/, "")) .join(", "); } 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 normalizeInputFilter(filter) { const source = filter || {}; return { enabled: Boolean(source.enabled), min_frequency_mhz: Number(source.min_frequency_mhz ?? hzToMhz(source.min_frequency_hz) ?? 0), max_frequency_mhz: Number(source.max_frequency_mhz ?? hzToMhz(source.max_frequency_hz) ?? 0), min_rssi_dbm: Number(source.min_rssi_dbm ?? -200), max_rssi_dbm: Number(source.max_rssi_dbm ?? 50), }; } function createReceiverDraft(index) { return { receiver_id: `r${index}`, source_url: "", frequencies_mhz: [], center: { x: 0, y: 0, z: 0 }, }; } function normalizeReceiverDraft(receiver, index) { const center = receiver?.center || {}; const access = receiver?.access || {}; const frequencies = Array.isArray(receiver?.frequencies_mhz) ? receiver.frequencies_mhz : []; return { receiver_id: receiver?.receiver_id || `r${index}`, source_url: String(receiver?.source_url || access.url || access.source_url || ""), frequencies_mhz: frequencies .map((value) => Number(value)) .filter((value) => Number.isFinite(value) && value > 0), center: { x: Number(center.x ?? 0), y: Number(center.y ?? 0), z: Number(center.z ?? 0), }, }; } function updateReceiverCountBadge() { byId("receiver-count").textContent = `входов: ${state.receiverDrafts.length}`; } function saveCurrentReceiverDraftFromInputs() { const idx = state.selectedReceiverIndex; if (!state.receiverDrafts[idx]) return; state.receiverDrafts[idx] = { ...state.receiverDrafts[idx], receiver_id: byId("rx-id").value.trim() || `r${idx}`, source_url: byId("rx-url").value.trim(), frequencies_mhz: parseMhzList(byId("rx-frequencies").value), center: { x: Number(byId("rx-center-x").value), y: Number(byId("rx-center-y").value), z: Number(byId("rx-center-z").value), }, }; } function renderSelectedReceiverDraft() { const draft = state.receiverDrafts[state.selectedReceiverIndex]; if (!draft) return; byId("rx-id").value = draft.receiver_id; byId("rx-url").value = draft.source_url; byId("rx-frequencies").value = formatMhzList(draft.frequencies_mhz); byId("rx-center-x").value = draft.center.x; byId("rx-center-y").value = draft.center.y; byId("rx-center-z").value = draft.center.z; } 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 || `вход_${index + 1}`; select.appendChild(option); }); if (state.selectedReceiverIndex >= state.receiverDrafts.length) { state.selectedReceiverIndex = Math.max(0, state.receiverDrafts.length - 1); } select.value = String(state.selectedReceiverIndex); updateReceiverCountBadge(); } function addReceiverDraft() { saveCurrentReceiverDraftFromInputs(); const nextIndex = state.receiverDrafts.length; state.receiverDrafts.push(createReceiverDraft(nextIndex)); state.selectedReceiverIndex = nextIndex; fillReceiverSelect(); renderSelectedReceiverDraft(); } function removeReceiverDraft() { if (state.receiverDrafts.length <= 3) { byId("servers-state").textContent = "серверы: необходимо минимум 3 входа"; return; } state.receiverDrafts.splice(state.selectedReceiverIndex, 1); state.selectedReceiverIndex = Math.max(0, state.selectedReceiverIndex - 1); fillReceiverSelect(); renderSelectedReceiverDraft(); } function createOutputDraft(index) { return { name: `выход_${index + 1}`, ip: "", }; } function normalizeOutputDraft(output, index) { const source = output || {}; return { name: String(source.name || `выход_${index + 1}`), ip: String(source.ip || ""), }; } function updateOutputCountBadge() { byId("output-count").textContent = `выходов: ${state.outputDrafts.length}`; } function saveCurrentOutputDraftFromInputs() { const idx = state.selectedOutputIndex; if (!state.outputDrafts[idx]) return; state.outputDrafts[idx] = { ...state.outputDrafts[idx], name: byId("out-name").value.trim() || `выход_${idx + 1}`, ip: byId("out-ip").value.trim(), }; } function renderSelectedOutputDraft() { const draft = state.outputDrafts[state.selectedOutputIndex]; if (!draft) return; byId("out-name").value = draft.name; byId("out-ip").value = draft.ip; } function fillOutputSelect() { const select = byId("output-select"); select.innerHTML = ""; state.outputDrafts.forEach((draft, index) => { const option = document.createElement("option"); option.value = String(index); option.textContent = draft.name || `выход_${index + 1}`; select.appendChild(option); }); if (state.selectedOutputIndex >= state.outputDrafts.length) { state.selectedOutputIndex = Math.max(0, state.outputDrafts.length - 1); } select.value = String(state.selectedOutputIndex); updateOutputCountBadge(); } function addOutputDraft() { saveCurrentOutputDraftFromInputs(); const nextIndex = state.outputDrafts.length; state.outputDrafts.push(createOutputDraft(nextIndex)); state.selectedOutputIndex = nextIndex; fillOutputSelect(); renderSelectedOutputDraft(); } function removeOutputDraft() { if (state.outputDrafts.length <= 1) { byId("servers-state").textContent = "серверы: необходим минимум 1 выход"; return; } state.outputDrafts.splice(state.selectedOutputIndex, 1); state.selectedOutputIndex = Math.max(0, state.selectedOutputIndex - 1); fillOutputSelect(); renderSelectedOutputDraft(); } 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; const updated = formatUpdatedTimestamp(state.result?.updated_at_utc); setTextWithPulse("updated-date", updated.date); setTextWithPulse("updated-time", updated.time); setTextWithPulse("health-status", `состояние: ${localizeStatus(state.health?.status)}`); setTextWithPulse("delivery-status", `доставка: ${localizeStatus(delivery?.status)}`); if (!data) { setTextWithPulse("selected-freq", "-"); setTextWithPulse("pos-x", "-"); setTextWithPulse("pos-y", "-"); setTextWithPulse("pos-z", "-"); setTextWithPulse("rmse", "-"); 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); setTextWithPulse("selected-freq", selectedMhz === null ? "-" : `${fmt(selectedMhz, 3)} МГц`); setTextWithPulse("pos-x", fmt(data.position?.x)); setTextWithPulse("pos-y", fmt(data.position?.y)); setTextWithPulse("pos-z", fmt(data.position?.z)); setTextWithPulse("rmse", 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, index) => ` ${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 ? "да" : "нет"} ` ) .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 = "конфиг: загружен"; byId("servers-state").textContent = "серверы: загружены"; } catch (err) { byId("config-state").textContent = `конфиг: ${localizeErrorMessage(err.message)}`; byId("servers-state").textContent = `серверы: ${localizeErrorMessage(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; const saveSuffix = result.save_error ? " (применено, но сохранить файл не удалось)" : ""; byId("config-state").textContent = result.restart_required ? `конфиг: сохранён, требуется перезапуск${saveSuffix}` : `конфиг: сохранён${saveSuffix}`; } catch (err) { byId("config-state").textContent = `конфиг: ${localizeErrorMessage(err.message)}`; } } function fillServerForm() { const cfg = state.config; if (!cfg) return; const receivers = cfg.input?.receivers || []; state.receiverDrafts = receivers.map((receiver, index) => normalizeReceiverDraft(receiver, index)); if (state.receiverDrafts.length < 3) { while (state.receiverDrafts.length < 3) { state.receiverDrafts.push(createReceiverDraft(state.receiverDrafts.length)); } } fillReceiverSelect(); renderSelectedReceiverDraft(); const sharedFilterSource = cfg.input?.default_input_filter || cfg.input?.receivers?.[0]?.input_filter || {}; state.sharedFilterDraft = normalizeInputFilter(sharedFilterSource); byId("shared-filter-enabled").value = String(Boolean(state.sharedFilterDraft.enabled)); byId("shared-min-freq").value = state.sharedFilterDraft.min_frequency_mhz; byId("shared-max-freq").value = state.sharedFilterDraft.max_frequency_mhz; byId("shared-min-rssi").value = state.sharedFilterDraft.min_rssi_dbm; byId("shared-max-rssi").value = state.sharedFilterDraft.max_rssi_dbm; const runtime = cfg.runtime || {}; const outputServers = Array.isArray(runtime.output_servers) ? runtime.output_servers : [runtime.output_server || {}]; state.outputDrafts = outputServers.map((output, index) => normalizeOutputDraft(output, index)); if (state.outputDrafts.length < 1) { state.outputDrafts.push(createOutputDraft(0)); } fillOutputSelect(); renderSelectedOutputDraft(); byId("write-token").value = ""; } async function saveServers() { try { if (!state.config) { await loadConfig(); } saveCurrentReceiverDraftFromInputs(); saveCurrentOutputDraftFromInputs(); if (state.receiverDrafts.length < 3) { throw new Error("at least 3 input servers are required"); } if (state.outputDrafts.length < 1) { throw new Error("at least 1 output server is required"); } const sharedFilter = { enabled: byId("shared-filter-enabled").value === "true", min_frequency_mhz: Number(byId("shared-min-freq").value), max_frequency_mhz: Number(byId("shared-max-freq").value), min_rssi_dbm: Number(byId("shared-min-rssi").value), max_rssi_dbm: Number(byId("shared-max-rssi").value), }; const cfg = structuredClone(state.config); cfg.input = cfg.input || {}; cfg.input.default_input_filter = { ...sharedFilter }; cfg.input.receivers = state.receiverDrafts.map((draft, index) => ({ receiver_id: draft.receiver_id || `r${index}`, center: { x: Number(draft.center.x), y: Number(draft.center.y), z: Number(draft.center.z), }, access: { url: draft.source_url, }, frequencies_mhz: Array.isArray(draft.frequencies_mhz) ? draft.frequencies_mhz .map((value) => Number(value)) .filter((value) => Number.isFinite(value) && value > 0) : [], })); cfg.runtime = cfg.runtime || {}; cfg.runtime.output_servers = state.outputDrafts.map((draft, index) => ({ name: draft.name || `выход_${index + 1}`, ip: draft.ip, })); cfg.runtime.output_server = { ...cfg.runtime.output_servers[0] }; const result = await postJson("/config", cfg); state.config = cfg; byId("config-editor").value = JSON.stringify(cfg, null, 2); const saveSuffix = result.save_error ? " (применено, но сохранить файл не удалось)" : ""; byId("servers-state").textContent = result.restart_required ? `серверы: сохранены, требуется перезапуск${saveSuffix}` : `серверы: сохранены${saveSuffix}`; } catch (err) { byId("servers-state").textContent = `серверы: ${localizeErrorMessage(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("add-receiver").addEventListener("click", addReceiverDraft); byId("remove-receiver").addEventListener("click", removeReceiverDraft); byId("receiver-select").addEventListener("change", (event) => { saveCurrentReceiverDraftFromInputs(); state.selectedReceiverIndex = Number(event.target.value); renderSelectedReceiverDraft(); }); byId("add-output-server").addEventListener("click", addOutputDraft); byId("remove-output-server").addEventListener("click", removeOutputDraft); byId("output-select").addEventListener("change", (event) => { saveCurrentOutputDraftFromInputs(); state.selectedOutputIndex = Number(event.target.value); renderSelectedOutputDraft(); }); byId("write-token").addEventListener("input", (event) => { state.writeToken = event.target.value; }); 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) => { setTextWithPulse("health-status", `состояние: ${localizeErrorMessage(err.message)}`); });