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