diff --git a/frontend/.gitignore b/frontend/.gitignore index ed4833caea..f6dbbf0541 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,3 +1,6 @@ # Vite config bundling temp files vite.config.ts.timestamp-* + +# Amp orb portal state +.amp/ diff --git a/frontend/.ladle/components.tsx b/frontend/.ladle/components.tsx new file mode 100644 index 0000000000..25d1adef25 --- /dev/null +++ b/frontend/.ladle/components.tsx @@ -0,0 +1,17 @@ +import type { GlobalProvider } from "@ladle/react"; + +/** + * Sync Ladle's theme addon with the dashboard's `.dark` token scope. + * + * Ladle sets `data-theme` on ``; the app's `theme.css` keys dark tokens + * off `:root.dark`, so mirror the state onto the class. Writing to the DOM + * during render is fine here: it is idempotent and must happen before the + * story paints to avoid a light/dark flash. + */ +export const Provider: GlobalProvider = ({ children, globalState }) => { + document.documentElement.classList.toggle( + "dark", + globalState.theme === "dark", + ); + return children; +}; diff --git a/frontend/.ladle/config.mjs b/frontend/.ladle/config.mjs index 0ab009a6e7..1ba09926a9 100644 --- a/frontend/.ladle/config.mjs +++ b/frontend/.ladle/config.mjs @@ -1,4 +1,12 @@ /** @type {import('@ladle/react').UserConfig} */ export default { viteConfig: ".ladle/vite.config.ts", + addons: { + // The dashboard ships dark-only (``), so match that by + // default; the theme toggle switches to the light tokens. + theme: { + enabled: true, + defaultState: "dark", + }, + }, }; diff --git a/frontend/.ladle/ladle.css b/frontend/.ladle/ladle.css index 5907b485a5..ed7ccec917 100644 --- a/frontend/.ladle/ladle.css +++ b/frontend/.ladle/ladle.css @@ -1,50 +1,13 @@ -@import "../src/components/theme.css"; - -@tailwind base; -@tailwind components; -@tailwind utilities; - -/* Force dark theme for all Ladle stories. */ -:root { - --background: 20 14.3% 4.1%; - --foreground: 60 9.1% 97.8%; - --card: 0 9.09% 6.47%; - --card-foreground: 60 9.1% 97.8%; - --popover: 0 9.09% 6.47%; - --popover-foreground: 60 9.1% 97.8%; - --primary: 18.5 100% 50%; - --primary-foreground: 60 9.1% 97.8%; - --secondary: 12 6.5% 15.1%; - --secondary-foreground: 60 9.1% 97.8%; - --muted: 34 10% 10%; - --muted-foreground: 24 5.4% 63.9%; - --accent: 12 6.5% 15.1%; - --accent-foreground: 60 9.1% 97.8%; - --destructive: 0 72.2% 50.6%; - --destructive-foreground: 60 9.1% 97.8%; - --warning: 47.9 95.8% 53.1%; - --border: 12 6.5% 15.1%; - --input: 12 6.5% 15.1%; - --ring: 18.59deg 100% 50%; - --background-main: 0 7.14% 5.49%; -} - -@layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground min-h-screen; - } -} +/* + * Use the real app stylesheet so stories render with the same tokens as the + * dashboard. Light tokens live on `:root`, dark tokens on `:root.dark`; the + * Ladle `Provider` in `components.tsx` toggles the `dark` class from Ladle's + * theme addon. + */ +@import "../src/index.css"; /* Remove Ladle story container padding and make it fill the full viewport. */ .ladle-main { padding: 0 !important; height: 100vh; } - -/* Dark theme for xyflow edges. */ -.react-flow__edge-path { - stroke: hsl(var(--muted-foreground)); -} diff --git a/frontend/public/images/brand/actors-logo.svg b/frontend/public/images/brand/actors-logo.svg new file mode 100644 index 0000000000..a753542cb1 --- /dev/null +++ b/frontend/public/images/brand/actors-logo.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/images/brand/agentos-logo.svg b/frontend/public/images/brand/agentos-logo.svg new file mode 100644 index 0000000000..4becde7d6a --- /dev/null +++ b/frontend/public/images/brand/agentos-logo.svg @@ -0,0 +1,12 @@ + + + + + + +OS + + + + + diff --git a/frontend/public/images/brand/dynamic-apps-logo.svg b/frontend/public/images/brand/dynamic-apps-logo.svg new file mode 100644 index 0000000000..7ff36b69e0 --- /dev/null +++ b/frontend/public/images/brand/dynamic-apps-logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/images/brand/workflows-logo.svg b/frontend/public/images/brand/workflows-logo.svg new file mode 100644 index 0000000000..a9133c2135 --- /dev/null +++ b/frontend/public/images/brand/workflows-logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/src/app/billing/billing-plan-badge.tsx b/frontend/src/app/billing/billing-plan-badge.tsx index bc0c9c383b..aa98a4b9e4 100644 --- a/frontend/src/app/billing/billing-plan-badge.tsx +++ b/frontend/src/app/billing/billing-plan-badge.tsx @@ -21,9 +21,12 @@ const PLAN_COLORS: Record = { export function PlanBadge({ plan, + label, className, }: { plan: string; + /** Overrides the plan's default label. */ + label?: string; className?: string; }) { return ( @@ -35,7 +38,7 @@ export function PlanBadge({ className, )} > - {PLAN_LABELS[plan] ?? plan} + {label ?? PLAN_LABELS[plan] ?? plan} ); } diff --git a/frontend/src/components/cluster-onboarding/actor-logs.ts b/frontend/src/components/cluster-onboarding/actor-logs.ts new file mode 100644 index 0000000000..f14cb8f8a0 --- /dev/null +++ b/frontend/src/components/cluster-onboarding/actor-logs.ts @@ -0,0 +1,341 @@ +import type { Rivet } from "@rivet-gg/cloud"; +import type { ActorStatus } from "@/components/actors/queries"; +import type { RecommendedActor, UseCase } from "./catalog"; + +export interface MockActor { + id: string; + name: string; + key: string; + status: ActorStatus; + region: string; + /** Unix ms. */ + createdAt: number; +} + +/** Deterministic xorshift PRNG so the mock renders the same on every load. */ +export function rng(seed: number) { + let s = seed >>> 0 || 1; + return () => { + s ^= s << 13; + s ^= s >>> 17; + s ^= s << 5; + return (s >>> 0) / 0xffffffff; + }; +} + +export function hash(input: string) { + let h = 2166136261; + for (let i = 0; i < input.length; i++) { + h ^= input.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +const HEX = "0123456789abcdef"; + +export function hexId(next: () => number, length: number) { + let out = ""; + for (let i = 0; i < length; i++) { + out += HEX[Math.floor(next() * 16)]; + } + return out; +} + +const STATUS_WEIGHTS: Array<[ActorStatus, number]> = [ + ["running", 0.62], + ["sleeping", 0.3], + ["starting", 0.04], + ["crashed", 0.04], +]; + +function pickStatus(next: () => number): ActorStatus { + const r = next(); + let acc = 0; + for (const [status, weight] of STATUS_WEIGHTS) { + acc += weight; + if (r < acc) return status; + } + return "running"; +} + +const KEY_WORDS = [ + "general", + "design", + "ops", + "random", + "support", + "launch", + "eu-sales", + "incident-42", +]; + +function fillKey(pattern: string, next: () => number, index: number) { + return pattern.replace(/\{(\w+)\}/g, (_, param: string) => { + if (/id$/i.test(param) && /room|doc|match|session|conv/i.test(param)) { + return KEY_WORDS[index % KEY_WORDS.length]; + } + return hexId(next, 8); + }); +} + +/** + * Builds a stable list of actor instances for the use case's recommended + * actors. The first actor type gets the most instances because it is the one + * users look at first. + */ +export function mockActors( + useCase: UseCase, + region: string, + now = Date.now(), +): MockActor[] { + const next = rng(hash(`${useCase.id}:${region}`)); + const actors: MockActor[] = []; + useCase.actors.forEach((actor: RecommendedActor, typeIndex) => { + const count = typeIndex === 0 ? 6 : 3; + for (let i = 0; i < count; i++) { + actors.push({ + id: `${hexId(next, 8)}-${hexId(next, 4)}-4${hexId(next, 3)}-${hexId(next, 4)}-${hexId(next, 12)}`, + name: actor.name, + key: fillKey(actor.key, next, typeIndex * 7 + i), + status: pickStatus(next), + region, + createdAt: now - Math.floor(next() * 36 * 60 * 60 * 1000), + }); + } + }); + return actors.sort((a, b) => b.createdAt - a.createdAt); +} + +export type LogEntry = Rivet.LogStreamEvent.Log; + +function isoTimestamp(ms: number) { + return new Date(ms).toISOString().replace("T", " ").replace("Z", ""); +} + +interface LogTemplate { + message: string; + severity: "info" | "warn" | "error"; +} + +/** Log lines per actor name. Generic fallbacks keep unknown actors realistic. */ +const LOG_TEMPLATES: Record = { + ChatRoom: [ + { message: "client connected conn=%h users=%n", severity: "info" }, + { + message: "message id=%h bytes=%n broadcast to %n clients", + severity: "info", + }, + { message: "persisted batch rows=%n took=%nms", severity: "info" }, + { + message: "client disconnected conn=%h reason=idle", + severity: "info", + }, + { + message: "slow consumer conn=%h backlog=%n dropping to delta sync", + severity: "warn", + }, + ], + UserPresence: [ + { message: "heartbeat user=%h rooms=%n", severity: "info" }, + { message: "typing start room=%h", severity: "info" }, + { message: "fan-out presence to %n rooms took=%nms", severity: "info" }, + ], + Inbox: [ + { message: "mention queued from=%h unread=%n", severity: "info" }, + { message: "flushed %n notifications via push", severity: "info" }, + ], + RateLimiter: [ + { message: "window rolled count=%n limit=120", severity: "info" }, + { message: "throttled client=%h over by %n", severity: "warn" }, + ], + AgentSession: [ + { + message: "turn=%n model=claude-sonnet tokens_in=%n", + severity: "info", + }, + { message: "tool call shell exit=0 took=%nms", severity: "info" }, + { message: "streamed %n tokens to %n clients", severity: "info" }, + { message: "provider 429, retrying in %nms", severity: "warn" }, + ], +}; + +const GENERIC_TEMPLATES: LogTemplate[] = [ + { message: "action %a took=%nms", severity: "info" }, + { message: "state saved bytes=%n took=%nms", severity: "info" }, + { message: "connection opened conn=%h", severity: "info" }, + { message: "connection closed conn=%h", severity: "info" }, + { message: "retrying upstream after %nms", severity: "warn" }, +]; + +const ACTIONS = ["send", "join", "leave", "sync", "update", "list"]; + +function fill(template: string, next: () => number) { + return template + .replace(/%h/g, () => hexId(next, 6)) + .replace(/%n/g, () => String(1 + Math.floor(next() * 480))) + .replace(/%a/g, () => ACTIONS[Math.floor(next() * ACTIONS.length)]); +} + +function entry( + actor: MockActor, + ts: number, + message: string, + severity: string, + stream: "stdout" | "stderr", + next: () => number, +): LogEntry { + return { + event: "log", + data: { + timestamp: isoTimestamp(ts), + severity, + message, + region: actor.region, + insertId: hexId(next, 16), + stream, + }, + }; +} + +/** Startup lines every actor emits, in order. */ +function lifecycleLines(actor: MockActor, next: () => number): LogEntry[] { + const start = actor.createdAt; + return [ + entry( + actor, + start, + `rivet: starting actor ${actor.name} key=${actor.key}`, + "info", + "stdout", + next, + ), + entry( + actor, + start + 12, + "rivet: opened sqlite state (0 B)", + "info", + "stdout", + next, + ), + entry( + actor, + start + 41, + `${actor.name}: ready`, + "info", + "stdout", + next, + ), + ]; +} + +/** + * Deterministic log history for an actor. Crashed actors end with a stack + * trace; sleeping actors end with the sleep line. + */ +export function mockActorLogs(actor: MockActor, now = Date.now()): LogEntry[] { + const next = rng(hash(actor.id)); + const templates = LOG_TEMPLATES[actor.name] ?? GENERIC_TEMPLATES; + const lines = lifecycleLines(actor, next); + const span = Math.max(now - actor.createdAt - 60, 1000); + const count = 40 + Math.floor(next() * 60); + for (let i = 0; i < count; i++) { + const t = templates[Math.floor(next() * templates.length)]; + const ts = + actor.createdAt + 60 + Math.floor((span * (i + 1)) / (count + 1)); + lines.push( + entry( + actor, + ts, + `${actor.name}: ${fill(t.message, next)}`, + t.severity, + t.severity === "info" ? "stdout" : "stderr", + next, + ), + ); + } + if (actor.status === "crashed") { + const ts = now - 2000; + lines.push( + entry( + actor, + ts, + `${actor.name}: unhandled rejection in action handler`, + "error", + "stderr", + next, + ), + entry( + actor, + ts + 1, + "TypeError: Cannot read properties of undefined (reading 'userId')", + "error", + "stderr", + next, + ), + entry( + actor, + ts + 1, + ` at ${actor.name}.onMessage (file:///app/src/actors/${actor.name.toLowerCase()}.ts:84:31)`, + "error", + "stderr", + next, + ), + entry( + actor, + ts + 1, + " at Actor.dispatch (node_modules/rivetkit/dist/actor.js:412:18)", + "error", + "stderr", + next, + ), + entry( + actor, + ts + 40, + "rivet: actor exited with code 1, restarting with backoff 4s", + "error", + "stderr", + next, + ), + ); + } else if (actor.status === "sleeping") { + lines.push( + entry( + actor, + now - 90_000, + "rivet: no connections for 60s, persisting state (18 KB)", + "info", + "stdout", + next, + ), + entry( + actor, + now - 89_950, + "rivet: actor sleeping", + "info", + "stdout", + next, + ), + ); + } + return lines; +} + +/** One new live line for a running actor, used to make the view tick. */ +export function mockLiveLogLine( + actor: MockActor, + seq: number, + now = Date.now(), +): LogEntry { + const next = rng(hash(`${actor.id}:${seq}`)); + const templates = LOG_TEMPLATES[actor.name] ?? GENERIC_TEMPLATES; + const t = templates[Math.floor(next() * templates.length)]; + return entry( + actor, + now, + `${actor.name}: ${fill(t.message, next)}`, + t.severity, + t.severity === "info" ? "stdout" : "stderr", + next, + ); +} diff --git a/frontend/src/components/cluster-onboarding/actor-metrics.tsx b/frontend/src/components/cluster-onboarding/actor-metrics.tsx new file mode 100644 index 0000000000..d24590bfff --- /dev/null +++ b/frontend/src/components/cluster-onboarding/actor-metrics.tsx @@ -0,0 +1,236 @@ +import { format } from "date-fns"; +import { useId, useMemo } from "react"; +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; + +/** Mock, deterministic per-product actor metrics for the onboarded view. */ +export interface ActorMetrics { + total: number; + awake: number; + connections: number; + actionsPerMin: number; + errors24h: number; + stateBytes: number; + wakeP50Ms: number; + /** 24 h series, one point per 15 min. */ + awakeSeries: number[]; + actionsSeries: number[]; +} + +const POINTS = 96; // 24 h at 15 min + +function seededRandom(seed: string) { + let h = 2166136261; + for (const ch of seed) { + h ^= ch.charCodeAt(0); + h = Math.imul(h, 16777619); + } + return () => { + h ^= h << 13; + h ^= h >>> 17; + h ^= h << 5; + return ((h >>> 0) % 10_000) / 10_000; + }; +} + +export function mockActorMetrics(seed: string): ActorMetrics { + const rand = seededRandom(seed); + const total = 400 + Math.floor(rand() * 2400); + // Diurnal curve with noise; awake is a fraction of total. + const awakeSeries = Array.from({ length: POINTS }, (_, i) => { + const t = i / POINTS; + const diurnal = 0.5 + 0.5 * Math.sin((t - 0.3) * Math.PI * 2); + const frac = 0.08 + 0.22 * diurnal + (rand() - 0.5) * 0.05; + return Math.max(1, Math.round(total * frac)); + }); + const actionsSeries = awakeSeries.map((a) => + Math.round(a * (2.4 + rand() * 1.2)), + ); + const awake = awakeSeries[POINTS - 1]; + return { + total, + awake, + connections: Math.round(awake * (1.3 + rand() * 0.9)), + actionsPerMin: actionsSeries[POINTS - 1], + errors24h: Math.floor(rand() * 14), + stateBytes: Math.round(total * (180_000 + rand() * 900_000)), + wakeP50Ms: Math.round(18 + rand() * 40), + awakeSeries, + actionsSeries, + }; +} + +export function formatBytes(bytes: number): string { + if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`; + if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(0)} MB`; + return `${(bytes / 1e3).toFixed(0)} KB`; +} + +export function ActorMetricsDashboard({ metrics }: { metrics: ActorMetrics }) { + const now = useMemo(() => Date.now(), []); + const stats: { label: string; value: string; hint?: string }[] = [ + { label: "Actors", value: metrics.total.toLocaleString() }, + { + label: "Awake", + value: metrics.awake.toLocaleString(), + hint: `${Math.round((metrics.awake / metrics.total) * 100)}% of actors`, + }, + { + label: "Sleeping", + value: (metrics.total - metrics.awake).toLocaleString(), + }, + { label: "Connections", value: metrics.connections.toLocaleString() }, + { + label: "Actions / min", + value: metrics.actionsPerMin.toLocaleString(), + }, + { + label: "Errors (24h)", + value: metrics.errors24h.toLocaleString(), + }, + { label: "State storage", value: formatBytes(metrics.stateBytes) }, + { label: "Wake latency (p50)", value: `${metrics.wakeP50Ms} ms` }, + ]; + + return ( +
+
+ {stats.map((s) => ( +
+
+ {s.label} +
+
+ {s.value} +
+ {s.hint ? ( +
+ {s.hint} +
+ ) : null} +
+ ))} +
+
+ + +
+
+ ); +} + +const chartConfig = { + value: { color: "hsl(var(--chart-1))", label: "Value" }, +} satisfies ChartConfig; + +function SeriesCard({ + title, + series, + now, + label, +}: { + title: string; + series: number[]; + now: number; + label: string; +}) { + const id = useId(); + const fillId = `fill-${id}`; + const data = series.map((value, i) => ({ + x: i, + value, + at: new Date(now - (series.length - 1 - i) * 15 * 60_000), + })); + return ( +
+
+

{title}

+ Last 24h +
+ + + + + + v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}` + } + /> + { + const at = payload?.[0]?.payload?.at; + return at ? format(at, "HH:mm") : ""; + }} + valueFormatter={(v) => + typeof v === "number" + ? v.toLocaleString() + : "n/a" + } + /> + } + /> + + + + + + + + + +
+ ); +} diff --git a/frontend/src/components/cluster-onboarding/agent-connect.tsx b/frontend/src/components/cluster-onboarding/agent-connect.tsx new file mode 100644 index 0000000000..136978213c --- /dev/null +++ b/frontend/src/components/cluster-onboarding/agent-connect.tsx @@ -0,0 +1,103 @@ +import { + faCheck, + faClaude, + faCopy, + faCursor, + faOpenai, + faRectangleTerminal, + faSparkles, + Icon, + type IconProp, +} from "@rivet-gg/icons"; +import { useState } from "react"; +import { CopyTrigger } from "@/components/copy-area"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/components/lib/utils"; +import { AGENT_CLIENTS, type AgentId } from "./catalog"; +import { SETUP_PROMPT } from "./model"; + +export const AGENT_ICONS: Record = { + "claude-code": faClaude, + codex: faOpenai, + cursor: faCursor, + opencode: faRectangleTerminal, +}; + +/** + * Cloudflare-style "connect your agent" button. One click copies the one-line + * prompt; the file it points to installs skills, MCP, and CLI, then has the + * agent ask the user what to build, start locally, deploy here, and build a + * dashboard. The what-to-do-next text only appears after the prompt is copied. + */ +export function AgentConnect({ + prompt = SETUP_PROMPT, + label = "Connect your agent to Rivet", + hint = "Copied. Paste it into your agent — it sets up Rivet, asks what to build, runs it locally, then deploys here.", +}: { + /** The prompt copied on click; product pages pass a product-specific one. */ + prompt?: string; + label?: string; + hint?: string; +}) { + const [copied, setCopied] = useState(false); + + return ( +
+ setCopied(true)}> + + + + {copied ? ( +

+ {hint} +

+ ) : null} +
+ ); +} + +export function Code({ + children, + className, +}: { + children: string; + className?: string; +}) { + return ( +
+			{children}
+		
+ ); +} diff --git a/frontend/src/components/cluster-onboarding/billing-panel.tsx b/frontend/src/components/cluster-onboarding/billing-panel.tsx new file mode 100644 index 0000000000..183cabd827 --- /dev/null +++ b/frontend/src/components/cluster-onboarding/billing-panel.tsx @@ -0,0 +1,546 @@ +import { + faArrowUpRight, + faBucket, + faHdd, + faInfoCircle, + faServer, + faSignalStream, + Icon, + type IconProp, +} from "@rivet-gg/icons"; +import { endOfMonth, startOfMonth } from "date-fns"; +import { useState } from "react"; +import { PlanBadge } from "@/app/billing/billing-plan-badge"; +import { SettingsCard } from "@/app/settings-pages/settings-card"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { WithTooltip } from "@/components/ui/tooltip"; +import { cn } from "@/components/lib/utils"; +import { formatCurrency } from "@/components/lib/formatter"; +import { TwinklingSparkles } from "@/components/twinkling-sparkles"; +import { BILLING, formatVcpu, type TierId, TIERS } from "./catalog"; +import { Highlight } from "./highlight"; +import { + type ClusterSelection, + selectTier, + formatMemory, + formatUsd, + type Quote, + quote, +} from "./model"; + +interface ClusterBillingPanelProps { + selection: ClusterSelection; + onTierChange: (tier: TierId) => void; +} + +/** + * Same layout as the real `BillingPanel` (current plan + current bill, a + * choose-a-plan dialog, and usage rows), but priced per cluster: the plan + * fee covers a fixed control plane, runner compute is priced per node for + * the chosen cloud, and storage, object storage, and egress are included up + * to the plan's allowance. + */ +export function ClusterBillingPanel({ + selection, + onTierChange, +}: ClusterBillingPanelProps) { + const q = quote(selection); + const usage = mockUsage(q); + const [plansOpen, setPlansOpen] = useState(false); + + const periodStart = startOfMonth(new Date()); + const periodEnd = endOfMonth(new Date()); + const overageUsd = usage.reduce((sum, u) => sum + u.overageUsd, 0); + + return ( +
+
+ setPlansOpen(true)} + /> + +
+ + + + + Choose a plan + + The plan sets the node count, support, and the + largest node size. Node pricing depends on the cloud + and size you picked for this cluster. + + +
+ {TIERS.map((tier) => { + const current = tier.id === selection.tier; + const tierQuote = quote( + selectTier(selection, tier.id), + ); + return ( +
+
+ +
+
+ {tierQuote.planUsd > 0 ? ( + + from + + ) : null} + + {tierQuote.planUsd === 0 + ? "$0" + : formatUsd(tierQuote.planUsd)} + + + /mo + actor nodes + +
+
+

+ {tier.description} +

+
+ {tier.highlights.map((h) => ( +
+
+ +
+
+ ))} +
+
+ Control Plane{" "} + {tierQuote.controlPlane.nodes} ×{" "} + { + tierQuote.controlPlane.size + .label + } +
+
+ Fixed +
+
+
+
+ Actor Nodes{" "} + {tierQuote.runners.count} ×{" "} + {tierQuote.runners.size.label} +
+
+ {formatUsd(tierQuote.totalUsd)} + /mo total +
+
+
+ +
+ ); + })} +
+ +
+ +
+
+
+

+ Subscription +

+

+ Billed monthly for this cluster. Scale actor nodes + in Settings or change plan any time; changes are + prorated. +

+
+
+ + {q.lines.map((line) => ( +
+ + {line.label} + + + {typeof line.amount === "number" + ? formatCurrency(line.amount) + : line.amount} + +
+ ))} +
+ + Total per month + + + {formatCurrency(q.totalUsd)} + +
+
+
+ +
+
+
+

+ Usage +

+

+ Current billing period usage vs. what the cluster + includes. +

+
+

+ Updated just now +

+
+ + + + {usage.map((u, idx) => ( + + ))} + +
+
+ ); +} + +function CurrentPlanCard({ + q, + onChangePlan, +}: { + q: Quote; + onChangePlan: () => void; +}) { + return ( + +
+ Current plan +
+
+ + + {formatUsd(q.planUsd)}/mo control plane + {q.runners.count}{" "} + × {q.runners.size.label} actor nodes on {q.cloud.label} + +
+

+ {q.tier.description} +

+
+ + +
+
+ ); +} + +function CurrentBillCard({ + subscriptionUsd, + overageUsd, + periodStart, + periodEnd, +}: { + subscriptionUsd: number; + overageUsd: number; + periodStart: Date; + periodEnd: Date; +}) { + const now = Date.now(); + const daysLeft = Math.max( + 0, + Math.ceil((periodEnd.getTime() - now) / (24 * 60 * 60 * 1000)), + ); + const fmtDate = (d: Date) => + d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + + return ( + +
+ + Current bill + + + } + content="Subscription for this period plus any usage over what the cluster includes." + /> +
+
+ {formatCurrency(subscriptionUsd + overageUsd)} +
+

+ {formatCurrency(subscriptionUsd)} subscription +{" "} + {formatCurrency(overageUsd)} usage +

+
+ + {fmtDate(periodStart)} – {fmtDate(periodEnd)} + + {daysLeft} days left +
+
+ ); +} + +interface UsageLine { + title: string; + description: string; + icon: IconProp; + usedGb: number; + includedGb: number; + usdPerGb: number; + overageUsd: number; +} + +// Deterministic month-to-date usage as fractions of the cluster's allowance. +// Egress runs over so the overage path is visible in the mock. +function mockUsage(q: Quote): UsageLine[] { + const line = ( + title: string, + description: string, + icon: IconProp, + includedGb: number, + fraction: number, + usdPerGb: number, + ): UsageLine => { + const usedGb = Math.round(includedGb * fraction * 10) / 10; + const overGb = Math.max(0, usedGb - includedGb); + return { + title, + description, + icon, + usedGb, + includedGb, + usdPerGb, + overageUsd: Math.round(overGb * usdPerGb * 100) / 100, + }; + }; + return [ + line( + "Actor storage", + `SQLite state for every actor in this cluster. ${formatUsd(BILLING.extraStorageUsdPerGb)}/GB over.`, + faHdd, + q.storageGb, + 0.37, + BILLING.extraStorageUsdPerGb, + ), + line( + "Object storage", + `S3-compatible buckets. ${formatUsd(BILLING.extraObjectStorageUsdPerGb)}/GB over.`, + faBucket, + q.objectStorageGb, + 0.42, + BILLING.extraObjectStorageUsdPerGb, + ), + line( + "Egress", + `Traffic from your actors to clients. ${formatUsd(BILLING.egressUsdPerGb)}/GB over.`, + faSignalStream, + q.egressGb, + 1.28, + BILLING.egressUsdPerGb, + ), + ]; +} + +const ROW_CLASS = + "grid grid-cols-[2fr_1fr_1fr_auto] items-center gap-6 px-5 py-3.5"; + +function UsageRow({ usage, last }: { usage: UsageLine; last: boolean }) { + const pct = Math.min(100, (usage.usedGb / usage.includedGb) * 100); + const over = usage.usedGb > usage.includedGb; + return ( +
+ +
+ {usage.usedGb} GB +
+
+
+ of {usage.includedGb} GB included +
+
+
+
+
+
+
+ {formatCurrency(usage.overageUsd)} +
+
+ this period +
+
+
+ ); +} + +// The control plane is fixed by the plan: nothing to meter, so show what it +// runs in place of a usage bar. +function ControlPlaneRow({ q }: { q: Quote }) { + const { nodes, size } = q.controlPlane; + return ( +
+ +
+ {nodes} × {size.label} +
+
+ {formatVcpu(size.vcpu * nodes)} vCPU ·{" "} + {formatMemory(size.memoryMb * nodes)} total +
+
+
+ {q.planUsd === 0 ? "Free" : formatCurrency(q.planUsd)} +
+
+ fixed per month +
+
+
+ ); +} + +// Runner compute is billed per node, not per second, so it is a count × size +// rather than a usage bar. +function RunnerComputeRow({ q }: { q: Quote }) { + const { count, size } = q.runners; + return ( +
+ +
+ {count} × {size.label} +
+
+ {formatVcpu(size.vcpu * count)} vCPU ·{" "} + {formatMemory(size.memoryMb * count)} total +
+
+
+ {formatCurrency(q.runnersUsd)} +
+
+ per month +
+
+
+ ); +} + +function RowTitle({ + icon, + title, + description, +}: { + icon: IconProp; + title: string; + description: string; +}) { + return ( +
+
+ +
+
+
+ {title} +
+
+ {description} +
+
+
+ ); +} diff --git a/frontend/src/components/cluster-onboarding/catalog.ts b/frontend/src/components/cluster-onboarding/catalog.ts new file mode 100644 index 0000000000..4ac39c647e --- /dev/null +++ b/frontend/src/components/cluster-onboarding/catalog.ts @@ -0,0 +1,958 @@ +// Fixture catalog for the cluster onboarding + dashboard mock. Everything in +// here is invented for design exploration; nothing is read from the API. + +export type SizeId = + | "r-10" + | "r-20" + | "r-40" + | "r-80" + | "r-160" + | "r-320" + | "r-640" + | "r-1280"; + +export interface NodeSize { + id: SizeId; + /** Hardware description shown to the user, such as `2 vCPU / 8 GB`. */ + label: string; + /** vCPU per node. The two smallest sizes are shared-core, so this is fractional. */ + vcpu: number; + memoryMb: number; + /** + * Local NVMe in front of S3 tiered storage, GB. Writes land here as a + * write-ahead log before they are flushed to S3, and reads back from S3 + * are cached here. Only the control plane uses it, so only the plan + * table shows it. + */ + diskCacheGb: number; + /** Sustained write throughput of that disk, MB/s. Cluster throughput is this times the node count. */ + writeMbps: number; + /** Monthly price per node on AWS, USD. */ + monthlyUsd: number; +} + +function nodeSize( + id: SizeId, + vcpu: number, + memoryGb: number, + writeMbps: number, + monthlyUsd: number, +): NodeSize { + return { + id, + label: `${formatVcpu(vcpu)} vCPU / ${memoryGb} GB`, + vcpu, + memoryMb: memoryGb * 1024, + diskCacheGb: memoryGb * 10, + writeMbps, + monthlyUsd, + }; +} + +export function formatVcpu(vcpu: number): string { + if (vcpu === 0.25) return "¼"; + if (vcpu === 0.5) return "½"; + return `${vcpu}`; +} + +// Size ids count memory in 100 MB units; the user only ever sees the hardware +// label. The ladder mirrors GCP's E2 line: two shared-core sizes at the +// bottom, then whole cores at 4 GB per vCPU, with prices roughly doubling per +// step. +export const NODE_SIZES: NodeSize[] = [ + nodeSize("r-10", 0.25, 1, 30, 7), + nodeSize("r-20", 0.5, 2, 60, 13), + nodeSize("r-40", 1, 4, 125, 25), + nodeSize("r-80", 2, 8, 250, 50), + nodeSize("r-160", 4, 16, 500, 100), + nodeSize("r-320", 8, 32, 1000, 195), + nodeSize("r-640", 16, 64, 2000, 380), + nodeSize("r-1280", 32, 128, 4000, 740), +]; + +export type TierId = "pro" | "team" | "enterprise"; + +/** + * One control plane configuration a plan can be bought in. Named like + * Aiven's plans after the RAM per node: `Startup-8` is the Startup plan on + * 8 GB nodes. Business also varies the node count, so `Business-7x-64` is 7 + * nodes with 64 GB each. + */ +export interface PlanOption { + id: string; + label: string; + nodes: number; + size: SizeId; + /** Monthly price on AWS, USD. */ + monthlyUsd: number; +} + +export interface Tier { + id: TierId; + label: string; + description: string; + /** Control plane configurations, smallest first. */ + options: PlanOption[]; + /** Control plane option selected when switching to this plan, a `PlanOption` id. */ + defaultPlan: string; + /** Largest runner node size on this plan. */ + maxSize: SizeId; + /** Fewest runner nodes on this plan. 3 on multi-zone plans so runners cover every zone. */ + minRunners: number; + /** Most runner nodes on this plan, or undefined when the plan has no limit. */ + maxRunners?: number; + /** Runner config selected when switching to this plan. */ + defaultRunners: { size: SizeId; count: number }; + /** Included actor storage, GB. */ + storageGb: number; + highlights: string[]; +} + +function planOption( + tier: TierId, + label: string, + nodes: number, + size: SizeId, + monthlyUsd: number, + naming: "ram" | "nodes-ram" = "ram", +): PlanOption { + const memoryMb = NODE_SIZES.find((s) => s.id === size)?.memoryMb ?? 0; + const gb = memoryMb / 1024; + const shape = naming === "ram" ? `${gb}` : `${nodes}x-${gb}`; + return { + id: `${tier}-${shape}`, + label: `${label}-${shape}`, + nodes, + size, + monthlyUsd, + }; +} + +// Plans are grouped by availability zone coverage: one node in one zone, a 3 +// node set across 3 zones, or 5+ nodes across 3 zones. Within a plan you pick +// how big each node is. Rivet scales horizontally by adding Actor Nodes and +// keeps actor data in S3 tiered storage, so the control plane's disk is a +// cache rather than the storage limit. +export const TIERS: Tier[] = [ + { + id: "pro", + label: "Hobby", + description: "For prototypes, side projects, and staging.", + options: [ + planOption("pro", "Hobby", 1, "r-10", 10), + planOption("pro", "Hobby", 1, "r-20", 18), + planOption("pro", "Hobby", 1, "r-40", 32), + planOption("pro", "Hobby", 1, "r-80", 60), + ], + defaultPlan: "pro-1", + maxSize: "r-80", + minRunners: 1, + maxRunners: 3, + defaultRunners: { size: "r-10", count: 1 }, + storageGb: 20, + highlights: [ + "1 control plane node in 1 availability zone", + "Up to 3 Actor Nodes", + "Email support", + ], + }, + { + id: "team", + label: "Startup", + description: "For production workloads.", + options: [ + planOption("team", "Startup", 3, "r-40", 120), + planOption("team", "Startup", 3, "r-80", 220), + planOption("team", "Startup", 3, "r-160", 420), + planOption("team", "Startup", 3, "r-320", 800), + planOption("team", "Startup", 3, "r-640", 1550), + planOption("team", "Startup", 3, "r-1280", 3000), + ], + defaultPlan: "team-4", + maxSize: "r-1280", + minRunners: 3, + maxRunners: 20, + defaultRunners: { size: "r-80", count: 3 }, + storageGb: 100, + highlights: [ + "3 control plane nodes across 3 availability zones", + "Up to 20 Actor Nodes", + "Slack & email support", + ], + }, + { + id: "enterprise", + label: "Business", + description: "For business-critical production at scale.", + options: [ + planOption("enterprise", "Business", 5, "r-160", 900, "nodes-ram"), + planOption("enterprise", "Business", 5, "r-320", 1700, "nodes-ram"), + planOption("enterprise", "Business", 5, "r-640", 3300, "nodes-ram"), + planOption("enterprise", "Business", 7, "r-640", 4500, "nodes-ram"), + planOption("enterprise", "Business", 9, "r-640", 5700, "nodes-ram"), + planOption( + "enterprise", + "Business", + 9, + "r-1280", + 11000, + "nodes-ram", + ), + ], + defaultPlan: "enterprise-5x-16", + maxSize: "r-1280", + minRunners: 3, + defaultRunners: { size: "r-80", count: 3 }, + storageGb: 500, + highlights: [ + "5 to 9 control plane nodes across 3 availability zones", + "Unlimited Actor Nodes", + "Slack & email support", + ], + }, +]; + +export type CloudId = "aws" | "gcp"; + +export interface Region { + id: string; + city: string; + /** ISO 3166-1 alpha-2, used to render a flag emoji. */ + country: string; +} + +export interface Cloud { + id: CloudId; + label: string; + description: string; + /** Price multiplier against the AWS baseline. */ + multiplier: number; + regions: Region[]; +} + +export const CLOUDS: Cloud[] = [ + { + id: "aws", + label: "AWS", + description: "Graviton instances in 7 regions.", + multiplier: 1, + regions: [ + { id: "us-east-1", city: "N. Virginia", country: "US" }, + { id: "us-west-2", city: "Oregon", country: "US" }, + { id: "eu-west-1", city: "Ireland", country: "IE" }, + { + id: "eu-central-1", + city: "Frankfurt", + country: "DE", + }, + { + id: "ap-southeast-1", + city: "Singapore", + country: "SG", + }, + { id: "ap-northeast-1", city: "Tokyo", country: "JP" }, + { id: "sa-east-1", city: "São Paulo", country: "BR" }, + ], + }, + { + id: "gcp", + label: "Google Cloud", + description: "Tau T2A machine family in 6 regions.", + multiplier: 1.05, + regions: [ + { id: "us-central1", city: "Iowa", country: "US" }, + { id: "us-east4", city: "N. Virginia", country: "US" }, + { id: "europe-west1", city: "Belgium", country: "BE" }, + { + id: "europe-west3", + city: "Frankfurt", + country: "DE", + }, + { id: "asia-east1", city: "Taiwan", country: "TW" }, + { + id: "asia-southeast1", + city: "Singapore", + country: "SG", + }, + ], + }, +]; + +/** Billing constants shared by the checkout rail and the dashboard. */ +export const BILLING = { + includedEgressGb: 100, + egressUsdPerGb: 0.06, + extraStorageUsdPerGb: 0.25, + /** S3-compatible object storage included per GB of actor storage. */ + objectStorageMultiplier: 5, + extraObjectStorageUsdPerGb: 0.02, +} as const; + +export type UseCaseId = + | "chat" + | "ai-agent" + | "multiplayer-game" + | "collab-docs" + | "custom"; + +export interface RecommendedActor { + name: string; + /** Key pattern, e.g. `room:{roomId}`. */ + key: string; + description: string; +} + +export interface Improvement { + title: string; + description: string; + /** Prompt handed to the coding agent. */ + prompt: string; + /** Which part of the dashboard it touches, used for the icon. */ + area: "security" | "reliability" | "scale" | "observability" | "product"; + /** Rivet product the improvement is built on, shown as a badge. */ + product?: RivetProduct; +} + +/** Rivet products beyond plain Actors, as named on rivet.dev. */ +export type RivetProduct = + | "Workflows" + | "agentOS" + | "Secure Exec" + | "Dynamic Apps"; + +export type DashboardWidget = + | { kind: "stat"; title: string; value: string; delta?: string } + | { kind: "chart"; title: string; series: string; points: number[] } + | { + kind: "table"; + title: string; + columns: string[]; + rows: string[][]; + }; + +export interface UseCase { + id: UseCaseId; + label: string; + /** Example text shown as a suggestion chip in the "what are you building" box. */ + example: string; + /** Keywords used to classify free text. */ + keywords: string[]; + actors: RecommendedActor[]; + improvements: Improvement[]; + dashboard: { + title: string; + /** Data the generated dashboard reads, used to describe it. */ + scopes: string[]; + widgets: DashboardWidget[]; + }; +} + +const COMMON_IMPROVEMENTS: Improvement[] = [ + { + area: "security", + title: "Lock down the IP allowlist", + description: + "The cluster currently accepts connections from 0.0.0.0/0. Restrict it to your app servers and CI.", + prompt: "Replace the 0.0.0.0/0 entry in this Rivet cluster's IP allowlist with the CIDR ranges of my application servers and CI runners. Ask me for the ranges if you cannot detect them from my deployment config, then verify a connection from an allowed range still works.", + }, + { + area: "observability", + title: "Wire up alerting", + description: + "Nobody is paged when an actor crash-loops. Send cluster alerts to Slack or PagerDuty.", + prompt: "Set up alerting for this Rivet cluster: actor crash loops, actor node pool saturation above 80%, and failed deploys. Ask me whether to send alerts to Slack or PagerDuty.", + }, + { + area: "scale", + title: "Enable actor node autoscaling", + description: + "The default pool is fixed at 4 actor nodes. Scale between 2 and 16 on actor demand.", + prompt: "Configure the default actor node pool on this Rivet cluster to autoscale between 2 and 16 nodes based on active actor count, and add a second pool for background jobs so they cannot starve interactive actors.", + }, + { + area: "reliability", + product: "Workflows", + title: "Turn multi-step jobs into Workflows", + description: + "Anything that calls more than one external API should replay from the last step instead of starting over.", + prompt: "Find the multi-step operations in this app (anything that chains external API calls, retries, or waits) and rewrite each as a Rivet Workflow so a crash resumes from the last completed step. Keep the existing actor actions as the entry points, run it locally with `rivet dev`, then deploy to this cluster.", + }, +]; + +export const USE_CASES: UseCase[] = [ + { + id: "chat", + label: "Realtime chat", + example: "A Slack-style chat app with channels, DMs, and presence", + keywords: [ + "chat", + "message", + "channel", + "dm", + "presence", + "slack", + "discord", + ], + actors: [ + { + name: "ChatRoom", + key: "room:{roomId}", + description: + "One actor per channel. Holds the message log, broadcasts new messages to connected clients.", + }, + { + name: "UserPresence", + key: "user:{userId}", + description: + "Tracks online/typing state per user and fans it out to the rooms they are in.", + }, + { + name: "Inbox", + key: "inbox:{userId}", + description: + "Per-user unread counts and mention notifications, updated by ChatRoom events.", + }, + { + name: "RateLimiter", + key: "ratelimit:{userId}", + description: + "Sliding-window limiter in front of message sends so one client cannot flood a room.", + }, + ], + improvements: [ + { + area: "product", + title: "Add message search", + description: + "Index ChatRoom history into a per-room SQLite FTS table so search stays local to the actor.", + prompt: "Add full-text search to the ChatRoom actor using the actor's SQLite database with an FTS5 table, expose a `search(query)` action, and add a search box to the generated dashboard that calls it.", + }, + { + area: "product", + product: "Workflows", + title: "Run moderation as a Workflow", + description: + "Flag, classify with a model, notify a moderator, and act. Each step is durable, so a restart never drops a report.", + prompt: "Add a Rivet Workflow for message moderation on this cluster: ChatRoom reports a flagged message, the workflow classifies it with an LLM, waits for a moderator decision with a 24h timeout, then deletes the message or clears the flag and notifies the reporter. Show pending reports in the Chat operations dashboard.", + }, + { + area: "security", + product: "Secure Exec", + title: "Sandbox custom bots and slash commands", + description: + "Let workspaces ship their own bots without giving their code access to other rooms or the admin token.", + prompt: "Add a Bot actor that runs workspace-authored JavaScript with Rivet Secure Exec, exposing only a `reply(roomId, text)` capability and a 50ms CPU budget per invocation. Wire ChatRoom to invoke bots on slash commands, and add a bots table to the dashboard.", + }, + ...COMMON_IMPROVEMENTS, + ], + dashboard: { + title: "Chat operations", + scopes: [ + "actors:read", + "actors:ChatRoom:actions:stats", + "metrics:read", + ], + widgets: [ + { + kind: "stat", + title: "Active rooms", + value: "1,284", + delta: "+6% today", + }, + { + kind: "stat", + title: "Connected users", + value: "9,412", + delta: "+1,102 vs. 1h ago", + }, + { kind: "stat", title: "Messages / min", value: "4,870" }, + { kind: "stat", title: "p95 send latency", value: "38 ms" }, + { + kind: "chart", + title: "Messages per minute", + series: "messages", + points: [ + 3200, 3500, 3900, 4400, 4100, 4800, 5200, 4900, 4700, + 4870, + ], + }, + { + kind: "table", + title: "Busiest rooms", + columns: ["Room", "Users", "Msg/min", "Node"], + rows: [ + ["room:general", "2,104", "1,240", "default-2"], + ["room:random", "1,588", "812", "default-1"], + ["room:incidents", "412", "640", "default-3"], + ["room:design", "230", "95", "default-2"], + ], + }, + ], + }, + }, + { + id: "ai-agent", + label: "AI agent", + example: + "A coding agent that runs long tasks with tools and streams results", + keywords: [ + "agent", + "llm", + "ai", + "tool", + "assistant", + "openai", + "anthropic", + "claude", + "gpt", + ], + actors: [ + { + name: "AgentSession", + key: "session:{sessionId}", + description: + "One actor per conversation. Owns the message history, streams tokens, and survives client disconnects.", + }, + { + name: "ToolRunner", + key: "tool:{sessionId}:{callId}", + description: + "Executes a single tool call with retries and a timeout, then reports back to the session.", + }, + { + name: "AgentWorkflow", + key: "workflow:{taskId}", + description: + "Durable multi-step task runner so a long job resumes after a crash instead of restarting.", + }, + { + name: "UsageMeter", + key: "usage:{orgId}", + description: + "Token and cost accounting per customer, used for billing and spend limits.", + }, + ], + improvements: [ + { + area: "product", + product: "agentOS", + title: "Give each agent its own sandbox", + description: + "Mount an agentOS sandbox into AgentSession so tool calls run in an isolated VM with a filesystem, shell, and network.", + prompt: "Add agentOS sandbox mounting to the AgentSession actor so each session gets an isolated VM for shell and file tools. Start locally with `rivet dev`, verify a tool call executes inside the sandbox, then deploy to the cluster.", + }, + { + area: "reliability", + product: "Workflows", + title: "Make long tool chains a Workflow", + description: + "Research and multi-hour tasks should resume from the last tool result after a node restart, not replay the whole conversation.", + prompt: "Refactor the AgentSession run loop so each agent task is a Rivet Workflow: every model call and tool call is a durable step, long waits (human approval, scheduled follow-ups) use workflow sleeps, and a restarted node resumes from the last completed step. Show step progress per session in the Agent fleet dashboard.", + }, + { + area: "security", + product: "Secure Exec", + title: "Run model-written code in Secure Exec", + description: + "Small snippets the model writes (parsers, calculators, data transforms) do not need a full VM. Run them in-process with no I/O.", + prompt: "Add a `runCode` tool to the AgentSession actor that executes model-generated JavaScript with Rivet Secure Exec: no filesystem or network, a 200ms CPU limit, and only the tool's input available in scope. Fall back to the agentOS sandbox when the code needs shell or network access.", + }, + ...COMMON_IMPROVEMENTS, + ], + dashboard: { + title: "Agent fleet", + scopes: [ + "actors:read", + "actors:AgentSession:actions:stats", + "metrics:read", + ], + widgets: [ + { + kind: "stat", + title: "Running sessions", + value: "312", + delta: "+24 last hour", + }, + { kind: "stat", title: "Tool calls / min", value: "1,906" }, + { + kind: "stat", + title: "Tokens today", + value: "48.2M", + delta: "$96.40", + }, + { kind: "stat", title: "Failed tool calls", value: "0.4%" }, + { + kind: "chart", + title: "Tokens per minute", + series: "tokens", + points: [ + 21000, 24000, 30000, 28000, 35000, 41000, 39000, 44000, + 42000, 46000, + ], + }, + { + kind: "table", + title: "Longest running sessions", + columns: ["Session", "Customer", "Duration", "Tool calls"], + rows: [ + ["session:8f3a…", "acme", "2h 14m", "1,204"], + ["session:11c0…", "globex", "1h 52m", "880"], + ["session:9d77…", "initech", "58m", "412"], + ["session:02be…", "acme", "41m", "301"], + ], + }, + ], + }, + }, + { + id: "multiplayer-game", + label: "Multiplayer game", + example: + "A browser .io game with lobbies, matchmaking, and leaderboards", + keywords: [ + "game", + "lobby", + "match", + "player", + "leaderboard", + "multiplayer", + "server", + ], + actors: [ + { + name: "GameRoom", + key: "match:{matchId}", + description: + "Authoritative game loop at a fixed tick rate. Receives inputs, broadcasts state deltas.", + }, + { + name: "Matchmaker", + key: "matchmaker:{region}", + description: + "Queues players by skill and region and spawns a GameRoom when a match is formed.", + }, + { + name: "Leaderboard", + key: "leaderboard:{season}", + description: + "Sorted set of scores per season, updated when a GameRoom ends.", + }, + { + name: "PlayerProfile", + key: "player:{playerId}", + description: + "Durable inventory, rating, and session token for one player.", + }, + ], + improvements: [ + { + area: "scale", + title: "Pin game rooms to the nearest region", + description: + "Spawn GameRoom actors in the region closest to the majority of players in the match.", + prompt: "Update the Matchmaker actor to spawn GameRoom actors in the actor node pool region closest to the players in the match. Add eu-west-1 and ap-northeast-1 actor node pools to this Rivet cluster and show the latency improvement in the dashboard.", + }, + { + area: "product", + product: "Workflows", + title: "Run tournaments as a Workflow", + description: + "Brackets, round timers, and prize payouts span hours and several services. A Workflow survives restarts mid-round.", + prompt: "Add a Tournament Rivet Workflow to this app: it creates GameRoom actors per round, waits for results with a per-round deadline, advances the bracket, and pays out through the payments API as a durable step. Show live brackets in the Live ops dashboard.", + }, + { + area: "security", + product: "Secure Exec", + title: "Sandbox player-made mods", + description: + "Let players script custom game modes without letting their code touch other rooms or the host.", + prompt: "Add mod support to the GameRoom actor using Rivet Secure Exec: load player-authored JavaScript rules with a fixed capability set (read state, emit events), a per-tick CPU budget, and no I/O. Add a mods table with per-mod CPU usage to the dashboard.", + }, + ...COMMON_IMPROVEMENTS, + ], + dashboard: { + title: "Live ops", + scopes: [ + "actors:read", + "actors:GameRoom:actions:stats", + "actors:Leaderboard:actions:top", + "metrics:read", + ], + widgets: [ + { + kind: "stat", + title: "Players online", + value: "18,420", + delta: "peak 22,100", + }, + { kind: "stat", title: "Active matches", value: "1,906" }, + { kind: "stat", title: "Avg. queue time", value: "6.2 s" }, + { kind: "stat", title: "Tick p99", value: "11 ms" }, + { + kind: "chart", + title: "Players online", + series: "players", + points: [ + 9000, 10400, 12800, 15200, 17100, 18900, 21500, 22100, + 19800, 18420, + ], + }, + { + kind: "table", + title: "Regions", + columns: ["Region", "Matches", "Players", "p99 tick"], + rows: [ + ["us-east-1", "1,012", "9,840", "9 ms"], + ["eu-west-1", "604", "6,120", "12 ms"], + ["ap-northeast-1", "290", "2,460", "14 ms"], + ], + }, + ], + }, + }, + { + id: "collab-docs", + label: "Collaborative docs", + example: "A Notion-like editor with live cursors and comments", + keywords: [ + "doc", + "document", + "editor", + "collab", + "crdt", + "yjs", + "cursor", + "notion", + "whiteboard", + ], + actors: [ + { + name: "Document", + key: "doc:{docId}", + description: + "Holds the CRDT state for one document, merges client updates, and persists snapshots.", + }, + { + name: "Awareness", + key: "doc:{docId}:awareness", + description: + "Ephemeral cursors and selections, broadcast at high frequency without touching storage.", + }, + { + name: "CommentThread", + key: "thread:{threadId}", + description: + "Comments anchored to a document range, with resolve/reopen history.", + }, + { + name: "Workspace", + key: "workspace:{workspaceId}", + description: + "Membership, permissions, and the document tree for one team.", + }, + ], + improvements: [ + { + area: "reliability", + title: "Snapshot documents hourly", + description: + "Compact the CRDT update log into a snapshot so cold loads stay fast as documents age.", + prompt: "Add an hourly schedule to the Document actor that compacts the CRDT update log into a snapshot stored in the actor's SQLite database, and expose a `history()` action so the dashboard can show version counts.", + }, + { + area: "product", + product: "Workflows", + title: "Publish and export as a Workflow", + description: + "Render to PDF, upload to object storage, notify collaborators. Durable steps mean a failed upload retries instead of re-rendering.", + prompt: "Add a Publish Rivet Workflow for the Document actor: snapshot the document, render it to PDF, upload the file to this cluster's object storage bucket, and email collaborators the link. Each step must be durable and retried independently. Show export history in the Editor health dashboard.", + }, + { + area: "security", + product: "Secure Exec", + title: "Run user formulas in Secure Exec", + description: + "Spreadsheet-style formulas and plugins written by users run in-process, with no access to other documents.", + prompt: "Add a formula engine to the Document actor using Rivet Secure Exec: evaluate user-authored expressions with only the current document's cells in scope, a 20ms CPU limit per evaluation, and no I/O. Surface slow or failing formulas per document in the dashboard.", + }, + { + area: "product", + product: "Dynamic Apps", + title: "Give every workspace its own backend", + description: + "Workspaces that want custom integrations get a generated backend deployed the moment it is created.", + prompt: "Add a Dynamic App per workspace to this cluster so a workspace admin can describe a custom integration (webhooks, sync to their CRM) and get a generated backend deployed next to their Document actors. Scope each app's token to that workspace's documents only.", + }, + ...COMMON_IMPROVEMENTS, + ], + dashboard: { + title: "Editor health", + scopes: [ + "actors:read", + "actors:Document:actions:stats", + "metrics:read", + ], + widgets: [ + { + kind: "stat", + title: "Open documents", + value: "3,208", + delta: "+310 today", + }, + { kind: "stat", title: "Editing now", value: "1,142" }, + { kind: "stat", title: "Updates / sec", value: "2,640" }, + { kind: "stat", title: "Snapshot lag", value: "12 min" }, + { + kind: "chart", + title: "CRDT updates per second", + series: "updates", + points: [ + 1800, 1950, 2100, 2400, 2300, 2650, 2900, 2800, 2700, + 2640, + ], + }, + { + kind: "table", + title: "Largest documents", + columns: ["Document", "Editors", "Size", "Snapshots"], + rows: [ + ["doc:roadmap-2026", "48", "14.2 MB", "212"], + ["doc:eng-handbook", "31", "9.8 MB", "188"], + ["doc:design-system", "22", "6.1 MB", "97"], + ], + }, + ], + }, + }, + { + id: "custom", + label: "Something else", + example: "Describe what you are building", + keywords: [], + actors: [ + { + name: "Session", + key: "session:{id}", + description: + "Per-user or per-connection state that outlives a single request.", + }, + { + name: "Workflow", + key: "workflow:{id}", + description: + "A durable multi-step operation that resumes after restarts.", + }, + { + name: "RateLimiter", + key: "ratelimit:{key}", + description: + "Sliding-window limiter you can put in front of any action.", + }, + ], + improvements: COMMON_IMPROVEMENTS, + dashboard: { + title: "Cluster overview", + scopes: ["actors:read", "metrics:read"], + widgets: [ + { kind: "stat", title: "Active actors", value: "2,410" }, + { kind: "stat", title: "Requests / min", value: "38,200" }, + { kind: "stat", title: "p95 latency", value: "42 ms" }, + { kind: "stat", title: "Errors", value: "0.1%" }, + { + kind: "chart", + title: "Requests per minute", + series: "requests", + points: [ + 21000, 24000, 27000, 31000, 29000, 33000, 36000, 35000, + 37000, 38200, + ], + }, + { + kind: "table", + title: "Actors by name", + columns: ["Actor", "Instances", "Req/min", "Pool"], + rows: [ + ["Session", "1,840", "29,100", "default"], + ["Workflow", "412", "6,400", "default"], + ["RateLimiter", "158", "2,700", "default"], + ], + }, + ], + }, + }, +]; + +/** Coding agents shown in the "connect your agent" bar. */ +export type AgentId = "claude-code" | "codex" | "cursor" | "opencode"; + +export interface AgentClient { + id: AgentId; + label: string; + /** Shell command that installs the Rivet MCP server for this client. */ + mcpInstall: (mcpUrl: string) => string; +} + +export const AGENT_CLIENTS: AgentClient[] = [ + { + id: "claude-code", + label: "Claude Code", + mcpInstall: (url) => `claude mcp add --transport http rivet "${url}"`, + }, + { + id: "codex", + label: "Codex", + mcpInstall: (url) => `codex mcp add rivet --url ${url}`, + }, + { + id: "cursor", + label: "Cursor", + mcpInstall: (url) => + `# .cursor/mcp.json\n${JSON.stringify({ mcpServers: { rivet: { url } } }, null, 2)}`, + }, + { + id: "opencode", + label: "OpenCode", + mcpInstall: (url) => + `# ~/.config/opencode/opencode.jsonc, under "mcp"\n"rivet": { "type": "remote", "url": "${url}", "enabled": true, "oauth": {} }`, + }, +]; + +/** Fixture rows for the builds table: every build pushed to the cluster. */ +export interface Build { + id: string; + version: string; + commit: string; + message: string; + createdAgo: string; +} + +export const BUILDS: Build[] = [ + { + id: "b_12", + version: "v12", + commit: "a81f2c3", + message: "feat: add RateLimiter in front of ChatRoom.send", + createdAgo: "14m ago", + }, + { + id: "b_11", + version: "v11", + commit: "7c0d9e1", + message: "fix: persist presence on reconnect", + createdAgo: "3h ago", + }, + { + id: "b_10", + version: "v10", + commit: "f31ab77", + message: "chore: bump rivetkit", + createdAgo: "1d ago", + }, + { + id: "b_09", + version: "v9", + commit: "2bd41e0", + message: "feat: inbox unread counts", + createdAgo: "1d ago", + }, +]; diff --git a/frontend/src/components/cluster-onboarding/cluster-dashboard-page.tsx b/frontend/src/components/cluster-onboarding/cluster-dashboard-page.tsx new file mode 100644 index 0000000000..841bcd372a --- /dev/null +++ b/frontend/src/components/cluster-onboarding/cluster-dashboard-page.tsx @@ -0,0 +1,749 @@ +import { + faBucket, + faBug, + faCopy, + faGlobe, + faKey, + faLock, + faServer, + faSpinnerThird, + Icon, + type IconProp, +} from "@rivet-gg/icons"; +import { useQuery } from "@tanstack/react-query"; +import { type ReactNode, useState } from "react"; +import { CopyTrigger, DiscreteInput } from "@/components/copy-area"; +import { PlanBadge } from "@/app/billing/billing-plan-badge"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn } from "@/components/lib/utils"; +import { ClusterBillingPanel } from "./billing-panel"; +import { AgentConnect } from "./agent-connect"; +import { type Build, BUILDS, type TierId } from "./catalog"; +import { CLOUD_ICONS } from "./create-cluster-page"; +import { + actorViewLabel, + ClusterSidebar, + type ClusterView, + isActorView, + userActorIcon, +} from "./cluster-sidebar"; +import { Endpoint, EndpointTitle } from "./connection-fields"; +import { DeploymentMap } from "./deployment-map"; +import { GetStartedCards } from "./get-started"; +import { IpAllowlist } from "./ip-allowlist"; +import { LogsPanel } from "./logs-panel"; +import { ActorMetricsDashboard, mockActorMetrics } from "./actor-metrics"; +import { MockTopBar } from "./mock-top-bar"; +import type { NamespaceId, NamespaceVariant } from "./namespace-picker"; +import { PRODUCT_PAGES } from "./product-content"; +import { ProductPage } from "./product-page"; +import { + type ClusterEndpoints, + type ClusterSelection, + clusterEndpoints, + clusterState, + detectUseCase, + ENGINE_VERSION, + nextEngineVersion, + getCloud, + getTier, + getUseCase, + quote, + type Rollout, + type RunnerConfig, +} from "./model"; +import { RunnersConfig } from "./runners-config"; + +interface ClusterDashboardPageProps { + selection: ClusterSelection; + /** When the cluster was created; drives the mock provisioning timeline. */ + createdAt: number; + /** When each extra runner was added, in ms since `createdAt`. */ + runnerAddedMs: number[]; + onAllowlistChange: (allowlist: string[]) => void; + onTierChange: (tier: TierId) => void; + onRunnersChange: (runners: RunnerConfig) => void; + /** + * What the agent deployed, as the user described it to the agent. Drives + * the mock actors shown in Logs. Omit for a cluster nothing has been + * deployed to yet. + */ + deployedDescription?: string; + /** Sidebar namespace-switcher exploration; see `NamespacePicker`. */ + namespaceVariant?: NamespaceVariant; +} + +/** Hidden for now; flip to show the control plane upgrade simulator. */ +const SHOW_DEBUG_CONTROLS = false; + +/** What runners report as their version before anything is deployed. */ +const NO_BUILD = "No build"; + +export function ClusterDashboardPage({ + selection, + createdAt, + runnerAddedMs, + onAllowlistChange, + onTierChange, + onRunnersChange, + deployedDescription, + namespaceVariant, +}: ClusterDashboardPageProps) { + const [namespace, setNamespace] = useState("production"); + const detected = deployedDescription + ? detectUseCase(deployedDescription) + : null; + const deployed = detected !== null && detected.id !== "custom"; + const [view, setView] = useState("overview"); + // Debug toggle per product page: quickstart vs. the post-setup metrics view. + const [onboarded, setOnboarded] = useState>({}); + // A cluster nothing has been pushed to yet has no builds: the overview + // shows what to get started with instead. Toggled by a debug button. + const [hasBuilds, setHasBuilds] = useState(deployed); + const builds = hasBuilds ? BUILDS : []; + const [deployedBuildId, setDeployedBuildId] = useState(BUILDS[0].id); + // Rolling updates shown on the deployment map: deploying a build rolls + // the runners; changing plan rolls the control plane. + const [rollouts, setRollouts] = useState([]); + const startRollout = (kind: Rollout["kind"], version: string) => + setRollouts((prev) => [ + ...prev, + { kind, version, startedMs: Date.now() - createdAt }, + ]); + const deploy = (id: string) => { + const build = BUILDS.find((b) => b.id === id); + if (!build) return; + setDeployedBuildId(id); + startRollout("runners", build.version); + }; + // Debug control: Rivet upgrades the engine on the control plane out of + // band, so the mock has a button to simulate one rolling through. + const [engineVersion, setEngineVersion] = useState(ENGINE_VERSION); + const changeTier = (next: TierId) => { + onTierChange(next); + startRollout("control-plane", engineVersion); + }; + const upgradeControlPlane = () => { + const next = nextEngineVersion(engineVersion); + setEngineVersion(next); + startRollout("control-plane", next); + }; + // Nothing deployed yet: show the chat pattern as a preview. + const useCase = deployed ? detected : getUseCase("chat"); + const endpoints = clusterEndpoints(selection); + const q = quote(selection); + const cloud = getCloud(selection.cloud); + const tier = getTier(selection.tier); + const region = selection.region.replace(/^(aws|gcp):/, ""); + + // Stands in for polling the cluster's node list: nodes come up on a fixed + // timeline after creation and polling stops once every node is ready. + const initialBuild = hasBuilds ? BUILDS[0].version : NO_BUILD; + const nodesQuery = useQuery({ + queryKey: [ + "mock-cluster-nodes", + selection.name, + selection.tier, + selection.runners, + createdAt, + runnerAddedMs, + rollouts, + initialBuild, + ], + queryFn: () => + clusterState( + selection, + Date.now() - createdAt, + runnerAddedMs, + rollouts, + initialBuild, + ), + initialData: () => + clusterState( + selection, + Date.now() - createdAt, + runnerAddedMs, + rollouts, + initialBuild, + ), + refetchInterval: (query) => + query.state.data?.status === "active" ? false : 250, + }); + const { nodes, status } = nodesQuery.data; + + const clusterHeader = ( +
+
+
+

+ {selection.name} +

+ {status === "active" ? ( + + + Running + + ) : status === "updating" ? ( + + + Updating + + ) : ( + + + Provisioning + + )} +
+
+ + + + {cloud.label} · {region} + +
+
+
+ ); + + // Same shell as the real `RouteLayout`: top bar, then a sidebar on the + // left and `main` with the page content directly on the background. + return ( +
+ +
+ +
+ {view === "logs" ? ( + + ) : null} + + {view === "overview" ? ( + + {clusterHeader} + + {hasBuilds ? null : ( +
+ + setView(product.id as ClusterView) + } + /> +
+ )} +
+ +
+
} + disabled={status !== "active"} + onClick={upgradeControlPlane} + > + Debug: upgrade control plane to{" "} + {nextEngineVersion(engineVersion)} + + ) : undefined + } + > + +
+
+ +
+
+ +
+
+ ) : null} + + {view === "settings" ? ( + +
+ + +
+ + + + + Control Plane + + + Preconfigured by the{" "} + {tier.label} plan. + Upgrade in Billing to + change. + + + + + {q.plan.label} ·{" "} + {q.controlPlane.nodes} ×{" "} + {q.controlPlane.size.label} + +
+
+ + Actor Nodes + + +
+
+
+
+
+ + + + + +
+
+ ) : null} + + {view === "billing" ? ( + +
+ +
+
+ ) : null} + + {isActorView(view) ? ( + + {PRODUCT_PAGES[view] ? ( + + setOnboarded((prev) => ({ + ...prev, + [view]: value, + })) + } + /> + ) : ( +
+ +
+ )} +
+ ) : null} +
+
+
+ ); +} + +/** The outlined, scrollable card the page content sits in, matching the real dashboard. */ +function PageCard({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ +
+ {children} +
+
+
+ ); +} + +/** + * Aiven-style connection information: endpoints, the admin token, and object + * storage credentials inline at the top of the overview, not behind a button. + */ +function ConnectionInfo({ + endpoints, + includedObjectStorageGb, +}: { + endpoints: ClusterEndpoints; + includedObjectStorageGb: number; +}) { + return ( + + +
+ + +
+ + +
+
+ +
+
+ ); +} + +function ObjectStorageEndpoint({ + storage, + includedGb, +}: { + storage: ClusterEndpoints["objectStorage"]; + includedGb: number; +}) { + return ( +
+
+ + + Object storage + + + S3-compatible. {includedGb} GB included, for blobs and + uploads that do not belong in actor state. Works with any S3 + SDK. + +
+ + + +
+ + + + + + {" "} + · region {storage.region} + + + + {storage.accessKeyId} + + + + +
+
+ ); +} + +function KeyValue({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ {label} +
{children}
+
+ ); +} + +function BuildsTable({ + builds, + deployedId, + onDeploy, + disabled, +}: { + builds: Build[]; + deployedId: string; + onDeploy: (id: string) => void; + /** One rollout at a time: deploys wait until the cluster is running. */ + disabled?: boolean; +}) { + // Deploying replaces the live build, so it asks for confirmation first. + const [pending, setPending] = useState(null); + const current = builds.find((b) => b.id === deployedId); + return ( + + { + if (!open) setPending(null); + }} + > + + + Deploy {pending?.version}? + + {current + ? `Rolls out to actor nodes one at a time, replacing ${current.version}. Running actors are drained onto the new build; clients reconnect automatically.` + : "Rolls out to actor nodes one at a time. Running actors are drained onto the new build; clients reconnect automatically."} + + + {pending ? ( +
+
{pending.message}
+
+ {pending.commit} · {pending.createdAgo} +
+
+ ) : null} + + + + +
+
+ + + + Version + Build + Created + Status + + + + {builds.length === 0 ? ( + + + No builds yet. Your first{" "} + + rivet deploy + {" "} + shows up here. + + + ) : null} + {builds.map((b) => { + const deployed = b.id === deployedId; + return ( + + + {b.version} + + +
+ {b.message} +
+
+ {b.commit} +
+
+ + {b.createdAgo} + + + {deployed ? ( + + + Deployed + + ) : ( + + )} + +
+ ); + })} +
+
+
+ ); +} + +function Section({ + title, + icon, + description, + aside, + children, +}: { + title: string; + /** Icon rendered before the heading, e.g. the actor mark on actor pages. */ + icon?: IconProp; + description?: string; + /** Controls rendered to the right of the heading. */ + aside?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+
+

+ {icon ? ( + + + + ) : null} + {title} +

+ {description ? ( +

+ {description} +

+ ) : null} +
+ {aside !== undefined ? ( +
+ {aside} +
+ ) : null} +
+ {children} +
+ ); +} diff --git a/frontend/src/components/cluster-onboarding/cluster-map-lab.tsx b/frontend/src/components/cluster-onboarding/cluster-map-lab.tsx new file mode 100644 index 0000000000..5b6ab0c83c --- /dev/null +++ b/frontend/src/components/cluster-onboarding/cluster-map-lab.tsx @@ -0,0 +1,185 @@ +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { DeploymentMap } from "./deployment-map"; +import { + clusterEndpoints, + clusterState, + DEMO_CLUSTER_SELECTION, + nextEngineVersion, + type Rollout, + rolloutEndMs, +} from "./model"; + +const ENGINE_VERSION = "3.0.0-alpha.2"; +const INITIAL_BUILD = "v12"; +/** Far enough in that every node is ready when the lab opens. */ +const SETTLED_MS = 60_000; +const TIME_SCALES = [1, 0.5, 0.25, 0.1, 0.05] as const; + +/** + * Standalone harness for the cluster map's motion. The cluster clock and + * every animation run at the chosen time scale, so a rollout can be stepped + * through with screenshots and inspected frame by frame. + */ +export function ClusterMapLab() { + const [timeScale, setTimeScale] = useState(1); + const [paused, setPaused] = useState(false); + const [runnerCount, setRunnerCount] = useState( + DEMO_CLUSTER_SELECTION.runners.count, + ); + const [elapsedMs, setElapsedMs] = useState(SETTLED_MS); + const [rollouts, setRollouts] = useState([]); + const [buildN, setBuildN] = useState(12); + const [engineVersion, setEngineVersion] = useState(ENGINE_VERSION); + + // Scaled wall clock: the model advances `timeScale` ms per real ms. + const clock = useRef({ timeScale, paused }); + clock.current = { timeScale, paused }; + useEffect(() => { + let last = performance.now(); + let frame = 0; + const tick = (now: number) => { + const dt = now - last; + last = now; + if (!clock.current.paused) { + setElapsedMs((ms) => ms + dt * clock.current.timeScale); + } + frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + }, []); + + const selection = { + ...DEMO_CLUSTER_SELECTION, + runners: { ...DEMO_CLUSTER_SELECTION.runners, count: runnerCount }, + }; + const { nodes, status } = clusterState( + selection, + elapsedMs, + [], + rollouts, + INITIAL_BUILD, + ); + const rolling = rollouts.some( + (r) => + r.startedMs <= elapsedMs && elapsedMs < rolloutEndMs(selection, r), + ); + const startRollout = (kind: Rollout["kind"], version: string) => + setRollouts((prev) => [ + ...prev, + { kind, version, startedMs: elapsedMs }, + ]); + const rollRunners = () => { + const next = buildN + 1; + setBuildN(next); + startRollout("runners", `v${next}`); + }; + const rollControlPlane = () => { + const next = nextEngineVersion(engineVersion); + setEngineVersion(next); + startRollout("control-plane", next); + }; + const reset = () => { + setRollouts([]); + setElapsedMs(SETTLED_MS); + setBuildN(12); + setEngineVersion(ENGINE_VERSION); + }; + + return ( +
+
+ + + + + + + +
+
+ t={Math.round(elapsedMs)}ms · {status} +
+ +
+ ); +} diff --git a/frontend/src/components/cluster-onboarding/cluster-onboarding-flow.tsx b/frontend/src/components/cluster-onboarding/cluster-onboarding-flow.tsx new file mode 100644 index 0000000000..dd83b8d8b5 --- /dev/null +++ b/frontend/src/components/cluster-onboarding/cluster-onboarding-flow.tsx @@ -0,0 +1,135 @@ +import { useState } from "react"; +import { ClusterDashboardPage } from "./cluster-dashboard-page"; +import type { NamespaceVariant } from "./namespace-picker"; +import { CreateClusterPage } from "./create-cluster-page"; +import { + type CreateVariant, + CreateClusterVariant, +} from "./create-cluster-variants"; +import { + selectTier, + type ClusterSelection, + DEMO_CLUSTER_SELECTION, + type RunnerConfig, +} from "./model"; + +// Design mock of the new cluster onboarding: an Aiven-style single-page +// create flow with a checkout rail, then the cluster dashboard with the agent +// bar, endpoints + admin token, deployment map, builds, logs, billing and the +// IP allowlist. Nothing here talks to a backend. +// +// Rendered both by the Ladle stories and by the dev-only `/mock/...` route so +// the mock can be reviewed inside the real dashboard shell. + +export const CHAT_DESCRIPTION = + "A team chat app with channels, DMs, typing indicators and presence. Messages need to persist and be searchable."; + +// A cluster created this long ago has every node ready, so stories and the +// `?view=dashboard` deep link skip the provisioning animation. +const LONG_AGO_MS = 60 * 60 * 1000; + +export function DashboardHarness({ + initial, + deployedDescription, + createdAt = Date.now() - LONG_AGO_MS, + namespaceVariant, +}: { + initial: ClusterSelection; + deployedDescription?: string; + createdAt?: number; + namespaceVariant?: NamespaceVariant; +}) { + const [selection, setSelection] = useState(initial); + // When runner `i` was added relative to `createdAt` (0 = at creation), so + // the deployment map shows new ones provisioning while the rest stay ready. + const [runnerAddedMs, setRunnerAddedMs] = useState(() => + Array.from({ length: initial.runners.count }, () => 0), + ); + + const setRunners = (runners: RunnerConfig) => { + setSelection((s) => { + const added = runners.count - s.runners.count; + if (added > 0) { + const now = Date.now() - createdAt; + setRunnerAddedMs((prev) => [ + ...prev, + ...Array.from({ length: added }, () => now), + ]); + } else if (added < 0) { + setRunnerAddedMs((prev) => prev.slice(0, prev.length + added)); + } + return { ...s, runners }; + }); + }; + + return ( + + setSelection((s) => ({ ...s, allowlist })) + } + onTierChange={(tier) => setSelection((s) => selectTier(s, tier))} + onRunnersChange={setRunners} + deployedDescription={deployedDescription} + namespaceVariant={namespaceVariant} + /> + ); +} + +/** + * Full flow: create -> dashboard. The cluster lands on its dashboard + * immediately and the header and deployment map show nodes provisioning. + * + * `initialView="dashboard"` skips straight to a populated, fully provisioned + * dashboard, handy for linking reviewers at a later part of the mock. + */ +export function ClusterOnboardingFlow({ + initialView = "create", + namespaceVariant, + createVariant = "dialog", +}: { + initialView?: "create" | "dashboard"; + /** Sidebar namespace-switcher exploration; see `NamespacePicker`. */ + namespaceVariant?: NamespaceVariant; + /** + * Create-page variant. The dialog over the cluster list is the chosen + * direction; `full` renders the original long-form page. + */ + createVariant?: CreateVariant | "full"; +}) { + const [created, setCreated] = useState<{ + selection: ClusterSelection; + createdAt: number; + } | null>( + initialView === "dashboard" + ? { + selection: { + ...DEMO_CLUSTER_SELECTION, + allowlist: ["203.0.113.42/32", "198.51.100.0/24"], + }, + createdAt: Date.now() - LONG_AGO_MS, + } + : null, + ); + + if (created) { + return ( + + ); + } + const onCreate = (selection: ClusterSelection) => + setCreated({ selection, createdAt: Date.now() }); + if (createVariant === "full") { + return ; + } + return ; +} diff --git a/frontend/src/components/cluster-onboarding/cluster-onboarding.stories.tsx b/frontend/src/components/cluster-onboarding/cluster-onboarding.stories.tsx new file mode 100644 index 0000000000..18ad48007f --- /dev/null +++ b/frontend/src/components/cluster-onboarding/cluster-onboarding.stories.tsx @@ -0,0 +1,99 @@ +import type { Story } from "@ladle/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import "../../../.ladle/ladle.css"; +import { Toaster } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { + CHAT_DESCRIPTION, + ClusterOnboardingFlow, + DashboardHarness, +} from "./cluster-onboarding-flow"; +import { CreateClusterPage } from "./create-cluster-page"; +import { DEMO_CLUSTER_SELECTION } from "./model"; + +// Design mock of the new cluster onboarding (see cluster-onboarding-flow.tsx). +// Also mounted in the dashboard at /mock/cluster-onboarding in dev. + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, +}); + +function Frame({ children }: { children: ReactNode }) { + return ( + + +
+ {children} +
+ +
+
+ ); +} + +export const CreateCluster: Story = () => ( + + console.log("create", s)} /> + +); + +export const CreateClusterBusiness: Story = () => ( + + console.log("create", s)} + /> + +); + +export const Dashboard: Story = () => ( + + + +); + +export const DashboardFreshCluster: Story = () => ( + + + +); + +export const DashboardGcp: Story = () => ( + + + +); + +export const FullFlow: Story = () => ( + + + +); diff --git a/frontend/src/components/cluster-onboarding/cluster-sidebar.tsx b/frontend/src/components/cluster-onboarding/cluster-sidebar.tsx new file mode 100644 index 0000000000..60b379dff3 --- /dev/null +++ b/frontend/src/components/cluster-onboarding/cluster-sidebar.tsx @@ -0,0 +1,237 @@ +import { + faActors, + faCreditCard, + faDiagramProject, + faGear, + faHouse, + faLogs, + faPlus, + faRobot, + Icon, + type IconProp, +} from "@rivet-gg/icons"; +import type { ReactNode } from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/components/lib/utils"; +import { ProductMark } from "@/components/products/product-picker"; +import { + type NamespaceId, + NamespacePicker, + type NamespaceVariant, +} from "./namespace-picker"; + +export type ClusterView = + | "overview" + | "logs" + | "billing" + | "settings" + | ActorView; + +export const CLUSTER_VIEWS: { + id: ClusterView; + label: string; + icon: IconProp; +}[] = [ + { id: "overview", label: "Overview", icon: faHouse }, + { id: "logs", label: "Logs", icon: faLogs }, + { id: "billing", label: "Billing", icon: faCreditCard }, + { id: "settings", label: "Settings", icon: faGear }, +]; + +/** Rivet products that run as actors on the cluster, each with its brand mark. */ +export const PRODUCT_ACTORS = [ + { + id: "actor:agentos", + label: "agentOS", + markFileName: "agentos-mark.svg", + section: "products", + }, + { + id: "actor:dynamic-apps", + label: "Dynamic Apps", + markFileName: "dynamic-apps-mark.svg", + section: "products", + }, + { + id: "actor:workflows", + label: "Workflows", + markFileName: "workflows-mark.svg", + section: "products", + }, + { + id: "actor:durable-streams", + label: "Durable Streams", + markFileName: "durable-streams-mark.svg", + section: "services", + }, +] as const; + +/** Actors the user deployed themselves; in the real dashboard these come from the registry. */ +export const USER_ACTORS = [ + { id: "actor:myUser", label: "myUser", icon: faActors }, + { id: "actor:myOrg", label: "myOrg", icon: faActors }, + { id: "actor:myAgent", label: "myAgent", icon: faRobot }, + { id: "actor:myWorkflow", label: "myWorkflow", icon: faDiagramProject }, +] as const; + +/** Quickstart page for writing and deploying your own actor. */ +export const DEPLOY_ACTOR = { + id: "actor:deploy", + label: "Deploy an Actor", + markFileName: "actors-mark.svg", + section: "products", +} as const; + +export type ActorView = + | (typeof PRODUCT_ACTORS)[number]["id"] + | (typeof USER_ACTORS)[number]["id"] + | typeof DEPLOY_ACTOR.id; + +export function isActorView(view: ClusterView): view is ActorView { + return view.startsWith("actor:"); +} + +export function userActorIcon(view: ActorView): IconProp { + return USER_ACTORS.find((a) => a.id === view)?.icon ?? faActors; +} + +export function actorViewLabel(view: ActorView): string { + return ( + [...PRODUCT_ACTORS, ...USER_ACTORS, DEPLOY_ACTOR].find( + (a) => a.id === view, + )?.label ?? view + ); +} + +interface ClusterSidebarProps { + view: ClusterView; + onViewChange: (view: ClusterView) => void; + /** Which namespace-switcher exploration to render. See `NamespacePicker`. */ + namespaceVariant?: NamespaceVariant; + namespace?: NamespaceId; + onNamespaceChange?: (id: NamespaceId) => void; +} + +/** + * Left sidebar, same chrome as the real dashboard `Sidebar` (ghost nav + * buttons). + */ +export function ClusterSidebar({ + view, + onViewChange, + namespaceVariant = "heading", + namespace = "production", + onNamespaceChange = () => {}, +}: ClusterSidebarProps) { + // dev-nathan is a smaller namespace so switching visibly changes the list. + const userActors = + namespace === "dev-nathan" ? USER_ACTORS.slice(2) : USER_ACTORS; + return ( + + ); +} + +/** Mirrors `HeaderLink` in `@/app/layout`: ghost button, active = foreground tint. */ +function SidebarButton({ + icon, + active, + disabled, + className, + children, + onClick, +}: { + icon: ReactNode; + active: boolean; + disabled?: boolean; + className?: string; + children: ReactNode; + onClick?: () => void; +}) { + return ( + + ); +} diff --git a/frontend/src/components/cluster-onboarding/connection-fields.tsx b/frontend/src/components/cluster-onboarding/connection-fields.tsx new file mode 100644 index 0000000000..e95b0fae46 --- /dev/null +++ b/frontend/src/components/cluster-onboarding/connection-fields.tsx @@ -0,0 +1,53 @@ +import { faCopy, Icon, type IconProp } from "@rivet-gg/icons"; +import { CopyTrigger } from "@/components/copy-area"; + +/** Icon + title + one-line detail above a connection value. */ +export function EndpointTitle({ + icon, + title, + detail, +}: { + icon: IconProp; + title: string; + detail?: string; +}) { + return ( +
+ + + {title} + + {detail ? ( + {detail} + ) : null} +
+ ); +} + +/** A titled URL that copies on click. Shared by the overview and product quickstarts. */ +export function Endpoint({ + icon, + title, + detail, + url, +}: { + icon: IconProp; + title: string; + detail?: string; + url: string; +}) { + return ( +
+ + + + +
+ ); +} diff --git a/frontend/src/components/cluster-onboarding/create-cluster-page.tsx b/frontend/src/components/cluster-onboarding/create-cluster-page.tsx new file mode 100644 index 0000000000..02ab9e1353 --- /dev/null +++ b/frontend/src/components/cluster-onboarding/create-cluster-page.tsx @@ -0,0 +1,675 @@ +import { + faArrowUpRightFromSquare, + faAws, + faCheck, + faChevronDown, + faChevronUp, + faBolt, + faCloud, + faCube, + faGoogleCloud, + faHardDrive, + faInfoCircle, + faLayerGroup, + faMemory, + faMicrochip, + faServer, + faShieldHalved, + faTag, + Icon, + type IconProp, +} from "@rivet-gg/icons"; +import { type ReactNode, useState } from "react"; +import { Controller, useForm, useWatch } from "react-hook-form"; +import { PlanBadge } from "@/app/billing/billing-plan-badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { WithTooltip } from "@/components/ui/tooltip"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { + type Cloud, + type CloudId, + CLOUDS, + formatVcpu, + type Tier, + type TierId, + TIERS, +} from "./catalog"; +import { Highlight } from "./highlight"; +import { IpAllowlist } from "./ip-allowlist"; +import { + type ClusterSelection, + DEFAULT_SELECTION, + formatDiskGb, + formatMemory, + formatThroughput, + formatUsd, + getCloud, + getSize, + getTier, + planMonthlyUsd, + planWriteMbps, + quote, + selectTier, +} from "./model"; +import { RunnersConfig } from "./runners-config"; + +export const CLOUD_ICONS: Record = { + aws: faAws, + gcp: faGoogleCloud, +}; + +export function flag(country: string): string { + return String.fromCodePoint( + ...[...country.toUpperCase()].map((c) => 0x1f1a5 + c.charCodeAt(0)), + ); +} + +export const RADIO_CLASS = + "border-foreground/40 text-foreground data-[state=checked]:border-foreground"; + +interface CreateClusterPageProps { + defaultValues?: ClusterSelection; + onCreate: (selection: ClusterSelection) => void; + isCreating?: boolean; +} + +/** + * Aiven-style single-page cluster creation: cloud and region first (prices + * depend on them), then a plan, which is a level (SLA, support, limits) with + * a table of control plane sizes to buy it in, then runners and the basics. + * A sticky summary rail on the right prices it. + */ +export function CreateClusterPage({ + defaultValues = DEFAULT_SELECTION, + onCreate, + isCreating, +}: CreateClusterPageProps) { + const form = useForm({ defaultValues }); + const selection = useWatch({ control: form.control }) as ClusterSelection; + const cloud = getCloud(selection.cloud); + const tier = getTier(selection.tier); + + return ( +
+
+
+

