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)} сэмплов
| Частота, МГц |
RSSI, дБм |
Дистанция, м |
${sampleRowsHtml}
${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))}
`;
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)}
`;
})
.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 `
${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)}`);
});