diff --git a/frontend/src/app/data-providers/cloud-data-provider.tsx b/frontend/src/app/data-providers/cloud-data-provider.tsx index fc13152ff6..ec8b370fb3 100644 --- a/frontend/src/app/data-providers/cloud-data-provider.tsx +++ b/frontend/src/app/data-providers/cloud-data-provider.tsx @@ -180,7 +180,11 @@ export const createGlobalContext = () => { org: opts.organization, }, ); - return response.managedPools; + // Hide tearing-down pools from switchers and resolution, + // matching `rivet pool list`. + return response.managedPools.filter( + (pool) => pool.status !== "destroying", + ); }, ...no404Retry(), }); @@ -1186,6 +1190,25 @@ export const createProjectContext = ({ }, }); }, + deleteCurrentProjectManagedPoolMutationOptions() { + return mutationOptions({ + mutationKey: [organization, project, "managed-pool", "delete"], + mutationFn: async ({ + namespace, + pool, + }: { + namespace: string; + pool: string; + }) => { + return await client.managedPools.delete( + project, + namespace, + pool, + { org: organization }, + ); + }, + }); + }, }; }; @@ -1408,5 +1431,21 @@ export const createNamespaceContext = ({ }, }); }, + + deleteCurrentNamespaceManagedPoolMutationOptions() { + return mutationOptions({ + mutationKey: + parent.deleteCurrentProjectManagedPoolMutationOptions() + .mutationKey, + mutationFn: async ({ pool }: { pool: string }) => { + return await parent.client.managedPools.delete( + parent.project, + namespace, + pool, + { org: parent.organization }, + ); + }, + }); + }, }; }; 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..149be9e6d3 100644 --- a/frontend/src/app/settings-pages/namespace-compute.tsx +++ b/frontend/src/app/settings-pages/namespace-compute.tsx @@ -1,13 +1,16 @@ import type { Rivet } from "@rivet-gg/cloud"; -import { faCircleExclamation, Icon } from "@rivet-gg/icons"; +import { faCircleExclamation, faTrash, Icon } from "@rivet-gg/icons"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { type ReactNode, useEffect } from "react"; +import { useNavigate, useSearch } from "@tanstack/react-router"; +import { type ReactNode, useEffect, useState } from "react"; import { useFormState } from "react-hook-form"; import z from "zod"; +import { resolvePoolName } from "@/app/pool-switcher"; import { Alert, AlertDescription, AlertTitle, + Button, Code, cn, createSchemaForm, @@ -23,6 +26,7 @@ import { import { useCloudNamespaceDataProvider } from "@/components/actors"; import { Slider } from "@/components/ui/slider"; import { Switch } from "@/components/ui/switch"; +import { features } from "@/lib/features"; import { SettingsCard } from "./settings-card"; const MEMORY_RE = /^(\d+)(Mi|Gi)$/; @@ -206,13 +210,25 @@ 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); + // Only non-default pools are deletable (the default anchors resolution) and + // only when more than one exists; gated on danger-zone like other + // destructive surfaces. + const defaultPool = resolvePoolName(pools, undefined); + const canDeletePool = + features.dangerZone && pools.length > 1 && selectedPool !== defaultPool; + 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 +262,13 @@ function NamespaceComputeContentInner() { } return ( + // Remount on pool switch; RHF only reads defaultValues at mount, so + // without this the form keeps the previous pool's values. { + // Switch back to the default pool before the deleted pool's query + // 404s, then refresh the list so the switcher drops it. + await navigate({ + to: ".", + search: (old) => ({ + ...(old as Record), + pool: defaultPool, + }), + }); + await queryClient.invalidateQueries( + dataProvider.currentNamespaceManagedPoolsQueryOptions(), + ); + }, + }); + + // Reset the confirm prompt if the user walks away without confirming, + // matching the actor destroy button's dwell window. + useEffect(() => { + if (!isConfirming) return; + const timer = setTimeout(() => setIsConfirming(false), 4000); + return () => clearTimeout(timer); + }, [isConfirming]); + + return ( + + ); +} + function isPoolBusy( status: Rivet.ManagedPoolsGetResponse.ManagedPool.Status | undefined, ) { @@ -286,10 +373,16 @@ function PoolErrorAlert({ } function ComputeForm({ + pool, + defaultPool, + canDelete, config, status, error, }: { + pool: string; + defaultPool: string; + canDelete: boolean; config: Rivet.ManagedPoolsGetResponse.ManagedPool.Config; status: Rivet.ManagedPoolsGetResponse.ManagedPool.Status; error: Rivet.ManagedPoolsGetResponse.ManagedPool.Error_ | undefined; @@ -358,7 +451,7 @@ function ComputeForm({ overrides.args = args.length === 0 ? null : args; } await mutateAsync({ - pool: "default", + pool, displayName: config.displayName || "Default", runnerConfig: { maxConcurrentActors: values.maxConcurrentActors, @@ -380,7 +473,7 @@ function ComputeForm({ }); await queryClient.invalidateQueries( dataProvider.currentNamespaceManagedPoolQueryOptions({ - pool: "default", + pool, }), ); form.reset(values); @@ -449,37 +542,69 @@ function ComputeForm({ label="Drain on version upgrade" /> - + ); } -// Renders the discard/deploy controls once the form has edits or a deploy is -// in flight, so the tab reads as a plain settings sheet otherwise. While the -// pool works through a deploy the submit button is locked and tracks the -// polled pool status. +// Delete/discard/deploy controls. The deploy side only shows once the form is +// dirty or a deploy is in flight; the delete button sits on the left. function DeployFooter({ status, + pool, + defaultPool, + canDelete, }: { status: Rivet.ManagedPoolsGetResponse.ManagedPool.Status; + pool: string; + defaultPool: string; + canDelete: boolean; }) { const { isDirty } = useFormState(); + + // A tearing-down pool is not a deploy: show "Deleting..." instead of the + // deploy button so the footer never reads "Deploying..." during teardown. + if (status === "destroying") { + return ( +
+ + Deleting... + +
+ ); + } + const busy = isPoolBusy(status); - if (!isDirty && !busy) return null; + const showDeploy = isDirty || busy; + if (!showDeploy && !canDelete) return null; return ( -
- {isDirty ? ( - - Discard - +
+ {canDelete ? ( + + ) : ( + + )} + {showDeploy ? ( +
+ {isDirty ? ( + + Discard + + ) : null} + + {busy ? "Deploying..." : "Deploy changes"} + +
) : null} - - {busy ? "Deploying..." : "Deploy changes"} -
); } diff --git a/frontend/src/components/actors/actor-details-shared.tsx b/frontend/src/components/actors/actor-details-shared.tsx index e358b4cf2e..c0c4885513 100644 --- a/frontend/src/components/actors/actor-details-shared.tsx +++ b/frontend/src/components/actors/actor-details-shared.tsx @@ -7,7 +7,10 @@ import { } from "@/components/deployment-logs"; import { features } from "@/lib/features"; import { ActorConfigTab } from "./actor-config-tab"; -import { useCloudNamespaceDataProvider } from "./data-provider"; +import { + useCloudNamespaceDataProvider, + useDataProvider, +} from "./data-provider"; import type { InspectorTabDescriptor } from "./inspector-tab-registry"; import type { ActorId } from "./queries"; @@ -62,7 +65,13 @@ export function useHasManagedPool(): boolean { */ function DeploymentLogsTab({ actorId }: { actorId: ActorId }) { const provider = useCloudNamespaceDataProvider(); + const { data: actor } = useQuery( + useDataProvider().actorQueryOptions(actorId), + ); const logsRef = useRef([]); + // The actor's pool is its runner name selector; fall back to the default + // pool until the actor loads or when it has no explicit selector. + const pool = actor?.runnerNameSelector || "default"; return (
@@ -76,7 +85,7 @@ function DeploymentLogsTab({ actorId }: { actorId: ActorId }) { diff --git a/frontend/src/components/actors/dialogs/create-actor-dialog.tsx b/frontend/src/components/actors/dialogs/create-actor-dialog.tsx index 253d60ff14..c520228d8f 100644 --- a/frontend/src/components/actors/dialogs/create-actor-dialog.tsx +++ b/frontend/src/components/actors/dialogs/create-actor-dialog.tsx @@ -46,6 +46,8 @@ export default function CreateActorDialog() { }); const { copy } = useActorsView(); + const defaultRunnerNameSelector = + ActorCreateForm.useDefaultRunnerNameSelector(); return ( - ) : null} + ) : ( + + )} diff --git a/frontend/src/components/actors/dialogs/create-actor-sheet.tsx b/frontend/src/components/actors/dialogs/create-actor-sheet.tsx index 844274d753..e940e4ad7b 100644 --- a/frontend/src/components/actors/dialogs/create-actor-sheet.tsx +++ b/frontend/src/components/actors/dialogs/create-actor-sheet.tsx @@ -56,6 +56,8 @@ export function CreateActorSheet({ }); const { copy } = useActorsView(); const [advancedOpen, setAdvancedOpen] = useState(false); + const defaultRunnerNameSelector = + ActorCreateForm.useDefaultRunnerNameSelector(); const isAgentOs = variant === "agent-os"; const title = isAgentOs @@ -96,7 +98,8 @@ export function CreateActorSheet({ key: values.key, datacenter: values.datacenter, runnerNameSelector: - values.runnerNameSelector || "default", + values.runnerNameSelector || + defaultRunnerNameSelector, crashPolicy: "destroy", }); }} @@ -224,7 +227,9 @@ function AdvancedFields() { - ) : null} + ) : ( + + )}
); diff --git a/frontend/src/components/actors/form/actor-create-form.tsx b/frontend/src/components/actors/form/actor-create-form.tsx index 839f4646b1..6f735c1b99 100644 --- a/frontend/src/components/actors/form/actor-create-form.tsx +++ b/frontend/src/components/actors/form/actor-create-form.tsx @@ -7,7 +7,7 @@ import { import { useEffect, useRef } from "react"; import { type UseFormReturn, useFormContext } from "react-hook-form"; import z from "zod"; -import { CodePreview, Input, Label } from "@/components"; +import { CodePreview, Combobox, Input, Label } from "@/components"; import { JsonCode } from "../../code-mirror"; import { createSchemaForm } from "../../lib/create-schema-form"; import { @@ -168,6 +168,118 @@ export const RunnerNameSelector = () => { ); }; +const selectRunnerConfigKeys = (data: { + pages: { runnerConfigs: Record }[]; +}) => data.pages.flatMap((page) => Object.keys(page.runnerConfigs)); + +const emptyRunnerConfigKeysQueryOptions = infiniteQueryOptions({ + queryKey: ["noop-runner-config-keys"] as readonly unknown[], + queryFn: async (): Promise => ({ + runnerConfigs: {}, + pagination: {}, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: () => undefined, + select: selectRunnerConfigKeys, +}); + +// The pool the create form defaults to: the runner config named "default" when +// present, otherwise the first one. Undefined when there are no runner configs. +function resolveDefaultPool(keys: string[]): string | undefined { + if (keys.length === 0) return undefined; + return keys.includes("default") ? "default" : keys[0]; +} + +// Loads the namespace's runner-config keys (the pool names) and the resolved +// default pool. Pages are fetched lazily via `fetchNextPage` (the Pool combobox +// loads more on scroll); empty on providers without runner configs. +function useRunnerConfigKeys() { + const dataProvider = useEngineCompatDataProvider(); + const hasRunnerConfigs = "runnerConfigsQueryOptions" in dataProvider; + const { + data: keys = [], + hasNextPage, + isLoading, + isFetchingNextPage, + fetchNextPage, + } = useInfiniteQuery< + Rivet.RunnerConfigsListResponse, + Error, + string[], + readonly unknown[], + string | undefined + >({ + ...(hasRunnerConfigs + ? { + ...dataProvider.runnerConfigsQueryOptions(), + select: selectRunnerConfigKeys, + } + : emptyRunnerConfigKeysQueryOptions), + enabled: hasRunnerConfigs, + }); + return { + keys, + defaultPool: resolveDefaultPool(keys), + hasNextPage, + isLoading, + isFetchingNextPage, + fetchNextPage, + }; +} + +// Lets the dialogs submit the resolved default pool even when Advanced (where +// the Pool selector lives) is never opened. Falls back to "default". +export function useDefaultRunnerNameSelector(): string { + return useRunnerConfigKeys().defaultPool ?? "default"; +} + +// Pool selector bound to `runnerNameSelector`. Hidden unless there is more than +// one pool to pick between; the submit handler still sends the resolved default. +export const Pool = () => { + const { control } = useFormContext(); + const { + keys, + defaultPool, + hasNextPage, + isLoading, + isFetchingNextPage, + fetchNextPage, + } = useRunnerConfigKeys(); + + if (keys.length <= 1) { + return null; + } + + const options = keys.map((key) => ({ label: key, value: key })); + + return ( + ( + + Pool + + + + + The pool the Actor will run on. + + + + )} + /> + ); +}; + export const ActorPreview = () => { const { watch } = useFormContext(); diff --git a/frontend/src/routes/_context.tsx b/frontend/src/routes/_context.tsx index c5f50dfab9..2bab960caa 100644 --- a/frontend/src/routes/_context.tsx +++ b/frontend/src/routes/_context.tsx @@ -6,7 +6,6 @@ import { useNavigate, useSearch, } from "@tanstack/react-router"; -import { zodValidator } from "@tanstack/zod-adapter"; import posthog from "posthog-js"; import { useEffect } from "react"; import z from "zod"; @@ -38,12 +37,23 @@ const searchSchema = z t: z.string().optional(), from: z.string().optional(), project: z.string().optional(), + pool: z.string().optional(), }) .and(z.record(z.string(), z.any())); export const Route = createFileRoute("/_context")({ component: RouteComponent, - validateSearch: zodValidator(searchSchema), + validateSearch: (search) => { + 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() {