import {
  openAdminDbBrowser,
  refreshAdminDbGrid,
  refreshAdminDbTree,
  wireAdminDbBrowserModal,
} from "./admin-db-browser.js";
import { mountWespLogoLoader } from "/static/js/wesp-logo-loader.js?v=admin-kiosk-setup";

const GAUGE_RADIUS = 47;
const GAUGE_CIRC = 2 * Math.PI * GAUGE_RADIUS;

const ADMIN_SIDEBAR_COLLAPSED_KEY = "wespAdminSidebarCollapsed";
const ADMIN_ACTIVITY_LOG_KEY = "wesp_admin_activity_v1";
/** История LLM-чата в админке: переживает F5 в той же вкладке. */
const WESP_ASSISTANT_CHAT_STORAGE_KEY = "wespAdminAssistantChatV1";
const ADMIN_ACTIVITY_RETENTION_MS = 3 * 24 * 60 * 60 * 1000;
const ADMIN_ACTIVITY_MAX_ENTRIES = 400;
/** Кэш GET /api/admin/activity-feed (JSONL + wesp.log), обновляется при hydrate и «Обновить». */
let activityServerCache = [];
const ADMIN_SHELL_MIN_MS = 400;
const ADMIN_SKEL_MIN_VISIBLE_MS = 650;
const diagState = {
  report: null,
  extraTargets: [],
};

function _blockHasSkelOverlay(el) {
  for (let i = 0; i < el.children.length; i += 1) {
    if (el.children[i].classList.contains("wesp-admin-skel-overlay")) return true;
  }
  return false;
}

function prepareAdminSkeletonBlocks() {
  const app = document.getElementById("app");
  if (!app) return;
  const seen = new Set();
  const add = (el) => {
    if (!el || seen.has(el)) return;
    seen.add(el);
    el.classList.add("wesp-admin-block");
    if (_blockHasSkelOverlay(el)) return;
    const ov = document.createElement("div");
    ov.className = "wesp-admin-skel-overlay";
    ov.setAttribute("aria-hidden", "true");
    el.appendChild(ov);
  };
  add(app.querySelector(".wesp-admin-sider"));
  add(app.querySelector("main.ant-layout-content .wesp-admin-content-topbar"));
  app.querySelectorAll("main.ant-layout-content .ant-card").forEach(add);
}

function finishAdminShellReveal(shellStart) {
  const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const armedAt =
    typeof window.__WESP_ADMIN_SKEL_ARMED_AT__ === "number"
      ? window.__WESP_ADMIN_SKEL_ARMED_AT__
      : shellStart;
  const elapsed = performance.now() - shellStart;
  const sinceArmed = performance.now() - armedAt;
  const wait = reduced
    ? 0
    : Math.max(
        0,
        ADMIN_SHELL_MIN_MS - elapsed,
        ADMIN_SKEL_MIN_VISIBLE_MS - sinceArmed,
      );
  return new Promise((resolve) => {
    window.setTimeout(() => {
      document.querySelectorAll(".wesp-admin-block").forEach((el, i) => {
        el.style.setProperty("--wesp-reveal-i", String(i));
      });
      document.documentElement.classList.remove("wesp-admin-loading");
      document.documentElement.classList.add("wesp-admin-loaded");
      window.setTimeout(() => {
        document.querySelectorAll(".wesp-admin-skel-overlay").forEach((o) => o.remove());
        resolve();
      }, reduced ? 80 : 450);
    }, wait);
  });
}

function stripAdminShellDecorations() {
  document.documentElement.classList.remove("wesp-admin-loading");
  document.querySelectorAll(".wesp-admin-skel-overlay").forEach((o) => o.remove());
}

function isAdminNarrowViewport() {
  return window.matchMedia("(max-width: 768px)").matches;
}

function applyAdminSidebarCollapsed(collapsed) {
  const sider = document.getElementById("wespAdminSider");
  const trigger = document.getElementById("wespAdminSiderTrigger");
  if (!sider || !trigger) return;
  if (isAdminNarrowViewport()) collapsed = false;
  sider.classList.toggle("ant-layout-sider-collapsed", collapsed);
  trigger.setAttribute("aria-expanded", collapsed ? "false" : "true");
  trigger.title = collapsed ? "Развернуть меню" : "Свернуть меню";
  const icon = trigger.querySelector(".wesp-sider-trigger-icon");
  if (icon) icon.textContent = collapsed ? "▶" : "◀";
}

function readSidebarCollapsedPreference() {
  /* По умолчанию узкий сайдбар; явно «0» в localStorage — развёрнут. */
  return localStorage.getItem(ADMIN_SIDEBAR_COLLAPSED_KEY) !== "0";
}

function initAdminSidebar() {
  const sider = document.getElementById("wespAdminSider");
  const trigger = document.getElementById("wespAdminSiderTrigger");
  if (!sider || !trigger) return;
  applyAdminSidebarCollapsed(readSidebarCollapsedPreference());
  trigger.addEventListener("click", () => {
    if (isAdminNarrowViewport()) return;
    const next = !sider.classList.contains("ant-layout-sider-collapsed");
    applyAdminSidebarCollapsed(next);
    if (next) localStorage.setItem(ADMIN_SIDEBAR_COLLAPSED_KEY, "1");
    else localStorage.setItem(ADMIN_SIDEBAR_COLLAPSED_KEY, "0");
  });
  trigger.addEventListener("keydown", (e) => {
    if (e.key !== "Enter" && e.key !== " ") return;
    e.preventDefault();
    trigger.click();
  });
  window.addEventListener("resize", () => {
    if (isAdminNarrowViewport()) {
      applyAdminSidebarCollapsed(false);
    } else {
      applyAdminSidebarCollapsed(readSidebarCollapsedPreference());
    }
  });
}

/** Админка всегда в ultra-dark; zootech-тема из localStorage не используется. */
function applyAdminDocumentTheme() {
  const root = document.documentElement;
  root.setAttribute("data-theme", "ultra-dark");
  root.classList.add("dark");
  root.classList.remove("light");
}

function formatJson(data) {
  return JSON.stringify(data, null, 2);
}

function escHtml(s) {
  return String(s)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

function defaultAssistantDateRange() {
  const to = new Date();
  const from = new Date(to);
  from.setDate(from.getDate() - 7);
  return { from, to };
}

function formatDateInputYmd(d) {
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, "0");
  const day = String(d.getDate()).padStart(2, "0");
  return `${y}-${m}-${day}`;
}

function _assistantChatEscapeHtml(text) {
  const d = document.createElement("div");
  d.textContent = text == null ? "" : String(text);
  return d.innerHTML;
}

/** Сообщения для API: без служебного «ожидания» и без system (его добавляет сервер). */
function assistantChatMessagesForApi() {
  const msgs = window.__wespAssistantChatMessages || [];
  return msgs.filter((m) => !m.pending && (m.role === "user" || m.role === "assistant"));
}

function persistAssistantChatMessages() {
  try {
    const msgs = window.__wespAssistantChatMessages || [];
    const toSave = msgs.filter((m) => !m.pending);
    sessionStorage.setItem(WESP_ASSISTANT_CHAT_STORAGE_KEY, JSON.stringify(toSave));
  } catch {
    /* quota / private mode */
  }
}

function hydrateAssistantChatMessagesFromStorage() {
  try {
    const raw = sessionStorage.getItem(WESP_ASSISTANT_CHAT_STORAGE_KEY);
    if (!raw) {
      if (!window.__wespAssistantChatMessages) window.__wespAssistantChatMessages = [];
      return;
    }
    const parsed = JSON.parse(raw);
    if (!Array.isArray(parsed)) {
      window.__wespAssistantChatMessages = [];
      return;
    }
    window.__wespAssistantChatMessages = parsed.filter(
      (m) => m && (m.role === "user" || m.role === "assistant") && !m.pending,
    );
  } catch {
    window.__wespAssistantChatMessages = [];
  }
}

function renderAssistantChatLog() {
  const log = document.getElementById("wespAssistantChatLog");
  if (!log) return;
  const msgs = window.__wespAssistantChatMessages || [];
  const parts = [];
  for (let i = 0; i < msgs.length; i += 1) {
    const m = msgs[i];
    const role = m.role;
    if (role !== "user" && role !== "assistant") continue;
    const label = role === "user" ? "Вы" : "Модель";
    const cls = role === "user" ? "wesp-assistant-msg-user" : "wesp-assistant-msg-assistant";
    const pendingCls = m.pending ? " wesp-assistant-msg--pending" : "";
    const contentHtml = m.pending
      ? `<span class="wesp-assistant-pending">${_assistantChatEscapeHtml(m.content)}</span>`
      : _assistantChatEscapeHtml(m.content);
    parts.push(
      `<div class="${cls}${pendingCls}" style="margin-bottom:10px;padding:8px;border-radius:8px;background:rgba(128,128,128,0.08);"><strong>${label}</strong><div style="margin-top:4px;">${contentHtml}</div></div>`,
    );
  }
  log.innerHTML = parts.join("");
  log.scrollTop = log.scrollHeight;
  persistAssistantChatMessages();
}

const WESP_LLM_EVENT_LABELS = {
  ping: "ping",
  summary: "сводка",
  chat: "чат",
  settings: "настройки",
  diagnostics: "диагностика",
  status: "статус",
};

function setAssistantInteractiveEnabled(enabled) {
  const ids = [
    "wespAssistantChatInput",
    "wespAssistantChatSendBtn",
    "wespAssistantPingBtn",
    "wespAssistantSummaryBtn",
    "wespAssistantDiagnosticsBtn",
    "wespAssistantSqlMode",
    "wespAssistantToolTraceCb",
    "wespAssistantDateFrom",
    "wespAssistantDateTo",
    "wespAssistantFocus",
  ];
  for (const id of ids) {
    const el = document.getElementById(id);
    if (!el) continue;
    if (el instanceof HTMLInputElement || el instanceof HTMLButtonElement || el instanceof HTMLSelectElement) {
      el.disabled = !enabled;
    }
  }
  const section = document.getElementById("wespAssistantChatSectionTitle");
  if (section) {
    section.style.opacity = enabled ? "1" : "0.55";
  }
}

function renderAssistantLlmLog(entries) {
  const wrap = document.getElementById("wespAssistantLlmLog");
  if (!wrap) return;
  const rows = Array.isArray(entries) ? entries : [];
  if (!rows.length) {
    wrap.innerHTML =
      '<div class="wesp-admin-activity-line wesp-admin-activity-line--empty">// журнал пуст (нет вызовов за окно хранения)</div>';
    return;
  }
  const sorted = [...rows].sort((a, b) => Number(a.u || 0) - Number(b.u || 0));
  wrap.innerHTML = sorted
    .map((e) => {
      let lvl = "OK";
      let cls = "wesp-admin-activity-line--ok";
      const level = String(e.level || "ok");
      if (level === "err") {
        lvl = "ERR";
        cls = "wesp-admin-activity-line--err";
      } else if (level === "warn") {
        lvl = "WRN";
        cls = "wesp-admin-activity-line--warn";
      }
      const ev = WESP_LLM_EVENT_LABELS[String(e.event || "")] || String(e.event || "llm");
      const dur =
        typeof e.duration_ms === "number" && e.duration_ms >= 0 ? ` · ${Math.round(e.duration_ms)} ms` : "";
      const user = String(e.user || "").trim();
      const userBit = user ? ` · ${user}` : "";
      const detail = String(e.detail || "").trim();
      const msg = detail ? `${ev}: ${detail}${dur}${userBit}` : `${ev}${dur}${userBit}`;
      const tsMs = Number(e.u || 0) * 1000;
      return `<div class="wesp-admin-activity-line ${cls}" role="listitem"><span class="wesp-admin-activity-ts">[${escHtml(formatActivityTs(tsMs))}]</span><span class="wesp-admin-activity-lvl">${lvl}</span><span class="wesp-admin-activity-msg">${escHtml(msg)}</span></div>`;
    })
    .join("");
  wrap.scrollTop = wrap.scrollHeight;
}

async function loadAdminAssistantLlmLog() {
  const badge = document.getElementById("wespAssistantLlmLogBadge");
  const r = await fetch("/api/admin/llm/activity", { credentials: "same-origin" });
  const data = await r.json().catch(() => ({}));
  if (!r.ok || data.status !== "success") {
    renderAssistantLlmLog([]);
    if (badge) badge.textContent = "ошибка загрузки";
    return;
  }
  const days = Number(data.retention_days) || 7;
  if (badge) badge.textContent = `${days}d · JSONL`;
  renderAssistantLlmLog(data.entries || []);
}

async function loadAdminAssistantStatus() {
  const badge = document.getElementById("wespAssistantStatusBadge");
  const hint = document.getElementById("wespAssistantLlmUrlHint");
  const r = await fetch("/api/admin/llm/status", { credentials: "same-origin" });
  const data = await r.json().catch(() => ({}));
  if (!r.ok) {
    if (badge) {
      badge.textContent = "ошибка API";
      badge.className = "ant-tag ant-tag-red";
    }
    return;
  }
  const p = data;
  if (hint && p.llm_base_url) {
    hint.textContent = String(p.llm_base_url).replace(/^https?:\/\//, "");
  }
  const adminLlmCb = document.getElementById("wespAssistantAdminLlmEnabled");
  const adminLlmHint = document.getElementById("wespAssistantAdminLlmEnabledHint");
  if (adminLlmCb) {
    adminLlmCb.checked = p.enabled !== false;
    adminLlmCb.disabled = !!p.admin_llm_enabled_locked_by_env;
    if (adminLlmHint) {
      if (p.admin_llm_enabled_locked_by_env) {
        adminLlmHint.style.display = "block";
        adminLlmHint.textContent =
          "Значение задано в окружении (WESP_ADMIN_LLM_ENABLED). Снимите переменную, чтобы менять галку здесь.";
      } else {
        adminLlmHint.style.display = "none";
        adminLlmHint.textContent = "";
      }
    }
  }
  const autostartCb = document.getElementById("wespAssistantLlmAutostart");
  const autostartHint = document.getElementById("wespAssistantLlmAutostartHint");
  if (autostartCb) {
    autostartCb.checked = p.llm_autostart !== false;
    autostartCb.disabled = !!p.llm_autostart_locked_by_env;
    if (autostartHint) {
      if (p.llm_autostart_locked_by_env) {
        autostartHint.style.display = "block";
        autostartHint.textContent =
          "Значение задано в окружении (WESP_LLM_AUTOSTART). Снимите переменную, чтобы менять галку здесь.";
      } else {
        autostartHint.style.display = "none";
        autostartHint.textContent = "";
      }
    }
  }
  const chatDbCb = document.getElementById("wespAssistantChatDbContext");
  const chatDbHint = document.getElementById("wespAssistantChatDbContextHint");
  if (chatDbCb) {
    chatDbCb.checked = p.llm_chat_db_context === true;
    chatDbCb.disabled = !!p.llm_chat_db_context_locked_by_env;
    if (chatDbHint) {
      if (p.llm_chat_db_context_locked_by_env) {
        chatDbHint.style.display = "block";
        chatDbHint.textContent =
          "Значение задано в окружении (WESP_LLM_CHAT_INCLUDE_DB_CONTEXT). Снимите переменную, чтобы менять галку здесь.";
      } else {
        chatDbHint.style.display = "none";
        chatDbHint.textContent = "";
      }
    }
  }
  const toolsCb = document.getElementById("wespAssistantLlmToolsEnabled");
  const toolsHint = document.getElementById("wespAssistantLlmToolsEnabledHint");
  if (toolsCb) {
    toolsCb.checked = p.llm_tools_enabled !== false;
    toolsCb.disabled = !!p.llm_tools_enabled_locked_by_env;
    if (toolsHint) {
      if (p.llm_tools_enabled_locked_by_env) {
        toolsHint.style.display = "block";
        toolsHint.textContent =
          "Значение задано в окружении (WESP_LLM_TOOLS_ENABLED). Снимите переменную, чтобы менять галку здесь.";
      } else {
        toolsHint.style.display = "none";
        toolsHint.textContent = "";
      }
    }
  }
  setAssistantInteractiveEnabled(p.enabled !== false);
  if (!badge) return;
  if (!p.enabled) {
    badge.textContent = "ИИ выкл.";
    badge.className = "ant-tag";
    return;
  }
  if (!p.llm_reachable) {
    badge.textContent = "LLM недоступен";
    badge.className = "ant-tag ant-tag-red";
    return;
  }
  if (!p.model_present) {
    badge.textContent = "модель не найдена";
    badge.className = "ant-tag";
    return;
  }
  badge.textContent = "OK";
  badge.className = "ant-tag ant-tag-green";
}

async function runAdminAssistantChatSend() {
  const input = document.getElementById("wespAssistantChatInput");
  const sendBtn = document.getElementById("wespAssistantChatSendBtn");
  const text = (input?.value || "").trim();
  if (!text) return;
  if (!window.__wespAssistantChatMessages) window.__wespAssistantChatMessages = [];
  if (sendBtn?.dataset.busy === "1") return;
  input.value = "";
  window.__wespAssistantChatMessages.push({ role: "user", content: text });
  window.__wespAssistantChatMessages.push({
    role: "assistant",
    content: "Генерируется ответ…",
    pending: true,
  });
  renderAssistantChatLog();
  if (sendBtn) {
    sendBtn.dataset.busy = "1";
    sendBtn.disabled = true;
  }
  try {
    const toolsEl = document.getElementById("wespAssistantLlmToolsEnabled");
    const toolsEnabled = !!(toolsEl && toolsEl instanceof HTMLInputElement && toolsEl.checked);
    const sqlModeEl = document.getElementById("wespAssistantSqlMode");
    const sqlMode =
      sqlModeEl && sqlModeEl instanceof HTMLSelectElement ? String(sqlModeEl.value || "preview") : "preview";
    const traceCb = document.getElementById("wespAssistantToolTraceCb");
    const toolTrace = !(traceCb && traceCb instanceof HTMLInputElement && !traceCb.checked);
    const tracePre = document.getElementById("wespAssistantToolTrace");
    const r = await fetch("/api/admin/llm/chat", {
      method: "POST",
      credentials: "same-origin",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        messages: assistantChatMessagesForApi(),
        sql_mode: sqlMode,
        tools_enabled: toolsEnabled,
        tool_trace: toolTrace,
      }),
    });
    const data = await r.json().catch(() => ({}));
    const last = window.__wespAssistantChatMessages.length - 1;
    if (window.__wespAssistantChatMessages[last]?.pending) {
      window.__wespAssistantChatMessages.pop();
    }
    if (!r.ok) {
      const errText = data.message || data.detail || String(r.status);
      window.__wespAssistantChatMessages.push({
        role: "assistant",
        content: `Ошибка: ${errText}`,
      });
      renderAssistantChatLog();
      setStatus(errText, true);
      if (tracePre) tracePre.textContent = "—";
      return;
    }
    window.__wespAssistantChatMessages.push({
      role: "assistant",
      content: data.reply || "",
    });
    renderAssistantChatLog();
    if (tracePre) {
      if (Array.isArray(data.tool_trace) && data.tool_trace.length) {
        tracePre.textContent = formatJson(data.tool_trace);
      } else {
        tracePre.textContent = "—";
      }
    }
    setStatus("Чат: OK");
    loadAdminAssistantLlmLog().catch(() => {});
  } catch (err) {
    const last = window.__wespAssistantChatMessages.length - 1;
    if (window.__wespAssistantChatMessages[last]?.pending) {
      window.__wespAssistantChatMessages.pop();
    }
    const msg = err?.message || "Ошибка сети или сервера";
    window.__wespAssistantChatMessages.push({
      role: "assistant",
      content: `Ошибка: ${msg}`,
    });
    renderAssistantChatLog();
    const tracePreErr = document.getElementById("wespAssistantToolTrace");
    if (tracePreErr) tracePreErr.textContent = "—";
    setStatus(msg, true);
  } finally {
    if (sendBtn) {
      delete sendBtn.dataset.busy;
      const master = document.getElementById("wespAssistantAdminLlmEnabled");
      const llmOn = !(master instanceof HTMLInputElement) || master.checked;
      sendBtn.disabled = !llmOn;
    }
  }
}

async function runAdminAssistantPing() {
  const out = document.getElementById("wespAssistantOutput");
  if (out) out.textContent = "…";
  const r = await fetch("/api/admin/llm/ping", {
    method: "POST",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json" },
    body: "{}",
  });
  const data = await r.json().catch(() => ({}));
  if (!r.ok) {
    if (out) out.textContent = userHint(data.message || String(r.status), "Не удалось выполнить операцию");
    setStatus(data.message, true, "llm/ping failed");
    return;
  }
  if (out) out.textContent = data.reply || "";
  setStatus("llm/ping: OK");
  loadAdminAssistantLlmLog().catch(() => {});
}

async function runAdminAssistantDiagnostics() {
  const out = document.getElementById("wespAssistantOutput");
  if (out) out.textContent = "Загрузка…";
  const r = await fetch("/api/admin/llm/diagnostics", {
    credentials: "same-origin",
  });
  const data = await r.json().catch(() => ({}));
  if (!r.ok) {
    if (out) out.textContent = userHint(data.message || String(r.status), "Не удалось выполнить операцию");
    setStatus(data.message, true, "diagnostics failed");
    return;
  }
  const bundle = {
    diagnostics: data.diagnostics,
    db_health: data.db_health,
    inventory: data.inventory,
    text_for_llm: data.text_for_llm,
  };
  if (out) out.textContent = formatJson(bundle);
  setStatus("Диагностика загружена");
}

async function runAdminAssistantSummary() {
  const out = document.getElementById("wespAssistantOutput");
  const df = document.getElementById("wespAssistantDateFrom");
  const dt = document.getElementById("wespAssistantDateTo");
  const focusEl = document.getElementById("wespAssistantFocus");
  if (!df?.value || !dt?.value) {
    setStatus("Укажите период (даты «с» и «по»)", true);
    return;
  }
  if (out) out.textContent = "…";
  const r = await fetch("/api/admin/llm/summary", {
    method: "POST",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      date_from: df.value,
      date_to: dt.value,
      focus: focusEl?.value || "both",
    }),
  });
  const data = await r.json().catch(() => ({}));
  if (!r.ok) {
    if (out) out.textContent = userHint(data.message || String(r.status), "Не удалось выполнить операцию");
    setStatus(data.message, true, "llm/summary failed");
    return;
  }
  if (out) out.textContent = data.reply || "";
  setStatus("llm/summary: OK");
  loadAdminAssistantLlmLog().catch(() => {});
}

function formatActivityTs(ts) {
  const d = new Date(ts);
  const pad = (n) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}

function loadAdminActivityLog() {
  try {
    const raw = localStorage.getItem(ADMIN_ACTIVITY_LOG_KEY);
    if (!raw) return [];
    const arr = JSON.parse(raw);
    return Array.isArray(arr) ? arr : [];
  } catch {
    return [];
  }
}

function saveAdminActivityLog(entries) {
  try {
    localStorage.setItem(ADMIN_ACTIVITY_LOG_KEY, JSON.stringify(entries));
  } catch {
    /* quota / private mode */
  }
}

function pruneAdminActivityLog(entries) {
  const cutoff = Date.now() - ADMIN_ACTIVITY_RETENTION_MS;
  return entries.filter(
    (e) => e && typeof e.ts === "number" && e.ts >= cutoff && typeof e.text === "string" && e.text.trim(),
  );
}

function renderAdminActivityTerminal(entries) {
  const wrap = document.getElementById("adminActivityLog");
  if (!wrap) return;
  const sorted = [...entries].sort((a, b) => a.ts - b.ts);
  if (!sorted.length) {
    wrap.innerHTML =
      '<div class="wesp-admin-activity-line wesp-admin-activity-line--empty">// no events in retention window</div>';
    return;
  }
  wrap.innerHTML = sorted
    .map((e) => {
      let lvl = "OK";
      let cls = "wesp-admin-activity-line--ok";
      if (e.level === "err") {
        lvl = "ERR";
        cls = "wesp-admin-activity-line--err";
      } else if (e.level === "warn") {
        lvl = "WRN";
        cls = "wesp-admin-activity-line--warn";
      }
      return `<div class="wesp-admin-activity-line ${cls}" role="listitem"><span class="wesp-admin-activity-ts">[${escHtml(formatActivityTs(e.ts))}]</span><span class="wesp-admin-activity-lvl">${lvl}</span><span class="wesp-admin-activity-msg">${escHtml(e.text)}</span></div>`;
    })
    .join("");
  wrap.scrollTop = wrap.scrollHeight;
}

async function refreshActivityServerCache() {
  try {
    const r = await fetch("/api/admin/activity-feed", { credentials: "same-origin" });
    const d = await r.json().catch(() => ({}));
    if (d.status === "success" && Array.isArray(d.entries)) {
      activityServerCache = d.entries.map((e) => ({
        ts: Number(e.ts) || 0,
        level: e.level === "err" ? "err" : e.level === "warn" ? "warn" : "ok",
        text: String(e.text || ""),
      }));
    }
  } catch {
    /* сеть / 401 */
  }
}

function mergeActivityEntriesForDisplay() {
  const local = pruneAdminActivityLog(loadAdminActivityLog());
  const cutoff = Date.now() - ADMIN_ACTIVITY_RETENTION_MS;
  const map = new Map();
  const keyOf = (e) => `${e.level}|${String(e.text || "").slice(0, 160)}`;
  for (const e of activityServerCache) {
    if (!e || !String(e.text || "").trim()) continue;
    if (e.ts < cutoff) continue;
    map.set(keyOf(e), { ts: e.ts, level: e.level, text: e.text });
  }
  for (const e of local) {
    if (!e || !String(e.text || "").trim()) continue;
    if (e.ts < cutoff) continue;
    const k = keyOf(e);
    const prev = map.get(k);
    if (!prev || prev.ts < e.ts) map.set(k, { ts: e.ts, level: e.level, text: e.text });
  }
  return Array.from(map.values()).sort((a, b) => a.ts - b.ts);
}

function renderAdminActivityTerminalFromMerged() {
  renderAdminActivityTerminal(mergeActivityEntriesForDisplay());
}

async function hydrateAdminActivityLog() {
  let entries = pruneAdminActivityLog(loadAdminActivityLog());
  saveAdminActivityLog(entries);
  await refreshActivityServerCache();
  renderAdminActivityTerminalFromMerged();
}

function persistAdminUiActivityToServer(text, isError) {
  fetch("/api/admin/ui-activity", {
    method: "POST",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ text, level: isError ? "err" : "ok" }),
  }).catch(() => {});
}

