Files
awg-profiler-golang/webui_glass/app.js
T
2026-07-18 10:02:43 +03:00

592 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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();