Files
Button/webconfig/static/app.js
T
2026-06-17 04:55:15 +03:00

337 lines
13 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 === "update") loadUpdateStatus();
});
});
// ---------- 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 ----------
$("#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 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-color").value = btn?.color || "#7b007b";
dlg.showModal();
}
$("#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,
color: $("#b-color").value,
};
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); }
});
// ---------- Update / OTA ----------
let updatePollTimer = null;
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 || "—";
}
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();