function userHint(message, fallback) {
  return (
    globalThis.WespUserMessages?.userFacingMessage?.(message, fallback) ||
    fallback ||
    "Не удалось выполнить операцию."
  );
}

function setStatus(message, isError = false, fallback) {
  const wrap = document.getElementById("adminActivityLog");
  if (!wrap) return;
  let text = message != null ? String(message).trim() : "";
  if (isError) {
    text =
      globalThis.WespUserMessages?.userFacingMessage?.(
        text,
        fallback || "Не удалось выполнить операцию."
      ) || fallback || "Не удалось выполнить операцию.";
  } else if (!text && fallback) {
    text = String(fallback).trim();
  }
  let entries = pruneAdminActivityLog(loadAdminActivityLog());
  if (text) {
    entries.push({ ts: Date.now(), level: isError ? "err" : "ok", text });
    while (entries.length > ADMIN_ACTIVITY_MAX_ENTRIES) entries.shift();
    saveAdminActivityLog(entries);
    persistAdminUiActivityToServer(text, Boolean(isError));
  }
  renderAdminActivityTerminalFromMerged();
}

/** Строка списка настроек (разметка Ant Design): слева заголовок и описание, справа значение или действия. */
function wespAdmListItem(title, extraHtml, descriptionText = null, opts = {}) {
  const itemClass = opts.itemClass || "";
  const dataAttrs = opts.dataAttrs || "";
  const desc =
    descriptionText != null && String(descriptionText).trim()
      ? `<div class="wesp-adm-list-desc">${escHtml(descriptionText)}</div>`
      : "";
  return `<li class="wesp-adm-list-item ${itemClass}" role="listitem" ${dataAttrs}>
    <div class="wesp-adm-list-main">
      <div class="wesp-adm-list-title">${escHtml(title)}</div>${desc}
    </div>
    <div class="wesp-adm-list-extra">${extraHtml}</div>
  </li>`;
}

function wespAdmListSection(title) {
  return `<li class="wesp-adm-list-item wesp-adm-list-section" role="separator"><div class="wesp-adm-list-section-text">${escHtml(title)}</div></li>`;
}

function wespAdmListEmpty(message) {
  return `<li class="wesp-adm-list-item wesp-adm-list-empty" role="status"><div class="wesp-adm-list-empty-text">${escHtml(message)}</div></li>`;
}

function wespAdmListLoading(message = "Загрузка…") {
  return `<li class="wesp-adm-list-item wesp-adm-list-empty wesp-adm-list-loading" role="status" aria-busy="true"><div class="wesp-adm-list-empty-text"><span class="wesp-inline-spinner" aria-hidden="true"></span><span>${escHtml(message)}</span></div></li>`;
}

/** Сводка security_checks: лаконично, без разговорных оборотов. */
function buildDiagSecurityOverviewRows(report) {
  const checks = Array.isArray(report.security_checks) ? report.security_checks : [];
  if (!checks.length) {
    return [
      wespAdmListItem(
        "rollup",
        diagOkBadge(false),
        "security_checks: []. retry GET; app log.",
      ),
    ];
  }
  const failed = checks.filter((c) => c && c.ok === false);
  if (!failed.length) {
    return [
      wespAdmListItem(
        "rollup",
        diagOkBadge(true),
        `heuristic: n=${checks.length}; fail=0. detail: Security tab.`,
      ),
    ];
  }
  const lines = failed.slice(0, 5).map((c) => {
    const t = (c.title || c.id || "check").trim();
    const d = (c.detail || "").trim();
    return d ? `${t}: ${d}` : t;
  });
  const tail = failed.length > 5 ? `; +${failed.length - 5} more` : "";
  const body = `fail=${failed.length}/${checks.length}. ${lines.join("; ")}${tail}. registry: Security tab.`;
  return [wespAdmListItem("rollup", diagOkBadge(false), body)];
}

/** Сводка периферии: классификация состояния, императивы минимальны. */
function buildDiagHardwareOverviewRows(hwData) {
  const sim = Boolean(hwData.simulation_mode);
  const gpioOk = Boolean(hwData.rpi_gpio_available);
  const hxOk = Boolean(hwData.hx711_driver_ready);
  const driversAbsent = Boolean(hwData.drivers_absent);

  if (sim) {
    return [
      wespAdmListItem(
        "rollup",
        diagOkBadge(true),
        "SIMULATION_MODE=1: SW model weight+GPIO; ADC/BCM idle. prod BCM: SIMULATION_MODE=0.",
      ),
    ];
  }
  if (driversAbsent) {
    return [
      wespAdmListItem(
        "rollup",
        diagOkBadge(false),
        "SIMULATION_MODE=0; RPi.GPIO+HX711 ∅ (typ. x86). mass=0. dev: WESP_SIMULATION_MODE=1. RPi: deps, VDD, BCM cfg.",
      ),
    ];
  }
  if (gpioOk && hxOk) {
    return [
      wespAdmListItem(
        "rollup",
        diagOkBadge(true),
        "RPi.GPIO+HX711: init OK. drift: wiring, VDD_ADC, gain; probe: Hardware tab.",
      ),
    ];
  }
  if (gpioOk && !hxOk) {
    return [
      wespAdmListItem(
        "rollup",
        diagOkBadge(false),
        "HX711: init fail. check: DOUT/PD_SCK BCM, module VDD, hx711 pkg; WESP_HX711_DOUT_PIN, WESP_HX711_PD_SCK_PIN.",
      ),
    ];
  }
  if (!gpioOk && hxOk) {
    return [
      wespAdmListItem(
        "rollup",
        diagOkBadge(false),
        "RPi.GPIO: missing or non-BCM. x86: SIMULATION_MODE=1; RPi: install RPi.GPIO.",
      ),
    ];
  }
  return [
    wespAdmListItem(
      "rollup",
      diagOkBadge(false),
      "peripheral: class unknown. src: Hardware tab, app log.",
    ),
  ];
}

function formatRuDateFromIso(iso) {
  if (!iso) return "—";
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return String(iso);
  return d.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" });
}

function formatRuDateTimeFromIso(iso) {
  if (!iso) return "—";
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return String(iso);
  return d.toLocaleString("ru-RU", {
    day: "numeric",
    month: "long",
    year: "numeric",
    hour: "2-digit",
    minute: "2-digit",
  });
}

function ruConfigModeLabel(mode) {
  const m = String(mode || "").toLowerCase();
  if (m === "production" || m === "prod") return "production";
  if (m === "testing" || m === "test") return "testing";
  if (m === "development" || m === "dev") return "development";
  return mode ? String(mode) : "—";
}

function ruSyncRoleLabel(role) {
  const r = String(role || "").toLowerCase();
  if (r === "server") return "Сервер";
  if (r === "client") return "Клиент";
  if (r === "other") return "Другое";
  return role ? String(role) : "—";
}

function ruSyncClientStatus(s) {
  const x = String(s || "").toLowerCase();
  if (x === "active") return "Активен";
  if (x === "disabled") return "Отключён";
  if (x === "inactive") return "Неактивен";
  return s ? String(s) : "—";
}

function formatWarrantyUntilCell(wu) {
  if (!wu) return "—";
  const p = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(wu));
  if (p) {
    const dt = new Date(Number(p[1]), Number(p[2]) - 1, Number(p[3]));
    return dt.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" });
  }
  return formatRuDateFromIso(wu);
}

function formatWarrantyDaysCell(dr) {
  if (dr == null || Number.isNaN(Number(dr))) return "—";
  if (Number(dr) < 0) return "истекла";
  return String(dr);
}

function formatBytes(n) {
  if (n == null || Number.isNaN(n)) return "—";
  const x = Number(n);
  if (x <= 0) return "0 B";
  const units = ["B", "KB", "MB", "GB", "TB"];
  let v = x;
  let i = 0;
  while (v >= 1024 && i < units.length - 1) {
    v /= 1024;
    i += 1;
  }
  const digits = i === 0 ? 0 : i === 1 ? 1 : 2;
  return `${v.toFixed(digits)} ${units[i]}`;
}

function ruCpuCoresLabel(n) {
  const x = Math.trunc(Number(n) || 0);
  if (x <= 0) return "—";
  const k100 = x % 100;
  const k10 = x % 10;
  if (k100 >= 11 && k100 <= 14) return `${x} ядер`;
  if (k10 === 1) return `${x} ядро`;
  if (k10 >= 2 && k10 <= 4) return `${x} ядра`;
  return `${x} ядер`;
}

function machineOverviewRows(m) {
  if (!m || typeof m !== "object") return [];
  const cores = m.cpu_logical_cores;
  const coresPart =
    cores != null && Number(cores) > 0 ? ` · ${ruCpuCoresLabel(Number(cores))}` : "";
  const cpuModel = (m.cpu_model && String(m.cpu_model).trim()) || "—";
  const cpuLine =
    cpuModel === "—" && !(cores != null && Number(cores) > 0) ? "—" : `${cpuModel}${coresPart}`;

  let ram = "—";
  if (m.ram_total_bytes != null) {
    ram = `${formatBytes(m.ram_total_bytes)} (всего)`;
  }

  let disk = (m.root_disk_media && String(m.root_disk_media)) || "—";
  if (m.root_disk_total_bytes != null) {
    const u = formatBytes(m.root_disk_used_bytes);
    const t = formatBytes(m.root_disk_total_bytes);
    const p = m.root_disk_percent != null ? ` · ${m.root_disk_percent}%` : "";
    disk = `${disk} · ${u} / ${t}${p}`;
  }

  return [
    ["ОС", m.os || "—"],
    ["CPU", cpuLine],
    ["GPU", m.gpu || "—"],
    ["RAM", ram],
    ["Root FS", disk],
  ];
}

const PERIPHERAL_EVENT_LABELS = {
  hx711_read_error: "Ошибка чтения HX711",
  hx711_recovered: "HX711 восстановлен",
  hx711_not_initialized: "HX711 не инициализирован",
  gpio_unavailable: "GPIO недоступен",
  host_cpu_high: "Высокая загрузка CPU",
  host_ram_high: "Высокая загрузка RAM",
  host_disk_high: "Диск почти заполнен",
  network_settings_saved: "Настройки сети",
  mdns_started: "mDNS запущен",
  mdns_stopped: "mDNS остановлен",
  mdns_error: "Ошибка mDNS",
};

let hardwarePollTimer = null;

function stopHardwarePolling() {
  if (hardwarePollTimer) {
    window.clearInterval(hardwarePollTimer);
    hardwarePollTimer = null;
  }
}

function startHardwarePolling() {
  stopHardwarePolling();
  const tick = () => {
    loadHardwareStatus().catch(() => {});
    loadPeripheralEvents().catch(() => {});
  };
  tick();
  hardwarePollTimer = window.setInterval(tick, 3000);
}

function peripheralEventTitle(code) {
  const c = String(code || "");
  return PERIPHERAL_EVENT_LABELS[c] || c || "событие";
}

function renderHardwareHostList(machine) {
  const el = document.getElementById("hardwareHostList");
  if (!el) return;
  const rows = machineOverviewRows(machine);
  if (!rows.length) {
    el.innerHTML = wespAdmListEmpty("Нет данных о хосте");
    return;
  }
  el.innerHTML =
    wespAdmListSection("Хост") +
    rows
      .map(([k, v]) =>
        wespAdmListItem(k, `<span class="wesp-adm-list-value">${escHtml(v)}</span>`),
      )
      .join("");
}

function renderHardwareHostAlerts(alerts) {
  const el = document.getElementById("hardwareHostAlertsList");
  if (!el) return;
  const list = Array.isArray(alerts) ? alerts : [];
  if (!list.length) {
    el.innerHTML = "";
    return;
  }
  el.innerHTML =
    wespAdmListSection("Алерты хоста") +
    list
      .map((a) =>
        wespAdmListItem(
          a.label || a.code || "—",
          diagOkBadge(Boolean(a.ok)),
          `${a.percent != null ? `${a.percent}%` : "—"} · порог ${a.threshold != null ? `${a.threshold}%` : "—"}`,
        ),
      )
      .join("");
}

function renderHardwarePeriphList(periph) {
  const el = document.getElementById("hardwarePeriphList");
  if (!el) return;
  const p = periph && typeof periph === "object" ? periph : {};
  const sim = Boolean(p.simulation_mode);
  const scaleHw = Boolean(p.scale_hardware_platform);
  const gpioOk = Boolean(p.rpi_gpio_available);
  const hxOk = Boolean(p.hx711_driver_ready);
  const gpioBadge = sim || !scaleHw ? '<span class="wesp-sync-hint" style="font-size:11px">sim</span>' : diagOkBadge(gpioOk);
  const hxBadge = sim ? '<span class="wesp-sync-hint" style="font-size:11px">sim</span>' : scaleHw ? diagOkBadge(hxOk) : '<span class="wesp-sync-hint" style="font-size:11px">—</span>';
  el.innerHTML = [
    wespAdmListSection("Периферия"),
    wespAdmListItem(
      "Режим",
      sim
        ? '<span class="wesp-diag-ok">SIM</span>'
        : scaleHw
          ? diagOkBadge(true)
          : '<span class="wesp-sync-hint">DEV</span>',
      sim
        ? "симуляция — HX711 не опрашивается"
        : scaleHw
          ? "целевое железо (Linux ARM + HX711)"
          : "не ARM — HX711 не опрашивается; включите симуляцию",
    ),
    wespAdmListItem(
      "GPIO / LED",
      `${gpioBadge}<span class="wesp-adm-list-value" style="margin-left:8px">пин ${escHtml(String(p.gpio_led_pin ?? "—"))}</span>`,
      gpioOk ? "RPi.GPIO доступен" : scaleHw ? "RPi.GPIO недоступен" : "не используется",
    ),
    wespAdmListItem(
      "HX711",
      `${hxBadge}<span class="wesp-adm-list-value" style="margin-left:8px">DOUT ${escHtml(String(p.hx711_dout_pin ?? "—"))} · SCK ${escHtml(String(p.hx711_pd_sck_pin ?? "—"))}</span>`,
      sim ? "не используется" : scaleHw ? (hxOk ? "драйвер инициализирован" : "драйвер не поднят") : "пропущен (не ARM)",
    ),
    p.last_hx711_error && !sim
      ? wespAdmListItem(
          "Последняя ошибка",
          '<span class="wesp-diag-bad">!</span>',
          escHtml(String(p.last_hx711_error)),
        )
      : "",
  ].join("");
}

function renderHardwareScalesList(scales) {
  const el = document.getElementById("hardwareScalesList");
  if (!el) return;
  const s = scales && typeof scales === "object" ? scales : {};
  if (!s.available) {
    el.innerHTML =
      wespAdmListSection("Весы (live)") +
      wespAdmListItem(
        "ScalesReader",
        diagOkBadge(false),
        escHtml(String(s.error || "недоступен")),
      );
    return;
  }
  const sim = Boolean(s.simulation_mode);
  const skipped = Boolean(s.hx711_skipped);
  const hxOk = Boolean(s.hx711_ok);
  el.innerHTML = [
    wespAdmListSection("Весы (live)"),
    wespAdmListItem(
      "Вес",
      `<span class="wesp-adm-list-value">${escHtml(String(s.weight_kg ?? "—"))} кг</span>`,
      sim ? "симуляция" : skipped ? "HX711 не опрашивается" : "с датчика",
    ),
    wespAdmListItem(
      "HX711",
      sim ? '<span class="wesp-sync-hint">sim</span>' : skipped ? '<span class="wesp-sync-hint">—</span>' : diagOkBadge(hxOk),
      sim ? "не используется" : skipped ? "включите симуляцию на этой платформе" : escHtml(String(s.hx711_error || "OK")),
    ),
    wespAdmListItem(
      "Калибровка",
      `<span class="wesp-adm-list-value">counts/kg ${escHtml(String(s.counts_per_kg ?? "—"))} · tare ${escHtml(String(s.tare_raw ?? "—"))}</span>`,
    ),
    wespAdmListItem(
      "Сырьё",
      `<span class="wesp-adm-list-value">n=${escHtml(String(s.raw_history_len ?? 0))}</span>`,
      [
        s.last_raw != null ? `последнее ${s.last_raw}` : null,
        s.raw_min_recent != null && s.raw_max_recent != null
          ? `мин ${s.raw_min_recent} · макс ${s.raw_max_recent}`
          : null,
        s.error_streak ? `серия ошибок ${s.error_streak}` : null,
      ]
        .filter(Boolean)
        .join(" · ") || "—",
    ),
  ].join("");
}

const HW_SIM_SLIDER_MAX = 500;
let hwSimUiLock = 0;
let hwSimSliderTimer = null;

function withHwSimUiLock(fn) {
  hwSimUiLock += 1;
  try {
    return fn();
  } finally {
    hwSimUiLock = Math.max(0, hwSimUiLock - 1);
  }
}

function syncHardwareSimulationControls(scales, periph) {
  if (hwSimUiLock > 0) return;
  const s = scales && typeof scales === "object" ? scales : {};
  const p = periph && typeof periph === "object" ? periph : {};
  const sim = Boolean(s.simulation_mode ?? p.simulation_mode);
  const weight = Math.max(0, Math.round(Number(s.simulation_weight_kg ?? 0) || 0));
  const liveWeight = Math.max(0, Math.round(Number(s.weight_kg ?? weight) || 0));

  const toggle = document.getElementById("hwSimulationToggle");
  const panel = document.getElementById("hwSimulationPanel");
  const status = document.getElementById("hwSimulationStatusLabel");
  const input = document.getElementById("hwSimWeightInput");
  const slider = document.getElementById("hwSimWeightSlider");
  const live = document.getElementById("hwSimWeightLive");

  if (toggle) toggle.checked = sim;
  if (panel) panel.hidden = !sim;
  if (status) {
    status.textContent = sim
      ? `Включена · задано ${weight} кг (live ${liveWeight} кг)`
      : "Выключена · чтение HX711";
  }
  if (input && document.activeElement !== input) input.value = String(weight);
  if (slider && document.activeElement !== slider) {
    slider.value = String(Math.min(HW_SIM_SLIDER_MAX, weight));
  }
  if (live) live.textContent = String(liveWeight);
}

async function patchHardwareSimulation(enabled) {
  const r = await fetch("/api/admin/hardware/simulation", {
    method: "PATCH",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ enabled: Boolean(enabled) }),
  });
  const d = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(d.message || String(r.status));
  const sim = Boolean(d.simulation_mode ?? (d.hardware || {}).simulation_mode);
  if (sim !== Boolean(enabled)) {
    throw new Error("Сервер не подтвердил переключение симуляции");
  }
  return d;
}

async function setHardwareSimulationWeight(weightKg) {
  const w = Math.max(0, Math.min(9999, Math.round(Number(weightKg) || 0)));
  const r = await fetch("/api/admin/hardware/simulation/weight", {
    method: "PUT",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ weight_kg: w }),
  });
  const d = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(d.message || String(r.status));
  return d;
}

async function adjustHardwareSimulationWeight(deltaKg) {
  const r = await fetch("/api/admin/hardware/simulation/weight", {
    method: "PUT",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ delta_kg: deltaKg }),
  });
  const d = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(d.message || String(r.status));
  return d;
}

async function applyHardwareSimulationWeightFromUi() {
  const input = document.getElementById("hwSimWeightInput");
  const slider = document.getElementById("hwSimWeightSlider");
  const raw = input && input.value !== "" ? input.value : slider ? slider.value : "0";
  await setHardwareSimulationWeight(raw);
  await refreshHardwareSection();
}

function scheduleHardwareSimulationWeightFromSlider() {
  if (hwSimSliderTimer) window.clearTimeout(hwSimSliderTimer);
  hwSimSliderTimer = window.setTimeout(async () => {
    hwSimSliderTimer = null;
    const slider = document.getElementById("hwSimWeightSlider");
    const input = document.getElementById("hwSimWeightInput");
    if (!slider) return;
    withHwSimUiLock(() => {
      if (input) input.value = slider.value;
    });
    try {
      await setHardwareSimulationWeight(slider.value);
      await loadHardwareStatus().catch(() => {});
    } catch (error) {
      setStatus(error.message, true, "Ошибка изменения веса");
    }
  }, 180);
}

function renderHardwareStatus(data) {
  const hint = document.getElementById("hardwareStatusHint");
  if (hint) {
    hint.textContent = data.generated_at
      ? `обновлено ${formatRuDateTimeFromIso(data.generated_at)}`
      : "—";
  }
  renderHardwareHostList(data.machine);
  renderHardwareHostAlerts(data.host_alerts);
  renderHardwarePeriphList(data.peripherals);
  renderHardwareScalesList(data.scales);
  syncHardwareSimulationControls(data.scales, data.peripherals);
}

async function loadHardwareStatus() {
  const r = await fetch("/api/admin/hardware-status", { credentials: "same-origin" });
  const d = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
  renderHardwareStatus(d);
}

function renderPeripheralEvents(events) {
  const el = document.getElementById("hardwareEventsList");
  if (!el) return;
  el.innerHTML = renderPeripheralEventsHtml(events);
}

function renderPeripheralEventsHtml(events) {
  const list = Array.isArray(events) ? events : [];
  if (!list.length) {
    return wespAdmListEmpty("Событий пока нет");
  }
  const rows = [...list].reverse().slice(0, 80);
  return rows
    .map((ev) => {
      const ts = ev.u ? formatRuDateTimeFromIso(new Date(Number(ev.u) * 1000).toISOString()) : "—";
      const code = peripheralEventTitle(ev.code);
      const lvl =
        ev.level === "err"
          ? "err"
          : ev.level === "ok"
            ? "ok"
            : ev.level === "info"
              ? "info"
              : "warn";
      const badge =
        lvl === "err"
          ? '<span class="wesp-diag-bad">ERR</span>'
          : lvl === "ok"
            ? diagOkBadge(true)
            : lvl === "info"
              ? '<span class="wesp-sync-hint">INFO</span>'
              : '<span class="wesp-sync-hint">WARN</span>';
      return wespAdmListItem(
        `${escHtml(ev.component || "—")} · ${escHtml(code)}`,
        badge,
        `${ts} — ${escHtml(String(ev.message || ""))}`,
      );
    })
    .join("");
}

function renderNetworkEvents(events) {
  const el = document.getElementById("networkEventsList");
  if (!el) return;
  el.innerHTML = renderPeripheralEventsHtml(events);
}

async function loadNetworkEvents() {
  const el = document.getElementById("networkEventsList");
  const showListLoading = el && !document.getElementById("wespSectionNetwork")?.classList.contains("wesp-network-section--loading");
  if (showListLoading && el) el.innerHTML = wespAdmListLoading("Загрузка журнала…");
  beginNetworkSectionLoad();
  try {
    const r = await fetch("/api/admin/peripheral-events?limit=50&component=network", {
      credentials: "same-origin",
    });
    const d = await r.json().catch(() => ({}));
    if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
    renderNetworkEvents(d.events);
  } finally {
    endNetworkSectionLoad();
  }
}

async function loadPeripheralEvents() {
  const r = await fetch("/api/admin/peripheral-events?limit=80&exclude_component=network", {
    credentials: "same-origin",
  });
  const d = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
  renderPeripheralEvents(d.events);
}

async function clearNetworkEventsLog() {
  if (!window.confirm("Очистить журнал сети?")) return;
  const r = await fetch("/api/admin/peripheral-events?component=network", {
    method: "DELETE",
    credentials: "same-origin",
  });
  const d = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(d.message || String(r.status));
  renderNetworkEvents([]);
  return d;
}

async function clearPeripheralEventsLog() {
  if (!window.confirm("Очистить журнал событий оборудования? (журнал сети не затрагивается)")) return;
  const r = await fetch("/api/admin/peripheral-events?exclude_component=network", {
    method: "DELETE",
    credentials: "same-origin",
  });
  const d = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(d.message || String(r.status));
  renderPeripheralEvents([]);
  return d;
}

function renderAutoUpdateFormFromSummary(au) {
  const a = au && typeof au === "object" ? au : {};
  const el = (id) => document.getElementById(id);
  const en = el("auEnabled");
  if (en) en.checked = Boolean(a.enabled);
  const iv = el("auCheckInterval");
  if (iv) {
    const n = Number(a.check_interval_sec);
    iv.value = Number.isFinite(n) ? String(n) : "3600";
  }
  const setVal = (id, v) => {
    const x = el(id);
    if (x) x.value = v != null ? String(v) : "";
  };
  setVal("auGiteaUrl", a.gitea_url);
  setVal("auGiteaOwner", a.gitea_owner);
  setVal("auGiteaRepo", a.gitea_repo);
  setVal("auRepositoryUrl", a.repository_url);
  const tok = el("auGiteaToken");
  if (tok) tok.value = "";
  const gu = el("auGiteaApiUser");
  if (gu) gu.value = "";
  const gp = el("auGiteaApiPass");
  if (gp) gp.value = "";
  const clr = el("auClearSecrets");
  if (clr) clr.checked = false;
  const runtimeHint = el("auRuntimeHint");
  if (runtimeHint) {
    const rtParts = [];
    if (a.runtime_current_version) rtParts.push(`Версия: ${a.runtime_current_version}`);
    if (a.runtime_last_check_at) rtParts.push(`Последняя проверка: ${a.runtime_last_check_at}`);
    if (a.runtime_update_available && a.runtime_pending_version) {
      rtParts.push(`Доступно: ${a.runtime_pending_version}`);
    } else if (a.enabled) {
      rtParts.push("Новых версий нет");
    }
    if (a.restart_configured) rtParts.push("Перезапуск после установки настроен");
    runtimeHint.textContent = rtParts.length ? rtParts.join(" · ") : "";
  }
}

