Skip to content
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>
)
}
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,
},
}
)
const ipPoolQuery = (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(ipPoolQuery(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
Loading