/* 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 GRID = 24; // matches the editor background 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: "nodes", ids: Set} | {kind: "link", link} tidy: false, // snap nodes to the grid 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})`); // The grid moves with the canvas so tidy nodes sit on its lines. editor.style.backgroundSize = `${GRID * S.zoom}px ${GRID * S.zoom}px`; editor.style.backgroundPosition = `${S.pan.x}px ${S.pan.y}px`; } function snap(v) { return Math.round(v / GRID) * GRID; } 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; } // How a sink passes the peer address on. Both options are exclusive, and // spoofing needs the source on a client (the daemon reports it otherwise). function originChecks(n) { const box = (label, value, title) => { const inp = PB.el("input", { type: "checkbox" }); const lab = PB.el("label", { class: "check", title }, inp, PB.el("span", { text: label })); inp.addEventListener("change", () => { n.origin = inp.checked ? value : ""; update(); changed(); }); return { inp, lab, value }; }; const proxy = box("PROXY v2", "proxy_v2", "Send a PROXY protocol v2 header with the peer address to the service (e.g. Apache mod_remoteip, nginx proxy_protocol)"); const spoof = box("Transparent source spoofing", "transparent", "Connect to the service from the peer's own address (IP_TRANSPARENT); the source must be on a client"); function update() { const src = sourceOf(n.id); const onClient = !src || !!src.client_id; for (const b of [proxy, spoof]) { b.inp.checked = n.origin === b.value; let off = !!n.origin && !b.inp.checked; if (b === spoof && !b.inp.checked && !onClient) off = true; b.inp.disabled = off; b.lab.classList.toggle("disabled", off); } } update(); const wrap = PB.el("div", { class: "origin" }, proxy.lab, spoof.lab); wrap._update = update; return wrap; } 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"))); let origin = null; if (t.input && n.type !== "splitter") { origin = originChecks(n); body.append(origin); } 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, // right clicks to the editor that draws the selection box. if (ev.button === 2 || ev.target.closest("input, select, button, .socket")) return; ev.stopPropagation(); if (!isSelected(n.id)) selectNodes([n.id]); }); el._status = status; el._title = title; el._origin = origin; 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) * GRID; const n = { id: S.nextTemp--, type, client_id: null, port: 0, proto: "tcp", label: "", origin: "", 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), }; if (S.tidy) { n.x = snap(n.x); n.y = snap(n.y); } S.nodes.set(n.id, n); const el = buildNode(n); S.els.set(n.id, el); canvas.append(el); placeNode(n); selectNodes([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 (isSelected(id)) { S.sel.ids.delete(id); if (!S.sel.ids.size) S.sel = null; } drawWires(); changed(); } function isSelected(id) { return !!S.sel && S.sel.kind === "nodes" && S.sel.ids.has(id); } function select(sel) { S.sel = sel; for (const [id, el] of S.els) el.classList.toggle("selected", isSelected(id)); drawWires(); } function selectNodes(ids) { select(ids.length ? { kind: "nodes", ids: new Set(ids) } : null); } /* 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}` : ""); if (el._origin) el._origin._update(); 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 (isSelected(oldId)) { S.sel.ids.delete(oldId); S.sel.ids.add(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(); // Dragging a member of a multi-selection moves the whole group. if (!isSelected(n.id)) selectNodes([n.id]); const group = [...S.sel.ids].map((id) => S.nodes.get(id)).filter(Boolean).map((g) => ({ g, x: g.x, y: g.y })); 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); let dx = Math.round(p.x - start.x), dy = Math.round(p.y - start.y); // Tidy snaps the grabbed node; the others keep their offset to it. if (S.tidy) { dx = snap(ox + dx) - ox; dy = snap(oy + dy) - oy; } for (const m of group) { m.g.x = m.x + dx; m.g.y = m.y + dy; placeNode(m.g); } moved = true; 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(); } } }); // Right-drag draws a selection box; nodes touching it are selected. // Shift keeps the current selection and adds to it. function startMarquee(ev) { ev.preventDefault(); editor.focus(); const r = editor.getBoundingClientRect(); const sx = ev.clientX - r.left, sy = ev.clientY - r.top; const base = ev.shiftKey && S.sel && S.sel.kind === "nodes" ? [...S.sel.ids] : []; const box = PB.el("div", { class: "marquee" }); editor.append(box); const move = (e) => { const x = e.clientX - r.left, y = e.clientY - r.top; const x0 = Math.min(sx, x), y0 = Math.min(sy, y), w = Math.abs(x - sx), h = Math.abs(y - sy); box.style.left = x0 + "px"; box.style.top = y0 + "px"; box.style.width = w + "px"; box.style.height = h + "px"; const a = toCanvas(r.left + x0, r.top + y0), b = toCanvas(r.left + x0 + w, r.top + y0 + h); const ids = new Set(base); for (const n of S.nodes.values()) { const el = S.els.get(n.id); const nh = el ? el.offsetHeight : 150; if (n.x < b.x && n.x + NODE_W > a.x && n.y < b.y && n.y + nh > a.y) ids.add(n.id); } selectNodes([...ids]); }; const up = (e) => { if (e.button !== 2) return; box.remove(); window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); }; move(ev); window.addEventListener("mousemove", move); window.addEventListener("mouseup", up); } editor.addEventListener("contextmenu", (ev) => { if (!ev.target.closest("input, select, textarea")) ev.preventDefault(); }); editor.addEventListener("mousedown", (ev) => { if (ev.button === 2) { if (!ev.target.closest("input, select, textarea")) startMarquee(ev); return; } 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 === "nodes") [...S.sel.ids].forEach(removeNode); 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); // Tidy: snaps every node to the grid when switched on and keeps drags and // new nodes on it. The switch is remembered per browser. const tidyBtn = document.getElementById("tidy-btn"); function setTidy(on, apply) { S.tidy = on; tidyBtn.setAttribute("aria-pressed", String(on)); try { localStorage.setItem("pb-tidy", on ? "1" : "0"); } catch (e) { /* storage blocked */ } if (!on || !apply) return; let moved = false; for (const n of S.nodes.values()) { const x = snap(n.x), y = snap(n.y); if (x === n.x && y === n.y) continue; n.x = x; n.y = y; placeNode(n); moved = true; } if (moved) changed(); } tidyBtn.addEventListener("click", () => setTidy(!S.tidy, true)); try { setTidy(localStorage.getItem("pb-tidy") === "1", false); } catch (e) { /* storage blocked */ } 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(); })();