diff --git a/canvas/src/components/tabs/ContainerConfigTab.tsx b/canvas/src/components/tabs/ContainerConfigTab.tsx index 24813e1a8..27c37fbbd 100644 --- a/canvas/src/components/tabs/ContainerConfigTab.tsx +++ b/canvas/src/components/tabs/ContainerConfigTab.tsx @@ -6,7 +6,21 @@ import { runtimeDisplayName } from "@/lib/runtime-names"; import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; import type { WorkspaceCompute } from "@/store/socket"; -const INSTANCE_TYPES = ["t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge", "m6i.large", "m6i.xlarge", "c6i.xlarge"]; +// Multi-provider RFC: provider drives the instance-type list. AWS is the only +// default-visible option until the CTO-gated flip; Hetzner/GCP are shown when +// the backend reports them registered + the customer is entitled. +// TODO(SSOT): replace these literals with the CP provider catalog (the same +// SSOT that prices the shapes) — keep in sync with workspace-server's +// workspaceComputeInstanceAllowlistByProvider and controlplane's RateCatalog +// until the catalog endpoint lands. +const PROVIDERS = ["aws", "hetzner", "gcp"]; +const PROVIDER_INSTANCE_TYPES: Record = { + aws: ["t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge", "m6i.large", "m6i.xlarge", "c6i.xlarge"], + hetzner: ["cpx11", "cpx21", "cpx31", "cpx41", "cpx51", "cax21", "cax31", "cax41"], + gcp: ["e2-small", "e2-standard-2", "e2-standard-4", "n2-standard-2", "n2-standard-4"], +}; +const providerLabel = (p: string): string => + p === "aws" ? "AWS" : p === "hetzner" ? "Hetzner (cost)" : p === "gcp" ? "Google Cloud" : p; const RUNTIME_OPTIONS = ["claude-code", "codex", "hermes", "openclaw", "kimi", "kimi-cli", "external"]; const RESOLUTIONS = ["1280x720", "1440x900", "1920x1080", "2560x1440"]; const DEFAULT_HEADLESS_INSTANCE_TYPE = "t3.medium"; @@ -23,6 +37,7 @@ type Props = { type FormState = { runtime: string; + provider: string; instanceType: string; rootGB: string; displayEnabled: boolean; @@ -40,6 +55,7 @@ const dataPersistenceLabel = (v: string): string => export function ContainerConfigTab({ workspaceId, data }: Props) { const runtime = data.runtime; + const provider = data.compute?.provider; const instanceType = data.compute?.instance_type; const rootGB = data.compute?.volume?.root_gb; const displayMode = data.compute?.display?.mode; @@ -48,8 +64,8 @@ export function ContainerConfigTab({ workspaceId, data }: Props) { const displayHeight = data.compute?.display?.height; const dataPersistence = data.compute?.data_persistence; const initial = useMemo( - () => formFromData({ runtime, instanceType, rootGB, displayMode, displayProtocol, displayWidth, displayHeight, dataPersistence }), - [runtime, instanceType, rootGB, displayMode, displayProtocol, displayWidth, displayHeight, dataPersistence], + () => formFromData({ runtime, provider, instanceType, rootGB, displayMode, displayProtocol, displayWidth, displayHeight, dataPersistence }), + [runtime, provider, instanceType, rootGB, displayMode, displayProtocol, displayWidth, displayHeight, dataPersistence], ); const [form, setForm] = useState(initial); const [saving, setSaving] = useState(false); @@ -87,6 +103,7 @@ export function ContainerConfigTab({ workspaceId, data }: Props) { const [width, height] = form.resolution.split("x").map((v) => parseInt(v, 10)); const compute: WorkspaceCompute = { + provider: form.provider, instance_type: form.instanceType, volume: { root_gb: rootGB }, display: form.displayEnabled @@ -139,11 +156,27 @@ export function ContainerConfigTab({ workspaceId, data }: Props) { optionLabel={runtimeDisplayName} onChange={(runtime) => setForm((s) => ({ ...s, runtime }))} /> + + setForm((s) => { + // Provider drives the instance-type set; reset to the new + // provider's first shape when the current one isn't valid there. + const shapes = PROVIDER_INSTANCE_TYPES[provider] ?? []; + const instanceType = shapes.includes(s.instanceType) ? s.instanceType : (shapes[0] ?? s.instanceType); + return { ...s, provider, instanceType }; + }) + } + /> setForm((s) => ({ ...s, instanceType }))} />