// ButtonTask v2 — web configurator client logic const $ = (sel) => document.querySelector(sel); const $$ = (sel) => Array.from(document.querySelectorAll(sel)); async function api(method, path, body) { const opts = { method, headers: {} }; if (body !== undefined) { opts.headers["Content-Type"] = "application/json"; opts.body = JSON.stringify(body); } const r = await fetch(path, opts); if (r.status === 401) { window.location.href = "/login?next=" + encodeURIComponent(window.location.pathname); throw new Error("auth required"); } if (!r.ok) { let msg = `${method} ${path} -> ${r.status}`; try { const j = await r.json(); if (j.error) msg = j.error; } catch (e) {} throw new Error(msg); } return r.headers.get("content-type")?.includes("application/json") ? r.json() : r.text(); } function flash(text, isError) { const t = document.createElement("div"); t.className = "toast" + (isError ? " error" : ""); t.textContent = text; document.body.appendChild(t); setTimeout(() => t.remove(), 2800); } // ---------- Tabs ---------- $$(".tab").forEach(tab => { tab.addEventListener("click", () => { $$(".tab").forEach(t => t.classList.remove("active")); $$(".panel").forEach(p => p.classList.remove("active")); tab.classList.add("active"); $("#tab-" + tab.dataset.tab).classList.add("active"); if (tab.dataset.tab === "network") loadNetwork(); if (tab.dataset.tab === "system") loadSystem(); if (tab.dataset.tab === "update") { loadUpdateStatus(); loadVersionList(); } }); }); // ---------- Layout ---------- $("#save-layout")?.addEventListener("click", async () => { try { await api("PUT", "/api/layout", { mode: $("#layout-mode").value, columns: parseInt($("#layout-columns").value, 10), spacing: parseInt($("#layout-spacing").value, 10), showLabels: $("#layout-show-labels").checked, }); flash("Расположение сохранено"); } catch (e) { flash(e.message, true); } }); // ---------- Background ---------- function toggleBgType() { const wrap = $("#bg-form"); if (!wrap) return; const t = $("#bg-type")?.value || "gradient"; wrap.classList.remove("mode-gradient", "mode-solid", "mode-image"); wrap.classList.add("mode-" + t); } $("#bg-type")?.addEventListener("change", toggleBgType); toggleBgType(); $("#save-bg")?.addEventListener("click", async () => { try { await api("PUT", "/api/background", { type: $("#bg-type").value, color1: $("#bg-c1").value, color2: $("#bg-c2").value, animated: $("#bg-animated").checked, imagePath: $("#bg-image").value, }); flash("Фон сохранён"); } catch (e) { flash(e.message, true); } }); // ---------- Feedback (borders) ---------- function syncFeedbackPreview() { const ok = $("#fb-preview-ok"), err = $("#fb-preview-error"); if (!ok) return; const okColor = $("#fb-ok-color").value; const errColor = $("#fb-error-color").value; const okW = $("#fb-ok-width").value; const errW = $("#fb-error-width").value; const glow = $("#fb-glow").value; $("#fb-ok-width-val").textContent = okW; $("#fb-error-width-val").textContent = errW; $("#fb-glow-val").textContent = glow; ok.style.boxShadow = `0 0 ${glow}px ${Math.round(glow/2)}px ${okColor}`; ok.style.border = `${okW}px solid ${okColor}`; err.style.boxShadow = `0 0 ${glow}px ${Math.round(glow/2)}px ${errColor}`; err.style.border = `${errW}px solid ${errColor}`; } ["fb-ok-color", "fb-error-color", "fb-ok-width", "fb-error-width", "fb-glow"] .forEach(id => $("#" + id)?.addEventListener("input", syncFeedbackPreview)); syncFeedbackPreview(); $("#save-feedback")?.addEventListener("click", async () => { try { await api("PUT", "/api/feedback", { okColor: $("#fb-ok-color").value, errorColor: $("#fb-error-color").value, okWidth: parseInt($("#fb-ok-width").value, 10), errorWidth: parseInt($("#fb-error-width").value, 10), glowRadius: parseInt($("#fb-glow").value, 10), }); flash("Обводка сохранена"); } catch (e) { flash(e.message, true); } }); // ---------- Icons ---------- $("#upload-icon")?.addEventListener("click", async () => { const file = $("#icon-file").files[0]; if (!file) { flash("Выберите файл", true); return; } const fd = new FormData(); fd.append("file", file); const r = await fetch("/api/icons", { method: "POST", body: fd }); if (!r.ok) { flash("Ошибка загрузки", true); return; } location.reload(); }); $$(".icon-tile .del").forEach(btn => { btn.addEventListener("click", async (e) => { const name = e.target.dataset.name; if (!confirm(`Удалить ${name}?`)) return; await api("DELETE", `/api/icons/${encodeURIComponent(name)}`); location.reload(); }); }); // ---------- Password ---------- $("#save-password")?.addEventListener("click", async () => { const pwd = $("#new-password").value; if (!pwd) { flash("Введите пароль", true); return; } await api("PUT", "/api/settings", { password: pwd }); flash("Пароль сохранён. Действует со следующего входа."); $("#new-password").value = ""; }); // ---------- Buttons drag-drop ---------- const list = $("#buttons-list"); if (list && window.Sortable) { new Sortable(list, { handle: ".handle", animation: 150, onEnd: async () => { const order = Array.from(list.children).map(li => li.dataset.id); await api("POST", "/api/buttons/reorder", { order }); flash("Порядок сохранён"); }, }); } // ---------- Buttons CRUD ---------- const dlg = $("#btn-dialog"); const form = $("#btn-form"); function syncHoldRow() { const mode = $("#b-trigger-mode").value; $("#b-hold-row").style.display = mode === "click" ? "none" : ""; $("#b-hold-val").textContent = $("#b-hold").value; } function openDialogFor(btn) { $("#btn-dialog-title").textContent = btn ? "Редактирование" : "Новая кнопка"; $("#b-id").value = btn?.id || ""; $("#b-label").value = btn?.label || ""; $("#b-icon").value = btn?.iconPath || ""; $("#b-type").value = btn?.action?.type || "http_get"; $("#b-url").value = btn?.action?.url || ""; $("#b-success").value = btn?.feedback?.successText || ""; $("#b-error").value = btn?.feedback?.errorText || "Ошибка"; $("#b-pending").value = btn?.feedback?.pendingText ?? "..."; $("#b-color").value = btn?.color || "#7b007b"; $("#b-trigger-mode").value = btn?.trigger?.mode || "hold"; $("#b-hold").value = btn?.trigger?.holdMs || 800; syncHoldRow(); dlg.showModal(); } $("#b-trigger-mode")?.addEventListener("change", syncHoldRow); $("#b-hold")?.addEventListener("input", syncHoldRow); $("#add-btn")?.addEventListener("click", () => openDialogFor(null)); $$(".buttons-list .edit").forEach(btn => { btn.addEventListener("click", async (e) => { const id = e.target.dataset.id; const cfg = await api("GET", "/api/config"); const b = cfg.buttons.find(x => x.id === id); if (b) openDialogFor(b); }); }); $$(".buttons-list .del").forEach(btn => { btn.addEventListener("click", async (e) => { const id = e.target.dataset.id; if (!confirm("Удалить кнопку?")) return; await api("DELETE", `/api/buttons/${encodeURIComponent(id)}`); location.reload(); }); }); form?.addEventListener("submit", async (e) => { if (e.submitter && e.submitter.value === "cancel") return; e.preventDefault(); const id = $("#b-id").value; const payload = { label: $("#b-label").value, iconPath: $("#b-icon").value, actionType: $("#b-type").value, url: $("#b-url").value, successText: $("#b-success").value, errorText: $("#b-error").value, pendingText: $("#b-pending").value || "...", color: $("#b-color").value, triggerMode: $("#b-trigger-mode").value, holdMs: parseInt($("#b-hold").value, 10), }; if (id) await api("PUT", `/api/buttons/${encodeURIComponent(id)}`, payload); else await api("POST", "/api/buttons", payload); dlg.close(); location.reload(); }); // ---------- Network ---------- function toggleStatic() { const wrap = $("#net-mode")?.closest(".grid-form"); if (!wrap) return; wrap.classList.toggle("show-static", $("#net-mode").value === "static"); } $("#net-mode")?.addEventListener("change", toggleStatic); async function loadNetwork() { const statusEl = $("#net-status"); const ifaceSel = $("#net-iface"); if (!statusEl) return; statusEl.textContent = "Загрузка состояния…"; try { const data = await api("GET", "/api/network"); const devs = data.devices || []; if (!devs.length) { statusEl.innerHTML = "

