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, "&") .replace(//g, ">") .replace(/"/g, """); } 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 ? `${_assistantChatEscapeHtml(m.content)}` : _assistantChatEscapeHtml(m.content); parts.push( `
${label}
${contentHtml}
`, ); } 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 = '
// журнал пуст (нет вызовов за окно хранения)
'; 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 `
[${escHtml(formatActivityTs(tsMs))}]${lvl}${escHtml(msg)}
`; }) .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 = '
// no events in retention window
'; 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 `
[${escHtml(formatActivityTs(e.ts))}]${lvl}${escHtml(e.text)}
`; }) .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() ? `
${escHtml(descriptionText)}
` : ""; return `
  • ${escHtml(title)}
    ${desc}
    ${extraHtml}
  • `; } function wespAdmListSection(title) { return ``; } function wespAdmListEmpty(message) { return `
  • ${escHtml(message)}
  • `; } function wespAdmListLoading(message = "Загрузка…") { return `
  • ${escHtml(message)}
  • `; } /** Сводка 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, `${escHtml(v)}`), ) .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 ? 'sim' : diagOkBadge(gpioOk); const hxBadge = sim ? 'sim' : scaleHw ? diagOkBadge(hxOk) : ''; el.innerHTML = [ wespAdmListSection("Периферия"), wespAdmListItem( "Режим", sim ? 'SIM' : scaleHw ? diagOkBadge(true) : 'DEV', sim ? "симуляция — HX711 не опрашивается" : scaleHw ? "целевое железо (Linux ARM + HX711)" : "не ARM — HX711 не опрашивается; включите симуляцию", ), wespAdmListItem( "GPIO / LED", `${gpioBadge}пин ${escHtml(String(p.gpio_led_pin ?? "—"))}`, gpioOk ? "RPi.GPIO доступен" : scaleHw ? "RPi.GPIO недоступен" : "не используется", ), wespAdmListItem( "HX711", `${hxBadge}DOUT ${escHtml(String(p.hx711_dout_pin ?? "—"))} · SCK ${escHtml(String(p.hx711_pd_sck_pin ?? "—"))}`, sim ? "не используется" : scaleHw ? (hxOk ? "драйвер инициализирован" : "драйвер не поднят") : "пропущен (не ARM)", ), p.last_hx711_error && !sim ? wespAdmListItem( "Последняя ошибка", '!', 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( "Вес", `${escHtml(String(s.weight_kg ?? "—"))} кг`, sim ? "симуляция" : skipped ? "HX711 не опрашивается" : "с датчика", ), wespAdmListItem( "HX711", sim ? 'sim' : skipped ? '' : diagOkBadge(hxOk), sim ? "не используется" : skipped ? "включите симуляцию на этой платформе" : escHtml(String(s.hx711_error || "OK")), ), wespAdmListItem( "Калибровка", `counts/kg ${escHtml(String(s.counts_per_kg ?? "—"))} · tare ${escHtml(String(s.tare_raw ?? "—"))}`, ), wespAdmListItem( "Сырьё", `n=${escHtml(String(s.raw_history_len ?? 0))}`, [ 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" ? 'ERR' : lvl === "ok" ? diagOkBadge(true) : lvl === "info" ? 'INFO' : 'WARN'; 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("—", `Нет данных`); 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}`, `${escHtml(sz)}`)); } for (const d of preview.directories || []) { const n = d.files_inside; const hint = n != null && n >= 0 ? `${n} файл(ов)` : "каталог"; rows.push( wespAdmListItem(`каталог: ${d.path}`, `${escHtml(hint)}`), ); } if (!rows.length) { rows.push( wespAdmListItem( "data/", `пусто или уже сброшено`, ), ); } list.innerHTML = rows.join(""); } async function loadFactoryResetPreview() { const list = document.getElementById("factoryResetPreviewList"); if (list) { list.innerHTML = wespAdmListItem("—", `Загрузка…`); } 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, `${escHtml(v)}`)) .join(""); const sectionHtml = machineRows.length > 0 ? `${wespAdmListSection("Host / hardware")}${machineRows .map(([k, v]) => wespAdmListItem(k, `${escHtml(v)}`)) .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), `${escHtml(sz)}`, 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 += ``; } 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 ``; }) .join(""); const yLbl = (v) => `${v}`; svgEl.innerHTML = ` ${grid} ${yLbl(0)}${yLbl(50)}${yLbl(100)} ${fmtTime(tMin)} ${fmtTime(tMax)} % ${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) => `${x.label}`, ) .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 = ``; 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, """)}" ` + `data-is-super="${user.is_superuser ? "1" : "0"}" ` + `data-lab-access="${user.lab_access ? "1" : "0"}" ` + `data-user-login="${String(user.login).replace(/"/g, """)}"`, }); } 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 ? '|' : ""}${v}`, ) .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", `${escHtml(String(d.lan_ip ?? "—"))}`, "текущий адрес в сети", ), wespAdmListItem( "Hostname ОС", escHtml(String(d.os_hostname ?? "—")), "socket.gethostname()", ), wespAdmListItem( "Listen", `${escHtml(String(d.listen_host ?? "—"))}:${escHtml(String(d.listen_port ?? "—"))}`, "WESP_LISTEN / waitress", ), wespAdmListItem( "mDNS", mdnsOn ? 'активен' : mdnsAvail ? 'выкл.' : 'zeroconf ∅', mdnsOn ? "имя объявляется в LAN" : d.mdns_error ? escHtml(String(d.mdns_error)) : mdnsAvail ? "включите toggle ниже" : "pip install zeroconf", ), wespAdmListItem( "Подсказка URL", `${escHtml(String(net.suggested_public_url ?? "—"))}`, "из имени + порта", ), wespAdmListItem( "Sync по IP", `${escHtml(String(net.sync_url_by_ip ?? "—"))}`, "если .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, `${escHtml(v)}`)) .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 = '

    Нет строк в sync_queue.

    '; return; } const statuses = ["pending", "processing", "completed", "failed"]; let html = ""; for (const s of statuses) html += ``; html += ""; for (const t of tables) { const row = tbl[t] || {}; let sum = 0; html += ``; for (const s of statuses) { const n = Number(row[s] || 0); sum += n; html += ``; } html += ``; } html += "
    table${escHtml(s)}Σ
    ${escHtml(t)}${n || "—"}${sum}
    "; 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 = '

    Проблемных задач не найдено (по текущим критериям).

    '; return; } let html = ''; const slice = all.slice(0, 120); for (const r of slice) { const note = String(r.error_message || r.stuck_reason || "—").slice(0, 320); html += ``; } html += "
    классtableidstatusretryпримечание
    ${escHtml(r.diagSection || "—")}${escHtml( String(r.table_name || "—"), )}${escHtml( String(r.id || "").slice(0, 8), )}…${escHtml(String(r.status || "—"))}${escHtml( String(r.retry_count ?? "—"), )}${escHtml(note)}
    "; if (all.length > 120) { html += `

    … и ещё ${all.length - 120}

    `; } 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 = `Снимок: ${escHtml(at)}. Метрики 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, `${escHtml(String(v))}`), ) .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, `${escHtml(String(v))}`, ), ) .join(""); if (r.note) { html += wespAdmListItem( "note", `${escHtml(String(r.note))}`, ); } 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, `${escHtml(v)}`)) .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", `${escHtml(String(es.universal_bootstrap_completed_at || "—"))}`, ), ); parts.push( wespAdmListItem( "bootstrap_cursor", `${escHtml(String(es.universal_bootstrap_cursor ?? "—"))}`, ), ); if (es.universal_bootstrap_last_error) { parts.push( wespAdmListItem( "bootstrap_last_error", `${escHtml(String(es.universal_bootstrap_last_error).slice(0, 500))}`, ), ); } } else { parts.push(wespAdmListItem("engine_state", ``)); } parts.push( wespAdmListItem( "initial_sync.active", `${escHtml(String(Boolean(isp.active)))}`, ), ); if (isp.percent != null) { parts.push( wespAdmListItem( "initial_sync.percent", `${escHtml(String(isp.percent))}`, ), ); } boot.innerHTML = parts.join(""); } const rp = document.getElementById("syncDiagReportPushList"); if (rp) { const x = diag.report_push || {}; const parts = []; parts.push( wespAdmListItem("scope", `${escHtml(String(x.scope || "—"))}`), ); if (x.note) { parts.push( wespAdmListItem("note", `${escHtml(String(x.note))}`), ); } if (x.tables && x.tables.length) { parts.push( wespAdmListItem( "tables", `${escHtml(x.tables.join(", "))}`, ), ); } if (x.by_status) { parts.push( wespAdmListItem( "by_status", `${escHtml(JSON.stringify(x.by_status))}`, ), ); } if (x.total != null) { parts.push( wespAdmListItem( "total report tasks", `${escHtml(String(x.total))}`, ), ); } const cr = x.client_runtime || {}; parts.push( wespAdmListItem( "sync_client init", `${cr.initialized ? "yes" : "no"}`, ), ); parts.push( wespAdmListItem( "sync_thread alive", `${cr.client_thread_alive ? "yes" : "no"}`, ), ); 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", `${escHtml(String(s.total ?? "—"))}`, ), wespAdmListItem( "pending", `${escHtml(String(s.pending ?? "—"))}`, ), ]; const body = rec.length ? rec.map((x) => wespAdmListItem( `${x.table_name || "—"} · ${String(x.record_id || "").slice(0, 8)}…`, `${escHtml(String(x.conflict_type || "—"))}`, `${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", `${escHtml(c.error)}`, ); } 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}`, `${escHtml(String(z.digest || "").slice(0, 16))}…`, `count=${String(z.count ?? "—")}`, ); }); const ir = snap.recipe_uuid_reference_issues; if (ir != null) { rows.push( wespAdmListItem( "recipe_uuid_reference_issues", `${escHtml(String(ir))}`, ), ); } 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)}…`, `${escHtml(String(d.client_id || "").slice(0, 8))}…`, 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, `${err}`, 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( ``, ); } 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( ``, ); } if (c.sync_client_autostart === false) { blocks.push( ``, ); } if (String(c.role || "").toLowerCase() === "client" && c.sync_server_url_explicit === false) { blocks.push( ``, ); } 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 ? 'OK' : 'Ошибка'; } function makeDiagTargetRow(target = {}) { const role = escHtml(target.role || ""); const host = escHtml(target.host || ""); const title = escHtml(target.title || ""); return `
    `; } 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", `${escHtml(roleRu)}`), wespAdmListItem( "primary_targets.n", `${escHtml(String((rpt.primary_targets || []).length))}`, ), wespAdmListItem( "extra_targets.n", `${escHtml(String((rpt.extra_targets || []).length))}`, ), 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 || "—", `${escHtml(t.host || "—")}`, `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", '!', 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 = 'sim'; const gpioBadge = sim ? badgeSim : diagOkBadge(gpioOk); const hxBadge = sim ? badgeSim : diagOkBadge(hxOk); hw.innerHTML = [ wespAdmListItem("MODE", `${escHtml(modeLine)}`), wespAdmListItem( "GPIO LED", `${gpioBadge}${escHtml(gpioLine)}`, ), wespAdmListItem( "HX711", `${hxBadge}${escHtml(hxLine)}`, ), wespAdmListItem( "BCM pins", `LED ${escHtml(String(hwData.gpio_led_pin ?? "—"))} · DOUT ${escHtml(String(hwData.hx711_dout_pin ?? "—"))} / SCK ${escHtml(String(hwData.hx711_pd_sck_pin ?? "—"))}`, ), ].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 = '

    results: ∅

    '; return; } let html = ""; for (const row of results) { const host = escHtml(row.host); if (row.validate_error) { html += `
    ${host}${escHtml(row.validate_error)}
    `; 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 = `
    ${host}`; if (probeBits) { body += `${escHtml(probeBits)}`; } body += `${tool}${okLabel}`; if (tr.exit_code != null && tr.exit_code !== "") { body += `exit ${escHtml(tr.exit_code)}`; } if (tr.error) body += `${escHtml(tr.error)}`; body += "
    "; const pr = row.parse; if (pr && pr.hops && pr.hops.length) { const st = pr.stats || {}; body += `

    hops=${escHtml(st.total_hops ?? pr.hops.length)} · private=${escHtml(st.private_count ?? "—")} · max_rtt_ms=${escHtml(st.max_delay_ms ?? "—")}

    `; body += ''; 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 += ``; } body += "
    ttladdrnetavg_ms
    ${escHtml(h.num)}${escHtml(h.ip)}${nt}${avg}
    "; } else if (tr.lines && tr.lines.length) { body += `
    ${escHtml(tr.lines.join("\n"))}
    `; } else { body += '

    stdout: ∅

    '; } body += "
    "; 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 = '

    traceroute: running…

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

    ${escHtml(userHint(e.message, "Не удалось загрузить данные сети"))}

    `; return; } if (!r.ok || d.status !== "success") { wrap.innerHTML = `

    ${escHtml(d.message || `Ошибка ${r.status}`)}

    `; 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, """); 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 = `
    `; 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) => `
    ${escHtml(a.interface)} ${escHtml(a.family)} ${escHtml(a.address)}
    `, ) .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();