From 18e5c9f20f088a1d7cf76b918ca5d3caa64bab0d Mon Sep 17 00:00:00 2001 From: David Crespo Date: Sat, 7 Feb 2026 22:17:39 -0600 Subject: [PATCH 1/7] set and clear default pool from silos list on pool detail --- app/pages/system/networking/IpPoolPage.tsx | 83 ++++++++++++++++++++-- test/e2e/ip-pools.e2e.ts | 57 +++++++++++++++ 2 files changed, 134 insertions(+), 6 deletions(-) diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index ed6edcb8f..034442584 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -258,15 +258,91 @@ const silosColHelper = createColumnHelper() 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, + async onActivate() { + const silo = await queryClient.fetchQuery( + q(api.siloView, { path: { silo: link.siloId } }) + ) + + if (link.isDefault) { + confirmAction({ + doAction: () => + updateSiloLink({ + path: { silo: link.siloId, pool: link.ipPoolId }, + body: { isDefault: false }, + }), + modalTitle: 'Confirm clear default', + modalContent: ( +

+ Are you sure you want {pool.name} to stop being the default pool + for silo {silo.name}? If there is no default, users in this silo + will have to specify a pool when allocating IPs. +

+ ), + errorTitle: 'Could not clear default', + actionType: 'danger', + }) + } else { + // find existing default for same version/type to warn the user + const siloPools = await queryClient.ensureQueryData( + q(api.siloIpPoolList, { + path: { silo: link.siloId }, + query: { limit: ALL_ISH }, + }) + ) + const existingDefault = siloPools.items.find( + (p) => + p.isDefault && + p.ipVersion === pool.ipVersion && + p.poolType === pool.poolType + ) + + const modalContent = existingDefault ? ( +

+ The current default pool for silo {silo.name} is{' '} + {existingDefault.name}. Are you sure you want to make{' '} + {pool.name} the default instead? +

+ ) : ( +

+ Are you sure you want to make {pool.name} the default pool for silo{' '} + {silo.name}? +

+ ) + + const verb = existingDefault ? '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', + }) + } + }, + }, { label: 'Unlink', className: 'destructive', @@ -275,11 +351,6 @@ function LinkedSilosTable() { 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: (

Are you sure you want to unlink the silo? Users in this silo will no longer @@ -293,7 +364,7 @@ function LinkedSilosTable() { }, }, ], - [unlinkSilo] + [pool, unlinkSilo, updateSiloLink] ) const [showLinkModal, setShowLinkModal] = useState(false) diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index 5cc208985..a23294237 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -124,6 +124,63 @@ test('IP pool link silo', async ({ page }) => { await expectRowVisible(table, { Silo: 'thrax', 'Silo default': '' }) }) +test('IP pool silo make default (no existing default)', async ({ page }) => { + // pelerines has ip-pool-1 linked but not as default, and has no v4 unicast default + await page.goto('/system/networking/ip-pools/ip-pool-1?tab=silos') + + const table = page.getByRole('table') + await expectRowVisible(table, { Silo: 'pelerines', 'Silo default': '' }) + + await clickRowAction(page, 'pelerines', 'Make default') + + const dialog = page.getByRole('dialog', { name: 'Confirm make default' }) + await expect( + dialog.getByText( + 'Are you sure you want to make ip-pool-1 the default pool for silo pelerines?' + ) + ).toBeVisible() + + await page.getByRole('button', { name: 'Confirm' }).click() + await expectRowVisible(table, { Silo: 'pelerines', 'Silo default': 'default' }) +}) + +test('IP pool silo make default (with existing default)', async ({ page }) => { + // ip-pool-3 is linked to myriad but not as default; ip-pool-1 is the v4 unicast default for myriad + await page.goto('/system/networking/ip-pools/ip-pool-3?tab=silos') + + const table = page.getByRole('table') + await expectRowVisible(table, { Silo: 'myriad', 'Silo default': '' }) + + await clickRowAction(page, 'myriad', 'Make default') + + const dialog = page.getByRole('dialog', { name: 'Confirm change default' }) + await expect( + dialog.getByText('The current default pool for silo myriad is ip-pool-1.') + ).toBeVisible() + + await page.getByRole('button', { name: 'Confirm' }).click() + await expectRowVisible(table, { Silo: 'myriad', 'Silo default': 'default' }) +}) + +test('IP pool silo clear default', async ({ page }) => { + await page.goto('/system/networking/ip-pools/ip-pool-1?tab=silos') + + const table = page.getByRole('table') + await expectRowVisible(table, { Silo: 'maze-war', 'Silo default': 'default' }) + + await clickRowAction(page, 'maze-war', 'Clear default') + + const dialog = page.getByRole('dialog', { name: 'Confirm clear default' }) + await expect( + dialog.getByText( + 'Are you sure you want ip-pool-1 to stop being the default pool for silo maze-war?' + ) + ).toBeVisible() + + await page.getByRole('button', { name: 'Confirm' }).click() + await expectRowVisible(table, { Silo: 'maze-war', 'Silo default': '' }) +}) + test('IP pool delete from IP Pools list page', async ({ page }) => { await page.goto('/system/networking/ip-pools') From 9ebfcf3ab53187cc6db12721a5782d0c6807855b Mon Sep 17 00:00:00 2001 From: David Crespo Date: Sat, 7 Feb 2026 22:46:44 -0600 Subject: [PATCH 2/7] properties table on ip pool detail --- app/pages/system/networking/IpPoolPage.tsx | 56 +++++++++++----------- test/e2e/ip-pools.e2e.ts | 44 ++++++----------- 2 files changed, 42 insertions(+), 58 deletions(-) diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 034442584..71d78bcd3 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -25,10 +25,10 @@ import { 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' @@ -41,6 +41,7 @@ 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' @@ -48,6 +49,7 @@ 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' @@ -129,7 +131,7 @@ export default function IpPoolpage() { - + IP ranges @@ -147,34 +149,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 ( -

- {capacity > 0 && ( - } - 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} - /> - )} -
+ + + + + + + + {pool.poolType} + + + + + {' / '} + + + + + ) } @@ -391,8 +389,8 @@ function LinkedSilosTable() { Silo default - 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. ) diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index a23294237..bb04c1566 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -59,9 +59,9 @@ test.describe('german locale', () => { }) }) - test('IP pool CapacityBar renders bignum with correct locale', async ({ page }) => { + test('IP pool properties table renders bignum with correct locale', async ({ page }) => { await page.goto('/system/networking/ip-pools/ip-pool-4') - await expect(page.getByText('Capacity18,4e18')).toBeVisible() + await expect(page.getByText('18,4e18 / 18,4e18')).toBeVisible() }) }) @@ -314,10 +314,8 @@ test('IP range validation and add', async ({ page }) => { const table = page.getByRole('table') await expectRowVisible(table, { First: v4Addr, Last: v4Addr }) - // now the utilization bar shows the single IP added - await expect(page.getByText('Allocated(IPs)')).toBeVisible() - await expect(page.getByText('Allocated0')).toBeVisible() - await expect(page.getByText('Capacity1')).toBeVisible() + // now the properties table shows the single IP added + await expect(page.getByText('1 / 1')).toBeVisible() // go back to the pool and verify the remaining/capacity columns changed // use the sidebar nav to get there @@ -390,10 +388,8 @@ test('remove range', async ({ page }) => { await expect(table.getByRole('cell', { name: '10.0.0.20' })).toBeHidden() await expect(table.getByRole('row')).toHaveCount(2) - // utilization updates - await expect(page.getByText('Allocated(IPs)')).toBeVisible() - await expect(page.getByText('Allocated8')).toBeVisible() - await expect(page.getByText('Capacity21')).toBeVisible() + // utilization updates in properties table + await expect(page.getByText('13 / 21')).toBeVisible() // go back to the pool and verify the remaining/capacity columns changed // use the topbar breadcrumb to get there @@ -431,26 +427,16 @@ test('deleting floating IP decrements utilization', async ({ page }) => { }) }) -test('no ranges means no utilization bar', async ({ page }) => { - await page.goto('/system/networking/ip-pools/ip-pool-1') - await expect(page.getByRole('heading', { name: 'ip-pool-1' })).toBeVisible() - await expect(page.getByText('Allocated(IPs)')).toBeVisible() - - await page.goto('/system/networking/ip-pools/ip-pool-2') - await expect(page.getByRole('heading', { name: 'ip-pool-2' })).toBeVisible() - await expect(page.getByText('Allocated(IPs)')).toBeVisible() - +test('IPs remaining in properties table', async ({ page }) => { + // pool with no ranges shows 0 / 0 await page.goto('/system/networking/ip-pools/ip-pool-3') - await expect(page.getByRole('heading', { name: 'ip-pool-3' })).toBeVisible() - await expect(page.getByText('Allocated(IPs)')).toBeHidden() - - await page.goto('/system/networking/ip-pools/ip-pool-4') - await expect(page.getByRole('heading', { name: 'ip-pool-4' })).toBeVisible() - await expect(page.getByText('Allocated(IPs)')).toBeVisible() + await expect(page.getByText('0 / 0')).toBeVisible() - await clickRowAction(page, '::1', 'Remove') - const confirmModal = page.getByRole('dialog', { name: 'Confirm remove range' }) - await confirmModal.getByRole('button', { name: 'Confirm' }).click() + // pool with ranges shows remaining / capacity + await page.goto('/system/networking/ip-pools/ip-pool-1') + await expect(page.getByText('16 / 24')).toBeVisible() - await expect(page.getByText('Allocated(IPs)')).toBeHidden() + // large IPv6 pool shows abbreviated bignum + await page.goto('/system/networking/ip-pools/ip-pool-4') + await expect(page.getByText('18.4e18 / 18.4e18')).toBeVisible() }) From 6135759486264b55918c8767eb22625762e3ec62 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Sat, 7 Feb 2026 23:03:32 -0600 Subject: [PATCH 3/7] say v4 unicast etc in confirm modals --- app/pages/system/networking/IpPoolPage.tsx | 14 ++++++++------ app/pages/system/silos/SiloIpPoolsTab.tsx | 11 ++++++----- test/e2e/ip-pools.e2e.ts | 6 +++--- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 71d78bcd3..54c4d94e0 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -280,6 +280,8 @@ function LinkedSilosTable() { q(api.siloView, { path: { silo: link.siloId } }) ) + const poolKind = `IP${pool.ipVersion} ${pool.poolType}` + if (link.isDefault) { confirmAction({ doAction: () => @@ -290,9 +292,9 @@ function LinkedSilosTable() { modalTitle: 'Confirm clear default', modalContent: (

- Are you sure you want {pool.name} to stop being the default pool - for silo {silo.name}? If there is no default, users in this silo - will have to specify a pool when allocating IPs. + Are you sure you want {pool.name} to stop being the default{' '} + {poolKind} pool for silo {silo.name}? If there is no default, + users in this silo will have to specify a pool when allocating IPs.

), errorTitle: 'Could not clear default', @@ -315,14 +317,14 @@ function LinkedSilosTable() { const modalContent = existingDefault ? (

- The current default pool for silo {silo.name} is{' '} + The current default {poolKind} pool for silo {silo.name} is{' '} {existingDefault.name}. Are you sure you want to make{' '} {pool.name} the default instead?

) : (

- Are you sure you want to make {pool.name} the default pool for silo{' '} - {silo.name}? + Are you sure you want to make {pool.name} the default {poolKind}{' '} + pool for silo {silo.name}?

) diff --git a/app/pages/system/silos/SiloIpPoolsTab.tsx b/app/pages/system/silos/SiloIpPoolsTab.tsx index 7acb12d79..712b32331 100644 --- a/app/pages/system/silos/SiloIpPoolsTab.tsx +++ b/app/pages/system/silos/SiloIpPoolsTab.tsx @@ -132,6 +132,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: () => @@ -142,9 +145,9 @@ export default function SiloIpPoolsTab() { modalTitle: 'Confirm clear default', modalContent: (

- Are you sure you want {pool.name} 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 {pool.name} 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.

), errorTitle: 'Could not clear default', @@ -152,8 +155,6 @@ export default function SiloIpPoolsTab() { }) } else { const existingDefault = findDefaultForVersionType(pool.ipVersion, pool.poolType) - const versionLabel = `IP${pool.ipVersion}` - const typeLabel = pool.poolType const modalContent = existingDefault ? (

diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index bb04c1566..747a066ef 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -136,7 +136,7 @@ test('IP pool silo make default (no existing default)', async ({ page }) => { const dialog = page.getByRole('dialog', { name: 'Confirm make default' }) await expect( dialog.getByText( - 'Are you sure you want to make ip-pool-1 the default pool for silo pelerines?' + 'Are you sure you want to make ip-pool-1 the default IPv4 unicast pool for silo pelerines?' ) ).toBeVisible() @@ -155,7 +155,7 @@ test('IP pool silo make default (with existing default)', async ({ page }) => { const dialog = page.getByRole('dialog', { name: 'Confirm change default' }) await expect( - dialog.getByText('The current default pool for silo myriad is ip-pool-1.') + dialog.getByText('The current default IPv4 unicast pool for silo myriad is ip-pool-1.') ).toBeVisible() await page.getByRole('button', { name: 'Confirm' }).click() @@ -173,7 +173,7 @@ test('IP pool silo clear default', async ({ page }) => { const dialog = page.getByRole('dialog', { name: 'Confirm clear default' }) await expect( dialog.getByText( - 'Are you sure you want ip-pool-1 to stop being the default pool for silo maze-war?' + 'Are you sure you want ip-pool-1 to stop being the default IPv4 unicast pool for silo maze-war?' ) ).toBeVisible() From 00e9ae8563e0f38011a54117367abdd270c43ea3 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Sat, 7 Feb 2026 23:06:55 -0600 Subject: [PATCH 4/7] align modal copy --- app/pages/system/networking/IpPoolPage.tsx | 6 +++--- test/e2e/ip-pools.e2e.ts | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 54c4d94e0..4a29a4e7a 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -317,9 +317,9 @@ function LinkedSilosTable() { const modalContent = existingDefault ? (

- The current default {poolKind} pool for silo {silo.name} is{' '} - {existingDefault.name}. Are you sure you want to make{' '} - {pool.name} the default instead? + Are you sure you want to change the default {poolKind} pool for silo{' '} + {silo.name} from {existingDefault.name} to{' '} + {pool.name}?

) : (

diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index 747a066ef..9279b476d 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -155,7 +155,9 @@ test('IP pool silo make default (with existing default)', async ({ page }) => { const dialog = page.getByRole('dialog', { name: 'Confirm change default' }) await expect( - dialog.getByText('The current default IPv4 unicast pool for silo myriad is ip-pool-1.') + 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?' + ) ).toBeVisible() await page.getByRole('button', { name: 'Confirm' }).click() From 56b716af2f6a865a49fa40ae3f21dd6ed431c6ed Mon Sep 17 00:00:00 2001 From: David Crespo Date: Sun, 8 Feb 2026 00:04:50 -0600 Subject: [PATCH 5/7] better invalidation on silo ip pools tab --- app/pages/system/silos/SiloIpPoolsTab.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/pages/system/silos/SiloIpPoolsTab.tsx b/app/pages/system/silos/SiloIpPoolsTab.tsx index 712b32331..de0ee517c 100644 --- a/app/pages/system/silos/SiloIpPoolsTab.tsx +++ b/app/pages/system/silos/SiloIpPoolsTab.tsx @@ -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' }) }, @@ -238,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' }) From 5ec9061f1af8db169250aadd06e7d6f5d0662242 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Sun, 8 Feb 2026 00:04:50 -0600 Subject: [PATCH 6/7] better error handling on confirm modal fetches --- app/pages/system/networking/IpPoolPage.tsx | 117 +++++++++++++-------- 1 file changed, 71 insertions(+), 46 deletions(-) diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 4a29a4e7a..16a4b47bf 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -21,6 +21,7 @@ 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' @@ -64,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)), @@ -275,11 +287,17 @@ function LinkedSilosTable() { { label: link.isDefault ? 'Clear default' : 'Make default', className: link.isDefault ? 'destructive' : undefined, - async onActivate() { - const silo = await queryClient.fetchQuery( - q(api.siloView, { path: { silo: link.siloId } }) + onActivate() { + const silo = queryClient.getQueryData( + 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 + // prettier-ignore + const siloLabel = siloName + ? <>silo {siloName} + : 'that silo' const poolKind = `IP${pool.ipVersion} ${pool.poolType}` if (link.isDefault) { @@ -293,53 +311,60 @@ function LinkedSilosTable() { modalContent: (

Are you sure you want {pool.name} to stop being the default{' '} - {poolKind} pool for silo {silo.name}? If there is no default, - users in this silo will have to specify a pool when allocating IPs. + {poolKind} pool for {siloLabel}? If there is no default, users in this + silo will have to specify a pool when allocating IPs.

), errorTitle: 'Could not clear default', actionType: 'danger', }) } else { - // find existing default for same version/type to warn the user - const siloPools = await queryClient.ensureQueryData( - q(api.siloIpPoolList, { - path: { silo: link.siloId }, - query: { limit: ALL_ISH }, + // 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 ? ( +

+ Are you sure you want to change the default {poolKind} pool for{' '} + {siloLabel} from {existingDefaultName} to {pool.name}? +

+ ) : ( +

+ Are you sure you want to make {pool.name} the default{' '} + {poolKind} pool for {siloLabel}? +

+ ) + + 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', + }) }) - ) - const existingDefault = siloPools.items.find( - (p) => - p.isDefault && - p.ipVersion === pool.ipVersion && - p.poolType === pool.poolType - ) - - const modalContent = existingDefault ? ( -

- Are you sure you want to change the default {poolKind} pool for silo{' '} - {silo.name} from {existingDefault.name} to{' '} - {pool.name}? -

- ) : ( -

- Are you sure you want to make {pool.name} the default {poolKind}{' '} - pool for silo {silo.name}? -

- ) - - const verb = existingDefault ? '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) } }, }, From 8c08fd63d14ae6b037f9bdbff4e53c2b1e709275 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Sun, 8 Feb 2026 09:23:57 -0600 Subject: [PATCH 7/7] use the same logic to improve the unlink modal copy --- app/pages/system/networking/IpPoolPage.tsx | 27 +++++++++++----------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 16a4b47bf..2a3543a93 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -266,6 +266,15 @@ function SiloNameFromId({ value: siloId }: { value: string }) { const silosColHelper = createColumnHelper() +/** Look up silo name from query cache and return a label for use in modals. */ +function getSiloLabel(siloId: string) { + const siloName = queryClient.getQueryData(siloView({ silo: siloId }).queryKey)?.name + // prettier-ignore + return siloName + ? <>silo {siloName} + : 'that silo' +} + function LinkedSilosTable() { const poolSelector = useIpPoolSelector() const { data: pool } = usePrefetchedQuery(ipPoolView(poolSelector)) @@ -288,16 +297,7 @@ function LinkedSilosTable() { label: link.isDefault ? 'Clear default' : 'Make default', className: link.isDefault ? 'destructive' : undefined, onActivate() { - const silo = queryClient.getQueryData( - 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 - // prettier-ignore - const siloLabel = siloName - ? <>silo {siloName} - : 'that silo' + const siloLabel = getSiloLabel(link.siloId) const poolKind = `IP${pool.ipVersion} ${pool.poolType}` if (link.isDefault) { @@ -372,15 +372,16 @@ function LinkedSilosTable() { label: 'Unlink', className: 'destructive', onActivate() { + const siloLabel = getSiloLabel(link.siloId) confirmAction({ doAction: () => unlinkSilo({ path: { silo: link.siloId, pool: link.ipPoolId } }), modalTitle: 'Confirm unlink silo', modalContent: (

- 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 {pool.name}? 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.

), errorTitle: 'Could not unlink silo',