|
|
|
|
@ -3,6 +3,8 @@
|
|
|
|
|
frequencies: null,
|
|
|
|
|
health: null,
|
|
|
|
|
config: null,
|
|
|
|
|
auth: null,
|
|
|
|
|
users: [],
|
|
|
|
|
writeToken: "",
|
|
|
|
|
activeSection: "overview",
|
|
|
|
|
selectedReceiverIndex: 0,
|
|
|
|
|
@ -39,6 +41,7 @@
|
|
|
|
|
lastDeliveryStatus: "n/a",
|
|
|
|
|
timezone: "local",
|
|
|
|
|
uiDensity: "detailed",
|
|
|
|
|
uiTheme: "aurora",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const HZ_IN_MHZ = 1_000_000;
|
|
|
|
|
@ -47,12 +50,14 @@ 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 UI_THEME_STORAGE_KEY = "triangulation.ui_theme";
|
|
|
|
|
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 UI_THEME_OPTIONS = ["aurora", "signal", "slate"];
|
|
|
|
|
const TIMEZONE_OPTIONS = [
|
|
|
|
|
{ value: "local", label: "Локальный (браузер)" },
|
|
|
|
|
{ value: "UTC", label: "UTC" },
|
|
|
|
|
@ -108,6 +113,15 @@ function localizeStatus(value) {
|
|
|
|
|
return mapping[status] || status;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function localizeThemeName(value) {
|
|
|
|
|
const mapping = {
|
|
|
|
|
aurora: "свет",
|
|
|
|
|
signal: "сигнал",
|
|
|
|
|
slate: "графит",
|
|
|
|
|
};
|
|
|
|
|
return mapping[String(value || "").toLowerCase()] || "свет";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function localizeErrorMessage(message) {
|
|
|
|
|
const text = String(message || "неизвестная ошибка");
|
|
|
|
|
const known = {
|
|
|
|
|
@ -115,6 +129,10 @@ function localizeErrorMessage(message) {
|
|
|
|
|
"at least 1 output server is required": "необходим минимум 1 выходной сервер",
|
|
|
|
|
Unauthorized: "доступ запрещён (проверьте токен)",
|
|
|
|
|
"unauthorized: missing or invalid API token": "доступ запрещён: отсутствует или неверный API-токен",
|
|
|
|
|
"authentication required": "требуется вход в систему",
|
|
|
|
|
"admin role required": "требуются права администратора",
|
|
|
|
|
"username and password are required": "нужны логин и пароль",
|
|
|
|
|
"user does not have required Keycloak role": "у пользователя нет нужной роли Keycloak",
|
|
|
|
|
warming_up: "прогрев",
|
|
|
|
|
not_found: "не найдено",
|
|
|
|
|
"no data yet": "данные пока не получены",
|
|
|
|
|
@ -198,6 +216,57 @@ function authHeaders() {
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function authEnabled() {
|
|
|
|
|
return Boolean(state.auth?.enabled);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isAuthenticated() {
|
|
|
|
|
return Boolean(state.auth?.authenticated) || !authEnabled();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isAdmin() {
|
|
|
|
|
return String(state.auth?.role || "") === "admin" || !authEnabled();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function visibleSections() {
|
|
|
|
|
if (!authEnabled()) {
|
|
|
|
|
return ["overview", "frequencies", "io", "history", "servers", "json"];
|
|
|
|
|
}
|
|
|
|
|
const sections = Array.isArray(state.auth?.visible_sections) ? state.auth.visible_sections : [];
|
|
|
|
|
return sections.length > 0 ? sections : [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyServerReadOnlyMode() {
|
|
|
|
|
const readOnly = !isAdmin();
|
|
|
|
|
[
|
|
|
|
|
"rx-id",
|
|
|
|
|
"rx-url",
|
|
|
|
|
"rx-frequencies",
|
|
|
|
|
"rx-center-x",
|
|
|
|
|
"rx-center-y",
|
|
|
|
|
"rx-center-z",
|
|
|
|
|
"shared-filter-enabled",
|
|
|
|
|
"shared-min-freq",
|
|
|
|
|
"shared-max-freq",
|
|
|
|
|
"shared-min-rssi",
|
|
|
|
|
"shared-max-rssi",
|
|
|
|
|
"out-name",
|
|
|
|
|
"out-ip",
|
|
|
|
|
"write-token",
|
|
|
|
|
].forEach((id) => {
|
|
|
|
|
const el = byId(id);
|
|
|
|
|
if (!el) return;
|
|
|
|
|
el.disabled = readOnly;
|
|
|
|
|
if ("readOnly" in el && el.tagName === "INPUT") {
|
|
|
|
|
el.readOnly = readOnly;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
const stateBadge = byId("servers-state");
|
|
|
|
|
if (stateBadge && readOnly) {
|
|
|
|
|
stateBadge.textContent = "узлы: только просмотр";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setActiveSection(section) {
|
|
|
|
|
state.activeSection = section;
|
|
|
|
|
document.querySelectorAll(".panel").forEach((panel) => {
|
|
|
|
|
@ -212,6 +281,102 @@ function setActiveSection(section) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ensureValidActiveSection() {
|
|
|
|
|
const allowed = visibleSections();
|
|
|
|
|
if (allowed.includes(state.activeSection)) return;
|
|
|
|
|
state.activeSection = allowed[0] || "overview";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applySectionAccess() {
|
|
|
|
|
const allowed = new Set(visibleSections());
|
|
|
|
|
document.querySelectorAll(".menu-item").forEach((item) => {
|
|
|
|
|
const section = String(item.dataset.section || "");
|
|
|
|
|
item.hidden = !allowed.has(section);
|
|
|
|
|
});
|
|
|
|
|
document.querySelectorAll(".panel").forEach((panel) => {
|
|
|
|
|
const section = panel.id.replace("section-", "");
|
|
|
|
|
panel.hidden = !allowed.has(section);
|
|
|
|
|
});
|
|
|
|
|
const usersCard = byId("users-card");
|
|
|
|
|
if (usersCard) {
|
|
|
|
|
usersCard.hidden = !isAdmin();
|
|
|
|
|
}
|
|
|
|
|
document.querySelectorAll(".menu-group").forEach((group) => {
|
|
|
|
|
const hasVisibleItems = Array.from(group.querySelectorAll(".menu-item")).some((item) => !item.hidden);
|
|
|
|
|
group.hidden = !hasVisibleItems;
|
|
|
|
|
});
|
|
|
|
|
const configGroup = document.querySelector('.menu-group[data-menu-group="config"]');
|
|
|
|
|
if (configGroup && !isAdmin()) {
|
|
|
|
|
configGroup.hidden = true;
|
|
|
|
|
}
|
|
|
|
|
ensureValidActiveSection();
|
|
|
|
|
setActiveSection(state.activeSection);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderAuthUi() {
|
|
|
|
|
const overlay = byId("auth-overlay");
|
|
|
|
|
const userChip = byId("auth-user-chip");
|
|
|
|
|
const roleChip = byId("auth-role-chip");
|
|
|
|
|
const logoutButton = byId("logout-button");
|
|
|
|
|
const loginState = byId("login-state");
|
|
|
|
|
const shell = byId("app-shell");
|
|
|
|
|
const body = document.body;
|
|
|
|
|
|
|
|
|
|
if (body) {
|
|
|
|
|
body.classList.remove("role-admin", "role-user", "role-guest");
|
|
|
|
|
if (isAdmin()) {
|
|
|
|
|
body.classList.add("role-admin");
|
|
|
|
|
} else if (isAuthenticated()) {
|
|
|
|
|
body.classList.add("role-user");
|
|
|
|
|
} else {
|
|
|
|
|
body.classList.add("role-guest");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (userChip) {
|
|
|
|
|
userChip.textContent = `пользователь: ${state.auth?.username || "гость"}`;
|
|
|
|
|
}
|
|
|
|
|
if (roleChip) {
|
|
|
|
|
roleChip.textContent = `роль: ${state.auth?.role || "-"}`;
|
|
|
|
|
}
|
|
|
|
|
if (logoutButton) {
|
|
|
|
|
logoutButton.hidden = !isAuthenticated() || !authEnabled();
|
|
|
|
|
}
|
|
|
|
|
if (overlay) {
|
|
|
|
|
const shouldShow = authEnabled() && !isAuthenticated();
|
|
|
|
|
overlay.classList.toggle("auth-overlay-hidden", !shouldShow);
|
|
|
|
|
}
|
|
|
|
|
if (shell) {
|
|
|
|
|
shell.classList.toggle("app-shell-locked", authEnabled() && !isAuthenticated());
|
|
|
|
|
}
|
|
|
|
|
[
|
|
|
|
|
"load-config",
|
|
|
|
|
"save-config",
|
|
|
|
|
"load-servers",
|
|
|
|
|
"save-servers",
|
|
|
|
|
"add-receiver",
|
|
|
|
|
"remove-receiver",
|
|
|
|
|
"add-output-server",
|
|
|
|
|
"remove-output-server",
|
|
|
|
|
].forEach((id) => {
|
|
|
|
|
const el = byId(id);
|
|
|
|
|
if (el) el.hidden = !isAdmin();
|
|
|
|
|
});
|
|
|
|
|
const ioAdminControls = byId("io-admin-controls");
|
|
|
|
|
if (ioAdminControls) {
|
|
|
|
|
ioAdminControls.hidden = !isAdmin();
|
|
|
|
|
}
|
|
|
|
|
const historyAdminActions = byId("history-admin-actions");
|
|
|
|
|
if (historyAdminActions) {
|
|
|
|
|
historyAdminActions.hidden = !isAdmin();
|
|
|
|
|
}
|
|
|
|
|
if (loginState && authEnabled() && !isAuthenticated()) {
|
|
|
|
|
loginState.textContent = "авторизация: требуется вход";
|
|
|
|
|
}
|
|
|
|
|
applySectionAccess();
|
|
|
|
|
applyServerReadOnlyMode();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setMenuCollapsed(isCollapsed) {
|
|
|
|
|
state.menuCollapsed = Boolean(isCollapsed);
|
|
|
|
|
const sideNav = byId("side-nav");
|
|
|
|
|
@ -320,8 +485,8 @@ function setDateTimeCollapsed(isCollapsed) {
|
|
|
|
|
}
|
|
|
|
|
if (toggle) {
|
|
|
|
|
toggle.textContent = state.dateTimeCollapsed
|
|
|
|
|
? "Показать служебную панель"
|
|
|
|
|
: "Скрыть служебную панель";
|
|
|
|
|
? "Показать панель"
|
|
|
|
|
: "Скрыть панель";
|
|
|
|
|
toggle.setAttribute("aria-expanded", String(!state.dateTimeCollapsed));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -373,6 +538,27 @@ function normalizeUiDensity(value) {
|
|
|
|
|
return String(value || "").toLowerCase() === "compact" ? "compact" : "detailed";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeUiTheme(value) {
|
|
|
|
|
const next = String(value || "").toLowerCase();
|
|
|
|
|
return UI_THEME_OPTIONS.includes(next) ? next : "aurora";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function saveUiThemePreference(value) {
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem(UI_THEME_STORAGE_KEY, normalizeUiTheme(value));
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore localStorage errors in restricted environments.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readUiThemePreference() {
|
|
|
|
|
try {
|
|
|
|
|
return normalizeUiTheme(localStorage.getItem(UI_THEME_STORAGE_KEY) || "aurora");
|
|
|
|
|
} catch {
|
|
|
|
|
return "aurora";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function saveUiDensityPreference(value) {
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem(UI_DENSITY_STORAGE_KEY, normalizeUiDensity(value));
|
|
|
|
|
@ -394,10 +580,20 @@ function applyUiDensity() {
|
|
|
|
|
document.body.classList.toggle("ui-compact", compact);
|
|
|
|
|
const toggle = byId("density-toggle");
|
|
|
|
|
if (toggle) {
|
|
|
|
|
toggle.textContent = compact ? "Режим: компактный" : "Режим: детальный";
|
|
|
|
|
toggle.textContent = compact ? "Вид: компактный" : "Вид: детальный";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyUiTheme() {
|
|
|
|
|
const nextTheme = normalizeUiTheme(state.uiTheme);
|
|
|
|
|
document.body.dataset.theme = nextTheme;
|
|
|
|
|
document.querySelectorAll(".theme-chip").forEach((button) => {
|
|
|
|
|
const isActive = normalizeUiTheme(button.dataset.themeValue) === nextTheme;
|
|
|
|
|
button.classList.toggle("theme-chip-active", isActive);
|
|
|
|
|
button.setAttribute("aria-pressed", String(isActive));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setUiDensity(value, options = {}) {
|
|
|
|
|
const { persist = true } = options;
|
|
|
|
|
state.uiDensity = normalizeUiDensity(value);
|
|
|
|
|
@ -407,6 +603,15 @@ function setUiDensity(value, options = {}) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setUiTheme(value, options = {}) {
|
|
|
|
|
const { persist = true } = options;
|
|
|
|
|
state.uiTheme = normalizeUiTheme(value);
|
|
|
|
|
applyUiTheme();
|
|
|
|
|
if (persist) {
|
|
|
|
|
saveUiThemePreference(state.uiTheme);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizePollIntervalMs(valueMs) {
|
|
|
|
|
const numeric = Number(valueMs);
|
|
|
|
|
if (!Number.isFinite(numeric)) return AUTO_REFRESH_DEFAULT_MS;
|
|
|
|
|
@ -631,13 +836,13 @@ function renderInputFlow(data) {
|
|
|
|
|
if (!root) return;
|
|
|
|
|
const receivers = Array.isArray(data?.receivers) ? data.receivers : [];
|
|
|
|
|
if (receivers.length === 0) {
|
|
|
|
|
root.innerHTML = '<div class="io-empty">Ожидание входных измерений от ресиверов.</div>';
|
|
|
|
|
root.innerHTML = '<div class="io-empty">Ожидание данных от приемников.</div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
root.innerHTML = receivers
|
|
|
|
|
.map((receiver) => {
|
|
|
|
|
const receiverId = escapeHtml(receiver?.receiver_id || "n/a");
|
|
|
|
|
const receiverId = escapeHtml(receiver?.receiver_id || "н/д");
|
|
|
|
|
const sourceUrl = escapeHtml(receiver?.source_url || "-");
|
|
|
|
|
const center = receiver?.center || {};
|
|
|
|
|
const samples = Array.isArray(receiver?.samples) ? receiver.samples : [];
|
|
|
|
|
@ -741,7 +946,7 @@ function renderOutputFlow(data, delivery) {
|
|
|
|
|
return `
|
|
|
|
|
<article class="io-card">
|
|
|
|
|
<header class="io-card-head">
|
|
|
|
|
<h4>${escapeHtml(server?.name || "output")}</h4>
|
|
|
|
|
<h4>${escapeHtml(server?.name || "выход")}</h4>
|
|
|
|
|
<span class="io-chip ${statusCls}">${escapeHtml(status)}</span>
|
|
|
|
|
</header>
|
|
|
|
|
<div class="io-meta-grid">
|
|
|
|
|
@ -768,7 +973,7 @@ function buildIoHistoryRow(data, delivery) {
|
|
|
|
|
const rssiValues = [];
|
|
|
|
|
|
|
|
|
|
const inputItems = receivers.map((receiver) => {
|
|
|
|
|
const receiverId = String(receiver?.receiver_id || "n/a");
|
|
|
|
|
const receiverId = String(receiver?.receiver_id || "н/д");
|
|
|
|
|
const sample = findByFrequency(receiver?.samples, selectedHz) || receiver?.samples?.[0] || null;
|
|
|
|
|
const perFrequency =
|
|
|
|
|
findByFrequency(receiver?.per_frequency, selectedHz) || receiver?.per_frequency?.[0] || null;
|
|
|
|
|
@ -790,7 +995,7 @@ function buildIoHistoryRow(data, delivery) {
|
|
|
|
|
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 name = String(server?.name || "выход");
|
|
|
|
|
const status = localizeStatus(server?.status);
|
|
|
|
|
const code = server?.http_status ?? "-";
|
|
|
|
|
return `${name}: ${status} (${code})`;
|
|
|
|
|
@ -1108,13 +1313,13 @@ 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>';
|
|
|
|
|
root.innerHTML = '<div class="io-empty">Сигналы от приемников еще не получены.</div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
root.innerHTML = receivers
|
|
|
|
|
.map((receiver) => {
|
|
|
|
|
const receiverId = String(receiver?.receiver_id || "n/a");
|
|
|
|
|
const receiverId = String(receiver?.receiver_id || "н/д");
|
|
|
|
|
const sample = findByFrequency(receiver?.samples, selectedHz) || receiver?.samples?.[0] || null;
|
|
|
|
|
const row = findByFrequency(receiver?.per_frequency, selectedHz) || receiver?.per_frequency?.[0] || null;
|
|
|
|
|
|
|
|
|
|
@ -1188,28 +1393,28 @@ function renderOverviewPipeline(summary) {
|
|
|
|
|
|
|
|
|
|
const stages = [
|
|
|
|
|
{
|
|
|
|
|
name: "Сбор входов",
|
|
|
|
|
name: "Прием",
|
|
|
|
|
status: inputStatus,
|
|
|
|
|
value: `${summary.inputOnline}/${summary.inputTotal}`,
|
|
|
|
|
note: "доступность входных серверов",
|
|
|
|
|
note: "доступность приемников",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "Решение",
|
|
|
|
|
status: solveStatus,
|
|
|
|
|
value: hasData ? `RMSE ${fmt(summary.data?.rmse_m, 2)} м` : "ожидание данных",
|
|
|
|
|
note: "оценка точности пересечения сфер",
|
|
|
|
|
note: "расчет координат",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "Доставка",
|
|
|
|
|
name: "Отправка",
|
|
|
|
|
status: summary.delivery?.status || "warming_up",
|
|
|
|
|
value: `${summary.outputOnline}/${summary.outputTotal}`,
|
|
|
|
|
note: "доступность выходных серверов",
|
|
|
|
|
note: "доступность серверов отправки",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "История",
|
|
|
|
|
name: "Журнал",
|
|
|
|
|
status: summary.total > 0 ? (summary.success >= 80 ? "ok" : "partial") : "warming_up",
|
|
|
|
|
value: `${summary.success}%`,
|
|
|
|
|
note: "успешность доставки по накопленной ленте",
|
|
|
|
|
note: "успех по накопленным событиям",
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
@ -1225,7 +1430,7 @@ function renderOverviewPipeline(summary) {
|
|
|
|
|
<span class="io-chip ${statusClass(stage.status)}">${escapeHtml(localizeStatus(stage.status))}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="monitor-progress monitor-progress-accent">
|
|
|
|
|
<span style="width:${clamp(stage.name === "История" ? summary.success : stage.name === "Сбор входов" ? summary.inputOnlinePercent : stage.name === "Доставка" ? summary.outputOnlinePercent : summary.success, 0, 100)}%"></span>
|
|
|
|
|
<span style="width:${clamp(stage.name === "Журнал" ? summary.success : stage.name === "Прием" ? summary.inputOnlinePercent : stage.name === "Отправка" ? summary.outputOnlinePercent : summary.success, 0, 100)}%"></span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="pipeline-stage-note">${escapeHtml(stage.value)} • ${escapeHtml(stage.note)}</div>
|
|
|
|
|
</article>
|
|
|
|
|
@ -1343,9 +1548,9 @@ 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>${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>${escapeHtml(deliveryStatus)}</b></div>
|
|
|
|
|
<div class="monitor-row"><span>Входы онлайн</span><b>${summary.inputOnline}/${summary.inputTotal}</b></div>
|
|
|
|
|
<div class="monitor-row"><span>Выходы онлайн</span><b>${summary.outputOnline}/${summary.outputTotal}</b></div>
|
|
|
|
|
<div class="monitor-row"><span>Проблемных событий</span><b>${problemCount}</b></div>
|
|
|
|
|
<div class="monitor-row"><span>Успех доставки</span><b>${summary.success}%</b></div>
|
|
|
|
|
<div class="metric-track"><span style="width:${summary.success}%"></span></div>
|
|
|
|
|
@ -1470,7 +1675,7 @@ function renderIoHistory() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (filtered.length === 0) {
|
|
|
|
|
let text = "История пока пуста.";
|
|
|
|
|
let text = "Журнал пока пуст.";
|
|
|
|
|
if (state.ioHistory.length > 0) {
|
|
|
|
|
if (hasDateFilter && state.historyFilter !== "all") {
|
|
|
|
|
text = "По статусу и диапазону времени записей нет.";
|
|
|
|
|
@ -1532,7 +1737,7 @@ function boolStateLabel(value, trueLabel, falseLabel) {
|
|
|
|
|
|
|
|
|
|
function buildControlCardHtml(item, target) {
|
|
|
|
|
const id = String(item?.id || "");
|
|
|
|
|
const name = escapeHtml(item?.name || id || "n/a");
|
|
|
|
|
const name = escapeHtml(item?.name || id || "н/д");
|
|
|
|
|
const reachable = Boolean(item?.reachable);
|
|
|
|
|
const errorText = item?.error ? escapeHtml(item.error) : "";
|
|
|
|
|
const stateValue = target === "input" ? item?.enabled : item?.accept_writes;
|
|
|
|
|
@ -1554,7 +1759,7 @@ function buildControlCardHtml(item, target) {
|
|
|
|
|
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 reachabilityText = reachable ? "онлайн" : "офлайн";
|
|
|
|
|
const disabledAttr = !id ? "disabled" : "";
|
|
|
|
|
const liveFrequencies = Array.isArray(item?.frequencies_mhz)
|
|
|
|
|
? item.frequencies_mhz.map((value) => fmt(Number(value), 3)).join(", ")
|
|
|
|
|
@ -1634,26 +1839,26 @@ function renderErrorControls() {
|
|
|
|
|
|
|
|
|
|
const inputHtml =
|
|
|
|
|
inputs.length === 0
|
|
|
|
|
? '<div class="io-empty">Входные источники не обнаружены.</div>'
|
|
|
|
|
? '<div class="io-empty">Приемники не найдены.</div>'
|
|
|
|
|
: inputs.map((item) => buildControlCardHtml(item, "input")).join("");
|
|
|
|
|
|
|
|
|
|
const outputHtml =
|
|
|
|
|
outputs.length === 0
|
|
|
|
|
? '<div class="io-empty">Выходные серверы не обнаружены.</div>'
|
|
|
|
|
? '<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>
|
|
|
|
|
<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>
|
|
|
|
|
<h4>Отправка</h4>
|
|
|
|
|
<span class="io-chip io-chip-neutral">${outputActiveCount}/${outputs.length} активны</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="io-control-list">${outputHtml}</div>
|
|
|
|
|
@ -1761,7 +1966,7 @@ function addReceiverDraft() {
|
|
|
|
|
|
|
|
|
|
function removeReceiverDraft() {
|
|
|
|
|
if (state.receiverDrafts.length <= 3) {
|
|
|
|
|
byId("servers-state").textContent = "серверы: необходимо минимум 3 входа";
|
|
|
|
|
byId("servers-state").textContent = "узлы: нужно минимум 3 приемника";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state.receiverDrafts.splice(state.selectedReceiverIndex, 1);
|
|
|
|
|
@ -1833,7 +2038,7 @@ function addOutputDraft() {
|
|
|
|
|
|
|
|
|
|
function removeOutputDraft() {
|
|
|
|
|
if (state.outputDrafts.length <= 1) {
|
|
|
|
|
byId("servers-state").textContent = "серверы: необходим минимум 1 выход";
|
|
|
|
|
byId("servers-state").textContent = "узлы: нужен минимум 1 сервер отправки";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state.outputDrafts.splice(state.selectedOutputIndex, 1);
|
|
|
|
|
@ -1846,6 +2051,10 @@ async function getJson(url) {
|
|
|
|
|
const res = await fetch(url);
|
|
|
|
|
const data = await res.json().catch(() => ({}));
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
if (res.status === 401 && authEnabled() && url !== "/auth/session") {
|
|
|
|
|
state.auth = { ...(state.auth || {}), authenticated: false, username: "", role: "", visible_sections: [] };
|
|
|
|
|
renderAuthUi();
|
|
|
|
|
}
|
|
|
|
|
throw new Error(data.error || data.status || `HTTP ${res.status}`);
|
|
|
|
|
}
|
|
|
|
|
return data;
|
|
|
|
|
@ -1859,11 +2068,141 @@ async function postJson(url, payload) {
|
|
|
|
|
});
|
|
|
|
|
const data = await res.json().catch(() => ({}));
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
if (res.status === 401 && authEnabled() && url !== "/auth/login") {
|
|
|
|
|
state.auth = { ...(state.auth || {}), authenticated: false, username: "", role: "", visible_sections: [] };
|
|
|
|
|
renderAuthUi();
|
|
|
|
|
}
|
|
|
|
|
throw new Error(data.error || data.status || `HTTP ${res.status}`);
|
|
|
|
|
}
|
|
|
|
|
return data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearUserForm() {
|
|
|
|
|
const ids = [
|
|
|
|
|
"user-id",
|
|
|
|
|
"user-username",
|
|
|
|
|
"user-first-name",
|
|
|
|
|
"user-last-name",
|
|
|
|
|
"user-password",
|
|
|
|
|
];
|
|
|
|
|
ids.forEach((id) => {
|
|
|
|
|
const input = byId(id);
|
|
|
|
|
if (input) input.value = "";
|
|
|
|
|
});
|
|
|
|
|
const userRole = byId("user-role");
|
|
|
|
|
if (userRole) userRole.value = "user";
|
|
|
|
|
const userEnabled = byId("user-enabled");
|
|
|
|
|
if (userEnabled) userEnabled.value = "true";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fillUserForm(user) {
|
|
|
|
|
byId("user-id").value = user?.id || "";
|
|
|
|
|
byId("user-username").value = user?.username || "";
|
|
|
|
|
byId("user-first-name").value = user?.first_name || "";
|
|
|
|
|
byId("user-last-name").value = user?.last_name || "";
|
|
|
|
|
byId("user-role").value = user?.role || "user";
|
|
|
|
|
byId("user-enabled").value = String(Boolean(user?.enabled));
|
|
|
|
|
byId("user-password").value = "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderUsers() {
|
|
|
|
|
const tbody = byId("users-table")?.querySelector("tbody");
|
|
|
|
|
if (!tbody) return;
|
|
|
|
|
tbody.innerHTML = (state.users || [])
|
|
|
|
|
.map(
|
|
|
|
|
(user) => `
|
|
|
|
|
<tr class="user-row" data-user-id="${escapeHtml(user.id || "")}">
|
|
|
|
|
<td>${escapeHtml(user.username || "")}</td>
|
|
|
|
|
<td>${escapeHtml(user.role || "user")}</td>
|
|
|
|
|
<td>${user.enabled ? "включён" : "отключён"}</td>
|
|
|
|
|
<td>${escapeHtml([user.first_name, user.last_name].filter(Boolean).join(" ") || "-")}</td>
|
|
|
|
|
</tr>`
|
|
|
|
|
)
|
|
|
|
|
.join("");
|
|
|
|
|
tbody.querySelectorAll("tr").forEach((row) => {
|
|
|
|
|
row.addEventListener("click", () => {
|
|
|
|
|
const userId = row.dataset.userId;
|
|
|
|
|
const user = state.users.find((item) => String(item.id) === String(userId));
|
|
|
|
|
if (user) fillUserForm(user);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadUsers() {
|
|
|
|
|
if (!isAdmin()) return;
|
|
|
|
|
try {
|
|
|
|
|
const payload = await getJson("/users");
|
|
|
|
|
state.users = Array.isArray(payload.users) ? payload.users : [];
|
|
|
|
|
renderUsers();
|
|
|
|
|
byId("users-state").textContent = `пользователи: ${state.users.length}`;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
byId("users-state").textContent = `пользователи: ${localizeErrorMessage(err.message)}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function submitUserAction(action) {
|
|
|
|
|
if (!isAdmin()) return;
|
|
|
|
|
const payload = {
|
|
|
|
|
action,
|
|
|
|
|
user_id: byId("user-id").value.trim(),
|
|
|
|
|
username: byId("user-username").value.trim(),
|
|
|
|
|
first_name: byId("user-first-name").value.trim(),
|
|
|
|
|
last_name: byId("user-last-name").value.trim(),
|
|
|
|
|
role: byId("user-role").value,
|
|
|
|
|
enabled: byId("user-enabled").value === "true",
|
|
|
|
|
password: byId("user-password").value,
|
|
|
|
|
};
|
|
|
|
|
try {
|
|
|
|
|
const response = await postJson("/users", payload);
|
|
|
|
|
state.users = Array.isArray(response.users) ? response.users : state.users;
|
|
|
|
|
renderUsers();
|
|
|
|
|
byId("users-state").textContent = "пользователи: сохранено";
|
|
|
|
|
if (action !== "update") clearUserForm();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
byId("users-state").textContent = `пользователи: ${localizeErrorMessage(err.message)}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadAuthSession() {
|
|
|
|
|
const payload = await getJson("/auth/session");
|
|
|
|
|
state.auth = payload.auth || null;
|
|
|
|
|
renderAuthUi();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function login() {
|
|
|
|
|
const username = byId("login-username").value.trim();
|
|
|
|
|
const password = byId("login-password").value;
|
|
|
|
|
try {
|
|
|
|
|
const payload = await postJson("/auth/login", { username, password });
|
|
|
|
|
state.auth = payload.auth || null;
|
|
|
|
|
byId("login-password").value = "";
|
|
|
|
|
byId("login-state").textContent = "авторизация: выполнена";
|
|
|
|
|
renderAuthUi();
|
|
|
|
|
await loadAll();
|
|
|
|
|
if (isAdmin()) {
|
|
|
|
|
await loadConfig();
|
|
|
|
|
await loadUsers();
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
byId("login-state").textContent = `авторизация: ${localizeErrorMessage(err.message)}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function logout() {
|
|
|
|
|
try {
|
|
|
|
|
await postJson("/auth/logout", {});
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore local logout errors and still reset the client state.
|
|
|
|
|
}
|
|
|
|
|
state.auth = authEnabled()
|
|
|
|
|
? { ...(state.auth || {}), authenticated: false, username: "", role: "", visible_sections: [] }
|
|
|
|
|
: null;
|
|
|
|
|
state.config = null;
|
|
|
|
|
state.users = [];
|
|
|
|
|
renderUsers();
|
|
|
|
|
renderAuthUi();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function render() {
|
|
|
|
|
const data = state.result?.data;
|
|
|
|
|
const delivery = state.result?.output_delivery || state.frequencies?.output_delivery;
|
|
|
|
|
@ -1920,39 +2259,47 @@ function render() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
const requests = [getJson("/health")];
|
|
|
|
|
if (isAuthenticated()) {
|
|
|
|
|
requests.push(getJson("/result"));
|
|
|
|
|
requests.push(getJson("/frequencies"));
|
|
|
|
|
requests.push(getJson("/mock/controls"));
|
|
|
|
|
}
|
|
|
|
|
const results = await Promise.allSettled(requests);
|
|
|
|
|
state.health = results[0]?.status === "fulfilled" ? results[0].value : { status: "error" };
|
|
|
|
|
state.result = results[1]?.status === "fulfilled" ? results[1].value : null;
|
|
|
|
|
state.frequencies = results[2]?.status === "fulfilled" ? results[2].value : null;
|
|
|
|
|
state.mockControls = results[3]?.status === "fulfilled" ? results[3].value : null;
|
|
|
|
|
render();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function refreshNow() {
|
|
|
|
|
if (!isAdmin()) {
|
|
|
|
|
await loadAll();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await postJson("/refresh", {});
|
|
|
|
|
await loadAll();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadConfig() {
|
|
|
|
|
if (!isAdmin()) return;
|
|
|
|
|
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 = "серверы: загружены";
|
|
|
|
|
byId("servers-state").textContent = "узлы: загружены";
|
|
|
|
|
applyServerReadOnlyMode();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
byId("config-state").textContent = `конфиг: ${localizeErrorMessage(err.message)}`;
|
|
|
|
|
byId("servers-state").textContent = `серверы: ${localizeErrorMessage(err.message)}`;
|
|
|
|
|
byId("servers-state").textContent = `узлы: ${localizeErrorMessage(err.message)}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveConfig() {
|
|
|
|
|
if (!isAdmin()) return;
|
|
|
|
|
const raw = byId("config-editor").value.trim();
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(raw);
|
|
|
|
|
@ -2005,6 +2352,7 @@ function fillServerForm() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveServers() {
|
|
|
|
|
if (!isAdmin()) return;
|
|
|
|
|
try {
|
|
|
|
|
if (!state.config) {
|
|
|
|
|
await loadConfig();
|
|
|
|
|
@ -2064,19 +2412,36 @@ async function saveServers() {
|
|
|
|
|
? result.mock_input_frequency_sync_errors.filter((item) => String(item || "").trim() !== "")
|
|
|
|
|
: [];
|
|
|
|
|
byId("servers-state").textContent = result.restart_required
|
|
|
|
|
? `серверы: сохранены, требуется перезапуск${saveSuffix}`
|
|
|
|
|
: `серверы: сохранены${saveSuffix}`;
|
|
|
|
|
? `узлы: сохранены, требуется перезапуск${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)}`;
|
|
|
|
|
byId("servers-state").textContent = `узлы: ${localizeErrorMessage(err.message)}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function bindUi() {
|
|
|
|
|
const loginSubmit = byId("login-submit");
|
|
|
|
|
if (loginSubmit) {
|
|
|
|
|
loginSubmit.addEventListener("click", login);
|
|
|
|
|
}
|
|
|
|
|
const loginPassword = byId("login-password");
|
|
|
|
|
if (loginPassword) {
|
|
|
|
|
loginPassword.addEventListener("keydown", (event) => {
|
|
|
|
|
if (event.key === "Enter") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
login();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const logoutButton = byId("logout-button");
|
|
|
|
|
if (logoutButton) {
|
|
|
|
|
logoutButton.addEventListener("click", logout);
|
|
|
|
|
}
|
|
|
|
|
byId("refresh-now").addEventListener("click", refreshNow);
|
|
|
|
|
const timezoneSelect = byId("timezone-select");
|
|
|
|
|
if (timezoneSelect) {
|
|
|
|
|
@ -2103,6 +2468,20 @@ function bindUi() {
|
|
|
|
|
byId("save-config").addEventListener("click", saveConfig);
|
|
|
|
|
byId("load-servers").addEventListener("click", loadConfig);
|
|
|
|
|
byId("save-servers").addEventListener("click", saveServers);
|
|
|
|
|
const loadUsersButton = byId("load-users");
|
|
|
|
|
if (loadUsersButton) loadUsersButton.addEventListener("click", loadUsers);
|
|
|
|
|
const createUserButton = byId("create-user");
|
|
|
|
|
if (createUserButton) createUserButton.addEventListener("click", () => submitUserAction("create"));
|
|
|
|
|
const updateUserButton = byId("update-user");
|
|
|
|
|
if (updateUserButton) updateUserButton.addEventListener("click", () => submitUserAction("update"));
|
|
|
|
|
const resetPasswordButton = byId("reset-user-password");
|
|
|
|
|
if (resetPasswordButton) {
|
|
|
|
|
resetPasswordButton.addEventListener("click", () => submitUserAction("set_password"));
|
|
|
|
|
}
|
|
|
|
|
const deleteUserButton = byId("delete-user");
|
|
|
|
|
if (deleteUserButton) deleteUserButton.addEventListener("click", () => submitUserAction("delete"));
|
|
|
|
|
const clearUserFormButton = byId("clear-user-form");
|
|
|
|
|
if (clearUserFormButton) clearUserFormButton.addEventListener("click", clearUserForm);
|
|
|
|
|
|
|
|
|
|
byId("add-receiver").addEventListener("click", addReceiverDraft);
|
|
|
|
|
byId("remove-receiver").addEventListener("click", removeReceiverDraft);
|
|
|
|
|
@ -2124,9 +2503,12 @@ function bindUi() {
|
|
|
|
|
state.writeToken = event.target.value;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
byId("menu-toggle").addEventListener("click", () => {
|
|
|
|
|
const menuToggle = byId("menu-toggle");
|
|
|
|
|
if (menuToggle) {
|
|
|
|
|
menuToggle.addEventListener("click", () => {
|
|
|
|
|
setMenuCollapsed(!state.menuCollapsed);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll(".menu-group-toggle").forEach((toggle) => {
|
|
|
|
|
toggle.addEventListener("click", () => {
|
|
|
|
|
@ -2158,6 +2540,15 @@ function bindUi() {
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll(".theme-chip").forEach((button) => {
|
|
|
|
|
button.addEventListener("click", () => {
|
|
|
|
|
const nextTheme = normalizeUiTheme(button.dataset.themeValue);
|
|
|
|
|
if (nextTheme === state.uiTheme) return;
|
|
|
|
|
setUiTheme(nextTheme, { persist: true });
|
|
|
|
|
showToast(`Тема интерфейса: ${localizeThemeName(nextTheme)}.`, "info");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const historyFilter = byId("history-filter");
|
|
|
|
|
if (historyFilter) {
|
|
|
|
|
historyFilter.addEventListener("change", (event) => {
|
|
|
|
|
@ -2263,13 +2654,20 @@ async function boot() {
|
|
|
|
|
state.menuGroupCollapsed = readMenuGroupCollapsed();
|
|
|
|
|
applyAllMenuGroupsCollapsed();
|
|
|
|
|
setUiDensity(readUiDensityPreference(), { persist: false });
|
|
|
|
|
setUiTheme(readUiThemePreference(), { persist: false });
|
|
|
|
|
setPollIntervalMs(readPollIntervalPreference(), { persist: false, restartPolling: false });
|
|
|
|
|
updateHistoryRecordingUi();
|
|
|
|
|
setMenuCollapsed(readMenuCollapsed());
|
|
|
|
|
setDateTimeCollapsed(readDateTimeCollapsed());
|
|
|
|
|
updateRefreshUi();
|
|
|
|
|
await loadAuthSession();
|
|
|
|
|
setActiveSection(state.activeSection);
|
|
|
|
|
if (isAdmin()) {
|
|
|
|
|
await loadConfig();
|
|
|
|
|
}
|
|
|
|
|
if (isAdmin()) {
|
|
|
|
|
await loadUsers();
|
|
|
|
|
}
|
|
|
|
|
await loadAll();
|
|
|
|
|
startPolling();
|
|
|
|
|
}
|
|
|
|
|
|