From 97dc6feaca465025c2860cd25b02cc8ab05607be Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 28 Jul 2026 12:57:49 +0100 Subject: [PATCH] feat: Tapo platform page --- site/app/globals.css | 106 +++++++ site/app/platform/page.tsx | 3 + site/components/TopologyDiagram.tsx | 450 ++++++++++++++++++++++++++++ 3 files changed, 559 insertions(+) create mode 100644 site/components/TopologyDiagram.tsx diff --git a/site/app/globals.css b/site/app/globals.css index 5392c59..942f908 100644 --- a/site/app/globals.css +++ b/site/app/globals.css @@ -1234,3 +1234,109 @@ table.cmp { .foot__in a:hover { color: var(--ink); } + +/* ---------- Topology diagram ---------- */ + +.topo { + margin: 2rem 0 0; + border: 1px solid var(--rule); + border-radius: 10px; + background: var(--panel-2); + overflow: hidden; +} + +.topo__head { + display: flex; + flex-wrap: wrap; + gap: 0.9rem 1.2rem; + align-items: center; + justify-content: space-between; + padding: 0.75rem 0.9rem; + border-bottom: 1px solid var(--rule); + background: var(--panel); +} + +.topo__seg { + display: inline-flex; + border: 1px solid var(--rule); + border-radius: 6px; + overflow: hidden; +} + +.topo__btn { + appearance: none; + border: 0; + background: transparent; + color: var(--ink-2); + font: inherit; + font-size: 0.85rem; + padding: 0.42rem 0.85rem; + cursor: pointer; +} + +.topo__btn + .topo__btn { + border-left: 1px solid var(--rule); +} + +.topo__btn:hover { + color: var(--ink); +} + +.topo__btn.is-on { + background: var(--accent); + color: var(--accent-ink); +} + +.topo__btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.topo__key { + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; + margin: 0; + color: var(--ink-3); + font-family: var(--mono); + font-size: 0.72rem; +} + +.topo__dot { + width: 7px; + height: 7px; + border-radius: 999px; + display: inline-block; +} + +.topo__dot--out { + background: var(--up); +} + +.topo__dot--in { + background: var(--accent); + margin-left: 0.8rem; +} + +.topo__canvas { + display: block; + width: 100%; + height: 380px; +} + +.topo__note { + margin: 0; + padding: 0.8rem 0.95rem; + border-top: 1px solid var(--rule); + background: var(--panel); + color: var(--ink-2); + font-size: 0.88rem; + max-width: 78ch; +} + +@media (max-width: 660px) { + .topo__canvas { + height: 460px; + } +} diff --git a/site/app/platform/page.tsx b/site/app/platform/page.tsx index b70feba..4976002 100644 --- a/site/app/platform/page.tsx +++ b/site/app/platform/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { TopologyDiagram } from "@/components/TopologyDiagram"; export const metadata: Metadata = { title: "Platform", @@ -13,6 +14,8 @@ export default function PlatformPage() {

How the pieces fit together.

There are only three parts: Vantage itself, a small program on each of your servers, and one connection between them that your server opens.

+ +

Your servers call us, not the other way round.

diff --git a/site/components/TopologyDiagram.tsx b/site/components/TopologyDiagram.tsx new file mode 100644 index 0000000..8f958a5 --- /dev/null +++ b/site/components/TopologyDiagram.tsx @@ -0,0 +1,450 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +type Mode = "cloud" | "self"; + +type Palette = { + ink: string; + ink3: string; + rule: string; + panel: string; + accent: string; + up: string; + pend: string; + mono: string; + sans: string; +}; + +type Node = { + label: string; + sub: string; + x: number; + y: number; + w: number; + h: number; + state: "up" | "pend"; + flash: number; +}; + +type Wire = { + node: number; + ax: number; + ay: number; + bx: number; + by: number; + c1x: number; + c1y: number; + c2x: number; + c2y: number; +}; + +type Packet = { + wire: number; + t: number; + speed: number; + /** true when travelling control plane to agent, which only happens down a wire the agent opened. */ + inbound: boolean; +}; + +const AGENTS: { label: string; sub: string; state: "up" | "pend" }[] = [ + { label: "proxmox-node-1", sub: "4 keys", state: "up" }, + { label: "db-primary", sub: "3 keys", state: "up" }, + { label: "edge-gw-02", sub: "2 keys", state: "up" }, + { label: "win-build-01", sub: "windows", state: "pend" }, +]; + +const MODES: { id: Mode; label: string }[] = [ + { id: "cloud", label: "Hosted by us" }, + { id: "self", label: "Hosted by you" }, +]; + +const NOTES: Record = { + cloud: "We run Vantage. Your servers reach out to it, so nothing needs to be open to the internet on your side.", + self: "You run Vantage too, on your own hardware. Nothing leaves your network at all, and there is nothing to phone home to.", +}; + +function readPalette(el: HTMLElement): Palette { + const s = getComputedStyle(el); + const v = (name: string, fallback: string) => s.getPropertyValue(name).trim() || fallback; + return { + ink: v("--ink", "#0a1b33"), + ink3: v("--ink-3", "#6c7f96"), + rule: v("--rule", "#cdd6e2"), + panel: v("--panel", "#ffffff"), + accent: v("--accent", "#0b2a58"), + up: v("--up", "#2f8a60"), + pend: v("--pend", "#b0801f"), + mono: `11px ${v("--mono", "monospace")}`, + sans: `600 15px ${v("--sans", "sans-serif")}`, + }; +} + +function pointAt(w: Wire, t: number) { + const u = 1 - t; + const a = u * u * u; + const b = 3 * u * u * t; + const c = 3 * u * t * t; + const d = t * t * t; + return { + x: a * w.ax + b * w.c1x + c * w.c2x + d * w.bx, + y: a * w.ay + b * w.c1y + c * w.c2y + d * w.by, + }; +} + +function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} + +export function TopologyDiagram() { + const [mode, setMode] = useState("cloud"); + const canvasRef = useRef(null); + const modeRef = useRef(mode); + + modeRef.current = mode; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + let palette = readPalette(document.documentElement); + let width = 0; + let height = 0; + let nodes: Node[] = []; + let wires: Wire[] = []; + let plane = { x: 0, y: 0, w: 0, h: 0 }; + let boundary: { x: number; y: number; w: number; h: number; label: string; split: number | null } | null = null; + let stacked = false; + + const packets: Packet[] = []; + let planePulse = 0; + let sinceSpawn = 0; + let sinceCommand = 2.2; + let raf = 0; + let last = 0; + + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)"); + + const layout = () => { + const rect = canvas.getBoundingClientRect(); + width = rect.width; + height = rect.height; + stacked = width < 660; + + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + const pad = stacked ? 14 : 22; + const nodeW = stacked ? Math.min(150, (width - pad * 2 - 16) / 2) : 168; + const nodeH = 46; + + if (stacked) { + plane = { x: width / 2 - 100, y: pad + 30, w: 200, h: 78 }; + const cols = 2; + const gapX = 14; + const gapY = 14; + const gridW = cols * nodeW + gapX; + const startX = width / 2 - gridW / 2; + const startY = plane.y + plane.h + (mode === "cloud" ? 84 : 66); + nodes = AGENTS.map((a, i) => ({ + label: a.label, + sub: a.sub, + state: a.state, + flash: 0, + w: nodeW, + h: nodeH, + x: startX + (i % cols) * (nodeW + gapX), + y: startY + Math.floor(i / cols) * (nodeH + gapY), + })); + } else { + const colX = pad + 8; + plane = { x: width - pad - 214, y: height / 2 - 46, w: 206, h: 92 }; + const total = AGENTS.length * nodeH + (AGENTS.length - 1) * 16; + const startY = height / 2 - total / 2; + nodes = AGENTS.map((a, i) => ({ + label: a.label, + sub: a.sub, + state: a.state, + flash: 0, + w: nodeW, + h: nodeH, + x: colX, + y: startY + i * (nodeH + 16), + })); + } + + wires = nodes.map((n, i) => { + if (stacked) { + const ax = n.x + n.w / 2; + const ay = n.y; + const bx = plane.x + plane.w * (0.2 + 0.2 * i); + const by = plane.y + plane.h; + const mid = (ay + by) / 2; + return { node: i, ax, ay, bx, by, c1x: ax, c1y: mid, c2x: bx, c2y: mid }; + } + const ax = n.x + n.w; + const ay = n.y + n.h / 2; + const bx = plane.x; + const by = plane.y + plane.h * (0.24 + 0.17 * i); + const mid = ax + (bx - ax) * 0.5; + return { node: i, ax, ay, bx, by, c1x: mid, c1y: ay, c2x: mid, c2y: by }; + }); + + if (modeRef.current === "self") { + boundary = { x: pad - 8, y: pad - 8, w: width - (pad - 8) * 2, h: height - (pad - 8) * 2, label: "YOUR NETWORK", split: null }; + } else if (stacked) { + const y = plane.y + plane.h + 34; + boundary = { x: pad - 8, y, w: width - (pad - 8) * 2, h: height - y - (pad - 8), label: "YOUR NETWORK", split: y }; + } else { + const split = plane.x - 46; + boundary = { x: pad - 8, y: pad - 8, w: split - (pad - 8), h: height - (pad - 8) * 2, label: "YOUR NETWORK", split }; + } + }; + + const spawn = () => { + if (packets.length > 60) return; + const wire = Math.floor(Math.random() * wires.length); + packets.push({ wire, t: 0, speed: 0.34 + Math.random() * 0.16, inbound: false }); + }; + + const drawWire = (w: Wire) => { + ctx.beginPath(); + ctx.moveTo(w.ax, w.ay); + ctx.bezierCurveTo(w.c1x, w.c1y, w.c2x, w.c2y, w.bx, w.by); + ctx.stroke(); + }; + + const draw = (dt: number) => { + ctx.clearRect(0, 0, width, height); + const mono = palette.mono; + + // Boundary of what you own. + if (boundary) { + ctx.save(); + ctx.setLineDash([5, 5]); + ctx.lineWidth = 1; + ctx.strokeStyle = palette.rule; + roundRect(ctx, boundary.x, boundary.y, boundary.w, boundary.h, 10); + ctx.stroke(); + ctx.restore(); + + ctx.font = mono; + ctx.fillStyle = palette.ink3; + ctx.textBaseline = "middle"; + ctx.textAlign = "left"; + ctx.fillText(boundary.label, boundary.x + 12, boundary.y + 13); + + if (modeRef.current === "cloud") { + ctx.textAlign = stacked ? "left" : "right"; + const x = stacked ? boundary.x + 12 : width - 22; + const y = stacked ? 18 : boundary.y + 13; + ctx.fillText("HOSTED BY US", x, y); + } + } + + // Wires. Drawn under everything, because they are infrastructure. + ctx.lineWidth = 1.25; + ctx.strokeStyle = palette.rule; + wires.forEach(drawWire); + + // Packets travelling the wires. + for (let i = packets.length - 1; i >= 0; i--) { + const p = packets[i]; + p.t += p.speed * dt; + if (p.t >= 1) { + if (p.inbound) nodes[wires[p.wire].node].flash = 1; + else planePulse = 1; + packets.splice(i, 1); + continue; + } + const w = wires[p.wire]; + const pos = pointAt(w, p.inbound ? 1 - p.t : p.t); + const fade = Math.min(1, Math.sin(p.t * Math.PI) * 2.2); + + ctx.globalAlpha = fade; + ctx.fillStyle = p.inbound ? palette.accent : palette.up; + ctx.beginPath(); + ctx.arc(pos.x, pos.y, p.inbound ? 3.4 : 2.4, 0, Math.PI * 2); + ctx.fill(); + + if (p.inbound) { + ctx.globalAlpha = fade * 0.22; + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 8, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; + } + + // Agents. + for (const n of nodes) { + if (n.flash > 0) { + ctx.globalAlpha = n.flash * 0.5; + ctx.strokeStyle = palette.accent; + ctx.lineWidth = 2; + roundRect(ctx, n.x - 2, n.y - 2, n.w + 4, n.h + 4, 8); + ctx.stroke(); + ctx.globalAlpha = 1; + n.flash = Math.max(0, n.flash - dt * 1.6); + } + + ctx.fillStyle = palette.panel; + ctx.strokeStyle = palette.rule; + ctx.lineWidth = 1; + roundRect(ctx, n.x, n.y, n.w, n.h, 6); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = n.state === "up" ? palette.up : palette.pend; + ctx.beginPath(); + ctx.arc(n.x + 14, n.y + n.h / 2, 3.5, 0, Math.PI * 2); + ctx.fill(); + + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + ctx.font = mono; + ctx.fillStyle = palette.ink; + ctx.fillText(n.label, n.x + 26, n.y + n.h / 2 - 6); + ctx.fillStyle = palette.ink3; + ctx.fillText(n.sub, n.x + 26, n.y + n.h / 2 + 8); + } + + // Control plane. + if (planePulse > 0) { + ctx.globalAlpha = planePulse * 0.35; + ctx.strokeStyle = palette.accent; + ctx.lineWidth = 2; + roundRect(ctx, plane.x - 3 - planePulse * 4, plane.y - 3 - planePulse * 4, plane.w + 6 + planePulse * 8, plane.h + 6 + planePulse * 8, 10); + ctx.stroke(); + ctx.globalAlpha = 1; + planePulse = Math.max(0, planePulse - dt * 1.5); + } + + ctx.fillStyle = palette.accent; + roundRect(ctx, plane.x, plane.y, plane.w, plane.h, 8); + ctx.fill(); + + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + const cx = plane.x + plane.w / 2; + ctx.fillStyle = palette.panel; + ctx.font = palette.sans; + ctx.fillText("Vantage", cx, plane.y + plane.h / 2 - 11); + ctx.font = mono; + ctx.globalAlpha = 0.78; + ctx.fillText(modeRef.current === "cloud" ? "vantage.hostxtra.co.uk" : "your own hardware", cx, plane.y + plane.h / 2 + 8); + ctx.globalAlpha = 1; + + // Direction of travel, stated once rather than on every wire. + if (!stacked) { + ctx.font = mono; + ctx.fillStyle = palette.ink3; + ctx.textAlign = "center"; + const midX = (nodes[0].x + nodes[0].w + plane.x) / 2; + ctx.fillText("your servers dial out", midX, height - 26); + } + }; + + const frame = (now: number) => { + const dt = Math.min((now - last) / 1000, 0.05); + last = now; + + sinceSpawn += dt; + if (sinceSpawn > 0.42) { + sinceSpawn = 0; + spawn(); + } + + sinceCommand += dt; + if (sinceCommand > 4.4) { + sinceCommand = 0; + const wire = Math.floor(Math.random() * wires.length); + packets.push({ wire, t: 0, speed: 0.62, inbound: true }); + } + + draw(dt); + raf = requestAnimationFrame(frame); + }; + + const start = () => { + cancelAnimationFrame(raf); + layout(); + if (reduced.matches) { + packets.length = 0; + wires.forEach((_, i) => packets.push({ wire: i, t: 0.35 + i * 0.12, speed: 0, inbound: false })); + draw(0); + return; + } + last = performance.now(); + raf = requestAnimationFrame(frame); + }; + + const onResize = () => start(); + const observer = new ResizeObserver(onResize); + observer.observe(canvas); + + const repaint = () => { + palette = readPalette(document.documentElement); + if (reduced.matches) draw(0); + }; + + const themeObserver = new MutationObserver(repaint); + themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); + + const scheme = window.matchMedia("(prefers-color-scheme: dark)"); + const onScheme = repaint; + scheme.addEventListener("change", onScheme); + reduced.addEventListener("change", start); + + start(); + + return () => { + cancelAnimationFrame(raf); + observer.disconnect(); + themeObserver.disconnect(); + scheme.removeEventListener("change", onScheme); + reduced.removeEventListener("change", start); + }; + }, [mode]); + + return ( +
+
+
+ {MODES.map((m) => ( + + ))} +
+

+ your server checking in + something you asked for +

+
+ + + +
{NOTES[mode]}
+
+ ); +}