This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "19ch" }}>How the pieces fit together.</h1>
|
||||
<p className="lede">There are only three parts: Vantage itself, a small program on each of your servers, and one connection between them that your server opens.</p>
|
||||
|
||||
<TopologyDiagram />
|
||||
|
||||
<div className="split" style={{ marginTop: "3rem" }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: "var(--s-2)", maxWidth: "18ch" }}>Your servers call us, not the other way round.</h2>
|
||||
|
||||
@@ -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<Mode, string> = {
|
||||
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<Mode>("cloud");
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const modeRef = useRef<Mode>(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 (
|
||||
<figure className="topo">
|
||||
<div className="topo__head">
|
||||
<div className="topo__seg" role="group" aria-label="Where Vantage runs">
|
||||
{MODES.map((m) => (
|
||||
<button key={m.id} type="button" className={m.id === mode ? "topo__btn is-on" : "topo__btn"} aria-pressed={m.id === mode} onClick={() => setMode(m.id)}>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="topo__key">
|
||||
<span className="topo__dot topo__dot--out" /> your server checking in
|
||||
<span className="topo__dot topo__dot--in" /> something you asked for
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="topo__canvas"
|
||||
role="img"
|
||||
aria-label={
|
||||
mode === "cloud"
|
||||
? "Four of your servers, inside your own network, each opening a connection out to Vantage running on our hardware. Nothing connects inward."
|
||||
: "Four of your servers and Vantage itself, all inside your own network. The servers connect to Vantage, and nothing leaves your network."
|
||||
}
|
||||
/>
|
||||
|
||||
<figcaption className="topo__note">{NOTES[mode]}</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user