Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 137 additions & 40 deletions app/pages/system/networking/IpPoolPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,15 @@ import {
usePrefetchedQuery,
type IpPoolRange,
type IpPoolSiloLink,
type Silo,
} from '@oxide/api'
import { IpGlobal16Icon, IpGlobal24Icon } from '@oxide/design-system/icons/react'
import { Badge } from '@oxide/design-system/ui'

import { CapacityBar } from '~/components/CapacityBar'
import { DocsPopover } from '~/components/DocsPopover'
import { ComboboxField } from '~/components/form/fields/ComboboxField'
import { HL } from '~/components/HL'
import { IpVersionBadge } from '~/components/IpVersionBadge'
import { MoreActionsMenu } from '~/components/MoreActionsMenu'
import { QueryParamTabs } from '~/components/QueryParamTabs'
import { makeCrumb } from '~/hooks/use-crumbs'
Expand All @@ -41,13 +42,15 @@ import { LinkCell } from '~/table/cells/LinkCell'
import { useColsWithActions, type MenuAction } from '~/table/columns/action-col'
import { Columns } from '~/table/columns/common'
import { useQueryTable } from '~/table/QueryTable'
import { BigNum } from '~/ui/lib/BigNum'
import { toComboboxItems } from '~/ui/lib/Combobox'
import { CreateButton, CreateLink } from '~/ui/lib/CreateButton'
import * as Dropdown from '~/ui/lib/DropdownMenu'
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
import { Message } from '~/ui/lib/Message'
import { Modal } from '~/ui/lib/Modal'
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
import { Tabs } from '~/ui/lib/Tabs'
import { TipIcon } from '~/ui/lib/TipIcon'
import { ALL_ISH } from '~/util/consts'
Expand All @@ -62,14 +65,25 @@ const ipPoolSiloList = ({ pool }: PP.IpPool) =>
getListQFn(api.ipPoolSiloList, { path: { pool } })
const ipPoolRangeList = ({ pool }: PP.IpPool) =>
getListQFn(api.ipPoolRangeList, { path: { pool } })
const siloList = q(api.siloList, { query: { limit: 200 } })
const siloList = q(api.siloList, { query: { limit: ALL_ISH } })
const siloView = ({ silo }: PP.Silo) => q(api.siloView, { path: { silo } })
const siloIpPoolList = (silo: string) =>
q(api.siloIpPoolList, { path: { silo }, query: { limit: ALL_ISH } })