Ethernet-интерфейсы не найдены (или nmcli недоступен).

"; } else { statusEl.innerHTML = devs.map(d => { const up = (d.state || "").includes("connected") && !(d.state || "").includes("dis"); const badge = up ? "up" : "down"; const addr = (d.addresses || []).join(", ") || "—"; return `
${d.device} ${badge}
Соединение: ${d.connection || "—"}
IP: ${addr}
Шлюз: ${d.gateway || "—"} · DNS: ${(d.dns||[]).join(", ") || "—"}
`; }).join(""); } if (ifaceSel) { const prev = ifaceSel.value; ifaceSel.innerHTML = devs.map(d => ``).join(""); if (prev) ifaceSel.value = prev; } } catch (e) { statusEl.innerHTML = `

${e.message}

`; } } $("#net-refresh")?.addEventListener("click", loadNetwork); $("#net-apply")?.addEventListener("click", async () => { const mode = $("#net-mode").value; const payload = { iface: $("#net-iface").value, mode }; if (mode === "static") { payload.address = $("#net-address").value.trim(); payload.prefix = parseInt($("#net-prefix").value, 10); payload.gateway = $("#net-gateway").value.trim(); payload.dns = $("#net-dns").value.trim(); } if (!payload.iface) { flash("Выберите интерфейс", true); return; } if (!confirm("Применить сетевые настройки? Соединение может прерваться.")) return; try { await api("PUT", "/api/network", payload); flash("Сетевые настройки применены"); setTimeout(loadNetwork, 1500); } catch (e) { flash(e.message, true); } }); // ---------- System ---------- let systemPollTimer = null; function formatKb(kb) { if (kb >= 1 << 20) return (kb / (1 << 20)).toFixed(1) + " ГБ"; if (kb >= 1 << 10) return (kb / (1 << 10)).toFixed(0) + " МБ"; return kb + " КБ"; } function renderSystemStats(s) { const el = $("#sys-stats"); if (!el) return; const cpu = s.cpu_percent != null ? s.cpu_percent + "%" : "—"; const mem = s.memory || {}; el.innerHTML = `
Часы${s.clock || "—"}
Аптайм${s.uptime || "—"}
CPU${cpu}
Память${mem.used_percent ?? "—"}% ${formatKb(mem.used_kb || 0)} / ${formatKb(mem.total_kb || 0)}
`; const clockEl = $("#sys-clock"); if (clockEl && s.clock) clockEl.textContent = "Текущее время: " + s.clock; } function renderButtonLog(entries) { const body = $("#sys-log-body"); if (!body) return; if (!entries || !entries.length) { body.innerHTML = 'Нажатий пока нет'; return; } body.innerHTML = entries.map(e => { const ok = e.ok; const result = ok ? (e.http ? `OK ${e.http}` : "OK") + (e.message ? ` — ${e.message}` : "") : (e.http ? `ERR ${e.http}` : "ERR") + (e.message ? ` — ${e.message}` : ""); return ` ${e.ts || "—"} ${e.label || "—"} ${e.method || "—"} ${e.url || "—"} ${result} ${e.ms != null ? e.ms : "—"} `; }).join(""); } function renderAccessStatus(s) { const el = $("#access-status"); if (!el) return; el.innerHTML = `
Ping / ICMP${s.icmp === "off" ? "отключён" : "включён"}
SSH${s.ssh === "on" ? "включён" : "отключён"}
Порт SSH${s.sshPort || "—"}
Порт веба${s.webPort || "—"}
`; const setIfIdle = (selector, value) => { const input = $(selector); if (!input || value == null || input.dataset.touched || document.activeElement === input) return; input.value = value; }; setIfIdle("#ssh-port", s.sshPort); setIfIdle("#web-port", s.webPort); } async function loadAccess() { try { const s = await api("GET", "/api/system/access"); renderAccessStatus(s); } catch (e) { const el = $("#access-status"); if (el) el.innerHTML = `

