Initial Awawawa

This commit is contained in:
mueller_minki
2026-10-04 09:08:27 +02:00
commit 8e83a98ef4
76 changed files with 8225 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
/* Shared helpers: CSRF-aware fetch, confirm dialogs, formatting. */
"use strict";
const PB = (() => {
const csrf = document.querySelector('meta[name="csrf-token"]').content;
async function api(method, url, body) {
const opts = { method, headers: { "X-CSRF-Token": csrf }, credentials: "same-origin" };
if (body !== undefined) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
const res = await fetch(url, opts);
if (res.status === 401) {
location.href = "/login";
throw new Error("not logged in");
}
let data = {};
try { data = await res.json(); } catch (e) { /* non-JSON error page */ }
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
function bytes(n) {
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
let i = 0;
n = Number(n) || 0;
while (Math.abs(n) >= 1000 && i < units.length - 1) { n /= 1000; i++; }
return (i === 0 ? n.toFixed(0) : n.toFixed(n < 10 ? 2 : n < 100 ? 1 : 0)) + " " + units[i];
}
function rate(bps) {
return bytes(bps) + "/s";
}
function ago(ts) {
if (!ts) return "never";
const s = Math.max(0, Date.now() / 1000 - ts);
if (s < 60) return "just now";
if (s < 3600) return Math.floor(s / 60) + " min ago";
if (s < 86400) return Math.floor(s / 3600) + " h ago";
return new Date(ts * 1000).toLocaleString();
}
function el(tag, attrs, ...children) {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (k === "class") e.className = v;
else if (k === "text") e.textContent = v;
else e.setAttribute(k, v);
}
for (const c of children) e.append(c);
return e;
}
// Inline handlers are blocked by the CSP, so confirmations are wired here.
document.addEventListener("submit", (ev) => {
const msg = ev.target.dataset && ev.target.dataset.confirm;
if (msg && !window.confirm(msg)) ev.preventDefault();
});
function fillTimestamps() {
document.querySelectorAll("[data-ts]").forEach((n) => {
const t = n.querySelector(".ts");
if (t) t.textContent = ago(Number(n.dataset.ts));
});
}
document.addEventListener("DOMContentLoaded", fillTimestamps);
return { api, bytes, rate, ago, el, csrf };
})();

View File

