Files
awg-profiler-golang/webui/app.js
T

592 lines
21 KiB
JavaScript
Raw Normal View History

2026-07-18 10:02:43 +03:00
"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();