|
|
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 = '<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)}`);
|
|
|
});
|
|
|
|
|
|
|