Files
Button/webconfig/static/app.js
T
2026-06-29 15:06:48 +03:00

691 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 defaultResponseCheck() {
return {
enabled: false,
requireHttpSuccess: true,
defaultResult: "error",
rules: [],
poll: { intervalMs: 3000, maxAttempts: 20, timeoutMs: 120000 },
};
}
function createRuleRow(rule = {}) {
const row = document.createElement("div");
row.className = "response-rule-row";
row.innerHTML = `
<input type="text" class="rc-field" placeholder="поле (success)" value="${rule.field || ""}">
<select class="rc-op">
<option value="equals">равно</option>
<option value="contains">содержит</option>
</select>
<input type="text" class="rc-value" placeholder="значение" value="${rule.equals ?? rule.contains ?? ""}">
<select class="rc-result">
<option value="error">Ошибка</option>
<option value="ok">Принято</option>
<option value="pending">Ожидание</option>
</select>
<input type="text" class="rc-message" placeholder="сообщение (опц.)" value="${rule.message || ""}">
<button type="button" class="ghost rc-del" title="Удалить">×</button>
`;
row.querySelector(".rc-op").value = rule.contains != null && rule.contains !== "" ? "contains" : "equals";
row.querySelector(".rc-result").value = rule.result || "error";
row.querySelector(".rc-del").addEventListener("click", () => row.remove());
return row;
}
function fillResponseCheckForm(rc) {
const cfg = rc || defaultResponseCheck();
$("#b-rc-enabled").checked = !!cfg.enabled;
$("#b-rc-require-http").checked = cfg.requireHttpSuccess !== false;
$("#b-rc-default").value = cfg.defaultResult || "error";
$("#b-rc-poll-interval").value = cfg.poll?.intervalMs ?? 3000;
$("#b-rc-poll-attempts").value = cfg.poll?.maxAttempts ?? 20;
$("#b-rc-poll-timeout").value = cfg.poll?.timeoutMs ?? 120000;
const wrap = $("#b-rc-rules");
wrap.innerHTML = "";
(cfg.rules || []).forEach(rule => wrap.appendChild(createRuleRow(rule)));
syncResponseCheckUi();
}
function readResponseCheckFromForm() {
if (!$("#b-rc-enabled").checked) {
return { enabled: false };
}
const rules = Array.from($$("#b-rc-rules .response-rule-row")).map(row => {
const field = row.querySelector(".rc-field").value.trim();
const op = row.querySelector(".rc-op").value;
const value = row.querySelector(".rc-value").value;
const result = row.querySelector(".rc-result").value;
const message = row.querySelector(".rc-message").value.trim();
const rule = { field, result };
rule[op] = value;
if (message) rule.message = message;
return rule;
}).filter(r => r.field);
return {
enabled: true,
requireHttpSuccess: $("#b-rc-require-http").checked,
defaultResult: $("#b-rc-default").value,
rules,
poll: {
intervalMs: parseInt($("#b-rc-poll-interval").value, 10) || 3000,
maxAttempts: parseInt($("#b-rc-poll-attempts").value, 10) || 20,
timeoutMs: parseInt($("#b-rc-poll-timeout").value, 10) || 120000,
},
};
}
function syncResponseCheckUi() {
const on = $("#b-rc-enabled")?.checked;
const adv = $("#b-rc-advanced");
if (adv) adv.classList.toggle("show", !!on);
}
$("#b-rc-enabled")?.addEventListener("change", syncResponseCheckUi);
$("#b-rc-add-rule")?.addEventListener("click", () => {
$("#b-rc-rules")?.appendChild(createRuleRow());
});
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;
fillResponseCheckForm(btn?.action?.responseCheck);
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),
responseCheck: readResponseCheckFromForm(),
};
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 = "<p class='muted'>Ethernet-интерфейсы не найдены (или nmcli недоступен).</p>";
} else {
statusEl.innerHTML = devs.map(d => {
const up = (d.state || "").includes("connected") && !(d.state || "").includes("dis");
const badge = up ? "<span class='badge up'>up</span>" : "<span class='badge down'>down</span>";
const addr = (d.addresses || []).join(", ") || "—";
return `<div class="net-device">
<div class="name">${d.device} ${badge}</div>
<div class="detail">Соединение: ${d.connection || "—"}</div>
<div class="detail">IP: ${addr}</div>
<div class="detail">Шлюз: ${d.gateway || "—"} · DNS: ${(d.dns||[]).join(", ") || "—"}</div>
</div>`;
}).join("");
}
if (ifaceSel) {
const prev = ifaceSel.value;
ifaceSel.innerHTML = devs.map(d => `<option value="${d.device}">${d.device}</option>`).join("");
if (prev) ifaceSel.value = prev;
}
} catch (e) {
statusEl.innerHTML = `<p class='warn'>${e.message}</p>`;
}
}
$("#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 = `
<div class="sys-grid">
<div class="sys-tile"><span>Часы</span><strong>${s.clock || "—"}</strong></div>
<div class="sys-tile"><span>Аптайм</span><strong>${s.uptime || "—"}</strong></div>
<div class="sys-tile"><span>CPU</span><strong>${cpu}</strong></div>
<div class="sys-tile"><span>Память</span><strong>${mem.used_percent ?? "—"}%</strong>
<small>${formatKb(mem.used_kb || 0)} / ${formatKb(mem.total_kb || 0)}</small></div>
</div>`;
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 = '<tr><td colspan="6" class="empty-cell">Нажатий пока нет</td></tr>';
return;
}
body.innerHTML = entries.map(e => {
const outcome = e.outcome || (e.ok ? "ok" : "error");
const ok = outcome === "ok";
const pending = outcome === "pending";
const http = e.http ? ` ${e.http}` : "";
const attempt = e.attempt > 1 ? ` [${e.attempt}]` : "";
const result = (ok ? "OK" : pending ? "WAIT" : "ERR") + http
+ attempt
+ (e.message ? `${e.message}` : "");
const cls = ok ? "log-ok" : pending ? "log-wait" : "log-err";
return `
<tr>
<td>${e.ts || "—"}</td>
<td>${e.label || "—"}</td>
<td>${e.method || "—"}</td>
<td class="log-path" title="${e.url || ""}">${e.url || "—"}</td>
<td class="${cls}">${result}</td>
<td>${e.ms != null ? e.ms : "—"}</td>
</tr>`;
}).join("");
}
function renderAccessStatus(s) {
const el = $("#access-status");
if (!el) return;
el.innerHTML = `
<div class="sys-grid">
<div class="sys-tile"><span>Ping / ICMP</span><strong>${s.icmp === "off" ? "отключён" : "включён"}</strong></div>
<div class="sys-tile"><span>SSH</span><strong>${s.ssh === "on" ? "включён" : "отключён"}</strong></div>
<div class="sys-tile"><span>Порт SSH</span><strong>${s.sshPort || "—"}</strong></div>
<div class="sys-tile"><span>Порт веба</span><strong>${s.webPort || "—"}</strong></div>
</div>`;
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 = `<p class="warn">${e.message}</p>`;
}
}
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 = '<li class="empty">Версий нет</li>';
return;
}
ul.innerHTML = versions.map(v => {
const badges = [];
if (v.current) badges.push('<span class="badge current">текущая</span>');
if (v.previous) badges.push('<span class="badge prev">откат</span>');
const canDelete = !v.current && !v.previous;
const created = v.created ? v.created.replace("T", " ").slice(0, 16) : "";
return `
<li class="version-item${v.current ? " is-current" : ""}">
<div class="version-head">
<strong>${v.version}</strong>
${badges.join(" ")}
</div>
<small class="version-meta">${v.name}${created ? " · " + created : ""} · ${formatBytes(v.size_bytes || 0)}</small>
<button class="del icon-only version-del" data-name="${v.name}" ${canDelete ? "" : "disabled"} title="Удалить">✕</button>
</li>`;
}).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 = `<li class="empty">${e.message}</li>`;
}
}
$("#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();