+ Create a cluster +

+
+ +
+ +
+ +
+ ( + { + if (!v) return; + field.onChange(v); + form.setValue( + "region", + getCloud(v as CloudId).regions[0].id, + ); + }} + className="flex-wrap justify-start gap-2" + > + {CLOUDS.map((c) => ( + + + {c.label} + + ))} + + )} + /> + ( + + + + {cloud.regions.map((region) => { + const selected = + field.value === region.id; + return ( + + + + + + + + + + + + ); + })} + +
+
+ )} + /> +
+ +
+ ( + { + const next = selectTier( + selection, + v as TierId, + ); + field.onChange(next.tier); + form.setValue("plan", next.plan); + form.setValue("runners", next.runners); + }} + > + + {TIERS.map((t) => ( + + {t.label} + + ))} + + + )} + /> + + ( + + )} + /> +
+ +
+ ( + + )} + /> + + Need a larger size? + + +
+ +
+ ( + + )} + /> +
+
+ + +
+ ); +} + +/** What the level buys regardless of size: node range, limits, support. */ +export function TierHighlights({ tier }: { tier: Tier }) { + return ( +
+

{tier.description}

+
    + {tier.highlights.map((h) => ( +
  • + + +
  • + ))} +
+
+ ); +} + +const COLLAPSED_OPTIONS = 4; + +/** The control plane configurations a plan is sold in, priced for the chosen cloud. */ +export function PlanOptions({ + tier, + cloud, + value, + onChange, +}: { + tier: Tier; + cloud: Cloud; + value: string; + onChange: (plan: string) => void; +}) { + const [expanded, setExpanded] = useState(false); + const collapsible = tier.options.length > COLLAPSED_OPTIONS; + const options = + collapsible && !expanded + ? tier.options.slice(0, COLLAPSED_OPTIONS) + : tier.options; + return ( +
+ + + + + + Plan + + Nodes + + + vCPU + + + RAM + + + + Hot cache + + + + + Write throughput + + + + Monthly price + + + + + {options.map((option) => { + const size = getSize(option.size); + const selected = value === option.id; + const usd = planMonthlyUsd(option, cloud); + return ( + + + + + + + + + {option.nodes} + + + {formatVcpu(size.vcpu)} + + + {formatMemory(size.memoryMb)} + + + {formatDiskGb( + size.diskCacheGb * option.nodes, + )} + + + {formatThroughput( + planWriteMbps(option), + )} + + + {usd === 0 ? "Free" : formatUsd(usd)} + + + ); + })} + +
+
+ {collapsible ? ( + + ) : null} +
+ ); +} + +/** Column header with a hover hint, right-aligned like the numeric columns. */ +function HeadWithInfo({ + info, + children, +}: { + info: string; + children: ReactNode; +}) { + return ( + + {children} + + } + content={info} + contentProps={{ className: "max-w-64" }} + /> + + ); +} + +export function SummaryCard({ + selection, + isCreating, +}: { + selection: ClusterSelection; + isCreating?: boolean; +}) { + const q = quote(selection); + const region = q.cloud.regions.find((r) => r.id === selection.region); + return ( + +
+

Summary

+ + {selection.name || "—"} + + + + + {q.cloud.label} + {region ? ( + + {region.id} + + ) : null} + + + + + + + + + {formatVcpu(q.controlPlane.size.vcpu)} vCPU + + + {formatMemory(q.controlPlane.size.memoryMb)} RAM + + + {formatDiskGb( + q.controlPlane.size.diskCacheGb * + q.controlPlane.nodes, + )}{" "} + hot cache + + + + + {formatThroughput(planWriteMbps(q.plan))} writes + + + + + {q.controlPlane.nodes}{" "} + {q.controlPlane.nodes === 1 ? "server" : "servers"}{" "} + across{" "} + {q.controlPlane.nodes >= 3 + ? "3 availability zones" + : "1 availability zone"} + + + + + + + {q.runners.count}{" "} + {q.runners.count === 1 ? "server" : "servers"} + + + {formatVcpu(q.runners.size.vcpu)} vCPU + + + {formatMemory(q.runners.size.memoryMb)} RAM + + + + + + {selection.allowlist.length === 0 + ? "None" + : selection.allowlist.join(", ")} + + +
+ +
+
+ + Per month + + + {formatUsd(q.totalUsd)} + +
+ +
+
+ ); +} + +/** One line of icon-prefixed specs in the summary, wrapping when narrow. */ +export function SpecLine({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function Spec({ + icon, + children, +}: { + icon: IconProp; + children: ReactNode; +}) { + return ( + + + {children} + + ); +} + +export function SummaryRow({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( +
+
{label}
+ {children} +
+ ); +} + +export function Section({ + icon, + title, + description, + children, +}: { + icon: IconProp; + title: string; + description?: string; + children: ReactNode; +}) { + // Each section renders its own slice of the stepper gutter. The line runs + // from below the icon through the parent's gap so it joins the next section. + return ( +
+
+ + + + +
+
+
+

+ {title} +

+ {description ? ( +

+ {description} +

+ ) : null} +
+ {children} +
+
+ ); +} diff --git a/frontend/src/components/cluster-onboarding/create-cluster-variants.tsx b/frontend/src/components/cluster-onboarding/create-cluster-variants.tsx new file mode 100644 index 0000000000..ebe01261f2 --- /dev/null +++ b/frontend/src/components/cluster-onboarding/create-cluster-variants.tsx @@ -0,0 +1,1323 @@ +import { + faArrowLeft, + faArrowRight, + faBolt, + faCheck, + faChevronDown, + faChevronRight, + faCloud, + faCube, + faHardDrive, + faLayerGroup, + faMemory, + faMicrochip, + faPlus, + faServer, + faShieldHalved, + faSliders, + faTag, + Icon, + type IconProp, +} from "@rivet-gg/icons"; +import { type ReactNode, useState } from "react"; +import { + Controller, + type UseFormReturn, + useForm, + useWatch, +} from "react-hook-form"; +import { PlanBadge } from "@/app/billing/billing-plan-badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { cn } from "@/components/lib/utils"; +import { + type Cloud, + type CloudId, + CLOUDS, + formatVcpu, + type Tier, + type TierId, + TIERS, +} from "./catalog"; +import { Highlight } from "./highlight"; +import { + CLOUD_ICONS, + flag, + PlanOptions, + RADIO_CLASS, + Section, + Spec, + SpecLine, + SummaryCard, +} from "./create-cluster-page"; +import { IpAllowlist } from "./ip-allowlist"; +import { + type ClusterSelection, + DEFAULT_SELECTION, + formatDiskGb, + formatMemory, + formatThroughput, + formatUsd, + getCloud, + getPlanOption, + getSize, + getTier, + nodeMonthlyUsd, + planMonthlyUsd, + planWriteMbps, + quote, + selectTier, +} from "./model"; +import { RunnersConfig } from "./runners-config"; + +// Explorations of a simplified create flow: pick a name, a region and a plan +// level (Hobby / Startup / Business) and everything else is configured from +// the plan's defaults. Each variant is reachable at +// /mock/cluster-onboarding?create=. + +export type CreateVariant = "fold" | "card" | "wizard" | "sentence" | "dialog"; + +export const CREATE_VARIANTS: { id: CreateVariant; label: string }[] = [ + { id: "fold", label: "A · Fold-down advanced settings" }, + { id: "card", label: "B · Centered summary card" }, + { id: "wizard", label: "C · Three-step wizard" }, + { id: "sentence", label: "D · One sentence" }, + { id: "dialog", label: "E · Dialog over cluster list" }, +]; + +interface VariantProps { + defaultValues?: ClusterSelection; + onCreate: (selection: ClusterSelection) => void; + isCreating?: boolean; +} + +export function CreateClusterVariant({ + variant, + ...props +}: VariantProps & { variant: CreateVariant }) { + switch (variant) { + case "fold": + return ; + case "card": + return ; + case "wizard": + return ; + case "sentence": + return ; + case "dialog": + return ; + } +} + +// --------------------------------------------------------------------------- +// Shared form plumbing + +type ClusterForm = UseFormReturn; + +function useClusterForm(defaultValues: ClusterSelection) { + const form = useForm({ defaultValues }); + const selection = useWatch({ control: form.control }) as ClusterSelection; + const setTier = (tier: TierId) => { + const next = selectTier(selection, tier); + form.setValue("tier", next.tier); + form.setValue("plan", next.plan); + form.setValue("runners", next.runners); + }; + const setCloud = (cloud: CloudId) => { + form.setValue("cloud", cloud); + form.setValue("region", getCloud(cloud).regions[0].id); + }; + return { + form, + selection, + cloud: getCloud(selection.cloud), + tier: getTier(selection.tier), + setTier, + setCloud, + }; +} + +/** The configuration a plan level starts with: its default control plane option and runners. */ +function tierDefaults(tier: Tier, cloud: Cloud) { + const plan = getPlanOption(tier, tier.defaultPlan); + const cp = getSize(plan.size); + const runners = getSize(tier.defaultRunners.size); + const usd = + planMonthlyUsd(plan, cloud) + + tier.defaultRunners.count * nodeMonthlyUsd(runners, cloud); + return { plan, cp, runners, usd }; +} + +// --------------------------------------------------------------------------- +// Shared controls + +function CloudPills({ + value, + onChange, + size = "default", +}: { + value: CloudId; + onChange: (cloud: CloudId) => void; + size?: "default" | "sm"; +}) { + return ( + { + if (v) onChange(v as CloudId); + }} + className="flex-wrap justify-start gap-2" + > + {CLOUDS.map((c) => ( + + + {c.label} + + ))} + + ); +} + +function RegionSelect({ + cloud, + value, + onChange, + className, +}: { + cloud: Cloud; + value: string; + onChange: (region: string) => void; + className?: string; +}) { + return ( + + ); +} + +/** Region radio table from the full create page, for the variants that keep it. */ +function RegionTable({ + cloud, + value, + onChange, +}: { + cloud: Cloud; + value: string; + onChange: (region: string) => void; +}) { + return ( + + + + {cloud.regions.map((region) => ( + + + + + + + + + + + + ))} + +
+
+ ); +} + +/** + * The three plan levels as selectable cards. Each shows what its default + * configuration costs on the chosen cloud, since that is what gets created. + */ +function TierCards({ + cloud, + value, + onChange, + compact, +}: { + cloud: Cloud; + value: TierId; + onChange: (tier: TierId) => void; + compact?: boolean; +}) { + return ( +
+ {TIERS.map((t) => { + const d = tierDefaults(t, cloud); + const selected = value === t.id; + return ( + + ); + })} +
+ ); +} + +/** What the plan configured on the user's behalf, as read-only lines. */ +function AutoConfigured({ selection }: { selection: ClusterSelection }) { + const q = quote(selection); + const rows = [ + { + key: "plan", + label: "Control Plane", + value: , + }, + { + key: "runners", + label: "Actor Nodes", + value: ( + + {q.runners.count} × {formatVcpu(q.runners.size.vcpu)} vCPU /{" "} + {formatMemory(q.runners.size.memoryMb)} + + ), + }, + { + key: "allowlist", + label: "Allowed IPs", + value: ( + + {selection.allowlist.length === 0 + ? "None" + : selection.allowlist.join(", ")} + + ), + }, + ]; + return ( +
+ {rows.map((r) => ( +
+
+ {r.label} +
+
{r.value}
+
+ ))} +
+ ); +} + +function PlanSummary({ selection }: { selection: ClusterSelection }) { + const q = quote(selection); + return ( + + {q.plan.label} + + {" "} + · {q.controlPlane.nodes} ×{" "} + {formatVcpu(q.controlPlane.size.vcpu)} vCPU /{" "} + {formatMemory(q.controlPlane.size.memoryMb)} + + + ); +} + +// --------------------------------------------------------------------------- +// The "Configuration" block in the dialog: a summary list where each row +// drills into its full editor in place, with a back button to return. + +type ConfigKey = "plan" | "runners" | "allowlist"; + +interface ConfiguredProps { + form: ClusterForm; + tier: Tier; + cloud: Cloud; + selection: ClusterSelection; +} + +function Configuration({ form, tier, cloud, selection }: ConfiguredProps) { + const [open, setOpen] = useState(null); + const q = quote(selection); + const rows: { + key: ConfigKey; + label: string; + /** Shown under the label in the left column. */ + badge?: ReactNode; + value: ReactNode; + editor: ReactNode; + }[] = [ + { + key: "plan", + label: "Control Plane", + badge: , + value: ( +
+ + + {q.controlPlane.nodes}{" "} + {q.controlPlane.nodes === 1 ? "server" : "servers"} + + + {formatVcpu(q.controlPlane.size.vcpu)} vCPU + + + {formatMemory(q.controlPlane.size.memoryMb)} RAM + + + {formatDiskGb( + q.controlPlane.size.diskCacheGb * + q.controlPlane.nodes, + )}{" "} + hot cache + + + {formatThroughput(planWriteMbps(q.plan))} writes + + +
+ ), + editor: ( + ( + + )} + /> + ), + }, + { + key: "runners", + label: "Actor Nodes", + value: ( + + + {q.runners.count}{" "} + {q.runners.count === 1 ? "server" : "servers"} + + + {formatVcpu(q.runners.size.vcpu)} vCPU + + + {formatMemory(q.runners.size.memoryMb)} RAM + + + ), + editor: ( + ( + + )} + /> + ), + }, + { + key: "allowlist", + label: "Allowed IPs", + value: ( + + {selection.allowlist.length === 0 + ? "None" + : selection.allowlist.join(", ")} + + ), + editor: ( + ( + + )} + /> + ), + }, + ]; + const active = rows.find((r) => r.key === open); + + if (active) { + return ( +
+
+ + {active.label} +
+ {active.editor} +
+ ); + } + + return ( +
+
+ Configuration +
+
+ {rows.map((r) => ( + + ))} +
+
+ ); +} + +/** The full-page controls behind "Advanced settings": plan size, runners, allowlist. */ +function AdvancedSettings({ + form, + tier, + cloud, +}: { + form: ClusterForm; + tier: Tier; + cloud: Cloud; +}) { + return ( +
+ + ( + + )} + /> + + + ( + + )} + /> + + + ( + + )} + /> + +
+ ); +} + +function Field({ + title, + description, + children, +}: { + title: string; + description?: string; + children: ReactNode; +}) { + return ( +
+
+

{title}

+ {description ? ( +

+ {description} +

+ ) : null} +
+ {children} +
+ ); +} + +function PriceFooter({ + selection, + isCreating, + className, +}: { + selection: ClusterSelection; + isCreating?: boolean; + className?: string; +}) { + const q = quote(selection); + return ( +
+
+ + {formatUsd(q.totalUsd)} + + per month +
+ +
+ ); +} + +// --------------------------------------------------------------------------- +// A. Current layout, plan levels as cards, advanced settings folded away. + +function FoldDownVariant({ + defaultValues = DEFAULT_SELECTION, + onCreate, + isCreating, +}: VariantProps) { + const { form, selection, cloud, tier, setTier, setCloud } = + useClusterForm(defaultValues); + const [advanced, setAdvanced] = useState(false); + + return ( +
+
+
+

