|
|
|
|
@ -17,6 +17,12 @@
|
|
|
|
|
selectedOutputIndex: 0,
|
|
|
|
|
outputDrafts: [],
|
|
|
|
|
menuCollapsed: false,
|
|
|
|
|
menuGroupCollapsed: {
|
|
|
|
|
monitoring: false,
|
|
|
|
|
io: false,
|
|
|
|
|
config: false,
|
|
|
|
|
},
|
|
|
|
|
dateTimeCollapsed: false,
|
|
|
|
|
ioHistory: [],
|
|
|
|
|
mockControls: null,
|
|
|
|
|
historyFilter: "all",
|
|
|
|
|
@ -32,12 +38,21 @@
|
|
|
|
|
lastHealthStatus: "n/a",
|
|
|
|
|
lastDeliveryStatus: "n/a",
|
|
|
|
|
timezone: "local",
|
|
|
|
|
uiDensity: "detailed",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const HZ_IN_MHZ = 1_000_000;
|
|
|
|
|
const MENU_COLLAPSED_STORAGE_KEY = "triangulation.menu_collapsed";
|
|
|
|
|
const MENU_GROUP_COLLAPSED_STORAGE_KEY = "triangulation.menu_group_collapsed";
|
|
|
|
|
const DATE_TIME_COLLAPSED_STORAGE_KEY = "triangulation.date_time_collapsed";
|
|
|
|
|
const TIMEZONE_STORAGE_KEY = "triangulation.timezone";
|
|
|
|
|
const UI_DENSITY_STORAGE_KEY = "triangulation.ui_density";
|
|
|
|
|
const AUTO_REFRESH_INTERVAL_STORAGE_KEY = "triangulation.auto_refresh_interval_ms";
|
|
|
|
|
const AUTO_REFRESH_MIN_MS = 1_000;
|
|
|
|
|
const AUTO_REFRESH_MAX_MS = 120_000;
|
|
|
|
|
const AUTO_REFRESH_DEFAULT_MS = 2_000;
|
|
|
|
|
const IO_HISTORY_LIMIT = 60;
|
|
|
|
|
const MENU_GROUP_KEYS = ["monitoring", "io", "config"];
|
|
|
|
|
const TIMEZONE_OPTIONS = [
|
|
|
|
|
{ value: "local", label: "Локальный (браузер)" },
|
|
|
|
|
{ value: "UTC", label: "UTC" },
|
|
|
|
|
@ -191,6 +206,10 @@ function setActiveSection(section) {
|
|
|
|
|
document.querySelectorAll(".menu-item").forEach((item) => {
|
|
|
|
|
item.classList.toggle("menu-item-active", item.dataset.section === section);
|
|
|
|
|
});
|
|
|
|
|
const group = findSectionMenuGroup(section);
|
|
|
|
|
if (group) {
|
|
|
|
|
setMenuGroupCollapsed(group, false, { persist: true });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setMenuCollapsed(isCollapsed) {
|
|
|
|
|
@ -221,6 +240,106 @@ function readMenuCollapsed() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeMenuGroupCollapsed(value) {
|
|
|
|
|
const result = {
|
|
|
|
|
monitoring: false,
|
|
|
|
|
io: false,
|
|
|
|
|
config: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!value || typeof value !== "object") return result;
|
|
|
|
|
MENU_GROUP_KEYS.forEach((groupKey) => {
|
|
|
|
|
result[groupKey] = Boolean(value[groupKey]);
|
|
|
|
|
});
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findSectionMenuGroup(section) {
|
|
|
|
|
const item = document.querySelector(`.menu-item[data-section="${String(section || "")}"]`);
|
|
|
|
|
const group = item?.closest(".menu-group")?.dataset?.menuGroup;
|
|
|
|
|
return MENU_GROUP_KEYS.includes(group) ? group : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyMenuGroupState(groupKey) {
|
|
|
|
|
if (!MENU_GROUP_KEYS.includes(groupKey)) return;
|
|
|
|
|
const root = document.querySelector(`.menu-group[data-menu-group="${groupKey}"]`);
|
|
|
|
|
if (!root) return;
|
|
|
|
|
const toggle = root.querySelector(".menu-group-toggle");
|
|
|
|
|
const collapsed = Boolean(state.menuGroupCollapsed[groupKey]);
|
|
|
|
|
root.classList.toggle("menu-group-collapsed", collapsed);
|
|
|
|
|
if (toggle) {
|
|
|
|
|
toggle.setAttribute("aria-expanded", String(!collapsed));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function persistMenuGroupCollapsedState() {
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem(
|
|
|
|
|
MENU_GROUP_COLLAPSED_STORAGE_KEY,
|
|
|
|
|
JSON.stringify(normalizeMenuGroupCollapsed(state.menuGroupCollapsed))
|
|
|
|
|
);
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore localStorage errors in restricted environments.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setMenuGroupCollapsed(groupKey, isCollapsed, options = {}) {
|
|
|
|
|
const { persist = true } = options;
|
|
|
|
|
if (!MENU_GROUP_KEYS.includes(groupKey)) return;
|
|
|
|
|
state.menuGroupCollapsed[groupKey] = Boolean(isCollapsed);
|
|
|
|
|
applyMenuGroupState(groupKey);
|
|
|
|
|
if (persist) {
|
|
|
|
|
persistMenuGroupCollapsedState();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyAllMenuGroupsCollapsed() {
|
|
|
|
|
MENU_GROUP_KEYS.forEach((groupKey) => applyMenuGroupState(groupKey));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readMenuGroupCollapsed() {
|
|
|
|
|
try {
|
|
|
|
|
const raw = localStorage.getItem(MENU_GROUP_COLLAPSED_STORAGE_KEY);
|
|
|
|
|
if (!raw) {
|
|
|
|
|
return normalizeMenuGroupCollapsed(null);
|
|
|
|
|
}
|
|
|
|
|
const parsed = JSON.parse(raw);
|
|
|
|
|
return normalizeMenuGroupCollapsed(parsed);
|
|
|
|
|
} catch {
|
|
|
|
|
return normalizeMenuGroupCollapsed(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setDateTimeCollapsed(isCollapsed) {
|
|
|
|
|
state.dateTimeCollapsed = Boolean(isCollapsed);
|
|
|
|
|
const sideNav = byId("side-nav");
|
|
|
|
|
const toggle = byId("datetime-toggle");
|
|
|
|
|
|
|
|
|
|
if (sideNav) {
|
|
|
|
|
sideNav.classList.toggle("date-time-collapsed", state.dateTimeCollapsed);
|
|
|
|
|
}
|
|
|
|
|
if (toggle) {
|
|
|
|
|
toggle.textContent = state.dateTimeCollapsed
|
|
|
|
|
? "Показать служебную панель"
|
|
|
|
|
: "Скрыть служебную панель";
|
|
|
|
|
toggle.setAttribute("aria-expanded", String(!state.dateTimeCollapsed));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem(DATE_TIME_COLLAPSED_STORAGE_KEY, state.dateTimeCollapsed ? "1" : "0");
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore localStorage errors in restricted environments.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readDateTimeCollapsed() {
|
|
|
|
|
try {
|
|
|
|
|
return localStorage.getItem(DATE_TIME_COLLAPSED_STORAGE_KEY) === "1";
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function browserTimeZone() {
|
|
|
|
|
try {
|
|
|
|
|
return Intl.DateTimeFormat().resolvedOptions().timeZone || "";
|
|
|
|
|
@ -250,6 +369,113 @@ function saveTimeZonePreference(value) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeUiDensity(value) {
|
|
|
|
|
return String(value || "").toLowerCase() === "compact" ? "compact" : "detailed";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function saveUiDensityPreference(value) {
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem(UI_DENSITY_STORAGE_KEY, normalizeUiDensity(value));
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore localStorage errors in restricted environments.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readUiDensityPreference() {
|
|
|
|
|
try {
|
|
|
|
|
return normalizeUiDensity(localStorage.getItem(UI_DENSITY_STORAGE_KEY) || "detailed");
|
|
|
|
|
} catch {
|
|
|
|
|
return "detailed";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyUiDensity() {
|
|
|
|
|
const compact = state.uiDensity === "compact";
|
|
|
|
|
document.body.classList.toggle("ui-compact", compact);
|
|
|
|
|
const toggle = byId("density-toggle");
|
|
|
|
|
if (toggle) {
|
|
|
|
|
toggle.textContent = compact ? "Режим: компактный" : "Режим: детальный";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setUiDensity(value, options = {}) {
|
|
|
|
|
const { persist = true } = options;
|
|
|
|
|
state.uiDensity = normalizeUiDensity(value);
|
|
|
|
|
applyUiDensity();
|
|
|
|
|
if (persist) {
|
|
|
|
|
saveUiDensityPreference(state.uiDensity);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizePollIntervalMs(valueMs) {
|
|
|
|
|
const numeric = Number(valueMs);
|
|
|
|
|
if (!Number.isFinite(numeric)) return AUTO_REFRESH_DEFAULT_MS;
|
|
|
|
|
const rounded = Math.round(numeric);
|
|
|
|
|
return Math.min(AUTO_REFRESH_MAX_MS, Math.max(AUTO_REFRESH_MIN_MS, rounded));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatPollIntervalSeconds(valueMs) {
|
|
|
|
|
const seconds = Number(valueMs) / 1000;
|
|
|
|
|
if (!Number.isFinite(seconds)) return "2";
|
|
|
|
|
return seconds.toFixed(2).replace(/\.?0+$/, "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readPollIntervalPreference() {
|
|
|
|
|
try {
|
|
|
|
|
const raw = localStorage.getItem(AUTO_REFRESH_INTERVAL_STORAGE_KEY);
|
|
|
|
|
if (!raw) return AUTO_REFRESH_DEFAULT_MS;
|
|
|
|
|
return normalizePollIntervalMs(Number(raw));
|
|
|
|
|
} catch {
|
|
|
|
|
return AUTO_REFRESH_DEFAULT_MS;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function savePollIntervalPreference(valueMs) {
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem(AUTO_REFRESH_INTERVAL_STORAGE_KEY, String(normalizePollIntervalMs(valueMs)));
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore localStorage errors in restricted environments.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function syncPollIntervalInput() {
|
|
|
|
|
const input = byId("auto-refresh-seconds");
|
|
|
|
|
if (!input) return;
|
|
|
|
|
input.value = formatPollIntervalSeconds(state.pollIntervalMs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setPollIntervalMs(valueMs, options = {}) {
|
|
|
|
|
const { persist = true, restartPolling = true } = options;
|
|
|
|
|
const next = normalizePollIntervalMs(valueMs);
|
|
|
|
|
state.pollIntervalMs = next;
|
|
|
|
|
if (persist) {
|
|
|
|
|
savePollIntervalPreference(next);
|
|
|
|
|
}
|
|
|
|
|
syncPollIntervalInput();
|
|
|
|
|
updateRefreshUi();
|
|
|
|
|
if (restartPolling && state.autoRefreshEnabled) {
|
|
|
|
|
startPolling();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setPollIntervalSeconds(rawValue, options = {}) {
|
|
|
|
|
const { notify = true } = options;
|
|
|
|
|
const normalized = String(rawValue ?? "").replace(",", ".").trim();
|
|
|
|
|
const seconds = Number(normalized);
|
|
|
|
|
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
|
|
|
syncPollIntervalInput();
|
|
|
|
|
if (notify) {
|
|
|
|
|
showToast("Введите корректный интервал автообновления (1-120 секунд).", "error");
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const nextMs = normalizePollIntervalMs(seconds * 1000);
|
|
|
|
|
setPollIntervalMs(nextMs);
|
|
|
|
|
if (notify) {
|
|
|
|
|
showToast(`Интервал автообновления: ${formatPollIntervalSeconds(nextMs)}с.`, "success");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function selectedTimeZoneValue() {
|
|
|
|
|
return state.timezone === "local" ? null : state.timezone;
|
|
|
|
|
}
|
|
|
|
|
@ -328,7 +554,7 @@ function updateRefreshUi() {
|
|
|
|
|
if (button) {
|
|
|
|
|
button.textContent = state.autoRefreshEnabled ? "Пауза автообновления" : "Запустить автообновление";
|
|
|
|
|
}
|
|
|
|
|
const suffix = `${Math.round(state.pollIntervalMs / 1000)}с`;
|
|
|
|
|
const suffix = `${formatPollIntervalSeconds(state.pollIntervalMs)}с`;
|
|
|
|
|
setTextWithPulse(
|
|
|
|
|
"refresh-state",
|
|
|
|
|
`автообновление: ${state.autoRefreshEnabled ? "вкл" : "выкл"} (${suffix})`
|
|
|
|
|
@ -537,17 +763,27 @@ function buildIoHistoryRow(data, delivery) {
|
|
|
|
|
const selectedMhz = data?.selected_frequency_mhz ?? hzToMhz(selectedHz);
|
|
|
|
|
const receivers = Array.isArray(data?.receivers) ? data.receivers : [];
|
|
|
|
|
const servers = Array.isArray(delivery?.servers) ? delivery.servers : [];
|
|
|
|
|
const rmseMRaw = Number(data?.rmse_m);
|
|
|
|
|
const rmseM = Number.isFinite(rmseMRaw) ? rmseMRaw : null;
|
|
|
|
|
const rssiValues = [];
|
|
|
|
|
|
|
|
|
|
const inputItems = receivers.map((receiver) => {
|
|
|
|
|
const receiverId = String(receiver?.receiver_id || "n/a");
|
|
|
|
|
const sample = findByFrequency(receiver?.samples, selectedHz) || receiver?.samples?.[0] || null;
|
|
|
|
|
const perFrequency =
|
|
|
|
|
findByFrequency(receiver?.per_frequency, selectedHz) || receiver?.per_frequency?.[0] || null;
|
|
|
|
|
const rssi = sample?.amplitude_dbm;
|
|
|
|
|
const rssi = Number(sample?.amplitude_dbm);
|
|
|
|
|
if (Number.isFinite(rssi)) {
|
|
|
|
|
rssiValues.push(rssi);
|
|
|
|
|
}
|
|
|
|
|
const radius = perFrequency?.radius_m ?? sample?.distance_m;
|
|
|
|
|
return `${receiverId}: ${fmt(rssi, 1)} dBm / ${fmt(radius, 2)} m`;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const avgRssiDbm = rssiValues.length > 0
|
|
|
|
|
? rssiValues.reduce((acc, value) => acc + value, 0) / rssiValues.length
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
const outputItems = (() => {
|
|
|
|
|
if (servers.length === 0) {
|
|
|
|
|
const pos = data?.position || {};
|
|
|
|
|
@ -565,11 +801,13 @@ function buildIoHistoryRow(data, delivery) {
|
|
|
|
|
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}`;
|
|
|
|
|
const key = `${timestamp}|${fmt(selectedMhz, 3)}|${inputItems.join("||")}|${outputItems.join("||")}|${statusRaw}|${fmt(rmseM, 2)}|${fmt(avgRssiDbm, 1)}`;
|
|
|
|
|
return {
|
|
|
|
|
key,
|
|
|
|
|
timestamp,
|
|
|
|
|
frequencyMhz: selectedMhz,
|
|
|
|
|
rmseM,
|
|
|
|
|
avgRssiDbm,
|
|
|
|
|
inputItems,
|
|
|
|
|
outputItems,
|
|
|
|
|
inputSummary,
|
|
|
|
|
@ -597,59 +835,506 @@ function toPercent(part, total) {
|
|
|
|
|
return Math.max(0, Math.min(100, Math.round((part / total) * 100)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderOverviewMetrics() {
|
|
|
|
|
function clamp(value, minValue, maxValue) {
|
|
|
|
|
const numeric = Number(value);
|
|
|
|
|
if (!Number.isFinite(numeric)) return minValue;
|
|
|
|
|
return Math.max(minValue, Math.min(maxValue, numeric));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setProgressWidth(id, percent) {
|
|
|
|
|
const el = byId(id);
|
|
|
|
|
if (!el) return;
|
|
|
|
|
const normalized = clamp(percent, 0, 100);
|
|
|
|
|
el.style.width = `${normalized}%`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function statusToSuccessScore(statusRaw) {
|
|
|
|
|
const status = String(statusRaw || "n/a").toLowerCase();
|
|
|
|
|
if (status === "ok") return 100;
|
|
|
|
|
if (status === "partial") return 70;
|
|
|
|
|
if (status === "warming_up") return 40;
|
|
|
|
|
if (status === "skipped") return 30;
|
|
|
|
|
if (status === "disabled") return 20;
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildTrendSeries(limit = 20) {
|
|
|
|
|
const rows = state.ioHistory.slice(0, Math.max(1, Number(limit) || 20)).reverse();
|
|
|
|
|
const rmse = rows
|
|
|
|
|
.map((row) => Number(row?.rmseM))
|
|
|
|
|
.filter((value) => Number.isFinite(value) && value >= 0);
|
|
|
|
|
const avgRssi = rows
|
|
|
|
|
.map((row) => Number(row?.avgRssiDbm))
|
|
|
|
|
.filter((value) => Number.isFinite(value));
|
|
|
|
|
const delivery = rows.map((row) => statusToSuccessScore(row?.statusRaw));
|
|
|
|
|
return { rows, rmse, avgRssi, delivery };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatTrendMeta(values, digits = 2, unit = "") {
|
|
|
|
|
if (!Array.isArray(values) || values.length === 0) return "н/д";
|
|
|
|
|
const finiteValues = values.filter((value) => Number.isFinite(Number(value)));
|
|
|
|
|
if (finiteValues.length === 0) return "н/д";
|
|
|
|
|
const last = Number(finiteValues[finiteValues.length - 1]);
|
|
|
|
|
const min = Math.min(...finiteValues);
|
|
|
|
|
const max = Math.max(...finiteValues);
|
|
|
|
|
return `посл ${fmt(last, digits)}${unit} | мин ${fmt(min, digits)}${unit} | макс ${fmt(max, digits)}${unit}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildSparklineSvg(values, options = {}) {
|
|
|
|
|
const {
|
|
|
|
|
width = 220,
|
|
|
|
|
height = 54,
|
|
|
|
|
padding = 4,
|
|
|
|
|
stroke = "#2b73f0",
|
|
|
|
|
area = "rgba(43, 115, 240, 0.22)",
|
|
|
|
|
invert = false,
|
|
|
|
|
} = options;
|
|
|
|
|
|
|
|
|
|
const points = Array.isArray(values)
|
|
|
|
|
? values.map((value) => Number(value)).filter((value) => Number.isFinite(value))
|
|
|
|
|
: [];
|
|
|
|
|
if (points.length === 0) {
|
|
|
|
|
return '<div class="sparkline-empty">Недостаточно данных</div>';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const minValue = Math.min(...points);
|
|
|
|
|
const maxValue = Math.max(...points);
|
|
|
|
|
const range = maxValue - minValue || 1;
|
|
|
|
|
const safeWidth = Math.max(40, Number(width) || 220);
|
|
|
|
|
const safeHeight = Math.max(24, Number(height) || 54);
|
|
|
|
|
const baseline = safeHeight - padding;
|
|
|
|
|
const usableWidth = Math.max(1, safeWidth - padding * 2);
|
|
|
|
|
const usableHeight = Math.max(1, safeHeight - padding * 2);
|
|
|
|
|
|
|
|
|
|
const normalized = points.map((value, index) => {
|
|
|
|
|
const x = padding + (points.length === 1 ? usableWidth / 2 : (index / (points.length - 1)) * usableWidth);
|
|
|
|
|
let ratio = (value - minValue) / range;
|
|
|
|
|
if (invert) ratio = 1 - ratio;
|
|
|
|
|
const y = baseline - ratio * usableHeight;
|
|
|
|
|
return { x, y, value };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const linePoints = normalized.map((point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`).join(" ");
|
|
|
|
|
const first = normalized[0];
|
|
|
|
|
const last = normalized[normalized.length - 1];
|
|
|
|
|
const areaPath = `M ${first.x.toFixed(2)} ${baseline.toFixed(2)} L ${linePoints
|
|
|
|
|
.replaceAll(" ", " L ")} L ${last.x.toFixed(2)} ${baseline.toFixed(2)} Z`;
|
|
|
|
|
const y25 = (padding + usableHeight * 0.25).toFixed(2);
|
|
|
|
|
const y50 = (padding + usableHeight * 0.5).toFixed(2);
|
|
|
|
|
const y75 = (padding + usableHeight * 0.75).toFixed(2);
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<svg class="sparkline-svg" viewBox="0 0 ${safeWidth} ${safeHeight}" preserveAspectRatio="none" role="img" aria-label="Тренд">
|
|
|
|
|
<line class="sparkline-grid" x1="${padding}" y1="${y25}" x2="${safeWidth - padding}" y2="${y25}"></line>
|
|
|
|
|
<line class="sparkline-grid" x1="${padding}" y1="${y50}" x2="${safeWidth - padding}" y2="${y50}"></line>
|
|
|
|
|
<line class="sparkline-grid" x1="${padding}" y1="${y75}" x2="${safeWidth - padding}" y2="${y75}"></line>
|
|
|
|
|
<path class="sparkline-area" d="${areaPath}" fill="${area}"></path>
|
|
|
|
|
<polyline class="sparkline-line" points="${linePoints}" stroke="${stroke}"></polyline>
|
|
|
|
|
<circle class="sparkline-dot" cx="${last.x.toFixed(2)}" cy="${last.y.toFixed(2)}" r="2.8" fill="${stroke}"></circle>
|
|
|
|
|
</svg>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderOverviewTrends() {
|
|
|
|
|
const rmseRoot = byId("ov-trend-rmse-chart");
|
|
|
|
|
const rssiRoot = byId("ov-trend-rssi-chart");
|
|
|
|
|
const deliveryRoot = byId("ov-trend-delivery-chart");
|
|
|
|
|
if (!rmseRoot && !rssiRoot && !deliveryRoot) return;
|
|
|
|
|
|
|
|
|
|
const series = buildTrendSeries(20);
|
|
|
|
|
|
|
|
|
|
if (rmseRoot) {
|
|
|
|
|
rmseRoot.innerHTML = buildSparklineSvg(series.rmse, {
|
|
|
|
|
stroke: "#2b73f0",
|
|
|
|
|
area: "rgba(43, 115, 240, 0.22)",
|
|
|
|
|
invert: true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (rssiRoot) {
|
|
|
|
|
rssiRoot.innerHTML = buildSparklineSvg(series.avgRssi, {
|
|
|
|
|
stroke: "#ff8a3d",
|
|
|
|
|
area: "rgba(255, 138, 61, 0.24)",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (deliveryRoot) {
|
|
|
|
|
deliveryRoot.innerHTML = buildSparklineSvg(series.delivery, {
|
|
|
|
|
stroke: "#14a37f",
|
|
|
|
|
area: "rgba(20, 163, 127, 0.22)",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setTextWithPulse("ov-trend-rmse-meta", formatTrendMeta(series.rmse, 2, " м"));
|
|
|
|
|
setTextWithPulse("ov-trend-rssi-meta", formatTrendMeta(series.avgRssi, 1, " дБм"));
|
|
|
|
|
setTextWithPulse("ov-trend-delivery-meta", formatTrendMeta(series.delivery, 0, "%"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderHistoryTrends() {
|
|
|
|
|
const root = byId("history-trends");
|
|
|
|
|
if (!root) return;
|
|
|
|
|
|
|
|
|
|
const series = buildTrendSeries(40);
|
|
|
|
|
const blocks = [
|
|
|
|
|
{
|
|
|
|
|
title: "RMSE, м",
|
|
|
|
|
meta: formatTrendMeta(series.rmse, 2, " м"),
|
|
|
|
|
chart: buildSparklineSvg(series.rmse, {
|
|
|
|
|
stroke: "#2b73f0",
|
|
|
|
|
area: "rgba(43, 115, 240, 0.22)",
|
|
|
|
|
invert: true,
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: "Средний RSSI, дБм",
|
|
|
|
|
meta: formatTrendMeta(series.avgRssi, 1, " дБм"),
|
|
|
|
|
chart: buildSparklineSvg(series.avgRssi, {
|
|
|
|
|
stroke: "#ff8a3d",
|
|
|
|
|
area: "rgba(255, 138, 61, 0.24)",
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: "Успех доставки, %",
|
|
|
|
|
meta: formatTrendMeta(series.delivery, 0, "%"),
|
|
|
|
|
chart: buildSparklineSvg(series.delivery, {
|
|
|
|
|
stroke: "#14a37f",
|
|
|
|
|
area: "rgba(20, 163, 127, 0.22)",
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
root.innerHTML = blocks
|
|
|
|
|
.map(
|
|
|
|
|
(block) => `
|
|
|
|
|
<article class="trend-card">
|
|
|
|
|
<div class="trend-head">
|
|
|
|
|
<span>${escapeHtml(block.title)}</span>
|
|
|
|
|
<b>${escapeHtml(block.meta)}</b>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="sparkline-wrap">${block.chart}</div>
|
|
|
|
|
</article>
|
|
|
|
|
`
|
|
|
|
|
)
|
|
|
|
|
.join("");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildMonitoringSummary() {
|
|
|
|
|
const data = state.result?.data || null;
|
|
|
|
|
const delivery = state.result?.output_delivery || state.frequencies?.output_delivery || {};
|
|
|
|
|
const inputs = Array.isArray(state.mockControls?.inputs) ? state.mockControls.inputs : [];
|
|
|
|
|
const outputs = Array.isArray(state.mockControls?.outputs) ? state.mockControls.outputs : [];
|
|
|
|
|
const inputOnline = inputs.filter((item) => Boolean(item?.reachable)).length;
|
|
|
|
|
const outputOnline = outputs.filter((item) => Boolean(item?.reachable)).length;
|
|
|
|
|
|
|
|
|
|
const total = state.ioHistory.length;
|
|
|
|
|
const okCount = state.ioHistory.filter((row) => String(row.statusRaw || "").toLowerCase() === "ok").length;
|
|
|
|
|
const success = toPercent(okCount, total);
|
|
|
|
|
const selectedHz = Number(data?.selected_frequency_hz);
|
|
|
|
|
const frequencyRows = Array.isArray(data?.frequency_table) ? data.frequency_table : [];
|
|
|
|
|
const updatedAt = state.result?.updated_at_utc || state.frequencies?.updated_at_utc || "";
|
|
|
|
|
const updatedText = updatedAt ? fmtDateTimeCompact(updatedAt) : "н/д";
|
|
|
|
|
|
|
|
|
|
setTextWithPulse("ov-input-online", `${inputOnline}/${inputs.length}`);
|
|
|
|
|
setTextWithPulse("ov-output-online", `${outputOnline}/${outputs.length}`);
|
|
|
|
|
setTextWithPulse("ov-history-total", total);
|
|
|
|
|
setTextWithPulse("ov-success-rate", `${success}%`);
|
|
|
|
|
return {
|
|
|
|
|
data,
|
|
|
|
|
delivery,
|
|
|
|
|
inputs,
|
|
|
|
|
outputs,
|
|
|
|
|
inputOnline,
|
|
|
|
|
outputOnline,
|
|
|
|
|
inputTotal: inputs.length,
|
|
|
|
|
outputTotal: outputs.length,
|
|
|
|
|
inputOnlinePercent: toPercent(inputOnline, inputs.length),
|
|
|
|
|
outputOnlinePercent: toPercent(outputOnline, outputs.length),
|
|
|
|
|
total,
|
|
|
|
|
okCount,
|
|
|
|
|
success,
|
|
|
|
|
selectedHz,
|
|
|
|
|
frequencyRows,
|
|
|
|
|
receivers: Array.isArray(data?.receivers) ? data.receivers : [],
|
|
|
|
|
updatedText,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderHistoryInsights() {
|
|
|
|
|
const feedRoot = byId("history-feed");
|
|
|
|
|
const monitorRoot = byId("history-monitor");
|
|
|
|
|
function renderOverviewFrequencyHealth(frequencyRows, selectedHz) {
|
|
|
|
|
const root = byId("ov-frequency-health");
|
|
|
|
|
if (!root) return;
|
|
|
|
|
|
|
|
|
|
if (!Array.isArray(frequencyRows) || frequencyRows.length === 0) {
|
|
|
|
|
root.innerHTML = '<div class="io-empty">Расчёты по частотам пока не получены.</div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (feedRoot) {
|
|
|
|
|
const feedRows = state.ioHistory.slice(0, 8);
|
|
|
|
|
if (feedRows.length === 0) {
|
|
|
|
|
feedRoot.innerHTML = '<div class="io-empty">Событий пока нет.</div>';
|
|
|
|
|
const finiteRmse = frequencyRows
|
|
|
|
|
.map((row) => Number(row?.rmse_m))
|
|
|
|
|
.filter((value) => Number.isFinite(value) && value >= 0);
|
|
|
|
|
const minRmse = finiteRmse.length > 0 ? Math.min(...finiteRmse) : 0;
|
|
|
|
|
const maxRmse = finiteRmse.length > 0 ? Math.max(...finiteRmse) : 1;
|
|
|
|
|
|
|
|
|
|
root.innerHTML = frequencyRows
|
|
|
|
|
.map((row) => {
|
|
|
|
|
const hz = Number(row?.frequency_hz);
|
|
|
|
|
const mhz = row?.frequency_mhz ?? hzToMhz(hz);
|
|
|
|
|
const rmse = Number(row?.rmse_m);
|
|
|
|
|
const exact = Boolean(row?.exact);
|
|
|
|
|
const isSelected = Number.isFinite(hz) && Math.abs(hz - Number(selectedHz)) <= 1;
|
|
|
|
|
|
|
|
|
|
let quality = 0;
|
|
|
|
|
if (Number.isFinite(rmse) && rmse >= 0) {
|
|
|
|
|
if (maxRmse <= minRmse) {
|
|
|
|
|
quality = 100;
|
|
|
|
|
} else {
|
|
|
|
|
feedRoot.innerHTML = feedRows
|
|
|
|
|
quality = Math.round(((maxRmse - rmse) / (maxRmse - minRmse)) * 100);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
quality = clamp(quality, 0, 100);
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<article class="frequency-health-item ${isSelected ? "frequency-health-item-active" : ""}">
|
|
|
|
|
<div class="frequency-health-head">
|
|
|
|
|
<b>${escapeHtml(fmt(mhz, 3))} МГц</b>
|
|
|
|
|
<span class="io-chip ${exact ? "io-status-ok" : "io-status-partial"}">${exact ? "точно" : "оценка"}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="monitor-progress monitor-progress-accent">
|
|
|
|
|
<span style="width:${quality}%"></span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="frequency-health-meta">
|
|
|
|
|
<span>RMSE: ${escapeHtml(fmt(rmse, 2))} м</span>
|
|
|
|
|
<span>Качество: ${quality}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
</article>
|
|
|
|
|
`;
|
|
|
|
|
})
|
|
|
|
|
.join("");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderOverviewSignalGrid(receivers, selectedHz) {
|
|
|
|
|
const root = byId("ov-signal-grid");
|
|
|
|
|
if (!root) return;
|
|
|
|
|
if (!Array.isArray(receivers) || receivers.length === 0) {
|
|
|
|
|
root.innerHTML = '<div class="io-empty">Сигналы ресиверов пока не получены.</div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
root.innerHTML = receivers
|
|
|
|
|
.map((receiver) => {
|
|
|
|
|
const receiverId = String(receiver?.receiver_id || "n/a");
|
|
|
|
|
const sample = findByFrequency(receiver?.samples, selectedHz) || receiver?.samples?.[0] || null;
|
|
|
|
|
const row = findByFrequency(receiver?.per_frequency, selectedHz) || receiver?.per_frequency?.[0] || null;
|
|
|
|
|
|
|
|
|
|
const rssi = Number(sample?.amplitude_dbm);
|
|
|
|
|
const radius = Number(row?.radius_m ?? sample?.distance_m);
|
|
|
|
|
const residual = Number(row?.residual_m);
|
|
|
|
|
const level = Number.isFinite(rssi) ? clamp(((rssi + 120) / 70) * 100, 0, 100) : 0;
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<article class="signal-item">
|
|
|
|
|
<div class="signal-item-head">
|
|
|
|
|
<b>${escapeHtml(receiverId)}</b>
|
|
|
|
|
<span>${escapeHtml(fmt(rssi, 1))} dBm</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="monitor-progress monitor-progress-signal">
|
|
|
|
|
<span style="width:${level}%"></span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="signal-item-meta">
|
|
|
|
|
<span>R=${escapeHtml(fmt(radius, 2))} м</span>
|
|
|
|
|
<span>ε=${escapeHtml(fmt(residual, 2))} м</span>
|
|
|
|
|
</div>
|
|
|
|
|
</article>
|
|
|
|
|
`;
|
|
|
|
|
})
|
|
|
|
|
.join("");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizePipelineStatus(status) {
|
|
|
|
|
const normalized = String(status || "n/a").toLowerCase();
|
|
|
|
|
if (normalized === "ok") return "ok";
|
|
|
|
|
if (normalized === "error") return "error";
|
|
|
|
|
if (normalized === "partial") return "partial";
|
|
|
|
|
if (normalized === "warming_up") return "warm";
|
|
|
|
|
return "warm";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pipelineDotClass(status) {
|
|
|
|
|
const normalized = normalizePipelineStatus(status);
|
|
|
|
|
if (normalized === "ok") return "pipeline-dot pipeline-dot-ok";
|
|
|
|
|
if (normalized === "error") return "pipeline-dot pipeline-dot-error";
|
|
|
|
|
if (normalized === "partial") return "pipeline-dot pipeline-dot-partial";
|
|
|
|
|
return "pipeline-dot pipeline-dot-warm";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderOverviewPipeline(summary) {
|
|
|
|
|
const root = byId("ov-pipeline-stages");
|
|
|
|
|
if (!root) return;
|
|
|
|
|
|
|
|
|
|
const hasData = Boolean(summary.data);
|
|
|
|
|
const solveStatus = hasData
|
|
|
|
|
? Number.isFinite(Number(summary.data?.rmse_m))
|
|
|
|
|
? "ok"
|
|
|
|
|
: "partial"
|
|
|
|
|
: "warming_up";
|
|
|
|
|
|
|
|
|
|
const inputStatus = summary.inputTotal <= 0
|
|
|
|
|
? "warming_up"
|
|
|
|
|
: summary.inputOnline === summary.inputTotal
|
|
|
|
|
? "ok"
|
|
|
|
|
: summary.inputOnline > 0
|
|
|
|
|
? "partial"
|
|
|
|
|
: "error";
|
|
|
|
|
|
|
|
|
|
const outputStatus = summary.outputTotal <= 0
|
|
|
|
|
? "warming_up"
|
|
|
|
|
: summary.outputOnline === summary.outputTotal
|
|
|
|
|
? "ok"
|
|
|
|
|
: summary.outputOnline > 0
|
|
|
|
|
? "partial"
|
|
|
|
|
: "error";
|
|
|
|
|
|
|
|
|
|
const stages = [
|
|
|
|
|
{
|
|
|
|
|
name: "Сбор входов",
|
|
|
|
|
status: inputStatus,
|
|
|
|
|
value: `${summary.inputOnline}/${summary.inputTotal}`,
|
|
|
|
|
note: "доступность входных серверов",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "Решение",
|
|
|
|
|
status: solveStatus,
|
|
|
|
|
value: hasData ? `RMSE ${fmt(summary.data?.rmse_m, 2)} м` : "ожидание данных",
|
|
|
|
|
note: "оценка точности пересечения сфер",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "Доставка",
|
|
|
|
|
status: summary.delivery?.status || "warming_up",
|
|
|
|
|
value: `${summary.outputOnline}/${summary.outputTotal}`,
|
|
|
|
|
note: "доступность выходных серверов",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "История",
|
|
|
|
|
status: summary.total > 0 ? (summary.success >= 80 ? "ok" : "partial") : "warming_up",
|
|
|
|
|
value: `${summary.success}%`,
|
|
|
|
|
note: "успешность доставки по накопленной ленте",
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
root.innerHTML = stages
|
|
|
|
|
.map(
|
|
|
|
|
(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>
|
|
|
|
|
(stage) => `
|
|
|
|
|
<article class="pipeline-stage">
|
|
|
|
|
<div class="pipeline-stage-head">
|
|
|
|
|
<span class="pipeline-stage-label">
|
|
|
|
|
<span class="${pipelineDotClass(stage.status)}"></span>
|
|
|
|
|
<b>${escapeHtml(stage.name)}</b>
|
|
|
|
|
</span>
|
|
|
|
|
<span class="io-chip ${statusClass(stage.status)}">${escapeHtml(localizeStatus(stage.status))}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="feed-body">
|
|
|
|
|
<div><b>${escapeHtml(fmt(row.frequencyMhz, 3))} МГц</b></div>
|
|
|
|
|
<div>${escapeHtml((row.outputItems && row.outputItems[0]) || row.outputSummary || "-")}</div>
|
|
|
|
|
<div class="monitor-progress monitor-progress-accent">
|
|
|
|
|
<span style="width:${clamp(stage.name === "История" ? summary.success : stage.name === "Сбор входов" ? summary.inputOnlinePercent : stage.name === "Доставка" ? summary.outputOnlinePercent : summary.success, 0, 100)}%"></span>
|
|
|
|
|
</div>
|
|
|
|
|
</article>`
|
|
|
|
|
<div class="pipeline-stage-note">${escapeHtml(stage.value)} • ${escapeHtml(stage.note)}</div>
|
|
|
|
|
</article>
|
|
|
|
|
`
|
|
|
|
|
)
|
|
|
|
|
.join("");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderOverviewTopFrequencies(frequencyRows) {
|
|
|
|
|
const root = byId("ov-top-frequencies");
|
|
|
|
|
if (!root) return;
|
|
|
|
|
|
|
|
|
|
if (!Array.isArray(frequencyRows) || frequencyRows.length === 0) {
|
|
|
|
|
root.innerHTML = '<div class="io-empty">Нет данных по частотам для рейтинга.</div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 rankedRows = [...frequencyRows].sort((a, b) => {
|
|
|
|
|
const ra = Number(a?.rmse_m);
|
|
|
|
|
const rb = Number(b?.rmse_m);
|
|
|
|
|
const aFinite = Number.isFinite(ra);
|
|
|
|
|
const bFinite = Number.isFinite(rb);
|
|
|
|
|
if (aFinite && bFinite) return ra - rb;
|
|
|
|
|
if (aFinite) return -1;
|
|
|
|
|
if (bFinite) return 1;
|
|
|
|
|
return 0;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const total = state.ioHistory.length;
|
|
|
|
|
const okCount = state.ioHistory.filter((row) => String(row.statusRaw || "").toLowerCase() === "ok").length;
|
|
|
|
|
const topRows = rankedRows.slice(0, 5);
|
|
|
|
|
const rmseValues = topRows
|
|
|
|
|
.map((row) => Number(row?.rmse_m))
|
|
|
|
|
.filter((value) => Number.isFinite(value) && value >= 0);
|
|
|
|
|
const minRmse = rmseValues.length > 0 ? Math.min(...rmseValues) : 0;
|
|
|
|
|
const maxRmse = rmseValues.length > 0 ? Math.max(...rmseValues) : 1;
|
|
|
|
|
|
|
|
|
|
root.innerHTML = topRows
|
|
|
|
|
.map((row, index) => {
|
|
|
|
|
const rmse = Number(row?.rmse_m);
|
|
|
|
|
const mhz = row?.frequency_mhz ?? hzToMhz(row?.frequency_hz);
|
|
|
|
|
const exact = Boolean(row?.exact);
|
|
|
|
|
let score = 0;
|
|
|
|
|
if (Number.isFinite(rmse) && rmse >= 0) {
|
|
|
|
|
if (maxRmse <= minRmse) {
|
|
|
|
|
score = 100;
|
|
|
|
|
} else {
|
|
|
|
|
score = Math.round(((maxRmse - rmse) / (maxRmse - minRmse)) * 100);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
score = clamp(score, 0, 100);
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<article class="top-frequency-item">
|
|
|
|
|
<div class="top-frequency-head">
|
|
|
|
|
<span class="top-frequency-title">
|
|
|
|
|
<span class="top-frequency-rank">${index + 1}</span>
|
|
|
|
|
<b>${escapeHtml(fmt(mhz, 3))} МГц</b>
|
|
|
|
|
</span>
|
|
|
|
|
<span class="io-chip ${exact ? "io-status-ok" : "io-status-partial"}">${exact ? "точно" : "оценка"}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="monitor-progress">
|
|
|
|
|
<span style="width:${score}%"></span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="top-frequency-meta">
|
|
|
|
|
<span>RMSE ${escapeHtml(fmt(rmse, 2))} м</span>
|
|
|
|
|
<span>Индекс ${score}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
</article>
|
|
|
|
|
`;
|
|
|
|
|
})
|
|
|
|
|
.join("");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderMenuGroupBadges(summary) {
|
|
|
|
|
setTextWithPulse("menu-badge-monitoring", `${summary.success}%`);
|
|
|
|
|
setTextWithPulse("menu-badge-io", `${summary.inputOnline}/${summary.inputTotal} • ${summary.outputOnline}/${summary.outputTotal}`);
|
|
|
|
|
setTextWithPulse("menu-badge-config", `${summary.inputs.length}вх/${summary.outputs.length}вых`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderOverviewMetrics() {
|
|
|
|
|
const summary = buildMonitoringSummary();
|
|
|
|
|
|
|
|
|
|
setTextWithPulse("ov-input-online", `${summary.inputOnline}/${summary.inputTotal}`);
|
|
|
|
|
setTextWithPulse("ov-output-online", `${summary.outputOnline}/${summary.outputTotal}`);
|
|
|
|
|
setTextWithPulse("ov-history-total", summary.total);
|
|
|
|
|
setTextWithPulse("ov-success-rate", `${summary.success}%`);
|
|
|
|
|
setTextWithPulse("ov-health-chip", localizeStatus(state.health?.status));
|
|
|
|
|
setTextWithPulse("ov-delivery-chip", localizeStatus(summary.delivery?.status));
|
|
|
|
|
setTextWithPulse("ov-updated-at", summary.updatedText);
|
|
|
|
|
setTextWithPulse("ov-input-online-bar-text", `${summary.inputOnline}/${summary.inputTotal}`);
|
|
|
|
|
setTextWithPulse("ov-output-online-bar-text", `${summary.outputOnline}/${summary.outputTotal}`);
|
|
|
|
|
setTextWithPulse("ov-delivery-bar-text", `${summary.success}%`);
|
|
|
|
|
setProgressWidth("ov-input-online-bar", summary.inputOnlinePercent);
|
|
|
|
|
setProgressWidth("ov-output-online-bar", summary.outputOnlinePercent);
|
|
|
|
|
setProgressWidth("ov-delivery-bar", summary.success);
|
|
|
|
|
renderMenuGroupBadges(summary);
|
|
|
|
|
renderOverviewFrequencyHealth(summary.frequencyRows, summary.selectedHz);
|
|
|
|
|
renderOverviewSignalGrid(summary.receivers, summary.selectedHz);
|
|
|
|
|
renderOverviewPipeline(summary);
|
|
|
|
|
renderOverviewTopFrequencies(summary.frequencyRows);
|
|
|
|
|
renderOverviewTrends();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderHistoryInsights() {
|
|
|
|
|
const monitorRoot = byId("history-monitor");
|
|
|
|
|
const summary = buildMonitoringSummary();
|
|
|
|
|
renderHistoryTrends();
|
|
|
|
|
|
|
|
|
|
if (monitorRoot) {
|
|
|
|
|
const problemCount = state.ioHistory.filter((row) => statusIsProblem(row.statusRaw)).length;
|
|
|
|
|
const success = toPercent(okCount, total);
|
|
|
|
|
|
|
|
|
|
const healthStatus = localizeStatus(state.health?.status);
|
|
|
|
|
const deliveryStatus = localizeStatus(
|
|
|
|
|
@ -659,11 +1344,11 @@ function renderHistoryInsights() {
|
|
|
|
|
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>Входы online</span><b>${summary.inputOnline}/${summary.inputTotal}</b></div>
|
|
|
|
|
<div class="monitor-row"><span>Выходы online</span><b>${summary.outputOnline}/${summary.outputTotal}</b></div>
|
|
|
|
|
<div class="monitor-row"><span>Проблемных событий</span><b>${problemCount}</b></div>
|
|
|
|
|
<div class="monitor-row"><span>Успех доставки</span><b>${success}%</b></div>
|
|
|
|
|
<div class="metric-track"><span style="width:${success}%"></span></div>
|
|
|
|
|
<div class="monitor-row"><span>Успех доставки</span><b>${summary.success}%</b></div>
|
|
|
|
|
<div class="metric-track"><span style="width:${summary.success}%"></span></div>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@ -871,6 +1556,16 @@ function buildControlCardHtml(item, target) {
|
|
|
|
|
const reachabilityKind = reachable ? "io-status-ok" : "io-status-error";
|
|
|
|
|
const reachabilityText = reachable ? "online" : "offline";
|
|
|
|
|
const disabledAttr = !id ? "disabled" : "";
|
|
|
|
|
const liveFrequencies = Array.isArray(item?.frequencies_mhz)
|
|
|
|
|
? item.frequencies_mhz.map((value) => fmt(Number(value), 3)).join(", ")
|
|
|
|
|
: "";
|
|
|
|
|
const configuredFrequencies = Array.isArray(item?.configured_frequencies_mhz)
|
|
|
|
|
? item.configured_frequencies_mhz.map((value) => fmt(Number(value), 3)).join(", ")
|
|
|
|
|
: "";
|
|
|
|
|
const frequencyText =
|
|
|
|
|
target === "input"
|
|
|
|
|
? `активные: ${liveFrequencies || "н/д"} • конфиг: ${configuredFrequencies || "н/д"}`
|
|
|
|
|
: "";
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<article class="io-card io-control-card io-control-card-compact">
|
|
|
|
|
@ -885,6 +1580,7 @@ function buildControlCardHtml(item, target) {
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
${errorText ? `<div class="io-control-error">Ошибка: ${errorText}</div>` : ""}
|
|
|
|
|
${frequencyText ? `<div class="io-control-sub">частоты (МГц): ${escapeHtml(frequencyText)}</div>` : ""}
|
|
|
|
|
<div class="io-control-actions">
|
|
|
|
|
<button
|
|
|
|
|
class="btn btn-compact flow-toggle-btn"
|
|
|
|
|
@ -1174,7 +1870,7 @@ function render() {
|
|
|
|
|
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("health-status", `сервис: ${localizeStatus(state.health?.status)}`);
|
|
|
|
|
setTextWithPulse("delivery-status", `доставка: ${localizeStatus(delivery?.status)}`);
|
|
|
|
|
renderOverviewMetrics();
|
|
|
|
|
maybeNotifyStatusChanges(delivery || {});
|
|
|
|
|
@ -1364,9 +2060,17 @@ async function saveServers() {
|
|
|
|
|
byId("config-editor").value = JSON.stringify(cfg, null, 2);
|
|
|
|
|
|
|
|
|
|
const saveSuffix = result.save_error ? " (применено, но сохранить файл не удалось)" : "";
|
|
|
|
|
const syncErrors = Array.isArray(result.mock_input_frequency_sync_errors)
|
|
|
|
|
? result.mock_input_frequency_sync_errors.filter((item) => String(item || "").trim() !== "")
|
|
|
|
|
: [];
|
|
|
|
|
byId("servers-state").textContent = result.restart_required
|
|
|
|
|
? `серверы: сохранены, требуется перезапуск${saveSuffix}`
|
|
|
|
|
: `серверы: сохранены${saveSuffix}`;
|
|
|
|
|
if (syncErrors.length > 0) {
|
|
|
|
|
showToast(`Частоты тестовых входов синхронизированы не полностью: ${syncErrors.join("; ")}`, "error");
|
|
|
|
|
} else if (result.mock_input_frequency_sync_enabled) {
|
|
|
|
|
showToast("Частоты тестовых входов обновлены и сохранены.", "success");
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
byId("servers-state").textContent = `серверы: ${localizeErrorMessage(err.message)}`;
|
|
|
|
|
}
|
|
|
|
|
@ -1389,6 +2093,12 @@ function bindUi() {
|
|
|
|
|
"info"
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
const autoRefreshSecondsInput = byId("auto-refresh-seconds");
|
|
|
|
|
if (autoRefreshSecondsInput) {
|
|
|
|
|
autoRefreshSecondsInput.addEventListener("change", (event) => {
|
|
|
|
|
setPollIntervalSeconds(event.target.value);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
byId("load-config").addEventListener("click", loadConfig);
|
|
|
|
|
byId("save-config").addEventListener("click", saveConfig);
|
|
|
|
|
byId("load-servers").addEventListener("click", loadConfig);
|
|
|
|
|
@ -1418,6 +2128,36 @@ function bindUi() {
|
|
|
|
|
setMenuCollapsed(!state.menuCollapsed);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll(".menu-group-toggle").forEach((toggle) => {
|
|
|
|
|
toggle.addEventListener("click", () => {
|
|
|
|
|
const group = String(toggle.dataset.menuGroupToggle || "");
|
|
|
|
|
if (!MENU_GROUP_KEYS.includes(group)) return;
|
|
|
|
|
const nextCollapsed = !Boolean(state.menuGroupCollapsed[group]);
|
|
|
|
|
setMenuGroupCollapsed(group, nextCollapsed, { persist: true });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const dateTimeToggle = byId("datetime-toggle");
|
|
|
|
|
if (dateTimeToggle) {
|
|
|
|
|
dateTimeToggle.addEventListener("click", () => {
|
|
|
|
|
setDateTimeCollapsed(!state.dateTimeCollapsed);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const densityToggle = byId("density-toggle");
|
|
|
|
|
if (densityToggle) {
|
|
|
|
|
densityToggle.addEventListener("click", () => {
|
|
|
|
|
const nextMode = state.uiDensity === "compact" ? "detailed" : "compact";
|
|
|
|
|
setUiDensity(nextMode, { persist: true });
|
|
|
|
|
showToast(
|
|
|
|
|
state.uiDensity === "compact"
|
|
|
|
|
? "Включен компактный режим интерфейса."
|
|
|
|
|
: "Включен детальный режим интерфейса.",
|
|
|
|
|
"info"
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const historyFilter = byId("history-filter");
|
|
|
|
|
if (historyFilter) {
|
|
|
|
|
historyFilter.addEventListener("change", (event) => {
|
|
|
|
|
@ -1520,8 +2260,13 @@ async function boot() {
|
|
|
|
|
fillTimeZoneSelect();
|
|
|
|
|
setTimeZone(readTimeZonePreference());
|
|
|
|
|
bindUi();
|
|
|
|
|
state.menuGroupCollapsed = readMenuGroupCollapsed();
|
|
|
|
|
applyAllMenuGroupsCollapsed();
|
|
|
|
|
setUiDensity(readUiDensityPreference(), { persist: false });
|
|
|
|
|
setPollIntervalMs(readPollIntervalPreference(), { persist: false, restartPolling: false });
|
|
|
|
|
updateHistoryRecordingUi();
|
|
|
|
|
setMenuCollapsed(readMenuCollapsed());
|
|
|
|
|
setDateTimeCollapsed(readDateTimeCollapsed());
|
|
|
|
|
updateRefreshUi();
|
|
|
|
|
setActiveSection(state.activeSection);
|
|
|
|
|
await loadConfig();
|
|
|
|
|
@ -1530,7 +2275,7 @@ async function boot() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
boot().catch((err) => {
|
|
|
|
|
setTextWithPulse("health-status", `состояние: ${localizeErrorMessage(err.message)}`);
|
|
|
|
|
setTextWithPulse("health-status", `сервис: ${localizeErrorMessage(err.message)}`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|