@@ -0,0 +1,634 @@
/* Patch editor: node graph with draggable wires, saved to /api/graph. */
"use strict";
(() => {
const NODE_W = 220;
const SOCKET_Y = 47; // socket centre below the node's top edge
const TYPES = {
client_source: { title: "Client Source", input: false, output: true },
client_sink: { title: "Client Sink", input: true, output: false },
public_sink: { title: "Public Sink", input: true, output: false },
splitter: { title: "Splitter", input: true, output: true },
tunnel_source: { title: "Tunnel Source", input: false, output: true },
tunnel_sink: { title: "Tunnel Sink", input: true, output: false },
};
const SOURCE_TYPES = new Set(["client_source", "tunnel_source"]);
const TUNNEL_TYPES = new Set(["tunnel_source", "tunnel_sink"]);
const editor = document.getElementById("editor");
const canvas = document.getElementById("canvas");
const wireLayer = document.getElementById("wire-layer");
const saveState = document.getElementById("save-state");
const SVG = "http://www.w3.org/2000/svg";
const S = {
nodes: new Map(), // id -> node
els: new Map(), // id -> element
links: [], // {from, to}
clients: [],
interfaces: {}, // host id ("0" = target) -> [{name, addr}]
status: { clients: {}, sinks: {} },
live: {},
daemon: true,
pan: { x: 40, y: 40 },
zoom: 1,
sel: null, // {kind: "node", id} | {kind: "link", link}
nextTemp: -1,
dirty: false,
saving: false,
saveTimer: null,
addOffset: 0,
lastWire: null,
};
/* Graph helpers */
function inputLink(id) {
return S.links.find((l) => l.to === id);
}
// Follows splitters upstream to the client source feeding a node.
function sourceOf(id) {
let cur = id;
for (let i = 0; i < 64; i++) {
const n = S.nodes.get(cur);
if (!n) return null;
if (SOURCE_TYPES.has(n.type)) return n;
const l = inputLink(cur);
if (!l) return null;
cur = l.from;
}
return null;
}
function createsLoop(from, to) {
// Walking upstream from "from" must not reach "to".
let cur = from;
for (let i = 0; i < 64; i++) {
if (cur === to) return true;
const l = inputLink(cur);
if (!l) return false;
cur = l.from;
}
return true;
}
/* View transform */
function applyView() {
const t = `translate(${S.pan.x}px, ${S.pan.y}px) scale(${S.zoom})`;
canvas.style.transform = t;
wireLayer.setAttribute("transform", `translate(${S.pan.x} ${S.pan.y}) scale(${S.zoom})`);
}
function toCanvas(clientX, clientY) {
const r = editor.getBoundingClientRect();
return { x: (clientX - r.left - S.pan.x) / S.zoom, y: (clientY - r.top - S.pan.y) / S.zoom };
}
function fit() {
if (!S.nodes.size) return;
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const n of S.nodes.values()) {
const el = S.els.get(n.id);
x0 = Math.min(x0, n.x);
y0 = Math.min(y0, n.y);
x1 = Math.max(x1, n.x + NODE_W);
y1 = Math.max(y1, n.y + (el ? el.offsetHeight : 150));
}
const r = editor.getBoundingClientRect();
const pad = 60;
S.zoom = Math.max(0.3, Math.min(1.2, Math.min((r.width - pad * 2) / (x1 - x0), (r.height - pad * 2) / (y1 - y0))));
S.pan.x = (r.width - (x1 - x0) * S.zoom) / 2 - x0 * S.zoom;
S.pan.y = (r.height - (y1 - y0) * S.zoom) / 2 - y0 * S.zoom;
applyView();
}
/* Wires */
function wirePath(x1, y1, x2, y2) {
const dx = Math.max(40, Math.abs(x2 - x1) * 0.5);
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
}
function outPos(n) { return { x: n.x + NODE_W + 1, y: n.y + SOCKET_Y }; }
function inPos(n) { return { x: n.x - 1, y: n.y + SOCKET_Y }; }
function drawWires() {
wireLayer.replaceChildren();
for (const l of S.links) {
const a = S.nodes.get(l.from), b = S.nodes.get(l.to);
if (!a || !b) continue;
const p = outPos(a), q = inPos(b);
const d = wirePath(p.x, p.y, q.x, q.y);
const src = sourceOf(l.from);
let cls = src ? src.proto : "none";
if (src && b.type !== "splitter" && b.proto !== src.proto) cls += " bad";
if (S.sel && S.sel.kind === "link" && S.sel.link === l) cls += " selected";
const vis = document.createElementNS(SVG, "path");
vis.setAttribute("d", d);
vis.setAttribute("class", cls);
const hit = document.createElementNS(SVG, "path");
hit.setAttribute("d", d);
hit.setAttribute("class", "hit");
hit.addEventListener("mousedown", (ev) => {
ev.stopPropagation();
// Selecting re-renders the wires, so dblclick would never fire; time it instead.
const now = Date.now();
if (S.lastWire && S.lastWire.link === l && now - S.lastWire.t < 400) {
S.lastWire = null;
removeLink(l);
return;
}
S.lastWire = { link: l, t: now };
select({ kind: "link", link: l });
editor.focus();
});
wireLayer.append(vis, hit);
// A selected wire gets a delete button at its midpoint (bezier t = 0.5).
if (S.sel && S.sel.kind === "link" && S.sel.link === l) {
const dx = Math.max(40, Math.abs(q.x - p.x) * 0.5);
const mx = (p.x + 3 * (p.x + dx) + 3 * (q.x - dx) + q.x) / 8;
const my = (p.y + q.y) / 2;
const btn = document.createElementNS(SVG, "g");
btn.setAttribute("class", "wire-del");
btn.setAttribute("transform", `translate(${mx} ${my})`);
const title = document.createElementNS(SVG, "title");
title.textContent = "Remove connection";
const circle = document.createElementNS(SVG, "circle");
circle.setAttribute("r", "10");
const x = document.createElementNS(SVG, "path");
x.setAttribute("d", "M -4 -4 L 4 4 M 4 -4 L -4 4");
btn.append(title, circle, x);
btn.addEventListener("mousedown", (ev) => ev.stopPropagation());
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
removeLink(l);
});
wireLayer.append(btn);
}
}
}
function removeLink(l) {
S.links = S.links.filter((x) => x !== l);
if (S.sel && S.sel.kind === "link" && S.sel.link === l) S.sel = null;
changed();
}
/* Nodes */
function field(label, input) {
return PB.el("label", {}, PB.el("span", { text: label }), input);
}
// Tunnel nodes may live on the target itself (client_id null).
function clientSelect(n, onChange) {
const sel = PB.el("select", { "aria-label": "Host" });
sel.append(PB.el("option", { value: "", text: TUNNEL_TYPES.has(n.type) ? "Target (this server)" : "- choose client -" }));
for (const c of S.clients) {
const o = PB.el("option", { value: String(c.id), text: c.name });
if (n.client_id === c.id) o.selected = true;
sel.append(o);
}
sel.addEventListener("change", () => {
n.client_id = sel.value ? Number(sel.value) : null;
if (onChange) onChange();
changed();
});
return sel;
}
// Free text with suggestions from the tun interfaces the host reported.
function ifaceInput(n) {
const listId = `ifaces-${Math.random().toString(36).slice(2)}`;
const list = PB.el("datalist", { id: listId });
const inp = PB.el("input", { value: n.iface || "", placeholder: "tun0", list: listId, spellcheck: "false" });
inp.addEventListener("input", () => { n.iface = inp.value.trim(); changed(); });
const fill = () => {
list.replaceChildren();
for (const i of S.interfaces[String(n.client_id || 0)] || []) {
list.append(PB.el("option", { value: i.name, text: i.addr !== "-" ? `${i.name} (${i.addr})` : i.name }));
}
};
fill();
const wrap = PB.el("span", { class: "iface-wrap" }, inp, list);
wrap._fill = fill;
return wrap;
}
function textInput(n, key, placeholder) {
const inp = PB.el("input", { value: n[key] || "", placeholder: placeholder || "", spellcheck: "false" });
inp.addEventListener("input", () => { n[key] = inp.value.trim(); changed(); });
return inp;
}
function portInput(n) {
const inp = PB.el("input", { type: "number", min: "1", max: "65535", value: n.port ? String(n.port) : "", placeholder: "port" });
inp.addEventListener("input", () => { n.port = Number(inp.value) || 0; changed(); });
return inp;
}
function protoSelect(n) {
const sel = PB.el("select", { "aria-label": "Protocol" });
for (const p of ["tcp", "udp"]) {
const o = PB.el("option", { value: p, text: p.toUpperCase() });
if (n.proto === p) o.selected = true;
sel.append(o);
}
sel.addEventListener("change", () => { n.proto = sel.value; changed(); });
return sel;
}
function buildNode(n) {
const t = TYPES[n.type];
const el = PB.el("div", { class: `node ${n.type}` });
const title = PB.el("span", { class: "title", text: t.title });
const close = PB.el("button", { class: "x", type: "button", title: "Delete node", "aria-label": "Delete node", text: "\u00d7" });
const head = PB.el("div", { class: "node-head" }, title, close);
const body = PB.el("div", { class: "node-body" });
if (n.type === "client_source") {
body.append(field("Client", clientSelect(n)), field("Address", textInput(n, "host", "127.0.0.1")),
field("Port", portInput(n)), field("Proto", protoSelect(n)));
} else if (n.type === "client_sink") {
body.append(field("Client", clientSelect(n)), field("Bind", textInput(n, "host", "127.0.0.1")),
field("Port", portInput(n)), field("Proto", protoSelect(n)));
} else if (n.type === "public_sink") {
body.append(field("Bind", textInput(n, "host", "0.0.0.0")), field("Port", portInput(n)), field("Proto", protoSelect(n)));
} else if (TUNNEL_TYPES.has(n.type)) {
const iface = ifaceInput(n);
body.append(field("Host", clientSelect(n, iface._fill)), field("Iface", iface));
if (n.type === "tunnel_source") body.append(field("Peer", textInput(n, "host", "e.g. 10.100.0.2")));
body.append(field("Port", portInput(n)), field("Proto", protoSelect(n)));
}
body.append(field("Label", textInput(n, "label", "optional")));
const status = PB.el("div", { class: "node-status" });
body.append(status);
el.append(head, body);
if (t.input) el.append(PB.el("div", { class: "socket in", "data-node": String(n.id), title: "Input" }));
if (t.output) el.append(PB.el("div", { class: "socket out", "data-node": String(n.id), title: "Output" }));
close.addEventListener("mousedown", (ev) => ev.stopPropagation());
close.addEventListener("click", () => removeNode(n.id));
head.addEventListener("mousedown", (ev) => startNodeDrag(ev, n));
el.addEventListener("mousedown", (ev) => {
// Sockets must bubble up to the canvas handler that starts wire drags.
if (ev.target.closest("input, select, button, .socket")) return;
ev.stopPropagation();
select({ kind: "node", id: n.id });
});
el._status = status;
el._title = title;
return el;
}
function placeNode(n) {
const el = S.els.get(n.id);
el.style.left = n.x + "px";
el.style.top = n.y + "px";
}
function renderAll() {
canvas.replaceChildren();
S.els.clear();
for (const n of S.nodes.values()) {
const el = buildNode(n);
S.els.set(n.id, el);
canvas.append(el);
placeNode(n);
}
// Socket data attributes carry ids; refresh after remapping.
updateStatus();
drawWires();
}
function addNode(type) {
const r = editor.getBoundingClientRect();
const c = toCanvas(r.left + r.width / 2, r.top + r.height / 2);
const off = (S.addOffset++ % 6) * 24;
const n = {
id: S.nextTemp--, type, client_id: null, port: 0, proto: "tcp", label: "",
iface: TUNNEL_TYPES.has(type) ? "tun0" : "",
host: type === "public_sink" ? "0.0.0.0" : (type === "splitter" || TUNNEL_TYPES.has(type)) ? "" : "127.0.0.1",
x: Math.round(c.x - NODE_W / 2 + off), y: Math.round(c.y - 80 + off),
};
S.nodes.set(n.id, n);
const el = buildNode(n);
S.els.set(n.id, el);
canvas.append(el);
placeNode(n);
select({ kind: "node", id: n.id });
changed();
}
function removeNode(id) {
S.nodes.delete(id);
const el = S.els.get(id);
if (el) el.remove();
S.els.delete(id);
S.links = S.links.filter((l) => l.from !== id && l.to !== id);
if (S.sel && S.sel.kind === "node" && S.sel.id === id) S.sel = null;
drawWires();
changed();
}
function select(sel) {
S.sel = sel;
for (const [id, el] of S.els) el.classList.toggle("selected", !!sel && sel.kind === "node" && sel.id === id);
drawWires();
}
/* Status */
function updateStatus() {
for (const n of S.nodes.values()) {
const el = S.els.get(n.id);
if (!el) continue;
el._title.textContent = TYPES[n.type].title + (n.id > 0 ? ` #${n.id}` : "");
const st = el._status;
st.replaceChildren();
st.classList.remove("err");
const parts = [];
if (n.client_id && n.type !== "splitter" && n.type !== "public_sink") {
const on = !!S.status.clients[String(n.client_id)];
parts.push(PB.el("span", { class: `dot ${on ? "on" : "off"}` }), PB.el("span", { text: on ? "online" : "offline" }));
}
if (n.type === "splitter") {
const outs = S.links.filter((l) => l.from === n.id).length;
parts.push(PB.el("span", { text: `fans out to ${outs} sink${outs === 1 ? "" : "s"}` }));
}
if (TYPES[n.type].input && n.type !== "splitter" && n.id > 0 && !S.dirty) {
const s = S.status.sinks[String(n.id)];
const lv = S.live[String(n.id)];
if (s && !s.ok) {
st.classList.add("err");
parts.push(PB.el("span", { text: s.error }));
} else if (s) {
const txt = lv ? `in ${PB.rate(lv.in)}, out ${PB.rate(lv.out)}, ${lv.active} open` : "ready";
parts.push(PB.el("span", { text: s.source_online ? txt : "source offline" }));
}
}
if (!S.daemon && n.type !== "splitter") {
st.classList.add("err");
parts.length = 0;
parts.push(PB.el("span", { text: "daemon not reachable" }));
}
st.append(...parts);
}
}
async function pollStatus() {
try {
const [st, lv] = await Promise.all([PB.api("GET", "/api/status"), PB.api("GET", "/api/live")]);
S.daemon = st.daemon;
S.status = { clients: st.clients || {}, sinks: st.sinks || {} };
S.live = lv.sinks || {};
} catch (e) {
S.daemon = false;
}
updateStatus();
}
/* Saving */
function setSaveState(text, err) {
saveState.textContent = text;
saveState.classList.toggle("err", !!err);
}
function changed() {
S.dirty = true;
setSaveState("Unsaved changes");
clearTimeout(S.saveTimer);
S.saveTimer = setTimeout(save, 700);
drawWires();
updateStatus();
}
async function save() {
if (S.saving) {
S.saveTimer = setTimeout(save, 300);
return;
}
S.saving = true;
S.dirty = false;
setSaveState("Saving...");
const payload = {
nodes: [...S.nodes.values()].map((n) => ({ ...n })),
links: S.links.map((l) => ({ from: l.from, to: l.to })),
};
try {
const res = await PB.api("PUT", "/api/graph", payload);
remapIds(res.ids || {});
setSaveState(res.daemon ? "Saved" : "Saved (daemon offline)", !res.daemon);
await pollStatus();
} catch (e) {
S.dirty = true;
setSaveState("Not saved: " + e.message, true);
} finally {
S.saving = false;
}
}
function remapIds(ids) {
let any = false;
for (const [oldStr, newId] of Object.entries(ids)) {
const oldId = Number(oldStr);
if (oldId === newId || !S.nodes.has(oldId)) continue;
const n = S.nodes.get(oldId);
S.nodes.delete(oldId);
n.id = newId;
S.nodes.set(newId, n);
for (const l of S.links) {
if (l.from === oldId) l.from = newId;
if (l.to === oldId) l.to = newId;
}
if (S.sel && S.sel.kind === "node" && S.sel.id === oldId) S.sel.id = newId;
any = true;
}
if (any) renderAll();
}
/* Mouse interaction */
function startNodeDrag(ev, n) {
if (ev.button !== 0 || ev.target.closest("button")) return;
ev.preventDefault();
ev.stopPropagation();
select({ kind: "node", id: n.id });
const start = toCanvas(ev.clientX, ev.clientY);
const ox = n.x, oy = n.y;
let moved = false;
const move = (e) => {
const p = toCanvas(e.clientX, e.clientY);
n.x = Math.round(ox + p.x - start.x);
n.y = Math.round(oy + p.y - start.y);
moved = true;
placeNode(n);
drawWires();
};
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
if (moved) changed();
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
}
function startWireDrag(ev, fromId) {
ev.preventDefault();
ev.stopPropagation();
const from = S.nodes.get(fromId);
const temp = document.createElementNS(SVG, "path");
temp.setAttribute("class", "temp");
wireLayer.append(temp);
let target = null;
const move = (e) => {
const p = outPos(from), q = toCanvas(e.clientX, e.clientY);
temp.setAttribute("d", wirePath(p.x, p.y, q.x, q.y));
const hit = document.elementFromPoint(e.clientX, e.clientY);
const sock = hit && hit.closest(".socket.in");
if (target && target !== sock) target.classList.remove("target");
target = sock && Number(sock.dataset.node) !== fromId ? sock : null;
if (target) target.classList.add("target");
};
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
temp.remove();
if (target) {
target.classList.remove("target");
connect(fromId, Number(target.dataset.node));
}
drawWires();
};
move(ev);
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
}
function connect(from, to) {
if (from === to || createsLoop(from, to)) {
setSaveState("That link would create a loop", true);
return;
}
S.links = S.links.filter((l) => l.to !== to);
// A source has a single output; fan-out goes through a splitter.
if (SOURCE_TYPES.has(S.nodes.get(from).type)) S.links = S.links.filter((l) => l.from !== from);
S.links.push({ from, to });
changed();
}
canvas.addEventListener("mousedown", (ev) => {
const sock = ev.target.closest(".socket");
if (!sock || ev.button !== 0) return;
const id = Number(sock.dataset.node);
if (sock.classList.contains("out")) {
startWireDrag(ev, id);
} else {
// Grabbing a connected input detaches the wire and keeps dragging it.
const l = inputLink(id);
if (l) {
removeLink(l);
startWireDrag(ev, l.from);
} else {
ev.stopPropagation();
}
}
});
editor.addEventListener("mousedown", (ev) => {
if (ev.button !== 0 && ev.button !== 1) return;
if (ev.target.closest(".node")) return;
ev.preventDefault();
editor.focus();
select(null);
const sx = ev.clientX, sy = ev.clientY, px = S.pan.x, py = S.pan.y;
editor.classList.add("panning");
const move = (e) => {
S.pan.x = px + e.clientX - sx;
S.pan.y = py + e.clientY - sy;
applyView();
};
const up = () => {
editor.classList.remove("panning");
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
});
editor.addEventListener("wheel", (ev) => {
if (ev.target.closest("select")) return;
ev.preventDefault();
const r = editor.getBoundingClientRect();
const mx = ev.clientX - r.left, my = ev.clientY - r.top;
const z = Math.max(0.3, Math.min(2, S.zoom * (ev.deltaY < 0 ? 1.1 : 1 / 1.1)));
S.pan.x = mx - (mx - S.pan.x) * (z / S.zoom);
S.pan.y = my - (my - S.pan.y) * (z / S.zoom);
S.zoom = z;
applyView();
}, { passive: false });
editor.addEventListener("keydown", (ev) => {
if (ev.target.closest("input, select, textarea")) return;
if ((ev.key === "Delete" || ev.key === "Backspace") && S.sel) {
ev.preventDefault();
if (S.sel.kind === "node") removeNode(S.sel.id);
else removeLink(S.sel.link);
}
});
/* Toolbar */
const addMenu = document.getElementById("add-menu");
const addBtn = document.getElementById("add-btn");
function setMenu(open) {
addMenu.classList.toggle("open", open);
addBtn.setAttribute("aria-expanded", String(open));
}
addBtn.addEventListener("click", () => setMenu(!addMenu.classList.contains("open")));
addMenu.querySelectorAll("[data-add]").forEach((b) => b.addEventListener("click", () => {
setMenu(false);
addNode(b.dataset.add);
}));
document.addEventListener("click", (ev) => { if (!addMenu.contains(ev.target)) setMenu(false); });
document.addEventListener("keydown", (ev) => { if (ev.key === "Escape") setMenu(false); });
document.getElementById("fit-btn").addEventListener("click", fit);
window.addEventListener("beforeunload", (ev) => {
if (S.dirty || S.saving) {
save();
ev.preventDefault();
}
});
/* Start */
async function load() {
try {
const g = await PB.api("GET", "/api/graph");
S.clients = g.clients;
S.interfaces = g.interfaces || {};
for (const n of g.nodes) S.nodes.set(n.id, n);
S.links = g.links;
renderAll();
applyView();
fit();
setSaveState(S.nodes.size ? "Saved" : "Empty patch: use Add to create nodes");
} catch (e) {
setSaveState("Loading failed: " + e.message, true);
}
await pollStatus();
setInterval(pollStatus, 3000);
}
load();
})();