+ Create a cluster +

+
+ +
+ +
+ +
+ + ( + + )} + /> +
+ +
+ +
+ +
+ {advanced ? null : } + + {advanced ? ( +
+ +
+ ) : null} +
+
+ + +
+ ); +} + +// --------------------------------------------------------------------------- +// B. The summary rail is the whole page: one centered card you edit inline. + +function CenteredCardVariant(props: VariantProps) { + return ( +
+ + + +
+ ); +} + +/** + * The compact editable summary used by B and E. Name, cloud and plan are + * edited in place; the rest is a configuration list that drills into each + * value's full editor. + */ +function CompactForm({ + defaultValues = DEFAULT_SELECTION, + onCreate, + isCreating, + title, +}: VariantProps & { title?: string }) { + const { form, selection, cloud, tier, setTier, setCloud } = + useClusterForm(defaultValues); + return ( +
+ {title ? ( +

+ {title} +

+ ) : null} +
+ + + + +
+ + ( + + )} + /> +
+
+ + +
    + {tier.highlights.map((h) => ( +
  • + + +
  • + ))} +
+
+
+ +
+
+ + + ); +} + +function CardRow({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
+ {label} +
+ {children} +
+ ); +} + +// --------------------------------------------------------------------------- +// C. One decision per screen. + +const WIZARD_STEPS = ["Basics", "Plan", "Review"] as const; + +function WizardVariant({ + defaultValues = DEFAULT_SELECTION, + onCreate, + isCreating, +}: VariantProps) { + const { form, selection, cloud, setTier, setCloud } = + useClusterForm(defaultValues); + const [step, setStep] = useState(0); + + return ( +
+
+

