"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 = ' 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 = ' 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 = '' + '
' + ''; 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 = 'UP'; b.className = "badge up"; } else { b.innerHTML = '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 = `
`; 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 ? 'Online' : 'Active · idle'; } else { st.innerHTML = 'Disabled'; } $("#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();