/* Stats page: live tiles plus throughput and connection charts (plain SVG). */ "use strict"; (() => { const sinkSel = document.getElementById("sink-select"); if (!sinkSel) return; const rangeGroup = document.getElementById("range-group"); const tooltip = document.getElementById("chart-tooltip"); const rateBox = document.querySelector('#chart-rate .chart'); const connBox = document.querySelector('#chart-conns .chart'); const tbody = document.querySelector("#stats-table tbody"); const SVG = "http://www.w3.org/2000/svg"; const M = { top: 12, right: 44, bottom: 26, left: 64 }; let range = "24h"; let data = null; function svgEl(tag, attrs) { const e = document.createElementNS(SVG, tag); for (const [k, v] of Object.entries(attrs || {})) e.setAttribute(k, v); return e; } function niceMax(v) { if (v <= 0) return 1; const p = Math.pow(10, Math.floor(Math.log10(v))); for (const m of [1, 2, 2.5, 5, 10]) if (m * p >= v) return m * p; return 10 * p; } function timeLabel(ts, span) { const d = new Date(ts * 1000); if (span <= 2 * 86400) return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); if (span <= 60 * 86400) return d.toLocaleDateString([], { month: "short", day: "numeric" }); return d.toLocaleDateString([], { year: "2-digit", month: "short" }); } function fullTime(ts) { return new Date(ts * 1000).toLocaleString(); } // Dense series with zero-filled gaps, one entry per bucket. function buckets(d) { const byTs = new Map(d.points.map((p) => [p[0], p])); let start = d.since ? Math.floor(d.since / d.step) * d.step : (d.points.length ? d.points[0][0] : d.now); const end = Math.floor(d.now / d.step) * d.step; if (end - start > 2000 * d.step) start = end - 2000 * d.step; const out = []; for (let t = start; t <= end; t += d.step) { const p = byTs.get(t); out.push({ ts: t, in: p ? p[1] : 0, out: p ? p[2] : 0, conns: p ? p[3] : 0 }); } return out; } function frame(box, yMax, fmt, rows) { box.replaceChildren(); const w = box.clientWidth, h = box.clientHeight; const svg = svgEl("svg", { viewBox: `0 0 ${w} ${h}`, role: "img" }); const pw = w - M.left - M.right, ph = h - M.top - M.bottom; const x = (i) => M.left + (rows.length > 1 ? (i / (rows.length - 1)) * pw : pw / 2); const y = (v) => M.top + ph - (v / yMax) * ph; const axis = svgEl("g", { class: "axis" }); for (let k = 0; k <= 4; k++) { const v = (yMax * k) / 4, yy = y(v); axis.append(svgEl("line", { class: "gridline", x1: M.left, x2: M.left + pw, y1: yy, y2: yy })); const t = svgEl("text", { x: M.left - 8, y: yy + 4, "text-anchor": "end" }); t.textContent = fmt(v); axis.append(t); } const span = rows.length ? rows[rows.length - 1].ts - rows[0].ts : 0; const nt = Math.max(2, Math.min(6, Math.floor(pw / 110))); for (let k = 0; k <= nt && rows.length > 1; k++) { const i = Math.round((k / nt) * (rows.length - 1)); const t = svgEl("text", { x: x(i), y: h - 6, "text-anchor": k === 0 ? "start" : k === nt ? "end" : "middle" }); t.textContent = timeLabel(rows[i].ts, span); axis.append(t); } svg.append(axis); box.append(svg); return { svg, w, h, pw, ph, x, y }; } function showTip(ev, rows) { tooltip.replaceChildren(); for (const r of rows) { if (r.time) { tooltip.append(PB.el("div", { class: "t-time", text: r.time })); continue; } const row = PB.el("div", { class: "t-row" }); if (r.cls) row.append(PB.el("span", { class: `swatch-line ${r.cls}` })); row.append(PB.el("strong", { text: r.value }), PB.el("span", { class: "muted", text: r.label })); tooltip.append(row); } tooltip.hidden = false; const tw = tooltip.offsetWidth, th = tooltip.offsetHeight; let left = ev.clientX + 14, top = ev.clientY - th - 10; if (left + tw > window.innerWidth - 8) left = ev.clientX - tw - 14; if (top < 8) top = ev.clientY + 16; tooltip.style.left = left + "px"; tooltip.style.top = top + "px"; } function hideTip() { tooltip.hidden = true; } function drawRate(rows, step) { const rates = rows.map((r) => ({ ts: r.ts, a: r.in / step, b: r.out / step })); const yMax = niceMax(Math.max(0, ...rates.map((r) => Math.max(r.a, r.b)))); const f = frame(rateBox, yMax, (v) => PB.rate(v), rows); if (!rows.length) return; for (const [key, cls, label] of [["a", "s1", "In"], ["b", "s2", "Out"]]) { const d = rates.map((r, i) => `${i ? "L" : "M"} ${f.x(i).toFixed(1)} ${f.y(r[key]).toFixed(1)}`).join(" "); f.svg.append(svgEl("path", { class: `line ${cls}`, d })); // Direct end label; the legend above repeats it. const last = rates[rates.length - 1]; const t = svgEl("text", { class: "endlabel", x: f.x(rates.length - 1) + 6, y: f.y(last[key]) + 4 }); t.textContent = label; f.svg.append(t); } // Keep end labels apart when both series end at the same height. const labels = f.svg.querySelectorAll(".endlabel"); if (labels.length === 2 && Math.abs(labels[0].getAttribute("y") - labels[1].getAttribute("y")) < 12) { labels[0].setAttribute("y", Number(labels[0].getAttribute("y")) - 6); labels[1].setAttribute("y", Number(labels[1].getAttribute("y")) + 6); } const cross = svgEl("line", { class: "crosshair", y1: M.top, y2: M.top + f.ph, visibility: "hidden" }); const m1 = svgEl("circle", { class: "marker s1", r: 4, visibility: "hidden" }); const m2 = svgEl("circle", { class: "marker s2", r: 4, visibility: "hidden" }); const hit = svgEl("rect", { x: M.left, y: M.top, width: f.pw, height: f.ph, fill: "transparent" }); f.svg.append(cross, m1, m2, hit); hit.addEventListener("pointermove", (ev) => { const r = f.svg.getBoundingClientRect(); const px = ((ev.clientX - r.left) / r.width) * f.w; const i = Math.max(0, Math.min(rates.length - 1, Math.round(((px - M.left) / f.pw) * (rates.length - 1)))); const xx = f.x(i); for (const [el, v] of [[cross, null], [m1, rates[i].a], [m2, rates[i].b]]) { el.setAttribute("visibility", "visible"); if (v === null) { el.setAttribute("x1", xx); el.setAttribute("x2", xx); } else { el.setAttribute("cx", xx); el.setAttribute("cy", f.y(v)); } } showTip(ev, [ { time: fullTime(rates[i].ts) }, { cls: "s1", value: PB.rate(rates[i].a), label: "In" }, { cls: "s2", value: PB.rate(rates[i].b), label: "Out" }, ]); }); hit.addEventListener("pointerleave", () => { [cross, m1, m2].forEach((e) => e.setAttribute("visibility", "hidden")); hideTip(); }); } function drawConns(rows) { const yMax = niceMax(Math.max(0, ...rows.map((r) => r.conns))); const f = frame(connBox, yMax, (v) => (Number.isInteger(v) ? String(v) : v.toFixed(1)), rows); if (!rows.length) return; const slot = f.pw / rows.length; const bw = Math.max(1, slot - 2); // 2px surface gap between bars rows.forEach((r, i) => { const x0 = M.left + i * slot + (slot - bw) / 2; // The hit area spans the whole slot and plot height, not just the bar. const hit = svgEl("rect", { x: M.left + i * slot, y: M.top, width: slot, height: f.ph, fill: "transparent" }); let bar = null; if (r.conns > 0) { const top = f.y(r.conns); bar = svgEl("rect", { class: "bar", x: x0, y: top, width: bw, height: M.top + f.ph - top, rx: bw >= 8 ? 4 : 0 }); f.svg.append(bar); } hit.addEventListener("pointermove", (ev) => { if (bar) bar.classList.add("hover"); showTip(ev, [{ time: fullTime(r.ts) }, { value: String(r.conns), label: r.conns === 1 ? "connection" : "connections" }]); }); hit.addEventListener("pointerleave", () => { if (bar) bar.classList.remove("hover"); hideTip(); }); f.svg.append(hit); }); } function fillTable(d) { tbody.replaceChildren(); for (const p of [...d.points].reverse()) { tbody.append(PB.el("tr", {}, PB.el("td", { text: fullTime(p[0]) }), PB.el("td", { class: "num", text: PB.bytes(p[1]) }), PB.el("td", { class: "num", text: PB.bytes(p[2]) }), PB.el("td", { class: "num", text: String(p[3]) }))); } if (!d.points.length) tbody.append(PB.el("tr", {}, PB.el("td", { colspan: "4", class: "muted", text: "No traffic recorded in this range." }))); } function render() { if (!data) return; const rows = buckets(data); drawRate(rows, data.step); drawConns(rows); const total = data.points.reduce((s, p) => s + p[1] + p[2], 0); document.getElementById("t-total").textContent = PB.bytes(total); } async function loadHistory() { rateBox.classList.add("loading"); connBox.classList.add("loading"); try { data = await PB.api("GET", `/api/stats?sink=${encodeURIComponent(sinkSel.value)}&range=${range}`); render(); fillTable(data); } catch (e) { rateBox.replaceChildren(PB.el("p", { class: "muted", text: "Loading failed: " + e.message })); } finally { rateBox.classList.remove("loading"); connBox.classList.remove("loading"); } } async function loadLive() { try { const lv = await PB.api("GET", "/api/live"); const s = (lv.sinks || {})[sinkSel.value]; document.getElementById("t-in").textContent = lv.daemon ? PB.rate(s ? s.in : 0) : "offline"; document.getElementById("t-out").textContent = lv.daemon ? PB.rate(s ? s.out : 0) : "offline"; document.getElementById("t-active").textContent = lv.daemon ? String(s ? s.active : 0) : "-"; } catch (e) { /* keep last values */ } } sinkSel.addEventListener("change", () => { loadHistory(); loadLive(); }); rangeGroup.querySelectorAll("button").forEach((b) => b.addEventListener("click", () => { rangeGroup.querySelectorAll("button").forEach((x) => x.classList.toggle("selected", x === b)); range = b.dataset.range; loadHistory(); })); window.addEventListener("resize", render); loadHistory(); loadLive(); setInterval(loadLive, 2000); setInterval(loadHistory, 60000); })();