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: [], menuCollapsed: false, menuGroupCollapsed: { monitoring: false, io: false, config: false, }, dateTimeCollapsed: false, ioHistory: [], mockControls: null, historyFilter: "all", historyPage: 1, historyPageSize: 10, historyDateFrom: "", historyDateTo: "", historyRecordingEnabled: true, autoRefreshEnabled: true, pollIntervalMs: 2000, pollTimer: null, initialized: false, lastHealthStatus: "n/a", lastDeliveryStatus: "n/a", timezone: "local", uiDensity: "detailed", }; const HZ_IN_MHZ = 1_000_000; const MENU_COLLAPSED_STORAGE_KEY = "triangulation.menu_collapsed"; const MENU_GROUP_COLLAPSED_STORAGE_KEY = "triangulation.menu_group_collapsed"; const DATE_TIME_COLLAPSED_STORAGE_KEY = "triangulation.date_time_collapsed"; const TIMEZONE_STORAGE_KEY = "triangulation.timezone"; const UI_DENSITY_STORAGE_KEY = "triangulation.ui_density"; const AUTO_REFRESH_INTERVAL_STORAGE_KEY = "triangulation.auto_refresh_interval_ms"; const AUTO_REFRESH_MIN_MS = 1_000; const AUTO_REFRESH_MAX_MS = 120_000; const AUTO_REFRESH_DEFAULT_MS = 2_000; const IO_HISTORY_LIMIT = 60; const MENU_GROUP_KEYS = ["monitoring", "io", "config"]; const TIMEZONE_OPTIONS = [ { value: "local", label: "Локальный (браузер)" }, { value: "UTC", label: "UTC" }, { value: "Europe/Moscow", label: "Москва (Europe/Moscow)" }, { value: "Asia/Novosibirsk", label: "Новосибирск (Asia/Novosibirsk)" }, { value: "Asia/Yekaterinburg", label: "Екатеринбург (Asia/Yekaterinburg)" }, { value: "Europe/London", label: "Лондон (Europe/London)" }, { value: "Europe/Berlin", label: "Берлин (Europe/Berlin)" }, { value: "Asia/Dubai", label: "Дубай (Asia/Dubai)" }, { value: "Asia/Tokyo", label: "Токио (Asia/Tokyo)" }, { value: "America/New_York", label: "Нью-Йорк (America/New_York)" }, { value: "America/Chicago", label: "Чикаго (America/Chicago)" }, { value: "America/Los_Angeles", label: "Лос-Анджелес (America/Los_Angeles)" }, ]; 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": "данные пока не получены", "receiver transmission is paused": "передача входных данных остановлена", "output sink receive is paused": "приём на выходном сервере остановлен", }; 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 dateObj = new Date(text); if (Number.isNaN(dateObj.getTime())) { return { date: `дата: ${text}`, time: "время: н/д" }; } const zone = selectedTimeZoneValue(); const dateOptions = { year: "numeric", month: "2-digit", day: "2-digit" }; const timeOptions = { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }; if (zone) { dateOptions.timeZone = zone; timeOptions.timeZone = zone; } let datePart = ""; let timePart = ""; try { datePart = new Intl.DateTimeFormat("ru-RU", dateOptions).format(dateObj); timePart = new Intl.DateTimeFormat("ru-RU", timeOptions).format(dateObj); } catch { datePart = new Intl.DateTimeFormat("ru-RU", { year: "numeric", month: "2-digit", day: "2-digit" }).format(dateObj); timePart = new Intl.DateTimeFormat("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }).format(dateObj); } return { date: `дата: ${datePart}`, time: `время: ${timePart} (${selectedTimeZoneLabel()})`, }; } 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); }); const group = findSectionMenuGroup(section); if (group) { setMenuGroupCollapsed(group, false, { persist: true }); } } function setMenuCollapsed(isCollapsed) { state.menuCollapsed = Boolean(isCollapsed); const sideNav = byId("side-nav"); const toggle = byId("menu-toggle"); if (sideNav) { sideNav.classList.toggle("menu-collapsed", state.menuCollapsed); } if (toggle) { toggle.textContent = state.menuCollapsed ? "Развернуть меню" : "Свернуть меню"; toggle.setAttribute("aria-expanded", String(!state.menuCollapsed)); } try { localStorage.setItem(MENU_COLLAPSED_STORAGE_KEY, state.menuCollapsed ? "1" : "0"); } catch { // Ignore localStorage errors in restricted environments. } } function readMenuCollapsed() { try { return localStorage.getItem(MENU_COLLAPSED_STORAGE_KEY) === "1"; } catch { return false; } } function normalizeMenuGroupCollapsed(value) { const result = { monitoring: false, io: false, config: false, }; if (!value || typeof value !== "object") return result; MENU_GROUP_KEYS.forEach((groupKey) => { result[groupKey] = Boolean(value[groupKey]); }); return result; } function findSectionMenuGroup(section) { const item = document.querySelector(`.menu-item[data-section="${String(section || "")}"]`); const group = item?.closest(".menu-group")?.dataset?.menuGroup; return MENU_GROUP_KEYS.includes(group) ? group : null; } function applyMenuGroupState(groupKey) { if (!MENU_GROUP_KEYS.includes(groupKey)) return; const root = document.querySelector(`.menu-group[data-menu-group="${groupKey}"]`); if (!root) return; const toggle = root.querySelector(".menu-group-toggle"); const collapsed = Boolean(state.menuGroupCollapsed[groupKey]); root.classList.toggle("menu-group-collapsed", collapsed); if (toggle) { toggle.setAttribute("aria-expanded", String(!collapsed)); } } function persistMenuGroupCollapsedState() { try { localStorage.setItem( MENU_GROUP_COLLAPSED_STORAGE_KEY, JSON.stringify(normalizeMenuGroupCollapsed(state.menuGroupCollapsed)) ); } catch { // Ignore localStorage errors in restricted environments. } } function setMenuGroupCollapsed(groupKey, isCollapsed, options = {}) { const { persist = true } = options; if (!MENU_GROUP_KEYS.includes(groupKey)) return; state.menuGroupCollapsed[groupKey] = Boolean(isCollapsed); applyMenuGroupState(groupKey); if (persist) { persistMenuGroupCollapsedState(); } } function applyAllMenuGroupsCollapsed() { MENU_GROUP_KEYS.forEach((groupKey) => applyMenuGroupState(groupKey)); } function readMenuGroupCollapsed() { try { const raw = localStorage.getItem(MENU_GROUP_COLLAPSED_STORAGE_KEY); if (!raw) { return normalizeMenuGroupCollapsed(null); } const parsed = JSON.parse(raw); return normalizeMenuGroupCollapsed(parsed); } catch { return normalizeMenuGroupCollapsed(null); } } function setDateTimeCollapsed(isCollapsed) { state.dateTimeCollapsed = Boolean(isCollapsed); const sideNav = byId("side-nav"); const toggle = byId("datetime-toggle"); if (sideNav) { sideNav.classList.toggle("date-time-collapsed", state.dateTimeCollapsed); } if (toggle) { toggle.textContent = state.dateTimeCollapsed ? "Показать служебную панель" : "Скрыть служебную панель"; toggle.setAttribute("aria-expanded", String(!state.dateTimeCollapsed)); } try { localStorage.setItem(DATE_TIME_COLLAPSED_STORAGE_KEY, state.dateTimeCollapsed ? "1" : "0"); } catch { // Ignore localStorage errors in restricted environments. } } function readDateTimeCollapsed() { try { return localStorage.getItem(DATE_TIME_COLLAPSED_STORAGE_KEY) === "1"; } catch { return false; } } function browserTimeZone() { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ""; } catch { return ""; } } function isKnownTimeZone(value) { return TIMEZONE_OPTIONS.some((option) => option.value === value); } function readTimeZonePreference() { try { const value = localStorage.getItem(TIMEZONE_STORAGE_KEY) || "local"; return isKnownTimeZone(value) ? value : "local"; } catch { return "local"; } } function saveTimeZonePreference(value) { try { localStorage.setItem(TIMEZONE_STORAGE_KEY, value); } catch { // Ignore localStorage errors in restricted environments. } } function normalizeUiDensity(value) { return String(value || "").toLowerCase() === "compact" ? "compact" : "detailed"; } function saveUiDensityPreference(value) { try { localStorage.setItem(UI_DENSITY_STORAGE_KEY, normalizeUiDensity(value)); } catch { // Ignore localStorage errors in restricted environments. } } function readUiDensityPreference() { try { return normalizeUiDensity(localStorage.getItem(UI_DENSITY_STORAGE_KEY) || "detailed"); } catch { return "detailed"; } } function applyUiDensity() { const compact = state.uiDensity === "compact"; document.body.classList.toggle("ui-compact", compact); const toggle = byId("density-toggle"); if (toggle) { toggle.textContent = compact ? "Режим: компактный" : "Режим: детальный"; } } function setUiDensity(value, options = {}) { const { persist = true } = options; state.uiDensity = normalizeUiDensity(value); applyUiDensity(); if (persist) { saveUiDensityPreference(state.uiDensity); } } function normalizePollIntervalMs(valueMs) { const numeric = Number(valueMs); if (!Number.isFinite(numeric)) return AUTO_REFRESH_DEFAULT_MS; const rounded = Math.round(numeric); return Math.min(AUTO_REFRESH_MAX_MS, Math.max(AUTO_REFRESH_MIN_MS, rounded)); } function formatPollIntervalSeconds(valueMs) { const seconds = Number(valueMs) / 1000; if (!Number.isFinite(seconds)) return "2"; return seconds.toFixed(2).replace(/\.?0+$/, ""); } function readPollIntervalPreference() { try { const raw = localStorage.getItem(AUTO_REFRESH_INTERVAL_STORAGE_KEY); if (!raw) return AUTO_REFRESH_DEFAULT_MS; return normalizePollIntervalMs(Number(raw)); } catch { return AUTO_REFRESH_DEFAULT_MS; } } function savePollIntervalPreference(valueMs) { try { localStorage.setItem(AUTO_REFRESH_INTERVAL_STORAGE_KEY, String(normalizePollIntervalMs(valueMs))); } catch { // Ignore localStorage errors in restricted environments. } } function syncPollIntervalInput() { const input = byId("auto-refresh-seconds"); if (!input) return; input.value = formatPollIntervalSeconds(state.pollIntervalMs); } function setPollIntervalMs(valueMs, options = {}) { const { persist = true, restartPolling = true } = options; const next = normalizePollIntervalMs(valueMs); state.pollIntervalMs = next; if (persist) { savePollIntervalPreference(next); } syncPollIntervalInput(); updateRefreshUi(); if (restartPolling && state.autoRefreshEnabled) { startPolling(); } } function setPollIntervalSeconds(rawValue, options = {}) { const { notify = true } = options; const normalized = String(rawValue ?? "").replace(",", ".").trim(); const seconds = Number(normalized); if (!Number.isFinite(seconds) || seconds <= 0) { syncPollIntervalInput(); if (notify) { showToast("Введите корректный интервал автообновления (1-120 секунд).", "error"); } return; } const nextMs = normalizePollIntervalMs(seconds * 1000); setPollIntervalMs(nextMs); if (notify) { showToast(`Интервал автообновления: ${formatPollIntervalSeconds(nextMs)}с.`, "success"); } } function selectedTimeZoneValue() { return state.timezone === "local" ? null : state.timezone; } function selectedTimeZoneLabel() { if (state.timezone === "local") { const zone = browserTimeZone(); return zone ? `локальный: ${zone}` : "локальный"; } const option = TIMEZONE_OPTIONS.find((item) => item.value === state.timezone); return option?.label || state.timezone; } function fillTimeZoneSelect() { const select = byId("timezone-select"); if (!select) return; select.innerHTML = ""; TIMEZONE_OPTIONS.forEach((option) => { const element = document.createElement("option"); element.value = option.value; element.textContent = option.label; select.appendChild(element); }); } function setTimeZone(value) { const next = isKnownTimeZone(value) ? value : "local"; state.timezone = next; saveTimeZonePreference(next); const select = byId("timezone-select"); if (select && select.value !== next) { select.value = next; } } function parseDateTimeInput(value) { const text = String(value || "").trim(); if (!text) return null; const parsed = new Date(text); if (Number.isNaN(parsed.getTime())) return null; return parsed.getTime(); } function updateHistoryRecordingUi() { const toggle = byId("history-record-toggle"); if (toggle) { toggle.textContent = state.historyRecordingEnabled ? "Пауза записи" : "Продолжить запись"; } setTextWithPulse( "history-record-state", `запись: ${state.historyRecordingEnabled ? "вкл" : "пауза"}` ); } function stopPolling() { if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; } } function pollTick() { loadAll().catch((err) => { showToast(`Ошибка обновления: ${localizeErrorMessage(err.message)}`, "error"); }); } function startPolling() { stopPolling(); if (!state.autoRefreshEnabled) return; state.pollTimer = setInterval(pollTick, state.pollIntervalMs); } function updateRefreshUi() { const button = byId("toggle-auto-refresh"); if (button) { button.textContent = state.autoRefreshEnabled ? "Пауза автообновления" : "Запустить автообновление"; } const suffix = `${formatPollIntervalSeconds(state.pollIntervalMs)}с`; setTextWithPulse( "refresh-state", `автообновление: ${state.autoRefreshEnabled ? "вкл" : "выкл"} (${suffix})` ); } function setAutoRefreshEnabled(enabled) { state.autoRefreshEnabled = Boolean(enabled); updateRefreshUi(); if (state.autoRefreshEnabled) { startPolling(); } else { stopPolling(); } } function showToast(message, kind = "info") { const root = byId("toast-container"); if (!root) return; const toast = document.createElement("div"); toast.className = `toast toast-${kind}`; toast.textContent = String(message || ""); root.appendChild(toast); requestAnimationFrame(() => toast.classList.add("toast-show")); setTimeout(() => { toast.classList.remove("toast-show"); setTimeout(() => toast.remove(), 220); }, 2600); } function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function statusClass(statusValue) { const status = String(statusValue || "n/a"); const mapping = { ok: "io-status-ok", error: "io-status-error", partial: "io-status-partial", skipped: "io-status-skipped", disabled: "io-status-disabled", warming_up: "io-status-warm", "n/a": "io-status-na", n_a: "io-status-na", }; return mapping[status] || "io-status-na"; } function fmtDateTimeCompact(value) { const parts = formatUpdatedTimestamp(value); const date = parts.date.replace(/^дата:\s*/i, ""); const time = parts.time.replace(/^время:\s*/i, ""); return `${date} ${time}`.trim(); } function findByFrequency(rows, frequencyHz) { if (!Array.isArray(rows) || !Number.isFinite(Number(frequencyHz))) return null; const target = Number(frequencyHz); return ( rows.find((row) => Math.abs(Number(row?.frequency_hz) - target) <= 1) || rows.find((row) => Number(row?.frequency_hz) === target) || null ); } function renderInputFlow(data) { const root = byId("input-flow"); if (!root) return; const receivers = Array.isArray(data?.receivers) ? data.receivers : []; if (receivers.length === 0) { root.innerHTML = '
Ожидание входных измерений от ресиверов.
'; return; } root.innerHTML = receivers .map((receiver) => { const receiverId = escapeHtml(receiver?.receiver_id || "n/a"); const sourceUrl = escapeHtml(receiver?.source_url || "-"); const center = receiver?.center || {}; const samples = Array.isArray(receiver?.samples) ? receiver.samples : []; const perFrequency = Array.isArray(receiver?.per_frequency) ? receiver.per_frequency : []; const sampleRowsHtml = samples.length === 0 ? 'Нет сэмплов' : samples .map( (sample) => ` ${fmt(sample?.frequency_mhz ?? hzToMhz(sample?.frequency_hz), 3)} ${fmt(sample?.amplitude_dbm, 1)} ${fmt(sample?.distance_m, 2)} ` ) .join(""); const perFrequencyHtml = perFrequency.length === 0 ? 'нет расчётов по частотам' : perFrequency .map( (row) => ` ${fmt(row?.frequency_mhz ?? hzToMhz(row?.frequency_hz), 3)} МГц: R=${fmt(row?.radius_m, 2)} м, ε=${fmt(row?.residual_m, 2)} м ` ) .join(""); return `

${receiverId}

${fmt(receiver?.filtered_samples_count, 0)}/${fmt(receiver?.raw_samples_count, 0)} сэмплов
Источник: ${sourceUrl}
Центр: X=${fmt(center?.x, 3)} Y=${fmt(center?.y, 3)} Z=${fmt(center?.z, 3)}
Радиус (все частоты): ${fmt(receiver?.radius_m_all_freq, 2)} м
${sampleRowsHtml}
Частота, МГц RSSI, дБм Дистанция, м
${perFrequencyHtml}
`; }) .join(""); } function renderOutputFlow(data, delivery) { const root = byId("output-flow"); if (!root) return; const servers = Array.isArray(delivery?.servers) ? delivery.servers : []; const selectedFrequencyMhz = data?.selected_frequency_mhz ?? hzToMhz(data?.selected_frequency_hz); const pos = data?.position || {}; const payloadPreview = `

Формируемый выходной пакет

${escapeHtml(localizeStatus(delivery?.status))}
Время расчёта: ${escapeHtml(fmtDateTimeCompact(data?.timestamp_utc || delivery?.sent_at_utc))}
Частота: ${fmt(selectedFrequencyMhz, 3)} МГц
Координаты: X=${fmt(pos?.x, 3)} Y=${fmt(pos?.y, 3)} Z=${fmt(pos?.z, 3)}
`; if (servers.length === 0) { root.innerHTML = payloadPreview + '
Нет настроенных выходных серверов или отправка ещё не выполнялась.
'; return; } const serversHtml = servers .map((server) => { const status = localizeStatus(server?.status); const statusCls = statusClass(server?.status); const target = server?.target || {}; const endpoint = `${target?.ip || "-"}:${target?.port || "-"}${target?.path || ""}`; const responseRaw = String(server?.response_body || "").trim() || "-"; const responseShort = responseRaw.length > 160 ? `${responseRaw.slice(0, 157)}...` : responseRaw; return `

${escapeHtml(server?.name || "output")}

${escapeHtml(status)}
Назначение: ${escapeHtml(endpoint)}
HTTP: ${escapeHtml(server?.http_status ?? "-")}
Время отправки: ${escapeHtml(fmtDateTimeCompact(server?.sent_at_utc || delivery?.sent_at_utc))}
Ответ: ${escapeHtml(responseShort)}
`; }) .join(""); root.innerHTML = payloadPreview + serversHtml; } function buildIoHistoryRow(data, delivery) { if (!data) return null; const selectedHz = Number(data?.selected_frequency_hz); const selectedMhz = data?.selected_frequency_mhz ?? hzToMhz(selectedHz); const receivers = Array.isArray(data?.receivers) ? data.receivers : []; const servers = Array.isArray(delivery?.servers) ? delivery.servers : []; const rmseMRaw = Number(data?.rmse_m); const rmseM = Number.isFinite(rmseMRaw) ? rmseMRaw : null; const rssiValues = []; const inputItems = receivers.map((receiver) => { const receiverId = String(receiver?.receiver_id || "n/a"); const sample = findByFrequency(receiver?.samples, selectedHz) || receiver?.samples?.[0] || null; const perFrequency = findByFrequency(receiver?.per_frequency, selectedHz) || receiver?.per_frequency?.[0] || null; const rssi = Number(sample?.amplitude_dbm); if (Number.isFinite(rssi)) { rssiValues.push(rssi); } const radius = perFrequency?.radius_m ?? sample?.distance_m; return `${receiverId}: ${fmt(rssi, 1)} dBm / ${fmt(radius, 2)} m`; }); const avgRssiDbm = rssiValues.length > 0 ? rssiValues.reduce((acc, value) => acc + value, 0) / rssiValues.length : null; const outputItems = (() => { if (servers.length === 0) { const pos = data?.position || {}; return [`X=${fmt(pos?.x, 3)} Y=${fmt(pos?.y, 3)} Z=${fmt(pos?.z, 3)}`]; } return servers.map((server) => { const name = String(server?.name || "output"); const status = localizeStatus(server?.status); const code = server?.http_status ?? "-"; return `${name}: ${status} (${code})`; }); })(); const inputSummary = inputItems.join(" | "); const outputSummary = outputItems.join("; "); const statusRaw = String(delivery?.status || "n/a"); const timestamp = String(data?.timestamp_utc || delivery?.sent_at_utc || ""); const key = `${timestamp}|${fmt(selectedMhz, 3)}|${inputItems.join("||")}|${outputItems.join("||")}|${statusRaw}|${fmt(rmseM, 2)}|${fmt(avgRssiDbm, 1)}`; return { key, timestamp, frequencyMhz: selectedMhz, rmseM, avgRssiDbm, inputItems, outputItems, inputSummary, outputSummary, statusRaw, }; } function appendIoHistory(data, delivery) { if (!state.historyRecordingEnabled) return; const row = buildIoHistoryRow(data, delivery); if (!row) return; if (state.ioHistory.some((existing) => existing.key === row.key)) return; state.ioHistory.unshift(row); if (state.ioHistory.length > IO_HISTORY_LIMIT) { state.ioHistory.length = IO_HISTORY_LIMIT; } } function statusIsProblem(statusRaw) { return !["ok"].includes(String(statusRaw || "").toLowerCase()); } function toPercent(part, total) { if (!Number.isFinite(part) || !Number.isFinite(total) || total <= 0) return 0; return Math.max(0, Math.min(100, Math.round((part / total) * 100))); } function clamp(value, minValue, maxValue) { const numeric = Number(value); if (!Number.isFinite(numeric)) return minValue; return Math.max(minValue, Math.min(maxValue, numeric)); } function setProgressWidth(id, percent) { const el = byId(id); if (!el) return; const normalized = clamp(percent, 0, 100); el.style.width = `${normalized}%`; } function statusToSuccessScore(statusRaw) { const status = String(statusRaw || "n/a").toLowerCase(); if (status === "ok") return 100; if (status === "partial") return 70; if (status === "warming_up") return 40; if (status === "skipped") return 30; if (status === "disabled") return 20; return 0; } function buildTrendSeries(limit = 20) { const rows = state.ioHistory.slice(0, Math.max(1, Number(limit) || 20)).reverse(); const rmse = rows .map((row) => Number(row?.rmseM)) .filter((value) => Number.isFinite(value) && value >= 0); const avgRssi = rows .map((row) => Number(row?.avgRssiDbm)) .filter((value) => Number.isFinite(value)); const delivery = rows.map((row) => statusToSuccessScore(row?.statusRaw)); return { rows, rmse, avgRssi, delivery }; } function formatTrendMeta(values, digits = 2, unit = "") { if (!Array.isArray(values) || values.length === 0) return "н/д"; const finiteValues = values.filter((value) => Number.isFinite(Number(value))); if (finiteValues.length === 0) return "н/д"; const last = Number(finiteValues[finiteValues.length - 1]); const min = Math.min(...finiteValues); const max = Math.max(...finiteValues); return `посл ${fmt(last, digits)}${unit} | мин ${fmt(min, digits)}${unit} | макс ${fmt(max, digits)}${unit}`; } function buildSparklineSvg(values, options = {}) { const { width = 220, height = 54, padding = 4, stroke = "#2b73f0", area = "rgba(43, 115, 240, 0.22)", invert = false, } = options; const points = Array.isArray(values) ? values.map((value) => Number(value)).filter((value) => Number.isFinite(value)) : []; if (points.length === 0) { return '
Недостаточно данных
'; } const minValue = Math.min(...points); const maxValue = Math.max(...points); const range = maxValue - minValue || 1; const safeWidth = Math.max(40, Number(width) || 220); const safeHeight = Math.max(24, Number(height) || 54); const baseline = safeHeight - padding; const usableWidth = Math.max(1, safeWidth - padding * 2); const usableHeight = Math.max(1, safeHeight - padding * 2); const normalized = points.map((value, index) => { const x = padding + (points.length === 1 ? usableWidth / 2 : (index / (points.length - 1)) * usableWidth); let ratio = (value - minValue) / range; if (invert) ratio = 1 - ratio; const y = baseline - ratio * usableHeight; return { x, y, value }; }); const linePoints = normalized.map((point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`).join(" "); const first = normalized[0]; const last = normalized[normalized.length - 1]; const areaPath = `M ${first.x.toFixed(2)} ${baseline.toFixed(2)} L ${linePoints .replaceAll(" ", " L ")} L ${last.x.toFixed(2)} ${baseline.toFixed(2)} Z`; const y25 = (padding + usableHeight * 0.25).toFixed(2); const y50 = (padding + usableHeight * 0.5).toFixed(2); const y75 = (padding + usableHeight * 0.75).toFixed(2); return ` `; } function renderOverviewTrends() { const rmseRoot = byId("ov-trend-rmse-chart"); const rssiRoot = byId("ov-trend-rssi-chart"); const deliveryRoot = byId("ov-trend-delivery-chart"); if (!rmseRoot && !rssiRoot && !deliveryRoot) return; const series = buildTrendSeries(20); if (rmseRoot) { rmseRoot.innerHTML = buildSparklineSvg(series.rmse, { stroke: "#2b73f0", area: "rgba(43, 115, 240, 0.22)", invert: true, }); } if (rssiRoot) { rssiRoot.innerHTML = buildSparklineSvg(series.avgRssi, { stroke: "#ff8a3d", area: "rgba(255, 138, 61, 0.24)", }); } if (deliveryRoot) { deliveryRoot.innerHTML = buildSparklineSvg(series.delivery, { stroke: "#14a37f", area: "rgba(20, 163, 127, 0.22)", }); } setTextWithPulse("ov-trend-rmse-meta", formatTrendMeta(series.rmse, 2, " м")); setTextWithPulse("ov-trend-rssi-meta", formatTrendMeta(series.avgRssi, 1, " дБм")); setTextWithPulse("ov-trend-delivery-meta", formatTrendMeta(series.delivery, 0, "%")); } function renderHistoryTrends() { const root = byId("history-trends"); if (!root) return; const series = buildTrendSeries(40); const blocks = [ { title: "RMSE, м", meta: formatTrendMeta(series.rmse, 2, " м"), chart: buildSparklineSvg(series.rmse, { stroke: "#2b73f0", area: "rgba(43, 115, 240, 0.22)", invert: true, }), }, { title: "Средний RSSI, дБм", meta: formatTrendMeta(series.avgRssi, 1, " дБм"), chart: buildSparklineSvg(series.avgRssi, { stroke: "#ff8a3d", area: "rgba(255, 138, 61, 0.24)", }), }, { title: "Успех доставки, %", meta: formatTrendMeta(series.delivery, 0, "%"), chart: buildSparklineSvg(series.delivery, { stroke: "#14a37f", area: "rgba(20, 163, 127, 0.22)", }), }, ]; root.innerHTML = blocks .map( (block) => `
${escapeHtml(block.title)} ${escapeHtml(block.meta)}
${block.chart}
` ) .join(""); } function buildMonitoringSummary() { const data = state.result?.data || null; const delivery = state.result?.output_delivery || state.frequencies?.output_delivery || {}; const inputs = Array.isArray(state.mockControls?.inputs) ? state.mockControls.inputs : []; const outputs = Array.isArray(state.mockControls?.outputs) ? state.mockControls.outputs : []; const inputOnline = inputs.filter((item) => Boolean(item?.reachable)).length; const outputOnline = outputs.filter((item) => Boolean(item?.reachable)).length; const total = state.ioHistory.length; const okCount = state.ioHistory.filter((row) => String(row.statusRaw || "").toLowerCase() === "ok").length; const success = toPercent(okCount, total); const selectedHz = Number(data?.selected_frequency_hz); const frequencyRows = Array.isArray(data?.frequency_table) ? data.frequency_table : []; const updatedAt = state.result?.updated_at_utc || state.frequencies?.updated_at_utc || ""; const updatedText = updatedAt ? fmtDateTimeCompact(updatedAt) : "н/д"; return { data, delivery, inputs, outputs, inputOnline, outputOnline, inputTotal: inputs.length, outputTotal: outputs.length, inputOnlinePercent: toPercent(inputOnline, inputs.length), outputOnlinePercent: toPercent(outputOnline, outputs.length), total, okCount, success, selectedHz, frequencyRows, receivers: Array.isArray(data?.receivers) ? data.receivers : [], updatedText, }; } function renderOverviewFrequencyHealth(frequencyRows, selectedHz) { const root = byId("ov-frequency-health"); if (!root) return; if (!Array.isArray(frequencyRows) || frequencyRows.length === 0) { root.innerHTML = '
Расчёты по частотам пока не получены.
'; return; } const finiteRmse = frequencyRows .map((row) => Number(row?.rmse_m)) .filter((value) => Number.isFinite(value) && value >= 0); const minRmse = finiteRmse.length > 0 ? Math.min(...finiteRmse) : 0; const maxRmse = finiteRmse.length > 0 ? Math.max(...finiteRmse) : 1; root.innerHTML = frequencyRows .map((row) => { const hz = Number(row?.frequency_hz); const mhz = row?.frequency_mhz ?? hzToMhz(hz); const rmse = Number(row?.rmse_m); const exact = Boolean(row?.exact); const isSelected = Number.isFinite(hz) && Math.abs(hz - Number(selectedHz)) <= 1; let quality = 0; if (Number.isFinite(rmse) && rmse >= 0) { if (maxRmse <= minRmse) { quality = 100; } else { quality = Math.round(((maxRmse - rmse) / (maxRmse - minRmse)) * 100); } } quality = clamp(quality, 0, 100); return `
${escapeHtml(fmt(mhz, 3))} МГц ${exact ? "точно" : "оценка"}
RMSE: ${escapeHtml(fmt(rmse, 2))} м Качество: ${quality}%
`; }) .join(""); } function renderOverviewSignalGrid(receivers, selectedHz) { const root = byId("ov-signal-grid"); if (!root) return; if (!Array.isArray(receivers) || receivers.length === 0) { root.innerHTML = '
Сигналы ресиверов пока не получены.
'; return; } root.innerHTML = receivers .map((receiver) => { const receiverId = String(receiver?.receiver_id || "n/a"); const sample = findByFrequency(receiver?.samples, selectedHz) || receiver?.samples?.[0] || null; const row = findByFrequency(receiver?.per_frequency, selectedHz) || receiver?.per_frequency?.[0] || null; const rssi = Number(sample?.amplitude_dbm); const radius = Number(row?.radius_m ?? sample?.distance_m); const residual = Number(row?.residual_m); const level = Number.isFinite(rssi) ? clamp(((rssi + 120) / 70) * 100, 0, 100) : 0; return `
${escapeHtml(receiverId)} ${escapeHtml(fmt(rssi, 1))} dBm
R=${escapeHtml(fmt(radius, 2))} м ε=${escapeHtml(fmt(residual, 2))} м
`; }) .join(""); } function normalizePipelineStatus(status) { const normalized = String(status || "n/a").toLowerCase(); if (normalized === "ok") return "ok"; if (normalized === "error") return "error"; if (normalized === "partial") return "partial"; if (normalized === "warming_up") return "warm"; return "warm"; } function pipelineDotClass(status) { const normalized = normalizePipelineStatus(status); if (normalized === "ok") return "pipeline-dot pipeline-dot-ok"; if (normalized === "error") return "pipeline-dot pipeline-dot-error"; if (normalized === "partial") return "pipeline-dot pipeline-dot-partial"; return "pipeline-dot pipeline-dot-warm"; } function renderOverviewPipeline(summary) { const root = byId("ov-pipeline-stages"); if (!root) return; const hasData = Boolean(summary.data); const solveStatus = hasData ? Number.isFinite(Number(summary.data?.rmse_m)) ? "ok" : "partial" : "warming_up"; const inputStatus = summary.inputTotal <= 0 ? "warming_up" : summary.inputOnline === summary.inputTotal ? "ok" : summary.inputOnline > 0 ? "partial" : "error"; const outputStatus = summary.outputTotal <= 0 ? "warming_up" : summary.outputOnline === summary.outputTotal ? "ok" : summary.outputOnline > 0 ? "partial" : "error"; const stages = [ { name: "Сбор входов", status: inputStatus, value: `${summary.inputOnline}/${summary.inputTotal}`, note: "доступность входных серверов", }, { name: "Решение", status: solveStatus, value: hasData ? `RMSE ${fmt(summary.data?.rmse_m, 2)} м` : "ожидание данных", note: "оценка точности пересечения сфер", }, { name: "Доставка", status: summary.delivery?.status || "warming_up", value: `${summary.outputOnline}/${summary.outputTotal}`, note: "доступность выходных серверов", }, { name: "История", status: summary.total > 0 ? (summary.success >= 80 ? "ok" : "partial") : "warming_up", value: `${summary.success}%`, note: "успешность доставки по накопленной ленте", }, ]; root.innerHTML = stages .map( (stage) => `
${escapeHtml(stage.name)} ${escapeHtml(localizeStatus(stage.status))}
${escapeHtml(stage.value)} • ${escapeHtml(stage.note)}
` ) .join(""); } function renderOverviewTopFrequencies(frequencyRows) { const root = byId("ov-top-frequencies"); if (!root) return; if (!Array.isArray(frequencyRows) || frequencyRows.length === 0) { root.innerHTML = '
Нет данных по частотам для рейтинга.
'; return; } const rankedRows = [...frequencyRows].sort((a, b) => { const ra = Number(a?.rmse_m); const rb = Number(b?.rmse_m); const aFinite = Number.isFinite(ra); const bFinite = Number.isFinite(rb); if (aFinite && bFinite) return ra - rb; if (aFinite) return -1; if (bFinite) return 1; return 0; }); const topRows = rankedRows.slice(0, 5); const rmseValues = topRows .map((row) => Number(row?.rmse_m)) .filter((value) => Number.isFinite(value) && value >= 0); const minRmse = rmseValues.length > 0 ? Math.min(...rmseValues) : 0; const maxRmse = rmseValues.length > 0 ? Math.max(...rmseValues) : 1; root.innerHTML = topRows .map((row, index) => { const rmse = Number(row?.rmse_m); const mhz = row?.frequency_mhz ?? hzToMhz(row?.frequency_hz); const exact = Boolean(row?.exact); let score = 0; if (Number.isFinite(rmse) && rmse >= 0) { if (maxRmse <= minRmse) { score = 100; } else { score = Math.round(((maxRmse - rmse) / (maxRmse - minRmse)) * 100); } } score = clamp(score, 0, 100); return `
${index + 1} ${escapeHtml(fmt(mhz, 3))} МГц ${exact ? "точно" : "оценка"}
RMSE ${escapeHtml(fmt(rmse, 2))} м Индекс ${score}%
`; }) .join(""); } function renderMenuGroupBadges(summary) { setTextWithPulse("menu-badge-monitoring", `${summary.success}%`); setTextWithPulse("menu-badge-io", `${summary.inputOnline}/${summary.inputTotal} • ${summary.outputOnline}/${summary.outputTotal}`); setTextWithPulse("menu-badge-config", `${summary.inputs.length}вх/${summary.outputs.length}вых`); } function renderOverviewMetrics() { const summary = buildMonitoringSummary(); setTextWithPulse("ov-input-online", `${summary.inputOnline}/${summary.inputTotal}`); setTextWithPulse("ov-output-online", `${summary.outputOnline}/${summary.outputTotal}`); setTextWithPulse("ov-history-total", summary.total); setTextWithPulse("ov-success-rate", `${summary.success}%`); setTextWithPulse("ov-health-chip", localizeStatus(state.health?.status)); setTextWithPulse("ov-delivery-chip", localizeStatus(summary.delivery?.status)); setTextWithPulse("ov-updated-at", summary.updatedText); setTextWithPulse("ov-input-online-bar-text", `${summary.inputOnline}/${summary.inputTotal}`); setTextWithPulse("ov-output-online-bar-text", `${summary.outputOnline}/${summary.outputTotal}`); setTextWithPulse("ov-delivery-bar-text", `${summary.success}%`); setProgressWidth("ov-input-online-bar", summary.inputOnlinePercent); setProgressWidth("ov-output-online-bar", summary.outputOnlinePercent); setProgressWidth("ov-delivery-bar", summary.success); renderMenuGroupBadges(summary); renderOverviewFrequencyHealth(summary.frequencyRows, summary.selectedHz); renderOverviewSignalGrid(summary.receivers, summary.selectedHz); renderOverviewPipeline(summary); renderOverviewTopFrequencies(summary.frequencyRows); renderOverviewTrends(); } function renderHistoryInsights() { const monitorRoot = byId("history-monitor"); const summary = buildMonitoringSummary(); renderHistoryTrends(); if (monitorRoot) { const problemCount = state.ioHistory.filter((row) => statusIsProblem(row.statusRaw)).length; const healthStatus = localizeStatus(state.health?.status); const deliveryStatus = localizeStatus( state.result?.output_delivery?.status || state.frequencies?.output_delivery?.status ); monitorRoot.innerHTML = `
Сервис${escapeHtml(healthStatus)}
Доставка${escapeHtml(deliveryStatus)}
Входы online${summary.inputOnline}/${summary.inputTotal}
Выходы online${summary.outputOnline}/${summary.outputTotal}
Проблемных событий${problemCount}
Успех доставки${summary.success}%
`; } } function maybeNotifyStatusChanges(delivery) { const healthNow = String(state.health?.status || "n/a").toLowerCase(); const deliveryNow = String(delivery?.status || "n/a").toLowerCase(); if (!state.initialized) { state.initialized = true; state.lastHealthStatus = healthNow; state.lastDeliveryStatus = deliveryNow; return; } if (healthNow !== state.lastHealthStatus) { if (healthNow === "error") { showToast("Сервис перешел в состояние ошибки.", "error"); } else if (state.lastHealthStatus === "error" && healthNow === "ok") { showToast("Сервис восстановлен.", "success"); } state.lastHealthStatus = healthNow; } if (deliveryNow !== state.lastDeliveryStatus) { if (deliveryNow === "error") { showToast("Проблема с отправкой на выходной сервер.", "error"); } else if (state.lastDeliveryStatus === "error" && deliveryNow === "ok") { showToast("Отправка на выход восстановлена.", "success"); } else if (deliveryNow === "partial") { showToast("Частичная отправка: проверьте состояние выходных серверов.", "info"); } state.lastDeliveryStatus = deliveryNow; } } function renderHistorySummary() { const total = state.ioHistory.length; const okCount = state.ioHistory.filter((row) => String(row.statusRaw).toLowerCase() === "ok").length; const problemCount = state.ioHistory.filter((row) => statusIsProblem(row.statusRaw)).length; const uniqueFreqCount = new Set( state.ioHistory .map((row) => Number(row.frequencyMhz)) .filter((value) => Number.isFinite(value)) .map((value) => value.toFixed(3)) ).size; const lastTimestamp = state.ioHistory[0]?.timestamp || ""; setTextWithPulse("hist-total", total); setTextWithPulse("hist-ok", okCount); setTextWithPulse("hist-problem", problemCount); setTextWithPulse("hist-freqs", uniqueFreqCount); setTextWithPulse("hist-last", lastTimestamp ? fmtDateTimeCompact(lastTimestamp) : "н/д"); } function renderHistoryPagination(totalRows, totalPages, fromIndex, toIndex) { const pageInfo = byId("history-page-info"); const prevBtn = byId("history-prev"); const nextBtn = byId("history-next"); const pageSizeSelect = byId("history-page-size"); if (pageSizeSelect && String(pageSizeSelect.value) !== String(state.historyPageSize)) { pageSizeSelect.value = String(state.historyPageSize); } if (prevBtn) { prevBtn.disabled = state.historyPage <= 1 || totalRows === 0; } if (nextBtn) { nextBtn.disabled = state.historyPage >= totalPages || totalRows === 0; } if (pageInfo) { if (totalRows === 0) { pageInfo.textContent = "Стр. 1/1 • 0 записей"; } else { pageInfo.textContent = `Стр. ${state.historyPage}/${totalPages} • ${fromIndex}-${toIndex} из ${totalRows}`; } } } function renderIoHistory() { const tbody = byId("io-history-table")?.querySelector("tbody"); if (!tbody) return; updateHistoryRecordingUi(); renderHistorySummary(); renderHistoryInsights(); const filteredByStatus = state.historyFilter === "all" ? state.ioHistory : state.ioHistory.filter( (row) => String(row.statusRaw || "").toLowerCase() === state.historyFilter ); const fromMsRaw = parseDateTimeInput(state.historyDateFrom); const toMsRaw = parseDateTimeInput(state.historyDateTo); const hasDateFilter = fromMsRaw !== null || toMsRaw !== null; let fromMs = fromMsRaw; let toMs = toMsRaw; if (fromMs !== null && toMs !== null && fromMs > toMs) { const temp = fromMs; fromMs = toMs; toMs = temp; } const filtered = filteredByStatus.filter((row) => { if (!hasDateFilter) return true; const rowMs = Date.parse(String(row.timestamp || "")); if (!Number.isFinite(rowMs)) return false; if (fromMs !== null && rowMs < fromMs) return false; if (toMs !== null && rowMs > toMs) return false; return true; }); const pageSize = Math.max(1, Number(state.historyPageSize) || 10); const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); if (state.historyPage > totalPages) { state.historyPage = totalPages; } if (state.historyPage < 1) { state.historyPage = 1; } if (filtered.length === 0) { let text = "История пока пуста."; if (state.ioHistory.length > 0) { if (hasDateFilter && state.historyFilter !== "all") { text = "По статусу и диапазону времени записей нет."; } else if (hasDateFilter) { text = "По выбранному диапазону времени записей нет."; } else { text = "По выбранному статусу записей нет."; } } tbody.innerHTML = `${text}`; renderHistoryPagination(0, 1, 0, 0); return; } const start = (state.historyPage - 1) * pageSize; const pageRows = filtered.slice(start, start + pageSize); const fromIndex = start + 1; const toIndex = start + pageRows.length; tbody.innerHTML = pageRows .map( (row) => ` ${escapeHtml(fmtDateTimeCompact(row.timestamp))} ${escapeHtml(fmt(row.frequencyMhz, 3))}
${(Array.isArray(row.inputItems) ? row.inputItems : String(row.inputSummary || "").split(" | ")) .filter((item) => String(item || "").trim().length > 0) .map((item) => `
${escapeHtml(item)}
`) .join("")}
${(Array.isArray(row.outputItems) ? row.outputItems : String(row.outputSummary || "").split("; ")) .filter((item) => String(item || "").trim().length > 0) .map((item) => `
${escapeHtml(item)}
`) .join("")}
${escapeHtml(localizeStatus(row.statusRaw))} ` ) .join(""); renderHistoryPagination(filtered.length, totalPages, fromIndex, toIndex); } function boolStateLabel(value, trueLabel, falseLabel) { if (value === true) return trueLabel; if (value === false) return falseLabel; return "состояние неизвестно"; } function buildControlCardHtml(item, target) { const id = String(item?.id || ""); const name = escapeHtml(item?.name || id || "n/a"); const reachable = Boolean(item?.reachable); const errorText = item?.error ? escapeHtml(item.error) : ""; const stateValue = target === "input" ? item?.enabled : item?.accept_writes; const isActive = stateValue === true; const nextEnabled = !isActive; const flowLabel = target === "input" ? "входной поток" : "выходной поток"; const statusText = target === "input" ? boolStateLabel(stateValue, "передача активна", "передача остановлена") : boolStateLabel(stateValue, "приём активен", "приём остановлен"); const buttonText = target === "input" ? isActive ? "Пауза входа" : "Запустить вход" : isActive ? "Пауза выхода" : "Запустить выход"; const statusKind = !reachable && stateValue === null ? "io-status-error" : isActive ? "io-status-ok" : "io-status-skipped"; const reachabilityKind = reachable ? "io-status-ok" : "io-status-error"; const reachabilityText = reachable ? "online" : "offline"; const disabledAttr = !id ? "disabled" : ""; const liveFrequencies = Array.isArray(item?.frequencies_mhz) ? item.frequencies_mhz.map((value) => fmt(Number(value), 3)).join(", ") : ""; const configuredFrequencies = Array.isArray(item?.configured_frequencies_mhz) ? item.configured_frequencies_mhz.map((value) => fmt(Number(value), 3)).join(", ") : ""; const frequencyText = target === "input" ? `активные: ${liveFrequencies || "н/д"} • конфиг: ${configuredFrequencies || "н/д"}` : ""; return `

${name}

${flowLabel}
${reachabilityText} ${escapeHtml(statusText)}
${errorText ? `
Ошибка: ${errorText}
` : ""} ${frequencyText ? `
частоты (МГц): ${escapeHtml(frequencyText)}
` : ""}
`; } function bindControlButtons() { document.querySelectorAll(".flow-toggle-btn").forEach((button) => { button.addEventListener("click", async () => { const target = button.getAttribute("data-target") || ""; const id = button.getAttribute("data-id") || ""; const nextEnabled = button.getAttribute("data-next-enabled") === "1"; if (!target || !id) return; button.disabled = true; try { const response = await postJson("/mock/control", { target, id, enabled: nextEnabled, }); showToast(response.message || "Состояние потока обновлено.", "success"); } catch (err) { showToast(localizeErrorMessage(err.message), "error"); } finally { await loadAll(); } }); }); } function renderErrorControls() { const root = byId("error-controls"); if (!root) return; const controls = state.mockControls; if (!controls) { root.innerHTML = '
Управление потоками недоступно.
'; return; } const inputs = Array.isArray(controls?.inputs) ? controls.inputs : []; const outputs = Array.isArray(controls?.outputs) ? controls.outputs : []; const inputActiveCount = inputs.filter((item) => item?.enabled === true).length; const outputActiveCount = outputs.filter((item) => item?.accept_writes === true).length; const inputHtml = inputs.length === 0 ? '
Входные источники не обнаружены.
' : inputs.map((item) => buildControlCardHtml(item, "input")).join(""); const outputHtml = outputs.length === 0 ? '
Выходные серверы не обнаружены.
' : outputs.map((item) => buildControlCardHtml(item, "output")).join(""); root.innerHTML = `

Входные Потоки

${inputActiveCount}/${inputs.length} активны
${inputHtml}

Выходные Потоки

${outputActiveCount}/${outputs.length} активны
${outputHtml}
`; bindControlButtons(); } 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)}`); renderOverviewMetrics(); maybeNotifyStatusChanges(delivery || {}); if (!data) { setTextWithPulse("selected-freq", "-"); setTextWithPulse("pos-x", "-"); setTextWithPulse("pos-y", "-"); setTextWithPulse("pos-z", "-"); setTextWithPulse("rmse", "-"); renderInputFlow(null); renderOutputFlow(null, delivery || {}); renderIoHistory(); renderErrorControls(); 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)); renderInputFlow(data); renderOutputFlow(data, delivery || {}); appendIoHistory(data, delivery || {}); renderIoHistory(); renderErrorControls(); 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, controlsRes] = await Promise.allSettled([ getJson("/health"), getJson("/result"), getJson("/frequencies"), getJson("/mock/controls"), ]); 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; state.mockControls = controlsRes.status === "fulfilled" ? controlsRes.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 ? " (применено, но сохранить файл не удалось)" : ""; const syncErrors = Array.isArray(result.mock_input_frequency_sync_errors) ? result.mock_input_frequency_sync_errors.filter((item) => String(item || "").trim() !== "") : []; byId("servers-state").textContent = result.restart_required ? `серверы: сохранены, требуется перезапуск${saveSuffix}` : `серверы: сохранены${saveSuffix}`; if (syncErrors.length > 0) { showToast(`Частоты тестовых входов синхронизированы не полностью: ${syncErrors.join("; ")}`, "error"); } else if (result.mock_input_frequency_sync_enabled) { showToast("Частоты тестовых входов обновлены и сохранены.", "success"); } } catch (err) { byId("servers-state").textContent = `серверы: ${localizeErrorMessage(err.message)}`; } } function bindUi() { byId("refresh-now").addEventListener("click", refreshNow); const timezoneSelect = byId("timezone-select"); if (timezoneSelect) { timezoneSelect.addEventListener("change", (event) => { setTimeZone(String(event.target.value || "local")); render(); showToast("Часовой пояс обновлён.", "info"); }); } byId("toggle-auto-refresh").addEventListener("click", () => { setAutoRefreshEnabled(!state.autoRefreshEnabled); showToast( state.autoRefreshEnabled ? "Автообновление включено." : "Автообновление остановлено.", "info" ); }); const autoRefreshSecondsInput = byId("auto-refresh-seconds"); if (autoRefreshSecondsInput) { autoRefreshSecondsInput.addEventListener("change", (event) => { setPollIntervalSeconds(event.target.value); }); } 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", () => { setMenuCollapsed(!state.menuCollapsed); }); document.querySelectorAll(".menu-group-toggle").forEach((toggle) => { toggle.addEventListener("click", () => { const group = String(toggle.dataset.menuGroupToggle || ""); if (!MENU_GROUP_KEYS.includes(group)) return; const nextCollapsed = !Boolean(state.menuGroupCollapsed[group]); setMenuGroupCollapsed(group, nextCollapsed, { persist: true }); }); }); const dateTimeToggle = byId("datetime-toggle"); if (dateTimeToggle) { dateTimeToggle.addEventListener("click", () => { setDateTimeCollapsed(!state.dateTimeCollapsed); }); } const densityToggle = byId("density-toggle"); if (densityToggle) { densityToggle.addEventListener("click", () => { const nextMode = state.uiDensity === "compact" ? "detailed" : "compact"; setUiDensity(nextMode, { persist: true }); showToast( state.uiDensity === "compact" ? "Включен компактный режим интерфейса." : "Включен детальный режим интерфейса.", "info" ); }); } const historyFilter = byId("history-filter"); if (historyFilter) { historyFilter.addEventListener("change", (event) => { const next = String(event.target.value || "all").toLowerCase(); state.historyFilter = next || "all"; state.historyPage = 1; renderIoHistory(); }); } const historyDateFrom = byId("history-date-from"); if (historyDateFrom) { historyDateFrom.addEventListener("change", (event) => { state.historyDateFrom = String(event.target.value || ""); state.historyPage = 1; renderIoHistory(); }); } const historyDateTo = byId("history-date-to"); if (historyDateTo) { historyDateTo.addEventListener("change", (event) => { state.historyDateTo = String(event.target.value || ""); state.historyPage = 1; renderIoHistory(); }); } const historyDateReset = byId("history-date-reset"); if (historyDateReset) { historyDateReset.addEventListener("click", () => { state.historyDateFrom = ""; state.historyDateTo = ""; if (historyDateFrom) historyDateFrom.value = ""; if (historyDateTo) historyDateTo.value = ""; state.historyPage = 1; renderIoHistory(); showToast("Фильтр времени сброшен.", "info"); }); } const historyPageSize = byId("history-page-size"); if (historyPageSize) { historyPageSize.addEventListener("change", (event) => { const nextSize = Number(event.target.value); state.historyPageSize = Number.isFinite(nextSize) && nextSize > 0 ? nextSize : 10; state.historyPage = 1; renderIoHistory(); }); } const historyRecordToggle = byId("history-record-toggle"); if (historyRecordToggle) { historyRecordToggle.addEventListener("click", () => { state.historyRecordingEnabled = !state.historyRecordingEnabled; updateHistoryRecordingUi(); showToast( state.historyRecordingEnabled ? "Запись истории продолжена." : "Запись истории остановлена.", "info" ); }); } const historyPrev = byId("history-prev"); if (historyPrev) { historyPrev.addEventListener("click", () => { if (state.historyPage > 1) { state.historyPage -= 1; renderIoHistory(); } }); } const historyNext = byId("history-next"); if (historyNext) { historyNext.addEventListener("click", () => { state.historyPage += 1; renderIoHistory(); }); } const clearHistory = byId("clear-history"); if (clearHistory) { clearHistory.addEventListener("click", () => { state.ioHistory = []; state.historyPage = 1; renderIoHistory(); showToast("История очищена.", "success"); }); } document.querySelectorAll(".menu-item").forEach((item) => { item.addEventListener("click", () => { setActiveSection(item.dataset.section); }); }); } async function boot() { fillTimeZoneSelect(); setTimeZone(readTimeZonePreference()); bindUi(); state.menuGroupCollapsed = readMenuGroupCollapsed(); applyAllMenuGroupsCollapsed(); setUiDensity(readUiDensityPreference(), { persist: false }); setPollIntervalMs(readPollIntervalPreference(), { persist: false, restartPolling: false }); updateHistoryRecordingUi(); setMenuCollapsed(readMenuCollapsed()); setDateTimeCollapsed(readDateTimeCollapsed()); updateRefreshUi(); setActiveSection(state.activeSection); await loadConfig(); await loadAll(); startPolling(); } boot().catch((err) => { setTextWithPulse("health-status", `сервис: ${localizeErrorMessage(err.message)}`); });