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(
`
`,
);
}
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 `
`;
}
function wespAdmListSection(title) {
return `${escHtml(title)}
`;
}
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, "Ошибка обновления"));
}
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 = `
Пароль
${user.is_superuser ? "Снять super" : "Сделать super"}
Удалить
`;
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 = "table ";
for (const s of statuses) html += `${escHtml(s)} `;
html += "Σ ";
for (const t of tables) {
const row = tbl[t] || {};
let sum = 0;
html += `${escHtml(t)} `;
for (const s of statuses) {
const n = Number(row[s] || 0);
sum += n;
html += `${n || "—"} `;
}
html += `${sum} `;
}
html += "
";
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 =
'класс table id status retry примечание ';
const slice = all.slice(0, 120);
for (const r of slice) {
const note = String(r.error_message || r.stuck_reason || "—").slice(0, 320);
html += `${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)} `;
}
html += "
";
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(
`WESP_SYNC_ROLE задан в окружении: роль из формы не сохраняется. Сейчас действует: ${v}. Уберите переменную из окружения (unit/systemd), чтобы менять роль из админки.
`,
);
}
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(
`WESP_SYNC_SERVER_URL задан в окружении: адрес из формы не сохраняется. Сейчас действует: ${v}. Уберите переменную из окружения, чтобы задавать URL из админки.
`,
);
}
if (c.sync_client_autostart === false) {
blocks.push(
`Фоновый sync выключен (SYNC_CLIENT_AUTOSTART / WESP_SYNC_CLIENT_AUTOSTART=0). Узел не опрашивает сервер в фоне; запись pull в локальную БД не выполняется. Включите автостарт в конфиге или в окружении и перезапустите службу WESP.
`,
);
}
if (String(c.role || "").toLowerCase() === "client" && c.sync_server_url_explicit === false) {
blocks.push(
`Клиент без адреса главного узла . Фоновый sync не запускается , пока не задан URL (поле ниже или WESP_SYNC_SERVER_URL в окружении и сохранение). Например http://192.168.0.10 или http://komton_srv_1.local.
`,
);
}
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 +=
'
ttl addr net avg_ms ';
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 += `${escHtml(h.num)} ${escHtml(h.ip)} ${nt} ${avg} `;
}
body += "
";
} 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 = `
IP
Имя
Удалить
`;
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 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("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();