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.

1537 lines
53 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,
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",
};
const HZ_IN_MHZ = 1_000_000;
const MENU_COLLAPSED_STORAGE_KEY = "triangulation.menu_collapsed";
const TIMEZONE_STORAGE_KEY = "triangulation.timezone";
const IO_HISTORY_LIMIT = 60;
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);
});
}
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 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 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 = `${Math.round(state.pollIntervalMs / 1000)}с`;
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("<", "&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 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 = sample?.amplitude_dbm;
const radius = perFrequency?.radius_m ?? sample?.distance_m;
return `${receiverId}: ${fmt(rssi, 1)} dBm / ${fmt(radius, 2)} m`;
});
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}`;
return {
key,
timestamp,
frequencyMhz: selectedMhz,
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 renderOverviewMetrics() {
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);
setTextWithPulse("ov-input-online", `${inputOnline}/${inputs.length}`);
setTextWithPulse("ov-output-online", `${outputOnline}/${outputs.length}`);
setTextWithPulse("ov-history-total", total);
setTextWithPulse("ov-success-rate", `${success}%`);
}
function renderHistoryInsights() {
const feedRoot = byId("history-feed");
const monitorRoot = byId("history-monitor");
if (feedRoot) {
const feedRows = state.ioHistory.slice(0, 8);
if (feedRows.length === 0) {
feedRoot.innerHTML = '<div class="io-empty">Событий пока нет.</div>';
} else {
feedRoot.innerHTML = feedRows
.map(
(row, index) => `
<article class="feed-item row-enter" style="animation-delay:${index * 35}ms">
<div class="feed-head">
<span class="feed-time">${escapeHtml(fmtDateTimeCompact(row.timestamp))}</span>
<span class="io-chip ${statusClass(row.statusRaw)}">${escapeHtml(localizeStatus(row.statusRaw))}</span>
</div>
<div class="feed-body">
<div><b>${escapeHtml(fmt(row.frequencyMhz, 3))} МГц</b></div>
<div>${escapeHtml((row.outputItems && row.outputItems[0]) || row.outputSummary || "-")}</div>
</div>
</article>`
)
.join("");
}
}
if (monitorRoot) {
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 problemCount = state.ioHistory.filter((row) => statusIsProblem(row.statusRaw)).length;
const success = toPercent(okCount, total);
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>${inputOnline}/${inputs.length}</b></div>
<div class="monitor-row"><span>Выходы online</span><b>${outputOnline}/${outputs.length}</b></div>
<div class="monitor-row"><span>Проблемных событий</span><b>${problemCount}</b></div>
<div class="monitor-row"><span>Успех доставки</span><b>${success}%</b></div>
<div class="metric-track"><span style="width:${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" : "";
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>` : ""}
<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 ? " (применено, но сохранить файл не удалось)" : "";
byId("servers-state").textContent = result.restart_required
? `серверы: сохранены, требуется перезапуск${saveSuffix}`
: `серверы: сохранены${saveSuffix}`;
} catch (err) {
byId("servers-state").textContent = `серверы: ${localizeErrorMessage(err.message)}`;
}
}
function bindUi() {
byId("refresh-now").addEventListener("click", refreshNow);
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"
);
});
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);
});
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();
updateHistoryRecordingUi();
setMenuCollapsed(readMenuCollapsed());
updateRefreshUi();
setActiveSection(state.activeSection);
await loadConfig();
await loadAll();
startPolling();
}
boot().catch((err) => {
setTextWithPulse("health-status", `состояние: ${localizeErrorMessage(err.message)}`);
});