592 lines
21 KiB
JavaScript
592 lines
21 KiB
JavaScript
"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();
|