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("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function statusClass(statusValue) {
const status = String(statusValue || "n/a");
const mapping = {
ok: "io-status-ok",
error: "io-status-error",
partial: "io-status-partial",
skipped: "io-status-skipped",
disabled: "io-status-disabled",
warming_up: "io-status-warm",
"n/a": "io-status-na",
n_a: "io-status-na",
};
return mapping[status] || "io-status-na";
}
function fmtDateTimeCompact(value) {
const parts = formatUpdatedTimestamp(value);
const date = parts.date.replace(/^дата:\s*/i, "");
const time = parts.time.replace(/^время:\s*/i, "");
return `${date} ${time}`.trim();
}
function findByFrequency(rows, frequencyHz) {
if (!Array.isArray(rows) || !Number.isFinite(Number(frequencyHz))) return null;
const target = Number(frequencyHz);
return (
rows.find((row) => Math.abs(Number(row?.frequency_hz) - target) <= 1) ||
rows.find((row) => Number(row?.frequency_hz) === target) ||
null
);
}
function renderInputFlow(data) {
const root = byId("input-flow");
if (!root) return;
const receivers = Array.isArray(data?.receivers) ? data.receivers : [];
if (receivers.length === 0) {
root.innerHTML = '
Ожидание входных измерений от ресиверов.
';
return;
}
root.innerHTML = receivers
.map((receiver) => {
const receiverId = escapeHtml(receiver?.receiver_id || "n/a");
const sourceUrl = escapeHtml(receiver?.source_url || "-");
const center = receiver?.center || {};
const samples = Array.isArray(receiver?.samples) ? receiver.samples : [];
const perFrequency = Array.isArray(receiver?.per_frequency) ? receiver.per_frequency : [];
const sampleRowsHtml =
samples.length === 0
? '| Нет сэмплов |
'
: samples
.map(
(sample) => `
| ${fmt(sample?.frequency_mhz ?? hzToMhz(sample?.frequency_hz), 3)} |
${fmt(sample?.amplitude_dbm, 1)} |
${fmt(sample?.distance_m, 2)} |
`
)
.join("");
const perFrequencyHtml =
perFrequency.length === 0
? 'нет расчётов по частотам'
: perFrequency
.map(
(row) => `
${fmt(row?.frequency_mhz ?? hzToMhz(row?.frequency_hz), 3)} МГц:
R=${fmt(row?.radius_m, 2)} м, ε=${fmt(row?.residual_m, 2)} м
`
)
.join("");
return `
${receiverId}
${fmt(receiver?.filtered_samples_count, 0)}/${fmt(receiver?.raw_samples_count, 0)} сэмплов
| Частота, МГц |
RSSI, дБм |
Дистанция, м |
${sampleRowsHtml}
${perFrequencyHtml}
`;
})
.join("");
}
function renderOutputFlow(data, delivery) {
const root = byId("output-flow");
if (!root) return;
const servers = Array.isArray(delivery?.servers) ? delivery.servers : [];
const selectedFrequencyMhz = data?.selected_frequency_mhz ?? hzToMhz(data?.selected_frequency_hz);
const pos = data?.position || {};
const payloadPreview = `
Формируемый выходной пакет
${escapeHtml(localizeStatus(delivery?.status))}
`;
if (servers.length === 0) {
root.innerHTML =
payloadPreview +
'Нет настроенных выходных серверов или отправка ещё не выполнялась.
';
return;
}
const serversHtml = servers
.map((server) => {
const status = localizeStatus(server?.status);
const statusCls = statusClass(server?.status);
const target = server?.target || {};
const endpoint = `${target?.ip || "-"}:${target?.port || "-"}${target?.path || ""}`;
const responseRaw = String(server?.response_body || "").trim() || "-";
const responseShort =
responseRaw.length > 160 ? `${responseRaw.slice(0, 157)}...` : responseRaw;
return `
${escapeHtml(server?.name || "output")}
${escapeHtml(status)}
`;
})
.join("");
root.innerHTML = payloadPreview + serversHtml;
}
function buildIoHistoryRow(data, delivery) {
if (!data) return null;
const selectedHz = Number(data?.selected_frequency_hz);
const selectedMhz = data?.selected_frequency_mhz ?? hzToMhz(selectedHz);
const receivers = Array.isArray(data?.receivers) ? data.receivers : [];
const servers = Array.isArray(delivery?.servers) ? delivery.servers : [];
const 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 = 'Событий пока нет.
';
} else {
feedRoot.innerHTML = feedRows
.map(
(row, index) => `
${escapeHtml(fmtDateTimeCompact(row.timestamp))}
${escapeHtml(localizeStatus(row.statusRaw))}
${escapeHtml(fmt(row.frequencyMhz, 3))} МГц
${escapeHtml((row.outputItems && row.outputItems[0]) || row.outputSummary || "-")}
`
)
.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 = `
Сервис${escapeHtml(healthStatus)}
Доставка${escapeHtml(deliveryStatus)}
Входы online${inputOnline}/${inputs.length}
Выходы online${outputOnline}/${outputs.length}
Проблемных событий${problemCount}
Успех доставки${success}%
`;
}
}
function maybeNotifyStatusChanges(delivery) {
const healthNow = String(state.health?.status || "n/a").toLowerCase();
const deliveryNow = String(delivery?.status || "n/a").toLowerCase();
if (!state.initialized) {
state.initialized = true;
state.lastHealthStatus = healthNow;
state.lastDeliveryStatus = deliveryNow;
return;
}
if (healthNow !== state.lastHealthStatus) {
if (healthNow === "error") {
showToast("Сервис перешел в состояние ошибки.", "error");
} else if (state.lastHealthStatus === "error" && healthNow === "ok") {
showToast("Сервис восстановлен.", "success");
}
state.lastHealthStatus = healthNow;
}
if (deliveryNow !== state.lastDeliveryStatus) {
if (deliveryNow === "error") {
showToast("Проблема с отправкой на выходной сервер.", "error");
} else if (state.lastDeliveryStatus === "error" && deliveryNow === "ok") {
showToast("Отправка на выход восстановлена.", "success");
} else if (deliveryNow === "partial") {
showToast("Частичная отправка: проверьте состояние выходных серверов.", "info");
}
state.lastDeliveryStatus = deliveryNow;
}
}
function renderHistorySummary() {
const total = state.ioHistory.length;
const okCount = state.ioHistory.filter((row) => String(row.statusRaw).toLowerCase() === "ok").length;
const problemCount = state.ioHistory.filter((row) => statusIsProblem(row.statusRaw)).length;
const uniqueFreqCount = new Set(
state.ioHistory
.map((row) => Number(row.frequencyMhz))
.filter((value) => Number.isFinite(value))
.map((value) => value.toFixed(3))
).size;
const lastTimestamp = state.ioHistory[0]?.timestamp || "";
setTextWithPulse("hist-total", total);
setTextWithPulse("hist-ok", okCount);
setTextWithPulse("hist-problem", problemCount);
setTextWithPulse("hist-freqs", uniqueFreqCount);
setTextWithPulse("hist-last", lastTimestamp ? fmtDateTimeCompact(lastTimestamp) : "н/д");
}
function renderHistoryPagination(totalRows, totalPages, fromIndex, toIndex) {
const pageInfo = byId("history-page-info");
const prevBtn = byId("history-prev");
const nextBtn = byId("history-next");
const pageSizeSelect = byId("history-page-size");
if (pageSizeSelect && String(pageSizeSelect.value) !== String(state.historyPageSize)) {
pageSizeSelect.value = String(state.historyPageSize);
}
if (prevBtn) {
prevBtn.disabled = state.historyPage <= 1 || totalRows === 0;
}
if (nextBtn) {
nextBtn.disabled = state.historyPage >= totalPages || totalRows === 0;
}
if (pageInfo) {
if (totalRows === 0) {
pageInfo.textContent = "Стр. 1/1 • 0 записей";
} else {
pageInfo.textContent = `Стр. ${state.historyPage}/${totalPages} • ${fromIndex}-${toIndex} из ${totalRows}`;
}
}
}
function renderIoHistory() {
const tbody = byId("io-history-table")?.querySelector("tbody");
if (!tbody) return;
updateHistoryRecordingUi();
renderHistorySummary();
renderHistoryInsights();
const filteredByStatus = state.historyFilter === "all"
? state.ioHistory
: state.ioHistory.filter(
(row) => String(row.statusRaw || "").toLowerCase() === state.historyFilter
);
const fromMsRaw = parseDateTimeInput(state.historyDateFrom);
const toMsRaw = parseDateTimeInput(state.historyDateTo);
const hasDateFilter = fromMsRaw !== null || toMsRaw !== null;
let fromMs = fromMsRaw;
let toMs = toMsRaw;
if (fromMs !== null && toMs !== null && fromMs > toMs) {
const temp = fromMs;
fromMs = toMs;
toMs = temp;
}
const filtered = filteredByStatus.filter((row) => {
if (!hasDateFilter) return true;
const rowMs = Date.parse(String(row.timestamp || ""));
if (!Number.isFinite(rowMs)) return false;
if (fromMs !== null && rowMs < fromMs) return false;
if (toMs !== null && rowMs > toMs) return false;
return true;
});
const pageSize = Math.max(1, Number(state.historyPageSize) || 10);
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
if (state.historyPage > totalPages) {
state.historyPage = totalPages;
}
if (state.historyPage < 1) {
state.historyPage = 1;
}
if (filtered.length === 0) {
let text = "История пока пуста.";
if (state.ioHistory.length > 0) {
if (hasDateFilter && state.historyFilter !== "all") {
text = "По статусу и диапазону времени записей нет.";
} else if (hasDateFilter) {
text = "По выбранному диапазону времени записей нет.";
} else {
text = "По выбранному статусу записей нет.";
}
}
tbody.innerHTML = `| ${text} |
`;
renderHistoryPagination(0, 1, 0, 0);
return;
}
const start = (state.historyPage - 1) * pageSize;
const pageRows = filtered.slice(start, start + pageSize);
const fromIndex = start + 1;
const toIndex = start + pageRows.length;
tbody.innerHTML = pageRows
.map(
(row) => `
| ${escapeHtml(fmtDateTimeCompact(row.timestamp))} |
${escapeHtml(fmt(row.frequencyMhz, 3))} |
${(Array.isArray(row.inputItems) ? row.inputItems : String(row.inputSummary || "").split(" | "))
.filter((item) => String(item || "").trim().length > 0)
.map((item) => ` ${escapeHtml(item)} `)
.join("")}
|
${(Array.isArray(row.outputItems) ? row.outputItems : String(row.outputSummary || "").split("; "))
.filter((item) => String(item || "").trim().length > 0)
.map((item) => ` ${escapeHtml(item)} `)
.join("")}
|
${escapeHtml(localizeStatus(row.statusRaw))}
|
`
)
.join("");
renderHistoryPagination(filtered.length, totalPages, fromIndex, toIndex);
}
function boolStateLabel(value, trueLabel, falseLabel) {
if (value === true) return trueLabel;
if (value === false) return falseLabel;
return "состояние неизвестно";
}
function buildControlCardHtml(item, target) {
const id = String(item?.id || "");
const name = escapeHtml(item?.name || id || "n/a");
const reachable = Boolean(item?.reachable);
const errorText = item?.error ? escapeHtml(item.error) : "";
const stateValue = target === "input" ? item?.enabled : item?.accept_writes;
const isActive = stateValue === true;
const nextEnabled = !isActive;
const flowLabel = target === "input" ? "входной поток" : "выходной поток";
const statusText =
target === "input"
? boolStateLabel(stateValue, "передача активна", "передача остановлена")
: boolStateLabel(stateValue, "приём активен", "приём остановлен");
const buttonText =
target === "input"
? isActive
? "Пауза входа"
: "Запустить вход"
: isActive
? "Пауза выхода"
: "Запустить выход";
const statusKind =
!reachable && stateValue === null ? "io-status-error" : isActive ? "io-status-ok" : "io-status-skipped";
const reachabilityKind = reachable ? "io-status-ok" : "io-status-error";
const reachabilityText = reachable ? "online" : "offline";
const disabledAttr = !id ? "disabled" : "";
return `
${reachabilityText}
${escapeHtml(statusText)}
${errorText ? `Ошибка: ${errorText}
` : ""}
`;
}
function bindControlButtons() {
document.querySelectorAll(".flow-toggle-btn").forEach((button) => {
button.addEventListener("click", async () => {
const target = button.getAttribute("data-target") || "";
const id = button.getAttribute("data-id") || "";
const nextEnabled = button.getAttribute("data-next-enabled") === "1";
if (!target || !id) return;
button.disabled = true;
try {
const response = await postJson("/mock/control", {
target,
id,
enabled: nextEnabled,
});
showToast(response.message || "Состояние потока обновлено.", "success");
} catch (err) {
showToast(localizeErrorMessage(err.message), "error");
} finally {
await loadAll();
}
});
});
}
function renderErrorControls() {
const root = byId("error-controls");
if (!root) return;
const controls = state.mockControls;
if (!controls) {
root.innerHTML = 'Управление потоками недоступно.
';
return;
}
const inputs = Array.isArray(controls?.inputs) ? controls.inputs : [];
const outputs = Array.isArray(controls?.outputs) ? controls.outputs : [];
const inputActiveCount = inputs.filter((item) => item?.enabled === true).length;
const outputActiveCount = outputs.filter((item) => item?.accept_writes === true).length;
const inputHtml =
inputs.length === 0
? 'Входные источники не обнаружены.
'
: inputs.map((item) => buildControlCardHtml(item, "input")).join("");
const outputHtml =
outputs.length === 0
? 'Выходные серверы не обнаружены.
'
: outputs.map((item) => buildControlCardHtml(item, "output")).join("");
root.innerHTML = `
Входные Потоки
${inputActiveCount}/${inputs.length} активны
${inputHtml}
Выходные Потоки
${outputActiveCount}/${outputs.length} активны
${outputHtml}
`;
bindControlButtons();
}
function normalizeInputFilter(filter) {
const source = filter || {};
return {
enabled: Boolean(source.enabled),
min_frequency_mhz: Number(source.min_frequency_mhz ?? hzToMhz(source.min_frequency_hz) ?? 0),
max_frequency_mhz: Number(source.max_frequency_mhz ?? hzToMhz(source.max_frequency_hz) ?? 0),
min_rssi_dbm: Number(source.min_rssi_dbm ?? -200),
max_rssi_dbm: Number(source.max_rssi_dbm ?? 50),
};
}
function createReceiverDraft(index) {
return {
receiver_id: `r${index}`,
source_url: "",
frequencies_mhz: [],
center: { x: 0, y: 0, z: 0 },
};
}
function normalizeReceiverDraft(receiver, index) {
const center = receiver?.center || {};
const access = receiver?.access || {};
const frequencies = Array.isArray(receiver?.frequencies_mhz)
? receiver.frequencies_mhz
: [];
return {
receiver_id: receiver?.receiver_id || `r${index}`,
source_url: String(receiver?.source_url || access.url || access.source_url || ""),
frequencies_mhz: frequencies
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0),
center: {
x: Number(center.x ?? 0),
y: Number(center.y ?? 0),
z: Number(center.z ?? 0),
},
};
}
function updateReceiverCountBadge() {
byId("receiver-count").textContent = `входов: ${state.receiverDrafts.length}`;
}
function saveCurrentReceiverDraftFromInputs() {
const idx = state.selectedReceiverIndex;
if (!state.receiverDrafts[idx]) return;
state.receiverDrafts[idx] = {
...state.receiverDrafts[idx],
receiver_id: byId("rx-id").value.trim() || `r${idx}`,
source_url: byId("rx-url").value.trim(),
frequencies_mhz: parseMhzList(byId("rx-frequencies").value),
center: {
x: Number(byId("rx-center-x").value),
y: Number(byId("rx-center-y").value),
z: Number(byId("rx-center-z").value),
},
};
}
function renderSelectedReceiverDraft() {
const draft = state.receiverDrafts[state.selectedReceiverIndex];
if (!draft) return;
byId("rx-id").value = draft.receiver_id;
byId("rx-url").value = draft.source_url;
byId("rx-frequencies").value = formatMhzList(draft.frequencies_mhz);
byId("rx-center-x").value = draft.center.x;
byId("rx-center-y").value = draft.center.y;
byId("rx-center-z").value = draft.center.z;
}
function fillReceiverSelect() {
const select = byId("receiver-select");
select.innerHTML = "";
state.receiverDrafts.forEach((draft, index) => {
const option = document.createElement("option");
option.value = String(index);
option.textContent = draft.receiver_id || `вход_${index + 1}`;
select.appendChild(option);
});
if (state.selectedReceiverIndex >= state.receiverDrafts.length) {
state.selectedReceiverIndex = Math.max(0, state.receiverDrafts.length - 1);
}
select.value = String(state.selectedReceiverIndex);
updateReceiverCountBadge();
}
function addReceiverDraft() {
saveCurrentReceiverDraftFromInputs();
const nextIndex = state.receiverDrafts.length;
state.receiverDrafts.push(createReceiverDraft(nextIndex));
state.selectedReceiverIndex = nextIndex;
fillReceiverSelect();
renderSelectedReceiverDraft();
}
function removeReceiverDraft() {
if (state.receiverDrafts.length <= 3) {
byId("servers-state").textContent = "серверы: необходимо минимум 3 входа";
return;
}
state.receiverDrafts.splice(state.selectedReceiverIndex, 1);
state.selectedReceiverIndex = Math.max(0, state.selectedReceiverIndex - 1);
fillReceiverSelect();
renderSelectedReceiverDraft();
}
function createOutputDraft(index) {
return {
name: `выход_${index + 1}`,
ip: "",
};
}
function normalizeOutputDraft(output, index) {
const source = output || {};
return {
name: String(source.name || `выход_${index + 1}`),
ip: String(source.ip || ""),
};
}
function updateOutputCountBadge() {
byId("output-count").textContent = `выходов: ${state.outputDrafts.length}`;
}
function saveCurrentOutputDraftFromInputs() {
const idx = state.selectedOutputIndex;
if (!state.outputDrafts[idx]) return;
state.outputDrafts[idx] = {
...state.outputDrafts[idx],
name: byId("out-name").value.trim() || `выход_${idx + 1}`,
ip: byId("out-ip").value.trim(),
};
}
function renderSelectedOutputDraft() {
const draft = state.outputDrafts[state.selectedOutputIndex];
if (!draft) return;
byId("out-name").value = draft.name;
byId("out-ip").value = draft.ip;
}
function fillOutputSelect() {
const select = byId("output-select");
select.innerHTML = "";
state.outputDrafts.forEach((draft, index) => {
const option = document.createElement("option");
option.value = String(index);
option.textContent = draft.name || `выход_${index + 1}`;
select.appendChild(option);
});
if (state.selectedOutputIndex >= state.outputDrafts.length) {
state.selectedOutputIndex = Math.max(0, state.outputDrafts.length - 1);
}
select.value = String(state.selectedOutputIndex);
updateOutputCountBadge();
}
function addOutputDraft() {
saveCurrentOutputDraftFromInputs();
const nextIndex = state.outputDrafts.length;
state.outputDrafts.push(createOutputDraft(nextIndex));
state.selectedOutputIndex = nextIndex;
fillOutputSelect();
renderSelectedOutputDraft();
}
function removeOutputDraft() {
if (state.outputDrafts.length <= 1) {
byId("servers-state").textContent = "серверы: необходим минимум 1 выход";
return;
}
state.outputDrafts.splice(state.selectedOutputIndex, 1);
state.selectedOutputIndex = Math.max(0, state.selectedOutputIndex - 1);
fillOutputSelect();
renderSelectedOutputDraft();
}
async function getJson(url) {
const res = await fetch(url);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || data.status || `HTTP ${res.status}`);
}
return data;
}
async function postJson(url, payload) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || data.status || `HTTP ${res.status}`);
}
return data;
}
function render() {
const data = state.result?.data;
const delivery = state.result?.output_delivery || state.frequencies?.output_delivery;
const updated = formatUpdatedTimestamp(state.result?.updated_at_utc);
setTextWithPulse("updated-date", updated.date);
setTextWithPulse("updated-time", updated.time);
setTextWithPulse("health-status", `состояние: ${localizeStatus(state.health?.status)}`);
setTextWithPulse("delivery-status", `доставка: ${localizeStatus(delivery?.status)}`);
renderOverviewMetrics();
maybeNotifyStatusChanges(delivery || {});
if (!data) {
setTextWithPulse("selected-freq", "-");
setTextWithPulse("pos-x", "-");
setTextWithPulse("pos-y", "-");
setTextWithPulse("pos-z", "-");
setTextWithPulse("rmse", "-");
renderInputFlow(null);
renderOutputFlow(null, delivery || {});
renderIoHistory();
renderErrorControls();
byId("freq-table").querySelector("tbody").innerHTML = "";
return;
}
const selectedMhz = data.selected_frequency_mhz ?? hzToMhz(data.selected_frequency_hz);
setTextWithPulse("selected-freq", selectedMhz === null ? "-" : `${fmt(selectedMhz, 3)} МГц`);
setTextWithPulse("pos-x", fmt(data.position?.x));
setTextWithPulse("pos-y", fmt(data.position?.y));
setTextWithPulse("pos-z", fmt(data.position?.z));
setTextWithPulse("rmse", fmt(data.rmse_m));
renderInputFlow(data);
renderOutputFlow(data, delivery || {});
appendIoHistory(data, delivery || {});
renderIoHistory();
renderErrorControls();
const rows = data.frequency_table || [];
const tbody = byId("freq-table").querySelector("tbody");
tbody.innerHTML = rows
.map(
(row, index) => `
| ${fmt(row.frequency_mhz ?? hzToMhz(row.frequency_hz), 3)} |
${fmt(row.position?.x)} |
${fmt(row.position?.y)} |
${fmt(row.position?.z)} |
${fmt(row.rmse_m)} |
${row.exact ? "да" : "нет"} |
`
)
.join("");
}
async function loadAll() {
const [healthRes, resultRes, freqRes, controlsRes] = await Promise.allSettled([
getJson("/health"),
getJson("/result"),
getJson("/frequencies"),
getJson("/mock/controls"),
]);
state.health = healthRes.status === "fulfilled" ? healthRes.value : { status: "error" };
state.result = resultRes.status === "fulfilled" ? resultRes.value : null;
state.frequencies = freqRes.status === "fulfilled" ? freqRes.value : null;
state.mockControls = controlsRes.status === "fulfilled" ? controlsRes.value : null;
render();
}
async function refreshNow() {
await postJson("/refresh", {});
await loadAll();
}
async function loadConfig() {
try {
const config = await getJson("/config");
state.config = config.config || null;
byId("config-editor").value = JSON.stringify(config.config, null, 2);
fillServerForm();
byId("config-state").textContent = "конфиг: загружен";
byId("servers-state").textContent = "серверы: загружены";
} catch (err) {
byId("config-state").textContent = `конфиг: ${localizeErrorMessage(err.message)}`;
byId("servers-state").textContent = `серверы: ${localizeErrorMessage(err.message)}`;
}
}
async function saveConfig() {
const raw = byId("config-editor").value.trim();
try {
const parsed = JSON.parse(raw);
const result = await postJson("/config", parsed);
state.config = parsed;
const saveSuffix = result.save_error ? " (применено, но сохранить файл не удалось)" : "";
byId("config-state").textContent = result.restart_required
? `конфиг: сохранён, требуется перезапуск${saveSuffix}`
: `конфиг: сохранён${saveSuffix}`;
} catch (err) {
byId("config-state").textContent = `конфиг: ${localizeErrorMessage(err.message)}`;
}
}
function fillServerForm() {
const cfg = state.config;
if (!cfg) return;
const receivers = cfg.input?.receivers || [];
state.receiverDrafts = receivers.map((receiver, index) => normalizeReceiverDraft(receiver, index));
if (state.receiverDrafts.length < 3) {
while (state.receiverDrafts.length < 3) {
state.receiverDrafts.push(createReceiverDraft(state.receiverDrafts.length));
}
}
fillReceiverSelect();
renderSelectedReceiverDraft();
const sharedFilterSource =
cfg.input?.default_input_filter || cfg.input?.receivers?.[0]?.input_filter || {};
state.sharedFilterDraft = normalizeInputFilter(sharedFilterSource);
byId("shared-filter-enabled").value = String(Boolean(state.sharedFilterDraft.enabled));
byId("shared-min-freq").value = state.sharedFilterDraft.min_frequency_mhz;
byId("shared-max-freq").value = state.sharedFilterDraft.max_frequency_mhz;
byId("shared-min-rssi").value = state.sharedFilterDraft.min_rssi_dbm;
byId("shared-max-rssi").value = state.sharedFilterDraft.max_rssi_dbm;
const runtime = cfg.runtime || {};
const outputServers = Array.isArray(runtime.output_servers)
? runtime.output_servers
: [runtime.output_server || {}];
state.outputDrafts = outputServers.map((output, index) => normalizeOutputDraft(output, index));
if (state.outputDrafts.length < 1) {
state.outputDrafts.push(createOutputDraft(0));
}
fillOutputSelect();
renderSelectedOutputDraft();
byId("write-token").value = "";
}
async function saveServers() {
try {
if (!state.config) {
await loadConfig();
}
saveCurrentReceiverDraftFromInputs();
saveCurrentOutputDraftFromInputs();
if (state.receiverDrafts.length < 3) {
throw new Error("at least 3 input servers are required");
}
if (state.outputDrafts.length < 1) {
throw new Error("at least 1 output server is required");
}
const sharedFilter = {
enabled: byId("shared-filter-enabled").value === "true",
min_frequency_mhz: Number(byId("shared-min-freq").value),
max_frequency_mhz: Number(byId("shared-max-freq").value),
min_rssi_dbm: Number(byId("shared-min-rssi").value),
max_rssi_dbm: Number(byId("shared-max-rssi").value),
};
const cfg = structuredClone(state.config);
cfg.input = cfg.input || {};
cfg.input.default_input_filter = { ...sharedFilter };
cfg.input.receivers = state.receiverDrafts.map((draft, index) => ({
receiver_id: draft.receiver_id || `r${index}`,
center: {
x: Number(draft.center.x),
y: Number(draft.center.y),
z: Number(draft.center.z),
},
access: {
url: draft.source_url,
},
frequencies_mhz: Array.isArray(draft.frequencies_mhz)
? draft.frequencies_mhz
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
: [],
}));
cfg.runtime = cfg.runtime || {};
cfg.runtime.output_servers = state.outputDrafts.map((draft, index) => ({
name: draft.name || `выход_${index + 1}`,
ip: draft.ip,
}));
cfg.runtime.output_server = { ...cfg.runtime.output_servers[0] };
const result = await postJson("/config", cfg);
state.config = cfg;
byId("config-editor").value = JSON.stringify(cfg, null, 2);
const saveSuffix = result.save_error ? " (применено, но сохранить файл не удалось)" : "";
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)}`);
});