Files
Button/webconfig/static/app.js
T
2026-06-26 14:45:42 +03:00

597 lines
24 KiB
JavaScript

// 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 = "<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 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 `
<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="${ok ? "log-ok" : "log-err"}">${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();