View File

@@ -0,0 +1,15 @@
"use strict";
document.getElementById("refresh-services").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
btn.disabled = true;
btn.textContent = "Refreshing...";
try {
await PB.api("POST", "/api/services/refresh");
// Clients answer asynchronously over their control channel.
setTimeout(() => location.reload(), 1500);
} catch (e) {
btn.textContent = "Failed: " + e.message;
btn.disabled = false;
}
});

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

View File

@@ -0,0 +1,246 @@
/* PatchBay UI: dark, node-editor inspired theme. */
:root {
color-scheme: dark;
--bg: #1d1d1d;
--surface-1: #1a1a19;
--surface-2: #262626;
--surface-3: #303030;
--border: #3d3d3d;
--text-primary: #ececec;
--text-secondary: #c3c2b7;
--text-muted: #8f8e88;
--accent: #4a8fe0;
--danger: #d03b3b;
--good: #0ca30c;
--series-1: #3987e5; /* in / TCP */
--series-2: #d95926; /* out / UDP */
--grid: #2f2f2d;
--node-source: #2e7d4f;
--node-csink: #2f5f9e;
--node-psink: #a8452a;
--node-split: #5a5a5a;
--node-tsource: #1f7a7a;
--node-tsink: #6a4a9e;
--radius: 6px;
--mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background: var(--bg);
color: var(--text-primary);
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
a { color: var(--accent); }
code, .mono { font-family: var(--mono); font-size: 12.5px; }
h1 { font-size: 22px; margin: 0 0 16px; }
h2 { font-size: 16px; margin: 0 0 12px; display: flex; align-items: center; gap: 8px; }
h3 { font-size: 14px; margin: 18px 0 8px; }
.muted { color: var(--text-muted); }
.small { font-size: 12.5px; }
.right { text-align: right; }
/* Top bar */
.topbar {
display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
padding: 8px 16px; background: var(--surface-2); border-bottom: 1px solid var(--border);
}
.brand { font-weight: 700; letter-spacing: .02em; }
.topbar nav { display: flex; gap: 4px; }
.topbar nav a {
color: var(--text-secondary); text-decoration: none; padding: 6px 12px; border-radius: var(--radius);
}
.topbar nav a:hover { background: var(--surface-3); }
.topbar nav a.active { background: var(--surface-3); color: var(--text-primary); }
.tools { display: flex; align-items: center; gap: 8px; }
.logout { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.who { color: var(--text-muted); }
/* Controls */
.btn {
font: inherit; color: var(--text-primary); background: var(--surface-3);
border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 14px; cursor: pointer;
}
.btn:hover { background: #3a3a3a; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn.primary:hover { filter: brightness(1.1); }
.btn.danger { color: #ff8a8a; }
.btn.small { padding: 3px 10px; font-size: 12.5px; }
input, select, textarea {
font: inherit; color: var(--text-primary); background: var(--surface-1);
border: 1px solid var(--border); border-radius: 4px; padding: 6px 8px; width: 100%;
}
input:focus, select:focus, textarea:focus, .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
label { display: flex; flex-direction: column; gap: 4px; color: var(--text-secondary); font-size: 13px; }
/* Layout */
.page { max-width: 1100px; margin: 0 auto; padding: 24px 16px 48px; }
.page-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.page-head h1 { margin: 0; }
.card {
background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px;
padding: 16px; margin: 16px 0;
}
.form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; align-items: end; margin-top: 12px; }
.form-grid .wide { grid-column: 1 / -1; }
.form-grid .actions { grid-column: 1 / -1; }
.inline-form { display: flex; gap: 8px; align-items: center; flex: 1; flex-wrap: wrap; }
.inline-form input { width: auto; flex: 1; min-width: 160px; }
.user-row { display: flex; gap: 8px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); }
table.grid { width: 100%; border-collapse: collapse; }
table.grid th, table.grid td { padding: 6px 8px; border-bottom: 1px solid var(--border); text-align: left; }
table.grid th { color: var(--text-muted); font-weight: 600; font-size: 12.5px; }
table.grid .num { text-align: right; font-variant-numeric: tabular-nums; }
td.key { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.table-wrap { max-height: 360px; overflow: auto; margin-top: 8px; }
.dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex: none; }
.dot.on { background: var(--good); }
.dot.off { background: #666; }
/* Flash messages */
.flashes { max-width: 1100px; margin: 12px auto 0; padding: 0 16px; }
.flash { padding: 8px 12px; border-radius: var(--radius); margin: 6px 0; border: 1px solid var(--border); }
.flash.ok { background: #1f3323; border-color: #2e5a36; }
.flash.error { background: #3a1f1f; border-color: #6a2e2e; }
/* Login */
body.auth main { display: flex; justify-content: center; padding-top: 12vh; }
.auth-card { width: 340px; display: flex; flex-direction: column; gap: 12px; }
.auth-card h1 { margin: 0; }
input.code { font-family: var(--mono); font-size: 22px; letter-spacing: .3em; text-align: center; }
/* Patch editor */
.editor-main { position: absolute; inset: 49px 0 0 0; }
#editor {
position: absolute; inset: 0; overflow: hidden; outline: none; cursor: default;
background-color: var(--surface-1);
background-image:
linear-gradient(var(--grid) 1px, transparent 1px),
linear-gradient(90deg, var(--grid) 1px, transparent 1px);
background-size: 24px 24px;
}
#editor.panning { cursor: grabbing; }
#wires { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
#wires path { fill: none; stroke-width: 2.5; pointer-events: stroke; cursor: pointer; }
#wires path.hit { stroke: transparent; stroke-width: 14; }
#wires path.tcp { stroke: var(--series-1); }
#wires path.udp { stroke: var(--series-2); }
#wires path.none { stroke: #9a9a9a; }
#wires path.selected { stroke: #fff; stroke-width: 3.5; }
#wires path.temp { stroke: #ddd; stroke-dasharray: 6 4; pointer-events: none; }
#wires path.bad { stroke-dasharray: 4 4; }
#wires .wire-del { pointer-events: all; cursor: pointer; }
#wires .wire-del circle { fill: var(--surface-3); stroke: #fff; stroke-width: 1.5; }
#wires .wire-del path { stroke: #fff; stroke-width: 2; pointer-events: none; }
#wires .wire-del:hover circle { fill: var(--danger); }
#canvas { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
.editor-hint {
position: absolute; left: 12px; bottom: 10px; color: var(--text-muted); font-size: 12px;
background: rgba(26, 26, 25, .85); padding: 4px 8px; border-radius: 4px; pointer-events: none;
}
.node {
position: absolute; width: 220px; background: var(--surface-3); border: 1px solid #111;
border-radius: 7px; box-shadow: 0 4px 14px rgba(0, 0, 0, .45); user-select: none;
}
.node.selected { outline: 2px solid #f0f0f0; }
.node-head {
display: flex; align-items: center; gap: 6px; padding: 5px 8px; border-radius: 6px 6px 0 0;
font-weight: 600; font-size: 12.5px; cursor: grab;
}
.node.client_source .node-head { background: var(--node-source); }
.node.client_sink .node-head { background: var(--node-csink); }
.node.public_sink .node-head { background: var(--node-psink); }
.node.splitter .node-head { background: var(--node-split); }
.node.tunnel_source .node-head { background: var(--node-tsource); }
.node.tunnel_sink .node-head { background: var(--node-tsink); }
.iface-wrap { display: block; width: 100%; }
.node-head .title { flex: 1; }
.node-head .x {
background: none; border: 0; color: inherit; cursor: pointer; font-size: 15px; line-height: 1; opacity: .7; padding: 0 2px;
}
.node-head .x:hover { opacity: 1; }
.node-body { padding: 8px 10px 10px; display: flex; flex-direction: column; gap: 6px; }
.node-body label { flex-direction: row; align-items: center; justify-content: space-between; gap: 8px; font-size: 12px; }
.node-body label > span { flex: none; width: 54px; }
.node-body input, .node-body select { padding: 3px 6px; font-size: 12.5px; }
.node-status { font-size: 11.5px; color: var(--text-muted); min-height: 16px; display: flex; gap: 6px; align-items: center; }
.node-status.err { color: #ff8a8a; }
.socket {
position: absolute; top: 40px; width: 14px; height: 14px; border-radius: 50%;
background: #c8c8c8; border: 2px solid #111; cursor: crosshair;
}
.socket.in { left: -8px; }
.socket.out { right: -8px; }
.socket:hover, .socket.target { background: #fff; transform: scale(1.25); }
.dropdown { position: relative; }
.dropdown .menu {
display: none; position: absolute; top: calc(100% + 4px); left: 0; z-index: 20; min-width: 190px;
background: var(--surface-3); border: 1px solid var(--border); border-radius: var(--radius); padding: 4px;
box-shadow: 0 6px 18px rgba(0, 0, 0, .5);
}
.dropdown.open .menu { display: block; }
.dropdown .menu button {
display: block; width: 100%; text-align: left; font: inherit; color: var(--text-primary);
background: none; border: 0; padding: 6px 10px; border-radius: 4px; cursor: pointer;
}
.dropdown .menu button:hover, .dropdown .menu button:focus { background: var(--accent); outline: none; }
.save-state { color: var(--text-muted); font-size: 12.5px; min-width: 120px; }
.save-state.err { color: #ff8a8a; }
/* Stats */
.filter-row { display: flex; gap: 16px; align-items: end; flex-wrap: wrap; margin-bottom: 16px; }
.filter-row label { min-width: 280px; flex: 1; }
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.segmented button {
font: inherit; background: var(--surface-2); color: var(--text-secondary); border: 0; padding: 6px 12px; cursor: pointer;
border-right: 1px solid var(--border);
}
.segmented button:last-child { border-right: 0; }
.segmented button.selected { background: var(--surface-3); color: var(--text-primary); font-weight: 600; }
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.tile { background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; }
.tile-value { font-size: 22px; font-weight: 650; font-variant-numeric: tabular-nums; }
.tile-label { color: var(--text-muted); font-size: 12.5px; }
.chart-card { background: var(--surface-1); }
.chart-head { display: flex; justify-content: space-between; align-items: center; }
.chart { position: relative; height: 240px; }
.chart.loading { opacity: .5; }
.chart svg { width: 100%; height: 100%; display: block; }
.chart .axis text { fill: var(--text-muted); font-size: 11px; }
.chart .gridline { stroke: var(--grid); stroke-width: 1; }
.chart .line { fill: none; stroke-width: 2; stroke-linejoin: round; }
.chart .s1 { stroke: var(--series-1); }
.chart .s2 { stroke: var(--series-2); }
.chart .bar { fill: var(--series-1); }
.chart .bar.hover { fill: #6da7ec; }
.chart .crosshair { stroke: var(--text-muted); stroke-width: 1; }
.chart .marker { stroke: var(--surface-1); stroke-width: 2; }
.chart .marker.s1 { fill: var(--series-1); }
.chart .marker.s2 { fill: var(--series-2); }
.chart .endlabel { font-size: 11.5px; fill: var(--text-secondary); }
.chart .empty { fill: var(--text-muted); font-size: 13px; }
.legend { display: flex; gap: 14px; color: var(--text-secondary); font-size: 12.5px; }
.legend .key { display: inline-flex; align-items: center; gap: 6px; }
.swatch-line { width: 16px; height: 2px; display: inline-block; }
.swatch-line.s1 { background: var(--series-1); }
.swatch-line.s2 { background: var(--series-2); }
.tooltip {
position: fixed; z-index: 50; pointer-events: none; background: var(--surface-3); border: 1px solid var(--border);
border-radius: var(--radius); padding: 8px 10px; font-size: 12.5px; box-shadow: 0 6px 18px rgba(0, 0, 0, .5);
}
.tooltip .t-time { color: var(--text-muted); margin-bottom: 4px; }
.tooltip .t-row { display: flex; align-items: center; gap: 8px; }
.tooltip .t-row strong { font-variant-numeric: tabular-nums; }
.tooltip .t-row .muted { margin-left: 2px; }
@media (max-width: 640px) {
.topbar { gap: 8px; }
.logout .who { display: none; }
.editor-main { inset: 96px 0 0 0; }
}