async function saveAutoUpdateFromForm() {
  const el = (id) => document.getElementById(id);
  const body = {
    enabled: el("auEnabled") ? el("auEnabled").checked : false,
    gitea_url: (el("auGiteaUrl") && el("auGiteaUrl").value.trim()) || "",
    gitea_owner: (el("auGiteaOwner") && el("auGiteaOwner").value.trim()) || "",
    gitea_repo: (el("auGiteaRepo") && el("auGiteaRepo").value.trim()) || "",
    repository_url: (el("auRepositoryUrl") && el("auRepositoryUrl").value.trim()) || "",
  };
  const iv = el("auCheckInterval");
  if (iv) {
    const n = parseInt(iv.value, 10);
    if (Number.isFinite(n)) body.check_interval_sec = n;
  }
  const r = await fetch("/api/admin/auto-update-settings", {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const d = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
  setStatus(d.message || "auto_update: OK");
  if (d.auto_update) renderAutoUpdateFormFromSummary(d.auto_update);
  else await updateSummary();
}

async function saveGiteaSecretsFromForm() {
  const el = (id) => document.getElementById(id);
  const clearSecrets = el("auClearSecrets") ? el("auClearSecrets").checked : false;
  const body = {
    clear_secrets: clearSecrets,
    gitea_token: (el("auGiteaToken") && el("auGiteaToken").value.trim()) || "",
    gitea_username: (el("auGiteaApiUser") && el("auGiteaApiUser").value.trim()) || "",
    gitea_password: (el("auGiteaApiPass") && el("auGiteaApiPass").value.trim()) || "",
  };
  const r = await fetch("/api/admin/gitea-secrets", {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const d = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
  setStatus(d.message || "gitea_secrets: OK");
  if (d.auto_update) renderAutoUpdateFormFromSummary(d.auto_update);
  else await updateSummary();
}

let _factoryResetConfirmPhrase = "СБРОС ДАННЫХ";

function renderFactoryResetPreview(preview) {
  const list = document.getElementById("factoryResetPreviewList");
  const note = document.getElementById("factoryResetPreservedNote");
  if (!list) return;
  if (!preview || typeof preview !== "object") {
    list.innerHTML = wespAdmListItem("—", `<span class="wesp-adm-list-value">Нет данных</span>`);
    return;
  }
  _factoryResetConfirmPhrase = String(preview.confirm_phrase || "СБРОС ДАННЫХ");
  const phraseInput = document.getElementById("factoryResetConfirmPhrase");
  if (phraseInput && !phraseInput.value) {
    phraseInput.placeholder = _factoryResetConfirmPhrase;
  }
  if (note) {
    const preserved = preview.preserved_note;
    if (preserved) {
      note.hidden = false;
      note.textContent = preserved;
    } else {
      note.hidden = true;
    }
  }
  const rows = [];
  for (const f of preview.files || []) {
    const sz =
      f.size_bytes != null && !Number.isNaN(Number(f.size_bytes))
        ? formatBytes(Number(f.size_bytes))
        : "—";
    rows.push(wespAdmListItem(`файл: ${f.path}`, `<span class="wesp-adm-list-value">${escHtml(sz)}</span>`));
  }
  for (const d of preview.directories || []) {
    const n = d.files_inside;
    const hint = n != null && n >= 0 ? `${n} файл(ов)` : "каталог";
    rows.push(
      wespAdmListItem(`каталог: ${d.path}`, `<span class="wesp-adm-list-value">${escHtml(hint)}</span>`),
    );
  }
  if (!rows.length) {
    rows.push(
      wespAdmListItem(
        "data/",
        `<span class="wesp-adm-list-value">пусто или уже сброшено</span>`,
      ),
    );
  }
  list.innerHTML = rows.join("");
}

async function loadFactoryResetPreview() {
  const list = document.getElementById("factoryResetPreviewList");
  if (list) {
    list.innerHTML = wespAdmListItem("—", `<span class="wesp-adm-list-value">Загрузка…</span>`);
  }
  const r = await fetch("/api/admin/factory-reset/preview");
  const d = await r.json().catch(() => ({}));
  if (!r.ok) {
    throw new Error(d.message || `Ошибка ${r.status}`);
  }
  renderFactoryResetPreview(d);
  return d;
}

async function runFactoryReset() {
  const phraseEl = document.getElementById("factoryResetConfirmPhrase");
  const phrase = (phraseEl?.value || "").trim();
  if (phrase !== _factoryResetConfirmPhrase) {
    throw new Error(`Введите фразу точно: «${_factoryResetConfirmPhrase}»`);
  }
  const keepLogs = Boolean(document.getElementById("factoryResetKeepLogs")?.checked);
  const safetyBackup = document.getElementById("factoryResetSafetyBackup")?.checked !== false;
  const ok = window.confirm(
    "Будут удалены базы SQLite, JSON настроек и каталоги в data/.\n\n" +
      (safetyBackup
        ? "Перед удалением будет создан ZIP с копией БД в data/backups/.\n\n"
        : "Резервная копия БД перед сбросом отключена.\n\n") +
      (keepLogs ? "Каталог data/logs будет сохранён.\n\n" : "Каталог data/logs будет удалён.\n\n") +
      "После сброса перезапустите WESP и откройте /setup.\n\n" +
      "Продолжить?",
  );
  if (!ok) return null;
  const r = await fetch("/api/admin/factory-reset", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      confirmation_phrase: phrase,
      remove_logs: !keepLogs,
      safety_sqlite_backup: safetyBackup,
    }),
  });
  const d = await r.json().catch(() => ({}));
  if (!r.ok) {
    throw new Error(d.message || `Ошибка ${r.status}`);
  }
  if (phraseEl) phraseEl.value = "";
  await loadFactoryResetPreview().catch(() => {});
  return d;
}

function renderSystemSectionTables(data) {
  const app = data.app || {};
  const sysRows = [
    ["CONFIG_MODE", ruConfigModeLabel(app.config_mode)],
    ["DEBUG", app.debug ? "true" : "false"],
    ["version", app.version ? `v${String(app.version).replace(/^v/i, "")}` : "—"],
    ["sync.role", ruSyncRoleLabel(app.role)],
    ["first_launch_at", formatRuDateFromIso(app.first_launch_at)],
    ["warranty_until", formatWarrantyUntilCell(app.warranty_until)],
    ["warranty_days", formatWarrantyDaysCell(app.warranty_days_remaining)],
  ];
  const machineRows = machineOverviewRows(data.machine);
  const sysList = document.getElementById("systemDataList");
  if (sysList) {
    const appHtml = sysRows
      .map(([k, v]) => wespAdmListItem(k, `<span class="wesp-adm-list-value">${escHtml(v)}</span>`))
      .join("");
    const sectionHtml =
      machineRows.length > 0
        ? `${wespAdmListSection("Host / hardware")}${machineRows
            .map(([k, v]) => wespAdmListItem(k, `<span class="wesp-adm-list-value">${escHtml(v)}</span>`))
            .join("")}`
        : "";
    sysList.innerHTML = appHtml + sectionHtml;
  }

  const dbs = data.databases || {};
  const dbList = document.getElementById("dbDataList");
  if (dbList) {
    const bindTitle = (key) => {
      if (key === "recipes") return "bind: recipes";
      if (key === "reports") return "bind: reports";
      return key;
    };
    const engineRu = (e) => {
      if (e == null || e === "") return "—";
      if (String(e).toLowerCase() === "sqlite") return "SQLite";
      return String(e);
    };
    dbList.innerHTML = Object.entries(dbs)
      .map(([key, desc]) => {
        const d = desc && typeof desc === "object" ? desc : {};
        const file = d.database != null && d.database !== "" ? String(d.database) : "—";
        const sz =
          d.size_bytes != null && !Number.isNaN(Number(d.size_bytes))
            ? formatBytes(Number(d.size_bytes))
            : "—";
        const eng = engineRu(d.engine);
        const descLine = `${eng} · ${file}`;
        return wespAdmListItem(bindTitle(key), `<span class="wesp-adm-list-value">${escHtml(sz)}</span>`, descLine);
      })
      .join("");
  }
}

function formatSpeed(bps) {
  if (bps == null || Number.isNaN(bps)) return "—";
  const x = Number(bps);
  if (x < 1024) return `${x.toFixed(0)} B/s`;
  if (x < 1024 * 1024) return `${(x / 1024).toFixed(2)} KB/s`;
  return `${(x / (1024 * 1024)).toFixed(2)} MB/s`;
}

function setRingPercent(gaugeRoot, percent) {
  const pctEl = gaugeRoot.querySelector("[data-gauge-pct]");
  if (!pctEl) return;
  const p = Math.min(100, Math.max(0, Number(percent) || 0));
  pctEl.textContent = p.toFixed(2);
  gaugeRoot.style.setProperty("--gauge-circ", String(GAUGE_CIRC));
  gaugeRoot.style.setProperty("--gauge-pct", String(p));
}

function decimateHardwarePoints(points, maxN) {
  if (!points.length || points.length <= maxN) return points;
  const step = Math.ceil(points.length / maxN);
  const out = [];
  for (let i = 0; i < points.length; i += step) out.push(points[i]);
  if (out[out.length - 1] !== points[points.length - 1]) out.push(points[points.length - 1]);
  return out;
}

function hardwarePathForKey(pts, key, tMin, tMax, padL, padT, plotW, plotH) {
  const span = Math.max(1, tMax - tMin);
  let d = "";
  let penUp = true;
  for (const p of pts) {
    let v = p[key];
    if (v == null || Number.isNaN(Number(v))) {
      penUp = true;
      continue;
    }
    v = Math.min(100, Math.max(0, Number(v)));
    const x = padL + ((p.t - tMin) / span) * plotW;
    const y = padT + plotH - (v / 100) * plotH;
    d += penUp ? `M${x.toFixed(1)},${y.toFixed(1)}` : `L${x.toFixed(1)},${y.toFixed(1)}`;
    penUp = false;
  }
  return d;
}

function renderHardwareHistorySvg(svgEl, points) {
  const pts = decimateHardwarePoints(points, 900);
  const tMin = pts[0].t;
  const tMax = pts[pts.length - 1].t;
  const W = 640;
  const H = 220;
  const padL = 42;
  const padR = 10;
  const padT = 12;
  const padB = 26;
  const plotW = W - padL - padR;
  const plotH = H - padT - padB;

  const fmtTime = (unix) => {
    const d = new Date(unix * 1000);
    return d.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
  };

  const gridYs = [0, 0.5, 1];
  let grid = "";
  for (const gy of gridYs) {
    const y = padT + plotH * (1 - gy);
    grid += `<line x1="${padL}" y1="${y.toFixed(1)}" x2="${padL + plotW}" y2="${y.toFixed(1)}" stroke="currentColor" stroke-opacity="0.08" stroke-width="1"/>`;
  }

  const series = [
    { key: "cpu", color: "#008771" },
    { key: "ram", color: "#1890ff" },
    { key: "swap", color: "#faad14" },
    { key: "disk", color: "#722ed1" },
  ];

  const paths = series
    .map((s) => {
      const d = hardwarePathForKey(pts, s.key, tMin, tMax, padL, padT, plotW, plotH);
      if (!d) return "";
      return `<path fill="none" stroke="${s.color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" d="${d}"/>`;
    })
    .join("");

  const yLbl = (v) =>
    `<text x="${padL - 6}" y="${(padT + plotH * (1 - v / 100) + 4).toFixed(1)}" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.45">${v}</text>`;

  svgEl.innerHTML = `
    <g opacity="0.9">${grid}</g>
    ${yLbl(0)}${yLbl(50)}${yLbl(100)}
    <text x="${padL}" y="${H - 6}" font-size="10" fill="currentColor" fill-opacity="0.45">${fmtTime(tMin)}</text>
    <text x="${padL + plotW}" y="${H - 6}" font-size="10" fill="currentColor" fill-opacity="0.45" text-anchor="end">${fmtTime(tMax)}</text>
    <text x="${padL - 38}" y="${padT + plotH / 2}" font-size="10" fill="currentColor" fill-opacity="0.45" text-anchor="middle" transform="rotate(-90 ${padL - 38},${padT + plotH / 2})">%</text>
    ${paths}
  `;
}

async function loadHardwareHistoryCharts() {
  const hint = document.getElementById("wespHardwareHistoryHint");
  const wrap = document.getElementById("wespHardwareHistoryWrap");
  const svg = document.getElementById("wespHardwareHistorySvg");
  const leg = document.getElementById("wespHardwareHistoryLegend");
  if (!hint || !wrap || !svg || !leg) return;
  try {
    const r = await fetch("/api/admin/hardware-metrics-history");
    const d = await r.json();
    if (!r.ok) throw new Error(d.message || String(r.status));
    const pts = Array.isArray(d.points) ? d.points : [];
    const days = d.retention_days ?? 3;
    if (pts.length === 0) {
      hint.textContent = `timeseries: ∅. Сэмплы при опросе summary; retention ${days} d.`;
      wrap.hidden = true;
      leg.innerHTML = "";
      return;
    }
    hint.textContent = `utilization %, окно ${days} d.`;
    wrap.hidden = false;
    renderHardwareHistorySvg(svg, pts);
    leg.innerHTML = [
      { key: "cpu", label: "ЦП", c: "#008771" },
      { key: "ram", label: "ОЗУ", c: "#1890ff" },
      { key: "swap", label: "Подкачка", c: "#faad14" },
      { key: "disk", label: "Диск", c: "#722ed1" },
    ]
      .map(
        (x) =>
          `<span><i style="background:${x.c}" aria-hidden="true"></i>${x.label}</span>`,
      )
      .join("");
  } catch (e) {
    hint.textContent = userHint(e.message, "Не удалось загрузить метрики");
    wrap.hidden = true;
    leg.innerHTML = "";
  }
}

function scrollToAdminSection(section) {
  const el = document.querySelector(`[data-section-content="${section}"]`);
  if (!el) return;
  window.requestAnimationFrame(() => {
    el.scrollIntoView({ behavior: "smooth", block: "start" });
  });
}

function goToDashboardStatNav(nav) {
  if (nav === "recipes") {
    window.location.href = "/recipes";
    return;
  }
  if (nav === "components") {
    window.location.href = "/components";
    return;
  }
  if (nav === "users" || nav === "sync") {
    activateSection(nav);
    scrollToAdminSection(nav);
  }
}

/** Какую БД предвыбрать в sql.js (счётчики дашборда относятся к основной). */
function dashboardStatPreferredBind(_nav) {
  return "recipes";
}

function initDashboardStatNav() {
  document.querySelectorAll("[data-stat-nav]").forEach((el) => {
    const nav = el.dataset.statNav;
    if (!nav) return;
    const onActivate = async (event) => {
      if (event && "altKey" in event && event.altKey) {
        event.preventDefault();
        try {
          await openAdminDbBrowser({
            preferredBind: dashboardStatPreferredBind(nav),
            setStatus,
          });
        } catch (e) {
          setStatus(e.message, true, "Ошибка редактора БД");
        }
        return;
      }
      goToDashboardStatNav(nav);
    };
    el.addEventListener("click", onActivate);
    el.addEventListener("keydown", (e) => {
      if (e.key !== "Enter" && e.key !== " ") return;
      e.preventDefault();
      if (e.altKey) {
        onActivate(e);
        return;
      }
      goToDashboardStatNav(nav);
    });
  });
}

function activateSection(section) {
  stopHardwarePolling();
  document.querySelectorAll("[data-section]").forEach((item) => {
    const on = item.dataset.section === section;
    item.classList.toggle("ant-menu-item-selected", on);
  });
  document.querySelectorAll("[data-section-content]").forEach((sectionEl) => {
    sectionEl.classList.toggle(
      "is-active",
      sectionEl.dataset.sectionContent === section,
    );
  });
  const active = document.querySelector(`[data-section-content="${section}"]`);
  if (!active) return;
  active.querySelectorAll(".wesp-admin-block").forEach((el, i) => {
    el.style.setProperty("--wesp-reveal-i", String(i));
    el.classList.remove("wesp-admin-section-enter");
    void el.offsetWidth;
    el.classList.add("wesp-admin-section-enter");
  });
  if (section === "hardware") {
    loadHardwareHistoryCharts().catch(() => {});
    startHardwarePolling();
  }
  if (section === "sync") {
    loadSyncDiagnostics().catch((e) => setStatus(e.message, true, "Ошибка обновления"));
    loadOrchestratorSyncSettings().catch(() => {});
  }
  if (section === "network") {
    Promise.all([
      loadNetworkSettings(),
      loadNetworkEvents(),
    ]).catch((e) => setStatus(e.message, true, "Ошибка загрузки сети"));
  }
  if (section === "diagnostics") {
    setDiagnosticsSecurityListLoading();
    loadDiagnosticsReport().catch((e) => setStatus(e.message, true, "Ошибка диагностики"));
  }
  if (section === "system") {
    loadFactoryResetPreview().catch((e) =>
      setStatus(e.message, true, "Ошибка предпросмотра сброса"),
    );
    refreshKioskFullSetupHint();
    refreshPiPlatformSetupHint();
    refreshPiRainbowSplashHint();
    refreshPlymouthStatusHint().then((running) => {
      if (running) startPlymouthStatusPoll();
    });
    refreshKioskBootStatusHint();
  }
  if (section === "assistant") {
    loadAdminAssistantStatus().catch(() => {});
    loadAdminAssistantLlmLog().catch(() => {});
    if (!window.__wespAssistantChatMessages) hydrateAssistantChatMessagesFromStorage();
    renderAssistantChatLog();
  }
}

/** Кнопка «история» у ЦП на дашборде: раздел Железо + график. */
function openHardwareChartFromDashboard() {
  activateSection("hardware");
  window.requestAnimationFrame(() => {
    document.getElementById("wespHardwareHistoryCard")?.scrollIntoView({
      behavior: "smooth",
      block: "start",
    });
  });
}

async function fetchAuth() {
  const response = await fetch("/api/auth/check");
  const data = await response.json();
  if (!(data.status === "success" && data.authenticated && data.is_superuser)) {
    window.location.href = "/recipes";
    return null;
  }
  return data;
}

function userRowTemplate(user) {
  const role = user.is_superuser ? "Суперпользователь" : "Пользователь";
  const modulesHint = user.is_superuser
    ? "Lab: всегда"
    : user.lab_access
      ? "Lab: вкл."
      : "Lab: выкл.";
  const extra = `<div class="wesp-adm-list-actions wesp-user-actions">
        <button type="button" class="ant-btn ant-btn-sm" data-action="set-password"><span>Пароль</span></button>
        <button type="button" class="ant-btn ant-btn-sm" data-action="toggle-super"><span>${user.is_superuser ? "Снять super" : "Сделать super"}</span></button>
        <button type="button" class="ant-btn ant-btn-sm ant-btn-danger" data-action="delete-user"><span>Удалить</span></button>
      </div>`;
  return wespAdmListItem(user.login, extra, `${role} · ${modulesHint}`, {
    itemClass: "wesp-adm-list-item--user wesp-adm-list-item--clickable",
    dataAttrs:
      `data-user-id="${String(user.id).replace(/"/g, "&quot;")}" ` +
      `data-is-super="${user.is_superuser ? "1" : "0"}" ` +
      `data-lab-access="${user.lab_access ? "1" : "0"}" ` +
      `data-user-login="${String(user.login).replace(/"/g, "&quot;")}"`,
  });
}

let userModulesModalState = null;

function closeUserModulesModal() {
  const modal = document.getElementById("userModulesModal");
  if (modal) modal.hidden = true;
  userModulesModalState = null;
}

function openUserModulesModal(row) {
  userModulesModalState = {
    userId: row.dataset.userId,
    login: row.dataset.userLogin || "",
    isSuper: row.dataset.isSuper === "1",
    labAccess: row.dataset.labAccess === "1",
  };
  const loginEl = document.getElementById("userModulesModalLogin");
  if (loginEl) loginEl.textContent = userModulesModalState.login || "—";
  const toggle = document.getElementById("userModuleLabAccess");
  if (toggle) {
    toggle.checked = userModulesModalState.isSuper || userModulesModalState.labAccess;
    toggle.disabled = userModulesModalState.isSuper;
    wireAntCheckbox(toggle);
  }
  const hint = document.getElementById("userModuleLabSuperHint");
  if (hint) hint.hidden = !userModulesModalState.isSuper;
  const modal = document.getElementById("userModulesModal");
  if (modal) modal.hidden = false;
}

async function saveUserModulesModal() {
  if (!userModulesModalState) return;
  if (userModulesModalState.isSuper) {
    closeUserModulesModal();
    return;
  }
  const toggle = document.getElementById("userModuleLabAccess");
  const labAccess = Boolean(toggle?.checked);
  const response = await fetch(`/api/admin/users/${userModulesModalState.userId}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ lab_access: labAccess }),
  });
  const data = await response.json();
  if (!response.ok) throw new Error(data.message || "Не удалось сохранить доступ к модулям");
  setStatus("lab_access: OK");
  closeUserModulesModal();
  await loadUsers();
}

async function loadUsers() {
  const response = await fetch("/api/admin/users");
  const data = await response.json();
  if (!response.ok) {
    throw new Error(data.message || "Ошибка загрузки пользователей");
  }
  const list = document.getElementById("usersList");
  if (!list) return;
  list.innerHTML = data.users.map(userRowTemplate).join("");
}

async function updateSystemMetrics() {
  const response = await fetch("/api/admin/system-metrics");
  const data = await response.json();
  if (!response.ok) {
    throw new Error(data.message || "Ошибка метрик системы");
  }
  const m = data.metrics;

  if (!m || m.available === false) {
    const unavailableMsg =
      (m && typeof m.message === "string" && m.message.trim()) ||
      "Метрики CPU/RAM недоступны (нужен psutil на сервере).";
    ["cpu", "ram", "swap", "disk"].forEach((key) => {
      const root = document.querySelector(`[data-gauge="${key}"]`);
      if (!root) return;
      root.style.setProperty("--gauge-pct", "0");
      const d = root.querySelector("[data-gauge-detail]");
      if (d) d.textContent = "—";
      const p = root.querySelector("[data-gauge-pct]");
      if (p) p.textContent = "—";
    });
    const pills = document.getElementById("loadAvgPills");
    if (pills) pills.textContent = "—";
    const nu = document.getElementById("netUpload");
    const nd = document.getElementById("netDownload");
    if (nu) nu.textContent = "—";
    if (nd) nd.textContent = "—";
    const nvs = document.getElementById("netVolumeSent");
    const nvr = document.getElementById("netVolumeRecv");
    if (nvs) nvs.textContent = "—";
    if (nvr) nvr.textContent = "—";
    renderServerIps([]);
    const pillH = document.getElementById("pillHostUptime");
    const pillW = document.getElementById("pillWespUptime");
    const pillC = document.getElementById("pillConnections");
    if (pillH) pillH.textContent = "host: —";
    if (pillW) pillW.textContent = "wesp: —";
    if (pillC) pillC.textContent = "TCP — · UDP —";
    const st = document.getElementById("wespRuntimeStatus");
    const dot = document.querySelector(".wesp-dash-status .wesp-status-dot");
    if (st) st.textContent = unavailableMsg;
    if (dot) {
      dot.classList.remove("wesp-status-dot--ok");
      dot.classList.add("wesp-status-dot--warn");
    }
    return;
  }

  const cpuRoot = document.querySelector('[data-gauge="cpu"]');
  if (cpuRoot) {
    setRingPercent(cpuRoot, m.cpu?.percent ?? 0);
    const d = cpuRoot.querySelector("[data-gauge-detail]");
    if (d) d.textContent = ruCpuCoresLabel(m.cpu?.cores);
  }

  const ramRoot = document.querySelector('[data-gauge="ram"]');
  if (ramRoot) {
    setRingPercent(ramRoot, m.memory?.percent ?? 0);
    const d = ramRoot.querySelector("[data-gauge-detail]");
    if (d) {
      d.textContent = `${formatBytes(m.memory?.used)} / ${formatBytes(m.memory?.total)}`;
    }
  }

  const swapRoot = document.querySelector('[data-gauge="swap"]');
  if (swapRoot) {
    const sp = m.swap?.total ? m.swap.percent : 0;
    setRingPercent(swapRoot, sp);
    const d = swapRoot.querySelector("[data-gauge-detail]");
    if (d) {
      d.textContent =
        m.swap?.total > 0
          ? `${formatBytes(m.swap.used)} / ${formatBytes(m.swap.total)}`
          : "Нет swap";
    }
  }

  const diskRoot = document.querySelector('[data-gauge="disk"]');
  if (diskRoot) {
    if (m.disk) {
      setRingPercent(diskRoot, m.disk.percent ?? 0);
      const d = diskRoot.querySelector("[data-gauge-detail]");
      if (d) {
        d.textContent = `${formatBytes(m.disk.used)} / ${formatBytes(m.disk.total)}`;
      }
    } else {
      setRingPercent(diskRoot, 0);
      const d = diskRoot.querySelector("[data-gauge-detail]");
      if (d) d.textContent = "n/a";
      const p = diskRoot.querySelector("[data-gauge-pct]");
      if (p) p.textContent = "—";
    }
  }

  const pills = document.getElementById("loadAvgPills");
  if (pills) {
    if (Array.isArray(m.load_avg) && m.load_avg.length === 3) {
      pills.innerHTML = m.load_avg
        .map(
          (v, i) =>
            `${i ? '<span style="opacity:0.45;margin:0 6px;">|</span>' : ""}<span class="ant-tag ant-tag-green">${v}</span>`,
        )
        .join("");
    } else {
      pills.textContent = "n/a (OS)";
    }
  }

  const nu = document.getElementById("netUpload");
  const nd = document.getElementById("netDownload");
  if (nu) nu.textContent = formatSpeed(m.network?.upload_bps);
  if (nd) nd.textContent = formatSpeed(m.network?.download_bps);

  const nvs = document.getElementById("netVolumeSent");
  const nvr = document.getElementById("netVolumeRecv");
  if (nvs) nvs.textContent = formatBytes(m.network?.bytes_sent_total);
  if (nvr) nvr.textContent = formatBytes(m.network?.bytes_recv_total);

  renderServerIps(m.addresses);

  const pillH = document.getElementById("pillHostUptime");
  const pillW = document.getElementById("pillWespUptime");
  const pillC = document.getElementById("pillConnections");
  if (pillH && m.host) pillH.textContent = `host: ${m.host.uptime_human || "—"}`;
  if (pillW && m.process) {
    pillW.textContent = `wesp: ${m.process.uptime_human || "—"}`;
  }
  if (pillC && m.connections) {
    pillC.textContent = `TCP ${m.connections.tcp} · UDP ${m.connections.udp}`;
  } else if (pillC) {
    pillC.textContent = "TCP/UDP: n/a";
  }

  const st = document.getElementById("wespRuntimeStatus");
  const dot = document.querySelector(".wesp-dash-status .wesp-status-dot");
  if (st) st.textContent = "OK";
  if (dot) {
    dot.classList.add("wesp-status-dot--ok");
    dot.classList.remove("wesp-status-dot--warn");
  }
}

async function updateSummary() {
  const response = await fetch("/api/admin/summary");
  const data = await response.json();
  if (!response.ok) {
    throw new Error(data.message || "Ошибка загрузки summary");
  }

  document.getElementById("statUsers").textContent = String(data.counts.users);
  document.getElementById("statRecipes").textContent = String(data.counts.recipes);
  document.getElementById("statComponents").textContent = String(data.counts.components);
  document.getElementById("statQueuePending").textContent = String(data.sync.queue.pending);

  const verEl = document.getElementById("wespDashVersion");
  if (verEl) {
    const v = data.app?.version || "—";
    verEl.textContent = `v${String(v).replace(/^v/i, "")}`;
  }
  const firstEl = document.getElementById("wespFirstLaunch");
  if (firstEl) firstEl.textContent = formatRuDateFromIso(data.app?.first_launch_at);
  const untilEl = document.getElementById("wespWarrantyUntil");
  if (untilEl) {
    const wu = data.app?.warranty_until;
    if (!wu) untilEl.textContent = "—";
    else {
      const p = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(wu));
      if (p) {
        const dt = new Date(Number(p[1]), Number(p[2]) - 1, Number(p[3]));
        untilEl.textContent = dt.toLocaleDateString("ru-RU", {
          day: "numeric",
          month: "long",
          year: "numeric",
        });
      } else {
        untilEl.textContent = formatRuDateFromIso(wu);
      }
    }
  }
  const daysEl = document.getElementById("wespWarrantyDays");
  if (daysEl) {
    const dr = data.app?.warranty_days_remaining;
    if (dr == null || Number.isNaN(Number(dr))) daysEl.textContent = "—";
    else if (Number(dr) < 0) daysEl.textContent = "истекла";
    else daysEl.textContent = String(dr);
  }

  const setTxt = (id, text) => {
    const el = document.getElementById(id);
    if (el) el.textContent = text;
  };
  const app = data.app || {};
  const counts = data.counts || {};
  setTxt("dashMetaConfigMode", ruConfigModeLabel(app.config_mode));
  setTxt("dashMetaDebug", app.debug ? "true" : "false");
  setTxt("dashMetaVersion", app.version ? `v${String(app.version).replace(/^v/i, "")}` : "—");
  setTxt("dashMetaSyncRole", ruSyncRoleLabel(app.role));
  setTxt("dashMetaFirstLaunch", formatRuDateFromIso(app.first_launch_at));
  const wu = app.warranty_until;
  if (wu) {
    const p = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(wu));
    if (p) {
      const dt = new Date(Number(p[1]), Number(p[2]) - 1, Number(p[3]));
      setTxt(
        "dashMetaWarrantyUntil",
        dt.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" }),
      );
    } else {
      setTxt("dashMetaWarrantyUntil", formatRuDateFromIso(wu));
    }
  } else {
    setTxt("dashMetaWarrantyUntil", "—");
  }
  const drDash = app.warranty_days_remaining;
  if (drDash == null || Number.isNaN(Number(drDash))) setTxt("dashMetaWarrantyDays", "—");
  else if (Number(drDash) < 0) setTxt("dashMetaWarrantyDays", "истекла");
  else setTxt("dashMetaWarrantyDays", String(drDash));

  setTxt("dashCountUsers", String(counts.users ?? "—"));
  setTxt("dashCountRecipes", String(counts.recipes ?? "—"));
  setTxt("dashCountIngredients", String(counts.ingredients ?? "—"));
  setTxt("dashCountComponents", String(counts.components ?? "—"));
  setTxt("dashCountDispensers", String(counts.feed_dispensers ?? "—"));
  setTxt("dashCountPeriods", String(counts.feeding_periods ?? "—"));

  setTxt(
    "dashMetaGenerated",
    data.generated_at
      ? `summary @ ${formatRuDateTimeFromIso(data.generated_at)}`
      : "—",
  );
  renderSystemSectionTables(data);
  renderAutoUpdateFormFromSummary(data.auto_update || {});
  renderSyncSectionFromSummary(data.sync || {});
  await loadSyncClientsTable();
  renderSecuritySectionFromSummary(data.security || {});
}

async function refreshHardwareSection() {
  await Promise.all([loadHardwareStatus(), loadPeripheralEvents()]);
  setStatus("Железо: обновлено");
}

function setSecurityFieldHint(id, locked, envName) {
  const hint = document.getElementById(id);
  if (!hint) return;
  if (locked) {
    hint.hidden = false;
    hint.textContent = `Заблокировано переменной окружения (${envName}). Снимите её, чтобы менять из админки.`;
  } else {
    hint.hidden = true;
    hint.textContent = "";
  }
}

async function patchSecurityBool(field, checked, el) {
  if (el?.disabled) return;
  const prev = !checked;
  try {
    const r = await fetch("/api/admin/security-settings", {
      method: "PATCH",
      credentials: "same-origin",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ [field]: checked }),
    });
    const d = await r.json().catch(() => ({}));
    if (!r.ok) {
      setStatus(d.message || `Ошибка ${r.status}`, true);
      if (el) el.checked = prev;
      return;
    }
    const labels = {
      mac_lock_enabled: checked ? "MAC-lock включён" : "MAC-lock выключен",
      kiosk_enforce_paired_only: checked
        ? "Требование привязки терминала включено"
        : "Требование привязки терминала выключено",
      calibration_public: checked
        ? "Публичная калибровка включена"
        : "Публичная калибровка выключена",
    };
    setStatus(d.message || labels[field] || "Сохранено");
    if (d.security) renderSecuritySectionFromSummary(d.security);
  } catch (err) {
    setStatus(err?.message, true, "Ошибка сохранения");
    if (el) el.checked = prev;
  }
}

function wireSecurityToggleAutoSave(id, field) {
  document.getElementById(id)?.addEventListener("change", async (e) => {
    const el = e.target;
    if (!(el instanceof HTMLInputElement)) return;
    await patchSecurityBool(field, el.checked, el);
  });
}

function renderNetworkDetectedList(net) {
  const el = document.getElementById("networkDetectedList");
  if (!el) return;
  const d = (net && net.detected) || {};
  const mdnsOn = Boolean(d.mdns_active);
  const mdnsAvail = Boolean(d.mdns_available);
  el.innerHTML = [
    wespAdmListSection("Обнаружено"),
    wespAdmListItem(
      "LAN IP",
      `<span class="wesp-adm-list-value">${escHtml(String(d.lan_ip ?? "—"))}</span>`,
      "текущий адрес в сети",
    ),
    wespAdmListItem(
      "Hostname ОС",
      escHtml(String(d.os_hostname ?? "—")),
      "socket.gethostname()",
    ),
    wespAdmListItem(
      "Listen",
      `<span class="wesp-adm-list-value">${escHtml(String(d.listen_host ?? "—"))}:${escHtml(String(d.listen_port ?? "—"))}</span>`,
      "WESP_LISTEN / waitress",
    ),
    wespAdmListItem(
      "mDNS",
      mdnsOn
        ? '<span class="wesp-diag-ok">активен</span>'
        : mdnsAvail
          ? '<span class="wesp-sync-hint">выкл.</span>'
          : '<span class="wesp-diag-bad">zeroconf ∅</span>',
      mdnsOn
        ? "имя объявляется в LAN"
        : d.mdns_error
          ? escHtml(String(d.mdns_error))
          : mdnsAvail
            ? "включите toggle ниже"
            : "pip install zeroconf",
    ),
    wespAdmListItem(
      "Подсказка URL",
      `<span class="wesp-adm-list-value">${escHtml(String(net.suggested_public_url ?? "—"))}</span>`,
      "из имени + порта",
    ),
    wespAdmListItem(
      "Sync по IP",
      `<span class="wesp-adm-list-value">${escHtml(String(net.sync_url_by_ip ?? "—"))}</span>`,
      "если .local в сети не резолвится — укажите этот адрес на терминалах",
    ),
  ].join("");
}

function renderNetworkSectionFromSummary(net) {
  const n = net && typeof net === "object" ? net : {};
  const hint = document.getElementById("networkSettingsHint");
  const lockParts = [];
  if (n.local_hostname_locked_by_env) lockParts.push("local_hostname ← WESP_NETWORK_LOCAL_HOSTNAME");
  if (n.public_base_url_locked_by_env) lockParts.push("public URL ← WESP_KIOSK_PUBLIC_BASE_URL");
  if (n.mdns_enabled_locked_by_env) lockParts.push("mDNS ← WESP_MDNS_ENABLED");
  if (hint) {
    const base =
      "PATCH → data/wesp_network_settings.json. Адрес sync-клиентов — вкладка «Синхронизация».";
    hint.textContent = lockParts.length ? `${base} env: ${lockParts.join("; ")}.` : base;
  }

  const hostInp = document.getElementById("netLocalHostname");
  if (hostInp) {
    hostInp.value = n.local_hostname || n.default_local_hostname || "komton_srv_1.local";
    hostInp.disabled = Boolean(n.local_hostname_locked_by_env);
  }
  const urlInp = document.getElementById("netPublicBaseUrl");
  if (urlInp) {
    urlInp.value = n.public_base_url || "";
    urlInp.disabled = Boolean(n.public_base_url_locked_by_env);
  }
  const mdns = document.getElementById("netMdnsEnabled");
  const mdnsLabel = document.getElementById("netMdnsStatusLabel");
  const mdnsHint = document.getElementById("netMdnsHint");
  const det = n.detected || {};
  if (mdns) {
    mdns.checked = Boolean(n.mdns_enabled);
    mdns.disabled = Boolean(n.mdns_enabled_locked_by_env) || !det.mdns_available;
  }
  if (mdnsLabel) {
    mdnsLabel.textContent = n.mdns_enabled ? "mDNS: вкл." : "mDNS: выкл.";
  }
  if (mdnsHint) {
    if (!det.mdns_available) {
      mdnsHint.textContent = "Пакет zeroconf не установлен — объявление имени в LAN недоступно.";
    } else if (det.mdns_active) {
      mdnsHint.textContent = "Сервис объявлен в локальной сети.";
    } else if (det.mdns_error) {
      mdnsHint.textContent = String(det.mdns_error);
    } else {
      mdnsHint.textContent =
        "Включите mDNS, чтобы клиенты в LAN резолвили имя без /etc/hosts и без DNS роутера.";
    }
  }
  const saveBtn = document.getElementById("netSettingsSave");
  if (saveBtn) {
    saveBtn.disabled =
      Boolean(n.local_hostname_locked_by_env) &&
      Boolean(n.public_base_url_locked_by_env) &&
      Boolean(n.mdns_enabled_locked_by_env);
  }
  renderNetworkDetectedList(n);
}

let lastNetworkSnapshot = null;
let networkSectionLoadPending = 0;

function setNetworkSectionLoading(loading) {
  const section = document.getElementById("wespSectionNetwork");
  if (section) section.classList.toggle("wesp-network-section--loading", loading);
  const banner = document.getElementById("networkFormLoading");
  if (banner) banner.hidden = !loading;
}

function beginNetworkSectionLoad() {
  networkSectionLoadPending += 1;
  if (networkSectionLoadPending === 1) {
    setNetworkSectionLoading(true);
    const det = document.getElementById("networkDetectedList");
    if (det) det.innerHTML = wespAdmListLoading("Определение LAN IP и mDNS…");
    const ev = document.getElementById("networkEventsList");
    if (ev && !ev.querySelector(".wesp-adm-list-loading")) {
      ev.innerHTML = wespAdmListLoading("Загрузка журнала…");
    }
  }
}

function endNetworkSectionLoad() {
  networkSectionLoadPending = Math.max(0, networkSectionLoadPending - 1);
  if (networkSectionLoadPending === 0) {
    setNetworkSectionLoading(false);
  }
}

async function loadNetworkSettings() {
  beginNetworkSectionLoad();
  try {
    const r = await fetch("/api/admin/network-settings", { credentials: "same-origin" });
    const d = await r.json().catch(() => ({}));
    if (!r.ok) throw new Error(d.message || String(r.status));
    lastNetworkSnapshot = d.network || d;
    renderNetworkSectionFromSummary(lastNetworkSnapshot);
    return lastNetworkSnapshot;
  } finally {
    endNetworkSectionLoad();
  }
}

function wespListenPortFallback() {
  const p = Number(lastNetworkSnapshot?.detected?.listen_port);
  return Number.isFinite(p) && p > 0 ? p : 80;
}

function applyNetworkSuggestedPublicUrl() {
  const hostInp = document.getElementById("netLocalHostname");
  const urlInp = document.getElementById("netPublicBaseUrl");
  if (!hostInp || !urlInp || urlInp.disabled) return;
  let host = (hostInp.value || "komton_srv_1.local").trim().toLowerCase();
  if (host && !host.endsWith(".local")) host = `${host}.local`;
  const port = wespListenPortFallback();
  urlInp.value = port === 80 ? `http://${host}` : `http://${host}:${port}`;
}

async function saveNetworkSettingsFromForm() {
  const hostInp = document.getElementById("netLocalHostname");
  const urlInp = document.getElementById("netPublicBaseUrl");
  const mdns = document.getElementById("netMdnsEnabled");
  const body = {};
  if (hostInp && !hostInp.disabled) body.local_hostname = hostInp.value.trim();
  if (urlInp && !urlInp.disabled) body.public_base_url = urlInp.value.trim();
  if (mdns && !mdns.disabled) body.mdns_enabled = mdns.checked;
  if (Object.keys(body).length === 0) {
    setStatus("network PATCH: noop (env lock / no delta)", true);
    return;
  }
  beginNetworkSectionLoad();
  try {
    const r = await fetch("/api/admin/network-settings", {
      method: "PATCH",
      credentials: "same-origin",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    const d = await r.json().catch(() => ({}));
    if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
    setStatus(d.message || "Сеть: сохранено");
    if (d.network) renderNetworkSectionFromSummary(d.network);
    await loadNetworkEvents();
  } finally {
    endNetworkSectionLoad();
  }
}

function renderSecuritySectionFromSummary(sec) {
  const s = sec && typeof sec === "object" ? sec : {};
  const hint = document.getElementById("securitySettingsHint");
  const lockParts = [];
  if (s.mac_lock_enabled_locked_by_env) lockParts.push("MAC: WESP_MAC_LOCK_ENABLED");
  if (s.allowed_mac_addresses_locked_by_env) lockParts.push("MAC list ← WESP_ALLOWED_MAC_ADDRESSES");
  if (s.kiosk_enforce_paired_only_locked_by_env) {
    lockParts.push("kiosk_enforce ← WESP_KIOSK_ENFORCE_PAIRED_ONLY");
  }
  if (s.calibration_public_locked_by_env) lockParts.push("CALIBRATION_PUBLIC ← env");
  if (s.device_token_locked_by_env) lockParts.push("DEVICE_TOKEN ← env");
  if (s.device_token_header_locked_by_env) lockParts.push("token header ← env");
  if (s.kiosk_pair_token_ttl_seconds_locked_by_env) {
    lockParts.push("pair TTL ← WESP_KIOSK_PAIR_TOKEN_TTL_SECONDS");
  }
  if (s.kiosk_auth_cookie_days_locked_by_env) lockParts.push("kiosk cookie ← WESP_KIOSK_AUTH_COOKIE_DAYS");
  if (s.session_remember_days_locked_by_env) lockParts.push("remember_days ← WESP_SESSION_REMEMBER_DAYS");

  if (hint) {
    hint.textContent = lockParts.length
      ? `env override: ${lockParts.join("; ")}.`
      : "PATCH → data/wesp_security_settings.json + in-process config; приоритет у WESP_*.";
  }

  const mac = document.getElementById("secMacLock");
  const macs = document.getElementById("secAllowedMacs");
  if (mac) {
    mac.checked = Boolean(s.mac_lock_enabled);
    mac.disabled = Boolean(s.mac_lock_enabled_locked_by_env);
  }
  setSecurityFieldHint(
    "secMacLockHint",
    Boolean(s.mac_lock_enabled_locked_by_env),
    "WESP_MAC_LOCK_ENABLED",
  );
  if (macs) {
    macs.value = s.allowed_mac_addresses || "";
    macs.disabled = Boolean(s.allowed_mac_addresses_locked_by_env);
  }

  const kEnf = document.getElementById("secKioskEnforce");
  if (kEnf) {
    kEnf.checked = Boolean(s.kiosk_enforce_paired_only);
    kEnf.disabled = Boolean(s.kiosk_enforce_paired_only_locked_by_env);
  }
  setSecurityFieldHint(
    "secKioskEnforceHint",
    Boolean(s.kiosk_enforce_paired_only_locked_by_env),
    "WESP_KIOSK_ENFORCE_PAIRED_ONLY",
  );

  const cal = document.getElementById("secCalibrationPublic");
  if (cal) {
    cal.checked = Boolean(s.calibration_public);
    cal.disabled = Boolean(s.calibration_public_locked_by_env);
  }
  setSecurityFieldHint(
    "secCalibrationPublicHint",
    Boolean(s.calibration_public_locked_by_env),
    "WESP_CALIBRATION_PUBLIC",
  );

  const th = document.getElementById("secDeviceTokenHeader");
  if (th) {
    th.value = s.device_token_header || "X-Device-Token";
    th.disabled = Boolean(s.device_token_header_locked_by_env);
  }

  const token = document.getElementById("secDeviceToken");
  const tclear = document.getElementById("secDeviceTokenClear");
  if (tclear) {
    tclear.checked = false;
    tclear.disabled = Boolean(s.device_token_locked_by_env);
  }
  if (token) {
    token.value = "";
    const lockedTok = Boolean(s.device_token_locked_by_env);
    token.dataset.envLocked = lockedTok ? "1" : "";
    token.disabled = lockedTok || Boolean(tclear && tclear.checked);
  }

  const note = document.getElementById("secDeviceTokenNote");
  if (note) {
    note.textContent = s.device_token_configured
      ? "DEVICE_TOKEN: задан. Смена — поле ниже или clear."
      : "DEVICE_TOKEN: ∅. Публичная калибровка OFF.";
  }

  const ttl = document.getElementById("secKioskPairTtl");
  if (ttl) {
    ttl.value = String(s.kiosk_pair_token_ttl_seconds ?? 300);
    ttl.disabled = Boolean(s.kiosk_pair_token_ttl_seconds_locked_by_env);
  }
  const ckd = document.getElementById("secKioskCookieDays");
  if (ckd) {
    ckd.value = String(s.kiosk_auth_cookie_days ?? 18250);
    ckd.disabled = Boolean(s.kiosk_auth_cookie_days_locked_by_env);
  }
  const sess = document.getElementById("secSessionRememberDays");
  if (sess) {
    sess.value = String(s.session_remember_days ?? 7);
    sess.disabled = Boolean(s.session_remember_days_locked_by_env);
  }

  const saveBtn = document.getElementById("securitySettingsSave");
  if (saveBtn) {
    const allLocked =
      Boolean(s.mac_lock_enabled_locked_by_env) &&
      Boolean(s.allowed_mac_addresses_locked_by_env) &&
      Boolean(s.kiosk_enforce_paired_only_locked_by_env) &&
      Boolean(s.calibration_public_locked_by_env) &&
      Boolean(s.device_token_locked_by_env) &&
      Boolean(s.device_token_header_locked_by_env) &&
      Boolean(s.kiosk_pair_token_ttl_seconds_locked_by_env) &&
      Boolean(s.kiosk_auth_cookie_days_locked_by_env) &&
      Boolean(s.session_remember_days_locked_by_env);
    saveBtn.disabled = allLocked;
  }
}

async function saveSecuritySettingsFromForm() {
  const el = (id) => document.getElementById(id);
  const body = {};
  const mac = el("secMacLock");
  if (mac && !mac.disabled) body.mac_lock_enabled = mac.checked;
  const macs = el("secAllowedMacs");
  if (macs && !macs.disabled) body.allowed_mac_addresses = macs.value.trim();
  const kEnf = el("secKioskEnforce");
  if (kEnf && !kEnf.disabled) body.kiosk_enforce_paired_only = kEnf.checked;
  const cal = el("secCalibrationPublic");
  if (cal && !cal.disabled) body.calibration_public = cal.checked;
  const th = el("secDeviceTokenHeader");
  if (th && !th.disabled) body.device_token_header = th.value.trim();
  const token = el("secDeviceToken");
  const tclear = el("secDeviceTokenClear");
  if (token && tclear && !tclear.disabled) {
    if (tclear.checked) body.device_token_clear = true;
    else if (token.value.trim()) body.device_token = token.value.trim();
  }
  const ttl = el("secKioskPairTtl");
  if (ttl && !ttl.disabled) {
    const n = parseInt(ttl.value, 10);
    if (Number.isFinite(n)) body.kiosk_pair_token_ttl_seconds = n;
  }
  const ckd = el("secKioskCookieDays");
  if (ckd && !ckd.disabled) {
    const n = parseInt(ckd.value, 10);
    if (Number.isFinite(n)) body.kiosk_auth_cookie_days = n;
  }
  const sess = el("secSessionRememberDays");
  if (sess && !sess.disabled) {
    const n = parseInt(sess.value, 10);
    if (Number.isFinite(n)) body.session_remember_days = n;
  }

  if (Object.keys(body).length === 0) {
    setStatus("security PATCH: noop (env lock / no delta)", true);
    return;
  }

  const r = await fetch("/api/admin/security-settings", {
    method: "PATCH",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const d = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
  setStatus(d.message || "persist OK");
  if (d.security) renderSecuritySectionFromSummary(d.security);
  else await updateSummary();
}

function renderSyncQueueFromSummary(sync) {
  const list = document.getElementById("syncQueueList");
  if (!list || !sync) return;
  const q = sync.queue || {};
  const rows = [
    ["queue.total", String(q.total ?? "—")],
    ["queue.pending", String(q.pending ?? "—")],
    ["queue.processing", String(q.processing ?? "—")],
    ["queue.failed", String(q.failed ?? "—")],
    ["max_concurrent", String(sync.max_concurrent ?? "—")],
    ["client_poll_interval_s", String(sync.client_poll_interval_sec ?? "—")],
    ["client_poll_interval_initial_s", String(sync.client_poll_interval_initial_sec ?? "—")],
    ["client_pull_limit_steady", String(sync.client_pull_limit_steady ?? "—")],
    ["client_pull_limit_initial", String(sync.client_pull_limit_initial ?? "—")],
  ];
  list.innerHTML = rows
    .map(([k, v]) => wespAdmListItem(k, `<span class="wesp-adm-list-value">${escHtml(v)}</span>`))
    .join("");
}

let lastSyncDiagnostics = null;

function applySyncSectionFromDiagnostics(diag) {
  const o = diag.overview || {};
  const cfg = o.config || {};
  const q = diag.queues || {};
  const stats = q.stats || {};
  const syncLike = {
    connection: o.connection,
    state: o.client_state,
    first_sync_banner: o.first_sync_banner,
    initial_sync_progress: (diag.bootstrap || {}).initial_sync_progress || {},
    queue_errors: q.recent_errors || [],
    queue: {
      total: stats.total,
      pending: stats.pending,
      processing: stats.processing,
      failed: stats.failed,
      completed: stats.completed,
    },
    max_concurrent: cfg.sync_max_concurrent,
    client_poll_interval_sec: cfg.client_poll_interval_sec,
    client_poll_interval_initial_sec: cfg.client_poll_interval_initial_sec,
    client_pull_limit_steady: cfg.client_pull_limit_steady,
    client_pull_limit_initial: cfg.client_pull_limit_initial,
  };
  renderSyncRuntimeNotices(o.connection || {});
  applySyncConnectionForm(o.connection || {});
  applySyncStateForm(o.client_state || {});
  renderInitialSyncProgress(syncLike);
  renderSyncQueueFromSummary(syncLike);
  renderSyncQueueErrorsFromSummary(syncLike);
}

function renderSyncDiagByTable(byTable) {
  const wrap = document.getElementById("syncDiagByTableWrap");
  if (!wrap) return;
  const tbl = byTable && typeof byTable === "object" ? byTable : {};
  const tables = Object.keys(tbl).sort();
  if (!tables.length) {
    wrap.innerHTML = '<p class="wesp-sync-hint">Нет строк в sync_queue.</p>';
    return;
  }
  const statuses = ["pending", "processing", "completed", "failed"];
  let html = "<table><thead><tr><th>table</th>";
  for (const s of statuses) html += `<th>${escHtml(s)}</th>`;
  html += "<th>Σ</th></tr></thead><tbody>";
  for (const t of tables) {
    const row = tbl[t] || {};
    let sum = 0;
    html += `<tr><td><code>${escHtml(t)}</code></td>`;
    for (const s of statuses) {
      const n = Number(row[s] || 0);
      sum += n;
      html += `<td>${n || "—"}</td>`;
    }
    html += `<td><strong>${sum}</strong></td></tr>`;
  }
  html += "</tbody></table>";
  wrap.innerHTML = html;
}

function renderSyncDiagProblems(diag) {
  const wrap = document.getElementById("syncDiagProblemsWrap");
  if (!wrap) return;
  const q = diag.queues || {};
  const chunks = [
    { title: "stuck processing (timeout)", rows: q.stuck_processing || [] },
    { title: "processing без processed_at", rows: q.processing_missing_processed_at || [] },
    { title: "failed (свежие)", rows: q.failed_recent || [] },
    { title: "pending с retry", rows: q.high_retry_pending || [] },
  ];
  const all = [];
  for (const ch of chunks) {
    for (const r of ch.rows) {
      all.push({ diagSection: ch.title, ...r });
    }
  }
  if (!all.length) {
    wrap.innerHTML =
      '<p class="wesp-sync-hint">Проблемных задач не найдено (по текущим критериям).</p>';
    return;
  }
  let html =
    '<table><thead><tr><th>класс</th><th>table</th><th>id</th><th>status</th><th>retry</th><th>примечание</th></tr></thead><tbody>';
  const slice = all.slice(0, 120);
  for (const r of slice) {
    const note = String(r.error_message || r.stuck_reason || "—").slice(0, 320);
    html += `<tr><td>${escHtml(r.diagSection || "—")}</td><td><code>${escHtml(
      String(r.table_name || "—"),
    )}</code></td><td><code title="${escHtml(String(r.id || ""))}">${escHtml(
      String(r.id || "").slice(0, 8),
    )}…</code></td><td>${escHtml(String(r.status || "—"))}</td><td>${escHtml(
      String(r.retry_count ?? "—"),
    )}</td><td class="wesp-adm-list-error">${escHtml(note)}</td></tr>`;
  }
  html += "</tbody></table>";
  if (all.length > 120) {
    html += `<p class="wesp-sync-hint">… и ещё ${all.length - 120}</p>`;
  }
  wrap.innerHTML = html;
}

function renderSyncDiagnosticsFull(diag) {
  lastSyncDiagnostics = diag;
  const hint = document.getElementById("syncDiagHint");
  if (hint) {
    const at = diag.generated_at ? formatRuDateTimeFromIso(diag.generated_at) : "—";
    hint.innerHTML = `Снимок: <strong>${escHtml(at)}</strong>. Метрики runtime — только этот воркер (см. ниже).`;
  }
  const ov = document.getElementById("syncDiagOverviewList");
  if (ov) {
    const o = diag.overview || {};
    const cfg = o.config || {};
    const meta = o.metadata || {};
    const rows = [
      ["effective_role", ruSyncRoleLabel(o.effective_role || "")],
      ["server_url", String((o.connection || {}).server_url || "—")],
      ["sync_status (metadata)", String(meta.sync_status || "—")],
      ["node_id (metadata)", String(meta.node_id || "—")],
      ["last_sync (metadata)", meta.last_sync ? formatRuDateTimeFromIso(meta.last_sync) : "—"],
      ["requeue_timeout_min", String(cfg.sync_requeue_timeout_minutes ?? "—")],
      ["max_concurrent", String(cfg.sync_max_concurrent ?? "—")],
    ];
    ov.innerHTML = rows
      .map(([k, v]) =>
        wespAdmListItem(k, `<span class="wesp-adm-list-value">${escHtml(String(v))}</span>`),
      )
      .join("");
  }
  const rt = document.getElementById("syncDiagRuntimeList");
  if (rt) {
    const r = diag.runtime || {};
    const rows = [
      ["scope", escHtml(String(r.scope || "—"))],
      [
        "concurrency",
        `${r.concurrency_used ?? "—"}/${r.concurrency_max ?? "—"} (avail ${r.concurrency_available ?? "—"})`,
      ],
      ["pull p95 s", String(r.pull_duration_p95_sec ?? "—")],
      ["pull samples", String(r.pull_duration_samples ?? "—")],
      ["rate_limit active clients", String(r.rate_limit_windows_with_activity ?? "—")],
      ["pull lock ipc", r.pull_lock_ipc_supported ? "yes" : "no"],
      [
        "ipc held / thread locks",
        `${r.pull_lock_ipc_held_clients ?? "—"} / ${r.pull_lock_thread_clients ?? "—"}`,
      ],
    ];
    let html = rows
      .map(([k, v]) =>
        wespAdmListItem(
          k,
          `<span class="wesp-adm-list-value">${escHtml(String(v))}</span>`,
        ),
      )
      .join("");
    if (r.note) {
      html += wespAdmListItem(
        "note",
        `<span class="wesp-sync-hint">${escHtml(String(r.note))}</span>`,
      );
    }
    rt.innerHTML = html;
  }
  const qs = document.getElementById("syncDiagQueueStatsList");
  if (qs) {
    const st = (diag.queues || {}).stats || {};
    const rows = [
      ["total", String(st.total ?? "—")],
      ["pending", String(st.pending ?? "—")],
      ["processing", String(st.processing ?? "—")],
      ["completed", String(st.completed ?? "—")],
      ["failed", String(st.failed ?? "—")],
    ];
    if (st.by_priority) {
      for (let p = 1; p <= 5; p++) {
        const key = `priority_${p}`;
        if (st.by_priority[key] != null) rows.push([key, String(st.by_priority[key])]);
      }
    }
    qs.innerHTML = rows
      .map(([k, v]) => wespAdmListItem(k, `<span class="wesp-adm-list-value">${escHtml(v)}</span>`))
      .join("");
  }
  renderSyncDiagByTable((diag.queues || {}).by_table_status);
  const boot = document.getElementById("syncDiagBootstrapList");
  if (boot) {
    const b = diag.bootstrap || {};
    const es = b.engine_state;
    const isp = b.initial_sync_progress || {};
    const parts = [];
    if (es) {
      parts.push(
        wespAdmListItem(
          "bootstrap_completed_at",
          `<span class="wesp-adm-list-value">${escHtml(String(es.universal_bootstrap_completed_at || "—"))}</span>`,
        ),
      );
      parts.push(
        wespAdmListItem(
          "bootstrap_cursor",
          `<span class="wesp-adm-list-value">${escHtml(String(es.universal_bootstrap_cursor ?? "—"))}</span>`,
        ),
      );
      if (es.universal_bootstrap_last_error) {
        parts.push(
          wespAdmListItem(
            "bootstrap_last_error",
            `<span class="wesp-adm-list-error">${escHtml(String(es.universal_bootstrap_last_error).slice(0, 500))}</span>`,
          ),
        );
      }
    } else {
      parts.push(wespAdmListItem("engine_state", `<span class="wesp-adm-list-value">—</span>`));
    }
    parts.push(
      wespAdmListItem(
        "initial_sync.active",
        `<span class="wesp-adm-list-value">${escHtml(String(Boolean(isp.active)))}</span>`,
      ),
    );
    if (isp.percent != null) {
      parts.push(
        wespAdmListItem(
          "initial_sync.percent",
          `<span class="wesp-adm-list-value">${escHtml(String(isp.percent))}</span>`,
        ),
      );
    }
    boot.innerHTML = parts.join("");
  }
  const rp = document.getElementById("syncDiagReportPushList");
  if (rp) {
    const x = diag.report_push || {};
    const parts = [];
    parts.push(
      wespAdmListItem("scope", `<span class="wesp-adm-list-value">${escHtml(String(x.scope || "—"))}</span>`),
    );
    if (x.note) {
      parts.push(
        wespAdmListItem("note", `<span class="wesp-adm-list-value">${escHtml(String(x.note))}</span>`),
      );
    }
    if (x.tables && x.tables.length) {
      parts.push(
        wespAdmListItem(
          "tables",
          `<span class="wesp-adm-list-value">${escHtml(x.tables.join(", "))}</span>`,
        ),
      );
    }
    if (x.by_status) {
      parts.push(
        wespAdmListItem(
          "by_status",
          `<span class="wesp-adm-list-value">${escHtml(JSON.stringify(x.by_status))}</span>`,
        ),
      );
    }
    if (x.total != null) {
      parts.push(
        wespAdmListItem(
          "total report tasks",
          `<span class="wesp-adm-list-value">${escHtml(String(x.total))}</span>`,
        ),
      );
    }
    const cr = x.client_runtime || {};
    parts.push(
      wespAdmListItem(
        "sync_client init",
        `<span class="wesp-adm-list-value">${cr.initialized ? "yes" : "no"}</span>`,
      ),
    );
    parts.push(
      wespAdmListItem(
        "sync_thread alive",
        `<span class="wesp-adm-list-value">${cr.client_thread_alive ? "yes" : "no"}</span>`,
      ),
    );
    rp.innerHTML = parts.join("");
  }
  renderSyncDiagProblems(diag);
  const cf = document.getElementById("syncDiagConflictsList");
  if (cf) {
    const c = diag.conflicts || {};
    const s = c.summary || {};
    const rec = Array.isArray(c.recent) ? c.recent : [];
    const head = [
      wespAdmListItem(
        "conflicts.total",
        `<span class="wesp-adm-list-value">${escHtml(String(s.total ?? "—"))}</span>`,
      ),
      wespAdmListItem(
        "pending",
        `<span class="wesp-adm-list-value">${escHtml(String(s.pending ?? "—"))}</span>`,
      ),
    ];
    const body = rec.length
      ? rec.map((x) =>
          wespAdmListItem(
            `${x.table_name || "—"} · ${String(x.record_id || "").slice(0, 8)}…`,
            `<span class="wesp-adm-list-value">${escHtml(String(x.conflict_type || "—"))}</span>`,
            `${String(x.resolution || "—")} · ${x.created_at ? formatRuDateTimeFromIso(x.created_at) : "—"}`,
          ),
        )
      : [wespAdmListEmpty("recent: ∅")];
    cf.innerHTML = head.join("") + body.join("");
  }
  const cons = document.getElementById("syncDiagConsistencyList");
  if (cons) {
    const c = diag.consistency || {};
    if (c.error) {
      cons.innerHTML = wespAdmListItem(
        "error",
        `<span class="wesp-adm-list-error">${escHtml(c.error)}</span>`,
      );
    } else {
      const snap = c.snapshot || {};
      const mt = snap.master_tables || {};
      const rows = Object.keys(mt)
        .sort()
        .map((t) => {
          const z = mt[t] || {};
          return wespAdmListItem(
            `digest.${t}`,
            `<span class="wesp-adm-list-value">${escHtml(String(z.digest || "").slice(0, 16))}…</span>`,
            `count=${String(z.count ?? "—")}`,
          );
        });
      const ir = snap.recipe_uuid_reference_issues;
      if (ir != null) {
        rows.push(
          wespAdmListItem(
            "recipe_uuid_reference_issues",
            `<span class="wesp-adm-list-value">${escHtml(String(ir))}</span>`,
          ),
        );
      }
      cons.innerHTML = rows.length ? rows.join("") : wespAdmListEmpty("snapshot: ∅");
    }
  }
  const cx = document.getElementById("syncDiagClientsExtraList");
  if (cx) {
    const cl = diag.clients || {};
    const deliv = cl.recent_deliveries || [];
    const lines = [
      wespAdmListSection("recent deliveries"),
      ...(deliv.length
        ? deliv.slice(0, 12).map((d) =>
            wespAdmListItem(
              `${String(d.task_id || "").slice(0, 8)}…`,
              `<span class="wesp-adm-list-value">${escHtml(String(d.client_id || "").slice(0, 8))}…</span>`,
              d.delivered_at ? formatRuDateTimeFromIso(d.delivered_at) : "—",
            ),
          )
        : [wespAdmListEmpty("deliveries: ∅")]),
    ];
    cx.innerHTML = lines.join("");
  }
  const cap = diag.actions_capabilities || {};
  const bRe = document.getElementById("syncActionRequeueStuck");
  const bRes = document.getElementById("syncActionRestartLocal");
  const bRt = document.getElementById("syncActionRefreshRuntime");
  const bPu = document.getElementById("syncActionRetryPush");
  if (bRe) bRe.disabled = !cap.requeue_stuck;
  if (bRes) bRes.disabled = !cap.restart_local_sync;
  if (bRt) bRt.disabled = !cap.refresh_diagnostics;
  if (bPu) bPu.disabled = !cap.retry_report_push;
}

async function loadSyncDiagnostics() {
  const r = await fetch("/api/admin/sync-diagnostics");
  const data = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(data.message || `sync-diagnostics ${r.status}`);
  applySyncSectionFromDiagnostics(data);
  renderSyncDiagnosticsFull(data);
  await loadSyncClientsTable();
}

function splitServerUrlForForm(urlStr) {
  const u = String(urlStr || "").trim();
  let scheme = "https";
  let host = "";
  if (!u) return { scheme, host };
  const m = u.match(/^(https?):\/\/(.*)$/i);
  if (m) {
    scheme = m[1].toLowerCase() === "http" ? "http" : "https";
    host = (m[2] || "").replace(/\/+$/, "");
  } else {
    host = u.replace(/^\/+/, "").replace(/\/+$/, "");
  }
  return { scheme, host };
}

function normalizeSyncHostInput(raw) {
  let h = String(raw || "").trim();
  if (!h) return "";
  h = h.replace(/^https?:\/\//i, "").replace(/\/+$/, "");
  return h;
}

function buildServerUrlFromForm() {
  const schemeSel = document.getElementById("syncSettingsServerScheme");
  const hostInp = document.getElementById("syncSettingsServerHost");
  const scheme = schemeSel && schemeSel.value === "http" ? "http" : "https";
  const host = normalizeSyncHostInput(hostInp && hostInp.value);
  if (!host) return "";
  return `${scheme}://${host}`;
}

function renderSyncQueueErrorsFromSummary(sync) {
  const list = document.getElementById("syncErrorsList");
  if (!list || !sync) return;
  const rows = sync.queue_errors || [];
  if (!rows.length) {
    list.innerHTML = wespAdmListEmpty("sync errors: ∅");
    return;
  }
  list.innerHTML = rows
    .map((r) => {
      const at = r.at ? formatRuDateTimeFromIso(r.at) : "—";
      const errPlain = r.error || "";
      const err = escHtml(errPlain);
      const title = [r.table_name || "—", r.record_id || "—"].filter(Boolean).join(" · ");
      const desc = `${at} · op=${r.action || "—"}`;
      return wespAdmListItem(
        title,
        `<span class="wesp-sync-error-cell wesp-adm-list-error" title="${err}">${err}</span>`,
        desc,
      );
    })
    .join("");
}

function renderSyncRuntimeNotices(conn) {
  const wrap = document.getElementById("syncRuntimeNotices");
  if (!wrap) return;
  const c = conn && typeof conn === "object" ? conn : {};
  const blocks = [];
  if (c.role_locked_by_env) {
    const v =
      c.env_role != null && String(c.env_role).trim()
        ? escHtml(String(c.env_role).trim())
        : escHtml(String(c.role || ""));
    blocks.push(
      `<div class="ant-alert ant-alert-warning" style="margin-bottom:8px" role="alert"><strong>WESP_SYNC_ROLE</strong> задан в окружении: роль из формы не сохраняется. Сейчас действует: <code>${v}</code>. Уберите переменную из окружения (unit/systemd), чтобы менять роль из админки.</div>`,
    );
  }
  if (c.server_url_locked_by_env) {
    const v =
      c.env_server_url != null && String(c.env_server_url).trim()
        ? escHtml(String(c.env_server_url).trim())
        : escHtml(String(c.server_url || ""));
    blocks.push(
      `<div class="ant-alert ant-alert-warning" style="margin-bottom:8px" role="alert"><strong>WESP_SYNC_SERVER_URL</strong> задан в окружении: адрес из формы не сохраняется. Сейчас действует: <code>${v}</code>. Уберите переменную из окружения, чтобы задавать URL из админки.</div>`,
    );
  }
  if (c.sync_client_autostart === false) {
    blocks.push(
      `<div class="ant-alert ant-alert-warning" style="margin-bottom:8px" role="alert"><strong>Фоновый sync выключен</strong> (<code>SYNC_CLIENT_AUTOSTART</code> / <code>WESP_SYNC_CLIENT_AUTOSTART=0</code>). Узел не опрашивает сервер в фоне; запись pull в локальную БД не выполняется. Включите автостарт в конфиге или в окружении и перезапустите службу WESP.</div>`,
    );
  }
  if (String(c.role || "").toLowerCase() === "client" && c.sync_server_url_explicit === false) {
    blocks.push(
      `<div class="ant-alert ant-alert-error" style="margin-bottom:8px" role="alert"><strong>Клиент без адреса главного узла</strong>. Фоновый sync <em>не запускается</em>, пока не задан URL (поле ниже или <code>WESP_SYNC_SERVER_URL</code> в окружении и сохранение). Например <code>http://192.168.0.10</code> или <code>http://komton_srv_1.local</code>.</div>`,
    );
  }
  wrap.innerHTML = blocks.join("");
  wrap.style.display = blocks.length ? "block" : "none";
}

function applySyncConnectionForm(conn) {
  const hint = document.getElementById("syncConnectionHint");
  const roleSel = document.getElementById("syncSettingsRole");
  const schemeSel = document.getElementById("syncSettingsServerScheme");
  const hostInp = document.getElementById("syncSettingsServerHost");
  const saveBtn = document.getElementById("syncSettingsSave");
  if (!roleSel || !schemeSel || !hostInp) return;
  const c = conn && typeof conn === "object" ? conn : {};
  const r = String(c.role || "server").toLowerCase();
  roleSel.value = ["server", "client", "other"].includes(r) ? r : "server";
  const parts = splitServerUrlForForm(c.server_url || "");
  schemeSel.value = parts.scheme === "http" ? "http" : "https";
  hostInp.value = parts.host;
  const lockParts = [];
  if (c.role_locked_by_env) lockParts.push("role ← env WESP_SYNC_ROLE");
  if (c.server_url_locked_by_env) lockParts.push("server_url ← env WESP_SYNC_SERVER_URL");
  const autostartOff = c.sync_client_autostart === false;
  if (hint) {
    if (lockParts.length) {
      hint.textContent = `read-only: ${lockParts.join("; ")}. Поля client_id / client_name / first_bootstrap_done можно сохранить в файл состояния. Подробности — в жёлтых блоках выше.`;
    } else if (autostartOff) {
      hint.textContent =
        "Сохранение записывает sync_client_state.json; фоновый опрос сервера отключён — см. предупреждение выше.";
    } else {
      hint.textContent =
        "Настройка синхронизации.";
    }
  }
  roleSel.disabled = Boolean(c.role_locked_by_env);
  schemeSel.disabled = Boolean(c.server_url_locked_by_env);
  hostInp.disabled = Boolean(c.server_url_locked_by_env);
  if (saveBtn) {
    saveBtn.disabled = false;
  }
  renderSyncRuntimeNotices(c);
}

function applySyncStateForm(state) {
  const idInp = document.getElementById("syncSettingsClientId");
  const nameInp = document.getElementById("syncSettingsClientName");
  const bootChk = document.getElementById("syncSettingsFirstBootstrapDone");
  if (!idInp || !nameInp || !bootChk) return;
  const s = state && typeof state === "object" ? state : {};
  idInp.value = s.client_id != null ? String(s.client_id) : "";
  nameInp.value = s.client_name != null ? String(s.client_name) : "";
  bootChk.checked = Boolean(s.first_bootstrap_done);
}

function formatRuEtaSeconds(sec) {
  if (sec == null || Number.isNaN(Number(sec))) return "—";
  const s = Math.max(0, Math.ceil(Number(sec)));
  if (s < 60) return `${s} с`;
  if (s < 3600) return `${Math.floor(s / 60)} мин ${s % 60} с`;
  const h = Math.floor(s / 3600);
  const m = Math.floor((s % 3600) / 60);
  return `${h} ч ${m} мин`;
}

function renderInitialSyncProgress(sync) {
  const wrap = document.getElementById("syncInitialSyncProgressWrap");
  const fill = document.getElementById("syncInitialSyncProgressFill");
  const txt = document.getElementById("syncInitialSyncProgressText");
  if (!wrap || !fill || !txt) return;
  const p = (sync && sync.initial_sync_progress) || {};
  const show = Boolean(sync && sync.first_sync_banner && p.active);
  wrap.style.display = show ? "block" : "none";
  if (!show) return;
  const pctRaw = p.percent != null ? Number(p.percent) : null;
  const pct = pctRaw != null && !Number.isNaN(pctRaw) ? Math.min(100, Math.max(0, pctRaw)) : null;
  fill.style.width = pct != null ? `${pct}%` : "0%";
  const parts = [];
  if (pct != null) parts.push(`${pct.toFixed(1)}%`);
  if (p.tasks_applied != null && p.tasks_total_estimate != null) {
    parts.push(`применено локально: ${p.tasks_applied} / ${p.tasks_total_estimate} (оценка по серверу)`);
  } else if (p.tasks_applied != null) {
    parts.push(`применено локально: ${p.tasks_applied}`);
  }
  if (p.tasks_per_sec != null && Number(p.tasks_per_sec) > 0) {
    parts.push(`~${Number(p.tasks_per_sec).toFixed(1)} задач/с`);
  }
  if (p.eta_sec != null && Number(p.eta_sec) >= 0) {
    parts.push(`осталось ~${formatRuEtaSeconds(p.eta_sec)}`);
  } else {
    parts.push("осталось: оценка…");
  }
  if (p.elapsed_sec != null) parts.push(`прошло ${formatRuEtaSeconds(p.elapsed_sec)}`);
  txt.textContent = parts.join(" · ");
}

function renderSyncSectionFromSummary(sync) {
  const banner = document.getElementById("syncInitialSyncBanner");
  if (banner) {
    banner.style.display = sync && sync.first_sync_banner ? "block" : "none";
  }
  renderInitialSyncProgress(sync || {});
  renderSyncQueueFromSummary(sync);
  renderSyncQueueErrorsFromSummary(sync);
  applySyncConnectionForm(sync?.connection || {});
  applySyncStateForm(sync?.state || {});
}

function diagOkBadge(ok) {
  return ok
    ? '<span class="wesp-diag-ok">OK</span>'
    : '<span class="wesp-diag-bad">Ошибка</span>';
}

function makeDiagTargetRow(target = {}) {
  const role = escHtml(target.role || "");
  const host = escHtml(target.host || "");
  const title = escHtml(target.title || "");
  return `<div class="wesp-diagnostics-target-row" data-diag-target-row>
    <input type="text" class="ant-input" data-diag-role value="${role}" placeholder="role tag" />
    <input type="text" class="ant-input" data-diag-host value="${host}" placeholder="FQDN / IP" />
    <div class="wesp-adm-list-actions">
      <input type="text" class="ant-input" data-diag-title value="${title}" placeholder="label" />
      <button type="button" class="ant-btn ant-btn-sm ant-btn-danger" data-diag-remove-target title="Удалить">×</button>
    </div>
  </div>`;
}

function renderDiagExtraTargetsRows() {
  const wrap = document.getElementById("diagExtraTargetsRows");
  if (!wrap) return;
  const rows = Array.isArray(diagState.extraTargets) ? diagState.extraTargets : [];
  if (!rows.length) {
    wrap.innerHTML = makeDiagTargetRow({});
    return;
  }
  wrap.innerHTML = rows.map((x) => makeDiagTargetRow(x)).join("");
}

function collectDiagExtraTargetsFromForm() {
  const wrap = document.getElementById("diagExtraTargetsRows");
  if (!wrap) return [];
  const out = [];
  wrap.querySelectorAll("[data-diag-target-row]").forEach((row) => {
    const role = row.querySelector("[data-diag-role]")?.value?.trim() || "";
    const host = row.querySelector("[data-diag-host]")?.value?.trim() || "";
    const title = row.querySelector("[data-diag-title]")?.value?.trim() || "";
    if (!host) return;
    out.push({ role, host, title: title || role || host });
  });
  return out;
}

function setDiagnosticsSecurityListLoading() {
  const sec = document.getElementById("diagSecurityList");
  if (!sec) return;
  sec.innerHTML = wespAdmListEmpty("загрузка security_checks…");
}

function renderDiagnosticsSecurity(report) {
  const sec = document.getElementById("diagSecurityList");
  if (!sec) return;
  const rpt = report && typeof report === "object" ? report : {};
  const secChecks = Array.isArray(rpt.security_checks) ? rpt.security_checks : [];
  if (!secChecks.length) {
    const errMsg = rpt.message || rpt.error;
    sec.innerHTML = wespAdmListEmpty(
      errMsg
        ? `security_checks: нет (${String(errMsg).slice(0, 200)})`
        : "security_checks: нет — откройте раздел «Диагностика» заново или нажмите «Обновить отчёт»",
    );
    return;
  }
  sec.innerHTML = secChecks
    .map((x) =>
      wespAdmListItem(
        x.title || x.id || "check",
        diagOkBadge(Boolean(x.ok)),
        x.detail || "",
      ),
    )
    .join("");
}

function renderDiagnosticsOverview(report) {
  const overview = document.getElementById("diagOverviewList");
  const nodeHint = document.getElementById("diagNodeHint");
  const primary = document.getElementById("diagPrimaryTargetsList");
  const hw = document.getElementById("diagHardwareList");
  const path = document.getElementById("diagSettingsPath");
  if (!overview || !primary || !hw) return;
  const rpt = report && typeof report === "object" ? report : {};
  const hwData = rpt.hardware || {};
  if (nodeHint) nodeHint.textContent = rpt.node_hint || "";
  if (path) path.textContent = rpt.paths?.network_settings_path ? `path: ${rpt.paths.network_settings_path}` : "";
  const roleRu = ruSyncRoleLabel(rpt.node_role);
  overview.innerHTML = [
    wespAdmListSection("LAN / node"),
    wespAdmListItem("node_role", `<span class="wesp-adm-list-value">${escHtml(roleRu)}</span>`),
    wespAdmListItem(
      "primary_targets.n",
      `<span class="wesp-adm-list-value">${escHtml(String((rpt.primary_targets || []).length))}</span>`,
    ),
    wespAdmListItem(
      "extra_targets.n",
      `<span class="wesp-adm-list-value">${escHtml(String((rpt.extra_targets || []).length))}</span>`,
    ),
    wespAdmListSection("Security"),
    ...buildDiagSecurityOverviewRows(rpt),
    wespAdmListSection("Hardware"),
    ...buildDiagHardwareOverviewRows(hwData),
  ].join("");
  const warnings = Array.isArray(rpt.warnings) ? rpt.warnings : [];
  const targets = Array.isArray(rpt.primary_targets) ? rpt.primary_targets : [];
  primary.innerHTML = "";
  if (targets.length === 0) {
    primary.innerHTML += wespAdmListEmpty("primary_targets: ∅");
  } else {
    primary.innerHTML += targets
      .map((t) =>
        wespAdmListItem(
          t.title || t.host || "—",
          `<span class="wesp-adm-list-value">${escHtml(t.host || "—")}</span>`,
          `kind: ${t.kind || "target"} · node_id: ${t.meta?.node_id || "—"} · last_seen: ${t.meta?.last_seen ? formatRuDateTimeFromIso(t.meta.last_seen) : "—"}`,
        ),
      )
      .join("");
  }
  if (warnings.length) {
    primary.innerHTML += warnings
      .map((w) =>
        wespAdmListItem("warning", '<span class="wesp-diag-bad">!</span>', String(w || "")),
      )
      .join("");
  }
  const sim = Boolean(hwData.simulation_mode);
  const gpioOk = Boolean(hwData.rpi_gpio_available);
  const hxOk = Boolean(hwData.hx711_driver_ready);
  const driversAbsent = Boolean(hwData.drivers_absent);
  const modeLine = sim
    ? "SIM=1: SW ADC/GPIO"
    : driversAbsent
      ? "SIM=0; RPi.GPIO+HX711 ∅ (x86?). mass=0; dev WESP_SIMULATION_MODE=1."
      : "SIM=0; target: BCM SoC + peripherals";
  const gpioLine = sim
    ? "N/A (sim)"
    : gpioOk
      ? "RPi.GPIO: LED out, BCM"
      : "RPi.GPIO: fail (not Pi / pkg)";
  const hxLine = sim
    ? "N/A (sim)"
    : hxOk
      ? "HX711: init OK"
      : "HX711: fail; mass N/A";
  const badgeSim = '<span class="wesp-sync-hint" style="font-size:11px">sim</span>';
  const gpioBadge = sim ? badgeSim : diagOkBadge(gpioOk);
  const hxBadge = sim ? badgeSim : diagOkBadge(hxOk);
  hw.innerHTML = [
    wespAdmListItem("MODE", `<span class="wesp-adm-list-value">${escHtml(modeLine)}</span>`),
    wespAdmListItem(
      "GPIO LED",
      `${gpioBadge}<span class="wesp-adm-list-value" style="margin-left:8px">${escHtml(gpioLine)}</span>`,
    ),
    wespAdmListItem(
      "HX711",
      `${hxBadge}<span class="wesp-adm-list-value" style="margin-left:8px">${escHtml(hxLine)}</span>`,
    ),
    wespAdmListItem(
      "BCM pins",
      `<span class="wesp-adm-list-value">LED ${escHtml(String(hwData.gpio_led_pin ?? "—"))} · DOUT ${escHtml(String(hwData.hx711_dout_pin ?? "—"))} / SCK ${escHtml(String(hwData.hx711_pd_sck_pin ?? "—"))}</span>`,
    ),
  ].join("");
}

function renderDiagnosticsNetworkResults(payload) {
  const list = document.getElementById("diagNetworkResultsList");
  if (!list) return;
  const body = payload && typeof payload === "object" ? payload : {};
  const rows = Array.isArray(body.results) ? body.results : [];
  const internet = Array.isArray(body.internet) ? body.internet : [];
  const networkRows = rows.map((r) => {
    const ping = r.probes?.ping?.ok ? "ping OK" : "ping fail";
    const tcpProbe = r.probes?.tcp ?? r.probes?.tcp80;
    const tcpPort = tcpProbe?.port ?? wespListenPortFallback();
    const tcp = tcpProbe?.ok ? `tcp:${tcpPort} OK` : `tcp:${tcpPort} fail`;
    const trace = r.probes?.trace
      ? r.probes.trace.ok
        ? `trace OK (${r.probes.trace.tool || "tool"})`
        : `trace fail (${r.probes.trace.tool || "tool"})`
      : "trace: off";
    return wespAdmListItem(
      r.title || r.host || "—",
      diagOkBadge(Boolean(r.ok)),
      `${r.host || "—"} · ${ping} · ${tcp} · ${trace}`,
    );
  });
  const internetRows = internet.map((it) =>
    wespAdmListItem(
      `Internet: ${it.url || "—"}`,
      diagOkBadge(Boolean(it.ok)),
      it.status_code != null ? `HTTP ${it.status_code}` : String(it.error || "no response"),
    ),
  );
  const allRows = [];
  if (networkRows.length) {
    allRows.push(wespAdmListSection("LAN / node"));
    allRows.push(...networkRows);
  }
  if (internetRows.length) {
    allRows.push(wespAdmListSection("WAN / egress"));
    allRows.push(...internetRows);
  }
  list.innerHTML = allRows.length ? allRows.join("") : wespAdmListEmpty("results: ∅");
}

async function loadDiagnosticsReport() {
  setDiagnosticsSecurityListLoading();
  const r = await fetch("/api/admin/diagnostics/report", { credentials: "same-origin" });
  const d = await r.json().catch(() => ({}));
  if (!r.ok) {
    renderDiagnosticsSecurity({ message: d.message || `HTTP ${r.status}` });
    throw new Error(d.message || `Ошибка ${r.status}`);
  }
  diagState.report = d;
  diagState.extraTargets = Array.isArray(d.extra_targets) ? d.extra_targets : [];
  try {
    renderDiagnosticsSecurity(d);
  } catch (e) {
    renderDiagnosticsSecurity({ message: e?.message || "render security" });
    throw e;
  }
  try {
    renderDiagExtraTargetsRows();
  } catch (e) {
    setStatus(e?.message, true, "extra_targets render");
  }
  try {
    renderDiagnosticsOverview(d);
  } catch (e) {
    setStatus(e?.message, true, "overview render");
  }
  try {
    renderDiagnosticsNetworkResults({});
  } catch (e) {
    setStatus(e?.message, true, "network results render");
  }
}

async function saveDiagnosticsSettings() {
  const extraTargets = collectDiagExtraTargetsFromForm();
  const r = await fetch("/api/admin/diagnostics/settings", {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ extra_targets: extraTargets }),
  });
  const d = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
  diagState.extraTargets = Array.isArray(d.extra_targets) ? d.extra_targets : extraTargets;
  renderDiagExtraTargetsRows();
  setStatus(d.message || "persist OK");
}

async function runDiagnosticsNetwork(scope, runTrace = false, customTargets = null) {
  const body = {
    scope,
    run_trace: runTrace,
    timeout_sec: 3,
    prefer_mtr: true,
    tcp_port: wespListenPortFallback(),
  };
  if (scope === "custom" && Array.isArray(customTargets)) {
    body.custom_targets = customTargets;
  }
  const r = await fetch("/api/admin/diagnostics/network-run", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const d = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
  renderDiagnosticsNetworkResults(d);
  setStatus("network-run: OK");
}

async function runDiagnosticsHxSample(listId = "diagHardwareList") {
  const list = document.getElementById(listId);
  const r = await fetch("/api/admin/diagnostics/hx711-sample", { method: "POST" });
  const d = await r.json().catch(() => ({}));
  if (list) {
    const prev = list.innerHTML;
    if (!r.ok) {
      const row = wespAdmListItem(
        "HX711 sample",
        diagOkBadge(false),
        `${escHtml(d.message || `Ошибка ${r.status}`)} · DOUT ${escHtml(String(d.pins?.dout ?? "—"))} · SCK ${escHtml(String(d.pins?.pd_sck ?? "—"))}`,
      );
      list.innerHTML = `${row}${prev}`;
      setStatus(d.message || `Ошибка ${r.status}`, true);
      return;
    }
    const row = wespAdmListItem(
      "HX711 sample",
      diagOkBadge(true),
      `sample=${d.sample} · DOUT ${d.pins?.dout ?? "—"} · SCK ${d.pins?.pd_sck ?? "—"} · driver: ${d.simulation_mode ? "sim" : d.driver_ready ? "ready" : "—"}`,
    );
    list.innerHTML = `${row}${prev}`;
  }
  if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
  setStatus("HX711 sample: OK");
}

async function runDiagnosticsGpioBlink(listId = "diagHardwareList") {
  const list = document.getElementById(listId);
  const r = await fetch("/api/admin/diagnostics/gpio-blink", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ times: 3, delay: 0.2 }),
  });
  const d = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
  if (list) {
    const prev = list.innerHTML;
    const gpioMode = d.simulation_mode
      ? "sim"
      : d.rpi_gpio_available
        ? "RPi.GPIO hw"
        : "RPi.GPIO ∅ (soft-only)";
    const row = wespAdmListItem(
      "GPIO blink",
      diagOkBadge(true),
      `${d.message || "started"} · pin ${d.pin ?? "—"} · ${gpioMode}`,
    );
    list.innerHTML = `${row}${prev}`;
  }
  setStatus("GPIO blink: dispatched");
}

function openTracerouteModal() {
  const m = document.getElementById("tracerouteModal");
  if (!m) return;
  m.hidden = false;
  document.body.style.overflow = "hidden";
}

function closeTracerouteModal() {
  const m = document.getElementById("tracerouteModal");
  if (m) m.hidden = true;
  document.body.style.overflow = "";
}

function fillTracerouteHostsFromPrimary() {
  const ta = document.getElementById("tracerouteHostsInput");
  if (!ta) return;
  const rpt = diagState.report;
  const targets = Array.isArray(rpt?.primary_targets) ? rpt.primary_targets : [];
  const hosts = [...new Set(targets.map((t) => (t.host || "").trim()).filter(Boolean))];
  ta.value = hosts.join("\n");
  if (!hosts.length) {
    setStatus("primary_targets: ∅; ввод вручную или отчёт диагностики", true);
  }
}

function renderTracerouteResults(data) {
  const wrap = document.getElementById("tracerouteModalResults");
  if (!wrap) return;
  const results = Array.isArray(data.results) ? data.results : [];
  if (!results.length) {
    wrap.innerHTML = '<p class="wesp-sync-hint">results: ∅</p>';
    return;
  }
  let html = "";
  for (const row of results) {
    const host = escHtml(row.host);
    if (row.validate_error) {
      html += `<div class="wesp-admin-traceroute-target"><strong>${host}</strong> — <span style="color:#cf1322">${escHtml(row.validate_error)}</span></div>`;
      continue;
    }
    const tr = row.trace || {};
    const okLabel = tr.ok ? "OK" : "fail";
    const tool = escHtml(tr.tool || "—");
    const pingLbl = row.ping ? (row.ping.ok ? "ping OK" : "ping fail") : null;
    const tcpP = row.tcp?.port ?? wespListenPortFallback();
    const tcpLbl = row.tcp ? (row.tcp.ok ? `tcp:${tcpP} OK` : `tcp:${tcpP} fail`) : null;
    const probeBits = [pingLbl, tcpLbl].filter(Boolean).join(" · ");
    let body = `<div class="wesp-admin-traceroute-target"><div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:6px;"><strong>${host}</strong>`;
    if (probeBits) {
      body += `<span style="font-size:11px;opacity:0.9;">${escHtml(probeBits)}</span>`;
    }
    body += `<span style="font-size:11px;padding:2px 6px;border-radius:4px;opacity:0.85;border:1px solid currentColor;">${tool}</span><span style="font-size:11px;">${okLabel}</span>`;
    if (tr.exit_code != null && tr.exit_code !== "") {
      body += `<span style="font-size:11px;">exit ${escHtml(tr.exit_code)}</span>`;
    }
    if (tr.error) body += `<span style="color:#cf1322;font-size:11px;">${escHtml(tr.error)}</span>`;
    body += "</div>";
    const pr = row.parse;
    if (pr && pr.hops && pr.hops.length) {
      const st = pr.stats || {};
      body += `<p class="wesp-sync-hint" style="margin:0 0 6px;">hops=${escHtml(st.total_hops ?? pr.hops.length)} · private=${escHtml(st.private_count ?? "—")} · max_rtt_ms=${escHtml(st.max_delay_ms ?? "—")}</p>`;
      body +=
        '<table style="width:100%;border-collapse:collapse;font-size:11px;"><thead><tr><th style="text-align:left;padding:4px;border-bottom:1px solid rgba(0,0,0,.12);">ttl</th><th style="text-align:left;padding:4px;border-bottom:1px solid rgba(0,0,0,.12);">addr</th><th style="text-align:left;padding:4px;border-bottom:1px solid rgba(0,0,0,.12);">net</th><th style="text-align:left;padding:4px;border-bottom:1px solid rgba(0,0,0,.12);">avg_ms</th></tr></thead><tbody>';
      for (const h of pr.hops) {
        const nt =
          h.network_type === "private" ? "RFC1918" : h.network_type === "public" ? "WAN" : "?";
        const avg = h.avg_ms != null ? escHtml(h.avg_ms) : "—";
        body += `<tr><td style="padding:4px;">${escHtml(h.num)}</td><td style="padding:4px;font-family:ui-monospace,monospace;">${escHtml(h.ip)}</td><td style="padding:4px;">${nt}</td><td style="padding:4px;">${avg}</td></tr>`;
      }
      body += "</tbody></table>";
    } else if (tr.lines && tr.lines.length) {
      body += `<pre class="wesp-admin-traceroute-pre">${escHtml(tr.lines.join("\n"))}</pre>`;
    } else {
      body += '<p class="wesp-sync-hint">stdout: ∅</p>';
    }
    body += "</div>";
    html += body;
  }
  wrap.innerHTML = html;
}

async function runTracerouteByHostsModal() {
  const ta = document.getElementById("tracerouteHostsInput");
  const wrap = document.getElementById("tracerouteModalResults");
  if (!ta || !wrap) return;
  const text = ta.value.trim();
  if (!text) {
    setStatus("hosts: требуется ≥1 строка FQDN/IP", true);
    return;
  }
  wrap.innerHTML = '<p class="wesp-sync-hint">traceroute: running…</p>';
  let r;
  let d = {};
  try {
    r = await fetch("/api/admin/diagnostics/traceroute-by-hosts", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      credentials: "same-origin",
      body: JSON.stringify({ hosts: text, timeout_sec: 12, prefer_mtr: true, tcp_port: wespListenPortFallback() }),
    });
    d = await r.json().catch(() => ({}));
  } catch (e) {
    wrap.innerHTML = `<p class="wesp-sync-hint" style="color:#cf1322">${escHtml(userHint(e.message, "Не удалось загрузить данные сети"))}</p>`;
    return;
  }
  if (!r.ok || d.status !== "success") {
    wrap.innerHTML = `<p class="wesp-sync-hint" style="color:#cf1322">${escHtml(d.message || `Ошибка ${r.status}`)}</p>`;
    return;
  }
  renderTracerouteResults(d);
  setStatus("traceroute: OK");
}

function openTracerouteModalFromDiagnostics() {
  openTracerouteModal();
  const wrap = document.getElementById("tracerouteModalResults");
  if (wrap) wrap.innerHTML = "";
  fillTracerouteHostsFromPrimary();
}

async function loadSyncClientsTable() {
  const list = document.getElementById("syncClientsList");
  if (!list) return;
  let d;
  try {
    const r = await fetch("/api/sync/clients");
    d = await r.json().catch(() => ({}));
    if (!r.ok) {
      throw new Error(d.message || `Ошибка ${r.status}`);
    }
  } catch (e) {
    list.innerHTML = wespAdmListEmpty(userHint(e.message, "Не удалось загрузить список"));
    return;
  }
  if (!Array.isArray(d) || d.length === 0) {
    list.innerHTML = wespAdmListEmpty("SyncClient[]: ∅");
    return;
  }
  list.innerHTML = d
    .map((c) => {
      const title =
        (c.display_name && String(c.display_name).trim()) || c.client_name || "—";
      const node = escHtml(c.node_id || "—");
      const ip = escHtml(c.ip_address || "—");
      const seen = c.last_seen ? formatRuDateTimeFromIso(c.last_seen) : "—";
      const st = ruSyncClientStatus(c.status);
      const nsync = String(c.total_syncs ?? "0");
      const nid = String(c.node_id || "").replace(/"/g, "&quot;");
      const errPlain = c.sync_error && String(c.sync_error).trim();
      const descParts = [
        `node_id=${c.node_id || "—"}`,
        `ip=${c.ip_address || "—"}`,
        `last_seen=${seen}`,
        `status=${st}`,
        `sync_count=${nsync}`,
      ];
      if (errPlain) descParts.push(`err=${errPlain}`);
      const desc = descParts.join(" · ");
      const extra = `<div class="wesp-adm-list-actions wesp-sync-client-actions">
        <button type="button" class="ant-btn ant-btn-sm" data-sync-client-action="set-ip"><span>IP</span></button>
        <button type="button" class="ant-btn ant-btn-sm" data-sync-client-action="rename"><span>Имя</span></button>
        <button type="button" class="ant-btn ant-btn-sm ant-btn-danger" data-sync-client-action="delete"><span>Удалить</span></button>
      </div>`;
      return wespAdmListItem(title, extra, desc, { dataAttrs: `data-node-id="${nid}"` });
    })
    .join("");
}

async function saveSyncSettingsFromForm() {
  const roleSel = document.getElementById("syncSettingsRole");
  const hostInp = document.getElementById("syncSettingsServerHost");
  const cidInp = document.getElementById("syncSettingsClientId");
  const nameInp = document.getElementById("syncSettingsClientName");
  const bootChk = document.getElementById("syncSettingsFirstBootstrapDone");
  if (!roleSel || !hostInp) return;
  const body = {};
  if (!roleSel.disabled) body.role = roleSel.value;
  if (!hostInp.disabled) body.server_url = buildServerUrlFromForm();
  if (cidInp) body.client_id = cidInp.value.trim();
  if (nameInp) body.client_name = nameInp.value.trim();
  if (bootChk) body.first_bootstrap_done = bootChk.checked;
  if (Object.keys(body).length === 0) {
    setStatus("sync form: нечего сохранить", true);
    return;
  }
  const r = await fetch("/api/admin/sync-settings", {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const d = await r.json().catch(() => ({}));
  if (!r.ok) {
    throw new Error(d.message || `Ошибка ${r.status}`);
  }
  setStatus(d.message || "persist OK");
  if (d.sync_reloaded) {
    setStatus(d.message || "Сохранено. Синхронизация перезапущена.");
  }
  if (d.state) {
    applySyncStateForm(d.state);
  }
  await loadSyncDiagnostics();
}

async function loadOrchestratorSyncSettings() {
  const r = await fetch("/api/admin/orchestrator-sync");
  const d = await r.json().catch(() => ({}));
  if (!r.ok) return;
  const s = d.orchestrator_sync || {};
  const urlEl = document.getElementById("orchSyncUpstreamUrl");
  const hubEl = document.getElementById("orchSyncHubSiteId");
  if (urlEl) urlEl.value = s.upstream_url || "";
  if (hubEl) hubEl.value = s.hub_site_id || "";
}

async function saveOrchestratorSyncFromForm() {
  const body = {
    upstream_url: document.getElementById("orchSyncUpstreamUrl")?.value?.trim() || "",
    hub_site_id: document.getElementById("orchSyncHubSiteId")?.value?.trim() || "",
  };
  const key = document.getElementById("orchSyncApiKey")?.value?.trim();
  if (key) body.api_key = key;
  const r = await fetch("/api/admin/orchestrator-sync", {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const d = await r.json().catch(() => ({}));
  const msg = document.getElementById("orchSyncMessage");
  if (!r.ok) {
    if (msg) msg.textContent = d.message || `Ошибка ${r.status}`;
    throw new Error(d.message || `Ошибка ${r.status}`);
  }
  if (msg) msg.textContent = d.message || "Сохранено";
  const keyEl = document.getElementById("orchSyncApiKey");
  if (keyEl) keyEl.value = "";
  await loadOrchestratorSyncSettings();
}

async function syncClientsTableAction(event) {
  const btn = event.target.closest("[data-sync-client-action]");
  if (!btn) return;
  const row = event.target.closest("li[data-node-id]");
  if (!row || !row.dataset.nodeId) return;
  const nodeId = row.dataset.nodeId;
  const action = btn.dataset.syncClientAction;
  if (action === "set-ip") {
    const curDesc = row.querySelector(".wesp-adm-list-desc")?.textContent || "";
    const curMatch = /IP:\s*([^·]+)/.exec(curDesc);
    const cur = curMatch ? String(curMatch[1]).trim() : "";
    const next = window.prompt(
      "IP или хост клиента (для трассировки). Пусто — очистить:",
      cur === "—" ? "" : cur,
    );
    if (next === null) return;
    const r = await fetch(`/api/admin/sync-clients/${encodeURIComponent(nodeId)}/ip`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ ip_address: next.trim() }),
    });
    const d = await r.json().catch(() => ({}));
    if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
    setStatus("SyncClient.ip: OK");
    await loadSyncClientsTable();
    return;
  }
  if (action === "rename") {
    const cur = row.querySelector(".wesp-adm-list-title")?.textContent?.trim() || "";
    const name = window.prompt("Отображаемое имя клиента:", cur);
    if (name === null) return;
    const r = await fetch(`/api/sync/clients/${encodeURIComponent(nodeId)}/display_name`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ display_name: name.trim() }),
    });
    const d = await r.json().catch(() => ({}));
    if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
    setStatus("SyncClient.name: OK");
    await loadSyncClientsTable();
    return;
  }
  if (action === "delete") {
    if (!window.confirm(`Удалить клиента ${nodeId} из реестра?`)) return;
    const r = await fetch(`/api/sync/clients/${encodeURIComponent(nodeId)}`, { method: "DELETE" });
    const d = await r.json().catch(() => ({}));
    if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
    setStatus("SyncClient: deleted");
    await loadSyncClientsTable();
  }
}

async function createUser(event) {
  event.preventDefault();
  const login = document.getElementById("newUserLogin")?.value?.trim();
  const password = document.getElementById("newUserPassword")?.value || "";
  const isSuperuser = Boolean(document.getElementById("newUserIsSuper")?.checked);

  const response = await fetch("/api/admin/users", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ login, password, is_superuser: isSuperuser }),
  });
  const data = await response.json();
  if (!response.ok) {
    throw new Error(data.message || "Не удалось создать пользователя");
  }

  event.target.reset();
  setStatus("WebUser: created");
  await loadUsers();
  await updateSummary();
}

