diff --git a/app/components/IpPoolDetailSideModal.tsx b/app/components/IpPoolDetailSideModal.tsx
new file mode 100644
index 000000000..7b74063fb
--- /dev/null
+++ b/app/components/IpPoolDetailSideModal.tsx
@@ -0,0 +1,51 @@
+/*
+ * 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 { 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
+ onDismiss: () => void
+}
+
+export function IpPoolDetailSideModal({ pool, onDismiss }: IpPoolDetailSideModalProps) {
+ return (
+
+ {pool.name}
+
+ }
+ >
+
+
+
+
+
+
+
+ {pool.poolType}
+
+
+
+
+
+
+ )
+}
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 fc234191b..65b62fe38 100644
--- a/app/table/cells/IpPoolCell.tsx
+++ b/app/table/cells/IpPoolCell.tsx
@@ -6,33 +6,52 @@
* Copyright Oxide Computer Company
*/
import { useQuery } from '@tanstack/react-query'
+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'
-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,
- },
- }
- )
+export const ipPoolErrorsAllowedQuery = (ipPoolId: string) =>
+ qErrorsAllowed(
+ api.ipPoolView,
+ { path: { pool: ipPoolId } },
+ {
+ errorsExpected: {
+ explanation: 'the referenced IP pool may have been deleted.',
+ statusCode: 404,
+ },
+ }
)
+
+/**
+ * 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 shows the
+ * description in a tooltip.
+ */
+export const IpPoolCell = ({ ipPoolId }: { ipPoolId: string }) => {
+ const inSideModal = useIsInSideModal()
+ const [showDetail, setShowDetail] = useState(false)
+ 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.
if (result.type === 'error') return
const pool = result.data
- return (
+ return inSideModal ? (
{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..fee8a7df6 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 dialog.locator('footer').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')
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()