init commit
This commit is contained in:
@@ -0,0 +1,591 @@
|
||||
"use strict";
|
||||
|
||||
// ─── tiny helpers ──────────────────────────────────────────────────────────────
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
||||
|
||||
async function api(method, path, body) {
|
||||
const opts = { method, headers: {} };
|
||||
// Server requires application/json on every POST/DELETE (CSRF hardening:
|
||||
// HTML forms can never set this content type), so set it even when there
|
||||
// is no body to send.
|
||||
if (method === "POST" || method === "DELETE") {
|
||||
opts.headers["Content-Type"] = "application/json";
|
||||
}
|
||||
if (body !== undefined) {
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(path, opts);
|
||||
const ct = res.headers.get("content-type") || "";
|
||||
const data = ct.includes("application/json") ? await res.json() : await res.text();
|
||||
if (!res.ok) {
|
||||
const msg = data && data.error ? data.error : (typeof data === "string" ? data : "Request failed");
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
let toastTimer;
|
||||
function toast(msg, kind) {
|
||||
const t = $("#toast");
|
||||
t.textContent = msg;
|
||||
t.className = "toast" + (kind ? " " + kind : "");
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => t.classList.add("hidden"), 3200);
|
||||
}
|
||||
|
||||
function fmtBytes(n) {
|
||||
n = Number(n) || 0;
|
||||
if (n < 1024) return n + " B";
|
||||
const u = ["KB", "MB", "GB", "TB"];
|
||||
let i = -1;
|
||||
do { n /= 1024; i++; } while (n >= 1024 && i < u.length - 1);
|
||||
return n.toFixed(n < 10 ? 1 : 0) + " " + u[i];
|
||||
}
|
||||
|
||||
function fmtAgo(ts) {
|
||||
if (!ts) return "never";
|
||||
const s = Math.floor(Date.now() / 1000) - ts;
|
||||
if (s < 0) return "just now";
|
||||
if (s < 60) return s + "s ago";
|
||||
if (s < 3600) return Math.floor(s / 60) + "m ago";
|
||||
if (s < 86400) return Math.floor(s / 3600) + "h ago";
|
||||
return Math.floor(s / 86400) + "d ago";
|
||||
}
|
||||
|
||||
function fmtDate(ts) {
|
||||
if (!ts) return "—";
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
// ─── state ───────────────────────────────────────────────────────────────────────
|
||||
let clientsCache = [];
|
||||
let clientFilter = "";
|
||||
let refreshTimer;
|
||||
let clientRows = new Map(); // id -> row (keyed DOM reconciliation, see paintClients)
|
||||
let hideDisabled = false;
|
||||
let hideOffline = false;
|
||||
|
||||
// ─── initial load / routing ────────────────────────────────────────────────────
|
||||
async function boot() {
|
||||
try {
|
||||
const setup = await api("GET", "/api/setup-status");
|
||||
$("#loading").classList.add("hidden");
|
||||
if (!setup.server_initialized) {
|
||||
showSetup(setup);
|
||||
} else {
|
||||
showDashboard();
|
||||
}
|
||||
} catch (e) {
|
||||
$("#loading").textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function showView(id) {
|
||||
$$(".view").forEach((v) => v.classList.add("hidden"));
|
||||
$(id).classList.remove("hidden");
|
||||
}
|
||||
|
||||
// ─── setup wizard ──────────────────────────────────────────────────────────────
|
||||
function showSetup(setup) {
|
||||
showView("#setup");
|
||||
const badge = $("#depsBadge");
|
||||
if (setup.deps_installed) {
|
||||
badge.textContent = "Installed";
|
||||
badge.className = "badge ok";
|
||||
$("#installDepsBtn").disabled = true;
|
||||
} else {
|
||||
badge.textContent = "Not installed";
|
||||
badge.className = "badge no";
|
||||
}
|
||||
}
|
||||
|
||||
$("#installDepsBtn").addEventListener("click", async (e) => {
|
||||
const btn = e.currentTarget;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<svg class="ic ic-sm spin" aria-hidden="true"><use href="#i-refresh"/></svg> Installing… (may take minutes)';
|
||||
try {
|
||||
await api("POST", "/api/install-deps");
|
||||
toast("Dependencies installed", "ok");
|
||||
boot();
|
||||
} catch (err) {
|
||||
toast(err.message, "err");
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Install dependencies";
|
||||
}
|
||||
});
|
||||
|
||||
$("#initForm").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector('button[type=submit]');
|
||||
const fd = new FormData(e.target);
|
||||
const body = {};
|
||||
for (const [k, v] of fd.entries()) body[k] = v.trim();
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<svg class="ic ic-sm spin" aria-hidden="true"><use href="#i-refresh"/></svg> Initialising…';
|
||||
try {
|
||||
await api("POST", "/api/init-server", body);
|
||||
toast("Server initialised", "ok");
|
||||
boot();
|
||||
} catch (err) {
|
||||
toast(err.message, "err");
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Initialise server";
|
||||
}
|
||||
});
|
||||
|
||||
// ─── dashboard ─────────────────────────────────────────────────────────────────
|
||||
async function showDashboard() {
|
||||
showView("#dashboard");
|
||||
paintSkeleton();
|
||||
await refreshAll();
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = setInterval(refreshAll, 10000);
|
||||
}
|
||||
|
||||
// paintSkeleton shows shimmer placeholders until the first data arrives, so the
|
||||
// dashboard never flashes empty "–" cells (Feedback: loading states).
|
||||
function paintSkeleton() {
|
||||
const list = $("#clientList");
|
||||
list.innerHTML = "";
|
||||
for (let n = 0; n < 3; n++) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "client skeleton";
|
||||
row.innerHTML =
|
||||
'<span class="sk sk-dot"></span>' +
|
||||
'<div class="info"><div class="sk sk-line1"></div><div class="sk sk-line2"></div></div>' +
|
||||
'<span class="sk sk-tr"></span>';
|
||||
list.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
let refreshing = false;
|
||||
async function refreshAll() {
|
||||
if (refreshing) return;
|
||||
refreshing = true;
|
||||
const icon = $("#refreshBtn .ic");
|
||||
if (icon) icon.classList.add("spin"); // auto-refresh activity indicator
|
||||
try {
|
||||
const [status, clients] = await Promise.all([
|
||||
api("GET", "/api/status"),
|
||||
api("GET", "/api/clients"),
|
||||
]);
|
||||
renderStatus(status);
|
||||
renderClients(clients);
|
||||
} catch (e) {
|
||||
toast(e.message, "err");
|
||||
} finally {
|
||||
refreshing = false;
|
||||
if (icon) icon.classList.remove("spin");
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatus(s) {
|
||||
$("#stTotal").textContent = s.total_clients ?? 0;
|
||||
$("#stActive").textContent = s.active_clients ?? 0;
|
||||
$("#stOnline").textContent = s.online_clients ?? 0;
|
||||
$("#svIface").textContent = s.interface || "–";
|
||||
$("#svEndpoint").textContent = (s.public_ip || "?") + ":" + (s.port || "?");
|
||||
$("#svNetwork").textContent = s.network || "–";
|
||||
$("#svKey").textContent = s.public_key || "–";
|
||||
const b = $("#ifaceBadge");
|
||||
if (s.interface_up) { b.innerHTML = '<span class="badge-dot"></span>UP'; b.className = "badge up"; }
|
||||
else { b.innerHTML = '<span class="badge-dot"></span>DOWN'; b.className = "badge down"; }
|
||||
}
|
||||
|
||||
// renderClients stores the fresh list, then paints it through the active filter.
|
||||
function renderClients(clients) {
|
||||
clientsCache = clients;
|
||||
paintClients();
|
||||
}
|
||||
|
||||
// createClientRow builds a client row's DOM once and wires its event handlers
|
||||
// once. The row object keeps references to the bits that can change plus the
|
||||
// last-painted value of each, so later updates touch only what actually
|
||||
// changed instead of tearing the node down (see updateClientRow).
|
||||
function createClientRow(c) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "client";
|
||||
el.setAttribute("role", "button");
|
||||
el.tabIndex = 0;
|
||||
el.innerHTML = `
|
||||
<span class="dot"></span>
|
||||
<div class="info">
|
||||
<div class="name"></div>
|
||||
<div class="sub"></div>
|
||||
<div class="note hidden"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-message"/></svg><span></span></div>
|
||||
</div>
|
||||
<div class="traffic">
|
||||
<div class="down"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-arrow-down"/></svg><span></span></div>
|
||||
<div class="up"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-arrow-up"/></svg><span></span></div>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" />
|
||||
<span class="slider"></span>
|
||||
</label>`;
|
||||
|
||||
const row = {
|
||||
id: c.id,
|
||||
el,
|
||||
dot: el.querySelector(".dot"),
|
||||
name: el.querySelector(".name"),
|
||||
sub: el.querySelector(".sub"),
|
||||
note: el.querySelector(".note"),
|
||||
noteText: el.querySelector(".note span"),
|
||||
rx: el.querySelector(".down span"),
|
||||
tx: el.querySelector(".up span"),
|
||||
switchLabel: el.querySelector(".switch"),
|
||||
input: el.querySelector(".switch input"),
|
||||
// last-painted values, used by updateClientRow to skip no-op writes
|
||||
_dotClass: null, _name: null, _sub: null, _note: null,
|
||||
_rx: null, _tx: null, _enabled: null,
|
||||
};
|
||||
|
||||
el.addEventListener("click", (e) => {
|
||||
// Ignore clicks that originate on the toggle switch.
|
||||
if (e.target.closest(".switch")) return;
|
||||
openDetail(row.id);
|
||||
});
|
||||
// Keyboard activation (role="button"): Enter/Space open the detail view,
|
||||
// but not when focus is on the inner switch (it has its own handling).
|
||||
el.addEventListener("keydown", (e) => {
|
||||
if (e.target.closest(".switch")) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
openDetail(row.id);
|
||||
}
|
||||
});
|
||||
row.input.addEventListener("change", () => toggleClient(row.id, row.input));
|
||||
|
||||
updateClientRow(row, c);
|
||||
return row;
|
||||
}
|
||||
|
||||
// updateClientRow paints only the fields that changed since the last call,
|
||||
// so a 10s refresh with unchanged data touches no DOM at all — the row's
|
||||
// entrance animation and focus/scroll state are left completely alone.
|
||||
function updateClientRow(row, c) {
|
||||
const enabled = c.is_enabled === "ACTIVE";
|
||||
|
||||
const dotClass = "dot" + (!enabled ? " disabled" : (c.online ? " online" : ""));
|
||||
if (dotClass !== row._dotClass) {
|
||||
row.dot.className = dotClass;
|
||||
row._dotClass = dotClass;
|
||||
}
|
||||
|
||||
if (c.name !== row._name) {
|
||||
row.name.textContent = c.name;
|
||||
row.el.setAttribute("aria-label", "Open " + c.name);
|
||||
row.input.setAttribute("aria-label", "Enable " + c.name);
|
||||
row._name = c.name;
|
||||
}
|
||||
|
||||
const handshake = enabled
|
||||
? (c.online ? "online" : (c.latest_handshake ? fmtAgo(c.latest_handshake) : "—"))
|
||||
: "disabled";
|
||||
const sub = c.ip + " · " + handshake;
|
||||
if (sub !== row._sub) {
|
||||
row.sub.textContent = sub;
|
||||
row._sub = sub;
|
||||
}
|
||||
|
||||
const noteVal = c.comment || "";
|
||||
if (noteVal !== row._note) {
|
||||
row.note.classList.toggle("hidden", !noteVal);
|
||||
if (noteVal) {
|
||||
row.note.title = noteVal;
|
||||
row.noteText.textContent = noteVal;
|
||||
}
|
||||
row._note = noteVal;
|
||||
}
|
||||
|
||||
const rx = fmtBytes(c.total_rx);
|
||||
if (rx !== row._rx) { row.rx.textContent = rx; row._rx = rx; }
|
||||
const tx = fmtBytes(c.total_tx);
|
||||
if (tx !== row._tx) { row.tx.textContent = tx; row._tx = tx; }
|
||||
|
||||
if (enabled !== row._enabled) {
|
||||
row.switchLabel.title = enabled ? "Enabled" : "Disabled";
|
||||
// Don't stomp on the checkbox mid-toggle: toggleClient disables it for
|
||||
// the duration of its request and repaints once that settles.
|
||||
if (!row.input.disabled) row.input.checked = enabled;
|
||||
row._enabled = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
// clientRank orders the list so online clients surface at the top; everyone
|
||||
// else (enabled-idle and disabled) keeps registry order.
|
||||
function clientRank(c) {
|
||||
return c.online ? 0 : 1;
|
||||
}
|
||||
|
||||
// paintClients reconciles clientsCache (filtered by search/hideDisabled and
|
||||
// sorted by clientRank) against the DOM by client id instead of rebuilding
|
||||
// the list from scratch.
|
||||
// Existing rows are matched by id and patched in place; only genuinely new
|
||||
// rows are created (and get the entrance animation) and only genuinely
|
||||
// removed/filtered-out rows are dropped. On a steady-state refresh this is a
|
||||
// no-op for structure — nothing is torn down, so the list never flashes.
|
||||
function paintClients() {
|
||||
const list = $("#clientList");
|
||||
if (list.querySelector(".skeleton")) {
|
||||
// First real paint after the loading skeleton: those rows aren't keyed.
|
||||
list.innerHTML = "";
|
||||
clientRows.clear();
|
||||
}
|
||||
|
||||
const q = clientFilter.trim().toLowerCase();
|
||||
const clients = clientsCache.filter((c) => {
|
||||
if (hideDisabled && c.is_enabled !== "ACTIVE") return false;
|
||||
if (hideOffline && !c.online) return false;
|
||||
if (!q) return true;
|
||||
return (c.name || "").toLowerCase().includes(q) ||
|
||||
(c.ip || "").toLowerCase().includes(q) ||
|
||||
(c.comment || "").toLowerCase().includes(q);
|
||||
});
|
||||
// Online clients first; stable sort keeps registry order within each group.
|
||||
clients.sort((a, b) => clientRank(a) - clientRank(b));
|
||||
|
||||
$("#clientEmpty").classList.toggle("hidden", clientsCache.length > 0);
|
||||
$("#clientNoMatch").classList.toggle("hidden", !(clientsCache.length > 0 && clients.length === 0));
|
||||
|
||||
const seen = new Set();
|
||||
let prevEl = null;
|
||||
clients.forEach((c, i) => {
|
||||
seen.add(c.id);
|
||||
let row = clientRows.get(c.id);
|
||||
if (!row) {
|
||||
row = createClientRow(c);
|
||||
row.el.style.animationDelay = (Math.min(i, 8) * 35) + "ms";
|
||||
clientRows.set(c.id, row);
|
||||
} else {
|
||||
updateClientRow(row, c);
|
||||
}
|
||||
const ref = prevEl ? prevEl.nextSibling : list.firstChild;
|
||||
if (ref !== row.el) list.insertBefore(row.el, ref);
|
||||
prevEl = row.el;
|
||||
});
|
||||
|
||||
for (const [id, row] of clientRows) {
|
||||
if (!seen.has(id)) {
|
||||
row.el.remove();
|
||||
clientRows.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// toggleClient enables/disables a client from its row switch. The input is
|
||||
// disabled during the request and reverted on failure.
|
||||
async function toggleClient(id, input) {
|
||||
const act = input.checked ? "enable" : "disable";
|
||||
input.disabled = true;
|
||||
try {
|
||||
await api("POST", "/api/clients/" + id + "/" + act);
|
||||
toast("Client " + act + "d", "ok");
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
input.checked = !input.checked; // revert optimistic flip
|
||||
toast(err.message, "err");
|
||||
} finally {
|
||||
input.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── server controls ───────────────────────────────────────────────────────────
|
||||
$$("[data-server]").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const action = btn.dataset.server;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api("POST", "/api/server/" + action);
|
||||
toast("Server " + action + "ed", "ok");
|
||||
await refreshAll();
|
||||
} catch (e) {
|
||||
toast(e.message, "err");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$("#syncBtn").addEventListener("click", async (e) => {
|
||||
const btn = e.currentTarget; // capture before await (currentTarget nulls out)
|
||||
const restart = confirm("Rebuild interface config from the registry.\n\nRestart the service to apply now?");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api("POST", "/api/server/sync", { restart });
|
||||
toast("Config synced" + (restart ? " & restarted" : ""), "ok");
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
toast(err.message, "err");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$("#refreshBtn").addEventListener("click", () => { if (!$("#dashboard").classList.contains("hidden")) refreshAll(); else boot(); });
|
||||
|
||||
// ─── search / filters ──────────────────────────────────────────────────────────
|
||||
$("#clientSearch").addEventListener("input", (e) => {
|
||||
clientFilter = e.target.value;
|
||||
paintClients();
|
||||
});
|
||||
|
||||
$("#hideDisabled").addEventListener("change", (e) => {
|
||||
hideDisabled = e.target.checked;
|
||||
paintClients();
|
||||
});
|
||||
|
||||
$("#hideOffline").addEventListener("change", (e) => {
|
||||
hideOffline = e.target.checked;
|
||||
paintClients();
|
||||
});
|
||||
|
||||
// ─── create client ─────────────────────────────────────────────────────────────
|
||||
$("#createBtn").addEventListener("click", () => openModal("#createModal"));
|
||||
$("#createForm").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector('button[type=submit]');
|
||||
const name = e.target.name.value.trim();
|
||||
if (!name) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const c = await api("POST", "/api/clients", { name });
|
||||
closeModals();
|
||||
e.target.reset();
|
||||
toast("Client “" + c.name + "” created", "ok");
|
||||
await refreshAll();
|
||||
openDetail(c.id);
|
||||
} catch (err) {
|
||||
toast(err.message, "err");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── client detail ─────────────────────────────────────────────────────────────
|
||||
let detailId = null;
|
||||
function openDetail(id) {
|
||||
const c = clientsCache.find((x) => x.id === id);
|
||||
if (!c) return;
|
||||
detailId = id;
|
||||
$("#dName").textContent = c.name;
|
||||
const st = $("#dStatus");
|
||||
if (c.is_enabled === "ACTIVE") {
|
||||
st.innerHTML = c.online
|
||||
? '<span class="badge up">Online</span>'
|
||||
: '<span class="badge">Active · idle</span>';
|
||||
} else {
|
||||
st.innerHTML = '<span class="badge no">Disabled</span>';
|
||||
}
|
||||
$("#dIP").textContent = c.ip;
|
||||
$("#dEndpoint").textContent = c.endpoint || "—";
|
||||
$("#dHandshake").textContent = c.is_enabled === "ACTIVE" ? fmtAgo(c.latest_handshake) : "—";
|
||||
$("#dRx").textContent = fmtBytes(c.total_rx);
|
||||
$("#dTx").textContent = fmtBytes(c.total_tx);
|
||||
$("#dSince").textContent = fmtDate(c.stats_since);
|
||||
$("#dQR").src = "/api/clients/" + id + "/qr?t=" + Date.now();
|
||||
$("#dDownload").href = "/api/clients/" + id + "/config";
|
||||
$("#dDownload").setAttribute("download", c.name + ".conf");
|
||||
|
||||
renderComment(c.comment || "");
|
||||
|
||||
openModal("#detailModal");
|
||||
}
|
||||
|
||||
// renderComment shows the note (or a placeholder) and collapses the editor back
|
||||
// to its read-only view.
|
||||
function renderComment(comment) {
|
||||
const view = $("#dCommentView");
|
||||
if (comment) {
|
||||
view.textContent = comment;
|
||||
view.classList.remove("muted");
|
||||
} else {
|
||||
view.textContent = "— no comment —";
|
||||
view.classList.add("muted");
|
||||
}
|
||||
$("#dCommentView").classList.remove("hidden");
|
||||
$("#dCommentEditor").classList.add("hidden");
|
||||
$("#dCommentEdit").classList.remove("hidden");
|
||||
}
|
||||
|
||||
$("#dCommentEdit").addEventListener("click", () => {
|
||||
const c = clientsCache.find((x) => x.id === detailId);
|
||||
$("#dCommentText").value = c ? (c.comment || "") : "";
|
||||
$("#dCommentView").classList.add("hidden");
|
||||
$("#dCommentEdit").classList.add("hidden");
|
||||
$("#dCommentEditor").classList.remove("hidden");
|
||||
$("#dCommentText").focus();
|
||||
});
|
||||
|
||||
$("#dCommentCancel").addEventListener("click", () => {
|
||||
const c = clientsCache.find((x) => x.id === detailId);
|
||||
renderComment(c ? (c.comment || "") : "");
|
||||
});
|
||||
|
||||
$("#dCommentSave").addEventListener("click", async (e) => {
|
||||
const btn = e.currentTarget;
|
||||
const id = detailId;
|
||||
const comment = $("#dCommentText").value.trim();
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await api("POST", "/api/clients/" + id + "/comment", { comment });
|
||||
const saved = res.comment || "";
|
||||
const c = clientsCache.find((x) => x.id === id);
|
||||
if (c) c.comment = saved; // keep cache in sync without a full refetch
|
||||
renderComment(saved);
|
||||
toast("Comment saved", "ok");
|
||||
} catch (err) {
|
||||
toast(err.message, "err");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$("#dDelete").addEventListener("click", async (e) => {
|
||||
// Capture the button now: e.currentTarget is null after the first await, so
|
||||
// re-enabling it in finally must not go through the event object (that threw
|
||||
// and left the button permanently disabled — the "delete works only once" bug).
|
||||
const btn = e.currentTarget;
|
||||
const id = detailId;
|
||||
const c = clientsCache.find((x) => x.id === id);
|
||||
if (!confirm("Delete client “" + (c ? c.name : id) + "”? This cannot be undone.")) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api("DELETE", "/api/clients/" + id);
|
||||
closeModals();
|
||||
toast("Client deleted", "ok");
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
toast(err.message, "err");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── modal plumbing ────────────────────────────────────────────────────────────
|
||||
function openModal(sel) { $(sel).classList.remove("hidden"); }
|
||||
$("#dResetStats").addEventListener("click", async (e) => {
|
||||
const btn = e.currentTarget; // capture before await (see #dDelete note)
|
||||
const id = detailId;
|
||||
if (!confirm("Reset accumulated traffic stats for this client?")) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api("POST", "/api/clients/" + id + "/stats/reset");
|
||||
toast("Stats reset", "ok");
|
||||
await refreshAll();
|
||||
if (clientsCache.find((x) => x.id === id)) openDetail(id); // re-render with zeroed totals
|
||||
} catch (err) {
|
||||
toast(err.message, "err");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
function closeModals() { $$(".modal").forEach((m) => m.classList.add("hidden")); }
|
||||
$$("[data-close]").forEach((b) => b.addEventListener("click", closeModals));
|
||||
$$(".modal").forEach((m) => m.addEventListener("click", (e) => { if (e.target === m) closeModals(); }));
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeModals(); });
|
||||
|
||||
boot();
|
||||
Reference in New Issue
Block a user