async function usersTableAction(event) {
  const actionEl = event.target.closest("[data-action]");
  if (actionEl) {
    const row = event.target.closest("li[data-user-id]");
    if (!row) return;
    const userId = row.dataset.userId;
    const action = actionEl.dataset.action;

    if (action === "set-password") {
      const password = window.prompt("Новый пароль (минимум 8 символов, буквы и цифры):", "");
      if (!password) return;
      const response = await fetch(`/api/admin/users/${userId}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ password }),
      });
      const data = await response.json();
      if (!response.ok) throw new Error(data.message || "Не удалось обновить пароль");
      setStatus("password_hash: OK");
      return;
    }

    if (action === "toggle-super") {
      const targetSuper = row.dataset.isSuper !== "1";
      const response = await fetch(`/api/admin/users/${userId}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ is_superuser: targetSuper }),
      });
      const data = await response.json();
      if (!response.ok) throw new Error(data.message || "Не удалось изменить роль");
      setStatus("is_superuser: OK");
      await loadUsers();
      await updateSummary();
      return;
    }

    if (action === "delete-user") {
      const ok = window.confirm("Удалить пользователя?");
      if (!ok) return;
      const response = await fetch(`/api/admin/users/${userId}`, { method: "DELETE" });
      const data = await response.json();
      if (!response.ok) throw new Error(data.message || "Не удалось удалить пользователя");
      setStatus("WebUser: deleted");
      await loadUsers();
      await updateSummary();
    }
    return;
  }

  const main = event.target.closest(".wesp-adm-list-main");
  const row = event.target.closest("li[data-user-id]");
  if (!main || !row || event.target.closest(".wesp-adm-list-actions")) return;
  openUserModulesModal(row);
}

