diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 88d066f6ef..5dc5ff2870 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -1,4 +1,8 @@ # Frontend -- When building new UI components, ship a Ladle story alongside the component (`*.stories.tsx` next to the source). Cover the meaningful states (empty, loading, error, success, edge cases) so the component can be reviewed and iterated on in isolation without booting the whole dashboard. Run with `pnpm dev:ladle` from `frontend/`. Existing example: [src/app/runner-pool-error-popover.stories.tsx](src/app/runner-pool-error-popover.stories.tsx). +- Ship a Ladle story alongside any new UI component, but only when the story would teach something a reader can't see from the component's source or the design system's existing stories. Run with `pnpm dev:ladle` from `frontend/`. Existing example: [src/app/runner-pool-error-popover.stories.tsx](src/app/runner-pool-error-popover.stories.tsx). + - **Story the integration unit, not the wrapper.** If a component is a thin wrapper over a primitive (e.g. a `` with three label variants), do not write a story for it — story the parent that combines it with other state. The interesting states live where data shapes interact: row + cell + tooltip, form + validation + submit. A trivial wrapper's "three near-identical renders" is noise, not coverage. + - **Drive stories from realistic data fixtures, not prop permutations.** Build fixtures that mirror real API responses (empty result, single item, multi-region, partial failure, mixed kinds). Each story should answer "what does this look like when the backend returns X?" Listing every prop combination produces stories that pass design review but miss bugs like an empty endpoint set falling through to "Multiple endpoints". + - **Cover the states that produced real bugs in this component.** When you fix a visual bug, the regression case becomes a story. If you can't articulate a state that would change behavior, you don't need another story. + - **Skip the story if it would require mocking route loaders, auth, or the full data-provider stack.** Either refactor the component to accept its inputs as props (preferred — the story falls out for free), or test it through the parent route in the running dashboard. Do not stub `useLoaderData` / `useRouteContext` inside a story; that path rots fast. - HTTP responses can be mocked end-to-end in dev via MSW. Append `?mock=1` to any dashboard URL (dev only, gated by `import.meta.env.DEV` in [src/lib/agent-mocks.ts](src/lib/agent-mocks.ts)) to boot the worker. Then in DevTools / agent-browser console: `window.__rivetMock("*/actors/:id/kv/keys/*", { status: 503, body: { group: "guard", code: "service_unavailable", message: "..." } })`. Mocks persist across reloads via sessionStorage; `window.__rivetClearMocks()` resets. Use this to exercise error UIs without standing up real engine state. Prod bundle is unaffected (dynamic `import("msw/browser")` behind the dev gate). diff --git a/frontend/src/app/dialogs/edit-runner-config.tsx b/frontend/src/app/dialogs/edit-runner-config.tsx index 598889cadc..9b78be53e2 100644 --- a/frontend/src/app/dialogs/edit-runner-config.tsx +++ b/frontend/src/app/dialogs/edit-runner-config.tsx @@ -242,15 +242,14 @@ function describeSwitches(switches: ModeSwitch[]): string { } function labelForMode(mode: RuntimeMode): string { - return mode === "serverless" ? "Serverless" : "Runners"; + return mode === "serverless" ? "Serverless" : "Runner"; } function ServerfullModeNotice() { return (
- This is a serverfull (Runners) configuration. Runners connect to - Rivet directly using the runner SDK. No additional configuration is - required here.{" "} + This is a Runner configuration. Runners connect to Rivet directly + using the runner SDK. No additional configuration is required here.{" "} (null); const turnstileSiteKey = cloudEnv().VITE_APP_TURNSTILE_SITE_KEY; @@ -68,7 +70,7 @@ export function Login() { setTurnstileToken(null); const [error] = await attemptAsync( - async () => await redirectToOrganization(), + async () => await redirectToOrganization({ from }), ); if (error && isRedirect(error)) { @@ -147,12 +149,15 @@ export function Login() { export function LoginWithGoogle() { const form = useFormContext(); + const { from } = useSearch({ strict: false }) as { from?: string }; const { isPending, mutate } = useMutation({ mutationFn: async () => { + const callbackPath = + from && from.startsWith("/") && !from.startsWith("//") ? from : "/"; return authClient.signIn.social({ provider: "google", - callbackURL: `${window.location.origin}/`, + callbackURL: `${window.location.origin}${callbackPath}`, }); }, onSettled(response) { diff --git a/frontend/src/app/runner-config-table.stories.tsx b/frontend/src/app/runner-config-table.stories.tsx new file mode 100644 index 0000000000..a156371383 --- /dev/null +++ b/frontend/src/app/runner-config-table.stories.tsx @@ -0,0 +1,389 @@ +import type { Story } from "@ladle/react"; +import type { Rivet } from "@rivetkit/engine-api-full"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import "../../.ladle/ladle.css"; +import { TooltipProvider } from "@/components"; +import { + getRegionLabel, + RegionIcon, +} from "@/components/matchmaker/lobby-region"; +import { RunnerConfigsTable } from "./runner-config-table"; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, +}); + +function Frame({ children }: { children: React.ReactNode }) { + return ( + + +
+
+ {children} +
+
+
+
+ ); +} + +const serverless = ( + url: string, + provider: string, + extra: Partial = {}, +): Rivet.RunnerConfigResponse => ({ + serverless: { + url, + headers: {}, + requestLifespan: 300, + runnersMargin: 1, + minRunners: 0, + maxRunners: 10, + slotsPerRunner: 100, + } as Rivet.RunnerConfigServerless, + metadata: { provider }, + ...extra, +}); + +const serverful = ( + provider: string, + extra: Partial = {}, +): Rivet.RunnerConfigResponse => ({ + normal: {}, + metadata: { provider }, + ...extra, +}); + +const renderRegion = ( + regionId: string, + { abbreviated }: { abbreviated?: boolean }, +) => ( + + + + {abbreviated ? regionId.toUpperCase() : getRegionLabel(regionId)} + + +); + +const tableProps = { + renderRegion, + onEditConfig: (name: string) => console.log("edit", name), + onDeleteConfig: (name: string) => console.log("delete", name), +}; + +export const SingleProviderServerlessOneEndpoint: Story = () => ( + + + +); + +export const SingleProviderServerlessMultipleEndpoints: Story = () => ( + + + +); + +export const AllServerful: Story = () => ( + + + +); + +export const MixedKinds: Story = () => ( + + + +); + +export const SameProviderMixedKinds: Story = () => ( + + + +); + +export const MultipleProviders: Story = () => ( + + + +); + +export const GlobalDeployment: Story = () => ( + + + +); + +export const WithRunnerPoolError: Story = () => ( + + + +); + +export const Loading: Story = () => ( + + + +); + +export const Empty: Story = () => ( + + + +); + +export const Error: Story = () => ( + + + +); + +export const Gallery: Story = () => ( + + + +); diff --git a/frontend/src/app/runner-config-table.tsx b/frontend/src/app/runner-config-table.tsx index c575b8137a..00bc98e4b0 100644 --- a/frontend/src/app/runner-config-table.tsx +++ b/frontend/src/app/runner-config-table.tsx @@ -12,8 +12,7 @@ import { Icon, } from "@rivet-gg/icons"; import type { Rivet } from "@rivetkit/engine-api-full"; -import { useInfiniteQuery } from "@tanstack/react-query"; -import { useNavigate } from "@tanstack/react-router"; +import type { ReactNode } from "react"; import { useMemo } from "react"; import { Button, @@ -22,8 +21,6 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - formatList, - Ping, Skeleton, Table, TableBody, @@ -34,11 +31,7 @@ import { Text, WithTooltip, } from "@/components"; -import { - ActorRegion, - useEngineCompatDataProvider, -} from "@/components/actors"; -import { REGION_LABEL } from "@/components/matchmaker/lobby-region"; +import { Badge } from "@/components/ui/badge"; import { deriveProviderFromMetadata } from "@/lib/data"; import type { RivetActorError } from "@/queries/types"; import { RunnerPoolErrorPopover } from "./runner-pool-error-popover"; @@ -49,6 +42,10 @@ interface RunnerConfigsTableProps { hasNextPage?: boolean; fetchNextPage?: () => void; configs: [string, Rivet.RunnerConfigsListResponseRunnerConfigsValue][]; + totalDatacenterCount?: number; + renderRegion: (regionId: string, opts: { abbreviated?: boolean }) => ReactNode; + onEditConfig: (name: string) => void; + onDeleteConfig: (name: string) => void; } export function RunnerConfigsTable({ @@ -57,6 +54,10 @@ export function RunnerConfigsTable({ hasNextPage, fetchNextPage, configs, + totalDatacenterCount, + renderRegion, + onEditConfig, + onDeleteConfig, }: RunnerConfigsTableProps) { return ( @@ -102,7 +103,15 @@ export function RunnerConfigsTable({ ) : null} {configs?.map(([id, config]) => ( - + ))} {!isLoading && hasNextPage ? ( @@ -149,10 +158,18 @@ function RowSkeleton() { function Row({ name, + totalDatacenterCount, + renderRegion, + onEditConfig, + onDeleteConfig, ...value -}: { name: string } & Rivet.RunnerConfigsListResponseRunnerConfigsValue) { - const navigate = useNavigate(); - +}: { + name: string; + totalDatacenterCount?: number; + renderRegion: (regionId: string, opts: { abbreviated?: boolean }) => ReactNode; + onEditConfig: (name: string) => void; + onDeleteConfig: (name: string) => void; +} & Rivet.RunnerConfigsListResponseRunnerConfigsValue) { const datacenters = Object.entries(value.datacenters); const isManaged = datacenters.some( @@ -163,18 +180,31 @@ function Row({ return ( - + {name} - + - + - + @@ -187,30 +217,14 @@ function Row({ {isManaged ? null : ( } - onSelect={() => - navigate({ - to: ".", - search: { - modal: "edit-provider-config", - config: name, - }, - }) - } + onSelect={() => onEditConfig(name)} > Edit )} } - onSelect={() => - navigate({ - to: ".", - search: { - modal: "delete-provider-config", - config: name, - }, - }) - } + onSelect={() => onDeleteConfig(name)} > Delete @@ -223,8 +237,10 @@ function Row({ function StatusCell({ datacenters, + renderRegion, }: { datacenters: Record; + renderRegion: (regionId: string, opts: { abbreviated?: boolean }) => ReactNode; }) { const errors = useMemo(() => { const errorMap: Record = {}; @@ -241,7 +257,10 @@ function StatusCell({ if (!errors) { return ( - + + + + ); } @@ -251,63 +270,161 @@ function StatusCell({ ( - - )} + renderRegion={(regionId) => + renderRegion(regionId, { abbreviated: true }) + } /> ); } -function Providers({ +const PROVIDER_LABELS: Record = { + vercel: "Vercel", + "next-js": "Next.js", + railway: "Railway", + hetzner: "Hetzner", + aws: "AWS ECS", + gcp: "Google Cloud Run", + "gcp-cloud-run": "Google Cloud Run", + rivet: "Rivet", +}; + +function getProviderLabel(provider: string | undefined): string { + if (!provider) return "Unknown"; + return PROVIDER_LABELS[provider] ?? provider; +} + +type RunnerKind = "serverless" | "runner"; + +function getDatacenterKind( + config: Rivet.RunnerConfigResponse, +): RunnerKind { + return config.serverless ? "serverless" : "runner"; +} + +function ProviderSummary({ datacenters, + renderRegion, }: { datacenters: [string, Rivet.RunnerConfigResponse][]; + renderRegion: (regionId: string, opts: { abbreviated?: boolean }) => ReactNode; }) { - const providers = useMemo(() => { - const providerSet = new Set(); - for (const [, config] of datacenters) { - const providerName = - deriveProviderFromMetadata(config.metadata) || "unknown"; - providerSet.add(providerName); - } - return Array.from(providerSet); + const breakdown = useMemo(() => { + const rows = datacenters.map(([dc, config]) => ({ + dc, + provider: deriveProviderFromMetadata(config.metadata) || "unknown", + kind: getDatacenterKind(config), + })); + const providers = new Set(rows.map((r) => r.provider)); + const kinds = new Set(rows.map((r) => r.kind)); + return { rows, providers, kinds }; }, [datacenters]); - if (providers.length === 1) { - return ; + if (breakdown.rows.length === 0) return null; + + const isUniform = + breakdown.providers.size === 1 && breakdown.kinds.size === 1; + + if (isUniform) { + const kind = breakdown.kinds.values().next().value as RunnerKind; + return ( +
+ + + {kind} + +
+ ); } + const providerNode = + breakdown.providers.size === 1 ? ( + + ) : ( + Multiple + ); + + const kindLabel = + breakdown.kinds.size === 1 + ? (breakdown.kinds.values().next().value as RunnerKind) + : "Mixed"; + + const showProviderInTooltip = breakdown.providers.size > 1; + const showKindInTooltip = breakdown.kinds.size > 1; + return ( Multiple providers} + content={ +
    + {breakdown.rows.map(({ dc, provider, kind }) => ( +
  • + {renderRegion(dc, { abbreviated: false })} + {showProviderInTooltip ? ( + <> + · + + + ) : null} + {showKindInTooltip ? ( + <> + · + {kind} + + ) : null} +
  • + ))} +
+ } + trigger={ +
+ {providerNode} + + {kindLabel} + +
+ } /> ); } +function truncateEndpoint(endpoint: string): string { + if (endpoint.length > 32) { + return `${endpoint.slice(0, 16)}...${endpoint.slice(-16)}`; + } + return endpoint; +} + function Endpoints({ datacenters, + renderRegion, }: { datacenters: [string, Rivet.RunnerConfigResponse][]; + renderRegion: (regionId: string, opts: { abbreviated?: boolean }) => ReactNode; }) { - const endpoints = useMemo(() => { - const endpointSet = new Set(); - for (const [, config] of datacenters) { - if (config.serverless?.url) { - endpointSet.add(config.serverless.url); - } - } - return Array.from(endpointSet); + const perDatacenter = useMemo(() => { + return datacenters + .filter(([, config]) => config.serverless?.url) + .map(([dc, config]) => [dc, config.serverless!.url] as const); }, [datacenters]); - if (endpoints.length === 1) { - const endpoint = endpoints[0]; + const uniqueEndpoints = useMemo( + () => new Set(perDatacenter.map(([, url]) => url)), + [perDatacenter], + ); + + if (perDatacenter.length === 0) { + return ( + + — + + ); + } + + if (uniqueEndpoints.size === 1) { + const endpoint = perDatacenter[0][1]; return ( - {endpoint && endpoint.length > 32 - ? `${endpoint.slice(0, 16)}...${endpoint.slice(-16)}` - : endpoint || "-"} + {truncateEndpoint(endpoint)} ); } @@ -315,116 +432,91 @@ function Endpoints({ return ( -

Endpoints:

-
    - {endpoints.map((endpoint) => ( -
  • - - - {endpoint && endpoint.length > 32 - ? `${endpoint.slice(0, 16)}...${endpoint.slice(-16)}` - : endpoint || "-"} - - -
  • - ))} -
- +
    + {perDatacenter.map(([dc, endpoint]) => ( +
  • + {renderRegion(dc, { abbreviated: false })} + · + + {truncateEndpoint(endpoint)} + +
  • + ))} +
+ } + trigger={ + + Multiple endpoints + } - trigger={Multiple endpoints} /> ); } -function Provider({ metadata }: { metadata: unknown }) { - const provider = deriveProviderFromMetadata(metadata); +const PROVIDER_ICONS: Record = { + vercel: faVercel, + "next-js": faNextjs, + railway: faRailway, + hetzner: faHetznerH, + aws: faAws, + gcp: faGoogleCloud, + "gcp-cloud-run": faGoogleCloud, + rivet: faRivet, +}; - if (provider === "vercel") { - return ( -
- Vercel -
- ); - } - if (provider === "next-js") { - return ( -
- Next.js -
- ); - } - if (provider === "railway") { - return ( -
- Railway -
- ); - } - if (provider === "hetzner") { - return ( -
- Hetzner -
- ); - } - if (provider === "aws") { - return ( -
- AWS ECS -
- ); - } - if (provider === "gcp" || provider === "gcp-cloud-run") { - return ( -
- Google Cloud Run -
- ); - } - if (provider === "rivet") { - return ( -
- Rivet -
- ); +function ProviderInline({ provider }: { provider: string | undefined }) { + const icon = provider ? PROVIDER_ICONS[provider] : undefined; + const label = getProviderLabel(provider); + + if (!icon) { + return {label}; } - return {provider || "Unknown"}; + + return ( + + + {label} + + ); } -function Regions({ regions }: { regions: string[] }) { - const { data: datacentersCount } = useInfiniteQuery({ - ...useEngineCompatDataProvider().datacentersQueryOptions(), - maxPages: Infinity, - select: (data) => - data.pages.reduce((acc, page) => acc + page.datacenters?.length, 0), - }); +function Provider({ metadata }: { metadata: unknown }) { + return ; +} - if (regions.length === datacentersCount) { +function Regions({ + regions, + totalDatacenterCount, + renderRegion, +}: { + regions: string[]; + totalDatacenterCount?: number; + renderRegion: (regionId: string, opts: { abbreviated?: boolean }) => ReactNode; +}) { + if ( + totalDatacenterCount !== undefined && + regions.length === totalDatacenterCount + ) { return Global; } if (regions.length === 1) { - return ( - - ); + return <>{renderRegion(regions[0], {})}; } return ( REGION_LABEL[region] ?? REGION_LABEL.unknown, - ), - )} + content={ +
    + {regions.map((region) => ( +
  • {renderRegion(region, {})}
  • + ))} +
+ } trigger={ Multiple regions } diff --git a/frontend/src/app/runner-config-toggle-group.tsx b/frontend/src/app/runner-config-toggle-group.tsx index 55919e1d1c..5b3e70e4ac 100644 --- a/frontend/src/app/runner-config-toggle-group.tsx +++ b/frontend/src/app/runner-config-toggle-group.tsx @@ -41,7 +41,7 @@ export function RunnerConfigToggleGroup({ value="serverfull" className="border-l rounded-none w-full" > - Runners + Runner diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 795c821e78..e3e37a5c18 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -16,11 +16,25 @@ type AuthClient = ReturnType; export const authClient: AuthClient = features.auth ? createClient() : (null as unknown as AuthClient); +const isSafeInternalPath = (path: string | undefined): path is string => { + if (!path) return false; + if (!path.startsWith("/")) return false; + if (path.startsWith("//")) return false; + if (path.startsWith("/login")) return false; + if (path.startsWith("/join")) return false; + if (path.startsWith("/verify-email-pending")) return false; + if (path.startsWith("/forgot-password")) return false; + return true; +}; + export const redirectToOrganization = async ( { from }: { from?: string } = {}, ) => { const session = await authClient.getSession(); if (session.data) { + if (isSafeInternalPath(from)) { + throw redirect({ to: from }); + } if (session.data.session.activeOrganizationId) { const org = await authClient.organization.getFullOrganization({ @@ -33,7 +47,6 @@ export const redirectToOrganization = async ( throw redirect({ to: "/orgs/$organization", - search: from ? { from } : undefined, params: { organization: org.data.slug }, }); } @@ -48,7 +61,6 @@ export const redirectToOrganization = async ( }); throw redirect({ to: "/orgs/$organization", - search: from ? { from } : undefined, params: { organization: orgs.data[0].slug }, }); } diff --git a/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace/settings.tsx b/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace/settings.tsx index d03471a148..5f3db3d065 100644 --- a/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace/settings.tsx +++ b/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace/settings.tsx @@ -139,6 +139,8 @@ function NoRunnersAlert() { } function Providers() { + const dataProvider = useEngineCompatDataProvider(); + const navigate = useNavigate(); const { isLoading, isError, @@ -146,10 +148,17 @@ function Providers() { hasNextPage, fetchNextPage, } = useInfiniteQuery({ - ...useEngineCompatDataProvider().runnerConfigsQueryOptions(), + ...dataProvider.runnerConfigsQueryOptions(), refetchInterval: 5000, }); + const { data: totalDatacenterCount } = useInfiniteQuery({ + ...dataProvider.datacentersQueryOptions(), + maxPages: Infinity, + select: (data) => + data.pages.reduce((acc, page) => acc + page.datacenters?.length, 0), + }); + return (
@@ -177,6 +186,32 @@ function Providers() { configs={configs || []} fetchNextPage={fetchNextPage} hasNextPage={hasNextPage} + totalDatacenterCount={totalDatacenterCount} + renderRegion={(regionId, { abbreviated }) => ( + + )} + onEditConfig={(name) => + navigate({ + to: ".", + search: { + modal: "edit-provider-config", + config: name, + }, + }) + } + onDeleteConfig={(name) => + navigate({ + to: ".", + search: { + modal: "delete-provider-config", + config: name, + }, + }) + } />