diff --git a/.claude/reference/feature-flags.md b/.claude/reference/feature-flags.md index dbfa4ab160..f3a8546c6d 100644 --- a/.claude/reference/feature-flags.md +++ b/.claude/reference/feature-flags.md @@ -35,6 +35,7 @@ if (features.platform) { | `datacenter` | Datacenter-related UI. | | `danger-zone` | Destructive settings actions (`features.dangerZone`). | | `byoc` | Bring Your Own Cloud (`features.byoc`): the BYOC option in the create-project flow, BYOC clusters in the project lists, and the `/orgs/$org/clusters/$cluster` page. Requires `platform`. | +| `pallet` | v3 dashboard ("pallet") backed by cloud API v2: the single-page create cluster screen that replaces the create-project dialog and the `/orgs/$org/new` page, and pallet clusters on `/orgs/$org/clusters/$cluster` (checked before BYOC). Requires `platform`. | | `services` | Managed services (`features.services`): the Services section of the onboarding product picker, the Durable Streams onboarding path, and the Services tab on the namespace settings drawer. Independent of `platform` (cloud hands out a managed service URL, self-host runs the worker container). | | `mcp` | MCP connection settings card on the namespace settings drawer. Flavor-dependent content: `platform` renders the hosted `mcp.rivet.dev` endpoint pinned to this namespace, everything else renders the local stdio (`npx @rivet-dev/mcp`) config. | diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index fc36e43775..f1bb082860 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -2,6 +2,7 @@ - Register keyboard shortcuts with `useHotkey` / `useHotkeySequence` from `@tanstack/react-hotkeys`. Do not hand-roll `window.addEventListener("keydown", ...)` handlers. Use the `ignoreInputs` option to skip shortcuts while typing in inputs/textareas/contenteditable instead of checking `e.target.tagName` manually. - Wrap async user actions (anything with pending/loading/error state) in `@tanstack/react-query` `useMutation`; drive UI with `mutation.isPending` / `mutation.mutate()`. Do not hand-roll a `useState` loading flag with try/catch/setState. +- Show API errors on forms with `showApiErrorOnForm(form, error, fields?)` from `src/lib/form-errors.ts`: set `meta: { hideErrorToast: true }` on the mutation and call it in the submit's `catch`. It puts `errors[].field` on matching fields and toasts the rest. Type the `fields` map with the request's keys (`ApiFieldMap`) so API renames fail to compile. - Derive state during render instead of syncing it with `useEffect`. Do not use `useEffect` for derived state or as a general escape hatch. If you believe an effect is genuinely unavoidable and cannot find another option, stop and ask the user for guidance before adding one. - The frontend build embedded into `rivet-engine` must use `pnpm build:engine` / `npx turbo build:engine -F @rivetkit/engine-frontend`, which sets `BASE_URL=/ui/`. Do not compensate for a root `/assets/*` build by adding root asset routes to the engine; fix the embedded build base instead. - The dashboard serves multiple deployment flavors (cloud / OSS / enterprise) from one build via `features.*`. Test frontend changes in more than one flavor before calling them done. **OSS especially**, since it disables `auth`/`platform`/`acl` and takes different code paths (context switcher, sidebar, onboarding, data providers) that the cloud default never exercises. Switch flavors in the browser console with `localStorage.setItem("FEATURE_FLAGS", "")` (OSS) / `localStorage.removeItem("FEATURE_FLAGS")` then reload. See [Feature flags](../.claude/reference/feature-flags.md#testing-across-flavors-required-for-frontend-changes). diff --git a/frontend/package.json b/frontend/package.json index d8140b3986..d00b9efe46 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -64,7 +64,7 @@ "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-visually-hidden": "^1.2.3", - "@rivet-gg/cloud": "https://pkg.pr.new/rivet-dev/engine-ee/@rivet-gg/cloud@979", + "@rivet-gg/cloud": "https://pkg.pr.new/rivet-dev/engine-ee/@rivet-gg/cloud@1330", "@rivet-gg/icons": "workspace:*", "@rivetkit/engine-api-full": "workspace:*", "@rivetkit/shared-data": "workspace:*", diff --git a/frontend/src/app/context-switcher.tsx b/frontend/src/app/context-switcher.tsx index c4b6de0972..1c65713fd0 100644 --- a/frontend/src/app/context-switcher.tsx +++ b/frontend/src/app/context-switcher.tsx @@ -436,19 +436,12 @@ function ClusterSegmentPopover({ currentCluster: string; }) { const [open, setOpen] = useState(false); - const { data } = useQuery( - useCloudDataProvider().currentOrgClusterQueryOptions({ - cluster: currentCluster, - }), - ); return (
- - {data?.name ?? currentCluster} - + {currentCluster}
{hasNextPage && !isFetchingNextPage ? ( +
+
+

+ {data?.cluster ?? cluster} +

+ {data ? : null} +
+
+ {regions.isLoading ? ( + + ) : null} + {primaryRegion ? ( + + {nodeSize(primaryRegion.node_size).label} + + ) : null} + {regions.data?.map((region) => ( + + + {cloudLabel(region.cloud)} · {region.region} + + ))} +
+
+ +
+ +
+ + + +
+ + +
+ + ); +} + +PalletClusterPage.Skeleton = function PalletClusterPageSkeleton() { + return ( + +
+ + +
+
+ +
+
+ + + + + +
+
+
+ + + + + + +
+
+
+
+ + + + + + +
+
+
+
+ + ); +}; + +export class ClusterLoadError extends Error { + constructor( + readonly cluster: string, + options: { cause: unknown }, + ) { + super(`Could not load cluster ${cluster}.`, options); + } +} + +function describeClusterLoadError(cause: unknown) { + const status = isRivetApiError(cause) ? cause.statusCode : undefined; + const message = getStructuredApiErrorMessage(cause); + if (status === 403) { + return { + title: "You don't have access to this cluster", + description: + message ?? + "Ask an admin of this organization to grant you access.", + canRetry: false, + }; + } + return { + title: "Couldn't load this cluster", + description: + message ?? + (status === undefined + ? "Couldn't reach the Cloud API. Check your connection and try again." + : `The Cloud API returned an unexpected response (${status}).`), + canRetry: true, + }; +} + +export function PalletClusterError({ error }: { error: ClusterLoadError }) { + const router = useRouter(); + const retry = useMutation({ mutationFn: () => router.invalidate() }); + const { title, description, canRetry } = describeClusterLoadError( + error.cause, + ); + + return ( + +

{error.cluster}

+ +
+ +
+

{title}

+ + {description} + +
+ {canRetry ? ( + + ) : null} +
+
+ + ); +} + +function Frame({ children }: { children: ReactNode }) { + return ( +
+ +
+ {children} +
+
+
+ ); +} + +function nodeSize(id: Region["node_size"]) { + return ( + PALLET_NODE_SIZES.find((size) => size.id === id) ?? PALLET_NODE_SIZES[0] + ); +} + +function cloudLabel(id: Region["cloud"]) { + return PALLET_CLOUDS.find((cloud) => cloud.id === id)?.label ?? id; +} + +function formatVcpu(vcpu: number) { + return vcpu < 1 ? `1/${Math.round(1 / vcpu)}` : `${vcpu}`; +} + +function statusLabel(status: Status) { + if (status.deleting) return "Deleting"; + return status.ready ? "Running" : "Provisioning"; +} + +function statusDotClassName(status: Status) { + if (status.deleting) return "bg-destructive"; + return status.ready ? "bg-emerald-500" : "bg-amber-500 animate-pulse"; +} + +function StatusDot({ className }: { className: string }) { + return ( +