function renderServerIps(addresses) {
  const el = document.getElementById("serverIpList");
  if (!el) return;
  if (!addresses || addresses.length === 0) {
    el.textContent = "—";
    return;
  }
  el.innerHTML = addresses
    .map(
      (a) =>
        `<div class="wesp-ip-line"><span class="wesp-ip-if">${escHtml(a.interface)}</span> <span class="wesp-ip-fam">${escHtml(a.family)}</span> <code class="wesp-ip-addr">${escHtml(a.address)}</code></div>`,
    )
    .join("");
  applyServerIpMask();
}

function applyServerIpMask() {
  const el = document.getElementById("serverIpList");
  const btn = document.getElementById("btnToggleServerIp");
  if (!el) return;
  const hide = localStorage.getItem("wespAdminMaskIp") === "1";
  el.classList.toggle("wesp-ip-masked", hide);
  if (btn) {
    btn.setAttribute("aria-pressed", hide ? "true" : "false");
    btn.classList.toggle("is-ip-hidden", hide);
    btn.title = hide ? "Показать адреса" : "Скрыть адреса";
  }
}

/** При каждом открытии админки адреса по умолчанию скрыты (до явного «показать» в этой сессии). */
function resetServerIpMaskOnPageOpen() {
  try {
    localStorage.setItem("wespAdminMaskIp", "1");
  } catch (e) {
    /* ignore */
  }
  applyServerIpMask();
}

