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/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/agent-connect.tsx b/frontend/src/components/cluster-onboarding/agent-connect.tsx new file mode 100644 index 0000000000..a7c69a8267 --- /dev/null +++ b/frontend/src/components/cluster-onboarding/agent-connect.tsx @@ -0,0 +1,95 @@ +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() { + const [copied, setCopied] = useState(false); + + return ( +
+ setCopied(true)}> + + + + {copied ? ( +

+ Copied. Paste it into your agent — it sets up Rivet, asks + what to build, runs it locally, then deploys here. +

+ ) : 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..2e03d81559 --- /dev/null +++ b/frontend/src/components/cluster-onboarding/billing-panel.tsx @@ -0,0 +1,555 @@ +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, type TierId, TIERS } from "./catalog"; +import { + type ClusterSelection, + clampRunners, + formatMemory, + formatUsd, + formatVcpu, + 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({ + ...selection, + tier: tier.id, + runners: clampRunners( + tier.id, + selection.runners, + ), + }); + return ( +
+
+ +
+
+ + {tier.monthlyUsd === 0 + ? "$0" + : formatUsd(tier.monthlyUsd)} + + + /mo + runners + +
+
+

+ {tier.description} +

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

+ Subscription +

+

+ Billed monthly for this cluster. Scale runners 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 +
+
+ + {q.cloud.id === "byoc" ? : null} + + {q.tier.monthlyUsd === 0 + ? "$0/mo" + : `${formatUsd(q.tier.monthlyUsd)}/mo`}{" "} + control plane + {q.runners.count} × {q.runners.size.label}{" "} + runners 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.value * nodes)} vCPU ·{" "} + {formatMemory(size.memoryMb * nodes)} total +
+
+
+ {q.tier.monthlyUsd === 0 + ? "Free" + : formatCurrency(q.tier.monthlyUsd)} +
+
+ 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.value * count)} vCPU ·{" "} + {formatMemory(size.memoryMb * count)} total +
+
+
+ {q.cloud.id === "byoc" + ? "Your cloud" + : q.tier.id === "free" + ? "Included" + : 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..2872c287ae --- /dev/null +++ b/frontend/src/components/cluster-onboarding/catalog.ts @@ -0,0 +1,990 @@ +// Fixture catalog for the cluster onboarding + dashboard mock. Everything in +// here is invented for design exploration; nothing is read from the API. + +export type TierId = "free" | "pro" | "team" | "enterprise"; + +export interface Tier { + id: TierId; + label: string; + /** Monthly platform fee in USD; covers the control plane. */ + monthlyUsd: number; + description: string; + /** The core engine nodes. Preconfigured by the plan, not user-sized. */ + controlPlane: { nodes: number; size: SizeId }; + /** Largest runner node size on this plan. */ + maxSize: SizeId; + /** Most runner nodes on this plan. */ + maxRunners: number; + /** Included actor storage, GB. */ + storageGb: number; + highlights: string[]; +} + +export const TIERS: Tier[] = [ + { + id: "free", + label: "Free", + monthlyUsd: 0, + description: "For prototyping.", + controlPlane: { nodes: 1, size: "r-5" }, + maxSize: "r-5", + maxRunners: 1, + storageGb: 5, + highlights: ["1 control plane node", "1 runner", "Community support"], + }, + { + id: "pro", + label: "Hobby", + monthlyUsd: 20, + description: "For side projects that need to stay up.", + controlPlane: { nodes: 1, size: "r-10" }, + maxSize: "r-40", + maxRunners: 3, + storageGb: 20, + highlights: [ + "1 control plane node", + "Up to 3 runners", + "Email support", + ], + }, + { + id: "team", + label: "Team", + monthlyUsd: 200, + description: "For production workloads and growing teams.", + controlPlane: { nodes: 3, size: "r-20" }, + maxSize: "r-640", + maxRunners: 20, + storageGb: 100, + highlights: [ + "3 control plane nodes, high availability", + "Up to 20 runners", + "Slack & email support", + ], + }, + { + id: "enterprise", + label: "Enterprise", + monthlyUsd: 1500, + description: "For compliance, SSO, audit logs, and dedicated support.", + controlPlane: { nodes: 3, size: "r-40" }, + maxSize: "r-2560", + maxRunners: 100, + storageGb: 500, + highlights: [ + "3 control plane nodes, high availability", + "Up to 100 runners", + "Dedicated support", + ], + }, +]; + +export type SizeId = + | "r-5" + | "r-10" + | "r-20" + | "r-40" + | "r-80" + | "r-160" + | "r-320" + | "r-640" + | "r-1280" + | "r-2560"; + +export interface NodeSize { + id: SizeId; + label: string; + /** vCPU per node, as a display fraction and a number. */ + vcpu: { label: string; value: number }; + memoryMb: number; + /** Monthly price per node on AWS, USD. */ + monthlyUsd: number; +} + +// Sizes follow a "memory in 100 MB units" naming scheme so the id reads as a +// capacity hint. Prices roughly double per step with a volume discount at the +// top end. +export const NODE_SIZES: NodeSize[] = [ + { + id: "r-5", + label: "R-5", + vcpu: { label: "1/16", value: 1 / 16 }, + memoryMb: 512, + monthlyUsd: 4, + }, + { + id: "r-10", + label: "R-10", + vcpu: { label: "1/8", value: 1 / 8 }, + memoryMb: 1024, + monthlyUsd: 8, + }, + { + id: "r-20", + label: "R-20", + vcpu: { label: "1/4", value: 1 / 4 }, + memoryMb: 2048, + monthlyUsd: 15, + }, + { + id: "r-40", + label: "R-40", + vcpu: { label: "1/2", value: 1 / 2 }, + memoryMb: 4096, + monthlyUsd: 26, + }, + { + id: "r-80", + label: "R-80", + vcpu: { label: "1", value: 1 }, + memoryMb: 8192, + monthlyUsd: 46, + }, + { + id: "r-160", + label: "R-160", + vcpu: { label: "2", value: 2 }, + memoryMb: 16384, + monthlyUsd: 90, + }, + { + id: "r-320", + label: "R-320", + vcpu: { label: "4", value: 4 }, + memoryMb: 32768, + monthlyUsd: 176, + }, + { + id: "r-640", + label: "R-640", + vcpu: { label: "8", value: 8 }, + memoryMb: 65536, + monthlyUsd: 350, + }, + { + id: "r-1280", + label: "R-1280", + vcpu: { label: "16", value: 16 }, + memoryMb: 131072, + monthlyUsd: 696, + }, + { + id: "r-2560", + label: "R-2560", + vcpu: { label: "32", value: 32 }, + memoryMb: 262144, + monthlyUsd: 1390, + }, +]; + +export type CloudId = "aws" | "gcp" | "azure" | "hetzner" | "byoc"; + +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", + }, + ], + }, + { + id: "azure", + label: "Azure", + description: "Ampere Altra series in 6 regions.", + multiplier: 1.1, + regions: [ + { id: "eastus", city: "Virginia", country: "US" }, + { id: "westus2", city: "Washington", country: "US" }, + { + id: "westeurope", + city: "Netherlands", + country: "NL", + }, + { id: "northeurope", city: "Ireland", country: "IE" }, + { + id: "southeastasia", + city: "Singapore", + country: "SG", + }, + { id: "japaneast", city: "Tokyo", country: "JP" }, + ], + }, + { + id: "hetzner", + label: "Hetzner", + description: "Ampere instances in 6 regions. Lowest cost.", + multiplier: 0.7, + regions: [ + { id: "fsn1", city: "Falkenstein", country: "DE" }, + { id: "nbg1", city: "Nuremberg", country: "DE" }, + { id: "hel1", city: "Helsinki", country: "FI" }, + { id: "ash", city: "Ashburn", country: "US" }, + { id: "hil", city: "Hillsboro", country: "US" }, + { id: "sin", city: "Singapore", country: "SG" }, + ], + }, + { + id: "byoc", + label: "Bring your own cloud", + description: + "Rivet runs the control plane inside your AWS or GCP account. You pay your cloud provider for compute; Rivet bills a platform fee.", + // BYOC compute is billed by the customer's cloud provider. Rivet charges + // a platform fee that is a fraction of the managed list price. + multiplier: 0.3, + regions: [ + { + id: "aws:us-east-1", + city: "Your AWS account", + country: "US", + }, + { + id: "aws:eu-west-1", + city: "Your AWS account", + country: "IE", + }, + { + id: "gcp:us-central1", + city: "Your GCP project", + country: "US", + }, + { + id: "gcp:europe-west1", + city: "Your GCP project", + country: "BE", + }, + ], + }, +]; + +/** 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, runner pool saturation above 80%, and failed deploys. Ask me whether to send alerts to Slack or PagerDuty.", + }, + { + area: "scale", + title: "Enable runner pool autoscaling", + description: + "The default pool is fixed at 4 runners. Scale between 2 and 16 on actor demand.", + prompt: "Configure the default runner pool on this Rivet cluster to autoscale between 2 and 16 runners 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", "Runner"], + 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 runner 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 runner 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 runner pool region closest to the players in the match. Add eu-west-1 and ap-northeast-1 runner 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..1d1280acb4 --- /dev/null +++ b/frontend/src/components/cluster-onboarding/cluster-dashboard-page.tsx @@ -0,0 +1,691 @@ +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 { ClusterSidebar, type ClusterView } from "./cluster-sidebar"; +import { DeploymentMap } from "./deployment-map"; +import { IpAllowlist } from "./ip-allowlist"; +import { LogsPanel } from "./logs-panel"; +import { MockTopBar } from "./mock-top-bar"; +import { + type ClusterEndpoints, + type ClusterSelection, + clusterEndpoints, + clusterState, + detectUseCase, + ENGINE_VERSION, + nextEngineVersion, + getCloud, + getSize, + 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; +} + +/** Hidden for now; flip to show the control plane upgrade simulator. */ +const SHOW_DEBUG_CONTROLS = false; + +export function ClusterDashboardPage({ + selection, + createdAt, + runnerAddedMs, + onAllowlistChange, + onTierChange, + onRunnersChange, + deployedDescription, +}: ClusterDashboardPageProps) { + const detected = deployedDescription + ? detectUseCase(deployedDescription) + : null; + const deployed = detected !== null && detected.id !== "custom"; + const [view, setView] = useState("overview"); + 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 nodesQuery = useQuery({ + queryKey: [ + "mock-cluster-nodes", + selection.name, + selection.tier, + selection.runners, + createdAt, + runnerAddedMs, + rollouts, + ], + queryFn: () => + clusterState( + selection, + Date.now() - createdAt, + runnerAddedMs, + rollouts, + BUILDS[0].version, + ), + initialData: () => + clusterState( + selection, + Date.now() - createdAt, + runnerAddedMs, + rollouts, + BUILDS[0].version, + ), + 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.id === "byoc" ? : null} + + + {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} + +
+ +
+
} + 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. + + + + + {tier.controlPlane.nodes} ×{" "} + { + getSize( + tier.controlPlane.size, + ).label + } + +
+
+ + Runners + + +
+
+
+
+
+ + + + + +
+
+ ) : null} + + {view === "billing" ? ( + +
+ +
+
+ ) : 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 EndpointTitle({ + icon, + title, + detail, +}: { + icon: IconProp; + title: string; + detail: string; +}) { + return ( +
+ + + {title} + + {detail} +
+ ); +} + +function Endpoint({ + icon, + title, + detail, + url, +}: { + icon: IconProp; + title: string; + detail: string; + url: string; +}) { + 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 runners one node at a time, replacing ${current.version}. Running actors are drained onto the new build; clients reconnect automatically.` + : "Rolls out to runners one node 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.map((b) => { + const deployed = b.id === deployedId; + return ( + + + {b.version} + + +
+ {b.message} +
+
+ {b.commit} +
+
+ + {b.createdAgo} + + + {deployed ? ( + + + Deployed + + ) : ( + + )} + +
+ ); + })} +
+
+
+ ); +} + +function Section({ + title, + description, + aside, + children, +}: { + title: string; + description?: string; + /** Controls rendered to the right of the heading. */ + aside?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+
+

{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..3e86c2d303 --- /dev/null +++ b/frontend/src/components/cluster-onboarding/cluster-map-lab.tsx @@ -0,0 +1,182 @@ +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { DeploymentMap } from "./deployment-map"; +import { + clusterEndpoints, + clusterState, + DEFAULT_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( + DEFAULT_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 = { + ...DEFAULT_SELECTION, + runners: { ...DEFAULT_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..a069528f1d --- /dev/null +++ b/frontend/src/components/cluster-onboarding/cluster-onboarding-flow.tsx @@ -0,0 +1,124 @@ +import { useState } from "react"; +import { ClusterDashboardPage } from "./cluster-dashboard-page"; +import { CreateClusterPage } from "./create-cluster-page"; +import { + clampRunners, + type ClusterSelection, + DEFAULT_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, +}: { + initial: ClusterSelection; + deployedDescription?: string; + createdAt?: number; +}) { + 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) => ({ + ...s, + tier, + runners: clampRunners(tier, s.runners), + })) + } + onRunnersChange={setRunners} + deployedDescription={deployedDescription} + /> + ); +} + +/** + * 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", +}: { + initialView?: "create" | "dashboard"; +}) { + const [created, setCreated] = useState<{ + selection: ClusterSelection; + createdAt: number; + } | null>( + initialView === "dashboard" + ? { + selection: { + ...DEFAULT_SELECTION, + allowlist: ["203.0.113.42/32", "198.51.100.0/24"], + }, + createdAt: Date.now() - LONG_AGO_MS, + } + : null, + ); + + if (created) { + return ( + + ); + } + return ( + + setCreated({ selection, createdAt: Date.now() }) + } + /> + ); +} 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..b4495a9bee --- /dev/null +++ b/frontend/src/components/cluster-onboarding/cluster-onboarding.stories.tsx @@ -0,0 +1,97 @@ +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 { DEFAULT_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 CreateClusterByoc: Story = () => ( + + console.log("create", s)} + /> + +); + +export const Dashboard: Story = () => ( + + + +); + +export const DashboardFreshCluster: Story = () => ( + + + +); + +export const DashboardHetzner: 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..61aed6bed7 --- /dev/null +++ b/frontend/src/components/cluster-onboarding/cluster-sidebar.tsx @@ -0,0 +1,89 @@ +import { + faCreditCard, + faGear, + faHouse, + faLogs, + Icon, + type IconProp, +} from "@rivet-gg/icons"; +import type { ReactNode } from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/components/lib/utils"; + +export type ClusterView = "overview" | "logs" | "billing" | "settings"; + +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 }, +]; + +interface ClusterSidebarProps { + view: ClusterView; + onViewChange: (view: ClusterView) => void; +} + +/** + * Left sidebar, same chrome as the real dashboard `Sidebar` (ghost nav + * buttons). + */ +export function ClusterSidebar({ view, onViewChange }: ClusterSidebarProps) { + return ( + + ); +} + +/** Mirrors `HeaderLink` in `@/app/layout`: ghost button, active = foreground tint. */ +function SidebarButton({ + icon, + active, + className, + children, + onClick, +}: { + icon: IconProp; + active: boolean; + className?: string; + children: ReactNode; + onClick: () => void; +}) { + 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..abf6408b2d --- /dev/null +++ b/frontend/src/components/cluster-onboarding/create-cluster-page.tsx @@ -0,0 +1,422 @@ +import { + faArrowUpRightFromSquare, + faAws, + faCloud, + faGoogleCloud, + faHetzner, + faMicrosoft, + faServer, + Icon, + type IconProp, +} from "@rivet-gg/icons"; +import type { ReactNode } 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, TableRow } from "@/components/ui/table"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { type CloudId, CLOUDS, type Tier, type TierId, TIERS } from "./catalog"; +import { IpAllowlist } from "./ip-allowlist"; +import { + type ClusterSelection, + clampRunners, + DEFAULT_SELECTION, + formatUsd, + getCloud, + getSize, + getTier, + quote, +} from "./model"; +import { RunnersConfig } from "./runners-config"; + +export const CLOUD_ICONS: Record = { + aws: faAws, + gcp: faGoogleCloud, + azure: faMicrosoft, + hetzner: faHetzner, + byoc: faCloud, +}; + +function flag(country: string): string { + return String.fromCodePoint( + ...[...country.toUpperCase()].map((c) => 0x1f1a5 + c.charCodeAt(0)), + ); +} + +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 (nodes, support) plus + * the hardware each node runs, then 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} + {c.id === "byoc" ? ( + + ) : null} + + ))} + + )} + /> + ( + + + + {cloud.regions.map((region) => { + const selected = + field.value === region.id; + return ( + + + + + + + + + + + + ); + })} + +
+
+ )} + /> +
+ +
+ ( + { + field.onChange(v); + form.setValue( + "runners", + clampRunners( + v as TierId, + selection.runners, + ), + ); + }} + > + + {TIERS.map((t) => ( + + {t.label} + + ))} + + + )} + /> + +

+ {tier.highlights.join(" · ")} +

+ +
+ +
+ ( + + )} + /> + + Need a larger size? + + +
+ +
+ +
+ +
+ ( + + )} + /> +
+
+ + +
+ ); +} + +/** The core engine nodes the plan provisions; not user-sized. */ +function ControlPlaneRow({ tier }: { tier: Tier }) { + const size = getSize(tier.controlPlane.size); + return ( +
+ + + + Control plane + + Preconfigured by the {tier.label} plan + + + + + {tier.controlPlane.nodes} × {size.label} + +
+ ); +} + +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} + + + + + + {q.cloud.id === "byoc" ? ( + + ) : null} + + + {q.controlPlane.nodes} ×{" "} + + {q.controlPlane.size.label} + {" "} + control plane + + + + + {q.runners.count} ×{" "} + + {q.runners.size.label} + + + + + + {selection.allowlist.length === 0 + ? "None" + : selection.allowlist.join(", ")} + + +
+ +
+
+ + Per month + + + {formatUsd(q.totalUsd)} + +
+ {q.cloud.id === "byoc" ? ( +

+ Platform fee only. Compute is billed by your cloud. +

+ ) : null} + +
+
+ ); +} + +function SummaryRow({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( +
+
{label}
+ {children} +
+ ); +} + +function Section({ + title, + description, + children, +}: { + title: string; + description?: string; + children: ReactNode; +}) { + return ( +
+
+

{title}

+ {description ? ( +

+ {description} +

+ ) : null} +
+ {children} +
+ ); +} 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..deaf0a053f --- /dev/null +++ b/frontend/src/components/cluster-onboarding/deployment-map.tsx @@ -0,0 +1,825 @@ +import { faBucket, faGlobe, Icon, type IconProp } from "@rivet-gg/icons"; +import confetti from "canvas-confetti"; +import { type ReactNode, useRef, useState } from "react"; +import { LogoMark } from "@/app/logo"; +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; + /** + * 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: public internet → control plane (with storage + * attached) → actor runners. Each group shows its nodes stacked by version, + * and traffic pulses along the connectors. + */ +export function DeploymentMap({ + nodes, + externalHost, + timeScale = 1, +}: DeploymentMapProps) { + const controlPlane = nodes.filter((n) => n.pool === undefined); + const runners = nodes.filter((n) => n.pool !== undefined); + const celebration = useCelebration(); + + const storage = ( + + } + title="Storage" + tint={FLOW.storage.color} + /> + + ); + + return ( +
+ + {/* Three columns: gutter, the flow, gutter. The flow column is + as wide as its widest group. Groups size themselves from + their animated stage width, so storage and the connectors + follow in normal flow on the same frames. */} +
+ } + title="Public internet" + subtitle={externalHost} + tint={FLOW.ingress.color} + className="col-start-2" + /> + + {/* Storage sits in normal flow beside the control plane so + the width animation carries it along; a hidden mirror + on the left keeps the group centered in the column. */} +
+ + } + title="Control Plane" + nodes={controlPlane} + timeScale={timeScale} + /> + {storage} +
+ + } + title="Actor Runners" + nodes={runners} + tint={FLOW.actors.color} + className="col-start-2" + timeScale={timeScale} + onRolled={celebration.burst} + /> +
+ +
+ ); +} + +// --------------------------------------------------------------------------- +// 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" | "storage" | "actors", Flow> = { + ingress: { color: "#8b5cf6", 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; + +/** + * 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-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; +} + +function pulseTiming(pulse: Pulse): React.CSSProperties { + const 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, +}: { + pulse: Pulse; + horizontal: boolean; +}) { + 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 = `${CONNECTOR_PX - PACKET_LEN / 2}px`; + const [start, end] = reverse ? [to, from] : [from, to]; + const axis = horizontal ? "90deg" : "180deg"; + return ( +