From 7ee480b66cd8cfe733a80438740e6d14e3e9a127 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 27 Mar 2026 16:51:33 -0700 Subject: [PATCH 01/10] Add IP pool sidebar --- app/components/IpPoolDetailSideModal.tsx | 48 +++++++++++++++++++ app/forms/floating-ip-edit.tsx | 2 +- .../project/vpcs/internet-gateway-edit.tsx | 2 +- app/table/cells/IpPoolCell.tsx | 48 ++++++++++++------- test/e2e/floating-ip-create.e2e.ts | 17 +++++++ 5 files changed, 98 insertions(+), 19 deletions(-) create mode 100644 app/components/IpPoolDetailSideModal.tsx diff --git a/app/components/IpPoolDetailSideModal.tsx b/app/components/IpPoolDetailSideModal.tsx new file mode 100644 index 000000000..5b07a21c7 --- /dev/null +++ b/app/components/IpPoolDetailSideModal.tsx @@ -0,0 +1,48 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { type IpPool } from '@oxide/api' +import { IpGlobal16Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' +import { IpVersionBadge } from '~/components/IpVersionBadge' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { ResourceLabel } from '~/ui/lib/SideModal' + +type IpPoolDetailSideModalProps = { + pool: IpPool + onDismiss: () => void +} + +export function IpPoolDetailSideModal({ pool, onDismiss }: IpPoolDetailSideModalProps) { + return ( + + {pool.name} + + } + > + + + + + + + + {pool.poolType} + + + + + + ) +} diff --git a/app/forms/floating-ip-edit.tsx b/app/forms/floating-ip-edit.tsx index 960f2e71e..02b522ed7 100644 --- a/app/forms/floating-ip-edit.tsx +++ b/app/forms/floating-ip-edit.tsx @@ -107,7 +107,7 @@ export default function EditFloatingIpSideModalForm() { - + diff --git a/app/pages/project/vpcs/internet-gateway-edit.tsx b/app/pages/project/vpcs/internet-gateway-edit.tsx index 5d5ac415d..19e382107 100644 --- a/app/pages/project/vpcs/internet-gateway-edit.tsx +++ b/app/pages/project/vpcs/internet-gateway-edit.tsx @@ -173,7 +173,7 @@ export default function EditInternetGatewayForm() { {gatewayIpPool.name} - + )) diff --git a/app/table/cells/IpPoolCell.tsx b/app/table/cells/IpPoolCell.tsx index fc234191b..155910c83 100644 --- a/app/table/cells/IpPoolCell.tsx +++ b/app/table/cells/IpPoolCell.tsx @@ -1,3 +1,4 @@ +import { useQuery } from '@tanstack/react-query' /* * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -5,34 +6,47 @@ * * Copyright Oxide Computer Company */ -import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' import { api, qErrorsAllowed } from '~/api' -import { Tooltip } from '~/ui/lib/Tooltip' +import { IpPoolDetailSideModal } from '~/components/IpPoolDetailSideModal' import { EmptyCell, SkeletonCell } from './EmptyCell' +import { ButtonCell } from './LinkCell' -export const IpPoolCell = ({ ipPoolId }: { ipPoolId: string }) => { - const { data: result } = useQuery( - qErrorsAllowed( - api.ipPoolView, - { path: { pool: ipPoolId } }, - { - errorsExpected: { - explanation: 'the referenced IP pool may have been deleted.', - statusCode: 404, - }, - } - ) +const ipPoolQuery = (ipPoolId: string) => + qErrorsAllowed( + api.ipPoolView, + { path: { pool: ipPoolId } }, + { + errorsExpected: { + explanation: 'the referenced IP pool may have been deleted.', + statusCode: 404, + }, + } ) + +type IpPoolCellProps = { + ipPoolId: string + /** Show the IP pool detail sidebar on click. Defaults to true. Pass false to render as plain text. */ + showPoolInfo?: boolean +} + +export const IpPoolCell = ({ ipPoolId, showPoolInfo = true }: IpPoolCellProps) => { + const [showDetail, setShowDetail] = useState(false) + const { data: result } = useQuery(ipPoolQuery(ipPoolId)) if (!result) return // Defensive: the error case should never happen in practice. It should not be // possible for a resource to reference a pool without that pool existing. if (result.type === 'error') return const pool = result.data + if (!showPoolInfo) return <>{pool.name} return ( - - {pool.name} - + <> + setShowDetail(true)}>{pool.name} + {showDetail && ( + setShowDetail(false)} /> + )} + ) } diff --git a/test/e2e/floating-ip-create.e2e.ts b/test/e2e/floating-ip-create.e2e.ts index c33bfeef4..44eae2767 100644 --- a/test/e2e/floating-ip-create.e2e.ts +++ b/test/e2e/floating-ip-create.e2e.ts @@ -50,6 +50,23 @@ test('can create a floating IP', async ({ page }) => { }) }) +test('can view IP pool details from floating IP table', async ({ page }) => { + await page.goto(floatingIpsPage) + + // cola-float is in ip-pool-1; click the pool cell to open the detail modal + const row = page.getByRole('row', { name: /cola-float/ }) + await row.getByRole('button', { name: 'ip-pool-1' }).click() + + const dialog = page.getByRole('dialog', { name: 'IP pool details' }) + await expect(dialog).toBeVisible() + await expect(dialog.getByText('public IPs')).toBeVisible() + await expect(dialog.getByText('v4')).toBeVisible() + await expect(dialog.getByText('unicast')).toBeVisible() + + await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() + await expect(dialog).toBeHidden() +}) + test('can detach and attach a floating IP', async ({ page }) => { // check floating IP is visible on instance detail await page.goto('/projects/mock-project/instances/db1') From e33b2459cdcab46a6b358895df5ab6481e4ddbd0 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 27 Mar 2026 16:54:13 -0700 Subject: [PATCH 02/10] Add relevant docs section --- app/components/IpPoolDetailSideModal.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/components/IpPoolDetailSideModal.tsx b/app/components/IpPoolDetailSideModal.tsx index 5b07a21c7..90f044b94 100644 --- a/app/components/IpPoolDetailSideModal.tsx +++ b/app/components/IpPoolDetailSideModal.tsx @@ -11,8 +11,10 @@ import { Badge } from '@oxide/design-system/ui' import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' import { IpVersionBadge } from '~/components/IpVersionBadge' +import { SideModalFormDocs } from '~/ui/lib/ModalLinks' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { ResourceLabel } from '~/ui/lib/SideModal' +import { docLinks } from '~/util/links' type IpPoolDetailSideModalProps = { pool: IpPool @@ -43,6 +45,7 @@ export function IpPoolDetailSideModal({ pool, onDismiss }: IpPoolDetailSideModal + ) } From 765d2507c9e8c5d461a8439d089a253001d1d86b Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 27 Mar 2026 17:17:33 -0700 Subject: [PATCH 03/10] linter fix --- app/table/cells/IpPoolCell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/table/cells/IpPoolCell.tsx b/app/table/cells/IpPoolCell.tsx index 155910c83..7a383e178 100644 --- a/app/table/cells/IpPoolCell.tsx +++ b/app/table/cells/IpPoolCell.tsx @@ -1,4 +1,3 @@ -import { useQuery } from '@tanstack/react-query' /* * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -6,6 +5,7 @@ import { useQuery } from '@tanstack/react-query' * * Copyright Oxide Computer Company */ +import { useQuery } from '@tanstack/react-query' import { useState } from 'react' import { api, qErrorsAllowed } from '~/api' From 349fe40b9b20db7c761928f80d8da92dc1ec31b3 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 6 Apr 2026 15:51:57 +0200 Subject: [PATCH 04/10] Update label: Pool type -> Type --- app/components/IpPoolDetailSideModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/IpPoolDetailSideModal.tsx b/app/components/IpPoolDetailSideModal.tsx index 90f044b94..7b74063fb 100644 --- a/app/components/IpPoolDetailSideModal.tsx +++ b/app/components/IpPoolDetailSideModal.tsx @@ -39,7 +39,7 @@ export function IpPoolDetailSideModal({ pool, onDismiss }: IpPoolDetailSideModal - + {pool.poolType} From e271bcba95cc5c2622afe554c2f18c54d62cadad Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 1 May 2026 11:24:41 -0400 Subject: [PATCH 05/10] Use context to determine IP linking --- app/forms/floating-ip-edit.tsx | 2 +- .../project/vpcs/internet-gateway-edit.tsx | 2 +- app/table/cells/IpPoolCell.tsx | 17 +++++++++-------- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/app/forms/floating-ip-edit.tsx b/app/forms/floating-ip-edit.tsx index 02b522ed7..960f2e71e 100644 --- a/app/forms/floating-ip-edit.tsx +++ b/app/forms/floating-ip-edit.tsx @@ -107,7 +107,7 @@ export default function EditFloatingIpSideModalForm() { - + diff --git a/app/pages/project/vpcs/internet-gateway-edit.tsx b/app/pages/project/vpcs/internet-gateway-edit.tsx index 19e382107..5d5ac415d 100644 --- a/app/pages/project/vpcs/internet-gateway-edit.tsx +++ b/app/pages/project/vpcs/internet-gateway-edit.tsx @@ -173,7 +173,7 @@ export default function EditInternetGatewayForm() { {gatewayIpPool.name} - + )) diff --git a/app/table/cells/IpPoolCell.tsx b/app/table/cells/IpPoolCell.tsx index 7a383e178..671449b52 100644 --- a/app/table/cells/IpPoolCell.tsx +++ b/app/table/cells/IpPoolCell.tsx @@ -10,6 +10,7 @@ import { useState } from 'react' import { api, qErrorsAllowed } from '~/api' import { IpPoolDetailSideModal } from '~/components/IpPoolDetailSideModal' +import { useIsInSideModal } from '~/ui/lib/modal-context' import { EmptyCell, SkeletonCell } from './EmptyCell' import { ButtonCell } from './LinkCell' @@ -26,13 +27,13 @@ const ipPoolQuery = (ipPoolId: string) => } ) -type IpPoolCellProps = { - ipPoolId: string - /** Show the IP pool detail sidebar on click. Defaults to true. Pass false to render as plain text. */ - showPoolInfo?: boolean -} - -export const IpPoolCell = ({ ipPoolId, showPoolInfo = true }: IpPoolCellProps) => { +/** + * Renders an IP pool name. In a table cell, clicking opens a side modal with + * pool details. Inside a side modal (detected via context) it falls back to + * plain text to avoid stacking a second side modal on top of the first. + */ +export const IpPoolCell = ({ ipPoolId }: { ipPoolId: string }) => { + const inSideModal = useIsInSideModal() const [showDetail, setShowDetail] = useState(false) const { data: result } = useQuery(ipPoolQuery(ipPoolId)) if (!result) return @@ -40,7 +41,7 @@ export const IpPoolCell = ({ ipPoolId, showPoolInfo = true }: IpPoolCellProps) = // possible for a resource to reference a pool without that pool existing. if (result.type === 'error') return const pool = result.data - if (!showPoolInfo) return <>{pool.name} + if (inSideModal) return <>{pool.name} return ( <> setShowDetail(true)}>{pool.name} From fd9b3cc63405ba65a3f2a3ddf6dc948a5b87ab83 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 4 May 2026 10:26:59 -0400 Subject: [PATCH 06/10] Slight tweak to e2e locator --- test/e2e/floating-ip-create.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/floating-ip-create.e2e.ts b/test/e2e/floating-ip-create.e2e.ts index 44eae2767..fee8a7df6 100644 --- a/test/e2e/floating-ip-create.e2e.ts +++ b/test/e2e/floating-ip-create.e2e.ts @@ -63,7 +63,7 @@ test('can view IP pool details from floating IP table', async ({ page }) => { await expect(dialog.getByText('v4')).toBeVisible() await expect(dialog.getByText('unicast')).toBeVisible() - await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() + await dialog.locator('footer').getByRole('button', { name: 'Close' }).click() await expect(dialog).toBeHidden() }) From 58b7ca4b7eb037bad0d152030eade12755546032 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 2 Jun 2026 13:00:27 -0700 Subject: [PATCH 07/10] keep tooltip in sidebar to see IP Pool description --- app/table/cells/IpPoolCell.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/app/table/cells/IpPoolCell.tsx b/app/table/cells/IpPoolCell.tsx index 671449b52..e87d0181a 100644 --- a/app/table/cells/IpPoolCell.tsx +++ b/app/table/cells/IpPoolCell.tsx @@ -11,6 +11,7 @@ import { useState } from 'react' import { api, qErrorsAllowed } from '~/api' import { IpPoolDetailSideModal } from '~/components/IpPoolDetailSideModal' import { useIsInSideModal } from '~/ui/lib/modal-context' +import { Tooltip } from '~/ui/lib/Tooltip' import { EmptyCell, SkeletonCell } from './EmptyCell' import { ButtonCell } from './LinkCell' @@ -29,8 +30,8 @@ const ipPoolQuery = (ipPoolId: string) => /** * Renders an IP pool name. In a table cell, clicking opens a side modal with - * pool details. Inside a side modal (detected via context) it falls back to - * plain text to avoid stacking a second side modal on top of the first. + * pool details. Inside a side modal (detected via context) it shows the + * description in a tooltip. */ export const IpPoolCell = ({ ipPoolId }: { ipPoolId: string }) => { const inSideModal = useIsInSideModal() @@ -41,7 +42,13 @@ export const IpPoolCell = ({ ipPoolId }: { ipPoolId: string }) => { // possible for a resource to reference a pool without that pool existing. if (result.type === 'error') return const pool = result.data - if (inSideModal) return <>{pool.name} + if (inSideModal) { + return ( + + {pool.name} + + ) + } return ( <> setShowDetail(true)}>{pool.name} From 6bc1ac2e32d49b181de25f9435886be91a2e108b Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 3 Jun 2026 09:37:39 -0700 Subject: [PATCH 08/10] cleanup with a ternary --- app/table/cells/IpPoolCell.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/app/table/cells/IpPoolCell.tsx b/app/table/cells/IpPoolCell.tsx index e87d0181a..e4c5efc33 100644 --- a/app/table/cells/IpPoolCell.tsx +++ b/app/table/cells/IpPoolCell.tsx @@ -42,14 +42,11 @@ export const IpPoolCell = ({ ipPoolId }: { ipPoolId: string }) => { // possible for a resource to reference a pool without that pool existing. if (result.type === 'error') return const pool = result.data - if (inSideModal) { - return ( - - {pool.name} - - ) - } - return ( + return inSideModal ? ( + + {pool.name} + + ) : ( <> setShowDetail(true)}>{pool.name} {showDetail && ( From 39d113262b60f65282e36937329410d2b4f612ed Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 3 Jun 2026 15:58:51 -0500 Subject: [PATCH 09/10] add a tiny test assert for the non-button side modal cell --- test/e2e/floating-ip-update.e2e.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/e2e/floating-ip-update.e2e.ts b/test/e2e/floating-ip-update.e2e.ts index 065358f48..7aeb8498d 100644 --- a/test/e2e/floating-ip-update.e2e.ts +++ b/test/e2e/floating-ip-update.e2e.ts @@ -34,6 +34,8 @@ test('can update a floating IP', async ({ page }) => { // Properties table should show resolved instance and pool names const dialog = page.getByRole('dialog') await expect(dialog.getByText('ip-pool-1')).toBeVisible() + // IP pool cells inside side modals should not open nested side modals + await expect(dialog.getByRole('button', { name: 'ip-pool-1' })).toBeHidden() // cola-float is attached to db1 await expect(dialog.getByRole('link', { name: 'db1' })).toBeVisible() From 0c181ce8b740a323b79599e9d6e8b77b9a277520 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 3 Jun 2026 16:02:37 -0500 Subject: [PATCH 10/10] fix pre-existing RQ cache bug: seed errors allowed IP pool query --- app/pages/project/floating-ips/FloatingIpsPage.tsx | 10 +++++----- app/pages/project/vpcs/VpcGatewaysTab.tsx | 12 ++++++------ app/table/cells/IpPoolCell.tsx | 4 ++-- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/pages/project/floating-ips/FloatingIpsPage.tsx b/app/pages/project/floating-ips/FloatingIpsPage.tsx index d67476c1d..8339f6777 100644 --- a/app/pages/project/floating-ips/FloatingIpsPage.tsx +++ b/app/pages/project/floating-ips/FloatingIpsPage.tsx @@ -34,7 +34,7 @@ import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { InstanceLink } from '~/table/cells/InstanceLinkCell' -import { IpPoolCell } from '~/table/cells/IpPoolCell' +import { IpPoolCell, ipPoolErrorsAllowedQuery } from '~/table/cells/IpPoolCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' import { useQueryTable } from '~/table/QueryTable' @@ -78,10 +78,10 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { .fetchQuery(q(api.ipPoolList, { query: { limit: ALL_ISH } })) .then((pools) => { for (const pool of pools.items) { - const { queryKey } = q(api.ipPoolView, { - path: { pool: pool.id }, - }) - queryClient.setQueryData(queryKey, pool) + // IpPoolCell uses the errors-allowed query shape, so seed that exact + // cache entry instead of the normal ipPoolView query. + const { queryKey } = ipPoolErrorsAllowedQuery(pool.id) + queryClient.setQueryData(queryKey, { type: 'success', data: pool }) } }), ]) diff --git a/app/pages/project/vpcs/VpcGatewaysTab.tsx b/app/pages/project/vpcs/VpcGatewaysTab.tsx index 7ae397c11..bbb095afa 100644 --- a/app/pages/project/vpcs/VpcGatewaysTab.tsx +++ b/app/pages/project/vpcs/VpcGatewaysTab.tsx @@ -11,10 +11,10 @@ import { createColumnHelper } from '@tanstack/react-table' import { useMemo } from 'react' import { Outlet, type LoaderFunctionArgs } from 'react-router' -import { api, getListQFn, q, queryClient, type InternetGateway } from '~/api' +import { api, getListQFn, queryClient, type InternetGateway } from '~/api' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { EmptyCell } from '~/table/cells/EmptyCell' -import { IpPoolCell } from '~/table/cells/IpPoolCell' +import { IpPoolCell, ipPoolErrorsAllowedQuery } from '~/table/cells/IpPoolCell' import { LinkCell, makeLinkCell } from '~/table/cells/LinkCell' import { Columns } from '~/table/columns/common' import { useQueryTable } from '~/table/QueryTable' @@ -83,10 +83,10 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { ), queryClient.fetchQuery(projectIpPoolList.optionsFn()).then((pools) => { for (const pool of pools.items) { - const { queryKey } = q(api.ipPoolView, { - path: { pool: pool.id }, - }) - queryClient.setQueryData(queryKey, pool) + // IpPoolCell uses the errors-allowed query shape, so seed that exact + // cache entry instead of the normal ipPoolView query. + const { queryKey } = ipPoolErrorsAllowedQuery(pool.id) + queryClient.setQueryData(queryKey, { type: 'success', data: pool }) } }), ] satisfies Promise[]) diff --git a/app/table/cells/IpPoolCell.tsx b/app/table/cells/IpPoolCell.tsx index e4c5efc33..65b62fe38 100644 --- a/app/table/cells/IpPoolCell.tsx +++ b/app/table/cells/IpPoolCell.tsx @@ -16,7 +16,7 @@ import { Tooltip } from '~/ui/lib/Tooltip' import { EmptyCell, SkeletonCell } from './EmptyCell' import { ButtonCell } from './LinkCell' -const ipPoolQuery = (ipPoolId: string) => +export const ipPoolErrorsAllowedQuery = (ipPoolId: string) => qErrorsAllowed( api.ipPoolView, { path: { pool: ipPoolId } }, @@ -36,7 +36,7 @@ const ipPoolQuery = (ipPoolId: string) => export const IpPoolCell = ({ ipPoolId }: { ipPoolId: string }) => { const inSideModal = useIsInSideModal() const [showDetail, setShowDetail] = useState(false) - const { data: result } = useQuery(ipPoolQuery(ipPoolId)) + const { data: result } = useQuery(ipPoolErrorsAllowedQuery(ipPoolId)) if (!result) return // Defensive: the error case should never happen in practice. It should not be // possible for a resource to reference a pool without that pool existing.