function openAdminModal(title, text) {
  const m = document.getElementById("adminModal");
  const t = document.getElementById("adminModalTitle");
  const b = document.getElementById("adminModalBody");
  if (!m || !t || !b) return;
  t.textContent = title;
  b.textContent = text;
  m.hidden = false;
  document.body.style.overflow = "hidden";
}

function closeAdminModal() {
  const m = document.getElementById("adminModal");
  if (m) m.hidden = true;
  document.body.style.overflow = "";
}

async function fetchAndShowServerLog(modalTitle) {
  const r = await fetch("/api/admin/server-log?lines=300");
  const d = await r.json();
  if (d.status !== "success") {
    openAdminModal(
      modalTitle,
      `${d.message || "Не удалось прочитать лог"}\n\n(Переменная WESP_ADMIN_LOG_PATH на сервере.)`,
    );
    return;
  }
  const body = (d.lines && d.lines.length ? d.lines.join("\n") : "(файл пуст)") + `\n\n— ${d.path}`;
  openAdminModal(`${modalTitle}: ${d.path}`, body);
}

async function fetchUploadedClientLogsIndex() {
  const r = await fetch("/api/admin/client-uploaded-logs", { credentials: "same-origin" });
  const d = await r.json();
  if (d.status !== "success") {
    throw new Error(d.message || `HTTP ${r.status}`);
  }
  return d;
}

/**
 * Диалог выбора клиента (и при необходимости файла) для логов из POST /api/sync/client-log.
 * @returns {Promise<{ apiClientId: string, fileName: string|null }|null>}
 */
async function promptPickUploadedLogClient(options) {
  const needExactFile = Boolean(options && options.needExactFile);
  const d = await fetchUploadedClientLogsIndex();
  const clients = d.clients || [];
  if (!clients.length) {
    openAdminModal(
      "Логи с клиентов",
      "Пока нет выгруженных файлов.\n\nНа узле: sync_client.upload_client_log_file(...) или POST /api/sync/client-log.",
    );
    return null;
  }
  const lines = clients.map((c, i) => {
    const label = c.display_name || c.client_name || c.node_id || c.dir_segment;
    const n = c.file_count != null ? c.file_count : (c.files || []).length;
    return `${i + 1}) ${label} — ${n} файл(ов)`;
  });
  const ans = window.prompt(`Выберите клиента (номер от 1 до ${clients.length}):\n\n${lines.join("\n")}`);
  if (ans === null) return null;
  const num = parseInt(String(ans).trim(), 10);
  if (!Number.isFinite(num) || num < 1 || num > clients.length) {
    window.alert("Некорректный номер.");
    return null;
  }
  const chosen = clients[num - 1];
  const apiClientId = chosen.api_client_id;
  const files = chosen.files || [];

  let fileName = null;
  if (needExactFile) {
    if (!files.length) {
      window.alert("У этого клиента нет файлов логов.");
      return null;
    }
    if (files.length === 1) {
      fileName = files[0].name;
    } else {
      const fl = files.map((f, i) => `${i + 1}) ${f.name} (${f.size} B)`).join("\n");
      const fa = window.prompt(`Файл для скачивания (1–${files.length}):\n\n${fl}`);
      if (fa === null) return null;
      const fi = parseInt(String(fa).trim(), 10);
      if (!Number.isFinite(fi) || fi < 1 || fi > files.length) {
        window.alert("Некорректный номер файла.");
        return null;
      }
      fileName = files[fi - 1].name;
    }
  } else if (files.length > 1) {
    const fl = files.map((f, i) => `${i + 1}) ${f.name} (${f.size} B)`).join("\n");
    const fa = window.prompt(
      `Какой файл показать (1–${files.length})? Пусто + OK — самый свежий.\n\n${fl}`,
      "",
    );
    if (fa === null) return null;
    const trimmed = String(fa).trim();
    if (trimmed) {
      const fi = parseInt(trimmed, 10);
      if (!Number.isFinite(fi) || fi < 1 || fi > files.length) {
        window.alert("Некорректный номер файла.");
        return null;
      }
      fileName = files[fi - 1].name;
    }
  }

  return { apiClientId, fileName };
}

