init commit
This commit is contained in:
+591
@@ -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();
|
||||
@@ -0,0 +1,208 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0F172A" />
|
||||
<title>AmneziaWG Profiler</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2322C55E' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z'/%3E%3C/svg%3E" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- ── inline SVG icon sprite (Lucide geometry; no external CDN) ────────────── -->
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
|
||||
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></symbol>
|
||||
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="M6 6l12 12"/></symbol>
|
||||
<symbol id="i-message" viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></symbol>
|
||||
<symbol id="i-arrow-down" viewBox="0 0 24 24"><path d="M12 5v14"/><path d="M19 12l-7 7-7-7"/></symbol>
|
||||
<symbol id="i-arrow-up" viewBox="0 0 24 24"><path d="M12 19V5"/><path d="M5 12l7-7 7 7"/></symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.3-4.3"/></symbol>
|
||||
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/></symbol>
|
||||
<symbol id="i-download" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></symbol>
|
||||
<symbol id="i-play" viewBox="0 0 24 24"><path d="M6 3l14 9-14 9V3z"/></symbol>
|
||||
<symbol id="i-stop" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></symbol>
|
||||
<symbol id="i-rotate" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24"><path d="M10 4H6v16h4z"/><path d="M18 4h-4v16h4z"/></symbol>
|
||||
</svg>
|
||||
|
||||
<main id="app">
|
||||
<!-- Loading placeholder -->
|
||||
<div id="loading" class="center muted">
|
||||
<svg class="ic ic-lg spin" aria-hidden="true"><use href="#i-refresh" /></svg>
|
||||
<div>Loading…</div>
|
||||
</div>
|
||||
|
||||
<!-- Setup wizard (shown when server not initialised) -->
|
||||
<section id="setup" class="view hidden">
|
||||
<h1>Server setup</h1>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>1 · Install dependencies</h2>
|
||||
<span id="depsBadge" class="badge">…</span>
|
||||
</div>
|
||||
<p class="muted">Installs AmneziaWG, qrencode, jq and nftables for this OS.</p>
|
||||
<button id="installDepsBtn" class="btn btn-primary">Install dependencies</button>
|
||||
</div>
|
||||
|
||||
<div class="card" id="initCard">
|
||||
<div class="card-head"><h2>2 · Initialise server</h2></div>
|
||||
<p class="muted">Generates keys & obfuscation parameters, writes configs and starts the service.</p>
|
||||
<form id="initForm" class="form">
|
||||
<label>Interface <input name="interface" placeholder="awg0" /></label>
|
||||
<label>Network CIDR <input name="network" placeholder="10.0.0.0/24" /></label>
|
||||
<label>Listen port <input name="port" inputmode="numeric" placeholder="51820" /></label>
|
||||
<label>Public IP / host <input name="public_ip" placeholder="auto-detect" /></label>
|
||||
<label>Client DNS <input name="dns" placeholder="1.1.1.1" /></label>
|
||||
<label>MTU <input name="mtu" inputmode="numeric" placeholder="1420" /></label>
|
||||
<button type="submit" class="btn btn-primary">Initialise server</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<section id="dashboard" class="view hidden">
|
||||
<div class="stat-grid">
|
||||
<div class="stat">
|
||||
<div class="stat-val" id="stTotal"><span class="sk sk-num"></span></div>
|
||||
<div class="stat-label">Clients</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-val" id="stActive"><span class="sk sk-num"></span></div>
|
||||
<div class="stat-label">Active</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-val stat-online" id="stOnline"><span class="sk sk-num"></span></div>
|
||||
<div class="stat-label">Online</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Server</h2>
|
||||
<span id="ifaceBadge" class="badge"><span class="badge-dot"></span>…</span>
|
||||
</div>
|
||||
<dl class="kv">
|
||||
<dt>Interface</dt><dd id="svIface">–</dd>
|
||||
<dt>Endpoint</dt><dd id="svEndpoint" class="mono">–</dd>
|
||||
<dt>Network</dt><dd id="svNetwork" class="mono">–</dd>
|
||||
<dt>Public key</dt><dd id="svKey" class="mono ellipsis">–</dd>
|
||||
</dl>
|
||||
<div class="btn-row">
|
||||
<button class="btn" data-server="start"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-play" /></svg>Start</button>
|
||||
<button class="btn" data-server="restart"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-rotate" /></svg>Restart</button>
|
||||
<button class="btn btn-danger-ghost" data-server="stop"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-stop" /></svg>Stop</button>
|
||||
<button class="btn" id="syncBtn"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-refresh" /></svg>Sync</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-head">
|
||||
<h2>Clients</h2>
|
||||
<div class="section-actions">
|
||||
<button id="refreshBtn" class="icon-btn" title="Refresh" aria-label="Refresh">
|
||||
<svg class="ic" aria-hidden="true"><use href="#i-refresh" /></svg>
|
||||
</button>
|
||||
<button id="createBtn" class="btn btn-primary btn-sm"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-plus" /></svg>New</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-wrap">
|
||||
<svg class="ic search-ic" aria-hidden="true"><use href="#i-search" /></svg>
|
||||
<input id="clientSearch" type="search" autocomplete="off" spellcheck="false"
|
||||
placeholder="Search by name, IP or comment…" aria-label="Search clients" />
|
||||
</div>
|
||||
<div class="list-filter">
|
||||
<span class="filter-item">
|
||||
<span>Hide offline</span>
|
||||
<label class="switch" title="Hide offline clients">
|
||||
<input type="checkbox" id="hideOffline" aria-label="Hide offline clients" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</span>
|
||||
<span class="filter-item">
|
||||
<span>Hide disabled</span>
|
||||
<label class="switch" title="Hide disabled clients">
|
||||
<input type="checkbox" id="hideDisabled" aria-label="Hide disabled clients" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
<div id="clientList" class="client-list"></div>
|
||||
<div id="clientEmpty" class="center muted hidden">No clients yet — create one.</div>
|
||||
<div id="clientNoMatch" class="center muted hidden">No clients match your filters.</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Create client modal -->
|
||||
<div id="createModal" class="modal hidden">
|
||||
<div class="modal-card">
|
||||
<h2>New client</h2>
|
||||
<form id="createForm" class="form">
|
||||
<label>Name
|
||||
<input name="name" autocomplete="off" placeholder="phone" required />
|
||||
</label>
|
||||
<div class="btn-row end">
|
||||
<button type="button" class="btn" data-close>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client detail / QR modal -->
|
||||
<div id="detailModal" class="modal hidden">
|
||||
<div class="modal-card">
|
||||
<div class="card-head">
|
||||
<h2 id="dName">Client</h2>
|
||||
<button class="icon-btn" data-close aria-label="Close">
|
||||
<svg class="ic" aria-hidden="true"><use href="#i-x" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<dl class="kv">
|
||||
<dt>Status</dt><dd id="dStatus">–</dd>
|
||||
<dt>IP</dt><dd id="dIP" class="mono">–</dd>
|
||||
<dt>Endpoint</dt><dd id="dEndpoint" class="mono">–</dd>
|
||||
<dt>Handshake</dt><dd id="dHandshake">–</dd>
|
||||
<dt>Received</dt><dd id="dRx" class="mono">–</dd>
|
||||
<dt>Sent</dt><dd id="dTx" class="mono">–</dd>
|
||||
</dl>
|
||||
|
||||
<div class="stats-meta">
|
||||
<span class="muted">Stats since <span id="dSince">–</span></span>
|
||||
<button id="dResetStats" type="button" class="btn btn-sm">
|
||||
<svg class="ic ic-sm" aria-hidden="true"><use href="#i-rotate" /></svg>Reset stats
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="comment-block">
|
||||
<div class="comment-head">
|
||||
<span class="comment-label">
|
||||
<svg class="ic ic-sm" aria-hidden="true"><use href="#i-message" /></svg>Comment
|
||||
</span>
|
||||
<button id="dCommentEdit" type="button" class="btn btn-sm">Edit</button>
|
||||
</div>
|
||||
<div id="dCommentView" class="comment-view muted">—</div>
|
||||
<div id="dCommentEditor" class="comment-editor hidden">
|
||||
<textarea id="dCommentText" rows="2" maxlength="500"
|
||||
placeholder="Add a note for this profile…"></textarea>
|
||||
<div class="btn-row end">
|
||||
<button id="dCommentCancel" type="button" class="btn btn-sm">Cancel</button>
|
||||
<button id="dCommentSave" type="button" class="btn btn-primary btn-sm">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="qr-wrap"><img id="dQR" alt="Client configuration QR code" /></div>
|
||||
<div class="btn-row">
|
||||
<a id="dDownload" class="btn btn-primary" download><svg class="ic ic-sm" aria-hidden="true"><use href="#i-download" /></svg>Download .conf</a>
|
||||
<button id="dDelete" class="btn btn-danger"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-trash" /></svg>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast hidden" role="status" aria-live="polite"></div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+548
@@ -0,0 +1,548 @@
|
||||
:root {
|
||||
/* Modern Dark (operations dashboard) — deep slate + connected-green.
|
||||
Semantic tokens; components never use raw hex. */
|
||||
--bg: #0F172A; /* slate-900, avoids pure black (OLED smear) */
|
||||
--bg-elev: #172033; /* elevated surface */
|
||||
--bg-elev2: #1E293B; /* slate-800, pressed / inset */
|
||||
--line: rgba(255, 255, 255, .09);
|
||||
--text: #F8FAFC;
|
||||
--muted: #94A3B8; /* slate-400, ≥3:1 on --bg */
|
||||
--accent: #22C55E; /* connected green — brand + primary CTA */
|
||||
--accent-ink: #052E16; /* near-black-green ink on accent, high contrast */
|
||||
--green: #22C55E; /* online */
|
||||
--sky: #38BDF8; /* rx / download traffic */
|
||||
--red: #F87171; /* danger text / ghost */
|
||||
--danger: #DC2626; /* destructive fill */
|
||||
--amber: #FBBF24; /* disabled / warning */
|
||||
--radius: 14px;
|
||||
--radius-sm: 10px;
|
||||
--shadow: 0 10px 30px rgba(0, 0, 0, .45);
|
||||
--maxw: 640px;
|
||||
--icon-sm: 16px;
|
||||
--icon-md: 20px;
|
||||
--icon-lg: 24px;
|
||||
/* dense/dashboard spacing rhythm (density 8/10) */
|
||||
--sp-1: 4px;
|
||||
--sp-2: 8px;
|
||||
--sp-3: 12px;
|
||||
--sp-4: 16px;
|
||||
--sp-5: 24px;
|
||||
}
|
||||
|
||||
/* Light palette. The server stamps data-theme on <html> per the admin's
|
||||
--ui-mode (default "dark" forces the dark base above regardless of OS):
|
||||
data-theme="light" — admin forced light
|
||||
data-theme="auto" — follow the OS, so only light under a light OS pref
|
||||
Forced dark emits neither trigger, so light never applies. */
|
||||
:root[data-theme="light"],
|
||||
:root[data-theme="auto"] { color-scheme: light; }
|
||||
:root[data-theme="dark"] { color-scheme: dark; }
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--bg: #F1F5F9; /* slate-100, not pure white */
|
||||
--bg-elev: #FFFFFF;
|
||||
--bg-elev2: #E2E8F0;
|
||||
--line: #E2E8F0;
|
||||
--text: #0F172A;
|
||||
--muted: #475569; /* slate-600, ≥4.5:1 on light surfaces */
|
||||
--accent: #16A34A; /* green-600 for crisper contrast on light */
|
||||
--accent-ink: #FFFFFF;
|
||||
--green: #16A34A;
|
||||
--sky: #0284C7;
|
||||
--red: #DC2626;
|
||||
--danger: #DC2626;
|
||||
--amber: #B45309;
|
||||
--shadow: 0 8px 24px rgba(15, 23, 32, .12);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root[data-theme="auto"] {
|
||||
--bg: #F1F5F9; /* slate-100, not pure white */
|
||||
--bg-elev: #FFFFFF;
|
||||
--bg-elev2: #E2E8F0;
|
||||
--line: #E2E8F0;
|
||||
--text: #0F172A;
|
||||
--muted: #475569; /* slate-600, ≥4.5:1 on light surfaces */
|
||||
--accent: #16A34A; /* green-600 for crisper contrast on light */
|
||||
--accent-ink: #FFFFFF;
|
||||
--green: #16A34A;
|
||||
--sky: #0284C7;
|
||||
--red: #DC2626;
|
||||
--danger: #DC2626;
|
||||
--amber: #B45309;
|
||||
--shadow: 0 8px 24px rgba(15, 23, 32, .12);
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-variant-numeric: tabular-nums; }
|
||||
.muted { color: var(--muted); }
|
||||
.hidden { display: none !important; }
|
||||
.center { text-align: center; padding: 40px 16px; }
|
||||
.ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ── icons ── */
|
||||
.ic {
|
||||
width: var(--icon-md);
|
||||
height: var(--icon-md);
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
flex: 0 0 auto;
|
||||
vertical-align: -.15em;
|
||||
}
|
||||
.ic-sm { width: var(--icon-sm); height: var(--icon-sm); }
|
||||
.ic-lg { width: var(--icon-lg); height: var(--icon-lg); }
|
||||
/* filled glyphs (play / stop / pause) */
|
||||
#i-play, #i-stop, #i-pause { fill: currentColor; stroke: none; }
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
transition: background .15s;
|
||||
}
|
||||
.icon-btn:active { background: var(--bg-elev2); }
|
||||
.icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
/* ── layout ── */
|
||||
main {
|
||||
max-width: var(--maxw);
|
||||
margin: 0 auto;
|
||||
padding: max(16px, env(safe-area-inset-top)) 16px calc(32px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
h1 { font-size: 22px; margin: 8px 0 16px; font-weight: 700; }
|
||||
h2 { font-size: 16px; margin: 0; font-weight: 620; }
|
||||
|
||||
#loading { display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
||||
|
||||
/* ── stat grid ── */
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.stat {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-val {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-height: 28px;
|
||||
}
|
||||
.stat-online { color: var(--green); }
|
||||
.stat-label { font-size: 12px; color: var(--muted); margin-top: 6px; text-transform: uppercase; letter-spacing: .5px; }
|
||||
|
||||
/* ── cards ── */
|
||||
.card {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.card p.muted { margin: 0 0 12px; font-size: 14px; }
|
||||
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 16px;
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.stats-meta {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; margin: 12px 0 4px; font-size: 13px;
|
||||
}
|
||||
.kv dt { color: var(--muted); }
|
||||
.kv dd { margin: 0; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ── badges ── */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-elev2);
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: 0 0 auto; }
|
||||
.badge.up { background: color-mix(in srgb, var(--green) 20%, transparent); color: var(--green); }
|
||||
.badge.down { background: color-mix(in srgb, var(--red) 20%, transparent); color: var(--red); }
|
||||
.badge.ok { background: color-mix(in srgb, var(--green) 20%, transparent); color: var(--green); }
|
||||
.badge.no { background: color-mix(in srgb, var(--amber) 20%, transparent); color: var(--amber); }
|
||||
|
||||
/* ── buttons ── */
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-elev2);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 550;
|
||||
padding: 10px 16px;
|
||||
min-height: 44px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: transform .12s cubic-bezier(.16, 1, .3, 1), filter .15s, background .15s;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.btn:hover { filter: brightness(1.08); }
|
||||
.btn:active { transform: scale(.97); }
|
||||
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.btn:disabled { opacity: .5; cursor: default; filter: none; }
|
||||
.btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
|
||||
.btn-danger { background: var(--danger); color: #fff; border-color: transparent; }
|
||||
.btn-danger-ghost { color: var(--red); }
|
||||
.btn-sm { padding: 7px 12px; min-height: 38px; font-size: 13px; }
|
||||
.btn-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.btn-row.end { justify-content: flex-end; }
|
||||
/* flex-basis 0 makes buttons that share a row equal width regardless of their
|
||||
label length (e.g. "Download .conf" vs "Delete" in the client modal). */
|
||||
.btn-row .btn { flex: 1 1 0; min-width: 0; }
|
||||
.btn-row.end .btn { flex: 0 0 auto; }
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 24px 0 12px;
|
||||
}
|
||||
.section-actions { display: flex; align-items: center; gap: 4px; }
|
||||
.section-actions .icon-btn { width: 38px; height: 38px; color: var(--muted); }
|
||||
.section-actions .icon-btn:hover { color: var(--text); }
|
||||
|
||||
/* ── forms ── */
|
||||
.form { display: flex; flex-direction: column; gap: 12px; }
|
||||
.form label { display: flex; flex-direction: column; gap: 5px; font-size: 13px; color: var(--muted); font-weight: 500; }
|
||||
.form input {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
padding: 12px;
|
||||
min-height: 44px;
|
||||
width: 100%;
|
||||
}
|
||||
.form input:focus { outline: 2px solid var(--accent); outline-offset: 0; border-color: transparent; }
|
||||
|
||||
/* ── search ── */
|
||||
.search-wrap { position: relative; margin-bottom: 12px; }
|
||||
.search-ic {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
.search-wrap input {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
padding: 12px 14px 12px 40px;
|
||||
min-height: 44px;
|
||||
width: 100%;
|
||||
}
|
||||
.search-wrap input:focus { outline: 2px solid var(--accent); outline-offset: 0; border-color: transparent; }
|
||||
.search-wrap input::placeholder { color: var(--muted); }
|
||||
|
||||
.list-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
margin: -4px 0 12px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ── comment block (client modal) ── */
|
||||
.comment-block {
|
||||
border-top: 1px solid var(--line);
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.comment-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.comment-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
.comment-view { font-size: 14px; white-space: pre-wrap; word-break: break-word; }
|
||||
.comment-editor textarea {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
padding: 10px 12px;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
.comment-editor textarea:focus { outline: 2px solid var(--accent); outline-offset: 0; border-color: transparent; }
|
||||
|
||||
/* ── client list ── */
|
||||
.client-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.client {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s, transform .12s cubic-bezier(.16, 1, .3, 1);
|
||||
}
|
||||
.client:hover { border-color: color-mix(in srgb, var(--accent) 40%, var(--line)); }
|
||||
.client:active { background: var(--bg-elev2); transform: scale(.99); }
|
||||
.client:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.client .dot {
|
||||
position: relative;
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
background: transparent;
|
||||
border: 2px solid var(--muted);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
/* status conveyed by shape + colour, not colour alone */
|
||||
.client .dot.online {
|
||||
background: var(--green); border-color: var(--green);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--green) 22%, transparent);
|
||||
animation: livepulse 2s ease-in-out infinite;
|
||||
}
|
||||
.client .dot.disabled {
|
||||
border-color: var(--amber); background: transparent;
|
||||
}
|
||||
.client .dot.disabled::after {
|
||||
content: ""; position: absolute; inset: 2px 1px; border-top: 2px solid var(--amber);
|
||||
transform: translateY(2px);
|
||||
}
|
||||
.client .info { flex: 1 1 auto; min-width: 0; }
|
||||
.client .name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.client .sub { font-size: 12px; color: var(--muted); font-family: ui-monospace, monospace; font-variant-numeric: tabular-nums; }
|
||||
.client .note { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); margin-top: 2px; overflow: hidden; }
|
||||
.client .note span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.client .traffic { text-align: right; font-size: 12px; color: var(--muted); flex: 0 0 auto; font-variant-numeric: tabular-nums; }
|
||||
.client .traffic > div { display: flex; align-items: center; justify-content: flex-end; gap: 3px; }
|
||||
.client .traffic .down { color: var(--sky); }
|
||||
.client .traffic .up { color: var(--green); }
|
||||
|
||||
@keyframes livepulse {
|
||||
0%, 100% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--green) 22%, transparent); }
|
||||
50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--green) 6%, transparent); }
|
||||
}
|
||||
|
||||
/* ── skeletons ── */
|
||||
.sk {
|
||||
display: inline-block;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--muted) 16%, transparent) 25%,
|
||||
color-mix(in srgb, var(--muted) 28%, transparent) 37%,
|
||||
color-mix(in srgb, var(--muted) 16%, transparent) 63%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s ease infinite;
|
||||
}
|
||||
.sk-num { width: 40%; height: 26px; }
|
||||
.client.skeleton { pointer-events: none; animation: none; }
|
||||
.client.skeleton .sk-dot { width: 12px; height: 12px; border-radius: 50%; }
|
||||
.client.skeleton .info .sk { display: block; }
|
||||
.client.skeleton .sk-line1 { width: 45%; height: 12px; margin-bottom: 7px; }
|
||||
.client.skeleton .sk-line2 { width: 65%; height: 10px; }
|
||||
.client.skeleton .sk-tr { width: 44px; height: 24px; }
|
||||
@keyframes shimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } }
|
||||
|
||||
/* ── toggle switch ── */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 46px;
|
||||
height: 44px; /* ≥44px touch target; visual track is the slider inside */
|
||||
cursor: pointer;
|
||||
}
|
||||
.switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.switch .slider {
|
||||
position: relative;
|
||||
width: 46px;
|
||||
height: 28px;
|
||||
background: var(--bg-elev2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
transition: background .18s, border-color .18s;
|
||||
}
|
||||
.switch .slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
transition: transform .18s cubic-bezier(.16, 1, .3, 1), background .18s;
|
||||
}
|
||||
.switch input:checked + .slider {
|
||||
background: color-mix(in srgb, var(--green) 30%, transparent);
|
||||
border-color: transparent;
|
||||
}
|
||||
.switch input:checked + .slider::before {
|
||||
transform: translateX(18px);
|
||||
background: var(--green);
|
||||
}
|
||||
.switch input:focus-visible + .slider { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.switch input:disabled { cursor: default; }
|
||||
.switch input:disabled + .slider { opacity: .5; }
|
||||
|
||||
/* ── modal ── */
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, .55);
|
||||
padding: 0;
|
||||
animation: fade .15s ease;
|
||||
}
|
||||
@media (min-width: 560px) { .modal { align-items: center; padding: 16px; } }
|
||||
.modal-card {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
box-shadow: var(--shadow);
|
||||
width: 100%;
|
||||
max-width: var(--maxw);
|
||||
padding: 20px 16px calc(20px + env(safe-area-inset-bottom));
|
||||
animation: slideup .24s cubic-bezier(.16, 1, .3, 1);
|
||||
}
|
||||
@media (min-width: 560px) { .modal-card { border-radius: var(--radius); animation: popin .2s cubic-bezier(.16, 1, .3, 1); } }
|
||||
.modal-card h2 { margin-bottom: 14px; }
|
||||
|
||||
.qr-wrap { text-align: center; margin: 16px 0; }
|
||||
.qr-wrap img {
|
||||
width: 220px; max-width: 70%;
|
||||
background: #fff; padding: 10px; border-radius: var(--radius-sm);
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes slideup { from { transform: translateY(24px); opacity: .6; } to { transform: translateY(0); opacity: 1; } }
|
||||
@keyframes popin { from { transform: scale(.96); opacity: .6; } to { transform: scale(1); opacity: 1; } }
|
||||
@keyframes rowin { from { transform: translateY(6px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
|
||||
/* staggered list entrance (subtle) */
|
||||
.client-list .client { animation: rowin .28s cubic-bezier(.16, 1, .3, 1) both; }
|
||||
|
||||
/* ── toast ── */
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(24px + env(safe-area-inset-bottom));
|
||||
transform: translateX(-50%);
|
||||
z-index: 100;
|
||||
background: var(--bg-elev2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
padding: 11px 18px;
|
||||
border-radius: 999px;
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 14px;
|
||||
max-width: 90%;
|
||||
animation: fade .15s ease;
|
||||
}
|
||||
.toast.err { border-color: var(--red); color: var(--red); }
|
||||
.toast.ok { border-color: var(--green); }
|
||||
|
||||
.spin { display: inline-block; animation: rot 1s linear infinite; transform-origin: center; }
|
||||
@keyframes rot { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── reduced motion ── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: .001ms !important;
|
||||
}
|
||||
.sk { animation: none; opacity: .6; }
|
||||
}
|
||||
Reference in New Issue
Block a user