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
51 changes: 51 additions & 0 deletions app/components/IpPoolDetailSideModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ReadOnlySideModalForm
title="IP pool details"
onDismiss={onDismiss}
animate
subtitle={
<ResourceLabel>
<IpGlobal16Icon /> {pool.name}
</ResourceLabel>
}
>
<PropertiesTable>
<PropertiesTable.IdRow id={pool.id} />
<PropertiesTable.DescriptionRow description={pool.description} sideModal />
<PropertiesTable.Row label="IP version">
<IpVersionBadge ipVersion={pool.ipVersion} />
</PropertiesTable.Row>
<PropertiesTable.Row label="Type">
<Badge color="neutral">{pool.poolType}</Badge>
</PropertiesTable.Row>
<PropertiesTable.DateRow label="Created" date={pool.timeCreated} />
<PropertiesTable.DateRow label="Last Modified" date={pool.timeModified} />
</PropertiesTable>
<SideModalFormDocs docs={[docLinks.systemIpPools]} />
</ReadOnlySideModalForm>
)
}
10 changes: 5 additions & 5 deletions app/pages/project/floating-ips/FloatingIpsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Copy link
Copy Markdown
Collaborator

@david-crespo david-crespo Jun 3, 2026

Choose a reason for hiding this comment

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

The robot noticed this pre-existing bug: the point of this is to pre-cache the data for the IP pool cells, but it doesn't work because the IP pool cells use the errors-allowed query, which has a different query key. I confirmed that fixing this eliminates the loading state from the cells.

// 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 })
}
}),
])
Expand Down
12 changes: 6 additions & 6 deletions app/pages/project/vpcs/VpcGatewaysTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<unknown>[])
Expand Down
45 changes: 32 additions & 13 deletions app/table/cells/IpPoolCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <SkeletonCell />
// 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 <EmptyCell />
const pool = result.data
return (
return inSideModal ? (
<Tooltip content={pool.description} placement="right">
<span>{pool.name}</span>
</Tooltip>
) : (
<>
<ButtonCell onClick={() => setShowDetail(true)}>{pool.name}</ButtonCell>
{showDetail && (
<IpPoolDetailSideModal pool={pool} onDismiss={() => setShowDetail(false)} />
)}
</>
)
}
17 changes: 17 additions & 0 deletions test/e2e/floating-ip-create.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
2 changes: 2 additions & 0 deletions test/e2e/floating-ip-update.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading