350 lines
14 KiB
JavaScript
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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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(); });
|