Initial Awawawa
This commit is contained in:
245
web/patchbay_web/static/stats.js
Normal file
245
web/patchbay_web/static/stats.js
Normal file
@@ -0,0 +1,245 @@
|
||||
/* 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);
|
||||
})();
|
||||
Reference in New Issue
Block a user