/* NetDesk management portal. */ "use strict"; const $ = (s) => document.querySelector(s); function fmtBytes(n) { if (n > 1e9) return (n / 1e9).toFixed(2) + " GB"; if (n > 1e6) return (n / 1e6).toFixed(1) + " MB"; if (n > 1e3) return (n / 1e3).toFixed(1) + " KB"; return n + " B"; } function fmtUptime(s) { const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60); return (d ? d + "d " : "") + (h ? h + "h " : "") + m + "m"; } function fmtAgo(ts) { const d = Math.max(0, Math.floor(Date.now() / 1000 - ts)); if (d < 60) return d + "s ago"; if (d < 3600) return Math.floor(d / 60) + "m ago"; if (d < 86400) return Math.floor(d / 3600) + "h ago"; return Math.floor(d / 86400) + "d ago"; } async function api() { const r = await fetch("/portal/api/overview", { cache: "no-store" }); if (r.status === 401) { showLogin(); return null; } return r.json(); } function showLogin() { $("#viewDash").hidden = true; $("#viewLogin").hidden = false; setTimeout(() => $("#pwPortal").focus(), 50); } function showDash() { $("#viewLogin").hidden = true; $("#viewDash").hidden = false; refresh(); } async function login() { const err = $("#loginErr"); err.hidden = true; const r = await fetch("/portal/login", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: "password=" + encodeURIComponent($("#pwPortal").value), redirect: "manual", }); if (r.ok || r.status === 0) { $("#pwPortal").value = ""; showDash(); return; } err.textContent = r.status === 401 ? "Wrong password." : "Login failed (" + r.status + ")"; err.hidden = false; } function flag(cc) { if (!cc || cc.length !== 2 || !/^[a-z]{2}$/i.test(cc)) return "๐ŸŒ"; return String.fromCodePoint(...[...cc.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65)); } // Escape untrusted values (viewer IP from X-Forwarded-For, host names, geo, โ€ฆ) // before they touch innerHTML โ€” prevents XSS in the admin console. function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } // A compact labelled load bar for host telemetry (CPU / RAM / Disk). function bar(label, pct, title) { pct = Math.max(0, Math.min(100, Math.round(pct || 0))); const col = pct > 85 ? "#ef4444" : pct > 60 ? "#f59e0b" : "#22c55e"; return `
${label}` + `` + `${pct}%
`; } function render(d) { d.ownerEmail = (d.users || []).find(u => u.email.toLowerCase() === "owner@example.com"); window.__ownerSeen = !!d.owner; $("#stOnline").textContent = d.hosts_online; $("#stTotal").textContent = d.hosts.length; $("#stSessions").textContent = d.sessions_total; $("#stTraffic").textContent = fmtBytes(d.bytes_relayed); $("#stUptime").textContent = fmtUptime(d.uptime); $("#metaServer").textContent = "server v" + d.version; $("#kvReg").textContent = d.reg_key || "-"; $("#kvServer").textContent = location.origin.replace(/^http/, "ws") + "/ws"; const tb = $("#tblHosts tbody"); tb.innerHTML = ""; for (const h of d.hosts) { const tr = document.createElement("tr"); const dot = document.createElement("td"); dot.innerHTML = `${h.online ? (h.in_session ? "in session" : "online") : "offline"}`; const name = document.createElement("td"); name.textContent = h.name || "(unnamed)"; if (h.hidden) { const badge = document.createElement("span"); badge.className = "pill-hidden"; badge.textContent = "HIDDEN"; name.append(" ", badge); } const id = document.createElement("td"); id.className = "mono"; id.textContent = h.id; const ver = document.createElement("td"); ver.textContent = h.ver || ""; const seen = document.createElement("td"); seen.textContent = h.online ? "now" : fmtAgo(h.last_seen); // live telemetry (CPU / RAM / Disk bars + battery) const met = document.createElement("td"); met.className = "metrics-cell"; const tm = h.telemetry || {}; if (h.online && tm.cpu != null) { const ramPct = tm.ram_total ? (100 * tm.ram_used / tm.ram_total) : 0; const diskPct = tm.disk_total ? (100 * tm.disk_used / tm.disk_total) : 0; let bat = ""; if (tm.battery != null && tm.battery >= 0) { bat = `
${tm.charging ? "โšก" : "๐Ÿ”‹"} ${tm.battery}%
`; } met.innerHTML = bar("CPU", tm.cpu) + bar("RAM", ramPct, fmtBytes(tm.ram_used) + " / " + fmtBytes(tm.ram_total)) + bar("SSD", diskPct, fmtBytes(tm.disk_used) + " / " + fmtBytes(tm.disk_total)) + bat; } else { met.innerHTML = 'โ€”'; } const act = document.createElement("td"); act.style.whiteSpace = "nowrap"; if (h.online && d.owner) { const disc = document.createElement("button"); disc.className = "tb"; disc.textContent = h.in_session ? "Disconnect" : ""; disc.style.marginRight = "6px"; disc.onclick = async () => { await fetch("/portal/api/session/disconnect", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: h.id }) }); refresh(); }; act.append(disc); } { // Hide/unhide is a portal-admin action (the console is password-gated), // so it's available to any authenticated portal session. const hd = document.createElement("button"); hd.className = "tb"; hd.textContent = h.hidden ? "Unhide" : "Hide"; hd.style.marginRight = "6px"; hd.onclick = async () => { await fetch("/portal/api/host/hide", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: h.id, hidden: !h.hidden }) }); refresh(); }; act.append(hd); } if (d.owner) { const rm = document.createElement("button"); rm.className = "tb danger"; rm.textContent = "Remove"; rm.onclick = async () => { if (!confirm("Remove " + h.name + " from the server?")) return; await fetch("/portal/api/host/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: h.id }) }); refresh(); }; act.append(rm); } if (h.online) { const a = document.createElement("a"); a.className = "tb connect"; a.href = "/app?id=" + h.id; a.textContent = "Connect"; act.append(a); } tr.append(dot, name, id, ver, seen, met, act); tb.append(tr); } $("#noHosts").hidden = d.hosts.length > 0; // users section (owner only) const usec = document.querySelector("#sec-users"); if (usec) usec.hidden = !d.owner; if (d.owner) { const utb = document.querySelector("#tblUsers tbody"); if (utb) { utb.innerHTML = ""; for (const u of d.users || []) { const tr = document.createElement("tr"); const em = document.createElement("td"); em.className = "mono"; em.textContent = (u.email.toLowerCase() === (d.ownerEmail || "").toLowerCase()) ? "โญ " + u.email : u.email; const vf = document.createElement("td"); vf.textContent = u.verified ? "yes" : "pending"; const cr = document.createElement("td"); cr.textContent = fmtAgo(u.created); const del = document.createElement("td"); const db = document.createElement("button"); db.className = "tb danger"; db.textContent = "Delete"; db.onclick = async () => { if (!confirm("Delete " + u.email + "?")) return; await fetch("/portal/api/user/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: u.email }) }); refresh(); }; del.append(db); tr.append(em, vf, cr, del); utb.append(tr); } } } const dl = $("#dlList"); if (d.files.length) { dl.innerHTML = ""; dl.classList.remove("muted"); for (const f of d.files) { const a = document.createElement("a"); a.className = "dl-row"; a.href = "/dl/" + encodeURIComponent(f.name); a.innerHTML = `โฌ‡${f.name}${fmtBytes(f.size)}`; dl.append(a); } } // (the #feed audit log is populated by loadAudit(), below) // ---- live visitors (IP intelligence + location) -------------------- const vtb = document.querySelector("#tblVisitors tbody"); if (vtb) { vtb.innerHTML = ""; for (const v of (d.visitors || [])) { const g = v.geo || {}; const loc = g.country ? `${g.city ? g.city + ", " : ""}${g.country}` : "โ€”"; const tr = document.createElement("tr"); tr.innerHTML = `${v.active ? "live" : "connecting"}` + `${esc(v.ip) || "โ€”"}` + `${flag(g.cc)} ${esc(loc)}
${esc(g.isp)}
` + `${esc(v.host_name || v.host)}` + `${v.email ? esc(v.email) : 'guest'} ${esc(v.plan)}` + `${fmtAgo(v.since)}`; vtb.append(tr); } const nv = document.querySelector("#noVis"); if (nv) nv.hidden = (d.visitors || []).length > 0; } const vr = document.querySelector("#visRecent"); if (vr) { vr.innerHTML = ""; for (const v of (d.visitors_recent || []).slice(0, 30)) { const g = v.geo || {}; const loc = g.country ? `${g.city ? g.city + ", " : ""}${g.country}` : ""; const row = document.createElement("div"); row.className = "feed-row"; row.innerHTML = `${fmtAgo(v.t)}` + `${flag(g.cc)} ${esc(v.ip) || "?"}${loc ? " ยท " + esc(loc) : ""} โ†’ ` + `${esc(v.host_name || v.host)} ${esc(v.plan)}`; vr.append(row); } if (!(d.visitors_recent || []).length) vr.innerHTML = '
no visitors yet
'; } } async function refresh() { const d = await api(); if (d) { showDash(); render(d); } } function applyTheme(t) { document.documentElement.dataset.theme = t; const b = document.querySelector("#btnThemeP"); if (b) b.textContent = t === "light" ? "โ˜พ Dark" : "โ˜€ Light"; } applyTheme(localStorage.getItem("nd.theme") || "dark"); if ($("#btnThemeP")) $("#btnThemeP").onclick = () => { const t = document.documentElement.dataset.theme === "light" ? "dark" : "light"; localStorage.setItem("nd.theme", t); applyTheme(t); }; // null-safe binding: a missing element must never kill the whole script function on(sel, fn) { const el = $(sel); if (el) el.onclick = fn; return el; } on("#btnLogin", login); if ($("#pwPortal")) $("#pwPortal").addEventListener("keydown", (e) => { if (e.key === "Enter") login(); }); on("#btnCopyReg", () => { navigator.clipboard.writeText($("#kvReg").textContent); $("#btnCopyReg").textContent = "Copied"; setTimeout(() => ($("#btnCopyReg").textContent = "Copy"), 1200); }); on("#btnCopySrv", () => { navigator.clipboard.writeText($("#kvServer").textContent.replace("/ws", "")); $("#btnCopySrv").textContent = "Copied"; setTimeout(() => ($("#btnCopySrv").textContent = "Copy"), 1200); }); on("#btnLogout", async () => { await fetch("/portal/logout", { redirect: "manual" }); location.href = location.pathname.replace(/\/portal$/, "/"); }); /* ---- audit log console (full persistent log + filters + CSV export) ---- */ function auditParams() { const p = new URLSearchParams(); const k = ($("#auKind") || {}).value, q = (($("#auQ") || {}).value || "").trim(), lim = ($("#auLimit") || {}).value; if (k) p.set("kind", k); if (q) p.set("q", q); if (lim) p.set("limit", lim); return p; } async function loadAudit() { const feed = $("#feed"); if (!feed) return; const p = auditParams(); const csv = $("#auCsv"); if (csv) csv.href = "/portal/api/audit.csv?" + p.toString(); try { const r = await fetch("/portal/api/audit?" + p.toString(), { cache: "no-store" }); if (!r.ok) return; const d = await r.json(); const sel = $("#auKind"); if (sel && sel.options.length <= 1 && Array.isArray(d.kinds)) { for (const k of d.kinds) { const o = document.createElement("option"); o.value = k; o.textContent = k; sel.append(o); } } feed.innerHTML = ""; if (!d.events.length) { feed.innerHTML = '
no matching events
'; return; } for (const e of d.events) { const row = document.createElement("div"); row.className = "feed-row kind-" + String(e.kind || "").replace(/[^a-z-]/g, ""); const dt = new Date((e.ts || 0) * 1000); const t = dt.toLocaleDateString() + " " + dt.toLocaleTimeString(); const act = e.actor ? ` ยท ${esc(e.actor)}` : ""; row.innerHTML = `${t}${esc(e.kind || "")}${esc(e.msg || "")}${act}`; feed.append(row); } } catch (err) {} } (function wireAudit() { const r = $("#auRefresh"); if (r) r.addEventListener("click", loadAudit); const k = $("#auKind"); if (k) k.addEventListener("change", loadAudit); const l = $("#auLimit"); if (l) l.addEventListener("change", loadAudit); const q = $("#auQ"); let t; if (q) q.addEventListener("input", () => { clearTimeout(t); t = setTimeout(loadAudit, 300); }); })(); setInterval(loadAudit, 8000); refresh().then(() => { loadAudit(); setInterval(refresh, 3000); }).catch((e) => { showLogin(); });