export async function clientLoader({ params }: LoaderFunctionArgs) {
const selector = getIpPoolSelector(params)
await Promise.all([
queryClient.prefetchQuery(ipPoolView(selector)),
queryClient.prefetchQuery(ipPoolSiloList(selector).optionsFn()),
// prefetch silo pool lists so "Make default" can show existing default name.
// fire-and-forget: don't block page load, the action handler fetches on
// demand if these haven't completed yet
queryClient.fetchQuery(ipPoolSiloList(selector).optionsFn()).then((links) => {
// only do first 50 to avoid kicking of a ridiculous number of requests if
// the user has 500 silos for some reason
for (const link of links.items.slice(0, 50)) {
queryClient.prefetchQuery(siloIpPoolList(link.siloId))
}
}),
queryClient.prefetchQuery(ipPoolRangeList(selector).optionsFn()),
queryClient.prefetchQuery(ipPoolUtilizationView(selector)),

Expand Down Expand Up @@ -129,7 +143,7 @@ export default function IpPoolpage() {
</MoreActionsMenu>
</div>
</PageHeader>
<UtilizationBars />
<PoolProperties />
<QueryParamTabs className="full-width" defaultValue="ranges">
<Tabs.List>
<Tabs.Trigger value="ranges">IP ranges</Tabs.Trigger>
Expand All @@ -147,34 +161,30 @@ export default function IpPoolpage() {
)
}

function UtilizationBars() {
function PoolProperties() {
const poolSelector = useIpPoolSelector()
const { data } = usePrefetchedQuery(ipPoolUtilizationView(poolSelector))
const { capacity, remaining } = data

if (capacity === 0) return null
const { data: pool } = usePrefetchedQuery(ipPoolView(poolSelector))
const { data: utilization } = usePrefetchedQuery(ipPoolUtilizationView(poolSelector))

return (
<div className="1000:flex-row -mt-8 mb-8 flex min-w-min flex-col gap-3">
{capacity > 0 && (
<CapacityBar
icon={<IpGlobal16Icon />}
title="ALLOCATED"
// TODO: this is potentially broken in the case of large IPv6 numbers
// due to lack of full precision. This should be fine and useful
// for IPv4 pools, but for IPv6 we should probably just show the two
// numbers. For now there are no IPv6 pools.
// https://github.com/oxidecomputer/omicron/issues/8966
// https://github.com/oxidecomputer/omicron/issues/9004
provisioned={Math.max(capacity - remaining, 0)}
capacity={capacity}
provisionedLabel="Allocated"
capacityLabel="Capacity"
unit="IPs"
includeUnit={false}
/>
)}
</div>
<PropertiesTable columns={2} className="-mt-8 mb-8">
<PropertiesTable.IdRow id={pool.id} />
<PropertiesTable.DescriptionRow description={pool.description} />
<PropertiesTable.Row label="IP version">
<IpVersionBadge ipVersion={pool.ipVersion} />
</PropertiesTable.Row>
<PropertiesTable.Row label="Type">
<Badge color="neutral">{pool.poolType}</Badge>
</PropertiesTable.Row>
<PropertiesTable.Row label="IPs remaining">
<span>
<BigNum className="text-raise" num={utilization.remaining} />
{' / '}
<BigNum className="text-secondary" num={utilization.capacity} />
</span>
</PropertiesTable.Row>
<PropertiesTable.DateRow date={pool.timeCreated} label="Created" />
</PropertiesTable>
)
}

Expand Down Expand Up @@ -256,35 +266,122 @@ function SiloNameFromId({ value: siloId }: { value: string }) {

const silosColHelper = createColumnHelper<IpPoolSiloLink>()

/** Look up silo name from query cache and return a label for use in modals. */
function getSiloLabel(siloId: string) {
const siloName = queryClient.getQueryData<Silo>(siloView({ silo: siloId }).queryKey)?.name
// prettier-ignore
return siloName
? <>silo <HL>{siloName}</HL></>
: 'that silo'
}

function LinkedSilosTable() {
const poolSelector = useIpPoolSelector()
const { data: pool } = usePrefetchedQuery(ipPoolView(poolSelector))

const { mutateAsync: unlinkSilo } = useApiMutation(api.ipPoolSiloUnlink, {
onSuccess() {
queryClient.invalidateEndpoint('ipPoolSiloList')
},
})
const { mutateAsync: updateSiloLink } = useApiMutation(api.ipPoolSiloUpdate, {
onSuccess() {
queryClient.invalidateEndpoint('ipPoolSiloList')
queryClient.invalidateEndpoint('siloIpPoolList')
},
})

const makeActions = useCallback(
(link: IpPoolSiloLink): MenuAction[] => [
{
label: link.isDefault ? 'Clear default' : 'Make default',
className: link.isDefault ? 'destructive' : undefined,
onActivate() {
const siloLabel = getSiloLabel(link.siloId)
const poolKind = `IP${pool.ipVersion} ${pool.poolType}`

if (link.isDefault) {
confirmAction({
doAction: () =>
updateSiloLink({
path: { silo: link.siloId, pool: link.ipPoolId },
body: { isDefault: false },
}),
modalTitle: 'Confirm clear default',
modalContent: (
<p>
Are you sure you want <HL>{pool.name}</HL> to stop being the default{' '}
{poolKind} pool for {siloLabel}? If there is no default, users in this
silo will have to specify a pool when allocating IPs.
</p>
),
errorTitle: 'Could not clear default',
actionType: 'danger',
})
} else {
// fetch on demand (usually already cached by loader prefetch). on
// failure, fall back to simpler modal copy. don't await, handle
// errors internally to minimize blast radius of failure.
void queryClient
// ensureQueryData makes sure we use cached data, at the expense
// of it possibly being stale. but you can't even change a silo
// name, so it should be fine
.ensureQueryData(siloIpPoolList(link.siloId))
.catch(() => null)
.then((siloPools) => {
const existingDefaultName = siloPools?.items.find(
(p) =>
p.isDefault &&
p.ipVersion === pool.ipVersion &&
p.poolType === pool.poolType
)?.name

// all this conditional stuff is just to handle the remote but
// real possibility of the fetch failing
const modalContent = existingDefaultName ? (
<p>
Are you sure you want to change the default {poolKind} pool for{' '}
{siloLabel} from <HL>{existingDefaultName}</HL> to <HL>{pool.name}</HL>?
</p>
) : (
<p>
Are you sure you want to make <HL>{pool.name}</HL> the default{' '}
{poolKind} pool for {siloLabel}?
</p>
)

const verb = existingDefaultName ? 'change' : 'make'
confirmAction({
doAction: () =>
updateSiloLink({
path: { silo: link.siloId, pool: link.ipPoolId },
body: { isDefault: true },
}),
modalTitle: `Confirm ${verb} default`,
modalContent,
errorTitle: `Could not ${verb} default`,
actionType: 'primary',
})
})
// be extra sure we don't have any unhandled promise rejections
.catch(() => null)
}
},
},
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This whole inline fetch thing is weird, but it ensures we have the data before the modal is rendered, kind of like a loader. I considered an alternative where instead we render a component as the modal contents, which means it can call useQuery in order to render first and then fetch the uncached silo information as needed. The problem, as with all such render-then-fetch setups, is that you have visible pop-in of the result of the query. You can restructure the text so that the pop-in is less noticeable, but that makes it less readable, and you still have the pop-in. So I prefer the above even though it's a little weird. What it makes me really want is a way of doing something that works like a loader but for pseudo-navs like modal opens that are not route changes. There probably is a way to build that.

Diff of alternative approach
diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx
index 16a4b47bff..19f404c38e 100644
--- a/app/pages/system/networking/IpPoolPage.tsx
+++ b/app/pages/system/networking/IpPoolPage.tsx
@@ -19,6 +19,7 @@
   queryClient,
   useApiMutation,
   usePrefetchedQuery,
+  type IpPool,
   type IpPoolRange,
   type IpPoolSiloLink,
   type Silo,
@@ -74,16 +75,7 @@
   const selector = getIpPoolSelector(params)
   await Promise.all([
     queryClient.prefetchQuery(ipPoolView(selector)),
-    // prefetch silo pool lists so "Make default" can show existing default name.
-    // fire-and-forget: don't block page load, the action handler fetches on
-    // demand if these haven't completed yet
-    queryClient.fetchQuery(ipPoolSiloList(selector).optionsFn()).then((links) => {
-      // only do first 50 to avoid kicking of a ridiculous number of requests if
-      // the user has 500 silos for some reason
-      for (const link of links.items.slice(0, 50)) {
-        queryClient.prefetchQuery(siloIpPoolList(link.siloId))
-      }
-    }),
+    queryClient.prefetchQuery(ipPoolSiloList(selector).optionsFn()),
     queryClient.prefetchQuery(ipPoolRangeList(selector).optionsFn()),
     queryClient.prefetchQuery(ipPoolUtilizationView(selector)),
 
@@ -266,6 +258,50 @@
 
 const silosColHelper = createColumnHelper<IpPoolSiloLink>()
 
+function SetDefaultModalContent({
+  siloId,
+  siloName,
+  pool,
+}: {
+  siloId: string
+  siloName?: string
+  pool: Pick<IpPool, 'name' | 'ipVersion' | 'poolType'>
+}) {
+  const { data: siloPools } = useQuery(siloIpPoolList(siloId))
+
+  const existingDefaultName = siloPools?.items.find(
+    (p) => p.isDefault && p.ipVersion === pool.ipVersion && p.poolType === pool.poolType
+  )?.name
+
+  const poolKind = `IP${pool.ipVersion} ${pool.poolType}`
+  // prettier-ignore
+  const siloLabel = siloName
+    ? <>silo <HL>{siloName}</HL></>
+    : 'that silo'
+
+  return (
+    <>
+      <p>
+        Are you sure you want to make <HL>{pool.name}</HL> the default {poolKind} pool for{' '}
+        {siloLabel}?
+      </p>
+      <p className="text-secondary">
+        Setting a new default replaces any existing default. Current default for {poolKind}:{' '}
+        {siloPools ? (
+          existingDefaultName ? (
+            <HL>{existingDefaultName}</HL>
+          ) : (
+            'none'
+          )
+        ) : (
+          <span className="bg-tertiary inline-block h-4 w-20 rounded-md align-middle motion-safe:animate-pulse" />
+        )}
+        .
+      </p>
+    </>
+  )
+}
+
 function LinkedSilosTable() {
   const poolSelector = useIpPoolSelector()
   const { data: pool } = usePrefetchedQuery(ipPoolView(poolSelector))
@@ -288,12 +324,9 @@
         label: link.isDefault ? 'Clear default' : 'Make default',
         className: link.isDefault ? 'destructive' : undefined,
         onActivate() {
-          const silo = queryClient.getQueryData<Silo>(
+          const siloName = queryClient.getQueryData<Silo>(
             siloView({ silo: link.siloId }).queryKey
-          )
-          // in order to hit this fallback, the user would have to have more
-          // than 1000 silos and be working on the 1001th
-          const siloName = silo?.name
+          )?.name
           // prettier-ignore
           const siloLabel = siloName
             ? <>silo <HL>{siloName}</HL></>
@@ -319,52 +352,23 @@
               actionType: 'danger',
             })
           } else {
-            // fetch on demand (usually already cached by loader prefetch). on
-            // failure, fall back to simpler modal copy. don't await, handle
-            // errors internally to minimize blast radius of failure.
-            void queryClient
-              // ensureQueryData makes sure we use cached data, at the expense
-              // of it possibly being stale. but you can't even change a silo
-              // name, so it should be fine
-              .ensureQueryData(siloIpPoolList(link.siloId))
-              .catch(() => null)
-              .then((siloPools) => {
-                const existingDefaultName = siloPools?.items.find(
-                  (p) =>
-                    p.isDefault &&
-                    p.ipVersion === pool.ipVersion &&
-                    p.poolType === pool.poolType
-                )?.name
-
-                // all this conditional stuff is just to handle the remote but
-                // real possibility of the fetch failing
-                const modalContent = existingDefaultName ? (
-                  <p>
-                    Are you sure you want to change the default {poolKind} pool for{' '}
-                    {siloLabel} from <HL>{existingDefaultName}</HL> to <HL>{pool.name}</HL>?
-                  </p>
-                ) : (
-                  <p>
-                    Are you sure you want to make <HL>{pool.name}</HL> the default{' '}
-                    {poolKind} pool for {siloLabel}?
-                  </p>
-                )
-
-                const verb = existingDefaultName ? 'change' : 'make'
-                confirmAction({
-                  doAction: () =>
-                    updateSiloLink({
-                      path: { silo: link.siloId, pool: link.ipPoolId },
-                      body: { isDefault: true },
-                    }),
-                  modalTitle: `Confirm ${verb} default`,
-                  modalContent,
-                  errorTitle: `Could not ${verb} default`,
-                  actionType: 'primary',
-                })
-              })
-              // be extra sure we don't have any unhandled promise rejections
-              .catch(() => null)
+            confirmAction({
+              doAction: () =>
+                updateSiloLink({
+                  path: { silo: link.siloId, pool: link.ipPoolId },
+                  body: { isDefault: true },
+                }),
+              modalTitle: 'Confirm set default',
+              modalContent: (
+                <SetDefaultModalContent
+                  siloId={link.siloId}
+                  siloName={siloName}
+                  pool={pool}
+                />
+              ),
+              errorTitle: 'Could not set default',
+              actionType: 'primary',
+            })
           }
         },
       },
diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts
index 9279b476d4..87e7d46603 100644
--- a/test/e2e/ip-pools.e2e.ts
+++ b/test/e2e/ip-pools.e2e.ts
@@ -133,12 +133,16 @@
 
   await clickRowAction(page, 'pelerines', 'Make default')
 
-  const dialog = page.getByRole('dialog', { name: 'Confirm make default' })
+  const dialog = page.getByRole('dialog', { name: 'Confirm set default' })
   await expect(
     dialog.getByText(
       'Are you sure you want to make ip-pool-1 the default IPv4 unicast pool for silo pelerines?'
     )
   ).toBeVisible()
+  await expect(
+    dialog.getByText('Setting a new default replaces any existing default.')
+  ).toBeVisible()
+  await expect(dialog.getByText('Current default for IPv4 unicast: none.')).toBeVisible()
 
   await page.getByRole('button', { name: 'Confirm' }).click()
   await expectRowVisible(table, { Silo: 'pelerines', 'Silo default': 'default' })
@@ -153,12 +157,18 @@
 
   await clickRowAction(page, 'myriad', 'Make default')
 
-  const dialog = page.getByRole('dialog', { name: 'Confirm change default' })
+  const dialog = page.getByRole('dialog', { name: 'Confirm set default' })
   await expect(
     dialog.getByText(
-      'Are you sure you want to change the default IPv4 unicast pool for silo myriad from ip-pool-1 to ip-pool-3?'
+      'Are you sure you want to make ip-pool-3 the default IPv4 unicast pool for silo myriad?'
     )
   ).toBeVisible()
+  await expect(
+    dialog.getByText('Setting a new default replaces any existing default.')
+  ).toBeVisible()
+  await expect(
+    dialog.getByText('Current default for IPv4 unicast: ip-pool-1.')
+  ).toBeVisible()
 
   await page.getByRole('button', { name: 'Confirm' }).click()
   await expectRowVisible(table, { Silo: 'myriad', 'Silo default': 'default' })

Copy link
Collaborator Author

@david-crespo david-crespo Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had Mr. Claude figure out how a loader-like setup for modals would have to work, based on this PR and the above comment. It's quite helpful despite the Core Tensions and Key Insights. Spinner on the button that opens the modal is interesting, though we'd probably want to have it on a timer so it only shows up if the request takes longer than 300ms or something.


The core tension: route loaders give you fetch-before-render for free, but modals are orthogonal to routing. You either pay with imperative .then() chains or with visible loading states.

The best fit for console: prefetchQuery + startTransition + useSuspenseQuery

This is the pattern that composes most naturally with the existing TanStack Query + React 19 stack:

  1. Click handler calls queryClient.prefetchQuery(...) to warm the cache, then wraps the modal-open state change in startTransition.
  2. Modal content component calls useSuspenseQuery(...), which suspends if data isn't cached yet.
  3. startTransition prevents the Suspense fallback from flashing — React keeps the current UI visible while the modal's data resolves in the background.
  4. isPending from useTransition can drive a subtle loading indicator on the trigger button.

If data is already cached (the common case after the loader prefetched), the modal opens instantly. If not, the button shows a brief spinner, then the modal appears fully populated. No .then() chains, no pop-in, and the modal component reads data declaratively.

The key insight is that startTransition is the missing piece that makes Suspense work for modals — without it, use() / useSuspenseQuery always shows the Suspense fallback initially, even for already-resolved promises (React issue #30701). With it, already-revealed content stays put while React renders the suspended tree offscreen.

Other patterns considered

TanStack Router route masking — Modals as real routes with real loaders, URL masked to the parent path. Elegant concept but requires a router migration, and has known issues (preloading broken for masked links, unexpected scroll-to-top).

Next.js parallel/intercepting routes — Same idea (modal as route with loader), framework-specific. Not applicable.

React 19 use() with promise-as-prop — Start a promise in the click handler, store it in state, call use(promise) in the modal. Works but redundant with TanStack Query's cache, and requires startTransition anyway to avoid the fallback flash.

React Router modal-as-route — Pass previous location as state, render the new route as a modal over the old one. The modal route gets a loader. Requires restructuring modals as routes, which is a big lift for the dozens of modals in console.

Hover prefetching — Call prefetchQuery on onMouseEnter/onFocus of the trigger. Orthogonal to the above and can be layered on top for even faster perceived performance. TanStack Router does this with defaultPreload="intent" on links.

What this would look like for the confirmAction pattern

The zustand-based confirmAction currently takes a pre-built modalContent: ReactNode, which means data must be fully resolved before calling it. To support the useSuspenseQuery pattern, confirmAction (or a new variant) would need to accept a component/render function instead of a static ReactNode, so the modal content can suspend. The ConfirmActionModal component would wrap that content in a <Suspense> boundary, and the caller would use useTransition to open it. This is a meaningful but contained refactor of the confirm-action store.

{
label: 'Unlink',
className: 'destructive',
onActivate() {
const siloLabel = getSiloLabel(link.siloId)
confirmAction({
doAction: () =>
unlinkSilo({ path: { silo: link.siloId, pool: link.ipPoolId } }),
modalTitle: 'Confirm unlink silo',
// Would be nice to reference the silo by name like we reference the
// pool by name on unlink in the silo pools list, but it's a pain to
// get the name here. Could use useQueries to get all the names, and
// RQ would dedupe the requests since they're already being fetched
// for the table. Not worth it right now.
modalContent: (
<p>
Are you sure you want to unlink the silo? Users in this silo will no longer
be able to allocate IPs from this pool. Unlink will fail if there are any
IPs from the pool in use in this silo.
Are you sure you want to unlink {siloLabel} from <HL>{pool.name}</HL>? Users
in the silo will no longer be able to allocate IPs from this pool. Unlink
will fail if there are any IPs from the pool in use in the silo.
</p>
),
errorTitle: 'Could not unlink silo',
Expand All @@ -293,7 +390,7 @@ function LinkedSilosTable() {
},
},
],
[unlinkSilo]
[pool, unlinkSilo, updateSiloLink]
)

const [showLinkModal, setShowLinkModal] = useState(false)
Expand All @@ -320,8 +417,8 @@ function LinkedSilosTable() {
<span className="inline-flex items-center gap-2">
Silo default
<TipIcon>
IPs are allocated from the default pool when users ask for an IP without
specifying a pool
When no pool is specified, IPs are allocated from the silo's default pool
for the relevant version and type.
</TipIcon>
</span>
)
Expand Down
14 changes: 9 additions & 5 deletions app/pages/system/silos/SiloIpPoolsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,13 @@ export default function SiloIpPoolsTab() {
const { mutateAsync: updatePoolLink } = useApiMutation(api.ipPoolSiloUpdate, {
onSuccess() {
queryClient.invalidateEndpoint('siloIpPoolList')
queryClient.invalidateEndpoint('ipPoolSiloList')
},
})
const { mutateAsync: unlinkPool } = useApiMutation(api.ipPoolSiloUnlink, {
onSuccess() {
queryClient.invalidateEndpoint('siloIpPoolList')
queryClient.invalidateEndpoint('ipPoolSiloList')
// We only have the ID, so will show a generic confirmation message
addToast({ content: 'IP pool unlinked' })
},
Expand All @@ -132,6 +134,9 @@ export default function SiloIpPoolsTab() {
label: pool.isDefault ? 'Clear default' : 'Make default',
className: pool.isDefault ? 'destructive' : undefined,
onActivate() {
const versionLabel = `IP${pool.ipVersion}`
const typeLabel = pool.poolType

if (pool.isDefault) {
confirmAction({
doAction: () =>
Expand All @@ -142,18 +147,16 @@ export default function SiloIpPoolsTab() {
modalTitle: 'Confirm clear default',
modalContent: (
<p>
Are you sure you want <HL>{pool.name}</HL> to stop being the default pool
for this silo? If there is no default, users in this silo will have to
specify a pool when allocating IPs.
Are you sure you want <HL>{pool.name}</HL> to stop being the default{' '}
{versionLabel} {typeLabel} pool for this silo? If there is no default,
users in this silo will have to specify a pool when allocating IPs.
</p>
),
errorTitle: 'Could not clear default',
actionType: 'danger',
})
} else {
const existingDefault = findDefaultForVersionType(pool.ipVersion, pool.poolType)
const versionLabel = `IP${pool.ipVersion}`
const typeLabel = pool.poolType

const modalContent = existingDefault ? (
<p>
Expand Down Expand Up @@ -237,6 +240,7 @@ function LinkPoolModal({ onDismiss }: { onDismiss: () => void }) {
const linkPool = useApiMutation(api.ipPoolSiloLink, {
onSuccess() {
queryClient.invalidateEndpoint('siloIpPoolList')
queryClient.invalidateEndpoint('ipPoolSiloList')
},
onError(err) {
addToast({ title: 'Could not link pool', content: err.message, variant: 'error' })
Expand Down
Loading
Loading