async function fetchAndShowClientUploadedLog() {
  const picked = await promptPickUploadedLogClient({ needExactFile: false });
  if (!picked) return;
  const q = new URLSearchParams({ client_id: picked.apiClientId, lines: "300" });
  if (picked.fileName) q.set("file", picked.fileName);
  const r = await fetch(`/api/admin/client-uploaded-logs/tail?${q}`, { credentials: "same-origin" });
  const d = await r.json();
  if (d.status !== "success") {
    openAdminModal(
      "Лог клиента",
      `${d.message || "Не удалось прочитать лог"}\n\n(client_id: ${picked.apiClientId})`,
    );
    return;
  }
  const body =
    (d.lines && d.lines.length ? d.lines.join("\n") : "(файл пуст)") + `\n\n— ${d.path || ""}`;
  openAdminModal(`Лог клиента: ${d.file || ""}`, body);
}

async function downloadClientUploadedLogFile() {
  const picked = await promptPickUploadedLogClient({ needExactFile: true });
  if (!picked || !picked.fileName) return;
  const q = new URLSearchParams({ client_id: picked.apiClientId, file: picked.fileName });
  const r = await fetch(`/api/admin/client-uploaded-logs/download?${q}`, { credentials: "same-origin" });
  if (!r.ok) {
    let msg = "client log download failed";
    try {
      const j = await r.json();
      if (j.message) msg = j.message;
    } catch (e) {
      /* ignore */
    }
    throw new Error(msg);
  }
  const cd = r.headers.get("Content-Disposition") || "";
  let name = picked.fileName;
  const mStar = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(cd);
  const mQuot = /filename\s*=\s*"([^"]+)"/i.exec(cd);
  const mPlain = /filename\s*=\s*([^;\s]+)/i.exec(cd);
  if (mStar) name = decodeURIComponent(mStar[1].trim());
  else if (mQuot) name = mQuot[1].trim();
  else if (mPlain) name = mPlain[1].trim();

  const blob = await r.blob();
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = name;
  a.click();
  URL.revokeObjectURL(url);
  setStatus("client log: downloaded");
}

async function downloadServerLogFile() {
  const r = await fetch("/api/admin/server-log/download", { credentials: "same-origin" });
  if (!r.ok) {
    let msg = "log download failed";
    try {
      const j = await r.json();
      if (j.message) msg = j.message;
    } catch (e) {
      /* ignore */
    }
    throw new Error(msg);
  }
  const cd = r.headers.get("Content-Disposition") || "";
  let name = "wesp.log";
  const mStar = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(cd);
  const mQuot = /filename\s*=\s*"([^"]+)"/i.exec(cd);
  const mPlain = /filename\s*=\s*([^;\s]+)/i.exec(cd);
  if (mStar) name = decodeURIComponent(mStar[1].trim());
  else if (mQuot) name = mQuot[1].trim();
  else if (mPlain) name = mPlain[1].trim();

  const blob = await r.blob();
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = name;
  a.click();
  URL.revokeObjectURL(url);
  setStatus("log file: downloaded");
}

const KIOSK_FULL_SETUP_QUIPS = [
  "Настраиваем wesp.env и systemd…",
  "Патчим labwc — рабочий стол отдыхает…",
  "Готовим Chromium к первому взвешиванию…",
  "Собираем пиксели логотипа в боевой порядок…",
  "Синхронизируем киоск с тёмной темой /starting…",
  "Проверяем: server и client — не один Pi",
  "Чайник почти вскипел, осталось чуть-чуть",
];

let kioskFullSetupLoaderMounted = false;
let kioskFullSetupQuipTimer = null;

function setKioskFullSetupQuip(text, isError = false) {
  const quipEl = document.getElementById("kioskFullSetupQuip");
  if (!quipEl) return;
  const next = String(text || "").trim();
  if (!next) return;
  quipEl.classList.add("wesp-startup__quip--fade");
  quipEl.classList.toggle("wesp-startup__quip--error", Boolean(isError));
  window.setTimeout(() => {
    quipEl.textContent = next;
    quipEl.classList.remove("wesp-startup__quip--fade");
  }, 200);
}

function stopKioskFullSetupQuipRotation() {
  if (kioskFullSetupQuipTimer) {
    clearInterval(kioskFullSetupQuipTimer);
    kioskFullSetupQuipTimer = null;
  }
}

function startKioskFullSetupQuipRotation() {
  stopKioskFullSetupQuipRotation();
  let i = 0;
  kioskFullSetupQuipTimer = window.setInterval(() => {
    setKioskFullSetupQuip(KIOSK_FULL_SETUP_QUIPS[i % KIOSK_FULL_SETUP_QUIPS.length]);
    i += 1;
  }, 1400);
}

async function showKioskFullSetupOverlay() {
  const overlay = document.getElementById("kioskFullSetupOverlay");
  const introEl = document.getElementById("kioskFullSetupIntro");
  if (!overlay || !introEl) return;
  overlay.hidden = false;
  overlay.setAttribute("aria-hidden", "false");
  document.body.style.overflow = "hidden";
  if (!kioskFullSetupLoaderMounted) {
    introEl.innerHTML = "";
    await mountWespLogoLoader(introEl, {
      loop: true,
      durationMs: 6000,
      embedded: true,
      transparentBackground: true,
      knockoutBackground: true,
      darkTheme: true,
      hideChrome: false,
    });
    kioskFullSetupLoaderMounted = true;
  }
  setKioskFullSetupQuip(KIOSK_FULL_SETUP_QUIPS[0]);
  startKioskFullSetupQuipRotation();
}

function hideKioskFullSetupOverlay() {
  const overlay = document.getElementById("kioskFullSetupOverlay");
  if (!overlay) return;
  overlay.hidden = true;
  overlay.setAttribute("aria-hidden", "true");
  document.body.style.overflow = "";
  stopKioskFullSetupQuipRotation();
}

async function refreshKioskFullSetupHint() {
  const hint = document.getElementById("kioskFullSetupHint");
  const btn = document.getElementById("btnKioskFullSetup");
  if (!hint) return;
  try {
    const r = await fetch("/api/admin/kiosk-full-setup/status");
    const d = await r.json().catch(() => ({}));
    if (!r.ok) {
      hint.textContent = d.message || "Не удалось прочитать статус";
      hint.classList.add("wesp-sync-hint--error");
      return;
    }
    const parts = [];
    if (!d.running_as_root) parts.push("WESP не от root");
    else if (d.full_setup_ready) parts.push("полностью настроен");
    else {
      if (!d.platform_ready) parts.push("нужен env/systemd");
      if (!d.kiosk_enabled || d.kiosk_mode !== "system_labwc") parts.push("нужен киоск");
    }
    if (!d.chromium_found) parts.push("chromium не найден");
    if (d.gui_user) parts.push(`user: ${d.gui_user}`);
    hint.textContent = parts.length ? parts.join(" · ") : (d.message || "—");
    hint.classList.toggle("wesp-sync-hint--ok", Boolean(d.full_setup_ready));
    hint.classList.toggle("wesp-sync-hint--error", !d.running_as_root || !d.chromium_found);
    if (btn) btn.disabled = !d.running_as_root || !d.chromium_found;
  } catch (error) {
    hint.textContent = userHint(error.message, "Ошибка статуса");
    hint.classList.add("wesp-sync-hint--error");
  }
}

async function applyFullKioskSetup() {
  const hideCb = document.getElementById("kioskBootHideDesktop");
  const hideDesktop = hideCb ? Boolean(hideCb.checked) : true;
  const ok = window.confirm(
    "Настроить киоск Pi целиком?\n\n" +
      "• /etc/wesp/wesp.env и myscript.service\n" +
      "• киоск Chromium" +
      (hideDesktop ? " без рабочего стола" : "") +
      "\n• перезагрузка устройства\n\n" +
      "Покажется анимация загрузки (как /starting). Нужен root.",
  );
  if (!ok) return;

  const btn = document.getElementById("btnKioskFullSetup");
  if (btn) btn.disabled = true;
  await showKioskFullSetupOverlay();
  setKioskFullSetupQuip("Записываем настройки…");

  try {
    const r = await fetch("/api/admin/kiosk-full-setup", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ hide_desktop: hideDesktop }),
    });
    const d = await r.json().catch(() => ({}));
    if (!r.ok) {
      throw new Error(d.message || `Ошибка ${r.status}`);
    }
    stopKioskFullSetupQuipRotation();
    setStatus(d.message || "Настройка киоска завершена");
    if (d.reboot_scheduled) {
      const sec = Math.max(5, Number(d.reboot_delay_sec) || 10);
      let left = sec;
      setKioskFullSetupQuip(`Готово — перезагрузка через ${left} с…`);
      const countdown = window.setInterval(() => {
        left -= 1;
        if (left <= 0) {
          clearInterval(countdown);
          setKioskFullSetupQuip("Перезагружаем устройство…");
          return;
        }
        setKioskFullSetupQuip(`Готово — перезагрузка через ${left} с…`);
      }, 1000);
    } else {
      setKioskFullSetupQuip("Готово — перезагрузите устройство вручную");
      window.setTimeout(() => hideKioskFullSetupOverlay(), 4000);
    }
    await refreshKioskFullSetupHint();
    await refreshPiPlatformSetupHint();
    await refreshKioskBootStatusHint();
  } catch (error) {
    stopKioskFullSetupQuipRotation();
    setKioskFullSetupQuip(error.message || "Ошибка настройки", true);
    setStatus(error.message, true, "Ошибка полной настройки киоска");
    window.setTimeout(() => hideKioskFullSetupOverlay(), 5000);
    await refreshKioskFullSetupHint();
  } finally {
    if (btn) btn.disabled = false;
  }
}

async function refreshPiPlatformSetupHint() {
  const hint = document.getElementById("piPlatformSetupHint");
  const btn = document.getElementById("btnPiPlatformApply");
  if (!hint) return;
  try {
    const r = await fetch("/api/admin/pi-platform/status");
    const d = await r.json().catch(() => ({}));
    if (!r.ok) {
      hint.textContent = d.message || "Не удалось прочитать статус";
      hint.classList.add("wesp-sync-hint--error");
      return;
    }
    const parts = [];
    if (!d.running_as_root) parts.push("WESP не от root — применить нельзя");
    if (d.env_path) parts.push(d.env_path);
    if (d.systemd_unit) parts.push(`unit: ${d.systemd_unit}`);
    if (d.env_keys_missing?.length) {
      parts.push(`нет ключей: ${d.env_keys_missing.join(", ")}`);
    }
    if (d.platform_ready) parts.push("готово");
    else parts.push("нужно применить");
    hint.textContent = parts.join(" · ");
    hint.classList.toggle("wesp-sync-hint--ok", Boolean(d.platform_ready));
    hint.classList.toggle("wesp-sync-hint--error", !d.running_as_root);
    if (btn) btn.disabled = !d.running_as_root;
  } catch (error) {
    hint.textContent = userHint(error.message, "Ошибка статуса");
    hint.classList.add("wesp-sync-hint--error");
  }
}

async function applyPiPlatformSetup() {
  const ok = window.confirm(
    "Применить настройки Pi?\n\n" +
      "• /etc/wesp/wesp.env (systemd, OTA, reboot, киоск)\n" +
      "• systemd unit (myscript)\n" +
      "• перезапуск службы WESP через ~2 с\n\n" +
      "Нужны права root.",
  );
  if (!ok) return;
  const btn = document.getElementById("btnPiPlatformApply");
  if (btn) btn.disabled = true;
  try {
    const r = await fetch("/api/admin/pi-platform/apply", { method: "POST" });
    const d = await r.json().catch(() => ({}));
    if (!r.ok) {
      throw new Error(d.message || `Ошибка ${r.status}`);
    }
    setStatus(d.message || "Настройка Pi применена");
    window.alert(d.message || "Готово");
    await refreshPiPlatformSetupHint();
  } catch (error) {
    setStatus(error.message, true, "Ошибка настройки Pi");
    await refreshPiPlatformSetupHint();
  } finally {
    if (btn) btn.disabled = false;
  }
}

async function refreshPiRainbowSplashHint() {
  const hint = document.getElementById("piRainbowSplashHint");
  const btnOff = document.getElementById("btnPiRainbowSplashDisable");
  const btnOn = document.getElementById("btnPiRainbowSplashEnable");
  if (!hint) return;
  try {
    const r = await fetch("/api/admin/pi-boot/rainbow-splash/status");
    const d = await r.json().catch(() => ({}));
    if (!r.ok) {
      hint.textContent = d.message || "Не удалось прочитать статус";
      hint.classList.add("wesp-sync-hint--error");
      return;
    }
    const parts = [];
    if (!d.config_found) parts.push("config.txt не найден");
    else parts.push(d.config_path || "config.txt");
    parts.push(d.disable_splash ? "disable_splash=1 (выкл.)" : "splash включён");
    if (d.message) parts.push(d.message);
    hint.textContent = parts.join(" · ");
    hint.classList.toggle("wesp-sync-hint--ok", Boolean(d.disable_splash));
    hint.classList.toggle("wesp-sync-hint--error", !d.config_found);
    if (btnOff) btnOff.disabled = Boolean(d.disable_splash) || !d.config_found;
    if (btnOn) btnOn.disabled = !d.disable_splash || !d.config_found;
  } catch (error) {
    hint.textContent = userHint(error.message, "Ошибка статуса");
    hint.classList.add("wesp-sync-hint--error");
  }
}

async function setPiRainbowSplash(enabled) {
  const label = enabled
    ? "Отключить радужный splash прошивки (disable_splash=1)?"
    : "Вернуть стандартный splash прошивки (убрать disable_splash)?";
  const extra = enabled
    ? "\n\nПосле записи устройство перезагрузится автоматически (если включено в wesp.env)."
    : "\n\nПосле изменения устройство перезагрузится автоматически (если включено).";
  if (!window.confirm(label + extra)) return;

  const btnOff = document.getElementById("btnPiRainbowSplashDisable");
  const btnOn = document.getElementById("btnPiRainbowSplashEnable");
  if (btnOff) btnOff.disabled = true;
  if (btnOn) btnOn.disabled = true;
  try {
    const r = await fetch("/api/admin/pi-boot/rainbow-splash", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ enabled }),
    });
    const d = await r.json().catch(() => ({}));
    if (!r.ok) {
      throw new Error(d.message || `Ошибка ${r.status}`);
    }
    setStatus(d.message || "Готово");
    if (d.reboot_scheduled) {
      window.alert(d.message || "Запланирована перезагрузка устройства.");
    }
    await refreshPiRainbowSplashHint();
  } catch (error) {
    setStatus(error.message, true, "Ошибка config.txt");
    await refreshPiRainbowSplashHint();
  }
}

let plymouthStatusPoll = null;
let plymouthJobTracked = false;
let plymouthLastNotifiedFinishAt = null;

function plymouthInstallFailed(job) {
  if (job.phase === "error") return true;
  const install = job.result?.install;
  return Boolean(install && install.ok === false);
}

function buildPlymouthResultMessage(job, statusPayload) {
  if (job.phase === "error") {
    return String(job.error || job.message || "Ошибка установки Plymouth").trim();
  }
  const install = job.result?.install;
  if (install && install.ok === false) {
    return String(install.message || "Установка темы Plymouth не удалась").trim();
  }
  const render = job.result?.render;
  const frames = render?.frames ?? statusPayload?.built_frames ?? statusPayload?.installed_frames;
  const base = String(job.message || install?.message || "Тема Plymouth WESP установлена").trim();
  if (frames) return `${base} (${frames} кадров). Перезагрузите устройство.`;
  return `${base} Перезагрузите устройство.`;
}

function notifyPlymouthJobFinished(job, statusPayload) {
  const finishedAt = job.finished_at;
  if (!plymouthJobTracked || !finishedAt || finishedAt === plymouthLastNotifiedFinishAt) return;
  if (job.phase !== "done" && job.phase !== "error") return;

  plymouthLastNotifiedFinishAt = finishedAt;
  plymouthJobTracked = false;

  const failed = plymouthInstallFailed(job);
  const message = buildPlymouthResultMessage(job, statusPayload);
  setStatus(message, failed);

  if (!failed) {
    const reboot = job.result?.reboot;
    const rebootNote = reboot?.scheduled
      ? `\n\n${reboot.message || "Перезагрузка запланирована."}`
      : "\n\nПерезагрузите устройство, чтобы увидеть заставку при загрузке.";
    window.alert("Тема Plymouth WESP установлена." + rebootNote);
  }
}

async function refreshPlymouthStatusHint() {
  const hint = document.getElementById("plymouthStatusHint");
  const btn = document.getElementById("btnPlymouthInstall");
  if (!hint && !btn) return false;
  try {
    const r = await fetch("/api/admin/plymouth/status");
    const d = await r.json().catch(() => ({}));
    if (!r.ok) return false;
    const job = d.job || {};
    const parts = [];
    if (!d.pillow_available) parts.push("нет Pillow");
    if (!d.plymouth_installed) parts.push("нет plymouth");
    if (d.active_theme) parts.push(`тема: ${d.active_theme}`);
    if (d.installed_frames) parts.push(`установлено ${d.installed_frames} кадр.`);
    else if (d.built_frames) parts.push(`собрано ${d.built_frames}`);
    if (job.running) {
      const prog = job.total ? `${job.progress || 0}/${job.total}` : "";
      parts.push([job.message || job.phase, prog].filter(Boolean).join(" "));
    } else if (job.phase === "error") {
      parts.push(String(job.error || job.message || "Ошибка").slice(0, 200));
    } else if (job.phase === "done") {
      parts.push(String(buildPlymouthResultMessage(job, d)).slice(0, 200));
    }

    const failed = !job.running && plymouthInstallFailed(job);
    const succeeded = !job.running && job.phase === "done" && !failed;
    hint.textContent = parts.length ? parts.join(" · ") : "Нажмите «Установить» для сборки темы (800×600).";
    hint.classList.toggle("wesp-sync-hint--error", failed);
    hint.classList.toggle("wesp-sync-hint--ok", succeeded);

    if (btn) btn.disabled = Boolean(job.running);

    if (job.running) {
      plymouthJobTracked = true;
    } else {
      notifyPlymouthJobFinished(job, d);
    }

    return Boolean(job.running);
  } catch {
    return false;
  }
}

function stopPlymouthStatusPoll() {
  if (plymouthStatusPoll) {
    clearInterval(plymouthStatusPoll);
    plymouthStatusPoll = null;
  }
}

function startPlymouthStatusPoll() {
  stopPlymouthStatusPoll();
  plymouthStatusPoll = window.setInterval(async () => {
    const running = await refreshPlymouthStatusHint();
    if (!running) stopPlymouthStatusPoll();
  }, 1200);
}

async function installPlymouthTheme() {
  const ok = window.confirm(
    "Собрать анимацию WESP (800×600) и установить тему Plymouth?\n\n" +
      "Нужны: Pillow (из requirements), plymouth, sudo для install_wesp_theme.sh " +
      "(или WESP_PLYMOUTH_INSTALL_CMD).\n\nПосле установки перезагрузите устройство."
  );
  if (!ok) return;
  const btn = document.getElementById("btnPlymouthInstall");
  if (btn) btn.disabled = true;
  try {
    const r = await fetch("/api/admin/plymouth/install", { method: "POST" });
    const d = await r.json().catch(() => ({}));
    if (!r.ok) {
      throw new Error(d.message || `Ошибка ${r.status}`);
    }
    plymouthJobTracked = true;
    plymouthLastNotifiedFinishAt = null;
    setStatus(d.message || "Установка Plymouth запущена");
    startPlymouthStatusPoll();
    await refreshPlymouthStatusHint();
  } catch (error) {
    setStatus(error.message, true, "Plymouth: ошибка");
    if (btn) btn.disabled = false;
  }
}

async function refreshKioskBootStatusHint() {
  const hint = document.getElementById("kioskBootStatusHint");
  const btnOn = document.getElementById("btnKioskBootEnable");
  const btnOff = document.getElementById("btnKioskBootDisable");
  const hideCb = document.getElementById("kioskBootHideDesktop");
  if (!hint) return;
  try {
    const r = await fetch("/api/admin/kiosk-boot/status");
    const d = await r.json().catch(() => ({}));
    if (!r.ok) {
      hint.textContent = d.message || "Не удалось прочитать статус киоска";
      hint.classList.add("wesp-sync-hint--error");
      return;
    }
    const parts = [];
    parts.push(d.kiosk_enabled ? "включён" : "выключен");
    if (d.gui_user) parts.push(`user: ${d.gui_user}`);
    if (d.active_url) parts.push(d.active_url);
    if (!d.chromium_found) parts.push("chromium не найден");
    if (d.kiosk_mode === "system_labwc") parts.push("патч /etc/xdg/labwc/autostart");
    else if (d.kiosk_mode === "user_labwc_legacy") parts.push("устаревший ~/.config/labwc");
    else if (d.kiosk_mode === "xdg") parts.push("режим XDG");
    if (d.system_labwc_patched) parts.push("system labwc OK");
    if (d.home_dir) parts.push(`home: ${d.home_dir}`);
    if (d.message) parts.push(d.message);
    hint.textContent = parts.join(" · ");
    hint.classList.toggle("wesp-sync-hint--ok", Boolean(d.kiosk_enabled));
    hint.classList.toggle("wesp-sync-hint--error", !d.chromium_found || !d.home_exists);
    if (hideCb && typeof d.hide_desktop === "boolean") hideCb.checked = d.hide_desktop;
    if (btnOn) btnOn.disabled = Boolean(d.kiosk_enabled) || !d.chromium_found;
    if (btnOff) btnOff.disabled = !d.kiosk_enabled;
  } catch (error) {
    hint.textContent = userHint(error.message, "Ошибка статуса киоска");
    hint.classList.add("wesp-sync-hint--error");
  }
}

async function setKioskBoot(enabled) {
  const hideCb = document.getElementById("kioskBootHideDesktop");
  const hideDesktop = hideCb ? Boolean(hideCb.checked) : false;
  const label = enabled
    ? "Включить автозапуск Chromium в kiosk?"
    : "Выключить киоск и удалить autostart?";
  const extra = enabled
    ? (hideDesktop
        ? "\n\nБудут скрыты панель/рабочий стол (если компоненты есть в системе)."
        : "") + "\n\nПосле сохранения — автоперезагрузка Pi (если включена в wesp.env)."
    : "\n\nПосле отключения — автоперезагрузка Pi (если включена).";
  if (!window.confirm(label + extra)) return;

  const btnOn = document.getElementById("btnKioskBootEnable");
  const btnOff = document.getElementById("btnKioskBootDisable");
  if (btnOn) btnOn.disabled = true;
  if (btnOff) btnOff.disabled = true;
  try {
    const body = { enabled };
    if (enabled) body.hide_desktop = hideDesktop;
    const r = await fetch("/api/admin/kiosk-boot", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    const d = await r.json().catch(() => ({}));
    if (!r.ok) {
      throw new Error(d.message || `Ошибка ${r.status}`);
    }
    setStatus(d.message || "Готово");
    if (d.reboot_scheduled) {
      window.alert(d.message || "Запланирована перезагрузка устройства.");
    } else if (enabled && !hideDesktop) {
      window.alert(
        "Киоск включён (XDG).\n\n" +
          "Включите «Без рабочего стола» и снова «Включить киоск», затем reboot.",
      );
    }
    await refreshKioskBootStatusHint();
  } catch (error) {
    setStatus(error.message, true, "Ошибка киоска");
    await refreshKioskBootStatusHint();
  } finally {
    if (btnOn) btnOn.disabled = false;
    if (btnOff) btnOff.disabled = false;
  }
}

async function postServiceControl(action) {
  const label = action === "stop" ? "остановку службы" : "перезапуск службы";
  if (!window.confirm(`Подтвердите ${label}. Сессия может оборваться.`)) return;
  const r = await fetch("/api/admin/service-control", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ action }),
  });
  let d = {};
  try {
    d = await r.json();
  } catch (e) {
    /* ignore */
  }
  if (r.ok && d.ok) {
    setStatus(d.message || "Команда запущена");
  } else {
    setStatus(d.message, true, "Операция не выполнена");
  }
}

async function downloadSqliteBackup() {
  const r = await fetch("/api/admin/backup.sqlite");
  if (!r.ok) {
    let msg = "Ошибка резервной копии";
    try {
      const j = await r.json();
      if (j.message) msg = j.message;
    } catch (e) {
      /* ignore */
    }
    throw new Error(msg);
  }
  const cd = r.headers.get("Content-Disposition") || "";
  let name = "wesp-backup.zip";
  const mStar = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(cd);
  const mQuot = /filename\s*=\s*"([^"]+)"/i.exec(cd);
  const mPlain = /filename\s*=\s*([^;\s]+)/i.exec(cd);
  if (mStar) name = decodeURIComponent(mStar[1].trim());
  else if (mQuot) name = mQuot[1].trim();
  else if (mPlain) name = mPlain[1].trim();

  const blob = await r.blob();
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = name;
  a.click();
  URL.revokeObjectURL(url);
  setStatus("SQLite ZIP: downloaded");
}

function wireAntCheckbox(input) {
  if (!input) return;
  const wrapper = input.closest(".ant-checkbox-wrapper");
  const box = wrapper?.querySelector(".ant-checkbox");
  if (!box) return;

  const apply = () => {
    box.classList.toggle("ant-checkbox-checked", input.checked);
    wrapper?.classList.toggle("ant-checkbox-disabled", input.disabled);
  };

  apply();
  if (input.dataset.antCheckboxWired === "1") return;
  input.dataset.antCheckboxWired = "1";
  input.addEventListener("change", apply);
}

function syncAntCheckboxVisual() {
  wireAntCheckbox(document.getElementById("newUserIsSuper"));
  wireAntCheckbox(document.getElementById("userModuleLabAccess"));
}

