diff --git a/frontend/src/app/pool-switcher.stories.tsx b/frontend/src/app/pool-switcher.stories.tsx new file mode 100644 index 0000000000..c3798ab0bf --- /dev/null +++ b/frontend/src/app/pool-switcher.stories.tsx @@ -0,0 +1,105 @@ +import type { Story } from "@ladle/react"; +import { useState } from "react"; +import "../../.ladle/ladle.css"; +import { + PoolSwitcher, + type PoolSwitcherPool, + poolHeaderText, +} from "./pool-switcher"; + +function Frame({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +// Mirrors how a page composes the pool-scoped title with the switcher: the +// switcher only renders when there is more than one pool, otherwise the title +// stays generic. This is the real integration unit, not the switcher alone. +function TitleWithSwitcher({ + pools, + suffix, + generic, +}: { + pools: PoolSwitcherPool[]; + suffix: string; + generic: string; +}) { + const [value, setValue] = useState(pools[0]?.name ?? "default"); + return ( +
+

+ {poolHeaderText(pools, suffix, generic)} +

+ {pools.length > 1 ? ( + + ) : null} +
+ ); +} + +const SINGLE: PoolSwitcherPool[] = [ + { name: "default", config: { displayName: "Default" } }, +]; + +const TWO: PoolSwitcherPool[] = [ + { name: "default", config: { displayName: "Default" } }, + { name: "backend", config: { displayName: "Backend" } }, +]; + +const MANY: PoolSwitcherPool[] = [ + { name: "default", config: { displayName: "Default" } }, + { name: "backend", config: { displayName: "Backend Workers" } }, + { name: "gpu", config: { displayName: "GPU Inference" } }, + { name: "batch", config: { displayName: "Batch Jobs" } }, + // No config: falls back to the machine name. + { name: "legacy-pool" }, +]; + +const LONG: PoolSwitcherPool[] = [ + { name: "default", config: { displayName: "Default" } }, + { + name: "long", + config: { + displayName: + "Extremely long managed pool display name that should truncate gracefully in both the title and the switcher list", + }, + }, +]; + +// Single pool: the switcher is not rendered and the title stays generic. +export const SinglePool: Story = () => ( + + + +); + +export const TwoPoolsLogs: Story = () => ( + + + +); + +export const TwoPoolsConfig: Story = () => ( + + + +); + +export const ManyPools: Story = () => ( + + + +); + +export const LongDisplayName: Story = () => ( + + + +); diff --git a/frontend/src/app/pool-switcher.tsx b/frontend/src/app/pool-switcher.tsx new file mode 100644 index 0000000000..161867031d --- /dev/null +++ b/frontend/src/app/pool-switcher.tsx @@ -0,0 +1,186 @@ +import { faCheck, Icon } from "@rivet-gg/icons"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/components/lib/utils"; + +/** + * Minimal shape of a managed pool needed to render the switcher. Kept + * structural (rather than importing the SDK type) so the component can be + * driven from fixtures in stories without the cloud data-provider stack. + */ +export interface PoolSwitcherPool { + name: string; + config?: { displayName?: string }; +} + +/** Human label for a pool, falling back to its machine name. */ +export function poolDisplayName(pool: PoolSwitcherPool): string { + return pool.config?.displayName || pool.name; +} + +/** + * Resolves which pool a surface should show given the `?pool=` search param and + * the available pools. Prefers the requested pool, then a pool literally named + * "default", then the first pool, then the "default" string as a last resort. + */ +export function resolvePoolName( + pools: PoolSwitcherPool[], + requested: unknown, +): string { + if ( + typeof requested === "string" && + pools.some((pool) => pool.name === requested) + ) { + return requested; + } + if (pools.some((pool) => pool.name === "default")) { + return "default"; + } + return pools[0]?.name ?? "default"; +} + +/** + * Static header text shown before the pool switcher button. The pool name lives + * in the switcher button itself, so the header reads e.g. "Pool Logs [Backend v]". + * With a single pool the switcher is hidden and the generic title ("Logs" / + * "Compute") is used instead. + */ +export function poolHeaderText( + pools: PoolSwitcherPool[], + suffix: string, + generic: string, +): string { + return pools.length <= 1 ? generic : `Pool ${suffix}`; +} + +// Up/down "unfold" chevron, matching the segment switchers in context-switcher. +function UnfoldIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +interface PoolSwitcherProps { + pools: PoolSwitcherPool[]; + value: string; + onChange: (poolName: string) => void; + className?: string; +} + +/** + * Compact switch button placed to the right of a pool-scoped title. Opens a + * searchable list of the namespace's managed pools. Callers should only render + * this when there is more than one pool. + */ +export function PoolSwitcher({ + pools, + value, + onChange, + className, +}: PoolSwitcherProps) { + const [open, setOpen] = useState(false); + // Controls which item cmdk highlights. Reset to the current pool each time + // the popover opens so it opens focused on the active selection. + const [commandValue, setCommandValue] = useState(value); + const selected = pools.find((pool) => pool.name === value); + const label = selected ? poolDisplayName(selected) : value; + + return ( + { + setOpen(next); + if (next) setCommandValue(value); + }} + > + + + + + + + + No pools found. + + {pools.map((pool) => { + const isCurrent = pool.name === value; + return ( + { + setOpen(false); + if (!isCurrent) { + onChange(pool.name); + } + }} + > + + + {poolDisplayName(pool)} + + + ); + })} + + + + + + ); +} diff --git a/frontend/src/app/settings-drawer.tsx b/frontend/src/app/settings-drawer.tsx index b728d7918d..edbbd128a5 100644 --- a/frontend/src/app/settings-drawer.tsx +++ b/frontend/src/app/settings-drawer.tsx @@ -27,6 +27,11 @@ import { } from "@/components/actors/data-provider"; import { features } from "@/lib/features"; import { BillingUsageGauge } from "./billing/billing-usage-gauge"; +import { + PoolSwitcher, + poolHeaderText, + resolvePoolName, +} from "./pool-switcher"; import { BillingPanel } from "./settings-pages/billing-panel"; import { NamespaceComputeContent } from "./settings-pages/namespace-compute"; import { @@ -166,6 +171,8 @@ export function SettingsDrawer({ const titleNode: ReactNode = activeTab === "settings" && onNamespace ? ( + ) : activeTab === "compute" && onNamespace ? ( + ) : activeTab === "billing" && onProject ? ( ) : ( @@ -421,6 +428,56 @@ function NamespaceSettingsTitleInner({ fallback }: { fallback: string }) { return <>{displayName ? `${displayName} settings` : fallback}; } +function ComputeSettingsTitle({ fallback }: { fallback: string }) { + // Same mid-transition guard as NamespaceSettingsTitle: the drawer can render + // this title while the namespace match tree is still resolving. The + // `dataProvider` check lives here (not in the inner component) so the inner + // component's hooks stay unconditional across namespace switches, matching + // the NamespaceComputeContent outer/inner split. + const match = useMatch({ + from: "/_context/orgs/$organization/projects/$project/ns/$namespace", + shouldThrow: false, + }); + const dataProvider = useCloudNamespaceDataProvider(); + if (!match || !match.loaderData || !dataProvider) { + return <>{fallback}; + } + return ; +} + +function ComputeSettingsTitleInner({ fallback }: { fallback: string }) { + const dataProvider = useCloudNamespaceDataProvider(); + const navigate = useNavigate(); + const { pool: poolParam } = useSearch({ strict: false }); + const { data: pools = [] } = useQuery( + dataProvider.currentNamespaceManagedPoolsQueryOptions(), + ); + const selectedPool = resolvePoolName(pools, poolParam); + + if (pools.length <= 1) { + return <>{fallback}; + } + + return ( + + {poolHeaderText(pools, "Config", fallback)} + + navigate({ + to: ".", + search: (old) => ({ + ...(old as Record), + pool: name, + }), + }) + } + /> + + ); +} + function ProjectBillingTitle({ fallback }: { fallback: string }) { const match = useMatch({ from: "/_context/orgs/$organization/projects/$project", diff --git a/frontend/src/app/settings-pages/namespace-compute.tsx b/frontend/src/app/settings-pages/namespace-compute.tsx index 3abb27e6ea..6610a81a9f 100644 --- a/frontend/src/app/settings-pages/namespace-compute.tsx +++ b/frontend/src/app/settings-pages/namespace-compute.tsx @@ -1,9 +1,11 @@ import type { Rivet } from "@rivet-gg/cloud"; import { faCircleExclamation, Icon } from "@rivet-gg/icons"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useSearch } from "@tanstack/react-router"; import { type ReactNode, useEffect } from "react"; import { useFormState } from "react-hook-form"; import z from "zod"; +import { resolvePoolName } from "@/app/pool-switcher"; import { Alert, AlertDescription, @@ -206,13 +208,19 @@ export function NamespaceComputeContent() { function NamespaceComputeContentInner() { const dataProvider = useCloudNamespaceDataProvider(); + const { pool: poolParam } = useSearch({ strict: false }); + const { data: pools = [] } = useQuery( + dataProvider.currentNamespaceManagedPoolsQueryOptions(), + ); + const selectedPool = resolvePoolName(pools, poolParam); + const { data: pool, isPending, isError, } = useQuery({ ...dataProvider.currentNamespaceManagedPoolQueryOptions({ - pool: "default", + pool: selectedPool, }), // Poll while a deploy is in flight so the footer button tracks the // pool status; back off entirely once the pool settles. @@ -246,7 +254,11 @@ function NamespaceComputeContentInner() { } return ( + // Remount on pool switch; RHF only reads defaultValues at mount, so + // without this the form keeps the previous pool's values. { + const validated = searchSchema.parse(search); + // `pool` is scoped to the pages that actually use it: the Logs route + // re-declares it in its own validateSearch, and the compute settings tab + // needs it while open. Drop it everywhere else so the selected pool does + // not cling to unrelated pages after navigating away. + if (validated.settings !== "compute") { + delete (validated as { pool?: string }).pool; + } + return validated; + }, context: ({ context }) => { if (features.platform) { return { diff --git a/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace/logs.tsx b/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace/logs.tsx index 3e4aa9c06d..3d33952dc7 100644 --- a/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace/logs.tsx +++ b/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace/logs.tsx @@ -1,10 +1,20 @@ import type { Rivet } from "@rivet-gg/cloud"; import { faPause, faPlay, Icon } from "@rivet-gg/icons"; import { useInfiniteQuery, useSuspenseQuery } from "@tanstack/react-query"; -import { createFileRoute, Link, redirect } from "@tanstack/react-router"; +import { + createFileRoute, + Link, + redirect, + useNavigate, +} from "@tanstack/react-router"; import { startTransition, useRef, useState } from "react"; import { z } from "zod"; import { Content } from "@/app/layout"; +import { + PoolSwitcher, + poolHeaderText, + resolvePoolName, +} from "@/app/pool-switcher"; import { Button, H1, Skeleton } from "@/components"; import { useCloudNamespaceDataProvider, @@ -22,6 +32,7 @@ export const Route = createFileRoute( )({ validateSearch: z.object({ search: z.string().optional(), + pool: z.string().optional(), }), component: RouteComponent, beforeLoad: ({ params }) => { @@ -32,11 +43,17 @@ export const Route = createFileRoute( }); } }, - loader: async ({ context }) => { + loaderDeps: ({ search }) => ({ pool: search.pool }), + loader: async ({ context, deps }) => { const dataProvider = context.dataProvider; + // Prefetch the pool resolved from `?pool=` so a deep link to a non-default + // pool doesn't refetch client-side after the loader settles. + const pools = await context.queryClient.ensureQueryData( + dataProvider.currentNamespaceManagedPoolsQueryOptions(), + ); await context.queryClient.prefetchQuery( dataProvider.currentNamespaceManagedPoolQueryOptions({ - pool: "default", + pool: resolvePoolName(pools, deps.pool), safe: true, }), ); @@ -45,12 +62,20 @@ export const Route = createFileRoute( }); function RouteComponent() { - const { namespace, project } = Route.useParams(); + const params = Route.useParams(); + const { namespace, project } = params; const dataProvider = useCloudNamespaceDataProvider(); + const navigate = useNavigate(); + + const { data: pools = [] } = useSuspenseQuery( + dataProvider.currentNamespaceManagedPoolsQueryOptions(), + ); + const { pool: poolParam, search: initialSearch } = Route.useSearch(); + const selectedPool = resolvePoolName(pools, poolParam); const { data: pool } = useSuspenseQuery( dataProvider.currentNamespaceManagedPoolQueryOptions({ - pool: "default", + pool: selectedPool, safe: true, }), ); @@ -65,7 +90,6 @@ function RouteComponent() { 0, ); - const { search: initialSearch } = Route.useSearch(); const [search, setSearch] = useState(initialSearch ?? ""); const [isPaused, setIsPaused] = useState(false); const [region, setRegion] = useState("all"); @@ -81,7 +105,7 @@ function RouteComponent() {