Browser-based remote control (noVNC) with invite links, per-user access control, garagedoor SSO and a persisted client list. The hub proxies RFB rather than pointing the browser at a VNC server. That is what lets it authenticate upstream with a stored password the browser never sees, and enforce view-only by dropping input messages on the client->server stream instead of hiding buttons. Machines are reachable two ways: direct TCP for LAN hosts, or an outbound agent tunnel for anything behind NAT. Node 22's global WebSocket keeps the agent dependency-free, and node:sqlite keeps the image free of native builds. Ships with an end-to-end suite that boots the real server against a fake VNC server and a fake auth service (72 assertions), plus Gitea Actions CI/CD to Portainer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
274 lines
8.1 KiB
JavaScript
274 lines
8.1 KiB
JavaScript
/* ============================================================================
|
|
particles.js — ambient "network of machines" field.
|
|
|
|
Self-contained, no dependencies. Mounts a <canvas> into a host element and
|
|
draws slow-drifting nodes with proximity links.
|
|
|
|
import { mountParticles } from '/particles.js';
|
|
const field = mountParticles(document.getElementById('bg-field'));
|
|
field.setMode('static'); // freeze: draws one frame, then zero cost
|
|
field.destroy();
|
|
|
|
Budget rules baked in, because this runs on whatever desktop is on the desk:
|
|
- node count is derived from viewport area and hard-capped
|
|
- device pixel ratio is capped at 2
|
|
- the loop is throttled to ~30fps and uses no shadows or gradients
|
|
- rAF is cancelled outright when the tab is hidden or the mode is static
|
|
- prefers-reduced-motion renders a single static frame and never loops
|
|
========================================================================= */
|
|
|
|
'use strict';
|
|
|
|
const DEFAULTS = {
|
|
/* one node per this many CSS pixels of area */
|
|
areaPerNode: 26000,
|
|
minNodes: 14,
|
|
maxNodes: 78,
|
|
/* proximity links */
|
|
linkDistance: 138,
|
|
linkAlpha: 0.20,
|
|
/* nodes */
|
|
nodeAlpha: 0.62,
|
|
nodeSize: 2,
|
|
hubEvery: 7, /* every Nth node is drawn as a larger "hub" */
|
|
/* drift, CSS px per second */
|
|
speed: 7,
|
|
fps: 30,
|
|
colors: ['#3ddc97', '#3ddc97', '#3ddc97', '#9aa2ff', '#e9a05c'],
|
|
linkColor: '61, 220, 151',
|
|
mode: 'animate',
|
|
};
|
|
|
|
const NOOP_HANDLE = {
|
|
setMode() {},
|
|
destroy() {},
|
|
canvas: null,
|
|
};
|
|
|
|
export function mountParticles(host, options = {}) {
|
|
if (!host || typeof document === 'undefined') return NOOP_HANDLE;
|
|
|
|
const cfg = { ...DEFAULTS, ...options };
|
|
const canvas = document.createElement('canvas');
|
|
canvas.setAttribute('aria-hidden', 'true');
|
|
const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
|
|
if (!ctx) return NOOP_HANDLE;
|
|
host.append(canvas);
|
|
|
|
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
|
|
let nodes = [];
|
|
let width = 0;
|
|
let height = 0;
|
|
let raf = 0;
|
|
let lastFrame = 0;
|
|
let mode = cfg.mode;
|
|
let destroyed = false;
|
|
|
|
/* ------------------------------------------------------------ geometry */
|
|
|
|
function targetCount() {
|
|
const raw = Math.round((width * height) / cfg.areaPerNode);
|
|
return Math.max(cfg.minNodes, Math.min(cfg.maxNodes, raw));
|
|
}
|
|
|
|
function makeNode(index) {
|
|
const angle = Math.random() * Math.PI * 2;
|
|
return {
|
|
x: Math.random() * width,
|
|
y: Math.random() * height,
|
|
vx: Math.cos(angle) * cfg.speed * (0.35 + Math.random() * 0.85),
|
|
vy: Math.sin(angle) * cfg.speed * (0.35 + Math.random() * 0.85),
|
|
color: cfg.colors[index % cfg.colors.length],
|
|
hub: index % cfg.hubEvery === 0,
|
|
/* per-node brightness keeps the field from looking like a lattice */
|
|
alpha: cfg.nodeAlpha * (0.45 + Math.random() * 0.55),
|
|
};
|
|
}
|
|
|
|
function reconcileNodes() {
|
|
const want = targetCount();
|
|
while (nodes.length > want) nodes.pop();
|
|
while (nodes.length < want) nodes.push(makeNode(nodes.length));
|
|
for (const n of nodes) {
|
|
if (n.x > width) n.x = Math.random() * width;
|
|
if (n.y > height) n.y = Math.random() * height;
|
|
}
|
|
}
|
|
|
|
function resize() {
|
|
if (destroyed) return;
|
|
const w = host.clientWidth || window.innerWidth;
|
|
const h = host.clientHeight || window.innerHeight;
|
|
if (!w || !h) return;
|
|
|
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
width = w;
|
|
height = h;
|
|
canvas.width = Math.round(w * dpr);
|
|
canvas.height = Math.round(h * dpr);
|
|
canvas.style.width = `${w}px`;
|
|
canvas.style.height = `${h}px`;
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
|
|
reconcileNodes();
|
|
if (!running()) draw();
|
|
}
|
|
|
|
/* --------------------------------------------------------------- paint */
|
|
|
|
function draw() {
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
/* links first, so nodes sit on top of them */
|
|
const max = cfg.linkDistance;
|
|
const maxSq = max * max;
|
|
ctx.lineWidth = 1;
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
const a = nodes[i];
|
|
for (let j = i + 1; j < nodes.length; j++) {
|
|
const b = nodes[j];
|
|
const dx = a.x - b.x;
|
|
const dy = a.y - b.y;
|
|
const distSq = dx * dx + dy * dy;
|
|
if (distSq > maxSq) continue;
|
|
const strength = 1 - Math.sqrt(distSq) / max;
|
|
ctx.strokeStyle = `rgba(${cfg.linkColor}, ${(strength * strength * cfg.linkAlpha).toFixed(3)})`;
|
|
ctx.beginPath();
|
|
ctx.moveTo(a.x, a.y);
|
|
ctx.lineTo(b.x, b.y);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
/* nodes: small squares read as devices, not as bokeh */
|
|
ctx.globalAlpha = 1;
|
|
for (const n of nodes) {
|
|
const s = n.hub ? cfg.nodeSize + 1.5 : cfg.nodeSize;
|
|
ctx.fillStyle = withAlpha(n.color, n.alpha);
|
|
ctx.fillRect(n.x - s / 2, n.y - s / 2, s, s);
|
|
if (n.hub) {
|
|
ctx.strokeStyle = withAlpha(n.color, n.alpha * 0.30);
|
|
ctx.strokeRect(n.x - s * 1.6, n.y - s * 1.6, s * 3.2, s * 3.2);
|
|
}
|
|
}
|
|
}
|
|
|
|
function step(now) {
|
|
raf = window.requestAnimationFrame(step);
|
|
|
|
const interval = 1000 / cfg.fps;
|
|
const elapsed = now - lastFrame;
|
|
if (elapsed < interval) return;
|
|
/* keep the phase, but never integrate a huge dt after a stall */
|
|
lastFrame = now - (elapsed % interval);
|
|
const dt = Math.min(elapsed, 100) / 1000;
|
|
|
|
for (const n of nodes) {
|
|
n.x += n.vx * dt;
|
|
n.y += n.vy * dt;
|
|
if (n.x < 0) { n.x = 0; n.vx = -n.vx; }
|
|
else if (n.x > width) { n.x = width; n.vx = -n.vx; }
|
|
if (n.y < 0) { n.y = 0; n.vy = -n.vy; }
|
|
else if (n.y > height) { n.y = height; n.vy = -n.vy; }
|
|
}
|
|
|
|
draw();
|
|
}
|
|
|
|
/* ------------------------------------------------------------ lifecycle */
|
|
|
|
function running() { return raf !== 0; }
|
|
|
|
function shouldAnimate() {
|
|
return !destroyed
|
|
&& mode === 'animate'
|
|
&& !document.hidden
|
|
&& !motionQuery.matches;
|
|
}
|
|
|
|
function stop() {
|
|
if (raf) window.cancelAnimationFrame(raf);
|
|
raf = 0;
|
|
}
|
|
|
|
function sync() {
|
|
if (shouldAnimate()) {
|
|
if (!running()) {
|
|
lastFrame = performance.now();
|
|
raf = window.requestAnimationFrame(step);
|
|
}
|
|
return;
|
|
}
|
|
stop();
|
|
if (!destroyed && mode !== 'off') draw();
|
|
if (mode === 'off') ctx.clearRect(0, 0, width, height);
|
|
}
|
|
|
|
/* ------------------------------------------------------------- plumbing */
|
|
|
|
let resizeTimer = 0;
|
|
const onResize = () => {
|
|
window.clearTimeout(resizeTimer);
|
|
resizeTimer = window.setTimeout(resize, 140);
|
|
};
|
|
|
|
const onVisibility = () => sync();
|
|
|
|
window.addEventListener('resize', onResize, { passive: true });
|
|
document.addEventListener('visibilitychange', onVisibility);
|
|
addMediaListener(motionQuery, sync);
|
|
|
|
let observer = null;
|
|
if (typeof ResizeObserver !== 'undefined') {
|
|
observer = new ResizeObserver(onResize);
|
|
observer.observe(host);
|
|
}
|
|
|
|
resize();
|
|
sync();
|
|
|
|
return {
|
|
canvas,
|
|
/** 'animate' | 'static' (one frozen frame) | 'off' (blank) */
|
|
setMode(next) {
|
|
if (next === mode) return;
|
|
mode = next;
|
|
sync();
|
|
},
|
|
destroy() {
|
|
destroyed = true;
|
|
stop();
|
|
window.clearTimeout(resizeTimer);
|
|
window.removeEventListener('resize', onResize);
|
|
document.removeEventListener('visibilitychange', onVisibility);
|
|
removeMediaListener(motionQuery, sync);
|
|
observer?.disconnect();
|
|
canvas.remove();
|
|
},
|
|
};
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ utils */
|
|
|
|
function withAlpha(hex, alpha) {
|
|
const h = hex.replace('#', '');
|
|
const n = parseInt(h.length === 3 ? h.replace(/./g, (c) => c + c) : h, 16);
|
|
const r = (n >> 16) & 255;
|
|
const g = (n >> 8) & 255;
|
|
const b = n & 255;
|
|
return `rgba(${r}, ${g}, ${b}, ${alpha.toFixed(3)})`;
|
|
}
|
|
|
|
/* Safari < 14 only has the deprecated listener API. */
|
|
function addMediaListener(query, fn) {
|
|
if (query.addEventListener) query.addEventListener('change', fn);
|
|
else if (query.addListener) query.addListener(fn);
|
|
}
|
|
function removeMediaListener(query, fn) {
|
|
if (query.removeEventListener) query.removeEventListener('change', fn);
|
|
else if (query.removeListener) query.removeListener(fn);
|
|
}
|
|
|
|
export default mountParticles;
|