diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index ed6edcb8f..2a3543a93 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -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' @@ -41,6 +42,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 +50,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' @@ -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)), @@ -129,7 +143,7 @@ export default function IpPoolpage() { - + IP ranges @@ -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 ( -
- {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} + + + + + {' / '} + + + + + ) } @@ -256,35 +266,122 @@ 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)) 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: ( +

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

+ ), + 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 ? ( +

+ 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', + }) + }) + // be extra sure we don't have any unhandled promise rejections + .catch(() => null) + } + }, + }, { 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: (

- 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', @@ -293,7 +390,7 @@ function LinkedSilosTable() { }, }, ], - [unlinkSilo] + [pool, unlinkSilo, updateSiloLink] ) const [showLinkModal, setShowLinkModal] = useState(false) @@ -320,8 +417,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/app/pages/system/silos/SiloIpPoolsTab.tsx b/app/pages/system/silos/SiloIpPoolsTab.tsx index 7acb12d79..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' }) }, @@ -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: () => @@ -142,9 +147,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 +157,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 ? (

@@ -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' }) diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index 5cc208985..9279b476d 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() }) }) @@ -124,6 +124,65 @@ 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 IPv4 unicast 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( + '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() + 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 IPv4 unicast 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') @@ -257,10 +316,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 @@ -333,10 +390,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 @@ -374,26 +429,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() })