async function init() {
  let metricsTimer = null;
  const shellStart = performance.now();
  prepareAdminSkeletonBlocks();
  try {
    const authData = await fetchAuth();
    if (!authData) {
      stripAdminShellDecorations();
      return;
    }

    syncAntCheckboxVisual();
    applyAdminDocumentTheme();
    initAdminSidebar();
    hydrateAssistantChatMessagesFromStorage();
    await hydrateAdminActivityLog();
    wireAdminDbBrowserModal();
    document.getElementById("adminDbRefreshTreeBtn")?.addEventListener("click", () => {
      refreshAdminDbTree(setStatus);
    });
    document.getElementById("adminDbRefreshGridBtn")?.addEventListener("click", () => {
      refreshAdminDbGrid(setStatus);
    });
    initDashboardStatNav();
    resetServerIpMaskOnPageOpen();

    document.querySelectorAll("[data-section]").forEach((btn) => {
      btn.addEventListener("click", () => activateSection(btn.dataset.section));
    });
    {
      const { from, to } = defaultAssistantDateRange();
      const df = document.getElementById("wespAssistantDateFrom");
      const dt = document.getElementById("wespAssistantDateTo");
      if (df && !df.value) df.value = formatDateInputYmd(from);
      if (dt && !dt.value) dt.value = formatDateInputYmd(to);
    }
    document.getElementById("wespAssistantRefreshStatusBtn")?.addEventListener("click", () => {
      loadAdminAssistantStatus().catch((e) => setStatus(e.message, true, "status fail"));
    });
    document.getElementById("wespAssistantRefreshLogBtn")?.addEventListener("click", () => {
      loadAdminAssistantLlmLog().catch((e) => setStatus(e.message, true, "журнал ИИ"));
    });
    document.getElementById("wespAssistantAdminLlmEnabled")?.addEventListener("change", async (e) => {
      const el = e.target;
      if (!(el instanceof HTMLInputElement)) return;
      const checked = el.checked;
      const prev = !checked;
      try {
        const r = await fetch("/api/admin/security-settings", {
          method: "PATCH",
          credentials: "same-origin",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ admin_llm_enabled: checked }),
        });
        const data = await r.json().catch(() => ({}));
        if (!r.ok) {
          setStatus(data.message || String(r.status), true);
          el.checked = prev;
          return;
        }
        setStatus(checked ? "ИИ включён" : "ИИ выключен");
        loadAdminAssistantStatus().catch(() => {});
        loadAdminAssistantLlmLog().catch(() => {});
      } catch (err) {
        setStatus(err?.message, true, "save fail");
        el.checked = prev;
      }
    });
    document.getElementById("wespAssistantLlmAutostart")?.addEventListener("change", async (e) => {
      const el = e.target;
      if (!(el instanceof HTMLInputElement)) return;
      const checked = el.checked;
      const prev = !checked;
      try {
        const r = await fetch("/api/admin/security-settings", {
          method: "PATCH",
          credentials: "same-origin",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ llm_autostart: checked }),
        });
        const data = await r.json().catch(() => ({}));
        if (!r.ok) {
          setStatus(data.message || String(r.status), true);
          el.checked = prev;
          return;
        }
        setStatus("Автозапуск llama-server сохранён в конфиг.");
        loadAdminAssistantStatus().catch(() => {});
        loadAdminAssistantLlmLog().catch(() => {});
      } catch (err) {
        setStatus(err?.message, true, "save fail");
        el.checked = prev;
      }
    });
    document.getElementById("wespAssistantChatDbContext")?.addEventListener("change", async (e) => {
      const el = e.target;
      if (!(el instanceof HTMLInputElement)) return;
      const checked = el.checked;
      const prev = !checked;
      try {
        const r = await fetch("/api/admin/security-settings", {
          method: "PATCH",
          credentials: "same-origin",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ llm_chat_db_context: checked }),
        });
        const data = await r.json().catch(() => ({}));
        if (!r.ok) {
          setStatus(data.message || String(r.status), true);
          el.checked = prev;
          return;
        }
        setStatus("Режим чата с контекстом БД сохранён.");
        loadAdminAssistantStatus().catch(() => {});
      } catch (err) {
        setStatus(err?.message, true, "save fail");
        el.checked = prev;
      }
    });
    document.getElementById("wespAssistantLlmToolsEnabled")?.addEventListener("change", async (e) => {
      const el = e.target;
      if (!(el instanceof HTMLInputElement)) return;
      const checked = el.checked;
      const prev = !checked;
      try {
        const r = await fetch("/api/admin/security-settings", {
          method: "PATCH",
          credentials: "same-origin",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ llm_tools_enabled: checked }),
        });
        const data = await r.json().catch(() => ({}));
        if (!r.ok) {
          setStatus(data.message || String(r.status), true);
          el.checked = prev;
          return;
        }
        setStatus("Инструменты чата (slim-tools) сохранены в конфиг.");
        loadAdminAssistantStatus().catch(() => {});
      } catch (err) {
        setStatus(err?.message, true, "save fail");
        el.checked = prev;
      }
    });
    document.getElementById("wespAssistantChatSendBtn")?.addEventListener("click", () => {
      runAdminAssistantChatSend().catch((e) => setStatus(e.message, true, "chat fail"));
    });
    document.getElementById("wespAssistantChatClearBtn")?.addEventListener("click", () => {
      window.__wespAssistantChatMessages = [];
      try {
        sessionStorage.removeItem(WESP_ASSISTANT_CHAT_STORAGE_KEY);
      } catch {
        /* ignore */
      }
      renderAssistantChatLog();
      setStatus("Диалог очищен");
    });
    document.getElementById("wespAssistantPingBtn")?.addEventListener("click", () => {
      runAdminAssistantPing().catch((e) => setStatus(e.message, true, "ping fail"));
    });
    document.getElementById("wespAssistantSummaryBtn")?.addEventListener("click", () => {
      runAdminAssistantSummary().catch((e) => setStatus(e.message, true, "summary fail"));
    });
    document.getElementById("wespAssistantDiagnosticsBtn")?.addEventListener("click", () => {
      runAdminAssistantDiagnostics().catch((e) => setStatus(e.message, true, "diagnostics fail"));
    });
    document.getElementById("wespAssistantChatInput")?.addEventListener("keydown", (e) => {
      if (e.key !== "Enter" || e.shiftKey) return;
      e.preventDefault();
      runAdminAssistantChatSend().catch((err) => setStatus(err.message, true, "chat fail"));
    });
    document.getElementById("btnDashHardwareChart")?.addEventListener("click", () => {
      openHardwareChartFromDashboard();
    });
    document.getElementById("refreshSummaryBtn")?.addEventListener("click", async () => {
      try {
        await Promise.all([updateSummary(), updateSystemMetrics(), loadUsers(), refreshActivityServerCache()]);
        renderAdminActivityTerminalFromMerged();
        setStatus("state: OK");
      } catch (error) {
        setStatus(error.message, true, "Ошибка обновления");
      }
    });
    document.getElementById("orchSyncSave")?.addEventListener("click", async () => {
      try {
        await saveOrchestratorSyncFromForm();
      } catch (error) {
        setStatus(error.message, true, "Orchestrator sync save failed");
      }
    });
    document.getElementById("syncSettingsSave")?.addEventListener("click", async () => {
      const btn = document.getElementById("syncSettingsSave");
      if (btn?.dataset.saving === "1") return;
      if (btn) {
        btn.dataset.saving = "1";
        btn.disabled = true;
      }
      try {
        await saveSyncSettingsFromForm();
      } catch (error) {
        setStatus(error.message, true, "Ошибка сохранения");
      } finally {
        if (btn) {
          btn.dataset.saving = "0";
          btn.disabled = false;
        }
      }
    });
    document.getElementById("syncDiagRefreshBtn")?.addEventListener("click", async () => {
      try {
        await loadSyncDiagnostics();
        setStatus("sync diagnostics: OK");
      } catch (e) {
        setStatus(e.message, true, "diagnostics fail");
      }
    });
    document.getElementById("syncActionRequeueStuck")?.addEventListener("click", async () => {
      if (
        !window.confirm(
          "Переставить зависшие задачи (processing → pending) по порогу SYNC_REQUEUE_TIMEOUT_MINUTES?",
        )
      ) {
        return;
      }
      try {
        const r = await fetch("/api/admin/sync-actions/requeue-stuck", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: "{}",
        });
        const d = await r.json().catch(() => ({}));
        if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
        setStatus(d.message || "requeue OK");
        await loadSyncDiagnostics();
      } catch (e) {
        setStatus(e.message, true, "requeue fail");
      }
    });
    document.getElementById("syncActionRestartLocal")?.addEventListener("click", async () => {
      if (!window.confirm("Перезапустить фоновый sync_client в этом процессе?")) return;
      try {
        const r = await fetch("/api/admin/sync-actions/restart-local-sync", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: "{}",
        });
        const d = await r.json().catch(() => ({}));
        if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
        setStatus(d.message || "restart OK");
        await loadSyncDiagnostics();
      } catch (e) {
        setStatus(e.message, true, "restart fail");
      }
    });
    document.getElementById("syncActionRefreshRuntime")?.addEventListener("click", async () => {
      try {
        const r = await fetch("/api/admin/sync-actions/refresh-runtime", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: "{}",
        });
        const d = await r.json().catch(() => ({}));
        if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
        setStatus(d.message || "runtime OK");
        const msg = document.getElementById("syncDiagActionMessage");
        if (msg && d.runtime) {
          msg.textContent = `runtime: ${JSON.stringify(d.runtime)}`;
        }
      } catch (e) {
        setStatus(e.message, true, "runtime fail");
      }
    });
    document.getElementById("syncActionRetryPush")?.addEventListener("click", async () => {
      if (!window.confirm("Запланировать полный sync_cycle (push отчётов + pull) в фоне?")) return;
      try {
        const r = await fetch("/api/admin/sync-actions/retry-report-push", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: "{}",
        });
        const d = await r.json().catch(() => ({}));
        if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`);
        setStatus(d.message || "scheduled OK");
      } catch (e) {
        setStatus(e.message, true, "push fail");
      }
    });
    document.getElementById("securitySettingsSave")?.addEventListener("click", async () => {
      try {
        await saveSecuritySettingsFromForm();
      } catch (error) {
        setStatus(error.message, true, "Ошибка сохранения");
      }
    });
    document.getElementById("netSettingsSave")?.addEventListener("click", async () => {
      try {
        await saveNetworkSettingsFromForm();
      } catch (error) {
        setStatus(error.message, true, "Ошибка сохранения сети");
      }
    });
    document.getElementById("netSettingsRefreshBtn")?.addEventListener("click", async () => {
      try {
        await loadNetworkSettings();
        await loadNetworkEvents();
        setStatus("Сеть: обновлено");
      } catch (error) {
        setStatus(error.message, true, "Ошибка загрузки сети");
      }
    });
    document.getElementById("netEventsRefreshBtn")?.addEventListener("click", async () => {
      try {
        await loadNetworkEvents();
        setStatus("Журнал сети обновлён");
      } catch (error) {
        setStatus(error.message, true, "Ошибка журнала сети");
      }
    });
    document.getElementById("netEventsClearBtn")?.addEventListener("click", async () => {
      try {
        await clearNetworkEventsLog();
        setStatus("Журнал сети очищен");
      } catch (error) {
        setStatus(error.message, true, "Ошибка очистки журнала сети");
      }
    });
    document.getElementById("netBuildUrlBtn")?.addEventListener("click", () => {
      applyNetworkSuggestedPublicUrl();
    });
    document.getElementById("netMdnsEnabled")?.addEventListener("change", (ev) => {
      const label = document.getElementById("netMdnsStatusLabel");
      if (label && ev.target instanceof HTMLInputElement) {
        label.textContent = ev.target.checked ? "mDNS: вкл." : "mDNS: выкл.";
      }
    });
    wireSecurityToggleAutoSave("secMacLock", "mac_lock_enabled");
    wireSecurityToggleAutoSave("secKioskEnforce", "kiosk_enforce_paired_only");
    wireSecurityToggleAutoSave("secCalibrationPublic", "calibration_public");
    document.getElementById("auSave")?.addEventListener("click", async () => {
      try {
        await saveAutoUpdateFromForm();
      } catch (error) {
        setStatus(error.message, true, "Ошибка сохранения автообновления");
      }
    });
    document.getElementById("auSaveSecrets")?.addEventListener("click", async () => {
      try {
        await saveGiteaSecretsFromForm();
      } catch (error) {
        setStatus(error.message, true, "Ошибка сохранения секретов Gitea");
      }
    });
    document.getElementById("secDeviceTokenClear")?.addEventListener("change", (ev) => {
      const token = document.getElementById("secDeviceToken");
      const box = ev.target;
      if (!token || !box) return;
      const locked = token.dataset.envLocked === "1";
      token.disabled = locked || box.checked;
    });
    document.getElementById("syncClientsList")?.addEventListener("click", async (event) => {
      try {
        await syncClientsTableAction(event);
      } catch (error) {
        setStatus(error.message, true, "Ошибка");
      }
    });
    document.getElementById("createUserForm")?.addEventListener("submit", async (event) => {
      try {
        await createUser(event);
      } catch (error) {
        setStatus(error.message, true, "Ошибка создания пользователя");
      }
    });
    document.getElementById("usersList")?.addEventListener("click", async (event) => {
      try {
        await usersTableAction(event);
      } catch (error) {
        setStatus(error.message, true, "Ошибка операции с пользователем");
      }
    });

    document.getElementById("adminModalClose")?.addEventListener("click", closeAdminModal);
    document.getElementById("adminModalMask")?.addEventListener("click", closeAdminModal);

    document.getElementById("userModulesModalClose")?.addEventListener("click", closeUserModulesModal);
    document.getElementById("userModulesModalMask")?.addEventListener("click", closeUserModulesModal);
    document.getElementById("userModulesModalCancel")?.addEventListener("click", closeUserModulesModal);
    document.getElementById("userModulesModalSave")?.addEventListener("click", async () => {
      try {
        await saveUserModulesModal();
      } catch (error) {
        setStatus(error.message, true, "Ошибка сохранения модулей");
      }
    });

    document.getElementById("btnManageLog")?.addEventListener("click", () => {
      fetchAndShowServerLog("Журнал").catch((e) => setStatus(e.message, true, "Не удалось выполнить операцию."));
    });
    document.getElementById("btnManageLogDownload")?.addEventListener("click", async () => {
      try {
        await downloadServerLogFile();
      } catch (error) {
        setStatus(error.message, true, "log download failed");
      }
    });
    document.getElementById("btnManageClientLogs")?.addEventListener("click", () => {
      fetchAndShowClientUploadedLog().catch((e) => setStatus(e.message, true, "client log failed"));
    });
    document.getElementById("btnManageClientLogsDownload")?.addEventListener("click", async () => {
      try {
        await downloadClientUploadedLogFile();
      } catch (error) {
        setStatus(error.message, true, "client log download failed");
      }
    });
    document.getElementById("btnManageConfig")?.addEventListener("click", () => {
      activateSection("system");
      document.getElementById("wespSectionSystem")?.scrollIntoView({ behavior: "smooth" });
    });
    document.getElementById("btnFactoryResetPreview")?.addEventListener("click", async () => {
      try {
        await loadFactoryResetPreview();
        setStatus("Список для сброса обновлён");
      } catch (error) {
        setStatus(error.message, true, "Ошибка предпросмотра");
      }
    });
    document.getElementById("btnFactoryReset")?.addEventListener("click", async () => {
      try {
        const d = await runFactoryReset();
        if (!d) return;
        const extra = d.safety_backup_path ? ` (${d.safety_backup_path})` : "";
        setStatus((d.message || "Сброс выполнен") + extra + " Перезапустите WESP.");
      } catch (error) {
        setStatus(error.message, true, "Ошибка сброса");
      }
    });
    document.getElementById("btnManageBackup")?.addEventListener("click", async () => {
      try {
        await downloadSqliteBackup();
      } catch (error) {
        setStatus(error.message, true, "Ошибка копии");
      }
    });
    document.getElementById("btnManageRestore")?.addEventListener("click", () => {
      document.getElementById("restoreSqliteFile")?.click();
    });
    document.getElementById("restoreSqliteFile")?.addEventListener("change", async (event) => {
      const input = event.target;
      const file = input?.files?.[0];
      if (!file) return;
      const name = (file.name || "").toLowerCase();
      if (!name.endsWith(".zip")) {
        setStatus("restore: требуется .zip", true);
        input.value = "";
        return;
      }
      const ok = window.confirm(
        "Текущие файлы SQLite будут скопированы с суффиксом .restore-bak-* и заменены содержимым архива.\n\n" +
          "Используйте только ZIP, скачанный здесь же («Скачать ZIP»).\n\n" +
          "После восстановления перезапустите WESP и обновите страницу.\n\n" +
          "Продолжить?",
      );
      if (!ok) {
        input.value = "";
        return;
      }
      const fd = new FormData();
      fd.append("file", file);
      try {
        const r = await fetch("/api/admin/restore.sqlite", { method: "POST", body: fd });
        const d = await r.json().catch(() => ({}));
        if (!r.ok) {
          throw new Error(d.message || `Ошибка ${r.status}`);
        }
        setStatus(d.message || "restore: OK");
      } catch (error) {
        setStatus(error.message, true, "Ошибка восстановления");
      } finally {
        input.value = "";
      }
    });
    document.getElementById("btnKioskFullSetup")?.addEventListener("click", () => {
      applyFullKioskSetup().catch((e) => setStatus(e.message, true, "Kiosk full setup"));
    });
    refreshKioskFullSetupHint();
    document.getElementById("btnPiPlatformApply")?.addEventListener("click", () => {
      applyPiPlatformSetup().catch((e) => setStatus(e.message, true, "Pi setup"));
    });
    refreshPiPlatformSetupHint();
    document.getElementById("btnPiRainbowSplashDisable")?.addEventListener("click", () => {
      setPiRainbowSplash(true).catch((e) => setStatus(e.message, true, "Pi boot"));
    });
    document.getElementById("btnPiRainbowSplashEnable")?.addEventListener("click", () => {
      setPiRainbowSplash(false).catch((e) => setStatus(e.message, true, "Pi boot"));
    });
    refreshPiRainbowSplashHint();
    document.getElementById("btnPlymouthInstall")?.addEventListener("click", () => {
      installPlymouthTheme().catch((e) => setStatus(e.message, true, "Plymouth"));
    });
    refreshPlymouthStatusHint().then((running) => {
      if (running) startPlymouthStatusPoll();
    });
    document.getElementById("btnKioskBootEnable")?.addEventListener("click", () => {
      setKioskBoot(true).catch((e) => setStatus(e.message, true, "Kiosk boot"));
    });
    document.getElementById("btnKioskBootDisable")?.addEventListener("click", () => {
      setKioskBoot(false).catch((e) => setStatus(e.message, true, "Kiosk boot"));
    });
    refreshKioskBootStatusHint();
    document.getElementById("btnWespStop")?.addEventListener("click", () => {
      postServiceControl("stop");
    });
    document.getElementById("btnWespRestart")?.addEventListener("click", () => {
      postServiceControl("restart");
    });
    document.getElementById("btnToggleServerIp")?.addEventListener("click", () => {
      const next = localStorage.getItem("wespAdminMaskIp") === "1" ? "0" : "1";
      if (next === "1") localStorage.setItem("wespAdminMaskIp", "1");
      else localStorage.removeItem("wespAdminMaskIp");
      applyServerIpMask();
    });
    document.getElementById("diagAddExtraTargetBtn")?.addEventListener("click", () => {
      const wrap = document.getElementById("diagExtraTargetsRows");
      if (!wrap) return;
      wrap.insertAdjacentHTML("beforeend", makeDiagTargetRow({}));
    });
    document.getElementById("diagExtraTargetsRows")?.addEventListener("click", (event) => {
      const btn = event.target.closest("[data-diag-remove-target]");
      if (!btn) return;
      const row = btn.closest("[data-diag-target-row]");
      if (row) row.remove();
    });
    document.getElementById("diagSaveExtraTargetsBtn")?.addEventListener("click", async () => {
      try {
        await saveDiagnosticsSettings();
      } catch (error) {
        setStatus(error.message, true, "Не удалось сохранить точки");
      }
    });
    document.getElementById("diagRefreshReportBtn")?.addEventListener("click", () => {
      loadDiagnosticsReport()
        .then(() => setStatus("Диагностика: отчёт обновлён"))
        .catch((e) => setStatus(e.message, true, "Ошибка диагностики"));
    });
    document.getElementById("diagRunPrimaryBtn")?.addEventListener("click", async () => {
      try {
        await runDiagnosticsNetwork("primary", false);
      } catch (error) {
        setStatus(error.message, true, "Ошибка проверки");
      }
    });
    document.getElementById("diagRunPrimaryTraceBtn")?.addEventListener("click", () => {
      openTracerouteModalFromDiagnostics();
    });
    document.getElementById("tracerouteModalClose")?.addEventListener("click", closeTracerouteModal);
    document.getElementById("tracerouteModalMask")?.addEventListener("click", closeTracerouteModal);
    document.getElementById("tracerouteRunBtn")?.addEventListener("click", async () => {
      try {
        await runTracerouteByHostsModal();
      } catch (error) {
        setStatus(error.message, true, "Ошибка трассировки");
      }
    });
    document.getElementById("tracerouteFillPrimaryBtn")?.addEventListener("click", fillTracerouteHostsFromPrimary);
    document.getElementById("tracerouteClearHostsBtn")?.addEventListener("click", () => {
      const ta = document.getElementById("tracerouteHostsInput");
      const wrap = document.getElementById("tracerouteModalResults");
      if (ta) ta.value = "";
      if (wrap) wrap.innerHTML = "";
    });
    document.getElementById("diagRunInternetBtn")?.addEventListener("click", async () => {
      try {
        await runDiagnosticsNetwork("custom", false, []);
      } catch (error) {
        setStatus(error.message, true, "Ошибка проверки интернета");
      }
    });
    document.getElementById("diagRunExtraTargetsBtn")?.addEventListener("click", async () => {
      try {
        await saveDiagnosticsSettings();
        await runDiagnosticsNetwork("extra", true);
      } catch (error) {
        setStatus(error.message, true, "Ошибка доп. точек");
      }
    });
    document.getElementById("diagHxSampleBtn")?.addEventListener("click", async () => {
      try {
        await runDiagnosticsHxSample("diagHardwareList");
        await loadPeripheralEvents().catch(() => {});
      } catch (error) {
        setStatus(error.message, true, "Ошибка HX711");
      }
    });
    document.getElementById("diagGpioBlinkBtn")?.addEventListener("click", async () => {
      try {
        await runDiagnosticsGpioBlink("diagHardwareList");
      } catch (error) {
        setStatus(error.message, true, "Ошибка GPIO");
      }
    });
    document.getElementById("hardwareRefreshBtn")?.addEventListener("click", async () => {
      try {
        await refreshHardwareSection();
      } catch (error) {
        setStatus(error.message, true, "Ошибка обновления железа");
      }
    });
    document.getElementById("hardwareEventsClearBtn")?.addEventListener("click", async () => {
      try {
        await clearPeripheralEventsLog();
        setStatus("Журнал событий очищен");
      } catch (error) {
        setStatus(error.message, true, "Ошибка очистки журнала");
      }
    });
    document.getElementById("hwHxSampleBtn")?.addEventListener("click", async () => {
      try {
        await runDiagnosticsHxSample("hardwarePeriphList");
        await loadHardwareStatus().catch(() => {});
        await loadPeripheralEvents().catch(() => {});
      } catch (error) {
        setStatus(error.message, true, "Ошибка HX711");
      }
    });
    document.getElementById("hwGpioBlinkBtn")?.addEventListener("click", async () => {
      try {
        await runDiagnosticsGpioBlink("hardwarePeriphList");
      } catch (error) {
        setStatus(error.message, true, "Ошибка GPIO");
      }
    });
    document.getElementById("hwSimulationToggle")?.addEventListener("change", async (ev) => {
      const enabled = Boolean(ev.target && ev.target.checked);
      withHwSimUiLock(() => {});
      try {
        await patchHardwareSimulation(enabled);
        await refreshHardwareSection();
        setStatus(
          enabled
            ? "Симуляция включена — HX711 не опрашивается"
            : "Симуляция выключена — чтение с HX711",
        );
      } catch (error) {
        if (ev.target) ev.target.checked = !enabled;
        setStatus(error.message, true, "Ошибка симуляции");
      }
    });
    document.getElementById("hwSimWeightMinus")?.addEventListener("click", async () => {
      try {
        await adjustHardwareSimulationWeight(-1);
        await refreshHardwareSection();
      } catch (error) {
        setStatus(error.message, true, "Ошибка изменения веса");
      }
    });
    document.getElementById("hwSimWeightPlus")?.addEventListener("click", async () => {
      try {
        await adjustHardwareSimulationWeight(1);
        await refreshHardwareSection();
      } catch (error) {
        setStatus(error.message, true, "Ошибка изменения веса");
      }
    });
    document.getElementById("hwSimWeightApply")?.addEventListener("click", async () => {
      try {
        await applyHardwareSimulationWeightFromUi();
        setStatus("Вес симуляции применён");
      } catch (error) {
        setStatus(error.message, true, "Ошибка изменения веса");
      }
    });
    document.getElementById("hwSimWeightInput")?.addEventListener("keydown", async (ev) => {
      if (ev.key !== "Enter") return;
      ev.preventDefault();
      try {
        await applyHardwareSimulationWeightFromUi();
        setStatus("Вес симуляции применён");
      } catch (error) {
        setStatus(error.message, true, "Ошибка изменения веса");
      }
    });
    document.getElementById("hwSimWeightSlider")?.addEventListener("input", (ev) => {
      const slider = ev.target;
      const input = document.getElementById("hwSimWeightInput");
      if (input && slider) input.value = slider.value;
      scheduleHardwareSimulationWeightFromSlider();
    });

    await Promise.all([updateSummary(), updateSystemMetrics(), loadUsers()]);
    await finishAdminShellReveal(shellStart);
    setStatus(`session: ${authData.user_login}`);

    metricsTimer = window.setInterval(() => {
      updateSystemMetrics().catch((err) => {
        const st = document.getElementById("wespRuntimeStatus");
        if (st) st.textContent = userHint(err?.message, "Не удалось загрузить метрики");
      });
    }, 4000);
    window.addEventListener("beforeunload", () => {
      if (metricsTimer) window.clearInterval(metricsTimer);
      stopHardwarePolling();
    });
  } catch (error) {
    stripAdminShellDecorations();
    setStatus(error.message, true, "Ошибка загрузки админ-панели");
  }
}

init();
