Files
mueller_minki 8e83a98ef4 Initial Awawawa
2026-10-04 09:08:27 +02:00

246 lines
10 KiB
JavaScript

/* 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);
})();