771 lines
30 KiB
JavaScript
771 lines
30 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,
|
||
labelColor: $("#layout-label-color").value,
|
||
});
|
||
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");
|
||
const hold = $("#fb-preview-hold"), pending = $("#fb-preview-pending");
|
||
if (!ok) return;
|
||
const okColor = $("#fb-ok-color").value;
|
||
const errColor = $("#fb-error-color").value;
|
||
const holdColor = $("#fb-hold-color").value;
|
||
const pendingColor = $("#fb-pending-color").value;
|
||
const okW = $("#fb-ok-width").value;
|
||
const errW = $("#fb-error-width").value;
|
||
const pendingW = $("#fb-pending-width").value;
|
||
const glow = $("#fb-glow").value;
|
||
$("#fb-ok-width-val").textContent = okW;
|
||
$("#fb-error-width-val").textContent = errW;
|
||
$("#fb-pending-width-val").textContent = pendingW;
|
||
$("#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}`;
|
||
if (hold) {
|
||
hold.style.boxShadow = `0 0 ${Math.max(8, glow * 0.5)}px ${Math.round(glow/3)}px ${holdColor}`;
|
||
hold.style.border = `3px solid ${holdColor}`;
|
||
}
|
||
if (pending) {
|
||
pending.style.boxShadow = `0 0 ${glow}px ${Math.round(glow/2)}px ${pendingColor}`;
|
||
pending.style.border = `${pendingW}px solid ${pendingColor}`;
|
||
}
|
||
}
|
||
["fb-ok-color", "fb-error-color", "fb-hold-color", "fb-pending-color",
|
||
"fb-ok-width", "fb-error-width", "fb-pending-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,
|
||
holdColor: $("#fb-hold-color").value,
|
||
pendingColor: $("#fb-pending-color").value,
|
||
okWidth: parseInt($("#fb-ok-width").value, 10),
|
||
errorWidth: parseInt($("#fb-error-width").value, 10),
|
||
pendingWidth: parseInt($("#fb-pending-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.trim();
|
||
if (!pwd) { flash("Введите пароль", true); return; }
|
||
try {
|
||
await api("PUT", "/api/settings", { password: pwd });
|
||
flash("Пароль сохранён. Действует со следующего входа.");
|
||
$("#new-password").value = "";
|
||
} catch (e) {
|
||
flash(e.message || "Не удалось сохранить пароль", true);
|
||
}
|
||
});
|
||
|
||
// ---------- 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>`;
|
||
}
|
||
}
|
||
|
||
function renderBrightness(s) {
|
||
const el = $("#brightness-status");
|
||
const slider = $("#brightness-slider");
|
||
const val = $("#brightness-val");
|
||
if (!el) return;
|
||
if (!s || s.ok === false) {
|
||
el.innerHTML = `<p class="warn">${(s && s.error) || "подсветка недоступна"}</p>`;
|
||
return;
|
||
}
|
||
const pct = s.percent != null ? s.percent : 100;
|
||
el.textContent = `Сейчас: ${pct}%` +
|
||
(s.device ? ` (${s.device}: ${s.brightness}/${s.max})` : "") +
|
||
(s.saved != null ? ` · сохранено: ${s.saved}%` : "");
|
||
if (slider && !slider.dataset.touched) {
|
||
slider.value = pct;
|
||
if (val) val.textContent = pct + "%";
|
||
}
|
||
}
|
||
|
||
async function loadBrightness() {
|
||
try {
|
||
const s = await api("GET", "/api/system/brightness");
|
||
renderBrightness(s);
|
||
} catch (e) {
|
||
renderBrightness({ ok: false, error: 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();
|
||
loadBrightness();
|
||
} 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";
|
||
});
|
||
|
||
$("#brightness-slider")?.addEventListener("input", (e) => {
|
||
e.target.dataset.touched = "1";
|
||
const val = $("#brightness-val");
|
||
if (val) val.textContent = e.target.value + "%";
|
||
});
|
||
$("#brightness-refresh")?.addEventListener("click", () => {
|
||
const slider = $("#brightness-slider");
|
||
if (slider) delete slider.dataset.touched;
|
||
loadBrightness();
|
||
});
|
||
$("#brightness-save")?.addEventListener("click", async () => {
|
||
const percent = parseInt($("#brightness-slider")?.value, 10);
|
||
if (Number.isNaN(percent) || percent < 0 || percent > 100) {
|
||
flash("Некорректная яркость", true);
|
||
return;
|
||
}
|
||
try {
|
||
const s = await api("POST", "/api/system/brightness", { percent });
|
||
const slider = $("#brightness-slider");
|
||
if (slider) delete slider.dataset.touched;
|
||
renderBrightness(s);
|
||
flash(`Яркость: ${s.percent}%`);
|
||
} catch (e) { flash(e.message, true); }
|
||
});
|
||
|
||
$("#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); }
|
||
});
|
||
const MIN_USER_PORT = 1024;
|
||
$("#save-web-port")?.addEventListener("click", async () => {
|
||
const port = parseInt($("#web-port").value, 10);
|
||
if (!port || port < MIN_USER_PORT || port > 65535) {
|
||
flash(`Веб-порт должен быть от ${MIN_USER_PORT} до 65535 (1–1023 зарезервированы в Linux)`, 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();
|