337 lines
13 KiB
JavaScript
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();
|