Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,6 @@ export { ContainerIsolation } from "./components/react/container/ContainerIsolat
export { ContainerRequestPath } from "./components/react/container/ContainerRequestPath";
export { ContainerLifecycle } from "./components/react/container/ContainerLifecycle";
export { ContainerPlacement } from "./components/react/container/ContainerPlacement";
export { TunnelOutbound } from "./components/react/tunnel/TunnelOutbound";
export { TunnelIdentity } from "./components/react/tunnel/TunnelIdentity";
export { TunnelTraffic } from "./components/react/tunnel/TunnelTraffic";
12 changes: 11 additions & 1 deletion src/components/react/container/Transport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,28 +175,38 @@ export function LabeledButton({
ariaLabel,
title,
highlight,
disabled,
ariaDisabled,
onClick,
children,
}: {
ariaLabel: string;
title?: string;
highlight?: boolean;
disabled?: boolean;
/** Visually dims and blocks clicks like `disabled`, but stays
* focusable — for controls that disable themselves once used. */
ariaDisabled?: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
onClick={ariaDisabled ? undefined : onClick}
aria-label={ariaLabel}
title={title}
aria-disabled={ariaDisabled || undefined}
disabled={disabled}
className={cn(
"inline-flex items-center justify-center gap-1.5 px-2 py-1 font-mono text-[10px] font-medium tracking-widest uppercase",
"cursor-pointer rounded-sm border shadow-xs select-none active:scale-[0.97]",
"transition-[background-color,opacity,transform] duration-200 ease-out",
"border-neutral-200 dark:border-neutral-800",
"bg-white hover:bg-neutral-50 dark:bg-neutral-900 dark:hover:bg-neutral-800",
highlight && "ring-brand ring-1",
(disabled || ariaDisabled) &&
"cursor-default opacity-45 hover:bg-white active:scale-100 dark:hover:bg-neutral-900",
)}
>
{children}
Expand Down
16 changes: 14 additions & 2 deletions src/components/react/diagram-weld/shapes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export interface WeldedCardProps {
accent?: boolean;
/** Drop the shadow — for a card overlaid on another. */
flat?: boolean;
/** Corner radius override — pass rect.h / 2 for a fully rounded pill. */
rx?: number;
}

export function WeldedCard({
Expand All @@ -81,10 +83,11 @@ export function WeldedCard({
dashed = false,
accent = false,
flat = false,
rx,
}: WeldedCardProps) {
return (
<path
d={indentedRect(rect, notches)}
d={indentedRect(rect, notches, rx)}
fill={accent ? "var(--color-brand)" : "white"}
fillOpacity={accent ? 0.06 : 1}
stroke={active ? "var(--color-brand)" : "currentColor"}
Expand Down Expand Up @@ -602,6 +605,8 @@ export interface LabelCardProps {
active?: boolean;
ghost?: boolean;
fontSize?: number;
/** Corner radius override — pass rect.h / 2 for a fully rounded pill. */
rx?: number;
}

export function LabelCard({
Expand All @@ -610,11 +615,18 @@ export function LabelCard({
label,
active = false,
ghost = false,
rx,
fontSize = 9,
}: LabelCardProps) {
return (
<g>
<WeldedCard rect={rect} notches={notches} active={active} ghost={ghost} />
<WeldedCard
rect={rect}
notches={notches}
active={active}
ghost={ghost}
rx={rx}
/>
<text
x={rect.cx}
y={rect.cy + fontSize * 0.33}
Expand Down
28 changes: 19 additions & 9 deletions src/components/react/diagram-weld/welding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,26 +30,36 @@ export interface NotchConfig {
right?: NotchSide;
}

function resolveSide(side: NotchSide | undefined, edge: number): number[] {
function resolveSide(
side: NotchSide | undefined,
edge: number,
radius = RX,
): number[] {
if (!side) return [];
const raw = side === true ? [0.5] : [...side].sort((a, b) => a - b);
if (edge <= 0) return [];
const min = (RX + NOTCH_W) / edge;
// a notch (width NOTCH_W, centred on f) must clear the corner arcs:
// centre >= radius + NOTCH_W/2 from either corner. Exact fit allowed.
const min = (radius + NOTCH_W / 2) / edge;
const max = 1 - min;
if (min >= max) return [];
if (min > max) return [];
return raw.filter((f) => f >= min && f <= max);
}

export function indentedRect(rect: NodeRect, notches: NotchConfig): string {
export function indentedRect(
rect: NodeRect,
notches: NotchConfig,
radius = RX,
): string {
const { l, t, r: right, b, w, h } = rect;
const rx = Math.min(RX, w / 2, h / 2);
const rx = Math.min(radius, w / 2, h / 2);
const nw = NOTCH_W;
const nd = NOTCH_D;

const top = resolveSide(notches.top, w);
const bottom = resolveSide(notches.bottom, w);
const leftSide = resolveSide(notches.left, h);
const rightSide = resolveSide(notches.right, h);
const top = resolveSide(notches.top, w, rx);
const bottom = resolveSide(notches.bottom, w, rx);
const leftSide = resolveSide(notches.left, h, rx);
const rightSide = resolveSide(notches.right, h, rx);

const parts: string[] = [`M ${l + rx},${t}`];

Expand Down
179 changes: 179 additions & 0 deletions src/components/react/tunnel/TunnelIdentity.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"use client";

// Figure 2 on the Tunnel concepts page: one tunnel identity above, two
// interchangeable connectors below, both inside the network boundary. Each
// request pours down to whichever connector serves this turn. Stopping
// connector B reroutes every request to connector A; the tunnel ID never
// changes.
import { useEffect, useRef, useState } from "react";
import { Diagram, useDiagramOrDefault } from "@cloudflare/nimbus-docs/react";
import { LabelCard, makeRect } from "../diagram-weld";
import { WeldCanvas } from "../container/WeldCanvas";
import { LabeledButton, Toolbar } from "../container/Transport";
import type { DiagramFallbackProps } from "../container/DiagramFallback";
import { NetBoundary, Port, Pour, ortho } from "./TunnelKit";

// Layout, in SVG user units. Both connectors hang off one vertical spine.
const VIEW_W = 340;
const VIEW_H = 290;
const CX = 170; // shared centre spine
const JY = 108; // junction row: stem in from above, two branches out
const TUN = makeRect(115, 26, 110, 40); // tunnel nameplate
const FIELD = makeRect(38, 152, 264, 116); // "Your network" boundary
const CARDS = [makeRect(57, 200, 104, 36), makeRect(179, 200, 104, 36)];
const XS = [109, 231]; // branch x-coordinate for connector A / B

// Animation timing.
const ARRIVE_MS = 820; // a pour reaches the connector
const TURN_MS = 1450; // the next request begins
const POUR_DUR = 0.8;
const POUR_FADE = 1.25;

// Path a request follows from the tunnel port down to the given connector.
function wavePath(lane: 0 | 1): string {
return ortho([
[CX, TUN.b - 6],
[CX, JY],
[XS[lane]!, JY],
[XS[lane]!, CARDS[lane]!.t],
]);
}

export function TunnelIdentity(_props: DiagramFallbackProps) {
return (
<Diagram label="Two connectors inside your network serve one stable tunnel identity. Requests alternate between them; stopping connector B sends every request through connector A while the tunnel ID never changes. Operate it with the Stop connector B control.">
<IdentityBody />
</Diagram>
);
}

function IdentityBody() {
const ctx = useDiagramOrDefault("TunnelIdentity");
const reduced = ctx.reducedMotion;
const [bStopped, setBStopped] = useState(false);
const [tick, setTick] = useState(0);
const [arrived, setArrived] = useState(false);
const [lane, setLane] = useState<0 | 1>(0);

// Sample the stop control only at a turn boundary, never mid-pour, so an
// in-flight request always finishes and only the following one reroutes.
const bStoppedRef = useRef(bStopped);
bStoppedRef.current = bStopped;

useEffect(() => {
if (reduced || !ctx.playing) {
setArrived(true);
setLane(0);
return;
}
setLane(bStoppedRef.current ? 0 : ((tick % 2) as 0 | 1));
setArrived(false);
const arrive = setTimeout(() => setArrived(true), ARRIVE_MS);
const advance = setTimeout(() => setTick((t) => t + 1), TURN_MS);
return () => {
clearTimeout(arrive);
clearTimeout(advance);
};
}, [tick, reduced, ctx.playing]);

return (
<WeldCanvas
width={VIEW_W}
height={VIEW_H}
liveStatus={
bStopped
? "Connector B is stopped; every request is served by connector A. The tunnel identity is unchanged."
: "Requests alternate between connector A and connector B under one tunnel identity."
}
controls={
<Toolbar
status={bStopped ? "Connector B stopped" : "Both connectors live"}
>
<LabeledButton
ariaLabel={bStopped ? "Start connector B" : "Stop connector B"}
onClick={() => setBStopped((v) => !v)}
highlight={!bStopped}
>
{bStopped ? "Start connector B" : "Stop connector B"}
</LabeledButton>
</Toolbar>
}
>
<NetBoundary rect={FIELD} label="Your network" labelAnchor="middle" />

{/* Stem from the tunnel down to the junction. */}
<path
d={`M ${CX} ${TUN.b + 5} L ${CX} ${JY - 4}`}
fill="none"
strokeWidth={1.25}
className="stroke-neutral-300 dark:stroke-neutral-700"
/>

{/* The two branches leaving the junction; B dims when stopped. */}
{CARDS.map((c, i) => (
<path
key={i}
d={ortho([
[CX, JY],
[XS[i]!, JY],
[XS[i]!, c.t - 1],
])}
fill="none"
strokeWidth={1.25}
className="stroke-neutral-300 dark:stroke-neutral-700"
opacity={bStopped && i === 1 ? 0.3 : 1}
style={{ transition: "opacity 250ms" }}
/>
))}

{/* The request: a pour down the live branch. Remounting on a new key
each turn restarts the CSS draw animation. */}
{!reduced && ctx.playing && (
<Pour
key={`${tick}-${lane}`}
d={wavePath(lane)}
dur={POUR_DUR}
fade={POUR_FADE}
/>
)}

{/* Junction node. */}
<rect
x={CX - 4}
y={JY - 4}
width={8}
height={8}
rx={1.5}
strokeWidth={1.25}
fill="var(--nb-background, white)"
className="stroke-neutral-300 dark:stroke-neutral-700"
/>

{/* The tunnel: a fixed identity and its port — the one thing that
never changes. */}
<LabelCard rect={TUN} label="Tunnel #123" fontSize={11} active />
<Port cx={CX} cy={TUN.b} taken />

{/* The connectors. Whichever receives this turn lights up; connector
B ghosts and frees its port while stopped. */}
{CARDS.map((c, i) => {
const receiving = arrived && lane === i && !(bStopped && i === 1);
return (
<LabelCard
key={i}
rect={c}
label={i === 0 ? "Connector A" : "Connector B"}
fontSize={11}
active={receiving && !reduced}
ghost={bStopped && i === 1}
notches={{ top: true }}
/>
);
})}
<Port cx={XS[0]!} cy={CARDS[0]!.t} taken />
<Port cx={XS[1]!} cy={CARDS[1]!.t} taken={!bStopped} />
</WeldCanvas>
);
}

export default TunnelIdentity;
Loading
Loading