You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

2282 lines
79 KiB
JavaScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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 = '<div class="io-empty">Ожидание входных измерений от ресиверов.</div>';
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
? '<tr><td colspan="3">Нет сэмплов</td></tr>'
: samples
.map(
(sample) => `
<tr>
<td>${fmt(sample?.frequency_mhz ?? hzToMhz(sample?.frequency_hz), 3)}</td>
<td>${fmt(sample?.amplitude_dbm, 1)}</td>
<td>${fmt(sample?.distance_m, 2)}</td>
</tr>`
)
.join("");
const perFrequencyHtml =
perFrequency.length === 0
? '<span class="io-chip io-status-na">нет расчётов по частотам</span>'
: perFrequency
.map(
(row) => `
<span class="io-chip io-chip-neutral">
${fmt(row?.frequency_mhz ?? hzToMhz(row?.frequency_hz), 3)} МГц:
R=${fmt(row?.radius_m, 2)} м, ε=${fmt(row?.residual_m, 2)} м
</span>`
)
.join("");
return `
<article class="io-card">
<header class="io-card-head">
<h4>${receiverId}</h4>
<span class="io-chip io-chip-neutral">
${fmt(receiver?.filtered_samples_count, 0)}/${fmt(receiver?.raw_samples_count, 0)} сэмплов
</span>
</header>
<div class="io-meta-grid">
<div><b>Источник:</b> ${sourceUrl}</div>
<div><b>Центр:</b> X=${fmt(center?.x, 3)} Y=${fmt(center?.y, 3)} Z=${fmt(center?.z, 3)}</div>
<div><b>Радиус (все частоты):</b> ${fmt(receiver?.radius_m_all_freq, 2)} м</div>
</div>
<div class="table-wrap io-mini-wrap">
<table class="io-mini-table">
<thead>
<tr>
<th>Частота, МГц</th>
<th>RSSI, дБм</th>
<th>Дистанция, м</th>
</tr>
</thead>
<tbody>${sampleRowsHtml}</tbody>
</table>
</div>
<div class="io-chip-row">${perFrequencyHtml}</div>
</article>`;
})
.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 = `
<article class="io-card">
<header class="io-card-head">
<h4>Формируемый выходной пакет</h4>
<span class="io-chip ${statusClass(delivery?.status)}">${escapeHtml(localizeStatus(delivery?.status))}</span>
</header>
<div class="io-meta-grid">
<div><b>Время расчёта:</b> ${escapeHtml(fmtDateTimeCompact(data?.timestamp_utc || delivery?.sent_at_utc))}</div>
<div><b>Частота:</b> ${fmt(selectedFrequencyMhz, 3)} МГц</div>
<div><b>Координаты:</b> X=${fmt(pos?.x, 3)} Y=${fmt(pos?.y, 3)} Z=${fmt(pos?.z, 3)}</div>
</div>
</article>
`;
if (servers.length === 0) {
root.innerHTML =
payloadPreview +
'<div class="io-empty">Нет настроенных выходных серверов или отправка ещё не выполнялась.</div>';
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 `
<article class="io-card">
<header class="io-card-head">
<h4>${escapeHtml(server?.name || "output")}</h4>
<span class="io-chip ${statusCls}">${escapeHtml(status)}</span>
</header>
<div class="io-meta-grid">
<div><b>Назначение:</b> ${escapeHtml(endpoint)}</div>
<div><b>HTTP:</b> ${escapeHtml(server?.http_status ?? "-")}</div>
<div><b>Время отправки:</b> ${escapeHtml(fmtDateTimeCompact(server?.sent_at_utc || delivery?.sent_at_utc))}</div>
<div><b>Ответ:</b> ${escapeHtml(responseShort)}</div>
</div>
</article>`;
})
.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 '<div class="sparkline-empty">Недостаточно данных</div>';
}
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 `
<svg class="sparkline-svg" viewBox="0 0 ${safeWidth} ${safeHeight}" preserveAspectRatio="none" role="img" aria-label="Тренд">
<line class="sparkline-grid" x1="${padding}" y1="${y25}" x2="${safeWidth - padding}" y2="${y25}"></line>
<line class="sparkline-grid" x1="${padding}" y1="${y50}" x2="${safeWidth - padding}" y2="${y50}"></line>
<line class="sparkline-grid" x1="${padding}" y1="${y75}" x2="${safeWidth - padding}" y2="${y75}"></line>
<path class="sparkline-area" d="${areaPath}" fill="${area}"></path>
<polyline class="sparkline-line" points="${linePoints}" stroke="${stroke}"></polyline>
<circle class="sparkline-dot" cx="${last.x.toFixed(2)}" cy="${last.y.toFixed(2)}" r="2.8" fill="${stroke}"></circle>
</svg>
`;
}
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) => `
<article class="trend-card">
<div class="trend-head">
<span>${escapeHtml(block.title)}</span>
<b>${escapeHtml(block.meta)}</b>
</div>
<div class="sparkline-wrap">${block.chart}</div>
</article>
`
)
.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 = '<div class="io-empty">Расчёты по частотам пока не получены.</div>';
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 `
<article class="frequency-health-item ${isSelected ? "frequency-health-item-active" : ""}">
<div class="frequency-health-head">
<b>${escapeHtml(fmt(mhz, 3))} МГц</b>
<span class="io-chip ${exact ? "io-status-ok" : "io-status-partial"}">${exact ? "точно" : "оценка"}</span>
</div>
<div class="monitor-progress monitor-progress-accent">
<span style="width:${quality}%"></span>
</div>
<div class="frequency-health-meta">
<span>RMSE: ${escapeHtml(fmt(rmse, 2))} м</span>
<span>Качество: ${quality}%</span>
</div>
</article>
`;
})
.join("");
}
function renderOverviewSignalGrid(receivers, selectedHz) {
const root = byId("ov-signal-grid");
if (!root) return;
if (!Array.isArray(receivers) || receivers.length === 0) {
root.innerHTML = '<div class="io-empty">Сигналы ресиверов пока не получены.</div>';
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 `
<article class="signal-item">
<div class="signal-item-head">
<b>${escapeHtml(receiverId)}</b>
<span>${escapeHtml(fmt(rssi, 1))} dBm</span>
</div>
<div class="monitor-progress monitor-progress-signal">
<span style="width:${level}%"></span>
</div>
<div class="signal-item-meta">
<span>R=${escapeHtml(fmt(radius, 2))} м</span>
<span>ε=${escapeHtml(fmt(residual, 2))} м</span>
</div>
</article>
`;
})
.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) => `
<article class="pipeline-stage">
<div class="pipeline-stage-head">
<span class="pipeline-stage-label">
<span class="${pipelineDotClass(stage.status)}"></span>
<b>${escapeHtml(stage.name)}</b>
</span>
<span class="io-chip ${statusClass(stage.status)}">${escapeHtml(localizeStatus(stage.status))}</span>
</div>
<div class="monitor-progress monitor-progress-accent">
<span style="width:${clamp(stage.name === "История" ? summary.success : stage.name === "Сбор входов" ? summary.inputOnlinePercent : stage.name === "Доставка" ? summary.outputOnlinePercent : summary.success, 0, 100)}%"></span>
</div>
<div class="pipeline-stage-note">${escapeHtml(stage.value)}${escapeHtml(stage.note)}</div>
</article>
`
)
.join("");
}
function renderOverviewTopFrequencies(frequencyRows) {
const root = byId("ov-top-frequencies");
if (!root) return;
if (!Array.isArray(frequencyRows) || frequencyRows.length === 0) {
root.innerHTML = '<div class="io-empty">Нет данных по частотам для рейтинга.</div>';
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 `
<article class="top-frequency-item">
<div class="top-frequency-head">
<span class="top-frequency-title">
<span class="top-frequency-rank">${index + 1}</span>
<b>${escapeHtml(fmt(mhz, 3))} МГц</b>
</span>
<span class="io-chip ${exact ? "io-status-ok" : "io-status-partial"}">${exact ? "точно" : "оценка"}</span>
</div>
<div class="monitor-progress">
<span style="width:${score}%"></span>
</div>
<div class="top-frequency-meta">
<span>RMSE ${escapeHtml(fmt(rmse, 2))} м</span>
<span>Индекс ${score}%</span>
</div>
</article>
`;
})
.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 = `
<div class="monitor-row"><span>Сервис</span><b>${escapeHtml(healthStatus)}</b></div>
<div class="monitor-row"><span>Доставка</span><b>${escapeHtml(deliveryStatus)}</b></div>
<div class="monitor-row"><span>Входы online</span><b>${summary.inputOnline}/${summary.inputTotal}</b></div>
<div class="monitor-row"><span>Выходы online</span><b>${summary.outputOnline}/${summary.outputTotal}</b></div>
<div class="monitor-row"><span>Проблемных событий</span><b>${problemCount}</b></div>
<div class="monitor-row"><span>Успех доставки</span><b>${summary.success}%</b></div>
<div class="metric-track"><span style="width:${summary.success}%"></span></div>
`;
}
}
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 = `<tr><td colspan="5">${text}</td></tr>`;
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) => `
<tr>
<td>${escapeHtml(fmtDateTimeCompact(row.timestamp))}</td>
<td>${escapeHtml(fmt(row.frequencyMhz, 3))}</td>
<td>
<div class="history-cell-list">
${(Array.isArray(row.inputItems) ? row.inputItems : String(row.inputSummary || "").split(" | "))
.filter((item) => String(item || "").trim().length > 0)
.map((item) => `<div class="history-cell-line">${escapeHtml(item)}</div>`)
.join("")}
</div>
</td>
<td>
<div class="history-cell-list">
${(Array.isArray(row.outputItems) ? row.outputItems : String(row.outputSummary || "").split("; "))
.filter((item) => String(item || "").trim().length > 0)
.map((item) => `<div class="history-cell-line">${escapeHtml(item)}</div>`)
.join("")}
</div>
</td>
<td>
<span class="io-chip ${statusClass(row.statusRaw)}">
${escapeHtml(localizeStatus(row.statusRaw))}
</span>
</td>
</tr>`
)
.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 `
<article class="io-card io-control-card io-control-card-compact">
<div class="io-control-top">
<div class="io-control-name-wrap">
<h4>${name}</h4>
<div class="io-control-sub">${flowLabel}</div>
</div>
<div class="io-control-badges">
<span class="io-chip ${reachabilityKind}">${reachabilityText}</span>
<span class="io-chip ${statusKind}">${escapeHtml(statusText)}</span>
</div>
</div>
${errorText ? `<div class="io-control-error">Ошибка: ${errorText}</div>` : ""}
${frequencyText ? `<div class="io-control-sub">частоты (МГц): ${escapeHtml(frequencyText)}</div>` : ""}
<div class="io-control-actions">
<button
class="btn btn-compact flow-toggle-btn"
type="button"
data-target="${target}"
data-id="${escapeHtml(id)}"
data-next-enabled="${nextEnabled ? "1" : "0"}"
${disabledAttr}
>${buttonText}</button>
</div>
</article>
`;
}
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 = '<div class="io-empty">Управление потоками недоступно.</div>';
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
? '<div class="io-empty">Входные источники не обнаружены.</div>'
: inputs.map((item) => buildControlCardHtml(item, "input")).join("");
const outputHtml =
outputs.length === 0
? '<div class="io-empty">Выходные серверы не обнаружены.</div>'
: outputs.map((item) => buildControlCardHtml(item, "output")).join("");
root.innerHTML = `
<div class="io-control-grid io-control-grid-compact">
<section class="io-block io-control-panel">
<div class="io-control-panel-head">
<h4>Входные Потоки</h4>
<span class="io-chip io-chip-neutral">${inputActiveCount}/${inputs.length} активны</span>
</div>
<div class="io-control-list">${inputHtml}</div>
</section>
<section class="io-block io-control-panel">
<div class="io-control-panel-head">
<h4>Выходные Потоки</h4>
<span class="io-chip io-chip-neutral">${outputActiveCount}/${outputs.length} активны</span>
</div>
<div class="io-control-list">${outputHtml}</div>
</section>
</div>
`;
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) => `
<tr class="row-enter" style="animation-delay:${index * 40}ms">
<td>${fmt(row.frequency_mhz ?? hzToMhz(row.frequency_hz), 3)}</td>
<td>${fmt(row.position?.x)}</td>
<td>${fmt(row.position?.y)}</td>
<td>${fmt(row.position?.z)}</td>
<td>${fmt(row.rmse_m)}</td>
<td>${row.exact ? "да" : "нет"}</td>
</tr>`
)
.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)}`);
});