+ Create a cluster +

+
    + {WIZARD_STEPS.map((s, i) => ( +
  1. + + {i < WIZARD_STEPS.length - 1 ? ( + + ) : null} +
  2. + ))} +
+
+ + {step === 0 ? ( +
+ + + + + + + + ( + + )} + /> + +
+ ) : null} + + {step === 1 ? ( + + ) : null} + + {step === 2 ? ( + + ) : null} + + {step < 2 ? ( +
+ + +
+ ) : ( + + )} + + ); +} + +// --------------------------------------------------------------------------- +// D. The whole request as one sentence with inline controls. + +function SentenceVariant({ + defaultValues = DEFAULT_SELECTION, + onCreate, + isCreating, +}: VariantProps) { + const { form, selection, cloud, tier, setTier, setCloud } = + useClusterForm(defaultValues); + const [advanced, setAdvanced] = useState(false); + const q = quote(selection); + + return ( +
+

+ Create + + on + + in + ( + + )} + /> + on the + + plan. +

+ +
+ = 3 + ? "3 availability zones" + : "1 availability zone", + ]} + /> + + +
+ +
+ + + + + + + + + +
+
+ ); +} + +function Tile({ + icon, + title, + lines, +}: { + icon: IconProp; + title: string; + lines: string[]; +}) { + return ( +
+
+ + {title} +
+
    + {lines.map((l) => ( +
  • + {l} +
  • + ))} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// E. The compact form in a dialog over the clusters list, like PlanetScale. + +function DialogVariant(props: VariantProps) { + const [open, setOpen] = useState(true); + return ( +
+
+

+ Clusters +

+ +
+
+ +

No clusters yet

+

+ Create one to get an endpoint and an admin token. +

+
+ + + + New cluster + + + + +
+ ); +} diff --git a/frontend/src/components/cluster-onboarding/deployment-map.tsx b/frontend/src/components/cluster-onboarding/deployment-map.tsx new file mode 100644 index 0000000000..3e5fbbe9ee --- /dev/null +++ b/frontend/src/components/cluster-onboarding/deployment-map.tsx @@ -0,0 +1,1150 @@ +import { + faBucket, + faCopy, + faGlobe, + Icon, + type IconProp, +} from "@rivet-gg/icons"; +import confetti from "canvas-confetti"; +import { + type ReactNode, + type RefObject, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { LogoMark } from "@/app/logo"; +import { ClickToCopy } from "@/components/copy-area"; +import { cn } from "@/components/lib/utils"; +import { WithTooltip } from "@/components/ui/tooltip"; +import { publicUrl } from "@/lib/utils"; +import type { ClusterNode, NodeStatus } from "./model"; +import { cubicBezier, type Pose, type SceneOptions, useScene } from "./scene"; + +interface DeploymentMapProps { + nodes: ClusterNode[]; + /** Host of the external endpoint, e.g. api.test-cluster-3.rivet.run. */ + externalHost: string; + /** Host of the runners' public URL, e.g. default.test-cluster-3.rivet.run. */ + runnerHost: string; + /** + * Stretches every animation by this factor (0.1 = ten times slower). + * Only the animation lab sets it, to inspect motion frame by frame. + */ + timeScale?: number; +} + +/** + * How traffic flows: the public internet fans out to the two public + * endpoints (the API into the control plane, the runner URL into the actor + * runners), the control plane talks to storage, and the control plane and + * runners talk both ways. Each group shows its nodes stacked by version, and + * traffic pulses along the wires. + */ +export function DeploymentMap({ + nodes, + externalHost, + runnerHost, + timeScale = 1, +}: DeploymentMapProps) { + const controlPlane = nodes.filter((n) => n.pool === undefined); + const runners = nodes.filter((n) => n.pool !== undefined); + const celebration = useCelebration(); + const internet = useRef(null); + const controlPlaneGroup = useRef(null); + const runnersGroup = useRef(null); + + const body = ( +
+ + + } + title="Control Plane" + nodes={controlPlane} + className="col-start-3 row-start-1" + timeScale={timeScale} + /> + + } + title="Storage" + tint={FLOW.storage.color} + className="col-start-5 row-start-1" + /> + {/* The two-way actor link hangs from a fixed offset so it stays + under the first stack while a rollout widens a group. */} + + } + title="Actor Nodes" + nodes={runners} + tint={FLOW.actors.color} + className="col-start-3 row-start-3" + timeScale={timeScale} + onRolled={celebration.burst} + /> +
+ ); + + return ( +
+ + {body} + +
+ ); +} + +// --------------------------------------------------------------------------- +// Public endpoints +// --------------------------------------------------------------------------- + +/** The public internet, where both public endpoints are reached from. */ +function Internet({ + ref, + className, +}: { + ref: RefObject; + className?: string; +}) { + return ( +
+ + + Public internet + +
+ ); +} + +/** Width of the fan of wires between the internet and the groups. */ +const FAN_W = 240; +/** The curves have levelled out by here; the wires run straight after it. */ +const FAN_BEND_PX = 100; +/** Gap between an endpoint chip and the group it feeds. */ +const CHIP_GAP_PX = 24; +/** Fan packets travel further than connector packets, so they get longer. */ +const FAN_CYCLE_S = 3.6; + +interface Wire { + /** Text shown on the chip; the full URL is what gets copied. */ + label: string; + host: string; + flow: Flow; + /** The group this endpoint feeds; the wire ends at its left edge. */ + to: RefObject; +} + +/** + * Where the wires start and end, as y offsets within the fan. Measured from + * the DOM because the rows take their height from the groups, which are laid + * out by CSS. + */ +interface FanGeometry { + height: number; + /** Centre of the dot on the globe's right edge; x is negative, since the + * globe sits in the column before the fan. */ + from: { x: number; y: number }; + to: number[]; +} + +/** + * The wires from the internet to each public endpoint: each one curves out + * of the globe to its row, then runs straight through the endpoint's chip + * into the group. Packets ride the curve on an `offset-path`, and the end + * dots light as they leave and arrive, like the straight connectors. + */ +function Fan({ + from, + wires, + className, +}: { + from: RefObject; + wires: Wire[]; + className?: string; +}) { + const fan = useRef(null); + const [geometry, setGeometry] = useState(null); + + useLayoutEffect(() => { + const el = fan.current; + if (!el) return; + const measure = () => { + const box = el.getBoundingClientRect(); + const middle = (target: HTMLElement | null) => { + const rect = target?.getBoundingClientRect(); + return rect ? rect.top + rect.height / 2 - box.top : 0; + }; + const globe = from.current?.getBoundingClientRect(); + const next: FanGeometry = { + height: box.height, + from: { + x: globe ? globe.right - box.left + END_DOT_PX / 2 : 0, + y: middle(from.current), + }, + to: wires.map((wire) => middle(wire.to.current)), + }; + setGeometry((prev) => + prev && + prev.height === next.height && + prev.from.x === next.from.x && + prev.from.y === next.from.y && + prev.to.every((y, i) => y === next.to[i]) + ? prev + : next, + ); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [from, wires]); + + const x1 = FAN_W - END_DOT_PX / 2; + + return ( +
+ {geometry + ? wires.map((wire, i) => { + const { x: x0, y: y0 } = geometry.from; + const y1 = geometry.to[i]; + // An S-curve with level tangents at both ends, then a + // straight run into the group. + const bend = (x0 + FAN_BEND_PX) / 2; + const d = `M ${x0} ${y0} C ${bend} ${y0}, ${bend} ${y1}, ${FAN_BEND_PX} ${y1} L ${x1} ${y1}`; + const pulses: Pulse[] = [ + { flow: wire.flow, reverse: false, delayMs: 0 }, + { + flow: wire.flow, + reverse: false, + delayMs: FAN_CYCLE_S * 500 + i * 450, + }, + ].map((pulse) => ({ ...pulse, cycleS: FAN_CYCLE_S })); + return ( +
+ + {pulses.map((pulse, j) => ( + + ))} + + + + + + + +
+ ); + }) + : null} +
+ ); +} + +/** Centres its child on a point of the fan. */ +function Anchored({ + x, + y, + children, +}: { + x: number; + y: number; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +/** A public endpoint as a chip sitting on its wire; click to copy its URL. */ +function EndpointChip({ + label, + host, + flow, + style, +}: { + label: string; + host: string; + flow: Flow; + style: React.CSSProperties; +}) { + return ( + + + + ); +} + +// --------------------------------------------------------------------------- +// Motion +// --------------------------------------------------------------------------- + +/** Vercel-style easing: fast start, long soft settle, no overshoot. */ +const EASE_OUT = cubicBezier(0.16, 1, 0.3, 1); +const EASE_CSS = "cubic-bezier(0.16, 1, 0.3, 1)"; +/** A stack opening, closing, or shifting; and the group width with it. */ +const STACK_MS = 650; +/** A layer stepping in behind the front card. */ +const LAYER_MS = 400; + +/** + * A small, quiet burst of confetti from the runners' front card when a + * rollout finishes. Drawn on the map's own canvas so it stays inside the + * map instead of raining over the page. + */ +function useCelebration() { + const canvas = useRef(null); + const fire = useRef(null); + const burst = (card: DOMRect) => { + const el = canvas.current; + if (!el) return; + fire.current ??= confetti.create(el, { + resize: true, + useWorker: false, + }); + const bounds = el.getBoundingClientRect(); + const origin = { + x: (card.left + card.width / 2 - bounds.left) / bounds.width, + y: (card.top - bounds.top) / bounds.height, + }; + const shared = { + origin, + colors: ["#38bdf8", "#8b5cf6", "#34d399", "#f8fafc"], + scalar: 0.5, + gravity: 0.7, + decay: 0.9, + ticks: 110, + disableForReducedMotion: true, + }; + fire.current({ + ...shared, + particleCount: 14, + spread: 70, + startVelocity: 13, + angle: 90, + }); + fire.current({ + ...shared, + particleCount: 8, + spread: 40, + startVelocity: 16, + angle: 60, + }); + fire.current({ + ...shared, + particleCount: 8, + spread: 40, + startVelocity: 16, + angle: 120, + }); + }; + return { canvas, burst }; +} + +// --------------------------------------------------------------------------- +// Traffic along connectors +// --------------------------------------------------------------------------- + +interface Flow { + color: string; + /** Steady streams run continuously; bursts travel then rest. */ + mode: "steady" | "burst"; + /** `both` sends a second particle the other way. */ + direction: "forward" | "both"; +} + +const FLOW: Record<"ingress" | "runners" | "storage" | "actors", Flow> = { + ingress: { color: "#8b5cf6", mode: "steady", direction: "forward" }, + runners: { color: "#38bdf8", mode: "steady", direction: "forward" }, + storage: { color: "#ef4444", mode: "burst", direction: "forward" }, + actors: { color: "#38bdf8", mode: "steady", direction: "both" }, +}; + +/** + * Each tile and group is washed with the colour of the traffic it handles: + * a faint gradient from the top plus a 1px highlight on the top edge, the + * way raised cards catch light. Alpha is low enough to work on both themes. + */ +function tinted(color: string | undefined): React.CSSProperties { + return { + backgroundImage: color + ? `linear-gradient(180deg, ${color}1f 0%, ${color}08 45%, transparent 100%)` + : "linear-gradient(180deg, hsl(var(--foreground) / 0.035) 0%, transparent 60%)", + "--card-highlight": color + ? `${color}40` + : "hsl(var(--foreground) / 0.08)", + } as React.CSSProperties; +} +/** Draws the top-edge highlight in a pseudo-element so it stacks with the + * drop shadow classes instead of replacing them. */ +const LIGHT = + "before:pointer-events-none before:absolute before:inset-0 before:rounded-[inherit] before:shadow-[inset_0_1px_0_var(--card-highlight)]"; + +/** Length of a connector line between its two end dots. */ +const CONNECTOR_PX = 40; +/** Diameter of an end dot (size-1.5), the width of a vertical connector. */ +const END_DOT_PX = 6; + +/** + * Packets are soft bulges in the wire: a short gradient streak, brightest + * at its centre, that slides along the line's axis with a fade at each end + * so it appears to leave one dot and arrive at the other. Bursts spend most + * of their cycle parked and invisible. + */ +const PACKET_LEN = 18; +const PACKET_WIDTH = 3; +/** + * The end dots light up in the flow's colour as a packet leaves (`send`) + * and as one arrives (`receive`). They run on the same cycle and delay as + * the packet, so the keyframe percentages line up with the packet's own: + * steady packets travel the whole cycle, bursts only its first 30%. + */ +const FLOW_KEYFRAMES = ` +@keyframes map-fade-in { + from { opacity: 0; transform: translateY(3px); } + to { opacity: 1; transform: none; } +} +@keyframes map-flow-steady { + 0% { transform: translate(var(--fx0), var(--fy0)); opacity: 0; } + 12% { opacity: 1; } + 88% { opacity: 1; } + 100% { transform: translate(var(--fx1), var(--fy1)); opacity: 0; } +} +@keyframes map-flow-path { + 0% { offset-distance: 0%; opacity: 0; } + 12% { opacity: 1; } + 88% { opacity: 1; } + 100% { offset-distance: 100%; opacity: 0; } +} +@keyframes map-flow-burst { + 0% { transform: translate(var(--fx0), var(--fy0)); opacity: 0; } + 4% { opacity: 1; } + 26% { opacity: 1; } + 30% { transform: translate(var(--fx1), var(--fy1)); opacity: 0; } + 100% { transform: translate(var(--fx1), var(--fy1)); opacity: 0; } +} +@keyframes map-dot-steady-send { + 0% { opacity: 1; } + 8% { opacity: 1; } + 26% { opacity: 0; } + 90% { opacity: 0; } + 100% { opacity: 1; } +} +@keyframes map-dot-steady-receive { + 0% { opacity: 1; } + 14% { opacity: 0; } + 84% { opacity: 0; } + 94% { opacity: 1; } + 100% { opacity: 1; } +} +@keyframes map-dot-burst-send { + 0% { opacity: 1; } + 5% { opacity: 1; } + 16% { opacity: 0; } + 97% { opacity: 0; } + 100% { opacity: 1; } +} +@keyframes map-dot-burst-receive { + 0% { opacity: 0; } + 24% { opacity: 0; } + 29% { opacity: 1; } + 33% { opacity: 1; } + 44% { opacity: 0; } + 100% { opacity: 0; } +} +`; + +/** One packet's timing; its end dots share it so they light in step. */ +interface Pulse { + flow: Flow; + /** Runs from the far dot back to the near one. */ + reverse: boolean; + delayMs: number; + /** Overrides the flow's cycle, for wires longer than a connector. */ + cycleS?: number; +} + +function pulseTiming(pulse: Pulse): React.CSSProperties { + const cycleS = pulse.cycleS ?? (pulse.flow.mode === "steady" ? 2.2 : 6); + return { + animationDuration: `calc(${cycleS}s / var(--map-time-scale, 1))`, + animationDelay: `calc(${pulse.delayMs}ms / var(--map-time-scale, 1))`, + animationIterationCount: "infinite", + }; +} + +function Particle({ + pulse, + horizontal, + length, +}: { + pulse: Pulse; + horizontal: boolean; + length: number; +}) { + const { flow, reverse } = pulse; + // Travel so the streak's centre runs from one end dot to the other. + const from = `${-PACKET_LEN / 2}px`; + const to = `${length - PACKET_LEN / 2}px`; + const [start, end] = reverse ? [to, from] : [from, to]; + const axis = horizontal ? "90deg" : "180deg"; + return ( +