Files

5448 lines
207 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
openAdminDbBrowser,
refreshAdminDbGrid,
refreshAdminDbTree,
wireAdminDbBrowserModal,
} from "./admin-db-browser.js";
import { mountWespLogoLoader } from "/static/js/wesp-logo-loader.js?v=admin-kiosk-setup";
const GAUGE_RADIUS = 47;
const GAUGE_CIRC = 2 * Math.PI * GAUGE_RADIUS;
const ADMIN_SIDEBAR_COLLAPSED_KEY = "wespAdminSidebarCollapsed";
const ADMIN_ACTIVITY_LOG_KEY = "wesp_admin_activity_v1";
/** История LLM-чата в админке: переживает F5 в той же вкладке. */
const WESP_ASSISTANT_CHAT_STORAGE_KEY = "wespAdminAssistantChatV1";
const ADMIN_ACTIVITY_RETENTION_MS = 3 * 24 * 60 * 60 * 1000;
const ADMIN_ACTIVITY_MAX_ENTRIES = 400;
/** Кэш GET /api/admin/activity-feed (JSONL + wesp.log), обновляется при hydrate и «Обновить». */
let activityServerCache = [];
const ADMIN_SHELL_MIN_MS = 400;
const ADMIN_SKEL_MIN_VISIBLE_MS = 650;
const diagState = {
report: null,
extraTargets: [],
};
function _blockHasSkelOverlay(el) {
for (let i = 0; i < el.children.length; i += 1) {
if (el.children[i].classList.contains("wesp-admin-skel-overlay")) return true;
}
return false;
}
function prepareAdminSkeletonBlocks() {
const app = document.getElementById("app");
if (!app) return;
const seen = new Set();
const add = (el) => {
if (!el || seen.has(el)) return;
seen.add(el);
el.classList.add("wesp-admin-block");
if (_blockHasSkelOverlay(el)) return;
const ov = document.createElement("div");
ov.className = "wesp-admin-skel-overlay";
ov.setAttribute("aria-hidden", "true");
el.appendChild(ov);
};
add(app.querySelector(".wesp-admin-sider"));
add(app.querySelector("main.ant-layout-content .wesp-admin-content-topbar"));
app.querySelectorAll("main.ant-layout-content .ant-card").forEach(add);
}
function finishAdminShellReveal(shellStart) {
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const armedAt =
typeof window.__WESP_ADMIN_SKEL_ARMED_AT__ === "number"
? window.__WESP_ADMIN_SKEL_ARMED_AT__
: shellStart;
const elapsed = performance.now() - shellStart;
const sinceArmed = performance.now() - armedAt;
const wait = reduced
? 0
: Math.max(
0,
ADMIN_SHELL_MIN_MS - elapsed,
ADMIN_SKEL_MIN_VISIBLE_MS - sinceArmed,
);
return new Promise((resolve) => {
window.setTimeout(() => {
document.querySelectorAll(".wesp-admin-block").forEach((el, i) => {
el.style.setProperty("--wesp-reveal-i", String(i));
});
document.documentElement.classList.remove("wesp-admin-loading");
document.documentElement.classList.add("wesp-admin-loaded");
window.setTimeout(() => {
document.querySelectorAll(".wesp-admin-skel-overlay").forEach((o) => o.remove());
resolve();
}, reduced ? 80 : 450);
}, wait);
});
}
function stripAdminShellDecorations() {
document.documentElement.classList.remove("wesp-admin-loading");
document.querySelectorAll(".wesp-admin-skel-overlay").forEach((o) => o.remove());
}
function isAdminNarrowViewport() {
return window.matchMedia("(max-width: 768px)").matches;
}
function applyAdminSidebarCollapsed(collapsed) {
const sider = document.getElementById("wespAdminSider");
const trigger = document.getElementById("wespAdminSiderTrigger");
if (!sider || !trigger) return;
if (isAdminNarrowViewport()) collapsed = false;
sider.classList.toggle("ant-layout-sider-collapsed", collapsed);
trigger.setAttribute("aria-expanded", collapsed ? "false" : "true");
trigger.title = collapsed ? "Развернуть меню" : "Свернуть меню";
const icon = trigger.querySelector(".wesp-sider-trigger-icon");
if (icon) icon.textContent = collapsed ? "▶" : "◀";
}
function readSidebarCollapsedPreference() {
/* По умолчанию узкий сайдбар; явно «0» в localStorage — развёрнут. */
return localStorage.getItem(ADMIN_SIDEBAR_COLLAPSED_KEY) !== "0";
}
function initAdminSidebar() {
const sider = document.getElementById("wespAdminSider");
const trigger = document.getElementById("wespAdminSiderTrigger");
if (!sider || !trigger) return;
applyAdminSidebarCollapsed(readSidebarCollapsedPreference());
trigger.addEventListener("click", () => {
if (isAdminNarrowViewport()) return;
const next = !sider.classList.contains("ant-layout-sider-collapsed");
applyAdminSidebarCollapsed(next);
if (next) localStorage.setItem(ADMIN_SIDEBAR_COLLAPSED_KEY, "1");
else localStorage.setItem(ADMIN_SIDEBAR_COLLAPSED_KEY, "0");
});
trigger.addEventListener("keydown", (e) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
trigger.click();
});
window.addEventListener("resize", () => {
if (isAdminNarrowViewport()) {
applyAdminSidebarCollapsed(false);
} else {
applyAdminSidebarCollapsed(readSidebarCollapsedPreference());
}
});
}
/** Админка всегда в ultra-dark; zootech-тема из localStorage не используется. */
function applyAdminDocumentTheme() {
const root = document.documentElement;
root.setAttribute("data-theme", "ultra-dark");
root.classList.add("dark");
root.classList.remove("light");
}
function formatJson(data) {
return JSON.stringify(data, null, 2);
}
function escHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function defaultAssistantDateRange() {
const to = new Date();
const from = new Date(to);
from.setDate(from.getDate() - 7);
return { from, to };
}
function formatDateInputYmd(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function _assistantChatEscapeHtml(text) {
const d = document.createElement("div");
d.textContent = text == null ? "" : String(text);
return d.innerHTML;
}
/** Сообщения для API: без служебного «ожидания» и без system (его добавляет сервер). */
function assistantChatMessagesForApi() {
const msgs = window.__wespAssistantChatMessages || [];
return msgs.filter((m) => !m.pending && (m.role === "user" || m.role === "assistant"));
}
function persistAssistantChatMessages() {
try {
const msgs = window.__wespAssistantChatMessages || [];
const toSave = msgs.filter((m) => !m.pending);
sessionStorage.setItem(WESP_ASSISTANT_CHAT_STORAGE_KEY, JSON.stringify(toSave));
} catch {
/* quota / private mode */
}
}
function hydrateAssistantChatMessagesFromStorage() {
try {
const raw = sessionStorage.getItem(WESP_ASSISTANT_CHAT_STORAGE_KEY);
if (!raw) {
if (!window.__wespAssistantChatMessages) window.__wespAssistantChatMessages = [];
return;
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
window.__wespAssistantChatMessages = [];
return;
}
window.__wespAssistantChatMessages = parsed.filter(
(m) => m && (m.role === "user" || m.role === "assistant") && !m.pending,
);
} catch {
window.__wespAssistantChatMessages = [];
}
}
function renderAssistantChatLog() {
const log = document.getElementById("wespAssistantChatLog");
if (!log) return;
const msgs = window.__wespAssistantChatMessages || [];
const parts = [];
for (let i = 0; i < msgs.length; i += 1) {
const m = msgs[i];
const role = m.role;
if (role !== "user" && role !== "assistant") continue;
const label = role === "user" ? "Вы" : "Модель";
const cls = role === "user" ? "wesp-assistant-msg-user" : "wesp-assistant-msg-assistant";
const pendingCls = m.pending ? " wesp-assistant-msg--pending" : "";
const contentHtml = m.pending
? `<span class="wesp-assistant-pending">${_assistantChatEscapeHtml(m.content)}</span>`
: _assistantChatEscapeHtml(m.content);
parts.push(
`<div class="${cls}${pendingCls}" style="margin-bottom:10px;padding:8px;border-radius:8px;background:rgba(128,128,128,0.08);"><strong>${label}</strong><div style="margin-top:4px;">${contentHtml}</div></div>`,
);
}
log.innerHTML = parts.join("");
log.scrollTop = log.scrollHeight;
persistAssistantChatMessages();
}
const WESP_LLM_EVENT_LABELS = {
ping: "ping",
summary: "сводка",
chat: "чат",
settings: "настройки",
diagnostics: "диагностика",
status: "статус",
};
function setAssistantInteractiveEnabled(enabled) {
const ids = [
"wespAssistantChatInput",
"wespAssistantChatSendBtn",
"wespAssistantPingBtn",
"wespAssistantSummaryBtn",
"wespAssistantDiagnosticsBtn",
"wespAssistantSqlMode",
"wespAssistantToolTraceCb",
"wespAssistantDateFrom",
"wespAssistantDateTo",
"wespAssistantFocus",
];
for (const id of ids) {
const el = document.getElementById(id);
if (!el) continue;
if (el instanceof HTMLInputElement || el instanceof HTMLButtonElement || el instanceof HTMLSelectElement) {
el.disabled = !enabled;
}
}
const section = document.getElementById("wespAssistantChatSectionTitle");
if (section) {
section.style.opacity = enabled ? "1" : "0.55";
}
}
function renderAssistantLlmLog(entries) {
const wrap = document.getElementById("wespAssistantLlmLog");
if (!wrap) return;
const rows = Array.isArray(entries) ? entries : [];
if (!rows.length) {
wrap.innerHTML =
'<div class="wesp-admin-activity-line wesp-admin-activity-line--empty">// журнал пуст (нет вызовов за окно хранения)</div>';
return;
}
const sorted = [...rows].sort((a, b) => Number(a.u || 0) - Number(b.u || 0));
wrap.innerHTML = sorted
.map((e) => {
let lvl = "OK";
let cls = "wesp-admin-activity-line--ok";
const level = String(e.level || "ok");
if (level === "err") {
lvl = "ERR";
cls = "wesp-admin-activity-line--err";
} else if (level === "warn") {
lvl = "WRN";
cls = "wesp-admin-activity-line--warn";
}
const ev = WESP_LLM_EVENT_LABELS[String(e.event || "")] || String(e.event || "llm");
const dur =
typeof e.duration_ms === "number" && e.duration_ms >= 0 ? ` · ${Math.round(e.duration_ms)} ms` : "";
const user = String(e.user || "").trim();
const userBit = user ? ` · ${user}` : "";
const detail = String(e.detail || "").trim();
const msg = detail ? `${ev}: ${detail}${dur}${userBit}` : `${ev}${dur}${userBit}`;
const tsMs = Number(e.u || 0) * 1000;
return `<div class="wesp-admin-activity-line ${cls}" role="listitem"><span class="wesp-admin-activity-ts">[${escHtml(formatActivityTs(tsMs))}]</span><span class="wesp-admin-activity-lvl">${lvl}</span><span class="wesp-admin-activity-msg">${escHtml(msg)}</span></div>`;
})
.join("");
wrap.scrollTop = wrap.scrollHeight;
}
async function loadAdminAssistantLlmLog() {
const badge = document.getElementById("wespAssistantLlmLogBadge");
const r = await fetch("/api/admin/llm/activity", { credentials: "same-origin" });
const data = await r.json().catch(() => ({}));
if (!r.ok || data.status !== "success") {
renderAssistantLlmLog([]);
if (badge) badge.textContent = "ошибка загрузки";
return;
}
const days = Number(data.retention_days) || 7;
if (badge) badge.textContent = `${days}d · JSONL`;
renderAssistantLlmLog(data.entries || []);
}
async function loadAdminAssistantStatus() {
const badge = document.getElementById("wespAssistantStatusBadge");
const hint = document.getElementById("wespAssistantLlmUrlHint");
const r = await fetch("/api/admin/llm/status", { credentials: "same-origin" });
const data = await r.json().catch(() => ({}));
if (!r.ok) {
if (badge) {
badge.textContent = "ошибка API";
badge.className = "ant-tag ant-tag-red";
}
return;
}
const p = data;
if (hint && p.llm_base_url) {
hint.textContent = String(p.llm_base_url).replace(/^https?:\/\//, "");
}
const adminLlmCb = document.getElementById("wespAssistantAdminLlmEnabled");
const adminLlmHint = document.getElementById("wespAssistantAdminLlmEnabledHint");
if (adminLlmCb) {
adminLlmCb.checked = p.enabled !== false;
adminLlmCb.disabled = !!p.admin_llm_enabled_locked_by_env;
if (adminLlmHint) {
if (p.admin_llm_enabled_locked_by_env) {
adminLlmHint.style.display = "block";
adminLlmHint.textContent =
"Значение задано в окружении (WESP_ADMIN_LLM_ENABLED). Снимите переменную, чтобы менять галку здесь.";
} else {
adminLlmHint.style.display = "none";
adminLlmHint.textContent = "";
}
}
}
const autostartCb = document.getElementById("wespAssistantLlmAutostart");
const autostartHint = document.getElementById("wespAssistantLlmAutostartHint");
if (autostartCb) {
autostartCb.checked = p.llm_autostart !== false;
autostartCb.disabled = !!p.llm_autostart_locked_by_env;
if (autostartHint) {
if (p.llm_autostart_locked_by_env) {
autostartHint.style.display = "block";
autostartHint.textContent =
"Значение задано в окружении (WESP_LLM_AUTOSTART). Снимите переменную, чтобы менять галку здесь.";
} else {
autostartHint.style.display = "none";
autostartHint.textContent = "";
}
}
}
const chatDbCb = document.getElementById("wespAssistantChatDbContext");
const chatDbHint = document.getElementById("wespAssistantChatDbContextHint");
if (chatDbCb) {
chatDbCb.checked = p.llm_chat_db_context === true;
chatDbCb.disabled = !!p.llm_chat_db_context_locked_by_env;
if (chatDbHint) {
if (p.llm_chat_db_context_locked_by_env) {
chatDbHint.style.display = "block";
chatDbHint.textContent =
"Значение задано в окружении (WESP_LLM_CHAT_INCLUDE_DB_CONTEXT). Снимите переменную, чтобы менять галку здесь.";
} else {
chatDbHint.style.display = "none";
chatDbHint.textContent = "";
}
}
}
const toolsCb = document.getElementById("wespAssistantLlmToolsEnabled");
const toolsHint = document.getElementById("wespAssistantLlmToolsEnabledHint");
if (toolsCb) {
toolsCb.checked = p.llm_tools_enabled !== false;
toolsCb.disabled = !!p.llm_tools_enabled_locked_by_env;
if (toolsHint) {
if (p.llm_tools_enabled_locked_by_env) {
toolsHint.style.display = "block";
toolsHint.textContent =
"Значение задано в окружении (WESP_LLM_TOOLS_ENABLED). Снимите переменную, чтобы менять галку здесь.";
} else {
toolsHint.style.display = "none";
toolsHint.textContent = "";
}
}
}
setAssistantInteractiveEnabled(p.enabled !== false);
if (!badge) return;
if (!p.enabled) {
badge.textContent = "ИИ выкл.";
badge.className = "ant-tag";
return;
}
if (!p.llm_reachable) {
badge.textContent = "LLM недоступен";
badge.className = "ant-tag ant-tag-red";
return;
}
if (!p.model_present) {
badge.textContent = "модель не найдена";
badge.className = "ant-tag";
return;
}
badge.textContent = "OK";
badge.className = "ant-tag ant-tag-green";
}
async function runAdminAssistantChatSend() {
const input = document.getElementById("wespAssistantChatInput");
const sendBtn = document.getElementById("wespAssistantChatSendBtn");
const text = (input?.value || "").trim();
if (!text) return;
if (!window.__wespAssistantChatMessages) window.__wespAssistantChatMessages = [];
if (sendBtn?.dataset.busy === "1") return;
input.value = "";
window.__wespAssistantChatMessages.push({ role: "user", content: text });
window.__wespAssistantChatMessages.push({
role: "assistant",
content: "Генерируется ответ…",
pending: true,
});
renderAssistantChatLog();
if (sendBtn) {
sendBtn.dataset.busy = "1";
sendBtn.disabled = true;
}
try {
const toolsEl = document.getElementById("wespAssistantLlmToolsEnabled");
const toolsEnabled = !!(toolsEl && toolsEl instanceof HTMLInputElement && toolsEl.checked);
const sqlModeEl = document.getElementById("wespAssistantSqlMode");
const sqlMode =
sqlModeEl && sqlModeEl instanceof HTMLSelectElement ? String(sqlModeEl.value || "preview") : "preview";
const traceCb = document.getElementById("wespAssistantToolTraceCb");
const toolTrace = !(traceCb && traceCb instanceof HTMLInputElement && !traceCb.checked);
const tracePre = document.getElementById("wespAssistantToolTrace");
const r = await fetch("/api/admin/llm/chat", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: assistantChatMessagesForApi(),
sql_mode: sqlMode,
tools_enabled: toolsEnabled,
tool_trace: toolTrace,
}),
});
const data = await r.json().catch(() => ({}));
const last = window.__wespAssistantChatMessages.length - 1;
if (window.__wespAssistantChatMessages[last]?.pending) {
window.__wespAssistantChatMessages.pop();
}
if (!r.ok) {
const errText = data.message || data.detail || String(r.status);
window.__wespAssistantChatMessages.push({
role: "assistant",
content: `Ошибка: ${errText}`,
});
renderAssistantChatLog();
setStatus(errText, true);
if (tracePre) tracePre.textContent = "—";
return;
}
window.__wespAssistantChatMessages.push({
role: "assistant",
content: data.reply || "",
});
renderAssistantChatLog();
if (tracePre) {
if (Array.isArray(data.tool_trace) && data.tool_trace.length) {
tracePre.textContent = formatJson(data.tool_trace);
} else {
tracePre.textContent = "—";
}
}
setStatus("Чат: OK");
loadAdminAssistantLlmLog().catch(() => {});
} catch (err) {
const last = window.__wespAssistantChatMessages.length - 1;
if (window.__wespAssistantChatMessages[last]?.pending) {
window.__wespAssistantChatMessages.pop();
}
const msg = err?.message || "Ошибка сети или сервера";
window.__wespAssistantChatMessages.push({
role: "assistant",
content: `Ошибка: ${msg}`,
});
renderAssistantChatLog();
const tracePreErr = document.getElementById("wespAssistantToolTrace");
if (tracePreErr) tracePreErr.textContent = "—";
setStatus(msg, true);
} finally {
if (sendBtn) {
delete sendBtn.dataset.busy;
const master = document.getElementById("wespAssistantAdminLlmEnabled");
const llmOn = !(master instanceof HTMLInputElement) || master.checked;
sendBtn.disabled = !llmOn;
}
}
}
async function runAdminAssistantPing() {
const out = document.getElementById("wespAssistantOutput");
if (out) out.textContent = "…";
const r = await fetch("/api/admin/llm/ping", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
if (out) out.textContent = userHint(data.message || String(r.status), "Не удалось выполнить операцию");
setStatus(data.message, true, "llm/ping failed");
return;
}
if (out) out.textContent = data.reply || "";
setStatus("llm/ping: OK");
loadAdminAssistantLlmLog().catch(() => {});
}
async function runAdminAssistantDiagnostics() {
const out = document.getElementById("wespAssistantOutput");
if (out) out.textContent = "Загрузка…";
const r = await fetch("/api/admin/llm/diagnostics", {
credentials: "same-origin",
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
if (out) out.textContent = userHint(data.message || String(r.status), "Не удалось выполнить операцию");
setStatus(data.message, true, "diagnostics failed");
return;
}
const bundle = {
diagnostics: data.diagnostics,
db_health: data.db_health,
inventory: data.inventory,
text_for_llm: data.text_for_llm,
};
if (out) out.textContent = formatJson(bundle);
setStatus("Диагностика загружена");
}
async function runAdminAssistantSummary() {
const out = document.getElementById("wespAssistantOutput");
const df = document.getElementById("wespAssistantDateFrom");
const dt = document.getElementById("wespAssistantDateTo");
const focusEl = document.getElementById("wespAssistantFocus");
if (!df?.value || !dt?.value) {
setStatus("Укажите период (даты «с» и «по»)", true);
return;
}
if (out) out.textContent = "…";
const r = await fetch("/api/admin/llm/summary", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
date_from: df.value,
date_to: dt.value,
focus: focusEl?.value || "both",
}),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
if (out) out.textContent = userHint(data.message || String(r.status), "Не удалось выполнить операцию");
setStatus(data.message, true, "llm/summary failed");
return;
}
if (out) out.textContent = data.reply || "";
setStatus("llm/summary: OK");
loadAdminAssistantLlmLog().catch(() => {});
}
function formatActivityTs(ts) {
const d = new Date(ts);
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function loadAdminActivityLog() {
try {
const raw = localStorage.getItem(ADMIN_ACTIVITY_LOG_KEY);
if (!raw) return [];
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
function saveAdminActivityLog(entries) {
try {
localStorage.setItem(ADMIN_ACTIVITY_LOG_KEY, JSON.stringify(entries));
} catch {
/* quota / private mode */
}
}
function pruneAdminActivityLog(entries) {
const cutoff = Date.now() - ADMIN_ACTIVITY_RETENTION_MS;
return entries.filter(
(e) => e && typeof e.ts === "number" && e.ts >= cutoff && typeof e.text === "string" && e.text.trim(),
);
}
function renderAdminActivityTerminal(entries) {
const wrap = document.getElementById("adminActivityLog");
if (!wrap) return;
const sorted = [...entries].sort((a, b) => a.ts - b.ts);
if (!sorted.length) {
wrap.innerHTML =
'<div class="wesp-admin-activity-line wesp-admin-activity-line--empty">// no events in retention window</div>';
return;
}
wrap.innerHTML = sorted
.map((e) => {
let lvl = "OK";
let cls = "wesp-admin-activity-line--ok";
if (e.level === "err") {
lvl = "ERR";
cls = "wesp-admin-activity-line--err";
} else if (e.level === "warn") {
lvl = "WRN";
cls = "wesp-admin-activity-line--warn";
}
return `<div class="wesp-admin-activity-line ${cls}" role="listitem"><span class="wesp-admin-activity-ts">[${escHtml(formatActivityTs(e.ts))}]</span><span class="wesp-admin-activity-lvl">${lvl}</span><span class="wesp-admin-activity-msg">${escHtml(e.text)}</span></div>`;
})
.join("");
wrap.scrollTop = wrap.scrollHeight;
}
async function refreshActivityServerCache() {
try {
const r = await fetch("/api/admin/activity-feed", { credentials: "same-origin" });
const d = await r.json().catch(() => ({}));
if (d.status === "success" && Array.isArray(d.entries)) {
activityServerCache = d.entries.map((e) => ({
ts: Number(e.ts) || 0,
level: e.level === "err" ? "err" : e.level === "warn" ? "warn" : "ok",
text: String(e.text || ""),
}));
}
} catch {
/* сеть / 401 */
}
}
function mergeActivityEntriesForDisplay() {
const local = pruneAdminActivityLog(loadAdminActivityLog());
const cutoff = Date.now() - ADMIN_ACTIVITY_RETENTION_MS;
const map = new Map();
const keyOf = (e) => `${e.level}|${String(e.text || "").slice(0, 160)}`;
for (const e of activityServerCache) {
if (!e || !String(e.text || "").trim()) continue;
if (e.ts < cutoff) continue;
map.set(keyOf(e), { ts: e.ts, level: e.level, text: e.text });
}
for (const e of local) {
if (!e || !String(e.text || "").trim()) continue;
if (e.ts < cutoff) continue;
const k = keyOf(e);
const prev = map.get(k);
if (!prev || prev.ts < e.ts) map.set(k, { ts: e.ts, level: e.level, text: e.text });
}
return Array.from(map.values()).sort((a, b) => a.ts - b.ts);
}
function renderAdminActivityTerminalFromMerged() {
renderAdminActivityTerminal(mergeActivityEntriesForDisplay());
}
async function hydrateAdminActivityLog() {
let entries = pruneAdminActivityLog(loadAdminActivityLog());
saveAdminActivityLog(entries);
await refreshActivityServerCache();
renderAdminActivityTerminalFromMerged();
}
function persistAdminUiActivityToServer(text, isError) {
fetch("/api/admin/ui-activity", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, level: isError ? "err" : "ok" }),
}).catch(() => {});
}
function userHint(message, fallback) {
return (
globalThis.WespUserMessages?.userFacingMessage?.(message, fallback) ||
fallback ||
"Не удалось выполнить операцию."
);
}
function setStatus(message, isError = false, fallback) {
const wrap = document.getElementById("adminActivityLog");
if (!wrap) return;
let text = message != null ? String(message).trim() : "";
if (isError) {
text =
globalThis.WespUserMessages?.userFacingMessage?.(
text,
fallback || "Не удалось выполнить операцию."
) || fallback || "Не удалось выполнить операцию.";
} else if (!text && fallback) {
text = String(fallback).trim();
}
let entries = pruneAdminActivityLog(loadAdminActivityLog());
if (text) {
entries.push({ ts: Date.now(), level: isError ? "err" : "ok", text });
while (entries.length > ADMIN_ACTIVITY_MAX_ENTRIES) entries.shift();
saveAdminActivityLog(entries);
persistAdminUiActivityToServer(text, Boolean(isError));
}
renderAdminActivityTerminalFromMerged();
}
/** Строка списка настроек (разметка Ant Design): слева заголовок и описание, справа значение или действия. */
function wespAdmListItem(title, extraHtml, descriptionText = null, opts = {}) {
const itemClass = opts.itemClass || "";
const dataAttrs = opts.dataAttrs || "";
const desc =
descriptionText != null && String(descriptionText).trim()
? `<div class="wesp-adm-list-desc">${escHtml(descriptionText)}</div>`
: "";
return `<li class="wesp-adm-list-item ${itemClass}" role="listitem" ${dataAttrs}>
<div class="wesp-adm-list-main">
<div class="wesp-adm-list-title">${escHtml(title)}</div>${desc}
</div>
<div class="wesp-adm-list-extra">${extraHtml}</div>
</li>`;
}
function wespAdmListSection(title) {
return `<li class="wesp-adm-list-item wesp-adm-list-section" role="separator"><div class="wesp-adm-list-section-text">${escHtml(title)}</div></li>`;
}
function wespAdmListEmpty(message) {
return `<li class="wesp-adm-list-item wesp-adm-list-empty" role="status"><div class="wesp-adm-list-empty-text">${escHtml(message)}</div></li>`;
}
function wespAdmListLoading(message = "Загрузка…") {
return `<li class="wesp-adm-list-item wesp-adm-list-empty wesp-adm-list-loading" role="status" aria-busy="true"><div class="wesp-adm-list-empty-text"><span class="wesp-inline-spinner" aria-hidden="true"></span><span>${escHtml(message)}</span></div></li>`;
}
/** Сводка security_checks: лаконично, без разговорных оборотов. */
function buildDiagSecurityOverviewRows(report) {
const checks = Array.isArray(report.security_checks) ? report.security_checks : [];
if (!checks.length) {
return [
wespAdmListItem(
"rollup",
diagOkBadge(false),
"security_checks: []. retry GET; app log.",
),
];
}
const failed = checks.filter((c) => c && c.ok === false);
if (!failed.length) {
return [
wespAdmListItem(
"rollup",
diagOkBadge(true),
`heuristic: n=${checks.length}; fail=0. detail: Security tab.`,
),
];
}
const lines = failed.slice(0, 5).map((c) => {
const t = (c.title || c.id || "check").trim();
const d = (c.detail || "").trim();
return d ? `${t}: ${d}` : t;
});
const tail = failed.length > 5 ? `; +${failed.length - 5} more` : "";
const body = `fail=${failed.length}/${checks.length}. ${lines.join("; ")}${tail}. registry: Security tab.`;
return [wespAdmListItem("rollup", diagOkBadge(false), body)];
}
/** Сводка периферии: классификация состояния, императивы минимальны. */
function buildDiagHardwareOverviewRows(hwData) {
const sim = Boolean(hwData.simulation_mode);
const gpioOk = Boolean(hwData.rpi_gpio_available);
const hxOk = Boolean(hwData.hx711_driver_ready);
const driversAbsent = Boolean(hwData.drivers_absent);
if (sim) {
return [
wespAdmListItem(
"rollup",
diagOkBadge(true),
"SIMULATION_MODE=1: SW model weight+GPIO; ADC/BCM idle. prod BCM: SIMULATION_MODE=0.",
),
];
}
if (driversAbsent) {
return [
wespAdmListItem(
"rollup",
diagOkBadge(false),
"SIMULATION_MODE=0; RPi.GPIO+HX711 ∅ (typ. x86). mass=0. dev: WESP_SIMULATION_MODE=1. RPi: deps, VDD, BCM cfg.",
),
];
}
if (gpioOk && hxOk) {
return [
wespAdmListItem(
"rollup",
diagOkBadge(true),
"RPi.GPIO+HX711: init OK. drift: wiring, VDD_ADC, gain; probe: Hardware tab.",
),
];
}
if (gpioOk && !hxOk) {
return [
wespAdmListItem(
"rollup",
diagOkBadge(false),
"HX711: init fail. check: DOUT/PD_SCK BCM, module VDD, hx711 pkg; WESP_HX711_DOUT_PIN, WESP_HX711_PD_SCK_PIN.",
),
];
}
if (!gpioOk && hxOk) {
return [
wespAdmListItem(
"rollup",
diagOkBadge(false),
"RPi.GPIO: missing or non-BCM. x86: SIMULATION_MODE=1; RPi: install RPi.GPIO.",
),
];
}
return [
wespAdmListItem(
"rollup",
diagOkBadge(false),
"peripheral: class unknown. src: Hardware tab, app log.",
),
];
}
function formatRuDateFromIso(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" });
}
function formatRuDateTimeFromIso(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return d.toLocaleString("ru-RU", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function ruConfigModeLabel(mode) {
const m = String(mode || "").toLowerCase();
if (m === "production" || m === "prod") return "production";
if (m === "testing" || m === "test") return "testing";
if (m === "development" || m === "dev") return "development";
return mode ? String(mode) : "—";
}
function ruSyncRoleLabel(role) {
const r = String(role || "").toLowerCase();
if (r === "server") return "Сервер";
if (r === "client") return "Клиент";
if (r === "other") return "Другое";
return role ? String(role) : "—";
}
function ruSyncClientStatus(s) {
const x = String(s || "").toLowerCase();
if (x === "active") return "Активен";
if (x === "disabled") return "Отключён";
if (x === "inactive") return "Неактивен";
return s ? String(s) : "—";
}
function formatWarrantyUntilCell(wu) {
if (!wu) return "—";
const p = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(wu));
if (p) {
const dt = new Date(Number(p[1]), Number(p[2]) - 1, Number(p[3]));
return dt.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" });
}
return formatRuDateFromIso(wu);
}
function formatWarrantyDaysCell(dr) {
if (dr == null || Number.isNaN(Number(dr))) return "—";
if (Number(dr) < 0) return "истекла";
return String(dr);
}
function formatBytes(n) {
if (n == null || Number.isNaN(n)) return "—";
const x = Number(n);
if (x <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let v = x;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i += 1;
}
const digits = i === 0 ? 0 : i === 1 ? 1 : 2;
return `${v.toFixed(digits)} ${units[i]}`;
}
function ruCpuCoresLabel(n) {
const x = Math.trunc(Number(n) || 0);
if (x <= 0) return "—";
const k100 = x % 100;
const k10 = x % 10;
if (k100 >= 11 && k100 <= 14) return `${x} ядер`;
if (k10 === 1) return `${x} ядро`;
if (k10 >= 2 && k10 <= 4) return `${x} ядра`;
return `${x} ядер`;
}
function machineOverviewRows(m) {
if (!m || typeof m !== "object") return [];
const cores = m.cpu_logical_cores;
const coresPart =
cores != null && Number(cores) > 0 ? ` · ${ruCpuCoresLabel(Number(cores))}` : "";
const cpuModel = (m.cpu_model && String(m.cpu_model).trim()) || "—";
const cpuLine =
cpuModel === "—" && !(cores != null && Number(cores) > 0) ? "—" : `${cpuModel}${coresPart}`;
let ram = "—";
if (m.ram_total_bytes != null) {
ram = `${formatBytes(m.ram_total_bytes)} (всего)`;
}
let disk = (m.root_disk_media && String(m.root_disk_media)) || "—";
if (m.root_disk_total_bytes != null) {
const u = formatBytes(m.root_disk_used_bytes);
const t = formatBytes(m.root_disk_total_bytes);
const p = m.root_disk_percent != null ? ` · ${m.root_disk_percent}%` : "";
disk = `${disk} · ${u} / ${t}${p}`;
}
return [
["ОС", m.os || "—"],
["CPU", cpuLine],
["GPU", m.gpu || "—"],
["RAM", ram],
["Root FS", disk],
];
}
const PERIPHERAL_EVENT_LABELS = {
hx711_read_error: "Ошибка чтения HX711",
hx711_recovered: "HX711 восстановлен",
hx711_not_initialized: "HX711 не инициализирован",
gpio_unavailable: "GPIO недоступен",
host_cpu_high: "Высокая загрузка CPU",
host_ram_high: "Высокая загрузка RAM",
host_disk_high: "Диск почти заполнен",
network_settings_saved: "Настройки сети",
mdns_started: "mDNS запущен",
mdns_stopped: "mDNS остановлен",
mdns_error: "Ошибка mDNS",
};
let hardwarePollTimer = null;
function stopHardwarePolling() {
if (hardwarePollTimer) {
window.clearInterval(hardwarePollTimer);
hardwarePollTimer = null;
}
}
function startHardwarePolling() {
stopHardwarePolling();
const tick = () => {
loadHardwareStatus().catch(() => {});
loadPeripheralEvents().catch(() => {});
};
tick();
hardwarePollTimer = window.setInterval(tick, 3000);
}
function peripheralEventTitle(code) {
const c = String(code || "");
return PERIPHERAL_EVENT_LABELS[c] || c || "событие";
}
function renderHardwareHostList(machine) {
const el = document.getElementById("hardwareHostList");
if (!el) return;
const rows = machineOverviewRows(machine);
if (!rows.length) {
el.innerHTML = wespAdmListEmpty("Нет данных о хосте");
return;
}
el.innerHTML =
wespAdmListSection("Хост") +
rows
.map(([k, v]) =>
wespAdmListItem(k, `<span class="wesp-adm-list-value">${escHtml(v)}</span>`),
)
.join("");
}
function renderHardwareHostAlerts(alerts) {
const el = document.getElementById("hardwareHostAlertsList");
if (!el) return;
const list = Array.isArray(alerts) ? alerts : [];
if (!list.length) {
el.innerHTML = "";
return;
}
el.innerHTML =
wespAdmListSection("Алерты хоста") +
list
.map((a) =>
wespAdmListItem(
a.label || a.code || "—",
diagOkBadge(Boolean(a.ok)),
`${a.percent != null ? `${a.percent}%` : "—"} · порог ${a.threshold != null ? `${a.threshold}%` : "—"}`,
),
)
.join("");
}
function renderHardwarePeriphList(periph) {
const el = document.getElementById("hardwarePeriphList");
if (!el) return;
const p = periph && typeof periph === "object" ? periph : {};
const sim = Boolean(p.simulation_mode);
const scaleHw = Boolean(p.scale_hardware_platform);
const gpioOk = Boolean(p.rpi_gpio_available);
const hxOk = Boolean(p.hx711_driver_ready);
const gpioBadge = sim || !scaleHw ? '<span class="wesp-sync-hint" style="font-size:11px">sim</span>' : diagOkBadge(gpioOk);
const hxBadge = sim ? '<span class="wesp-sync-hint" style="font-size:11px">sim</span>' : scaleHw ? diagOkBadge(hxOk) : '<span class="wesp-sync-hint" style="font-size:11px">—</span>';
el.innerHTML = [
wespAdmListSection("Периферия"),
wespAdmListItem(
"Режим",
sim
? '<span class="wesp-diag-ok">SIM</span>'
: scaleHw
? diagOkBadge(true)
: '<span class="wesp-sync-hint">DEV</span>',
sim
? "симуляция — HX711 не опрашивается"
: scaleHw
? "целевое железо (Linux ARM + HX711)"
: "не ARM — HX711 не опрашивается; включите симуляцию",
),
wespAdmListItem(
"GPIO / LED",
`${gpioBadge}<span class="wesp-adm-list-value" style="margin-left:8px">пин ${escHtml(String(p.gpio_led_pin ?? "—"))}</span>`,
gpioOk ? "RPi.GPIO доступен" : scaleHw ? "RPi.GPIO недоступен" : "не используется",
),
wespAdmListItem(
"HX711",
`${hxBadge}<span class="wesp-adm-list-value" style="margin-left:8px">DOUT ${escHtml(String(p.hx711_dout_pin ?? "—"))} · SCK ${escHtml(String(p.hx711_pd_sck_pin ?? "—"))}</span>`,
sim ? "не используется" : scaleHw ? (hxOk ? "драйвер инициализирован" : "драйвер не поднят") : "пропущен (не ARM)",
),
p.last_hx711_error && !sim
? wespAdmListItem(
"Последняя ошибка",
'<span class="wesp-diag-bad">!</span>',
escHtml(String(p.last_hx711_error)),
)
: "",
].join("");
}
function renderHardwareScalesList(scales) {
const el = document.getElementById("hardwareScalesList");
if (!el) return;
const s = scales && typeof scales === "object" ? scales : {};
if (!s.available) {
el.innerHTML =
wespAdmListSection("Весы (live)") +
wespAdmListItem(
"ScalesReader",
diagOkBadge(false),
escHtml(String(s.error || "недоступен")),
);
return;
}
const sim = Boolean(s.simulation_mode);
const skipped = Boolean(s.hx711_skipped);
const hxOk = Boolean(s.hx711_ok);
el.innerHTML = [
wespAdmListSection("Весы (live)"),
wespAdmListItem(
"Вес",
`<span class="wesp-adm-list-value">${escHtml(String(s.weight_kg ?? "—"))} кг</span>`,
sim ? "симуляция" : skipped ? "HX711 не опрашивается" : "с датчика",
),
wespAdmListItem(
"HX711",
sim ? '<span class="wesp-sync-hint">sim</span>' : skipped ? '<span class="wesp-sync-hint">—</span>' : diagOkBadge(hxOk),
sim ? "не используется" : skipped ? "включите симуляцию на этой платформе" : escHtml(String(s.hx711_error || "OK")),
),
wespAdmListItem(
"Калибровка",
`<span class="wesp-adm-list-value">counts/kg ${escHtml(String(s.counts_per_kg ?? "—"))} · tare ${escHtml(String(s.tare_raw ?? "—"))}</span>`,
),
wespAdmListItem(
"Сырьё",
`<span class="wesp-adm-list-value">n=${escHtml(String(s.raw_history_len ?? 0))}</span>`,
[
s.last_raw != null ? `последнее ${s.last_raw}` : null,
s.raw_min_recent != null && s.raw_max_recent != null
? `мин ${s.raw_min_recent} · макс ${s.raw_max_recent}`
: null,
s.error_streak ? `серия ошибок ${s.error_streak}` : null,
]
.filter(Boolean)
.join(" · ") || "—",
),
].join("");
}
const HW_SIM_SLIDER_MAX = 500;
let hwSimUiLock = 0;
let hwSimSliderTimer = null;
function withHwSimUiLock(fn) {
hwSimUiLock += 1;
try {
return fn();
} finally {
hwSimUiLock = Math.max(0, hwSimUiLock - 1);
}
}
function syncHardwareSimulationControls(scales, periph) {
if (hwSimUiLock > 0) return;
const s = scales && typeof scales === "object" ? scales : {};
const p = periph && typeof periph === "object" ? periph : {};
const sim = Boolean(s.simulation_mode ?? p.simulation_mode);
const weight = Math.max(0, Math.round(Number(s.simulation_weight_kg ?? 0) || 0));
const liveWeight = Math.max(0, Math.round(Number(s.weight_kg ?? weight) || 0));
const toggle = document.getElementById("hwSimulationToggle");
const panel = document.getElementById("hwSimulationPanel");
const status = document.getElementById("hwSimulationStatusLabel");
const input = document.getElementById("hwSimWeightInput");
const slider = document.getElementById("hwSimWeightSlider");
const live = document.getElementById("hwSimWeightLive");
if (toggle) toggle.checked = sim;
if (panel) panel.hidden = !sim;
if (status) {
status.textContent = sim
? `Включена · задано ${weight} кг (live ${liveWeight} кг)`
: "Выключена · чтение HX711";
}
if (input && document.activeElement !== input) input.value = String(weight);
if (slider && document.activeElement !== slider) {
slider.value = String(Math.min(HW_SIM_SLIDER_MAX, weight));
}
if (live) live.textContent = String(liveWeight);
}
async function patchHardwareSimulation(enabled) {
const r = await fetch("/api/admin/hardware/simulation", {
method: "PATCH",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: Boolean(enabled) }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || String(r.status));
const sim = Boolean(d.simulation_mode ?? (d.hardware || {}).simulation_mode);
if (sim !== Boolean(enabled)) {
throw new Error("Сервер не подтвердил переключение симуляции");
}
return d;
}
async function setHardwareSimulationWeight(weightKg) {
const w = Math.max(0, Math.min(9999, Math.round(Number(weightKg) || 0)));
const r = await fetch("/api/admin/hardware/simulation/weight", {
method: "PUT",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ weight_kg: w }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || String(r.status));
return d;
}
async function adjustHardwareSimulationWeight(deltaKg) {
const r = await fetch("/api/admin/hardware/simulation/weight", {
method: "PUT",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delta_kg: deltaKg }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || String(r.status));
return d;
}
async function applyHardwareSimulationWeightFromUi() {
const input = document.getElementById("hwSimWeightInput");
const slider = document.getElementById("hwSimWeightSlider");
const raw = input && input.value !== "" ? input.value : slider ? slider.value : "0";
await setHardwareSimulationWeight(raw);
await refreshHardwareSection();
}
function scheduleHardwareSimulationWeightFromSlider() {
if (hwSimSliderTimer) window.clearTimeout(hwSimSliderTimer);
hwSimSliderTimer = window.setTimeout(async () => {
hwSimSliderTimer = null;
const slider = document.getElementById("hwSimWeightSlider");
const input = document.getElementById("hwSimWeightInput");
if (!slider) return;
withHwSimUiLock(() => {
if (input) input.value = slider.value;
});
try {
await setHardwareSimulationWeight(slider.value);
await loadHardwareStatus().catch(() => {});
} catch (error) {
setStatus(error.message, true, "Ошибка изменения веса");
}
}, 180);
}
function renderHardwareStatus(data) {
const hint = document.getElementById("hardwareStatusHint");
if (hint) {
hint.textContent = data.generated_at
? `обновлено ${formatRuDateTimeFromIso(data.generated_at)}`
: "—";
}
renderHardwareHostList(data.machine);
renderHardwareHostAlerts(data.host_alerts);
renderHardwarePeriphList(data.peripherals);
renderHardwareScalesList(data.scales);
syncHardwareSimulationControls(data.scales, data.peripherals);
}
async function loadHardwareStatus() {
const r = await fetch("/api/admin/hardware-status", { credentials: "same-origin" });
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
renderHardwareStatus(d);
}
function renderPeripheralEvents(events) {
const el = document.getElementById("hardwareEventsList");
if (!el) return;
el.innerHTML = renderPeripheralEventsHtml(events);
}
function renderPeripheralEventsHtml(events) {
const list = Array.isArray(events) ? events : [];
if (!list.length) {
return wespAdmListEmpty("Событий пока нет");
}
const rows = [...list].reverse().slice(0, 80);
return rows
.map((ev) => {
const ts = ev.u ? formatRuDateTimeFromIso(new Date(Number(ev.u) * 1000).toISOString()) : "—";
const code = peripheralEventTitle(ev.code);
const lvl =
ev.level === "err"
? "err"
: ev.level === "ok"
? "ok"
: ev.level === "info"
? "info"
: "warn";
const badge =
lvl === "err"
? '<span class="wesp-diag-bad">ERR</span>'
: lvl === "ok"
? diagOkBadge(true)
: lvl === "info"
? '<span class="wesp-sync-hint">INFO</span>'
: '<span class="wesp-sync-hint">WARN</span>';
return wespAdmListItem(
`${escHtml(ev.component || "—")} · ${escHtml(code)}`,
badge,
`${ts}${escHtml(String(ev.message || ""))}`,
);
})
.join("");
}
function renderNetworkEvents(events) {
const el = document.getElementById("networkEventsList");
if (!el) return;
el.innerHTML = renderPeripheralEventsHtml(events);
}
async function loadNetworkEvents() {
const el = document.getElementById("networkEventsList");
const showListLoading = el && !document.getElementById("wespSectionNetwork")?.classList.contains("wesp-network-section--loading");
if (showListLoading && el) el.innerHTML = wespAdmListLoading("Загрузка журнала…");
beginNetworkSectionLoad();
try {
const r = await fetch("/api/admin/peripheral-events?limit=50&component=network", {
credentials: "same-origin",
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
renderNetworkEvents(d.events);
} finally {
endNetworkSectionLoad();
}
}
async function loadPeripheralEvents() {
const r = await fetch("/api/admin/peripheral-events?limit=80&exclude_component=network", {
credentials: "same-origin",
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
renderPeripheralEvents(d.events);
}
async function clearNetworkEventsLog() {
if (!window.confirm("Очистить журнал сети?")) return;
const r = await fetch("/api/admin/peripheral-events?component=network", {
method: "DELETE",
credentials: "same-origin",
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || String(r.status));
renderNetworkEvents([]);
return d;
}
async function clearPeripheralEventsLog() {
if (!window.confirm("Очистить журнал событий оборудования? (журнал сети не затрагивается)")) return;
const r = await fetch("/api/admin/peripheral-events?exclude_component=network", {
method: "DELETE",
credentials: "same-origin",
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || String(r.status));
renderPeripheralEvents([]);
return d;
}
function renderAutoUpdateFormFromSummary(au) {
const a = au && typeof au === "object" ? au : {};
const el = (id) => document.getElementById(id);
const en = el("auEnabled");
if (en) en.checked = Boolean(a.enabled);
const iv = el("auCheckInterval");
if (iv) {
const n = Number(a.check_interval_sec);
iv.value = Number.isFinite(n) ? String(n) : "3600";
}
const setVal = (id, v) => {
const x = el(id);
if (x) x.value = v != null ? String(v) : "";
};
setVal("auGiteaUrl", a.gitea_url);
setVal("auGiteaOwner", a.gitea_owner);
setVal("auGiteaRepo", a.gitea_repo);
setVal("auRepositoryUrl", a.repository_url);
const tok = el("auGiteaToken");
if (tok) tok.value = "";
const gu = el("auGiteaApiUser");
if (gu) gu.value = "";
const gp = el("auGiteaApiPass");
if (gp) gp.value = "";
const clr = el("auClearSecrets");
if (clr) clr.checked = false;
const runtimeHint = el("auRuntimeHint");
if (runtimeHint) {
const rtParts = [];
if (a.runtime_current_version) rtParts.push(`Версия: ${a.runtime_current_version}`);
if (a.runtime_last_check_at) rtParts.push(`Последняя проверка: ${a.runtime_last_check_at}`);
if (a.runtime_update_available && a.runtime_pending_version) {
rtParts.push(`Доступно: ${a.runtime_pending_version}`);
} else if (a.enabled) {
rtParts.push("Новых версий нет");
}
if (a.restart_configured) rtParts.push("Перезапуск после установки настроен");
runtimeHint.textContent = rtParts.length ? rtParts.join(" · ") : "";
}
}
async function saveAutoUpdateFromForm() {
const el = (id) => document.getElementById(id);
const body = {
enabled: el("auEnabled") ? el("auEnabled").checked : false,
gitea_url: (el("auGiteaUrl") && el("auGiteaUrl").value.trim()) || "",
gitea_owner: (el("auGiteaOwner") && el("auGiteaOwner").value.trim()) || "",
gitea_repo: (el("auGiteaRepo") && el("auGiteaRepo").value.trim()) || "",
repository_url: (el("auRepositoryUrl") && el("auRepositoryUrl").value.trim()) || "",
};
const iv = el("auCheckInterval");
if (iv) {
const n = parseInt(iv.value, 10);
if (Number.isFinite(n)) body.check_interval_sec = n;
}
const r = await fetch("/api/admin/auto-update-settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
setStatus(d.message || "auto_update: OK");
if (d.auto_update) renderAutoUpdateFormFromSummary(d.auto_update);
else await updateSummary();
}
async function saveGiteaSecretsFromForm() {
const el = (id) => document.getElementById(id);
const clearSecrets = el("auClearSecrets") ? el("auClearSecrets").checked : false;
const body = {
clear_secrets: clearSecrets,
gitea_token: (el("auGiteaToken") && el("auGiteaToken").value.trim()) || "",
gitea_username: (el("auGiteaApiUser") && el("auGiteaApiUser").value.trim()) || "",
gitea_password: (el("auGiteaApiPass") && el("auGiteaApiPass").value.trim()) || "",
};
const r = await fetch("/api/admin/gitea-secrets", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
setStatus(d.message || "gitea_secrets: OK");
if (d.auto_update) renderAutoUpdateFormFromSummary(d.auto_update);
else await updateSummary();
}
let _factoryResetConfirmPhrase = "СБРОС ДАННЫХ";
function renderFactoryResetPreview(preview) {
const list = document.getElementById("factoryResetPreviewList");
const note = document.getElementById("factoryResetPreservedNote");
if (!list) return;
if (!preview || typeof preview !== "object") {
list.innerHTML = wespAdmListItem("—", `<span class="wesp-adm-list-value">Нет данных</span>`);
return;
}
_factoryResetConfirmPhrase = String(preview.confirm_phrase || "СБРОС ДАННЫХ");
const phraseInput = document.getElementById("factoryResetConfirmPhrase");
if (phraseInput && !phraseInput.value) {
phraseInput.placeholder = _factoryResetConfirmPhrase;
}
if (note) {
const preserved = preview.preserved_note;
if (preserved) {
note.hidden = false;
note.textContent = preserved;
} else {
note.hidden = true;
}
}
const rows = [];
for (const f of preview.files || []) {
const sz =
f.size_bytes != null && !Number.isNaN(Number(f.size_bytes))
? formatBytes(Number(f.size_bytes))
: "—";
rows.push(wespAdmListItem(`файл: ${f.path}`, `<span class="wesp-adm-list-value">${escHtml(sz)}</span>`));
}
for (const d of preview.directories || []) {
const n = d.files_inside;
const hint = n != null && n >= 0 ? `${n} файл(ов)` : "каталог";
rows.push(
wespAdmListItem(`каталог: ${d.path}`, `<span class="wesp-adm-list-value">${escHtml(hint)}</span>`),
);
}
if (!rows.length) {
rows.push(
wespAdmListItem(
"data/",
`<span class="wesp-adm-list-value">пусто или уже сброшено</span>`,
),
);
}
list.innerHTML = rows.join("");
}
async function loadFactoryResetPreview() {
const list = document.getElementById("factoryResetPreviewList");
if (list) {
list.innerHTML = wespAdmListItem("—", `<span class="wesp-adm-list-value">Загрузка…</span>`);
}
const r = await fetch("/api/admin/factory-reset/preview");
const d = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(d.message || `Ошибка ${r.status}`);
}
renderFactoryResetPreview(d);
return d;
}
async function runFactoryReset() {
const phraseEl = document.getElementById("factoryResetConfirmPhrase");
const phrase = (phraseEl?.value || "").trim();
if (phrase !== _factoryResetConfirmPhrase) {
throw new Error(`Введите фразу точно: «${_factoryResetConfirmPhrase}»`);
}
const keepLogs = Boolean(document.getElementById("factoryResetKeepLogs")?.checked);
const safetyBackup = document.getElementById("factoryResetSafetyBackup")?.checked !== false;
const ok = window.confirm(
"Будут удалены базы SQLite, JSON настроек и каталоги в data/.\n\n" +
(safetyBackup
? "Перед удалением будет создан ZIP с копией БД в data/backups/.\n\n"
: "Резервная копия БД перед сбросом отключена.\n\n") +
(keepLogs ? "Каталог data/logs будет сохранён.\n\n" : "Каталог data/logs будет удалён.\n\n") +
"После сброса перезапустите WESP и откройте /setup.\n\n" +
"Продолжить?",
);
if (!ok) return null;
const r = await fetch("/api/admin/factory-reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
confirmation_phrase: phrase,
remove_logs: !keepLogs,
safety_sqlite_backup: safetyBackup,
}),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(d.message || `Ошибка ${r.status}`);
}
if (phraseEl) phraseEl.value = "";
await loadFactoryResetPreview().catch(() => {});
return d;
}
function renderSystemSectionTables(data) {
const app = data.app || {};
const sysRows = [
["CONFIG_MODE", ruConfigModeLabel(app.config_mode)],
["DEBUG", app.debug ? "true" : "false"],
["version", app.version ? `v${String(app.version).replace(/^v/i, "")}` : "—"],
["sync.role", ruSyncRoleLabel(app.role)],
["first_launch_at", formatRuDateFromIso(app.first_launch_at)],
["warranty_until", formatWarrantyUntilCell(app.warranty_until)],
["warranty_days", formatWarrantyDaysCell(app.warranty_days_remaining)],
];
const machineRows = machineOverviewRows(data.machine);
const sysList = document.getElementById("systemDataList");
if (sysList) {
const appHtml = sysRows
.map(([k, v]) => wespAdmListItem(k, `<span class="wesp-adm-list-value">${escHtml(v)}</span>`))
.join("");
const sectionHtml =
machineRows.length > 0
? `${wespAdmListSection("Host / hardware")}${machineRows
.map(([k, v]) => wespAdmListItem(k, `<span class="wesp-adm-list-value">${escHtml(v)}</span>`))
.join("")}`
: "";
sysList.innerHTML = appHtml + sectionHtml;
}
const dbs = data.databases || {};
const dbList = document.getElementById("dbDataList");
if (dbList) {
const bindTitle = (key) => {
if (key === "recipes") return "bind: recipes";
if (key === "reports") return "bind: reports";
return key;
};
const engineRu = (e) => {
if (e == null || e === "") return "—";
if (String(e).toLowerCase() === "sqlite") return "SQLite";
return String(e);
};
dbList.innerHTML = Object.entries(dbs)
.map(([key, desc]) => {
const d = desc && typeof desc === "object" ? desc : {};
const file = d.database != null && d.database !== "" ? String(d.database) : "—";
const sz =
d.size_bytes != null && !Number.isNaN(Number(d.size_bytes))
? formatBytes(Number(d.size_bytes))
: "—";
const eng = engineRu(d.engine);
const descLine = `${eng} · ${file}`;
return wespAdmListItem(bindTitle(key), `<span class="wesp-adm-list-value">${escHtml(sz)}</span>`, descLine);
})
.join("");
}
}
function formatSpeed(bps) {
if (bps == null || Number.isNaN(bps)) return "—";
const x = Number(bps);
if (x < 1024) return `${x.toFixed(0)} B/s`;
if (x < 1024 * 1024) return `${(x / 1024).toFixed(2)} KB/s`;
return `${(x / (1024 * 1024)).toFixed(2)} MB/s`;
}
function setRingPercent(gaugeRoot, percent) {
const pctEl = gaugeRoot.querySelector("[data-gauge-pct]");
if (!pctEl) return;
const p = Math.min(100, Math.max(0, Number(percent) || 0));
pctEl.textContent = p.toFixed(2);
gaugeRoot.style.setProperty("--gauge-circ", String(GAUGE_CIRC));
gaugeRoot.style.setProperty("--gauge-pct", String(p));
}
function decimateHardwarePoints(points, maxN) {
if (!points.length || points.length <= maxN) return points;
const step = Math.ceil(points.length / maxN);
const out = [];
for (let i = 0; i < points.length; i += step) out.push(points[i]);
if (out[out.length - 1] !== points[points.length - 1]) out.push(points[points.length - 1]);
return out;
}
function hardwarePathForKey(pts, key, tMin, tMax, padL, padT, plotW, plotH) {
const span = Math.max(1, tMax - tMin);
let d = "";
let penUp = true;
for (const p of pts) {
let v = p[key];
if (v == null || Number.isNaN(Number(v))) {
penUp = true;
continue;
}
v = Math.min(100, Math.max(0, Number(v)));
const x = padL + ((p.t - tMin) / span) * plotW;
const y = padT + plotH - (v / 100) * plotH;
d += penUp ? `M${x.toFixed(1)},${y.toFixed(1)}` : `L${x.toFixed(1)},${y.toFixed(1)}`;
penUp = false;
}
return d;
}
function renderHardwareHistorySvg(svgEl, points) {
const pts = decimateHardwarePoints(points, 900);
const tMin = pts[0].t;
const tMax = pts[pts.length - 1].t;
const W = 640;
const H = 220;
const padL = 42;
const padR = 10;
const padT = 12;
const padB = 26;
const plotW = W - padL - padR;
const plotH = H - padT - padB;
const fmtTime = (unix) => {
const d = new Date(unix * 1000);
return d.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
};
const gridYs = [0, 0.5, 1];
let grid = "";
for (const gy of gridYs) {
const y = padT + plotH * (1 - gy);
grid += `<line x1="${padL}" y1="${y.toFixed(1)}" x2="${padL + plotW}" y2="${y.toFixed(1)}" stroke="currentColor" stroke-opacity="0.08" stroke-width="1"/>`;
}
const series = [
{ key: "cpu", color: "#008771" },
{ key: "ram", color: "#1890ff" },
{ key: "swap", color: "#faad14" },
{ key: "disk", color: "#722ed1" },
];
const paths = series
.map((s) => {
const d = hardwarePathForKey(pts, s.key, tMin, tMax, padL, padT, plotW, plotH);
if (!d) return "";
return `<path fill="none" stroke="${s.color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" d="${d}"/>`;
})
.join("");
const yLbl = (v) =>
`<text x="${padL - 6}" y="${(padT + plotH * (1 - v / 100) + 4).toFixed(1)}" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.45">${v}</text>`;
svgEl.innerHTML = `
<g opacity="0.9">${grid}</g>
${yLbl(0)}${yLbl(50)}${yLbl(100)}
<text x="${padL}" y="${H - 6}" font-size="10" fill="currentColor" fill-opacity="0.45">${fmtTime(tMin)}</text>
<text x="${padL + plotW}" y="${H - 6}" font-size="10" fill="currentColor" fill-opacity="0.45" text-anchor="end">${fmtTime(tMax)}</text>
<text x="${padL - 38}" y="${padT + plotH / 2}" font-size="10" fill="currentColor" fill-opacity="0.45" text-anchor="middle" transform="rotate(-90 ${padL - 38},${padT + plotH / 2})">%</text>
${paths}
`;
}
async function loadHardwareHistoryCharts() {
const hint = document.getElementById("wespHardwareHistoryHint");
const wrap = document.getElementById("wespHardwareHistoryWrap");
const svg = document.getElementById("wespHardwareHistorySvg");
const leg = document.getElementById("wespHardwareHistoryLegend");
if (!hint || !wrap || !svg || !leg) return;
try {
const r = await fetch("/api/admin/hardware-metrics-history");
const d = await r.json();
if (!r.ok) throw new Error(d.message || String(r.status));
const pts = Array.isArray(d.points) ? d.points : [];
const days = d.retention_days ?? 3;
if (pts.length === 0) {
hint.textContent = `timeseries: ∅. Сэмплы при опросе summary; retention ${days} d.`;
wrap.hidden = true;
leg.innerHTML = "";
return;
}
hint.textContent = `utilization %, окно ${days} d.`;
wrap.hidden = false;
renderHardwareHistorySvg(svg, pts);
leg.innerHTML = [
{ key: "cpu", label: "ЦП", c: "#008771" },
{ key: "ram", label: "ОЗУ", c: "#1890ff" },
{ key: "swap", label: "Подкачка", c: "#faad14" },
{ key: "disk", label: "Диск", c: "#722ed1" },
]
.map(
(x) =>
`<span><i style="background:${x.c}" aria-hidden="true"></i>${x.label}</span>`,
)
.join("");
} catch (e) {
hint.textContent = userHint(e.message, "Не удалось загрузить метрики");
wrap.hidden = true;
leg.innerHTML = "";
}
}
function scrollToAdminSection(section) {
const el = document.querySelector(`[data-section-content="${section}"]`);
if (!el) return;
window.requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "smooth", block: "start" });
});
}
function goToDashboardStatNav(nav) {
if (nav === "recipes") {
window.location.href = "/recipes";
return;
}
if (nav === "components") {
window.location.href = "/components";
return;
}
if (nav === "users" || nav === "sync") {
activateSection(nav);
scrollToAdminSection(nav);
}
}
/** Какую БД предвыбрать в sql.js (счётчики дашборда относятся к основной). */
function dashboardStatPreferredBind(_nav) {
return "recipes";
}
function initDashboardStatNav() {
document.querySelectorAll("[data-stat-nav]").forEach((el) => {
const nav = el.dataset.statNav;
if (!nav) return;
const onActivate = async (event) => {
if (event && "altKey" in event && event.altKey) {
event.preventDefault();
try {
await openAdminDbBrowser({
preferredBind: dashboardStatPreferredBind(nav),
setStatus,
});
} catch (e) {
setStatus(e.message, true, "Ошибка редактора БД");
}
return;
}
goToDashboardStatNav(nav);
};
el.addEventListener("click", onActivate);
el.addEventListener("keydown", (e) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
if (e.altKey) {
onActivate(e);
return;
}
goToDashboardStatNav(nav);
});
});
}
function activateSection(section) {
stopHardwarePolling();
document.querySelectorAll("[data-section]").forEach((item) => {
const on = item.dataset.section === section;
item.classList.toggle("ant-menu-item-selected", on);
});
document.querySelectorAll("[data-section-content]").forEach((sectionEl) => {
sectionEl.classList.toggle(
"is-active",
sectionEl.dataset.sectionContent === section,
);
});
const active = document.querySelector(`[data-section-content="${section}"]`);
if (!active) return;
active.querySelectorAll(".wesp-admin-block").forEach((el, i) => {
el.style.setProperty("--wesp-reveal-i", String(i));
el.classList.remove("wesp-admin-section-enter");
void el.offsetWidth;
el.classList.add("wesp-admin-section-enter");
});
if (section === "hardware") {
loadHardwareHistoryCharts().catch(() => {});
startHardwarePolling();
}
if (section === "sync") {
loadSyncDiagnostics().catch((e) => setStatus(e.message, true, "Ошибка обновления"));
loadOrchestratorSyncSettings().catch(() => {});
}
if (section === "network") {
Promise.all([
loadNetworkSettings(),
loadNetworkEvents(),
]).catch((e) => setStatus(e.message, true, "Ошибка загрузки сети"));
}
if (section === "diagnostics") {
setDiagnosticsSecurityListLoading();
loadDiagnosticsReport().catch((e) => setStatus(e.message, true, "Ошибка диагностики"));
}
if (section === "system") {
loadFactoryResetPreview().catch((e) =>
setStatus(e.message, true, "Ошибка предпросмотра сброса"),
);
refreshKioskFullSetupHint();
refreshPiPlatformSetupHint();
refreshPiRainbowSplashHint();
refreshPlymouthStatusHint().then((running) => {
if (running) startPlymouthStatusPoll();
});
refreshKioskBootStatusHint();
}
if (section === "assistant") {
loadAdminAssistantStatus().catch(() => {});
loadAdminAssistantLlmLog().catch(() => {});
if (!window.__wespAssistantChatMessages) hydrateAssistantChatMessagesFromStorage();
renderAssistantChatLog();
}
}
/** Кнопка «история» у ЦП на дашборде: раздел Железо + график. */
function openHardwareChartFromDashboard() {
activateSection("hardware");
window.requestAnimationFrame(() => {
document.getElementById("wespHardwareHistoryCard")?.scrollIntoView({
behavior: "smooth",
block: "start",
});
});
}
async function fetchAuth() {
const response = await fetch("/api/auth/check");
const data = await response.json();
if (!(data.status === "success" && data.authenticated && data.is_superuser)) {
window.location.href = "/recipes";
return null;
}
return data;
}
function userRowTemplate(user) {
const role = user.is_superuser ? "Суперпользователь" : "Пользователь";
const modulesHint = user.is_superuser
? "Lab: всегда"
: user.lab_access
? "Lab: вкл."
: "Lab: выкл.";
const extra = `<div class="wesp-adm-list-actions wesp-user-actions">
<button type="button" class="ant-btn ant-btn-sm" data-action="set-password"><span>Пароль</span></button>
<button type="button" class="ant-btn ant-btn-sm" data-action="toggle-super"><span>${user.is_superuser ? "Снять super" : "Сделать super"}</span></button>
<button type="button" class="ant-btn ant-btn-sm ant-btn-danger" data-action="delete-user"><span>Удалить</span></button>
</div>`;
return wespAdmListItem(user.login, extra, `${role} · ${modulesHint}`, {
itemClass: "wesp-adm-list-item--user wesp-adm-list-item--clickable",
dataAttrs:
`data-user-id="${String(user.id).replace(/"/g, "&quot;")}" ` +
`data-is-super="${user.is_superuser ? "1" : "0"}" ` +
`data-lab-access="${user.lab_access ? "1" : "0"}" ` +
`data-user-login="${String(user.login).replace(/"/g, "&quot;")}"`,
});
}
let userModulesModalState = null;
function closeUserModulesModal() {
const modal = document.getElementById("userModulesModal");
if (modal) modal.hidden = true;
userModulesModalState = null;
}
function openUserModulesModal(row) {
userModulesModalState = {
userId: row.dataset.userId,
login: row.dataset.userLogin || "",
isSuper: row.dataset.isSuper === "1",
labAccess: row.dataset.labAccess === "1",
};
const loginEl = document.getElementById("userModulesModalLogin");
if (loginEl) loginEl.textContent = userModulesModalState.login || "—";
const toggle = document.getElementById("userModuleLabAccess");
if (toggle) {
toggle.checked = userModulesModalState.isSuper || userModulesModalState.labAccess;
toggle.disabled = userModulesModalState.isSuper;
wireAntCheckbox(toggle);
}
const hint = document.getElementById("userModuleLabSuperHint");
if (hint) hint.hidden = !userModulesModalState.isSuper;
const modal = document.getElementById("userModulesModal");
if (modal) modal.hidden = false;
}
async function saveUserModulesModal() {
if (!userModulesModalState) return;
if (userModulesModalState.isSuper) {
closeUserModulesModal();
return;
}
const toggle = document.getElementById("userModuleLabAccess");
const labAccess = Boolean(toggle?.checked);
const response = await fetch(`/api/admin/users/${userModulesModalState.userId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lab_access: labAccess }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || "Не удалось сохранить доступ к модулям");
setStatus("lab_access: OK");
closeUserModulesModal();
await loadUsers();
}
async function loadUsers() {
const response = await fetch("/api/admin/users");
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "Ошибка загрузки пользователей");
}
const list = document.getElementById("usersList");
if (!list) return;
list.innerHTML = data.users.map(userRowTemplate).join("");
}
async function updateSystemMetrics() {
const response = await fetch("/api/admin/system-metrics");
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "Ошибка метрик системы");
}
const m = data.metrics;
if (!m || m.available === false) {
const unavailableMsg =
(m && typeof m.message === "string" && m.message.trim()) ||
"Метрики CPU/RAM недоступны (нужен psutil на сервере).";
["cpu", "ram", "swap", "disk"].forEach((key) => {
const root = document.querySelector(`[data-gauge="${key}"]`);
if (!root) return;
root.style.setProperty("--gauge-pct", "0");
const d = root.querySelector("[data-gauge-detail]");
if (d) d.textContent = "—";
const p = root.querySelector("[data-gauge-pct]");
if (p) p.textContent = "—";
});
const pills = document.getElementById("loadAvgPills");
if (pills) pills.textContent = "—";
const nu = document.getElementById("netUpload");
const nd = document.getElementById("netDownload");
if (nu) nu.textContent = "—";
if (nd) nd.textContent = "—";
const nvs = document.getElementById("netVolumeSent");
const nvr = document.getElementById("netVolumeRecv");
if (nvs) nvs.textContent = "—";
if (nvr) nvr.textContent = "—";
renderServerIps([]);
const pillH = document.getElementById("pillHostUptime");
const pillW = document.getElementById("pillWespUptime");
const pillC = document.getElementById("pillConnections");
if (pillH) pillH.textContent = "host: —";
if (pillW) pillW.textContent = "wesp: —";
if (pillC) pillC.textContent = "TCP — · UDP —";
const st = document.getElementById("wespRuntimeStatus");
const dot = document.querySelector(".wesp-dash-status .wesp-status-dot");
if (st) st.textContent = unavailableMsg;
if (dot) {
dot.classList.remove("wesp-status-dot--ok");
dot.classList.add("wesp-status-dot--warn");
}
return;
}
const cpuRoot = document.querySelector('[data-gauge="cpu"]');
if (cpuRoot) {
setRingPercent(cpuRoot, m.cpu?.percent ?? 0);
const d = cpuRoot.querySelector("[data-gauge-detail]");
if (d) d.textContent = ruCpuCoresLabel(m.cpu?.cores);
}
const ramRoot = document.querySelector('[data-gauge="ram"]');
if (ramRoot) {
setRingPercent(ramRoot, m.memory?.percent ?? 0);
const d = ramRoot.querySelector("[data-gauge-detail]");
if (d) {
d.textContent = `${formatBytes(m.memory?.used)} / ${formatBytes(m.memory?.total)}`;
}
}
const swapRoot = document.querySelector('[data-gauge="swap"]');
if (swapRoot) {
const sp = m.swap?.total ? m.swap.percent : 0;
setRingPercent(swapRoot, sp);
const d = swapRoot.querySelector("[data-gauge-detail]");
if (d) {
d.textContent =
m.swap?.total > 0
? `${formatBytes(m.swap.used)} / ${formatBytes(m.swap.total)}`
: "Нет swap";
}
}
const diskRoot = document.querySelector('[data-gauge="disk"]');
if (diskRoot) {
if (m.disk) {
setRingPercent(diskRoot, m.disk.percent ?? 0);
const d = diskRoot.querySelector("[data-gauge-detail]");
if (d) {
d.textContent = `${formatBytes(m.disk.used)} / ${formatBytes(m.disk.total)}`;
}
} else {
setRingPercent(diskRoot, 0);
const d = diskRoot.querySelector("[data-gauge-detail]");
if (d) d.textContent = "n/a";
const p = diskRoot.querySelector("[data-gauge-pct]");
if (p) p.textContent = "—";
}
}
const pills = document.getElementById("loadAvgPills");
if (pills) {
if (Array.isArray(m.load_avg) && m.load_avg.length === 3) {
pills.innerHTML = m.load_avg
.map(
(v, i) =>
`${i ? '<span style="opacity:0.45;margin:0 6px;">|</span>' : ""}<span class="ant-tag ant-tag-green">${v}</span>`,
)
.join("");
} else {
pills.textContent = "n/a (OS)";
}
}
const nu = document.getElementById("netUpload");
const nd = document.getElementById("netDownload");
if (nu) nu.textContent = formatSpeed(m.network?.upload_bps);
if (nd) nd.textContent = formatSpeed(m.network?.download_bps);
const nvs = document.getElementById("netVolumeSent");
const nvr = document.getElementById("netVolumeRecv");
if (nvs) nvs.textContent = formatBytes(m.network?.bytes_sent_total);
if (nvr) nvr.textContent = formatBytes(m.network?.bytes_recv_total);
renderServerIps(m.addresses);
const pillH = document.getElementById("pillHostUptime");
const pillW = document.getElementById("pillWespUptime");
const pillC = document.getElementById("pillConnections");
if (pillH && m.host) pillH.textContent = `host: ${m.host.uptime_human || "—"}`;
if (pillW && m.process) {
pillW.textContent = `wesp: ${m.process.uptime_human || "—"}`;
}
if (pillC && m.connections) {
pillC.textContent = `TCP ${m.connections.tcp} · UDP ${m.connections.udp}`;
} else if (pillC) {
pillC.textContent = "TCP/UDP: n/a";
}
const st = document.getElementById("wespRuntimeStatus");
const dot = document.querySelector(".wesp-dash-status .wesp-status-dot");
if (st) st.textContent = "OK";
if (dot) {
dot.classList.add("wesp-status-dot--ok");
dot.classList.remove("wesp-status-dot--warn");
}
}
async function updateSummary() {
const response = await fetch("/api/admin/summary");
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "Ошибка загрузки summary");
}
document.getElementById("statUsers").textContent = String(data.counts.users);
document.getElementById("statRecipes").textContent = String(data.counts.recipes);
document.getElementById("statComponents").textContent = String(data.counts.components);
document.getElementById("statQueuePending").textContent = String(data.sync.queue.pending);
const verEl = document.getElementById("wespDashVersion");
if (verEl) {
const v = data.app?.version || "—";
verEl.textContent = `v${String(v).replace(/^v/i, "")}`;
}
const firstEl = document.getElementById("wespFirstLaunch");
if (firstEl) firstEl.textContent = formatRuDateFromIso(data.app?.first_launch_at);
const untilEl = document.getElementById("wespWarrantyUntil");
if (untilEl) {
const wu = data.app?.warranty_until;
if (!wu) untilEl.textContent = "—";
else {
const p = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(wu));
if (p) {
const dt = new Date(Number(p[1]), Number(p[2]) - 1, Number(p[3]));
untilEl.textContent = dt.toLocaleDateString("ru-RU", {
day: "numeric",
month: "long",
year: "numeric",
});
} else {
untilEl.textContent = formatRuDateFromIso(wu);
}
}
}
const daysEl = document.getElementById("wespWarrantyDays");
if (daysEl) {
const dr = data.app?.warranty_days_remaining;
if (dr == null || Number.isNaN(Number(dr))) daysEl.textContent = "—";
else if (Number(dr) < 0) daysEl.textContent = "истекла";
else daysEl.textContent = String(dr);
}
const setTxt = (id, text) => {
const el = document.getElementById(id);
if (el) el.textContent = text;
};
const app = data.app || {};
const counts = data.counts || {};
setTxt("dashMetaConfigMode", ruConfigModeLabel(app.config_mode));
setTxt("dashMetaDebug", app.debug ? "true" : "false");
setTxt("dashMetaVersion", app.version ? `v${String(app.version).replace(/^v/i, "")}` : "—");
setTxt("dashMetaSyncRole", ruSyncRoleLabel(app.role));
setTxt("dashMetaFirstLaunch", formatRuDateFromIso(app.first_launch_at));
const wu = app.warranty_until;
if (wu) {
const p = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(wu));
if (p) {
const dt = new Date(Number(p[1]), Number(p[2]) - 1, Number(p[3]));
setTxt(
"dashMetaWarrantyUntil",
dt.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" }),
);
} else {
setTxt("dashMetaWarrantyUntil", formatRuDateFromIso(wu));
}
} else {
setTxt("dashMetaWarrantyUntil", "—");
}
const drDash = app.warranty_days_remaining;
if (drDash == null || Number.isNaN(Number(drDash))) setTxt("dashMetaWarrantyDays", "—");
else if (Number(drDash) < 0) setTxt("dashMetaWarrantyDays", "истекла");
else setTxt("dashMetaWarrantyDays", String(drDash));
setTxt("dashCountUsers", String(counts.users ?? "—"));
setTxt("dashCountRecipes", String(counts.recipes ?? "—"));
setTxt("dashCountIngredients", String(counts.ingredients ?? "—"));
setTxt("dashCountComponents", String(counts.components ?? "—"));
setTxt("dashCountDispensers", String(counts.feed_dispensers ?? "—"));
setTxt("dashCountPeriods", String(counts.feeding_periods ?? "—"));
setTxt(
"dashMetaGenerated",
data.generated_at
? `summary @ ${formatRuDateTimeFromIso(data.generated_at)}`
: "—",
);
renderSystemSectionTables(data);
renderAutoUpdateFormFromSummary(data.auto_update || {});
renderSyncSectionFromSummary(data.sync || {});
await loadSyncClientsTable();
renderSecuritySectionFromSummary(data.security || {});
}
async function refreshHardwareSection() {
await Promise.all([loadHardwareStatus(), loadPeripheralEvents()]);
setStatus("Железо: обновлено");
}
function setSecurityFieldHint(id, locked, envName) {
const hint = document.getElementById(id);
if (!hint) return;
if (locked) {
hint.hidden = false;
hint.textContent = `Заблокировано переменной окружения (${envName}). Снимите её, чтобы менять из админки.`;
} else {
hint.hidden = true;
hint.textContent = "";
}
}
async function patchSecurityBool(field, checked, el) {
if (el?.disabled) return;
const prev = !checked;
try {
const r = await fetch("/api/admin/security-settings", {
method: "PATCH",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [field]: checked }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
setStatus(d.message || `Ошибка ${r.status}`, true);
if (el) el.checked = prev;
return;
}
const labels = {
mac_lock_enabled: checked ? "MAC-lock включён" : "MAC-lock выключен",
kiosk_enforce_paired_only: checked
? "Требование привязки терминала включено"
: "Требование привязки терминала выключено",
calibration_public: checked
? "Публичная калибровка включена"
: "Публичная калибровка выключена",
};
setStatus(d.message || labels[field] || "Сохранено");
if (d.security) renderSecuritySectionFromSummary(d.security);
} catch (err) {
setStatus(err?.message, true, "Ошибка сохранения");
if (el) el.checked = prev;
}
}
function wireSecurityToggleAutoSave(id, field) {
document.getElementById(id)?.addEventListener("change", async (e) => {
const el = e.target;
if (!(el instanceof HTMLInputElement)) return;
await patchSecurityBool(field, el.checked, el);
});
}
function renderNetworkDetectedList(net) {
const el = document.getElementById("networkDetectedList");
if (!el) return;
const d = (net && net.detected) || {};
const mdnsOn = Boolean(d.mdns_active);
const mdnsAvail = Boolean(d.mdns_available);
el.innerHTML = [
wespAdmListSection("Обнаружено"),
wespAdmListItem(
"LAN IP",
`<span class="wesp-adm-list-value">${escHtml(String(d.lan_ip ?? "—"))}</span>`,
"текущий адрес в сети",
),
wespAdmListItem(
"Hostname ОС",
escHtml(String(d.os_hostname ?? "—")),
"socket.gethostname()",
),
wespAdmListItem(
"Listen",
`<span class="wesp-adm-list-value">${escHtml(String(d.listen_host ?? "—"))}:${escHtml(String(d.listen_port ?? "—"))}</span>`,
"WESP_LISTEN / waitress",
),
wespAdmListItem(
"mDNS",
mdnsOn
? '<span class="wesp-diag-ok">активен</span>'
: mdnsAvail
? '<span class="wesp-sync-hint">выкл.</span>'
: '<span class="wesp-diag-bad">zeroconf ∅</span>',
mdnsOn
? "имя объявляется в LAN"
: d.mdns_error
? escHtml(String(d.mdns_error))
: mdnsAvail
? "включите toggle ниже"
: "pip install zeroconf",
),
wespAdmListItem(
"Подсказка URL",
`<span class="wesp-adm-list-value">${escHtml(String(net.suggested_public_url ?? "—"))}</span>`,
"из имени + порта",
),
wespAdmListItem(
"Sync по IP",
`<span class="wesp-adm-list-value">${escHtml(String(net.sync_url_by_ip ?? "—"))}</span>`,
"если .local в сети не резолвится — укажите этот адрес на терминалах",
),
].join("");
}
function renderNetworkSectionFromSummary(net) {
const n = net && typeof net === "object" ? net : {};
const hint = document.getElementById("networkSettingsHint");
const lockParts = [];
if (n.local_hostname_locked_by_env) lockParts.push("local_hostname ← WESP_NETWORK_LOCAL_HOSTNAME");
if (n.public_base_url_locked_by_env) lockParts.push("public URL ← WESP_KIOSK_PUBLIC_BASE_URL");
if (n.mdns_enabled_locked_by_env) lockParts.push("mDNS ← WESP_MDNS_ENABLED");
if (hint) {
const base =
"PATCH → data/wesp_network_settings.json. Адрес sync-клиентов — вкладка «Синхронизация».";
hint.textContent = lockParts.length ? `${base} env: ${lockParts.join("; ")}.` : base;
}
const hostInp = document.getElementById("netLocalHostname");
if (hostInp) {
hostInp.value = n.local_hostname || n.default_local_hostname || "komton_srv_1.local";
hostInp.disabled = Boolean(n.local_hostname_locked_by_env);
}
const urlInp = document.getElementById("netPublicBaseUrl");
if (urlInp) {
urlInp.value = n.public_base_url || "";
urlInp.disabled = Boolean(n.public_base_url_locked_by_env);
}
const mdns = document.getElementById("netMdnsEnabled");
const mdnsLabel = document.getElementById("netMdnsStatusLabel");
const mdnsHint = document.getElementById("netMdnsHint");
const det = n.detected || {};
if (mdns) {
mdns.checked = Boolean(n.mdns_enabled);
mdns.disabled = Boolean(n.mdns_enabled_locked_by_env) || !det.mdns_available;
}
if (mdnsLabel) {
mdnsLabel.textContent = n.mdns_enabled ? "mDNS: вкл." : "mDNS: выкл.";
}
if (mdnsHint) {
if (!det.mdns_available) {
mdnsHint.textContent = "Пакет zeroconf не установлен — объявление имени в LAN недоступно.";
} else if (det.mdns_active) {
mdnsHint.textContent = "Сервис объявлен в локальной сети.";
} else if (det.mdns_error) {
mdnsHint.textContent = String(det.mdns_error);
} else {
mdnsHint.textContent =
"Включите mDNS, чтобы клиенты в LAN резолвили имя без /etc/hosts и без DNS роутера.";
}
}
const saveBtn = document.getElementById("netSettingsSave");
if (saveBtn) {
saveBtn.disabled =
Boolean(n.local_hostname_locked_by_env) &&
Boolean(n.public_base_url_locked_by_env) &&
Boolean(n.mdns_enabled_locked_by_env);
}
renderNetworkDetectedList(n);
}
let lastNetworkSnapshot = null;
let networkSectionLoadPending = 0;
function setNetworkSectionLoading(loading) {
const section = document.getElementById("wespSectionNetwork");
if (section) section.classList.toggle("wesp-network-section--loading", loading);
const banner = document.getElementById("networkFormLoading");
if (banner) banner.hidden = !loading;
}
function beginNetworkSectionLoad() {
networkSectionLoadPending += 1;
if (networkSectionLoadPending === 1) {
setNetworkSectionLoading(true);
const det = document.getElementById("networkDetectedList");
if (det) det.innerHTML = wespAdmListLoading("Определение LAN IP и mDNS…");
const ev = document.getElementById("networkEventsList");
if (ev && !ev.querySelector(".wesp-adm-list-loading")) {
ev.innerHTML = wespAdmListLoading("Загрузка журнала…");
}
}
}
function endNetworkSectionLoad() {
networkSectionLoadPending = Math.max(0, networkSectionLoadPending - 1);
if (networkSectionLoadPending === 0) {
setNetworkSectionLoading(false);
}
}
async function loadNetworkSettings() {
beginNetworkSectionLoad();
try {
const r = await fetch("/api/admin/network-settings", { credentials: "same-origin" });
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || String(r.status));
lastNetworkSnapshot = d.network || d;
renderNetworkSectionFromSummary(lastNetworkSnapshot);
return lastNetworkSnapshot;
} finally {
endNetworkSectionLoad();
}
}
function wespListenPortFallback() {
const p = Number(lastNetworkSnapshot?.detected?.listen_port);
return Number.isFinite(p) && p > 0 ? p : 80;
}
function applyNetworkSuggestedPublicUrl() {
const hostInp = document.getElementById("netLocalHostname");
const urlInp = document.getElementById("netPublicBaseUrl");
if (!hostInp || !urlInp || urlInp.disabled) return;
let host = (hostInp.value || "komton_srv_1.local").trim().toLowerCase();
if (host && !host.endsWith(".local")) host = `${host}.local`;
const port = wespListenPortFallback();
urlInp.value = port === 80 ? `http://${host}` : `http://${host}:${port}`;
}
async function saveNetworkSettingsFromForm() {
const hostInp = document.getElementById("netLocalHostname");
const urlInp = document.getElementById("netPublicBaseUrl");
const mdns = document.getElementById("netMdnsEnabled");
const body = {};
if (hostInp && !hostInp.disabled) body.local_hostname = hostInp.value.trim();
if (urlInp && !urlInp.disabled) body.public_base_url = urlInp.value.trim();
if (mdns && !mdns.disabled) body.mdns_enabled = mdns.checked;
if (Object.keys(body).length === 0) {
setStatus("network PATCH: noop (env lock / no delta)", true);
return;
}
beginNetworkSectionLoad();
try {
const r = await fetch("/api/admin/network-settings", {
method: "PATCH",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
setStatus(d.message || "Сеть: сохранено");
if (d.network) renderNetworkSectionFromSummary(d.network);
await loadNetworkEvents();
} finally {
endNetworkSectionLoad();
}
}
function renderSecuritySectionFromSummary(sec) {
const s = sec && typeof sec === "object" ? sec : {};
const hint = document.getElementById("securitySettingsHint");
const lockParts = [];
if (s.mac_lock_enabled_locked_by_env) lockParts.push("MAC: WESP_MAC_LOCK_ENABLED");
if (s.allowed_mac_addresses_locked_by_env) lockParts.push("MAC list ← WESP_ALLOWED_MAC_ADDRESSES");
if (s.kiosk_enforce_paired_only_locked_by_env) {
lockParts.push("kiosk_enforce ← WESP_KIOSK_ENFORCE_PAIRED_ONLY");
}
if (s.calibration_public_locked_by_env) lockParts.push("CALIBRATION_PUBLIC ← env");
if (s.device_token_locked_by_env) lockParts.push("DEVICE_TOKEN ← env");
if (s.device_token_header_locked_by_env) lockParts.push("token header ← env");
if (s.kiosk_pair_token_ttl_seconds_locked_by_env) {
lockParts.push("pair TTL ← WESP_KIOSK_PAIR_TOKEN_TTL_SECONDS");
}
if (s.kiosk_auth_cookie_days_locked_by_env) lockParts.push("kiosk cookie ← WESP_KIOSK_AUTH_COOKIE_DAYS");
if (s.session_remember_days_locked_by_env) lockParts.push("remember_days ← WESP_SESSION_REMEMBER_DAYS");
if (hint) {
hint.textContent = lockParts.length
? `env override: ${lockParts.join("; ")}.`
: "PATCH → data/wesp_security_settings.json + in-process config; приоритет у WESP_*.";
}
const mac = document.getElementById("secMacLock");
const macs = document.getElementById("secAllowedMacs");
if (mac) {
mac.checked = Boolean(s.mac_lock_enabled);
mac.disabled = Boolean(s.mac_lock_enabled_locked_by_env);
}
setSecurityFieldHint(
"secMacLockHint",
Boolean(s.mac_lock_enabled_locked_by_env),
"WESP_MAC_LOCK_ENABLED",
);
if (macs) {
macs.value = s.allowed_mac_addresses || "";
macs.disabled = Boolean(s.allowed_mac_addresses_locked_by_env);
}
const kEnf = document.getElementById("secKioskEnforce");
if (kEnf) {
kEnf.checked = Boolean(s.kiosk_enforce_paired_only);
kEnf.disabled = Boolean(s.kiosk_enforce_paired_only_locked_by_env);
}
setSecurityFieldHint(
"secKioskEnforceHint",
Boolean(s.kiosk_enforce_paired_only_locked_by_env),
"WESP_KIOSK_ENFORCE_PAIRED_ONLY",
);
const cal = document.getElementById("secCalibrationPublic");
if (cal) {
cal.checked = Boolean(s.calibration_public);
cal.disabled = Boolean(s.calibration_public_locked_by_env);
}
setSecurityFieldHint(
"secCalibrationPublicHint",
Boolean(s.calibration_public_locked_by_env),
"WESP_CALIBRATION_PUBLIC",
);
const th = document.getElementById("secDeviceTokenHeader");
if (th) {
th.value = s.device_token_header || "X-Device-Token";
th.disabled = Boolean(s.device_token_header_locked_by_env);
}
const token = document.getElementById("secDeviceToken");
const tclear = document.getElementById("secDeviceTokenClear");
if (tclear) {
tclear.checked = false;
tclear.disabled = Boolean(s.device_token_locked_by_env);
}
if (token) {
token.value = "";
const lockedTok = Boolean(s.device_token_locked_by_env);
token.dataset.envLocked = lockedTok ? "1" : "";
token.disabled = lockedTok || Boolean(tclear && tclear.checked);
}
const note = document.getElementById("secDeviceTokenNote");
if (note) {
note.textContent = s.device_token_configured
? "DEVICE_TOKEN: задан. Смена — поле ниже или clear."
: "DEVICE_TOKEN: ∅. Публичная калибровка OFF.";
}
const ttl = document.getElementById("secKioskPairTtl");
if (ttl) {
ttl.value = String(s.kiosk_pair_token_ttl_seconds ?? 300);
ttl.disabled = Boolean(s.kiosk_pair_token_ttl_seconds_locked_by_env);
}
const ckd = document.getElementById("secKioskCookieDays");
if (ckd) {
ckd.value = String(s.kiosk_auth_cookie_days ?? 18250);
ckd.disabled = Boolean(s.kiosk_auth_cookie_days_locked_by_env);
}
const sess = document.getElementById("secSessionRememberDays");
if (sess) {
sess.value = String(s.session_remember_days ?? 7);
sess.disabled = Boolean(s.session_remember_days_locked_by_env);
}
const saveBtn = document.getElementById("securitySettingsSave");
if (saveBtn) {
const allLocked =
Boolean(s.mac_lock_enabled_locked_by_env) &&
Boolean(s.allowed_mac_addresses_locked_by_env) &&
Boolean(s.kiosk_enforce_paired_only_locked_by_env) &&
Boolean(s.calibration_public_locked_by_env) &&
Boolean(s.device_token_locked_by_env) &&
Boolean(s.device_token_header_locked_by_env) &&
Boolean(s.kiosk_pair_token_ttl_seconds_locked_by_env) &&
Boolean(s.kiosk_auth_cookie_days_locked_by_env) &&
Boolean(s.session_remember_days_locked_by_env);
saveBtn.disabled = allLocked;
}
}
async function saveSecuritySettingsFromForm() {
const el = (id) => document.getElementById(id);
const body = {};
const mac = el("secMacLock");
if (mac && !mac.disabled) body.mac_lock_enabled = mac.checked;
const macs = el("secAllowedMacs");
if (macs && !macs.disabled) body.allowed_mac_addresses = macs.value.trim();
const kEnf = el("secKioskEnforce");
if (kEnf && !kEnf.disabled) body.kiosk_enforce_paired_only = kEnf.checked;
const cal = el("secCalibrationPublic");
if (cal && !cal.disabled) body.calibration_public = cal.checked;
const th = el("secDeviceTokenHeader");
if (th && !th.disabled) body.device_token_header = th.value.trim();
const token = el("secDeviceToken");
const tclear = el("secDeviceTokenClear");
if (token && tclear && !tclear.disabled) {
if (tclear.checked) body.device_token_clear = true;
else if (token.value.trim()) body.device_token = token.value.trim();
}
const ttl = el("secKioskPairTtl");
if (ttl && !ttl.disabled) {
const n = parseInt(ttl.value, 10);
if (Number.isFinite(n)) body.kiosk_pair_token_ttl_seconds = n;
}
const ckd = el("secKioskCookieDays");
if (ckd && !ckd.disabled) {
const n = parseInt(ckd.value, 10);
if (Number.isFinite(n)) body.kiosk_auth_cookie_days = n;
}
const sess = el("secSessionRememberDays");
if (sess && !sess.disabled) {
const n = parseInt(sess.value, 10);
if (Number.isFinite(n)) body.session_remember_days = n;
}
if (Object.keys(body).length === 0) {
setStatus("security PATCH: noop (env lock / no delta)", true);
return;
}
const r = await fetch("/api/admin/security-settings", {
method: "PATCH",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
setStatus(d.message || "persist OK");
if (d.security) renderSecuritySectionFromSummary(d.security);
else await updateSummary();
}
function renderSyncQueueFromSummary(sync) {
const list = document.getElementById("syncQueueList");
if (!list || !sync) return;
const q = sync.queue || {};
const rows = [
["queue.total", String(q.total ?? "—")],
["queue.pending", String(q.pending ?? "—")],
["queue.processing", String(q.processing ?? "—")],
["queue.failed", String(q.failed ?? "—")],
["max_concurrent", String(sync.max_concurrent ?? "—")],
["client_poll_interval_s", String(sync.client_poll_interval_sec ?? "—")],
["client_poll_interval_initial_s", String(sync.client_poll_interval_initial_sec ?? "—")],
["client_pull_limit_steady", String(sync.client_pull_limit_steady ?? "—")],
["client_pull_limit_initial", String(sync.client_pull_limit_initial ?? "—")],
];
list.innerHTML = rows
.map(([k, v]) => wespAdmListItem(k, `<span class="wesp-adm-list-value">${escHtml(v)}</span>`))
.join("");
}
let lastSyncDiagnostics = null;
function applySyncSectionFromDiagnostics(diag) {
const o = diag.overview || {};
const cfg = o.config || {};
const q = diag.queues || {};
const stats = q.stats || {};
const syncLike = {
connection: o.connection,
state: o.client_state,
first_sync_banner: o.first_sync_banner,
initial_sync_progress: (diag.bootstrap || {}).initial_sync_progress || {},
queue_errors: q.recent_errors || [],
queue: {
total: stats.total,
pending: stats.pending,
processing: stats.processing,
failed: stats.failed,
completed: stats.completed,
},
max_concurrent: cfg.sync_max_concurrent,
client_poll_interval_sec: cfg.client_poll_interval_sec,
client_poll_interval_initial_sec: cfg.client_poll_interval_initial_sec,
client_pull_limit_steady: cfg.client_pull_limit_steady,
client_pull_limit_initial: cfg.client_pull_limit_initial,
};
renderSyncRuntimeNotices(o.connection || {});
applySyncConnectionForm(o.connection || {});
applySyncStateForm(o.client_state || {});
renderInitialSyncProgress(syncLike);
renderSyncQueueFromSummary(syncLike);
renderSyncQueueErrorsFromSummary(syncLike);
}
function renderSyncDiagByTable(byTable) {
const wrap = document.getElementById("syncDiagByTableWrap");
if (!wrap) return;
const tbl = byTable && typeof byTable === "object" ? byTable : {};
const tables = Object.keys(tbl).sort();
if (!tables.length) {
wrap.innerHTML = '<p class="wesp-sync-hint">Нет строк в sync_queue.</p>';
return;
}
const statuses = ["pending", "processing", "completed", "failed"];
let html = "<table><thead><tr><th>table</th>";
for (const s of statuses) html += `<th>${escHtml(s)}</th>`;
html += "<th>Σ</th></tr></thead><tbody>";
for (const t of tables) {
const row = tbl[t] || {};
let sum = 0;
html += `<tr><td><code>${escHtml(t)}</code></td>`;
for (const s of statuses) {
const n = Number(row[s] || 0);
sum += n;
html += `<td>${n || "—"}</td>`;
}
html += `<td><strong>${sum}</strong></td></tr>`;
}
html += "</tbody></table>";
wrap.innerHTML = html;
}
function renderSyncDiagProblems(diag) {
const wrap = document.getElementById("syncDiagProblemsWrap");
if (!wrap) return;
const q = diag.queues || {};
const chunks = [
{ title: "stuck processing (timeout)", rows: q.stuck_processing || [] },
{ title: "processing без processed_at", rows: q.processing_missing_processed_at || [] },
{ title: "failed (свежие)", rows: q.failed_recent || [] },
{ title: "pending с retry", rows: q.high_retry_pending || [] },
];
const all = [];
for (const ch of chunks) {
for (const r of ch.rows) {
all.push({ diagSection: ch.title, ...r });
}
}
if (!all.length) {
wrap.innerHTML =
'<p class="wesp-sync-hint">Проблемных задач не найдено (по текущим критериям).</p>';
return;
}
let html =
'<table><thead><tr><th>класс</th><th>table</th><th>id</th><th>status</th><th>retry</th><th>примечание</th></tr></thead><tbody>';
const slice = all.slice(0, 120);
for (const r of slice) {
const note = String(r.error_message || r.stuck_reason || "—").slice(0, 320);
html += `<tr><td>${escHtml(r.diagSection || "—")}</td><td><code>${escHtml(
String(r.table_name || "—"),
)}</code></td><td><code title="${escHtml(String(r.id || ""))}">${escHtml(
String(r.id || "").slice(0, 8),
)}…</code></td><td>${escHtml(String(r.status || "—"))}</td><td>${escHtml(
String(r.retry_count ?? "—"),
)}</td><td class="wesp-adm-list-error">${escHtml(note)}</td></tr>`;
}
html += "</tbody></table>";
if (all.length > 120) {
html += `<p class="wesp-sync-hint">… и ещё ${all.length - 120}</p>`;
}
wrap.innerHTML = html;
}
function renderSyncDiagnosticsFull(diag) {
lastSyncDiagnostics = diag;
const hint = document.getElementById("syncDiagHint");
if (hint) {
const at = diag.generated_at ? formatRuDateTimeFromIso(diag.generated_at) : "—";
hint.innerHTML = `Снимок: <strong>${escHtml(at)}</strong>. Метрики runtime — только этот воркер (см. ниже).`;
}
const ov = document.getElementById("syncDiagOverviewList");
if (ov) {
const o = diag.overview || {};
const cfg = o.config || {};
const meta = o.metadata || {};
const rows = [
["effective_role", ruSyncRoleLabel(o.effective_role || "")],
["server_url", String((o.connection || {}).server_url || "—")],
["sync_status (metadata)", String(meta.sync_status || "—")],
["node_id (metadata)", String(meta.node_id || "—")],
["last_sync (metadata)", meta.last_sync ? formatRuDateTimeFromIso(meta.last_sync) : "—"],
["requeue_timeout_min", String(cfg.sync_requeue_timeout_minutes ?? "—")],
["max_concurrent", String(cfg.sync_max_concurrent ?? "—")],
];
ov.innerHTML = rows
.map(([k, v]) =>
wespAdmListItem(k, `<span class="wesp-adm-list-value">${escHtml(String(v))}</span>`),
)
.join("");
}
const rt = document.getElementById("syncDiagRuntimeList");
if (rt) {
const r = diag.runtime || {};
const rows = [
["scope", escHtml(String(r.scope || "—"))],
[
"concurrency",
`${r.concurrency_used ?? "—"}/${r.concurrency_max ?? "—"} (avail ${r.concurrency_available ?? "—"})`,
],
["pull p95 s", String(r.pull_duration_p95_sec ?? "—")],
["pull samples", String(r.pull_duration_samples ?? "—")],
["rate_limit active clients", String(r.rate_limit_windows_with_activity ?? "—")],
["pull lock ipc", r.pull_lock_ipc_supported ? "yes" : "no"],
[
"ipc held / thread locks",
`${r.pull_lock_ipc_held_clients ?? "—"} / ${r.pull_lock_thread_clients ?? "—"}`,
],
];
let html = rows
.map(([k, v]) =>
wespAdmListItem(
k,
`<span class="wesp-adm-list-value">${escHtml(String(v))}</span>`,
),
)
.join("");
if (r.note) {
html += wespAdmListItem(
"note",
`<span class="wesp-sync-hint">${escHtml(String(r.note))}</span>`,
);
}
rt.innerHTML = html;
}
const qs = document.getElementById("syncDiagQueueStatsList");
if (qs) {
const st = (diag.queues || {}).stats || {};
const rows = [
["total", String(st.total ?? "—")],
["pending", String(st.pending ?? "—")],
["processing", String(st.processing ?? "—")],
["completed", String(st.completed ?? "—")],
["failed", String(st.failed ?? "—")],
];
if (st.by_priority) {
for (let p = 1; p <= 5; p++) {
const key = `priority_${p}`;
if (st.by_priority[key] != null) rows.push([key, String(st.by_priority[key])]);
}
}
qs.innerHTML = rows
.map(([k, v]) => wespAdmListItem(k, `<span class="wesp-adm-list-value">${escHtml(v)}</span>`))
.join("");
}
renderSyncDiagByTable((diag.queues || {}).by_table_status);
const boot = document.getElementById("syncDiagBootstrapList");
if (boot) {
const b = diag.bootstrap || {};
const es = b.engine_state;
const isp = b.initial_sync_progress || {};
const parts = [];
if (es) {
parts.push(
wespAdmListItem(
"bootstrap_completed_at",
`<span class="wesp-adm-list-value">${escHtml(String(es.universal_bootstrap_completed_at || "—"))}</span>`,
),
);
parts.push(
wespAdmListItem(
"bootstrap_cursor",
`<span class="wesp-adm-list-value">${escHtml(String(es.universal_bootstrap_cursor ?? "—"))}</span>`,
),
);
if (es.universal_bootstrap_last_error) {
parts.push(
wespAdmListItem(
"bootstrap_last_error",
`<span class="wesp-adm-list-error">${escHtml(String(es.universal_bootstrap_last_error).slice(0, 500))}</span>`,
),
);
}
} else {
parts.push(wespAdmListItem("engine_state", `<span class="wesp-adm-list-value">—</span>`));
}
parts.push(
wespAdmListItem(
"initial_sync.active",
`<span class="wesp-adm-list-value">${escHtml(String(Boolean(isp.active)))}</span>`,
),
);
if (isp.percent != null) {
parts.push(
wespAdmListItem(
"initial_sync.percent",
`<span class="wesp-adm-list-value">${escHtml(String(isp.percent))}</span>`,
),
);
}
boot.innerHTML = parts.join("");
}
const rp = document.getElementById("syncDiagReportPushList");
if (rp) {
const x = diag.report_push || {};
const parts = [];
parts.push(
wespAdmListItem("scope", `<span class="wesp-adm-list-value">${escHtml(String(x.scope || "—"))}</span>`),
);
if (x.note) {
parts.push(
wespAdmListItem("note", `<span class="wesp-adm-list-value">${escHtml(String(x.note))}</span>`),
);
}
if (x.tables && x.tables.length) {
parts.push(
wespAdmListItem(
"tables",
`<span class="wesp-adm-list-value">${escHtml(x.tables.join(", "))}</span>`,
),
);
}
if (x.by_status) {
parts.push(
wespAdmListItem(
"by_status",
`<span class="wesp-adm-list-value">${escHtml(JSON.stringify(x.by_status))}</span>`,
),
);
}
if (x.total != null) {
parts.push(
wespAdmListItem(
"total report tasks",
`<span class="wesp-adm-list-value">${escHtml(String(x.total))}</span>`,
),
);
}
const cr = x.client_runtime || {};
parts.push(
wespAdmListItem(
"sync_client init",
`<span class="wesp-adm-list-value">${cr.initialized ? "yes" : "no"}</span>`,
),
);
parts.push(
wespAdmListItem(
"sync_thread alive",
`<span class="wesp-adm-list-value">${cr.client_thread_alive ? "yes" : "no"}</span>`,
),
);
rp.innerHTML = parts.join("");
}
renderSyncDiagProblems(diag);
const cf = document.getElementById("syncDiagConflictsList");
if (cf) {
const c = diag.conflicts || {};
const s = c.summary || {};
const rec = Array.isArray(c.recent) ? c.recent : [];
const head = [
wespAdmListItem(
"conflicts.total",
`<span class="wesp-adm-list-value">${escHtml(String(s.total ?? "—"))}</span>`,
),
wespAdmListItem(
"pending",
`<span class="wesp-adm-list-value">${escHtml(String(s.pending ?? "—"))}</span>`,
),
];
const body = rec.length
? rec.map((x) =>
wespAdmListItem(
`${x.table_name || "—"} · ${String(x.record_id || "").slice(0, 8)}…`,
`<span class="wesp-adm-list-value">${escHtml(String(x.conflict_type || "—"))}</span>`,
`${String(x.resolution || "—")} · ${x.created_at ? formatRuDateTimeFromIso(x.created_at) : "—"}`,
),
)
: [wespAdmListEmpty("recent: ∅")];
cf.innerHTML = head.join("") + body.join("");
}
const cons = document.getElementById("syncDiagConsistencyList");
if (cons) {
const c = diag.consistency || {};
if (c.error) {
cons.innerHTML = wespAdmListItem(
"error",
`<span class="wesp-adm-list-error">${escHtml(c.error)}</span>`,
);
} else {
const snap = c.snapshot || {};
const mt = snap.master_tables || {};
const rows = Object.keys(mt)
.sort()
.map((t) => {
const z = mt[t] || {};
return wespAdmListItem(
`digest.${t}`,
`<span class="wesp-adm-list-value">${escHtml(String(z.digest || "").slice(0, 16))}…</span>`,
`count=${String(z.count ?? "—")}`,
);
});
const ir = snap.recipe_uuid_reference_issues;
if (ir != null) {
rows.push(
wespAdmListItem(
"recipe_uuid_reference_issues",
`<span class="wesp-adm-list-value">${escHtml(String(ir))}</span>`,
),
);
}
cons.innerHTML = rows.length ? rows.join("") : wespAdmListEmpty("snapshot: ∅");
}
}
const cx = document.getElementById("syncDiagClientsExtraList");
if (cx) {
const cl = diag.clients || {};
const deliv = cl.recent_deliveries || [];
const lines = [
wespAdmListSection("recent deliveries"),
...(deliv.length
? deliv.slice(0, 12).map((d) =>
wespAdmListItem(
`${String(d.task_id || "").slice(0, 8)}…`,
`<span class="wesp-adm-list-value">${escHtml(String(d.client_id || "").slice(0, 8))}…</span>`,
d.delivered_at ? formatRuDateTimeFromIso(d.delivered_at) : "—",
),
)
: [wespAdmListEmpty("deliveries: ∅")]),
];
cx.innerHTML = lines.join("");
}
const cap = diag.actions_capabilities || {};
const bRe = document.getElementById("syncActionRequeueStuck");
const bRes = document.getElementById("syncActionRestartLocal");
const bRt = document.getElementById("syncActionRefreshRuntime");
const bPu = document.getElementById("syncActionRetryPush");
if (bRe) bRe.disabled = !cap.requeue_stuck;
if (bRes) bRes.disabled = !cap.restart_local_sync;
if (bRt) bRt.disabled = !cap.refresh_diagnostics;
if (bPu) bPu.disabled = !cap.retry_report_push;
}
async function loadSyncDiagnostics() {
const r = await fetch("/api/admin/sync-diagnostics");
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.message || `sync-diagnostics ${r.status}`);
applySyncSectionFromDiagnostics(data);
renderSyncDiagnosticsFull(data);
await loadSyncClientsTable();
}
function splitServerUrlForForm(urlStr) {
const u = String(urlStr || "").trim();
let scheme = "https";
let host = "";
if (!u) return { scheme, host };
const m = u.match(/^(https?):\/\/(.*)$/i);
if (m) {
scheme = m[1].toLowerCase() === "http" ? "http" : "https";
host = (m[2] || "").replace(/\/+$/, "");
} else {
host = u.replace(/^\/+/, "").replace(/\/+$/, "");
}
return { scheme, host };
}
function normalizeSyncHostInput(raw) {
let h = String(raw || "").trim();
if (!h) return "";
h = h.replace(/^https?:\/\//i, "").replace(/\/+$/, "");
return h;
}
function buildServerUrlFromForm() {
const schemeSel = document.getElementById("syncSettingsServerScheme");
const hostInp = document.getElementById("syncSettingsServerHost");
const scheme = schemeSel && schemeSel.value === "http" ? "http" : "https";
const host = normalizeSyncHostInput(hostInp && hostInp.value);
if (!host) return "";
return `${scheme}://${host}`;
}
function renderSyncQueueErrorsFromSummary(sync) {
const list = document.getElementById("syncErrorsList");
if (!list || !sync) return;
const rows = sync.queue_errors || [];
if (!rows.length) {
list.innerHTML = wespAdmListEmpty("sync errors: ∅");
return;
}
list.innerHTML = rows
.map((r) => {
const at = r.at ? formatRuDateTimeFromIso(r.at) : "—";
const errPlain = r.error || "";
const err = escHtml(errPlain);
const title = [r.table_name || "—", r.record_id || "—"].filter(Boolean).join(" · ");
const desc = `${at} · op=${r.action || "—"}`;
return wespAdmListItem(
title,
`<span class="wesp-sync-error-cell wesp-adm-list-error" title="${err}">${err}</span>`,
desc,
);
})
.join("");
}
function renderSyncRuntimeNotices(conn) {
const wrap = document.getElementById("syncRuntimeNotices");
if (!wrap) return;
const c = conn && typeof conn === "object" ? conn : {};
const blocks = [];
if (c.role_locked_by_env) {
const v =
c.env_role != null && String(c.env_role).trim()
? escHtml(String(c.env_role).trim())
: escHtml(String(c.role || ""));
blocks.push(
`<div class="ant-alert ant-alert-warning" style="margin-bottom:8px" role="alert"><strong>WESP_SYNC_ROLE</strong> задан в окружении: роль из формы не сохраняется. Сейчас действует: <code>${v}</code>. Уберите переменную из окружения (unit/systemd), чтобы менять роль из админки.</div>`,
);
}
if (c.server_url_locked_by_env) {
const v =
c.env_server_url != null && String(c.env_server_url).trim()
? escHtml(String(c.env_server_url).trim())
: escHtml(String(c.server_url || ""));
blocks.push(
`<div class="ant-alert ant-alert-warning" style="margin-bottom:8px" role="alert"><strong>WESP_SYNC_SERVER_URL</strong> задан в окружении: адрес из формы не сохраняется. Сейчас действует: <code>${v}</code>. Уберите переменную из окружения, чтобы задавать URL из админки.</div>`,
);
}
if (c.sync_client_autostart === false) {
blocks.push(
`<div class="ant-alert ant-alert-warning" style="margin-bottom:8px" role="alert"><strong>Фоновый sync выключен</strong> (<code>SYNC_CLIENT_AUTOSTART</code> / <code>WESP_SYNC_CLIENT_AUTOSTART=0</code>). Узел не опрашивает сервер в фоне; запись pull в локальную БД не выполняется. Включите автостарт в конфиге или в окружении и перезапустите службу WESP.</div>`,
);
}
if (String(c.role || "").toLowerCase() === "client" && c.sync_server_url_explicit === false) {
blocks.push(
`<div class="ant-alert ant-alert-error" style="margin-bottom:8px" role="alert"><strong>Клиент без адреса главного узла</strong>. Фоновый sync <em>не запускается</em>, пока не задан URL (поле ниже или <code>WESP_SYNC_SERVER_URL</code> в окружении и сохранение). Например <code>http://192.168.0.10</code> или <code>http://komton_srv_1.local</code>.</div>`,
);
}
wrap.innerHTML = blocks.join("");
wrap.style.display = blocks.length ? "block" : "none";
}
function applySyncConnectionForm(conn) {
const hint = document.getElementById("syncConnectionHint");
const roleSel = document.getElementById("syncSettingsRole");
const schemeSel = document.getElementById("syncSettingsServerScheme");
const hostInp = document.getElementById("syncSettingsServerHost");
const saveBtn = document.getElementById("syncSettingsSave");
if (!roleSel || !schemeSel || !hostInp) return;
const c = conn && typeof conn === "object" ? conn : {};
const r = String(c.role || "server").toLowerCase();
roleSel.value = ["server", "client", "other"].includes(r) ? r : "server";
const parts = splitServerUrlForForm(c.server_url || "");
schemeSel.value = parts.scheme === "http" ? "http" : "https";
hostInp.value = parts.host;
const lockParts = [];
if (c.role_locked_by_env) lockParts.push("role ← env WESP_SYNC_ROLE");
if (c.server_url_locked_by_env) lockParts.push("server_url ← env WESP_SYNC_SERVER_URL");
const autostartOff = c.sync_client_autostart === false;
if (hint) {
if (lockParts.length) {
hint.textContent = `read-only: ${lockParts.join("; ")}. Поля client_id / client_name / first_bootstrap_done можно сохранить в файл состояния. Подробности — в жёлтых блоках выше.`;
} else if (autostartOff) {
hint.textContent =
"Сохранение записывает sync_client_state.json; фоновый опрос сервера отключён — см. предупреждение выше.";
} else {
hint.textContent =
"Настройка синхронизации.";
}
}
roleSel.disabled = Boolean(c.role_locked_by_env);
schemeSel.disabled = Boolean(c.server_url_locked_by_env);
hostInp.disabled = Boolean(c.server_url_locked_by_env);
if (saveBtn) {
saveBtn.disabled = false;
}
renderSyncRuntimeNotices(c);
}
function applySyncStateForm(state) {
const idInp = document.getElementById("syncSettingsClientId");
const nameInp = document.getElementById("syncSettingsClientName");
const bootChk = document.getElementById("syncSettingsFirstBootstrapDone");
if (!idInp || !nameInp || !bootChk) return;
const s = state && typeof state === "object" ? state : {};
idInp.value = s.client_id != null ? String(s.client_id) : "";
nameInp.value = s.client_name != null ? String(s.client_name) : "";
bootChk.checked = Boolean(s.first_bootstrap_done);
}
function formatRuEtaSeconds(sec) {
if (sec == null || Number.isNaN(Number(sec))) return "—";
const s = Math.max(0, Math.ceil(Number(sec)));
if (s < 60) return `${s} с`;
if (s < 3600) return `${Math.floor(s / 60)} мин ${s % 60} с`;
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
return `${h} ч ${m} мин`;
}
function renderInitialSyncProgress(sync) {
const wrap = document.getElementById("syncInitialSyncProgressWrap");
const fill = document.getElementById("syncInitialSyncProgressFill");
const txt = document.getElementById("syncInitialSyncProgressText");
if (!wrap || !fill || !txt) return;
const p = (sync && sync.initial_sync_progress) || {};
const show = Boolean(sync && sync.first_sync_banner && p.active);
wrap.style.display = show ? "block" : "none";
if (!show) return;
const pctRaw = p.percent != null ? Number(p.percent) : null;
const pct = pctRaw != null && !Number.isNaN(pctRaw) ? Math.min(100, Math.max(0, pctRaw)) : null;
fill.style.width = pct != null ? `${pct}%` : "0%";
const parts = [];
if (pct != null) parts.push(`${pct.toFixed(1)}%`);
if (p.tasks_applied != null && p.tasks_total_estimate != null) {
parts.push(`применено локально: ${p.tasks_applied} / ${p.tasks_total_estimate} (оценка по серверу)`);
} else if (p.tasks_applied != null) {
parts.push(`применено локально: ${p.tasks_applied}`);
}
if (p.tasks_per_sec != null && Number(p.tasks_per_sec) > 0) {
parts.push(`~${Number(p.tasks_per_sec).toFixed(1)} задач/с`);
}
if (p.eta_sec != null && Number(p.eta_sec) >= 0) {
parts.push(`осталось ~${formatRuEtaSeconds(p.eta_sec)}`);
} else {
parts.push("осталось: оценка…");
}
if (p.elapsed_sec != null) parts.push(`прошло ${formatRuEtaSeconds(p.elapsed_sec)}`);
txt.textContent = parts.join(" · ");
}
function renderSyncSectionFromSummary(sync) {
const banner = document.getElementById("syncInitialSyncBanner");
if (banner) {
banner.style.display = sync && sync.first_sync_banner ? "block" : "none";
}
renderInitialSyncProgress(sync || {});
renderSyncQueueFromSummary(sync);
renderSyncQueueErrorsFromSummary(sync);
applySyncConnectionForm(sync?.connection || {});
applySyncStateForm(sync?.state || {});
}
function diagOkBadge(ok) {
return ok
? '<span class="wesp-diag-ok">OK</span>'
: '<span class="wesp-diag-bad">Ошибка</span>';
}
function makeDiagTargetRow(target = {}) {
const role = escHtml(target.role || "");
const host = escHtml(target.host || "");
const title = escHtml(target.title || "");
return `<div class="wesp-diagnostics-target-row" data-diag-target-row>
<input type="text" class="ant-input" data-diag-role value="${role}" placeholder="role tag" />
<input type="text" class="ant-input" data-diag-host value="${host}" placeholder="FQDN / IP" />
<div class="wesp-adm-list-actions">
<input type="text" class="ant-input" data-diag-title value="${title}" placeholder="label" />
<button type="button" class="ant-btn ant-btn-sm ant-btn-danger" data-diag-remove-target title="Удалить">×</button>
</div>
</div>`;
}
function renderDiagExtraTargetsRows() {
const wrap = document.getElementById("diagExtraTargetsRows");
if (!wrap) return;
const rows = Array.isArray(diagState.extraTargets) ? diagState.extraTargets : [];
if (!rows.length) {
wrap.innerHTML = makeDiagTargetRow({});
return;
}
wrap.innerHTML = rows.map((x) => makeDiagTargetRow(x)).join("");
}
function collectDiagExtraTargetsFromForm() {
const wrap = document.getElementById("diagExtraTargetsRows");
if (!wrap) return [];
const out = [];
wrap.querySelectorAll("[data-diag-target-row]").forEach((row) => {
const role = row.querySelector("[data-diag-role]")?.value?.trim() || "";
const host = row.querySelector("[data-diag-host]")?.value?.trim() || "";
const title = row.querySelector("[data-diag-title]")?.value?.trim() || "";
if (!host) return;
out.push({ role, host, title: title || role || host });
});
return out;
}
function setDiagnosticsSecurityListLoading() {
const sec = document.getElementById("diagSecurityList");
if (!sec) return;
sec.innerHTML = wespAdmListEmpty("загрузка security_checks…");
}
function renderDiagnosticsSecurity(report) {
const sec = document.getElementById("diagSecurityList");
if (!sec) return;
const rpt = report && typeof report === "object" ? report : {};
const secChecks = Array.isArray(rpt.security_checks) ? rpt.security_checks : [];
if (!secChecks.length) {
const errMsg = rpt.message || rpt.error;
sec.innerHTML = wespAdmListEmpty(
errMsg
? `security_checks: нет (${String(errMsg).slice(0, 200)})`
: "security_checks: нет — откройте раздел «Диагностика» заново или нажмите «Обновить отчёт»",
);
return;
}
sec.innerHTML = secChecks
.map((x) =>
wespAdmListItem(
x.title || x.id || "check",
diagOkBadge(Boolean(x.ok)),
x.detail || "",
),
)
.join("");
}
function renderDiagnosticsOverview(report) {
const overview = document.getElementById("diagOverviewList");
const nodeHint = document.getElementById("diagNodeHint");
const primary = document.getElementById("diagPrimaryTargetsList");
const hw = document.getElementById("diagHardwareList");
const path = document.getElementById("diagSettingsPath");
if (!overview || !primary || !hw) return;
const rpt = report && typeof report === "object" ? report : {};
const hwData = rpt.hardware || {};
if (nodeHint) nodeHint.textContent = rpt.node_hint || "";
if (path) path.textContent = rpt.paths?.network_settings_path ? `path: ${rpt.paths.network_settings_path}` : "";
const roleRu = ruSyncRoleLabel(rpt.node_role);
overview.innerHTML = [
wespAdmListSection("LAN / node"),
wespAdmListItem("node_role", `<span class="wesp-adm-list-value">${escHtml(roleRu)}</span>`),
wespAdmListItem(
"primary_targets.n",
`<span class="wesp-adm-list-value">${escHtml(String((rpt.primary_targets || []).length))}</span>`,
),
wespAdmListItem(
"extra_targets.n",
`<span class="wesp-adm-list-value">${escHtml(String((rpt.extra_targets || []).length))}</span>`,
),
wespAdmListSection("Security"),
...buildDiagSecurityOverviewRows(rpt),
wespAdmListSection("Hardware"),
...buildDiagHardwareOverviewRows(hwData),
].join("");
const warnings = Array.isArray(rpt.warnings) ? rpt.warnings : [];
const targets = Array.isArray(rpt.primary_targets) ? rpt.primary_targets : [];
primary.innerHTML = "";
if (targets.length === 0) {
primary.innerHTML += wespAdmListEmpty("primary_targets: ∅");
} else {
primary.innerHTML += targets
.map((t) =>
wespAdmListItem(
t.title || t.host || "—",
`<span class="wesp-adm-list-value">${escHtml(t.host || "—")}</span>`,
`kind: ${t.kind || "target"} · node_id: ${t.meta?.node_id || "—"} · last_seen: ${t.meta?.last_seen ? formatRuDateTimeFromIso(t.meta.last_seen) : "—"}`,
),
)
.join("");
}
if (warnings.length) {
primary.innerHTML += warnings
.map((w) =>
wespAdmListItem("warning", '<span class="wesp-diag-bad">!</span>', String(w || "")),
)
.join("");
}
const sim = Boolean(hwData.simulation_mode);
const gpioOk = Boolean(hwData.rpi_gpio_available);
const hxOk = Boolean(hwData.hx711_driver_ready);
const driversAbsent = Boolean(hwData.drivers_absent);
const modeLine = sim
? "SIM=1: SW ADC/GPIO"
: driversAbsent
? "SIM=0; RPi.GPIO+HX711 ∅ (x86?). mass=0; dev WESP_SIMULATION_MODE=1."
: "SIM=0; target: BCM SoC + peripherals";
const gpioLine = sim
? "N/A (sim)"
: gpioOk
? "RPi.GPIO: LED out, BCM"
: "RPi.GPIO: fail (not Pi / pkg)";
const hxLine = sim
? "N/A (sim)"
: hxOk
? "HX711: init OK"
: "HX711: fail; mass N/A";
const badgeSim = '<span class="wesp-sync-hint" style="font-size:11px">sim</span>';
const gpioBadge = sim ? badgeSim : diagOkBadge(gpioOk);
const hxBadge = sim ? badgeSim : diagOkBadge(hxOk);
hw.innerHTML = [
wespAdmListItem("MODE", `<span class="wesp-adm-list-value">${escHtml(modeLine)}</span>`),
wespAdmListItem(
"GPIO LED",
`${gpioBadge}<span class="wesp-adm-list-value" style="margin-left:8px">${escHtml(gpioLine)}</span>`,
),
wespAdmListItem(
"HX711",
`${hxBadge}<span class="wesp-adm-list-value" style="margin-left:8px">${escHtml(hxLine)}</span>`,
),
wespAdmListItem(
"BCM pins",
`<span class="wesp-adm-list-value">LED ${escHtml(String(hwData.gpio_led_pin ?? "—"))} · DOUT ${escHtml(String(hwData.hx711_dout_pin ?? "—"))} / SCK ${escHtml(String(hwData.hx711_pd_sck_pin ?? "—"))}</span>`,
),
].join("");
}
function renderDiagnosticsNetworkResults(payload) {
const list = document.getElementById("diagNetworkResultsList");
if (!list) return;
const body = payload && typeof payload === "object" ? payload : {};
const rows = Array.isArray(body.results) ? body.results : [];
const internet = Array.isArray(body.internet) ? body.internet : [];
const networkRows = rows.map((r) => {
const ping = r.probes?.ping?.ok ? "ping OK" : "ping fail";
const tcpProbe = r.probes?.tcp ?? r.probes?.tcp80;
const tcpPort = tcpProbe?.port ?? wespListenPortFallback();
const tcp = tcpProbe?.ok ? `tcp:${tcpPort} OK` : `tcp:${tcpPort} fail`;
const trace = r.probes?.trace
? r.probes.trace.ok
? `trace OK (${r.probes.trace.tool || "tool"})`
: `trace fail (${r.probes.trace.tool || "tool"})`
: "trace: off";
return wespAdmListItem(
r.title || r.host || "—",
diagOkBadge(Boolean(r.ok)),
`${r.host || "—"} · ${ping} · ${tcp} · ${trace}`,
);
});
const internetRows = internet.map((it) =>
wespAdmListItem(
`Internet: ${it.url || "—"}`,
diagOkBadge(Boolean(it.ok)),
it.status_code != null ? `HTTP ${it.status_code}` : String(it.error || "no response"),
),
);
const allRows = [];
if (networkRows.length) {
allRows.push(wespAdmListSection("LAN / node"));
allRows.push(...networkRows);
}
if (internetRows.length) {
allRows.push(wespAdmListSection("WAN / egress"));
allRows.push(...internetRows);
}
list.innerHTML = allRows.length ? allRows.join("") : wespAdmListEmpty("results: ∅");
}
async function loadDiagnosticsReport() {
setDiagnosticsSecurityListLoading();
const r = await fetch("/api/admin/diagnostics/report", { credentials: "same-origin" });
const d = await r.json().catch(() => ({}));
if (!r.ok) {
renderDiagnosticsSecurity({ message: d.message || `HTTP ${r.status}` });
throw new Error(d.message || `Ошибка ${r.status}`);
}
diagState.report = d;
diagState.extraTargets = Array.isArray(d.extra_targets) ? d.extra_targets : [];
try {
renderDiagnosticsSecurity(d);
} catch (e) {
renderDiagnosticsSecurity({ message: e?.message || "render security" });
throw e;
}
try {
renderDiagExtraTargetsRows();
} catch (e) {
setStatus(e?.message, true, "extra_targets render");
}
try {
renderDiagnosticsOverview(d);
} catch (e) {
setStatus(e?.message, true, "overview render");
}
try {
renderDiagnosticsNetworkResults({});
} catch (e) {
setStatus(e?.message, true, "network results render");
}
}
async function saveDiagnosticsSettings() {
const extraTargets = collectDiagExtraTargetsFromForm();
const r = await fetch("/api/admin/diagnostics/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ extra_targets: extraTargets }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
diagState.extraTargets = Array.isArray(d.extra_targets) ? d.extra_targets : extraTargets;
renderDiagExtraTargetsRows();
setStatus(d.message || "persist OK");
}
async function runDiagnosticsNetwork(scope, runTrace = false, customTargets = null) {
const body = {
scope,
run_trace: runTrace,
timeout_sec: 3,
prefer_mtr: true,
tcp_port: wespListenPortFallback(),
};
if (scope === "custom" && Array.isArray(customTargets)) {
body.custom_targets = customTargets;
}
const r = await fetch("/api/admin/diagnostics/network-run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
renderDiagnosticsNetworkResults(d);
setStatus("network-run: OK");
}
async function runDiagnosticsHxSample(listId = "diagHardwareList") {
const list = document.getElementById(listId);
const r = await fetch("/api/admin/diagnostics/hx711-sample", { method: "POST" });
const d = await r.json().catch(() => ({}));
if (list) {
const prev = list.innerHTML;
if (!r.ok) {
const row = wespAdmListItem(
"HX711 sample",
diagOkBadge(false),
`${escHtml(d.message || `Ошибка ${r.status}`)} · DOUT ${escHtml(String(d.pins?.dout ?? "—"))} · SCK ${escHtml(String(d.pins?.pd_sck ?? "—"))}`,
);
list.innerHTML = `${row}${prev}`;
setStatus(d.message || `Ошибка ${r.status}`, true);
return;
}
const row = wespAdmListItem(
"HX711 sample",
diagOkBadge(true),
`sample=${d.sample} · DOUT ${d.pins?.dout ?? "—"} · SCK ${d.pins?.pd_sck ?? "—"} · driver: ${d.simulation_mode ? "sim" : d.driver_ready ? "ready" : "—"}`,
);
list.innerHTML = `${row}${prev}`;
}
if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
setStatus("HX711 sample: OK");
}
async function runDiagnosticsGpioBlink(listId = "diagHardwareList") {
const list = document.getElementById(listId);
const r = await fetch("/api/admin/diagnostics/gpio-blink", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ times: 3, delay: 0.2 }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
if (list) {
const prev = list.innerHTML;
const gpioMode = d.simulation_mode
? "sim"
: d.rpi_gpio_available
? "RPi.GPIO hw"
: "RPi.GPIO ∅ (soft-only)";
const row = wespAdmListItem(
"GPIO blink",
diagOkBadge(true),
`${d.message || "started"} · pin ${d.pin ?? "—"} · ${gpioMode}`,
);
list.innerHTML = `${row}${prev}`;
}
setStatus("GPIO blink: dispatched");
}
function openTracerouteModal() {
const m = document.getElementById("tracerouteModal");
if (!m) return;
m.hidden = false;
document.body.style.overflow = "hidden";
}
function closeTracerouteModal() {
const m = document.getElementById("tracerouteModal");
if (m) m.hidden = true;
document.body.style.overflow = "";
}
function fillTracerouteHostsFromPrimary() {
const ta = document.getElementById("tracerouteHostsInput");
if (!ta) return;
const rpt = diagState.report;
const targets = Array.isArray(rpt?.primary_targets) ? rpt.primary_targets : [];
const hosts = [...new Set(targets.map((t) => (t.host || "").trim()).filter(Boolean))];
ta.value = hosts.join("\n");
if (!hosts.length) {
setStatus("primary_targets: ∅; ввод вручную или отчёт диагностики", true);
}
}
function renderTracerouteResults(data) {
const wrap = document.getElementById("tracerouteModalResults");
if (!wrap) return;
const results = Array.isArray(data.results) ? data.results : [];
if (!results.length) {
wrap.innerHTML = '<p class="wesp-sync-hint">results: ∅</p>';
return;
}
let html = "";
for (const row of results) {
const host = escHtml(row.host);
if (row.validate_error) {
html += `<div class="wesp-admin-traceroute-target"><strong>${host}</strong> — <span style="color:#cf1322">${escHtml(row.validate_error)}</span></div>`;
continue;
}
const tr = row.trace || {};
const okLabel = tr.ok ? "OK" : "fail";
const tool = escHtml(tr.tool || "—");
const pingLbl = row.ping ? (row.ping.ok ? "ping OK" : "ping fail") : null;
const tcpP = row.tcp?.port ?? wespListenPortFallback();
const tcpLbl = row.tcp ? (row.tcp.ok ? `tcp:${tcpP} OK` : `tcp:${tcpP} fail`) : null;
const probeBits = [pingLbl, tcpLbl].filter(Boolean).join(" · ");
let body = `<div class="wesp-admin-traceroute-target"><div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:6px;"><strong>${host}</strong>`;
if (probeBits) {
body += `<span style="font-size:11px;opacity:0.9;">${escHtml(probeBits)}</span>`;
}
body += `<span style="font-size:11px;padding:2px 6px;border-radius:4px;opacity:0.85;border:1px solid currentColor;">${tool}</span><span style="font-size:11px;">${okLabel}</span>`;
if (tr.exit_code != null && tr.exit_code !== "") {
body += `<span style="font-size:11px;">exit ${escHtml(tr.exit_code)}</span>`;
}
if (tr.error) body += `<span style="color:#cf1322;font-size:11px;">${escHtml(tr.error)}</span>`;
body += "</div>";
const pr = row.parse;
if (pr && pr.hops && pr.hops.length) {
const st = pr.stats || {};
body += `<p class="wesp-sync-hint" style="margin:0 0 6px;">hops=${escHtml(st.total_hops ?? pr.hops.length)} · private=${escHtml(st.private_count ?? "—")} · max_rtt_ms=${escHtml(st.max_delay_ms ?? "—")}</p>`;
body +=
'<table style="width:100%;border-collapse:collapse;font-size:11px;"><thead><tr><th style="text-align:left;padding:4px;border-bottom:1px solid rgba(0,0,0,.12);">ttl</th><th style="text-align:left;padding:4px;border-bottom:1px solid rgba(0,0,0,.12);">addr</th><th style="text-align:left;padding:4px;border-bottom:1px solid rgba(0,0,0,.12);">net</th><th style="text-align:left;padding:4px;border-bottom:1px solid rgba(0,0,0,.12);">avg_ms</th></tr></thead><tbody>';
for (const h of pr.hops) {
const nt =
h.network_type === "private" ? "RFC1918" : h.network_type === "public" ? "WAN" : "?";
const avg = h.avg_ms != null ? escHtml(h.avg_ms) : "—";
body += `<tr><td style="padding:4px;">${escHtml(h.num)}</td><td style="padding:4px;font-family:ui-monospace,monospace;">${escHtml(h.ip)}</td><td style="padding:4px;">${nt}</td><td style="padding:4px;">${avg}</td></tr>`;
}
body += "</tbody></table>";
} else if (tr.lines && tr.lines.length) {
body += `<pre class="wesp-admin-traceroute-pre">${escHtml(tr.lines.join("\n"))}</pre>`;
} else {
body += '<p class="wesp-sync-hint">stdout: ∅</p>';
}
body += "</div>";
html += body;
}
wrap.innerHTML = html;
}
async function runTracerouteByHostsModal() {
const ta = document.getElementById("tracerouteHostsInput");
const wrap = document.getElementById("tracerouteModalResults");
if (!ta || !wrap) return;
const text = ta.value.trim();
if (!text) {
setStatus("hosts: требуется ≥1 строка FQDN/IP", true);
return;
}
wrap.innerHTML = '<p class="wesp-sync-hint">traceroute: running…</p>';
let r;
let d = {};
try {
r = await fetch("/api/admin/diagnostics/traceroute-by-hosts", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ hosts: text, timeout_sec: 12, prefer_mtr: true, tcp_port: wespListenPortFallback() }),
});
d = await r.json().catch(() => ({}));
} catch (e) {
wrap.innerHTML = `<p class="wesp-sync-hint" style="color:#cf1322">${escHtml(userHint(e.message, "Не удалось загрузить данные сети"))}</p>`;
return;
}
if (!r.ok || d.status !== "success") {
wrap.innerHTML = `<p class="wesp-sync-hint" style="color:#cf1322">${escHtml(d.message || `Ошибка ${r.status}`)}</p>`;
return;
}
renderTracerouteResults(d);
setStatus("traceroute: OK");
}
function openTracerouteModalFromDiagnostics() {
openTracerouteModal();
const wrap = document.getElementById("tracerouteModalResults");
if (wrap) wrap.innerHTML = "";
fillTracerouteHostsFromPrimary();
}
async function loadSyncClientsTable() {
const list = document.getElementById("syncClientsList");
if (!list) return;
let d;
try {
const r = await fetch("/api/sync/clients");
d = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(d.message || `Ошибка ${r.status}`);
}
} catch (e) {
list.innerHTML = wespAdmListEmpty(userHint(e.message, "Не удалось загрузить список"));
return;
}
if (!Array.isArray(d) || d.length === 0) {
list.innerHTML = wespAdmListEmpty("SyncClient[]: ∅");
return;
}
list.innerHTML = d
.map((c) => {
const title =
(c.display_name && String(c.display_name).trim()) || c.client_name || "—";
const node = escHtml(c.node_id || "—");
const ip = escHtml(c.ip_address || "—");
const seen = c.last_seen ? formatRuDateTimeFromIso(c.last_seen) : "—";
const st = ruSyncClientStatus(c.status);
const nsync = String(c.total_syncs ?? "0");
const nid = String(c.node_id || "").replace(/"/g, "&quot;");
const errPlain = c.sync_error && String(c.sync_error).trim();
const descParts = [
`node_id=${c.node_id || "—"}`,
`ip=${c.ip_address || "—"}`,
`last_seen=${seen}`,
`status=${st}`,
`sync_count=${nsync}`,
];
if (errPlain) descParts.push(`err=${errPlain}`);
const desc = descParts.join(" · ");
const extra = `<div class="wesp-adm-list-actions wesp-sync-client-actions">
<button type="button" class="ant-btn ant-btn-sm" data-sync-client-action="set-ip"><span>IP</span></button>
<button type="button" class="ant-btn ant-btn-sm" data-sync-client-action="rename"><span>Имя</span></button>
<button type="button" class="ant-btn ant-btn-sm ant-btn-danger" data-sync-client-action="delete"><span>Удалить</span></button>
</div>`;
return wespAdmListItem(title, extra, desc, { dataAttrs: `data-node-id="${nid}"` });
})
.join("");
}
async function saveSyncSettingsFromForm() {
const roleSel = document.getElementById("syncSettingsRole");
const hostInp = document.getElementById("syncSettingsServerHost");
const cidInp = document.getElementById("syncSettingsClientId");
const nameInp = document.getElementById("syncSettingsClientName");
const bootChk = document.getElementById("syncSettingsFirstBootstrapDone");
if (!roleSel || !hostInp) return;
const body = {};
if (!roleSel.disabled) body.role = roleSel.value;
if (!hostInp.disabled) body.server_url = buildServerUrlFromForm();
if (cidInp) body.client_id = cidInp.value.trim();
if (nameInp) body.client_name = nameInp.value.trim();
if (bootChk) body.first_bootstrap_done = bootChk.checked;
if (Object.keys(body).length === 0) {
setStatus("sync form: нечего сохранить", true);
return;
}
const r = await fetch("/api/admin/sync-settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(d.message || `Ошибка ${r.status}`);
}
setStatus(d.message || "persist OK");
if (d.sync_reloaded) {
setStatus(d.message || "Сохранено. Синхронизация перезапущена.");
}
if (d.state) {
applySyncStateForm(d.state);
}
await loadSyncDiagnostics();
}
async function loadOrchestratorSyncSettings() {
const r = await fetch("/api/admin/orchestrator-sync");
const d = await r.json().catch(() => ({}));
if (!r.ok) return;
const s = d.orchestrator_sync || {};
const urlEl = document.getElementById("orchSyncUpstreamUrl");
const hubEl = document.getElementById("orchSyncHubSiteId");
if (urlEl) urlEl.value = s.upstream_url || "";
if (hubEl) hubEl.value = s.hub_site_id || "";
}
async function saveOrchestratorSyncFromForm() {
const body = {
upstream_url: document.getElementById("orchSyncUpstreamUrl")?.value?.trim() || "",
hub_site_id: document.getElementById("orchSyncHubSiteId")?.value?.trim() || "",
};
const key = document.getElementById("orchSyncApiKey")?.value?.trim();
if (key) body.api_key = key;
const r = await fetch("/api/admin/orchestrator-sync", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const d = await r.json().catch(() => ({}));
const msg = document.getElementById("orchSyncMessage");
if (!r.ok) {
if (msg) msg.textContent = d.message || `Ошибка ${r.status}`;
throw new Error(d.message || `Ошибка ${r.status}`);
}
if (msg) msg.textContent = d.message || "Сохранено";
const keyEl = document.getElementById("orchSyncApiKey");
if (keyEl) keyEl.value = "";
await loadOrchestratorSyncSettings();
}
async function syncClientsTableAction(event) {
const btn = event.target.closest("[data-sync-client-action]");
if (!btn) return;
const row = event.target.closest("li[data-node-id]");
if (!row || !row.dataset.nodeId) return;
const nodeId = row.dataset.nodeId;
const action = btn.dataset.syncClientAction;
if (action === "set-ip") {
const curDesc = row.querySelector(".wesp-adm-list-desc")?.textContent || "";
const curMatch = /IP:\s*([^·]+)/.exec(curDesc);
const cur = curMatch ? String(curMatch[1]).trim() : "";
const next = window.prompt(
"IP или хост клиента (для трассировки). Пусто — очистить:",
cur === "—" ? "" : cur,
);
if (next === null) return;
const r = await fetch(`/api/admin/sync-clients/${encodeURIComponent(nodeId)}/ip`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ip_address: next.trim() }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
setStatus("SyncClient.ip: OK");
await loadSyncClientsTable();
return;
}
if (action === "rename") {
const cur = row.querySelector(".wesp-adm-list-title")?.textContent?.trim() || "";
const name = window.prompt("Отображаемое имя клиента:", cur);
if (name === null) return;
const r = await fetch(`/api/sync/clients/${encodeURIComponent(nodeId)}/display_name`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ display_name: name.trim() }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
setStatus("SyncClient.name: OK");
await loadSyncClientsTable();
return;
}
if (action === "delete") {
if (!window.confirm(`Удалить клиента ${nodeId} из реестра?`)) return;
const r = await fetch(`/api/sync/clients/${encodeURIComponent(nodeId)}`, { method: "DELETE" });
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
setStatus("SyncClient: deleted");
await loadSyncClientsTable();
}
}
async function createUser(event) {
event.preventDefault();
const login = document.getElementById("newUserLogin")?.value?.trim();
const password = document.getElementById("newUserPassword")?.value || "";
const isSuperuser = Boolean(document.getElementById("newUserIsSuper")?.checked);
const response = await fetch("/api/admin/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ login, password, is_superuser: isSuperuser }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "Не удалось создать пользователя");
}
event.target.reset();
setStatus("WebUser: created");
await loadUsers();
await updateSummary();
}
async function usersTableAction(event) {
const actionEl = event.target.closest("[data-action]");
if (actionEl) {
const row = event.target.closest("li[data-user-id]");
if (!row) return;
const userId = row.dataset.userId;
const action = actionEl.dataset.action;
if (action === "set-password") {
const password = window.prompt("Новый пароль (минимум 8 символов, буквы и цифры):", "");
if (!password) return;
const response = await fetch(`/api/admin/users/${userId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || "Не удалось обновить пароль");
setStatus("password_hash: OK");
return;
}
if (action === "toggle-super") {
const targetSuper = row.dataset.isSuper !== "1";
const response = await fetch(`/api/admin/users/${userId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_superuser: targetSuper }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || "Не удалось изменить роль");
setStatus("is_superuser: OK");
await loadUsers();
await updateSummary();
return;
}
if (action === "delete-user") {
const ok = window.confirm("Удалить пользователя?");
if (!ok) return;
const response = await fetch(`/api/admin/users/${userId}`, { method: "DELETE" });
const data = await response.json();
if (!response.ok) throw new Error(data.message || "Не удалось удалить пользователя");
setStatus("WebUser: deleted");
await loadUsers();
await updateSummary();
}
return;
}
const main = event.target.closest(".wesp-adm-list-main");
const row = event.target.closest("li[data-user-id]");
if (!main || !row || event.target.closest(".wesp-adm-list-actions")) return;
openUserModulesModal(row);
}
function renderServerIps(addresses) {
const el = document.getElementById("serverIpList");
if (!el) return;
if (!addresses || addresses.length === 0) {
el.textContent = "—";
return;
}
el.innerHTML = addresses
.map(
(a) =>
`<div class="wesp-ip-line"><span class="wesp-ip-if">${escHtml(a.interface)}</span> <span class="wesp-ip-fam">${escHtml(a.family)}</span> <code class="wesp-ip-addr">${escHtml(a.address)}</code></div>`,
)
.join("");
applyServerIpMask();
}
function applyServerIpMask() {
const el = document.getElementById("serverIpList");
const btn = document.getElementById("btnToggleServerIp");
if (!el) return;
const hide = localStorage.getItem("wespAdminMaskIp") === "1";
el.classList.toggle("wesp-ip-masked", hide);
if (btn) {
btn.setAttribute("aria-pressed", hide ? "true" : "false");
btn.classList.toggle("is-ip-hidden", hide);
btn.title = hide ? "Показать адреса" : "Скрыть адреса";
}
}
/** При каждом открытии админки адреса по умолчанию скрыты (до явного «показать» в этой сессии). */
function resetServerIpMaskOnPageOpen() {
try {
localStorage.setItem("wespAdminMaskIp", "1");
} catch (e) {
/* ignore */
}
applyServerIpMask();
}
function openAdminModal(title, text) {
const m = document.getElementById("adminModal");
const t = document.getElementById("adminModalTitle");
const b = document.getElementById("adminModalBody");
if (!m || !t || !b) return;
t.textContent = title;
b.textContent = text;
m.hidden = false;
document.body.style.overflow = "hidden";
}
function closeAdminModal() {
const m = document.getElementById("adminModal");
if (m) m.hidden = true;
document.body.style.overflow = "";
}
async function fetchAndShowServerLog(modalTitle) {
const r = await fetch("/api/admin/server-log?lines=300");
const d = await r.json();
if (d.status !== "success") {
openAdminModal(
modalTitle,
`${d.message || "Не удалось прочитать лог"}\n\n(Переменная WESP_ADMIN_LOG_PATH на сервере.)`,
);
return;
}
const body = (d.lines && d.lines.length ? d.lines.join("\n") : "(файл пуст)") + `\n\n— ${d.path}`;
openAdminModal(`${modalTitle}: ${d.path}`, body);
}
async function fetchUploadedClientLogsIndex() {
const r = await fetch("/api/admin/client-uploaded-logs", { credentials: "same-origin" });
const d = await r.json();
if (d.status !== "success") {
throw new Error(d.message || `HTTP ${r.status}`);
}
return d;
}
/**
* Диалог выбора клиента (и при необходимости файла) для логов из POST /api/sync/client-log.
* @returns {Promise<{ apiClientId: string, fileName: string|null }|null>}
*/
async function promptPickUploadedLogClient(options) {
const needExactFile = Boolean(options && options.needExactFile);
const d = await fetchUploadedClientLogsIndex();
const clients = d.clients || [];
if (!clients.length) {
openAdminModal(
"Логи с клиентов",
"Пока нет выгруженных файлов.\n\nНа узле: sync_client.upload_client_log_file(...) или POST /api/sync/client-log.",
);
return null;
}
const lines = clients.map((c, i) => {
const label = c.display_name || c.client_name || c.node_id || c.dir_segment;
const n = c.file_count != null ? c.file_count : (c.files || []).length;
return `${i + 1}) ${label}${n} файл(ов)`;
});
const ans = window.prompt(`Выберите клиента (номер от 1 до ${clients.length}):\n\n${lines.join("\n")}`);
if (ans === null) return null;
const num = parseInt(String(ans).trim(), 10);
if (!Number.isFinite(num) || num < 1 || num > clients.length) {
window.alert("Некорректный номер.");
return null;
}
const chosen = clients[num - 1];
const apiClientId = chosen.api_client_id;
const files = chosen.files || [];
let fileName = null;
if (needExactFile) {
if (!files.length) {
window.alert("У этого клиента нет файлов логов.");
return null;
}
if (files.length === 1) {
fileName = files[0].name;
} else {
const fl = files.map((f, i) => `${i + 1}) ${f.name} (${f.size} B)`).join("\n");
const fa = window.prompt(`Файл для скачивания (1–${files.length}):\n\n${fl}`);
if (fa === null) return null;
const fi = parseInt(String(fa).trim(), 10);
if (!Number.isFinite(fi) || fi < 1 || fi > files.length) {
window.alert("Некорректный номер файла.");
return null;
}
fileName = files[fi - 1].name;
}
} else if (files.length > 1) {
const fl = files.map((f, i) => `${i + 1}) ${f.name} (${f.size} B)`).join("\n");
const fa = window.prompt(
`Какой файл показать (1–${files.length})? Пусто + OK — самый свежий.\n\n${fl}`,
"",
);
if (fa === null) return null;
const trimmed = String(fa).trim();
if (trimmed) {
const fi = parseInt(trimmed, 10);
if (!Number.isFinite(fi) || fi < 1 || fi > files.length) {
window.alert("Некорректный номер файла.");
return null;
}
fileName = files[fi - 1].name;
}
}
return { apiClientId, fileName };
}
async function fetchAndShowClientUploadedLog() {
const picked = await promptPickUploadedLogClient({ needExactFile: false });
if (!picked) return;
const q = new URLSearchParams({ client_id: picked.apiClientId, lines: "300" });
if (picked.fileName) q.set("file", picked.fileName);
const r = await fetch(`/api/admin/client-uploaded-logs/tail?${q}`, { credentials: "same-origin" });
const d = await r.json();
if (d.status !== "success") {
openAdminModal(
"Лог клиента",
`${d.message || "Не удалось прочитать лог"}\n\n(client_id: ${picked.apiClientId})`,
);
return;
}
const body =
(d.lines && d.lines.length ? d.lines.join("\n") : "(файл пуст)") + `\n\n— ${d.path || ""}`;
openAdminModal(`Лог клиента: ${d.file || ""}`, body);
}
async function downloadClientUploadedLogFile() {
const picked = await promptPickUploadedLogClient({ needExactFile: true });
if (!picked || !picked.fileName) return;
const q = new URLSearchParams({ client_id: picked.apiClientId, file: picked.fileName });
const r = await fetch(`/api/admin/client-uploaded-logs/download?${q}`, { credentials: "same-origin" });
if (!r.ok) {
let msg = "client log download failed";
try {
const j = await r.json();
if (j.message) msg = j.message;
} catch (e) {
/* ignore */
}
throw new Error(msg);
}
const cd = r.headers.get("Content-Disposition") || "";
let name = picked.fileName;
const mStar = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(cd);
const mQuot = /filename\s*=\s*"([^"]+)"/i.exec(cd);
const mPlain = /filename\s*=\s*([^;\s]+)/i.exec(cd);
if (mStar) name = decodeURIComponent(mStar[1].trim());
else if (mQuot) name = mQuot[1].trim();
else if (mPlain) name = mPlain[1].trim();
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
setStatus("client log: downloaded");
}
async function downloadServerLogFile() {
const r = await fetch("/api/admin/server-log/download", { credentials: "same-origin" });
if (!r.ok) {
let msg = "log download failed";
try {
const j = await r.json();
if (j.message) msg = j.message;
} catch (e) {
/* ignore */
}
throw new Error(msg);
}
const cd = r.headers.get("Content-Disposition") || "";
let name = "wesp.log";
const mStar = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(cd);
const mQuot = /filename\s*=\s*"([^"]+)"/i.exec(cd);
const mPlain = /filename\s*=\s*([^;\s]+)/i.exec(cd);
if (mStar) name = decodeURIComponent(mStar[1].trim());
else if (mQuot) name = mQuot[1].trim();
else if (mPlain) name = mPlain[1].trim();
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
setStatus("log file: downloaded");
}
const KIOSK_FULL_SETUP_QUIPS = [
"Настраиваем wesp.env и systemd…",
"Патчим labwc — рабочий стол отдыхает…",
"Готовим Chromium к первому взвешиванию…",
"Собираем пиксели логотипа в боевой порядок…",
"Синхронизируем киоск с тёмной темой /starting…",
"Проверяем: server и client — не один Pi",
"Чайник почти вскипел, осталось чуть-чуть",
];
let kioskFullSetupLoaderMounted = false;
let kioskFullSetupQuipTimer = null;
function setKioskFullSetupQuip(text, isError = false) {
const quipEl = document.getElementById("kioskFullSetupQuip");
if (!quipEl) return;
const next = String(text || "").trim();
if (!next) return;
quipEl.classList.add("wesp-startup__quip--fade");
quipEl.classList.toggle("wesp-startup__quip--error", Boolean(isError));
window.setTimeout(() => {
quipEl.textContent = next;
quipEl.classList.remove("wesp-startup__quip--fade");
}, 200);
}
function stopKioskFullSetupQuipRotation() {
if (kioskFullSetupQuipTimer) {
clearInterval(kioskFullSetupQuipTimer);
kioskFullSetupQuipTimer = null;
}
}
function startKioskFullSetupQuipRotation() {
stopKioskFullSetupQuipRotation();
let i = 0;
kioskFullSetupQuipTimer = window.setInterval(() => {
setKioskFullSetupQuip(KIOSK_FULL_SETUP_QUIPS[i % KIOSK_FULL_SETUP_QUIPS.length]);
i += 1;
}, 1400);
}
async function showKioskFullSetupOverlay() {
const overlay = document.getElementById("kioskFullSetupOverlay");
const introEl = document.getElementById("kioskFullSetupIntro");
if (!overlay || !introEl) return;
overlay.hidden = false;
overlay.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
if (!kioskFullSetupLoaderMounted) {
introEl.innerHTML = "";
await mountWespLogoLoader(introEl, {
loop: true,
durationMs: 6000,
embedded: true,
transparentBackground: true,
knockoutBackground: true,
darkTheme: true,
hideChrome: false,
});
kioskFullSetupLoaderMounted = true;
}
setKioskFullSetupQuip(KIOSK_FULL_SETUP_QUIPS[0]);
startKioskFullSetupQuipRotation();
}
function hideKioskFullSetupOverlay() {
const overlay = document.getElementById("kioskFullSetupOverlay");
if (!overlay) return;
overlay.hidden = true;
overlay.setAttribute("aria-hidden", "true");
document.body.style.overflow = "";
stopKioskFullSetupQuipRotation();
}
async function refreshKioskFullSetupHint() {
const hint = document.getElementById("kioskFullSetupHint");
const btn = document.getElementById("btnKioskFullSetup");
if (!hint) return;
try {
const r = await fetch("/api/admin/kiosk-full-setup/status");
const d = await r.json().catch(() => ({}));
if (!r.ok) {
hint.textContent = d.message || "Не удалось прочитать статус";
hint.classList.add("wesp-sync-hint--error");
return;
}
const parts = [];
if (!d.running_as_root) parts.push("WESP не от root");
else if (d.full_setup_ready) parts.push("полностью настроен");
else {
if (!d.platform_ready) parts.push("нужен env/systemd");
if (!d.kiosk_enabled || d.kiosk_mode !== "system_labwc") parts.push("нужен киоск");
}
if (!d.chromium_found) parts.push("chromium не найден");
if (d.gui_user) parts.push(`user: ${d.gui_user}`);
hint.textContent = parts.length ? parts.join(" · ") : (d.message || "—");
hint.classList.toggle("wesp-sync-hint--ok", Boolean(d.full_setup_ready));
hint.classList.toggle("wesp-sync-hint--error", !d.running_as_root || !d.chromium_found);
if (btn) btn.disabled = !d.running_as_root || !d.chromium_found;
} catch (error) {
hint.textContent = userHint(error.message, "Ошибка статуса");
hint.classList.add("wesp-sync-hint--error");
}
}
async function applyFullKioskSetup() {
const hideCb = document.getElementById("kioskBootHideDesktop");
const hideDesktop = hideCb ? Boolean(hideCb.checked) : true;
const ok = window.confirm(
"Настроить киоск Pi целиком?\n\n" +
"• /etc/wesp/wesp.env и myscript.service\n" +
"• киоск Chromium" +
(hideDesktop ? " без рабочего стола" : "") +
"\n• перезагрузка устройства\n\n" +
"Покажется анимация загрузки (как /starting). Нужен root.",
);
if (!ok) return;
const btn = document.getElementById("btnKioskFullSetup");
if (btn) btn.disabled = true;
await showKioskFullSetupOverlay();
setKioskFullSetupQuip("Записываем настройки…");
try {
const r = await fetch("/api/admin/kiosk-full-setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hide_desktop: hideDesktop }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(d.message || `Ошибка ${r.status}`);
}
stopKioskFullSetupQuipRotation();
setStatus(d.message || "Настройка киоска завершена");
if (d.reboot_scheduled) {
const sec = Math.max(5, Number(d.reboot_delay_sec) || 10);
let left = sec;
setKioskFullSetupQuip(`Готово — перезагрузка через ${left} с…`);
const countdown = window.setInterval(() => {
left -= 1;
if (left <= 0) {
clearInterval(countdown);
setKioskFullSetupQuip("Перезагружаем устройство…");
return;
}
setKioskFullSetupQuip(`Готово — перезагрузка через ${left} с…`);
}, 1000);
} else {
setKioskFullSetupQuip("Готово — перезагрузите устройство вручную");
window.setTimeout(() => hideKioskFullSetupOverlay(), 4000);
}
await refreshKioskFullSetupHint();
await refreshPiPlatformSetupHint();
await refreshKioskBootStatusHint();
} catch (error) {
stopKioskFullSetupQuipRotation();
setKioskFullSetupQuip(error.message || "Ошибка настройки", true);
setStatus(error.message, true, "Ошибка полной настройки киоска");
window.setTimeout(() => hideKioskFullSetupOverlay(), 5000);
await refreshKioskFullSetupHint();
} finally {
if (btn) btn.disabled = false;
}
}
async function refreshPiPlatformSetupHint() {
const hint = document.getElementById("piPlatformSetupHint");
const btn = document.getElementById("btnPiPlatformApply");
if (!hint) return;
try {
const r = await fetch("/api/admin/pi-platform/status");
const d = await r.json().catch(() => ({}));
if (!r.ok) {
hint.textContent = d.message || "Не удалось прочитать статус";
hint.classList.add("wesp-sync-hint--error");
return;
}
const parts = [];
if (!d.running_as_root) parts.push("WESP не от root — применить нельзя");
if (d.env_path) parts.push(d.env_path);
if (d.systemd_unit) parts.push(`unit: ${d.systemd_unit}`);
if (d.env_keys_missing?.length) {
parts.push(`нет ключей: ${d.env_keys_missing.join(", ")}`);
}
if (d.platform_ready) parts.push("готово");
else parts.push("нужно применить");
hint.textContent = parts.join(" · ");
hint.classList.toggle("wesp-sync-hint--ok", Boolean(d.platform_ready));
hint.classList.toggle("wesp-sync-hint--error", !d.running_as_root);
if (btn) btn.disabled = !d.running_as_root;
} catch (error) {
hint.textContent = userHint(error.message, "Ошибка статуса");
hint.classList.add("wesp-sync-hint--error");
}
}
async function applyPiPlatformSetup() {
const ok = window.confirm(
"Применить настройки Pi?\n\n" +
"• /etc/wesp/wesp.env (systemd, OTA, reboot, киоск)\n" +
"• systemd unit (myscript)\n" +
"• перезапуск службы WESP через ~2 с\n\n" +
"Нужны права root.",
);
if (!ok) return;
const btn = document.getElementById("btnPiPlatformApply");
if (btn) btn.disabled = true;
try {
const r = await fetch("/api/admin/pi-platform/apply", { method: "POST" });
const d = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(d.message || `Ошибка ${r.status}`);
}
setStatus(d.message || "Настройка Pi применена");
window.alert(d.message || "Готово");
await refreshPiPlatformSetupHint();
} catch (error) {
setStatus(error.message, true, "Ошибка настройки Pi");
await refreshPiPlatformSetupHint();
} finally {
if (btn) btn.disabled = false;
}
}
async function refreshPiRainbowSplashHint() {
const hint = document.getElementById("piRainbowSplashHint");
const btnOff = document.getElementById("btnPiRainbowSplashDisable");
const btnOn = document.getElementById("btnPiRainbowSplashEnable");
if (!hint) return;
try {
const r = await fetch("/api/admin/pi-boot/rainbow-splash/status");
const d = await r.json().catch(() => ({}));
if (!r.ok) {
hint.textContent = d.message || "Не удалось прочитать статус";
hint.classList.add("wesp-sync-hint--error");
return;
}
const parts = [];
if (!d.config_found) parts.push("config.txt не найден");
else parts.push(d.config_path || "config.txt");
parts.push(d.disable_splash ? "disable_splash=1 (выкл.)" : "splash включён");
if (d.message) parts.push(d.message);
hint.textContent = parts.join(" · ");
hint.classList.toggle("wesp-sync-hint--ok", Boolean(d.disable_splash));
hint.classList.toggle("wesp-sync-hint--error", !d.config_found);
if (btnOff) btnOff.disabled = Boolean(d.disable_splash) || !d.config_found;
if (btnOn) btnOn.disabled = !d.disable_splash || !d.config_found;
} catch (error) {
hint.textContent = userHint(error.message, "Ошибка статуса");
hint.classList.add("wesp-sync-hint--error");
}
}
async function setPiRainbowSplash(enabled) {
const label = enabled
? "Отключить радужный splash прошивки (disable_splash=1)?"
: "Вернуть стандартный splash прошивки (убрать disable_splash)?";
const extra = enabled
? "\n\nПосле записи устройство перезагрузится автоматически (если включено в wesp.env)."
: "\n\nПосле изменения устройство перезагрузится автоматически (если включено).";
if (!window.confirm(label + extra)) return;
const btnOff = document.getElementById("btnPiRainbowSplashDisable");
const btnOn = document.getElementById("btnPiRainbowSplashEnable");
if (btnOff) btnOff.disabled = true;
if (btnOn) btnOn.disabled = true;
try {
const r = await fetch("/api/admin/pi-boot/rainbow-splash", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(d.message || `Ошибка ${r.status}`);
}
setStatus(d.message || "Готово");
if (d.reboot_scheduled) {
window.alert(d.message || "Запланирована перезагрузка устройства.");
}
await refreshPiRainbowSplashHint();
} catch (error) {
setStatus(error.message, true, "Ошибка config.txt");
await refreshPiRainbowSplashHint();
}
}
let plymouthStatusPoll = null;
let plymouthJobTracked = false;
let plymouthLastNotifiedFinishAt = null;
function plymouthInstallFailed(job) {
if (job.phase === "error") return true;
const install = job.result?.install;
return Boolean(install && install.ok === false);
}
function buildPlymouthResultMessage(job, statusPayload) {
if (job.phase === "error") {
return String(job.error || job.message || "Ошибка установки Plymouth").trim();
}
const install = job.result?.install;
if (install && install.ok === false) {
return String(install.message || "Установка темы Plymouth не удалась").trim();
}
const render = job.result?.render;
const frames = render?.frames ?? statusPayload?.built_frames ?? statusPayload?.installed_frames;
const base = String(job.message || install?.message || "Тема Plymouth WESP установлена").trim();
if (frames) return `${base} (${frames} кадров). Перезагрузите устройство.`;
return `${base} Перезагрузите устройство.`;
}
function notifyPlymouthJobFinished(job, statusPayload) {
const finishedAt = job.finished_at;
if (!plymouthJobTracked || !finishedAt || finishedAt === plymouthLastNotifiedFinishAt) return;
if (job.phase !== "done" && job.phase !== "error") return;
plymouthLastNotifiedFinishAt = finishedAt;
plymouthJobTracked = false;
const failed = plymouthInstallFailed(job);
const message = buildPlymouthResultMessage(job, statusPayload);
setStatus(message, failed);
if (!failed) {
const reboot = job.result?.reboot;
const rebootNote = reboot?.scheduled
? `\n\n${reboot.message || "Перезагрузка запланирована."}`
: "\n\nПерезагрузите устройство, чтобы увидеть заставку при загрузке.";
window.alert("Тема Plymouth WESP установлена." + rebootNote);
}
}
async function refreshPlymouthStatusHint() {
const hint = document.getElementById("plymouthStatusHint");
const btn = document.getElementById("btnPlymouthInstall");
if (!hint && !btn) return false;
try {
const r = await fetch("/api/admin/plymouth/status");
const d = await r.json().catch(() => ({}));
if (!r.ok) return false;
const job = d.job || {};
const parts = [];
if (!d.pillow_available) parts.push("нет Pillow");
if (!d.plymouth_installed) parts.push("нет plymouth");
if (d.active_theme) parts.push(`тема: ${d.active_theme}`);
if (d.installed_frames) parts.push(`установлено ${d.installed_frames} кадр.`);
else if (d.built_frames) parts.push(`собрано ${d.built_frames}`);
if (job.running) {
const prog = job.total ? `${job.progress || 0}/${job.total}` : "";
parts.push([job.message || job.phase, prog].filter(Boolean).join(" "));
} else if (job.phase === "error") {
parts.push(String(job.error || job.message || "Ошибка").slice(0, 200));
} else if (job.phase === "done") {
parts.push(String(buildPlymouthResultMessage(job, d)).slice(0, 200));
}
const failed = !job.running && plymouthInstallFailed(job);
const succeeded = !job.running && job.phase === "done" && !failed;
hint.textContent = parts.length ? parts.join(" · ") : "Нажмите «Установить» для сборки темы (800×600).";
hint.classList.toggle("wesp-sync-hint--error", failed);
hint.classList.toggle("wesp-sync-hint--ok", succeeded);
if (btn) btn.disabled = Boolean(job.running);
if (job.running) {
plymouthJobTracked = true;
} else {
notifyPlymouthJobFinished(job, d);
}
return Boolean(job.running);
} catch {
return false;
}
}
function stopPlymouthStatusPoll() {
if (plymouthStatusPoll) {
clearInterval(plymouthStatusPoll);
plymouthStatusPoll = null;
}
}
function startPlymouthStatusPoll() {
stopPlymouthStatusPoll();
plymouthStatusPoll = window.setInterval(async () => {
const running = await refreshPlymouthStatusHint();
if (!running) stopPlymouthStatusPoll();
}, 1200);
}
async function installPlymouthTheme() {
const ok = window.confirm(
"Собрать анимацию WESP (800×600) и установить тему Plymouth?\n\n" +
"Нужны: Pillow (из requirements), plymouth, sudo для install_wesp_theme.sh " +
"(или WESP_PLYMOUTH_INSTALL_CMD).\n\nПосле установки перезагрузите устройство."
);
if (!ok) return;
const btn = document.getElementById("btnPlymouthInstall");
if (btn) btn.disabled = true;
try {
const r = await fetch("/api/admin/plymouth/install", { method: "POST" });
const d = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(d.message || `Ошибка ${r.status}`);
}
plymouthJobTracked = true;
plymouthLastNotifiedFinishAt = null;
setStatus(d.message || "Установка Plymouth запущена");
startPlymouthStatusPoll();
await refreshPlymouthStatusHint();
} catch (error) {
setStatus(error.message, true, "Plymouth: ошибка");
if (btn) btn.disabled = false;
}
}
async function refreshKioskBootStatusHint() {
const hint = document.getElementById("kioskBootStatusHint");
const btnOn = document.getElementById("btnKioskBootEnable");
const btnOff = document.getElementById("btnKioskBootDisable");
const hideCb = document.getElementById("kioskBootHideDesktop");
if (!hint) return;
try {
const r = await fetch("/api/admin/kiosk-boot/status");
const d = await r.json().catch(() => ({}));
if (!r.ok) {
hint.textContent = d.message || "Не удалось прочитать статус киоска";
hint.classList.add("wesp-sync-hint--error");
return;
}
const parts = [];
parts.push(d.kiosk_enabled ? "включён" : "выключен");
if (d.gui_user) parts.push(`user: ${d.gui_user}`);
if (d.active_url) parts.push(d.active_url);
if (!d.chromium_found) parts.push("chromium не найден");
if (d.kiosk_mode === "system_labwc") parts.push("патч /etc/xdg/labwc/autostart");
else if (d.kiosk_mode === "user_labwc_legacy") parts.push("устаревший ~/.config/labwc");
else if (d.kiosk_mode === "xdg") parts.push("режим XDG");
if (d.system_labwc_patched) parts.push("system labwc OK");
if (d.home_dir) parts.push(`home: ${d.home_dir}`);
if (d.message) parts.push(d.message);
hint.textContent = parts.join(" · ");
hint.classList.toggle("wesp-sync-hint--ok", Boolean(d.kiosk_enabled));
hint.classList.toggle("wesp-sync-hint--error", !d.chromium_found || !d.home_exists);
if (hideCb && typeof d.hide_desktop === "boolean") hideCb.checked = d.hide_desktop;
if (btnOn) btnOn.disabled = Boolean(d.kiosk_enabled) || !d.chromium_found;
if (btnOff) btnOff.disabled = !d.kiosk_enabled;
} catch (error) {
hint.textContent = userHint(error.message, "Ошибка статуса киоска");
hint.classList.add("wesp-sync-hint--error");
}
}
async function setKioskBoot(enabled) {
const hideCb = document.getElementById("kioskBootHideDesktop");
const hideDesktop = hideCb ? Boolean(hideCb.checked) : false;
const label = enabled
? "Включить автозапуск Chromium в kiosk?"
: "Выключить киоск и удалить autostart?";
const extra = enabled
? (hideDesktop
? "\n\nБудут скрыты панель/рабочий стол (если компоненты есть в системе)."
: "") + "\n\nПосле сохранения — автоперезагрузка Pi (если включена в wesp.env)."
: "\n\nПосле отключения — автоперезагрузка Pi (если включена).";
if (!window.confirm(label + extra)) return;
const btnOn = document.getElementById("btnKioskBootEnable");
const btnOff = document.getElementById("btnKioskBootDisable");
if (btnOn) btnOn.disabled = true;
if (btnOff) btnOff.disabled = true;
try {
const body = { enabled };
if (enabled) body.hide_desktop = hideDesktop;
const r = await fetch("/api/admin/kiosk-boot", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(d.message || `Ошибка ${r.status}`);
}
setStatus(d.message || "Готово");
if (d.reboot_scheduled) {
window.alert(d.message || "Запланирована перезагрузка устройства.");
} else if (enabled && !hideDesktop) {
window.alert(
"Киоск включён (XDG).\n\n" +
"Включите «Без рабочего стола» и снова «Включить киоск», затем reboot.",
);
}
await refreshKioskBootStatusHint();
} catch (error) {
setStatus(error.message, true, "Ошибка киоска");
await refreshKioskBootStatusHint();
} finally {
if (btnOn) btnOn.disabled = false;
if (btnOff) btnOff.disabled = false;
}
}
async function postServiceControl(action) {
const label = action === "stop" ? "остановку службы" : "перезапуск службы";
if (!window.confirm(`Подтвердите ${label}. Сессия может оборваться.`)) return;
const r = await fetch("/api/admin/service-control", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
});
let d = {};
try {
d = await r.json();
} catch (e) {
/* ignore */
}
if (r.ok && d.ok) {
setStatus(d.message || "Команда запущена");
} else {
setStatus(d.message, true, "Операция не выполнена");
}
}
async function downloadSqliteBackup() {
const r = await fetch("/api/admin/backup.sqlite");
if (!r.ok) {
let msg = "Ошибка резервной копии";
try {
const j = await r.json();
if (j.message) msg = j.message;
} catch (e) {
/* ignore */
}
throw new Error(msg);
}
const cd = r.headers.get("Content-Disposition") || "";
let name = "wesp-backup.zip";
const mStar = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(cd);
const mQuot = /filename\s*=\s*"([^"]+)"/i.exec(cd);
const mPlain = /filename\s*=\s*([^;\s]+)/i.exec(cd);
if (mStar) name = decodeURIComponent(mStar[1].trim());
else if (mQuot) name = mQuot[1].trim();
else if (mPlain) name = mPlain[1].trim();
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
setStatus("SQLite ZIP: downloaded");
}
function wireAntCheckbox(input) {
if (!input) return;
const wrapper = input.closest(".ant-checkbox-wrapper");
const box = wrapper?.querySelector(".ant-checkbox");
if (!box) return;
const apply = () => {
box.classList.toggle("ant-checkbox-checked", input.checked);
wrapper?.classList.toggle("ant-checkbox-disabled", input.disabled);
};
apply();
if (input.dataset.antCheckboxWired === "1") return;
input.dataset.antCheckboxWired = "1";
input.addEventListener("change", apply);
}
function syncAntCheckboxVisual() {
wireAntCheckbox(document.getElementById("newUserIsSuper"));
wireAntCheckbox(document.getElementById("userModuleLabAccess"));
}
async function init() {
let metricsTimer = null;
const shellStart = performance.now();
prepareAdminSkeletonBlocks();
try {
const authData = await fetchAuth();
if (!authData) {
stripAdminShellDecorations();
return;
}
syncAntCheckboxVisual();
applyAdminDocumentTheme();
initAdminSidebar();
hydrateAssistantChatMessagesFromStorage();
await hydrateAdminActivityLog();
wireAdminDbBrowserModal();
document.getElementById("adminDbRefreshTreeBtn")?.addEventListener("click", () => {
refreshAdminDbTree(setStatus);
});
document.getElementById("adminDbRefreshGridBtn")?.addEventListener("click", () => {
refreshAdminDbGrid(setStatus);
});
initDashboardStatNav();
resetServerIpMaskOnPageOpen();
document.querySelectorAll("[data-section]").forEach((btn) => {
btn.addEventListener("click", () => activateSection(btn.dataset.section));
});
{
const { from, to } = defaultAssistantDateRange();
const df = document.getElementById("wespAssistantDateFrom");
const dt = document.getElementById("wespAssistantDateTo");
if (df && !df.value) df.value = formatDateInputYmd(from);
if (dt && !dt.value) dt.value = formatDateInputYmd(to);
}
document.getElementById("wespAssistantRefreshStatusBtn")?.addEventListener("click", () => {
loadAdminAssistantStatus().catch((e) => setStatus(e.message, true, "status fail"));
});
document.getElementById("wespAssistantRefreshLogBtn")?.addEventListener("click", () => {
loadAdminAssistantLlmLog().catch((e) => setStatus(e.message, true, "журнал ИИ"));
});
document.getElementById("wespAssistantAdminLlmEnabled")?.addEventListener("change", async (e) => {
const el = e.target;
if (!(el instanceof HTMLInputElement)) return;
const checked = el.checked;
const prev = !checked;
try {
const r = await fetch("/api/admin/security-settings", {
method: "PATCH",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ admin_llm_enabled: checked }),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
setStatus(data.message || String(r.status), true);
el.checked = prev;
return;
}
setStatus(checked ? "ИИ включён" : "ИИ выключен");
loadAdminAssistantStatus().catch(() => {});
loadAdminAssistantLlmLog().catch(() => {});
} catch (err) {
setStatus(err?.message, true, "save fail");
el.checked = prev;
}
});
document.getElementById("wespAssistantLlmAutostart")?.addEventListener("change", async (e) => {
const el = e.target;
if (!(el instanceof HTMLInputElement)) return;
const checked = el.checked;
const prev = !checked;
try {
const r = await fetch("/api/admin/security-settings", {
method: "PATCH",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ llm_autostart: checked }),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
setStatus(data.message || String(r.status), true);
el.checked = prev;
return;
}
setStatus("Автозапуск llama-server сохранён в конфиг.");
loadAdminAssistantStatus().catch(() => {});
loadAdminAssistantLlmLog().catch(() => {});
} catch (err) {
setStatus(err?.message, true, "save fail");
el.checked = prev;
}
});
document.getElementById("wespAssistantChatDbContext")?.addEventListener("change", async (e) => {
const el = e.target;
if (!(el instanceof HTMLInputElement)) return;
const checked = el.checked;
const prev = !checked;
try {
const r = await fetch("/api/admin/security-settings", {
method: "PATCH",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ llm_chat_db_context: checked }),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
setStatus(data.message || String(r.status), true);
el.checked = prev;
return;
}
setStatus("Режим чата с контекстом БД сохранён.");
loadAdminAssistantStatus().catch(() => {});
} catch (err) {
setStatus(err?.message, true, "save fail");
el.checked = prev;
}
});
document.getElementById("wespAssistantLlmToolsEnabled")?.addEventListener("change", async (e) => {
const el = e.target;
if (!(el instanceof HTMLInputElement)) return;
const checked = el.checked;
const prev = !checked;
try {
const r = await fetch("/api/admin/security-settings", {
method: "PATCH",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ llm_tools_enabled: checked }),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
setStatus(data.message || String(r.status), true);
el.checked = prev;
return;
}
setStatus("Инструменты чата (slim-tools) сохранены в конфиг.");
loadAdminAssistantStatus().catch(() => {});
} catch (err) {
setStatus(err?.message, true, "save fail");
el.checked = prev;
}
});
document.getElementById("wespAssistantChatSendBtn")?.addEventListener("click", () => {
runAdminAssistantChatSend().catch((e) => setStatus(e.message, true, "chat fail"));
});
document.getElementById("wespAssistantChatClearBtn")?.addEventListener("click", () => {
window.__wespAssistantChatMessages = [];
try {
sessionStorage.removeItem(WESP_ASSISTANT_CHAT_STORAGE_KEY);
} catch {
/* ignore */
}
renderAssistantChatLog();
setStatus("Диалог очищен");
});
document.getElementById("wespAssistantPingBtn")?.addEventListener("click", () => {
runAdminAssistantPing().catch((e) => setStatus(e.message, true, "ping fail"));
});
document.getElementById("wespAssistantSummaryBtn")?.addEventListener("click", () => {
runAdminAssistantSummary().catch((e) => setStatus(e.message, true, "summary fail"));
});
document.getElementById("wespAssistantDiagnosticsBtn")?.addEventListener("click", () => {
runAdminAssistantDiagnostics().catch((e) => setStatus(e.message, true, "diagnostics fail"));
});
document.getElementById("wespAssistantChatInput")?.addEventListener("keydown", (e) => {
if (e.key !== "Enter" || e.shiftKey) return;
e.preventDefault();
runAdminAssistantChatSend().catch((err) => setStatus(err.message, true, "chat fail"));
});
document.getElementById("btnDashHardwareChart")?.addEventListener("click", () => {
openHardwareChartFromDashboard();
});
document.getElementById("refreshSummaryBtn")?.addEventListener("click", async () => {
try {
await Promise.all([updateSummary(), updateSystemMetrics(), loadUsers(), refreshActivityServerCache()]);
renderAdminActivityTerminalFromMerged();
setStatus("state: OK");
} catch (error) {
setStatus(error.message, true, "Ошибка обновления");
}
});
document.getElementById("orchSyncSave")?.addEventListener("click", async () => {
try {
await saveOrchestratorSyncFromForm();
} catch (error) {
setStatus(error.message, true, "Orchestrator sync save failed");
}
});
document.getElementById("syncSettingsSave")?.addEventListener("click", async () => {
const btn = document.getElementById("syncSettingsSave");
if (btn?.dataset.saving === "1") return;
if (btn) {
btn.dataset.saving = "1";
btn.disabled = true;
}
try {
await saveSyncSettingsFromForm();
} catch (error) {
setStatus(error.message, true, "Ошибка сохранения");
} finally {
if (btn) {
btn.dataset.saving = "0";
btn.disabled = false;
}
}
});
document.getElementById("syncDiagRefreshBtn")?.addEventListener("click", async () => {
try {
await loadSyncDiagnostics();
setStatus("sync diagnostics: OK");
} catch (e) {
setStatus(e.message, true, "diagnostics fail");
}
});
document.getElementById("syncActionRequeueStuck")?.addEventListener("click", async () => {
if (
!window.confirm(
"Переставить зависшие задачи (processing → pending) по порогу SYNC_REQUEUE_TIMEOUT_MINUTES?",
)
) {
return;
}
try {
const r = await fetch("/api/admin/sync-actions/requeue-stuck", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
setStatus(d.message || "requeue OK");
await loadSyncDiagnostics();
} catch (e) {
setStatus(e.message, true, "requeue fail");
}
});
document.getElementById("syncActionRestartLocal")?.addEventListener("click", async () => {
if (!window.confirm("Перезапустить фоновый sync_client в этом процессе?")) return;
try {
const r = await fetch("/api/admin/sync-actions/restart-local-sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
setStatus(d.message || "restart OK");
await loadSyncDiagnostics();
} catch (e) {
setStatus(e.message, true, "restart fail");
}
});
document.getElementById("syncActionRefreshRuntime")?.addEventListener("click", async () => {
try {
const r = await fetch("/api/admin/sync-actions/refresh-runtime", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
setStatus(d.message || "runtime OK");
const msg = document.getElementById("syncDiagActionMessage");
if (msg && d.runtime) {
msg.textContent = `runtime: ${JSON.stringify(d.runtime)}`;
}
} catch (e) {
setStatus(e.message, true, "runtime fail");
}
});
document.getElementById("syncActionRetryPush")?.addEventListener("click", async () => {
if (!window.confirm("Запланировать полный sync_cycle (push отчётов + pull) в фоне?")) return;
try {
const r = await fetch("/api/admin/sync-actions/retry-report-push", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
setStatus(d.message || "scheduled OK");
} catch (e) {
setStatus(e.message, true, "push fail");
}
});
document.getElementById("securitySettingsSave")?.addEventListener("click", async () => {
try {
await saveSecuritySettingsFromForm();
} catch (error) {
setStatus(error.message, true, "Ошибка сохранения");
}
});
document.getElementById("netSettingsSave")?.addEventListener("click", async () => {
try {
await saveNetworkSettingsFromForm();
} catch (error) {
setStatus(error.message, true, "Ошибка сохранения сети");
}
});
document.getElementById("netSettingsRefreshBtn")?.addEventListener("click", async () => {
try {
await loadNetworkSettings();
await loadNetworkEvents();
setStatus("Сеть: обновлено");
} catch (error) {
setStatus(error.message, true, "Ошибка загрузки сети");
}
});
document.getElementById("netEventsRefreshBtn")?.addEventListener("click", async () => {
try {
await loadNetworkEvents();
setStatus("Журнал сети обновлён");
} catch (error) {
setStatus(error.message, true, "Ошибка журнала сети");
}
});
document.getElementById("netEventsClearBtn")?.addEventListener("click", async () => {
try {
await clearNetworkEventsLog();
setStatus("Журнал сети очищен");
} catch (error) {
setStatus(error.message, true, "Ошибка очистки журнала сети");
}
});
document.getElementById("netBuildUrlBtn")?.addEventListener("click", () => {
applyNetworkSuggestedPublicUrl();
});
document.getElementById("netMdnsEnabled")?.addEventListener("change", (ev) => {
const label = document.getElementById("netMdnsStatusLabel");
if (label && ev.target instanceof HTMLInputElement) {
label.textContent = ev.target.checked ? "mDNS: вкл." : "mDNS: выкл.";
}
});
wireSecurityToggleAutoSave("secMacLock", "mac_lock_enabled");
wireSecurityToggleAutoSave("secKioskEnforce", "kiosk_enforce_paired_only");
wireSecurityToggleAutoSave("secCalibrationPublic", "calibration_public");
document.getElementById("auSave")?.addEventListener("click", async () => {
try {
await saveAutoUpdateFromForm();
} catch (error) {
setStatus(error.message, true, "Ошибка сохранения автообновления");
}
});
document.getElementById("auSaveSecrets")?.addEventListener("click", async () => {
try {
await saveGiteaSecretsFromForm();
} catch (error) {
setStatus(error.message, true, "Ошибка сохранения секретов Gitea");
}
});
document.getElementById("secDeviceTokenClear")?.addEventListener("change", (ev) => {
const token = document.getElementById("secDeviceToken");
const box = ev.target;
if (!token || !box) return;
const locked = token.dataset.envLocked === "1";
token.disabled = locked || box.checked;
});
document.getElementById("syncClientsList")?.addEventListener("click", async (event) => {
try {
await syncClientsTableAction(event);
} catch (error) {
setStatus(error.message, true, "Ошибка");
}
});
document.getElementById("createUserForm")?.addEventListener("submit", async (event) => {
try {
await createUser(event);
} catch (error) {
setStatus(error.message, true, "Ошибка создания пользователя");
}
});
document.getElementById("usersList")?.addEventListener("click", async (event) => {
try {
await usersTableAction(event);
} catch (error) {
setStatus(error.message, true, "Ошибка операции с пользователем");
}
});
document.getElementById("adminModalClose")?.addEventListener("click", closeAdminModal);
document.getElementById("adminModalMask")?.addEventListener("click", closeAdminModal);
document.getElementById("userModulesModalClose")?.addEventListener("click", closeUserModulesModal);
document.getElementById("userModulesModalMask")?.addEventListener("click", closeUserModulesModal);
document.getElementById("userModulesModalCancel")?.addEventListener("click", closeUserModulesModal);
document.getElementById("userModulesModalSave")?.addEventListener("click", async () => {
try {
await saveUserModulesModal();
} catch (error) {
setStatus(error.message, true, "Ошибка сохранения модулей");
}
});
document.getElementById("btnManageLog")?.addEventListener("click", () => {
fetchAndShowServerLog("Журнал").catch((e) => setStatus(e.message, true, "Не удалось выполнить операцию."));
});
document.getElementById("btnManageLogDownload")?.addEventListener("click", async () => {
try {
await downloadServerLogFile();
} catch (error) {
setStatus(error.message, true, "log download failed");
}
});
document.getElementById("btnManageClientLogs")?.addEventListener("click", () => {
fetchAndShowClientUploadedLog().catch((e) => setStatus(e.message, true, "client log failed"));
});
document.getElementById("btnManageClientLogsDownload")?.addEventListener("click", async () => {
try {
await downloadClientUploadedLogFile();
} catch (error) {
setStatus(error.message, true, "client log download failed");
}
});
document.getElementById("btnManageConfig")?.addEventListener("click", () => {
activateSection("system");
document.getElementById("wespSectionSystem")?.scrollIntoView({ behavior: "smooth" });
});
document.getElementById("btnFactoryResetPreview")?.addEventListener("click", async () => {
try {
await loadFactoryResetPreview();
setStatus("Список для сброса обновлён");
} catch (error) {
setStatus(error.message, true, "Ошибка предпросмотра");
}
});
document.getElementById("btnFactoryReset")?.addEventListener("click", async () => {
try {
const d = await runFactoryReset();
if (!d) return;
const extra = d.safety_backup_path ? ` (${d.safety_backup_path})` : "";
setStatus((d.message || "Сброс выполнен") + extra + " Перезапустите WESP.");
} catch (error) {
setStatus(error.message, true, "Ошибка сброса");
}
});
document.getElementById("btnManageBackup")?.addEventListener("click", async () => {
try {
await downloadSqliteBackup();
} catch (error) {
setStatus(error.message, true, "Ошибка копии");
}
});
document.getElementById("btnManageRestore")?.addEventListener("click", () => {
document.getElementById("restoreSqliteFile")?.click();
});
document.getElementById("restoreSqliteFile")?.addEventListener("change", async (event) => {
const input = event.target;
const file = input?.files?.[0];
if (!file) return;
const name = (file.name || "").toLowerCase();
if (!name.endsWith(".zip")) {
setStatus("restore: требуется .zip", true);
input.value = "";
return;
}
const ok = window.confirm(
"Текущие файлы SQLite будут скопированы с суффиксом .restore-bak-* и заменены содержимым архива.\n\n" +
"Используйте только ZIP, скачанный здесь же («Скачать ZIP»).\n\n" +
"После восстановления перезапустите WESP и обновите страницу.\n\n" +
"Продолжить?",
);
if (!ok) {
input.value = "";
return;
}
const fd = new FormData();
fd.append("file", file);
try {
const r = await fetch("/api/admin/restore.sqlite", { method: "POST", body: fd });
const d = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(d.message || `Ошибка ${r.status}`);
}
setStatus(d.message || "restore: OK");
} catch (error) {
setStatus(error.message, true, "Ошибка восстановления");
} finally {
input.value = "";
}
});
document.getElementById("btnKioskFullSetup")?.addEventListener("click", () => {
applyFullKioskSetup().catch((e) => setStatus(e.message, true, "Kiosk full setup"));
});
refreshKioskFullSetupHint();
document.getElementById("btnPiPlatformApply")?.addEventListener("click", () => {
applyPiPlatformSetup().catch((e) => setStatus(e.message, true, "Pi setup"));
});
refreshPiPlatformSetupHint();
document.getElementById("btnPiRainbowSplashDisable")?.addEventListener("click", () => {
setPiRainbowSplash(true).catch((e) => setStatus(e.message, true, "Pi boot"));
});
document.getElementById("btnPiRainbowSplashEnable")?.addEventListener("click", () => {
setPiRainbowSplash(false).catch((e) => setStatus(e.message, true, "Pi boot"));
});
refreshPiRainbowSplashHint();
document.getElementById("btnPlymouthInstall")?.addEventListener("click", () => {
installPlymouthTheme().catch((e) => setStatus(e.message, true, "Plymouth"));
});
refreshPlymouthStatusHint().then((running) => {
if (running) startPlymouthStatusPoll();
});
document.getElementById("btnKioskBootEnable")?.addEventListener("click", () => {
setKioskBoot(true).catch((e) => setStatus(e.message, true, "Kiosk boot"));
});
document.getElementById("btnKioskBootDisable")?.addEventListener("click", () => {
setKioskBoot(false).catch((e) => setStatus(e.message, true, "Kiosk boot"));
});
refreshKioskBootStatusHint();
document.getElementById("btnWespStop")?.addEventListener("click", () => {
postServiceControl("stop");
});
document.getElementById("btnWespRestart")?.addEventListener("click", () => {
postServiceControl("restart");
});
document.getElementById("btnToggleServerIp")?.addEventListener("click", () => {
const next = localStorage.getItem("wespAdminMaskIp") === "1" ? "0" : "1";
if (next === "1") localStorage.setItem("wespAdminMaskIp", "1");
else localStorage.removeItem("wespAdminMaskIp");
applyServerIpMask();
});
document.getElementById("diagAddExtraTargetBtn")?.addEventListener("click", () => {
const wrap = document.getElementById("diagExtraTargetsRows");
if (!wrap) return;
wrap.insertAdjacentHTML("beforeend", makeDiagTargetRow({}));
});
document.getElementById("diagExtraTargetsRows")?.addEventListener("click", (event) => {
const btn = event.target.closest("[data-diag-remove-target]");
if (!btn) return;
const row = btn.closest("[data-diag-target-row]");
if (row) row.remove();
});
document.getElementById("diagSaveExtraTargetsBtn")?.addEventListener("click", async () => {
try {
await saveDiagnosticsSettings();
} catch (error) {
setStatus(error.message, true, "Не удалось сохранить точки");
}
});
document.getElementById("diagRefreshReportBtn")?.addEventListener("click", () => {
loadDiagnosticsReport()
.then(() => setStatus("Диагностика: отчёт обновлён"))
.catch((e) => setStatus(e.message, true, "Ошибка диагностики"));
});
document.getElementById("diagRunPrimaryBtn")?.addEventListener("click", async () => {
try {
await runDiagnosticsNetwork("primary", false);
} catch (error) {
setStatus(error.message, true, "Ошибка проверки");
}
});
document.getElementById("diagRunPrimaryTraceBtn")?.addEventListener("click", () => {
openTracerouteModalFromDiagnostics();
});
document.getElementById("tracerouteModalClose")?.addEventListener("click", closeTracerouteModal);
document.getElementById("tracerouteModalMask")?.addEventListener("click", closeTracerouteModal);
document.getElementById("tracerouteRunBtn")?.addEventListener("click", async () => {
try {
await runTracerouteByHostsModal();
} catch (error) {
setStatus(error.message, true, "Ошибка трассировки");
}
});
document.getElementById("tracerouteFillPrimaryBtn")?.addEventListener("click", fillTracerouteHostsFromPrimary);
document.getElementById("tracerouteClearHostsBtn")?.addEventListener("click", () => {
const ta = document.getElementById("tracerouteHostsInput");
const wrap = document.getElementById("tracerouteModalResults");
if (ta) ta.value = "";
if (wrap) wrap.innerHTML = "";
});
document.getElementById("diagRunInternetBtn")?.addEventListener("click", async () => {
try {
await runDiagnosticsNetwork("custom", false, []);
} catch (error) {
setStatus(error.message, true, "Ошибка проверки интернета");
}
});
document.getElementById("diagRunExtraTargetsBtn")?.addEventListener("click", async () => {
try {
await saveDiagnosticsSettings();
await runDiagnosticsNetwork("extra", true);
} catch (error) {
setStatus(error.message, true, "Ошибка доп. точек");
}
});
document.getElementById("diagHxSampleBtn")?.addEventListener("click", async () => {
try {
await runDiagnosticsHxSample("diagHardwareList");
await loadPeripheralEvents().catch(() => {});
} catch (error) {
setStatus(error.message, true, "Ошибка HX711");
}
});
document.getElementById("diagGpioBlinkBtn")?.addEventListener("click", async () => {
try {
await runDiagnosticsGpioBlink("diagHardwareList");
} catch (error) {
setStatus(error.message, true, "Ошибка GPIO");
}
});
document.getElementById("hardwareRefreshBtn")?.addEventListener("click", async () => {
try {
await refreshHardwareSection();
} catch (error) {
setStatus(error.message, true, "Ошибка обновления железа");
}
});
document.getElementById("hardwareEventsClearBtn")?.addEventListener("click", async () => {
try {
await clearPeripheralEventsLog();
setStatus("Журнал событий очищен");
} catch (error) {
setStatus(error.message, true, "Ошибка очистки журнала");
}
});
document.getElementById("hwHxSampleBtn")?.addEventListener("click", async () => {
try {
await runDiagnosticsHxSample("hardwarePeriphList");
await loadHardwareStatus().catch(() => {});
await loadPeripheralEvents().catch(() => {});
} catch (error) {
setStatus(error.message, true, "Ошибка HX711");
}
});
document.getElementById("hwGpioBlinkBtn")?.addEventListener("click", async () => {
try {
await runDiagnosticsGpioBlink("hardwarePeriphList");
} catch (error) {
setStatus(error.message, true, "Ошибка GPIO");
}
});
document.getElementById("hwSimulationToggle")?.addEventListener("change", async (ev) => {
const enabled = Boolean(ev.target && ev.target.checked);
withHwSimUiLock(() => {});
try {
await patchHardwareSimulation(enabled);
await refreshHardwareSection();
setStatus(
enabled
? "Симуляция включена — HX711 не опрашивается"
: "Симуляция выключена — чтение с HX711",
);
} catch (error) {
if (ev.target) ev.target.checked = !enabled;
setStatus(error.message, true, "Ошибка симуляции");
}
});
document.getElementById("hwSimWeightMinus")?.addEventListener("click", async () => {
try {
await adjustHardwareSimulationWeight(-1);
await refreshHardwareSection();
} catch (error) {
setStatus(error.message, true, "Ошибка изменения веса");
}
});
document.getElementById("hwSimWeightPlus")?.addEventListener("click", async () => {
try {
await adjustHardwareSimulationWeight(1);
await refreshHardwareSection();
} catch (error) {
setStatus(error.message, true, "Ошибка изменения веса");
}
});
document.getElementById("hwSimWeightApply")?.addEventListener("click", async () => {
try {
await applyHardwareSimulationWeightFromUi();
setStatus("Вес симуляции применён");
} catch (error) {
setStatus(error.message, true, "Ошибка изменения веса");
}
});
document.getElementById("hwSimWeightInput")?.addEventListener("keydown", async (ev) => {
if (ev.key !== "Enter") return;
ev.preventDefault();
try {
await applyHardwareSimulationWeightFromUi();
setStatus("Вес симуляции применён");
} catch (error) {
setStatus(error.message, true, "Ошибка изменения веса");
}
});
document.getElementById("hwSimWeightSlider")?.addEventListener("input", (ev) => {
const slider = ev.target;
const input = document.getElementById("hwSimWeightInput");
if (input && slider) input.value = slider.value;
scheduleHardwareSimulationWeightFromSlider();
});
await Promise.all([updateSummary(), updateSystemMetrics(), loadUsers()]);
await finishAdminShellReveal(shellStart);
setStatus(`session: ${authData.user_login}`);
metricsTimer = window.setInterval(() => {
updateSystemMetrics().catch((err) => {
const st = document.getElementById("wespRuntimeStatus");
if (st) st.textContent = userHint(err?.message, "Не удалось загрузить метрики");
});
}, 4000);
window.addEventListener("beforeunload", () => {
if (metricsTimer) window.clearInterval(metricsTimer);
stopHardwarePolling();
});
} catch (error) {
stripAdminShellDecorations();
setStatus(error.message, true, "Ошибка загрузки админ-панели");
}
}
init();