Files
netdesk/server/web/portal.js
T

350 lines
14 KiB
JavaScript

/* 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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 `<div class="mbar" title="${esc(title || "")}"><span class="mlab">${label}</span>` +
`<span class="mtrack"><span class="mfill" style="width:${pct}%;background:${col}"></span></span>` +
`<span class="mpct">${pct}%</span></div>`;
}
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 = `<span class="dot ${h.online ? "on" : "off"}"></span>${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 = `<div class="bat">${tm.charging ? "⚡" : "🔋"} ${tm.battery}%</div>`;
}
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 = '<span class="muted sm">—</span>';
}
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 = `<span>⬇</span><span class="name">${f.name}</span><span class="stats">${fmtBytes(f.size)}</span>`;
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 =
`<td><span class="dot on"></span>${v.active ? "live" : "connecting"}</td>` +
`<td class="mono">${esc(v.ip) || "—"}</td>` +
`<td>${flag(g.cc)} ${esc(loc)}<div class="muted sm">${esc(g.isp)}</div></td>` +
`<td>${esc(v.host_name || v.host)}</td>` +
`<td>${v.email ? esc(v.email) : '<span class="muted">guest</span>'} <span class="pill">${esc(v.plan)}</span></td>` +
`<td>${fmtAgo(v.since)}</td>`;
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 =
`<span class="stats">${fmtAgo(v.t)}</span>` +
`<span>${flag(g.cc)} <b class="mono">${esc(v.ip) || "?"}</b>${loc ? " · " + esc(loc) : ""} → ` +
`${esc(v.host_name || v.host)} <span class="pill">${esc(v.plan)}</span></span>`;
vr.append(row);
}
if (!(d.visitors_recent || []).length) vr.innerHTML = '<div class="muted">no visitors yet</div>';
}
}
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 = '<div class="muted">no matching events</div>'; 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 ? `<span class="act"> · ${esc(e.actor)}</span>` : "";
row.innerHTML = `<span class="stats">${t}</span><span><span class="kbadge">${esc(e.kind || "")}</span>${esc(e.msg || "")}${act}</span>`;
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(); });