${e.message}

`; } } async function setAccess(action, value) { const r = await api("POST", "/api/system/access", { action, value }); if (action !== "webPort") renderAccessStatus(r); return r; } async function loadSystem() { try { const [stats, blog] = await Promise.all([ api("GET", "/api/system/stats"), api("GET", "/api/system/button-log"), ]); renderSystemStats(stats); renderButtonLog(blog.entries); const dtInput = $("#sys-datetime"); if (dtInput && stats.clock && !dtInput.dataset.touched) { dtInput.value = stats.clock.replace(" ", "T").slice(0, 16); } loadAccess(); } catch (e) { const el = $("#sys-stats"); if (el) el.textContent = e.message; } if (!systemPollTimer) { systemPollTimer = setInterval(() => { if ($("#tab-system")?.classList.contains("active")) loadSystem(); }, 3000); } } $("#sys-datetime")?.addEventListener("input", (e) => { e.target.dataset.touched = "1"; }); $("#ssh-port")?.addEventListener("input", (e) => { e.target.dataset.touched = "1"; }); $("#web-port")?.addEventListener("input", (e) => { e.target.dataset.touched = "1"; }); $("#sys-set-time")?.addEventListener("click", async () => { const raw = $("#sys-datetime")?.value; if (!raw) { flash("Укажите дату и время", true); return; } const dt = raw.replace("T", " ") + ":00"; try { const r = await api("POST", "/api/system/time", { datetime: dt }); flash("Время установлено: " + r.datetime); const dtInput = $("#sys-datetime"); if (dtInput) delete dtInput.dataset.touched; loadSystem(); } catch (e) { flash(e.message, true); } }); $("#sys-reboot")?.addEventListener("click", async () => { if (!confirm("Перезагрузить устройство? Соединение будет разорвано.")) return; try { await api("POST", "/api/system/reboot"); flash("Перезагрузка…"); } catch (e) { flash(e.message, true); } }); $("#access-refresh")?.addEventListener("click", loadAccess); $("#icmp-on")?.addEventListener("click", async () => { try { await setAccess("icmp", "on"); flash("ICMP включён"); } catch (e) { flash(e.message, true); } }); $("#icmp-off")?.addEventListener("click", async () => { if (!confirm("Отключить ответы на ping?")) return; try { await setAccess("icmp", "off"); flash("ICMP отключён"); } catch (e) { flash(e.message, true); } }); $("#ssh-on")?.addEventListener("click", async () => { try { await setAccess("ssh", "on"); flash("SSH включён"); } catch (e) { flash(e.message, true); } }); $("#ssh-off")?.addEventListener("click", async () => { if (!confirm("Отключить SSH? Восстановить доступ можно будет через веб или локально.")) return; try { await setAccess("ssh", "off"); flash("SSH отключён"); } catch (e) { flash(e.message, true); } }); $("#save-ssh-port")?.addEventListener("click", async () => { const port = parseInt($("#ssh-port").value, 10); if (!port || port < 1 || port > 65535) { flash("Некорректный порт SSH", true); return; } if (!confirm(`Сменить порт SSH на ${port}?`)) return; try { await setAccess("sshPort", port); delete $("#ssh-port").dataset.touched; flash("Порт SSH обновлён"); } catch (e) { flash(e.message, true); } }); $("#save-web-port")?.addEventListener("click", async () => { const port = parseInt($("#web-port").value, 10); if (!port || port < 1 || port > 65535) { flash("Некорректный веб-порт", true); return; } if (!confirm(`Сменить веб-порт на ${port}? Веб-сервис перезапустится.`)) return; try { await setAccess("webPort", port); delete $("#web-port").dataset.touched; flash(`Веб-порт обновлён: ${port}. Откройте страницу на новом порту.`); } catch (e) { flash(e.message, true); } }); $("#sys-refresh")?.addEventListener("click", loadSystem); // ---------- Update / OTA ---------- let updatePollTimer = null; function formatBytes(n) { if (n >= 1 << 20) return (n / (1 << 20)).toFixed(1) + " МБ"; if (n >= 1 << 10) return (n / (1 << 10)).toFixed(0) + " КБ"; return n + " Б"; } function renderVersionList(versions) { const ul = $("#version-list"); if (!ul) return; if (!versions || !versions.length) { ul.innerHTML = '
  • Версий нет
  • '; return; } ul.innerHTML = versions.map(v => { const badges = []; if (v.current) badges.push('текущая'); if (v.previous) badges.push('откат'); const canDelete = !v.current && !v.previous; const created = v.created ? v.created.replace("T", " ").slice(0, 16) : ""; return `
  • ${v.version} ${badges.join(" ")}
    ${v.name}${created ? " · " + created : ""} · ${formatBytes(v.size_bytes || 0)}
  • `; }).join(""); ul.querySelectorAll(".version-del").forEach(btn => { btn.addEventListener("click", async () => { const name = btn.dataset.name; if (!name || !confirm(`Удалить версию ${name}?`)) return; try { await api("DELETE", `/api/update/versions/${encodeURIComponent(name)}`); flash(`Версия ${name} удалена`); loadVersionList(); } catch (e) { flash(e.message, true); } }); }); } async function loadVersionList() { try { const data = await api("GET", "/api/update/versions"); renderVersionList(data.versions); } catch (e) { const ul = $("#version-list"); if (ul) ul.innerHTML = `
  • ${e.message}
  • `; } } $("#versions-refresh")?.addEventListener("click", loadVersionList); function renderUpdateStatus(s) { const el = $("#update-status"); if (!el) return; const st = (s.status && s.status.state) || "idle"; const map = { idle: ["Статус: готово к обновлению", ""], staging: ["Распаковка пакета…", "busy"], switching: ["Переключение версии…", "busy"], health_check: ["Проверка работоспособности…", "busy"], success: ["Обновление успешно применено", "ok"], rolledback: ["Запуск новой версии не удался — выполнен откат", "fail"], rollback_done: ["Откат выполнен", "ok"], error: ["Ошибка обновления", "fail"], }; const [text, cls] = map[st] || [`Статус: ${st}`, ""]; el.className = "update-status " + cls; el.textContent = (s.status && s.status.message) ? `${text} — ${s.status.message}` : text; if (s.current) { $("#update-version").textContent = s.current.version || "—"; $("#update-path").textContent = s.current.path || "—"; } if (st === "success" || st === "rollback_done") loadVersionList(); const busy = ["staging", "switching", "health_check"].includes(st); if (busy && !updatePollTimer) { updatePollTimer = setInterval(loadUpdateStatus, 2000); } else if (!busy && updatePollTimer) { clearInterval(updatePollTimer); updatePollTimer = null; } } async function loadUpdateStatus() { try { const s = await api("GET", "/api/update/status"); renderUpdateStatus(s); } catch (e) { const el = $("#update-status"); if (el) { el.className = "update-status fail"; el.textContent = e.message; } } } $("#update-upload")?.addEventListener("click", async () => { const file = $("#update-file").files[0]; if (!file) { flash("Выберите пакет .tar.gz", true); return; } if (!confirm("Загрузить и применить обновление? Сервис будет перезапущен.")) return; const fd = new FormData(); fd.append("file", file); try { const r = await fetch("/api/update", { method: "POST", body: fd }); if (!r.ok) { let msg = "Ошибка загрузки"; try { const j = await r.json(); if (j.error) msg = j.error; } catch (e) {} throw new Error(msg); } flash("Обновление запущено"); setTimeout(loadUpdateStatus, 2000); } catch (e) { flash(e.message, true); } }); $("#update-rollback")?.addEventListener("click", async () => { if (!confirm("Откатить на предыдущую версию?")) return; try { await api("POST", "/api/update/rollback"); flash("Откат запущен"); setTimeout(loadUpdateStatus, 2000); } catch (e) { flash(e.message, true); } }); toggleStatic();