diff --git a/app/components/ExternalIps.tsx b/app/components/ExternalIps.tsx
index 45fc96331..2ce91ba27 100644
--- a/app/components/ExternalIps.tsx
+++ b/app/components/ExternalIps.tsx
@@ -8,21 +8,17 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router'
-import * as R from 'remeda'
-import { api, q, type ExternalIp } from '@oxide/api'
+import { api, q } from '@oxide/api'
import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell'
import { CopyableIp } from '~/ui/lib/CopyableIp'
import { Slash } from '~/ui/lib/Slash'
import { intersperse } from '~/util/array'
+import { orderIps } from '~/util/ip'
import { pb } from '~/util/path-builder'
import type * as PP from '~/util/path-params'
-/** Order IPs: floating first, then ephemeral, then SNAT */
-const IP_ORDER = { floating: 0, ephemeral: 1, snat: 2 } as const
-export const orderIps = (ips: ExternalIp[]) => R.sortBy(ips, (a) => IP_ORDER[a.kind])
-
export function ExternalIps({ project, instance }: PP.Instance) {
const { data, isPending } = useQuery(
q(api.instanceExternalIpList, { path: { instance }, query: { project } })
diff --git a/app/components/ListPlusCell.tsx b/app/components/ListPlusCell.tsx
index e6fd1f801..691391abf 100644
--- a/app/components/ListPlusCell.tsx
+++ b/app/components/ListPlusCell.tsx
@@ -11,6 +11,33 @@ import React from 'react'
import { EmptyCell } from '~/table/cells/EmptyCell'
import { Tooltip } from '~/ui/lib/Tooltip'
+type ListPlusOverflowProps = {
+ tooltipTitle: string
+ /** The overflow items, shown in the tooltip. Renders nothing when empty. */
+ children: React.ReactNode
+}
+
+/**
+ * A `+N` count whose tooltip lists the overflow `children` on hover. Rendered on
+ * its own so a cell can pair it with a richer (e.g. copyable) leading item that
+ * lives outside the overflow group. Renders nothing when there are no children.
+ */
+export const ListPlusOverflow = ({ tooltipTitle, children }: ListPlusOverflowProps) => {
+ const rest = React.Children.toArray(children)
+ if (rest.length === 0) return null
+ const content = (
+
+
{tooltipTitle}
+
{...rest}
+
+ )
+ return (
+
+ +{rest.length}
+
+ )
+}
+
type ListPlusCellProps = {
tooltipTitle: string
children: React.ReactNode
@@ -22,7 +49,7 @@ type ListPlusCellProps = {
* Gives a count with a tooltip that expands to show details when the user hovers over it.
* The ReactNode children are split into two groups: the first `numInCell` are shown in the cell,
* and the rest are shown in the tooltip. If the number of children is less than or equal to
- * `numInCell`, no tooltip (or `+N` target) is shown.
+ * `numInCell`, no tooltip or `+N` is shown.
*/
export const ListPlusCell = ({
tooltipTitle,
@@ -35,20 +62,10 @@ export const ListPlusCell = ({
}
const inCell = array.slice(0, numInCell)
const rest = array.slice(numInCell)
- const content = (
-
-
{tooltipTitle}
-
{...rest}
-
- )
return (
{inCell}
- {rest.length > 0 && (
-
- +{rest.length}
-
- )}
+
{rest}
)
}
diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx
index 19af2687d..21f720526 100644
--- a/app/pages/project/instances/InstancesPage.tsx
+++ b/app/pages/project/instances/InstancesPage.tsx
@@ -26,6 +26,7 @@ import { InstanceDocsPopover } from '~/components/InstanceDocsPopover'
import { RefreshButton } from '~/components/RefreshButton'
import { getProjectSelector, useProjectSelector } from '~/hooks/use-params'
import { useQuickActions } from '~/hooks/use-quick-actions'
+import { ExternalIpsCell } from '~/table/cells/ExternalIpsCell'
import { InstanceStateCell } from '~/table/cells/InstanceStateCell'
import { makeLinkCell } from '~/table/cells/LinkCell'
import { getActionsCol } from '~/table/columns/action-col'
@@ -67,13 +68,32 @@ const instanceList = (
export async function clientLoader({ params }: LoaderFunctionArgs) {
const { project } = getProjectSelector(params)
- await queryClient.prefetchQuery(instanceList(project).optionsFn())
+ const instances = await queryClient.fetchQuery(instanceList(project).optionsFn())
+ // Warm the external IP cache for each instance in parallel as the route
+ // loads. This doesn't add requests: ExternalIpsCell would issue the same
+ // ones on mount, showing a skeleton until its list arrives. Prefetching
+ // just starts them earlier.
+ const ipPrefetches = instances.items.map(({ name: instance }) =>
+ queryClient.prefetchQuery(
+ q(api.instanceExternalIpList, { path: { instance }, query: { project } })
+ )
+ )
+ // Block render on the first few so the top of the table doesn't flash
+ // skeletons, but let the rest roll in after. Awaiting all of them would
+ // block render on the slowest of up to 50 requests, and the odds of hitting
+ // a slow one (while low) grow with N. 6 is a magic number: enough to usually
+ // paint the top of the table without skeletons, but still small. It happens
+ // to match the browser's per-host connection limit, but that only applies
+ // over HTTP/1.1; in production, Nexus uses HTTP/2, so requests multiplex and
+ // that limit shouldn't matter.
+ await Promise.all(ipPrefetches.slice(0, 6))
return null
}
const refetchInstances = () =>
Promise.all([
queryClient.invalidateEndpoint('instanceList'),
+ queryClient.invalidateEndpoint('instanceExternalIpList'),
queryClient.invalidateEndpoint('antiAffinityGroupMemberList'),
])
@@ -101,6 +121,13 @@ export default function InstancesPage() {
colHelper.accessor('name', {
cell: makeLinkCell((instance) => pb.instance({ project, instance })),
}),
+ colHelper.accessor(
+ (i) => ({ runState: i.runState, timeRunStateUpdated: i.timeRunStateUpdated }),
+ {
+ header: 'state',
+ cell: (info) => ,
+ }
+ ),
colHelper.accessor('ncpus', {
header: 'CPU',
cell: (info) => (
@@ -121,13 +148,13 @@ export default function InstancesPage() {
)
},
}),
- colHelper.accessor(
- (i) => ({ runState: i.runState, timeRunStateUpdated: i.timeRunStateUpdated }),
- {
- header: 'state',
- cell: (info) => ,
- }
- ),
+ colHelper.display({
+ id: 'externalIps',
+ header: 'External IPs',
+ cell: (info) => (
+
+ ),
+ }),
colHelper.accessor('timeCreated', Columns.timeCreated),
getActionsCol((instance: Instance) => [
...makeButtonActions(instance),
diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx
index b2ffbccce..6aef91adc 100644
--- a/app/pages/project/instances/NetworkingTab.tsx
+++ b/app/pages/project/instances/NetworkingTab.tsx
@@ -32,7 +32,6 @@ import { Badge } from '@oxide/design-system/ui'
import { AttachEphemeralIpModal } from '~/components/AttachEphemeralIpModal'
import { AttachFloatingIpModal } from '~/components/AttachFloatingIpModal'
-import { orderIps } from '~/components/ExternalIps'
import { ListboxField } from '~/components/form/fields/ListboxField'
import { ModalForm } from '~/components/form/ModalForm'
import { HL } from '~/components/HL'
@@ -68,6 +67,7 @@ import {
getCompatibleVersionsFromNics,
getEphemeralIpSlots,
ipHasVersion,
+ orderIps,
parseIp,
} from '~/util/ip'
import { pb } from '~/util/path-builder'
diff --git a/app/table/cells/ExternalIpsCell.tsx b/app/table/cells/ExternalIpsCell.tsx
new file mode 100644
index 000000000..d3e9b94ff
--- /dev/null
+++ b/app/table/cells/ExternalIpsCell.tsx
@@ -0,0 +1,50 @@
+/*
+ * 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 { useQuery } from '@tanstack/react-query'
+
+import { api, q } from '@oxide/api'
+
+import { ListPlusOverflow } from '~/components/ListPlusCell'
+import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell'
+import { CopyableIp } from '~/ui/lib/CopyableIp'
+import { orderIps } from '~/util/ip'
+import type * as PP from '~/util/path-params'
+
+// Shows the instance's first external IP (copyable), plus a `+N` tooltip.
+// SNAT IPs are excluded because they can't receive inbound traffic, so are
+// rarely the "external IP" a user is looking for. This might change with
+// https://github.com/oxidecomputer/omicron/issues/4317
+export function ExternalIpsCell({ project, instance }: PP.Instance) {
+ const { data, isPending } = useQuery(
+ q(
+ api.instanceExternalIpList,
+ { path: { instance }, query: { project } },
+ // The instance may have just been deleted while its row is still
+ // rendered (e.g., delete invalidates this list concurrently with the
+ // instance list). This prevents a 404 from taking down the whole
+ // table. Instead, the cell will just render as empty.
+ { throwOnError: false }
+ )
+ )
+ if (isPending) return
+
+ const [first, ...rest] = orderIps((data?.items || []).filter((ip) => ip.kind !== 'snat'))
+ if (!first) return
+
+ return (
+
+ {/* only the leading IP is copyable; the rest live in the +N tooltip */}
+
+
+ {rest.map((ip) => (
+ {ip.ip}
+ ))}
+
+
+ )
+}
diff --git a/app/util/ip.ts b/app/util/ip.ts
index 7e1f6e768..b1452531b 100644
--- a/app/util/ip.ts
+++ b/app/util/ip.ts
@@ -6,9 +6,15 @@
* Copyright Oxide Computer Company
*/
+import * as R from 'remeda'
+
import type { ExternalIp, InstanceNetworkInterface, IpVersion, UnicastIpPool } from '~/api'
import { setDiff, setIntersection } from '~/util/array'
+/** Order IPs: floating first, then ephemeral, then SNAT */
+const IP_ORDER = { floating: 0, ephemeral: 1, snat: 2 } as const
+export const orderIps = (ips: ExternalIp[]) => R.sortBy(ips, (a) => IP_ORDER[a.kind])
+
// Borrowed from Valibot. I tried some from Zod and an O'Reilly regex cookbook
// but they didn't match results with std::net on simple test cases
// https://github.com/fabian-hiller/valibot/blob/2554aea5/library/src/regex.ts#L43-L54
diff --git a/mock-api/external-ip.ts b/mock-api/external-ip.ts
index 0874c7b48..dd04a197e 100644
--- a/mock-api/external-ip.ts
+++ b/mock-api/external-ip.ts
@@ -7,7 +7,13 @@
*/
import type { ExternalIp } from '@oxide/api'
-import { failedInstance, instance, instanceDb2, startingInstance } from './instance'
+import {
+ failedInstance,
+ instance,
+ instanceDb2,
+ instanceDb3,
+ startingInstance,
+} from './instance'
import { ipPool1 } from './ip-pool'
import type { Json } from './json-type'
@@ -63,6 +69,16 @@ export const ephemeralIps: DbExternalIp[] = [
kind: 'ephemeral',
},
},
+ {
+ instance_id: instanceDb3.id,
+ external_ip: {
+ // careful: addresses in this file must not collide with the floating IPs,
+ // or the pool utilization numbers get confusing (they dedupe by address)
+ ip: '123.4.56.7',
+ ip_pool_id: ipPool1.id,
+ kind: 'ephemeral',
+ },
+ },
]
// Note that SNAT IPs are subdivided into four ranges of ports,
diff --git a/mock-api/instance.ts b/mock-api/instance.ts
index 7fb5aa029..60d6c3715 100644
--- a/mock-api/instance.ts
+++ b/mock-api/instance.ts
@@ -144,6 +144,19 @@ export const stoppedInstance: Json = {
boot_disk_id: 'f5bc2085-d18e-4698-86ab-69c62a74e541', // disk-stopped-boot
}
+// 7th instance in mock-project: the instances page loader only awaits external
+// IP prefetches for the first 6 instances, so this one exercises the
+// unawaited-prefetch path in ExternalIpsCell
+export const instanceDb3: Json = {
+ ...base,
+ id: 'a7abaacd-0721-4885-8db5-e743ee061d2b',
+ name: 'db3',
+ description: 'a third database instance',
+ hostname: 'oxide.com',
+ project_id: project.id,
+ run_state: 'running',
+}
+
export const instances: Json[] = [
instance,
failedInstance,
@@ -154,4 +167,5 @@ export const instances: Json[] = [
instanceUpdateError,
instanceDb2,
stoppedInstance,
+ instanceDb3,
]
diff --git a/test/e2e/combobox.e2e.ts b/test/e2e/combobox.e2e.ts
index 4e05adc57..45aa43b90 100644
--- a/test/e2e/combobox.e2e.ts
+++ b/test/e2e/combobox.e2e.ts
@@ -178,6 +178,7 @@ test('arbitrary-values combobox keeps typed values and resets submitted fields',
'instance-update-error',
'db2',
'db-stopped',
+ 'db3',
])
await instanceInput.fill('d')
@@ -186,6 +187,7 @@ test('arbitrary-values combobox keeps typed values and resets submitted fields',
'instance-update-error',
'db2',
'db-stopped',
+ 'db3',
'Custom: d',
])
@@ -200,6 +202,7 @@ test('arbitrary-values combobox keeps typed values and resets submitted fields',
'instance-update-error',
'db2',
'db-stopped',
+ 'db3',
'Custom: d',
])
diff --git a/test/e2e/instance.e2e.ts b/test/e2e/instance.e2e.ts
index ec18e6741..256e6d227 100644
--- a/test/e2e/instance.e2e.ts
+++ b/test/e2e/instance.e2e.ts
@@ -38,6 +38,38 @@ test('can delete a failed instance', async ({ page }) => {
await expect(cell).toBeHidden() // bye
})
+test('shows external IPs on the instances table', async ({ page }) => {
+ await page.goto('/projects/mock-project/instances')
+ const table = page.getByRole('table')
+
+ // db1 has a floating IP (123.4.56.5, sorted first) and an ephemeral one, so it
+ // shows the first plus a +1 overflow
+ await expectRowVisible(table, {
+ name: 'db1',
+ 'External IPs': expect.stringMatching(/123\.4\.56\.5.*\+1/),
+ })
+ // only the leading IP is copyable; the overflow IPs live in the tooltip
+ await expect(
+ table.getByRole('row', { name: 'db1' }).getByRole('button', { name: 'Click to copy' })
+ ).toHaveCount(1)
+ // hovering the +1 reveals the other external IP in a tooltip
+ await table.getByRole('row', { name: 'db1' }).getByText('+1').hover()
+ await expect(page.getByText('Other external IPs')).toBeVisible()
+ await expect(page.getByText('123.4.56.0')).toBeVisible()
+
+ // not-there-yet has three ephemeral IPs, so it shows the first plus a +2 overflow
+ await expect(
+ table.getByRole('row', { name: 'not-there-yet' }).getByText('+2')
+ ).toBeVisible()
+
+ // you-fail has only a SNAT IP, which is excluded, so the cell is empty
+ await expectRowVisible(table, { name: 'you-fail', 'External IPs': '—' })
+
+ // db3 is the 7th instance, so its IP prefetch is not awaited by the loader:
+ // the cell renders a skeleton first and fills in when the query lands
+ await expectRowVisible(table, { name: 'db3', 'External IPs': '123.4.56.7' })
+})
+
test('can start a failed instance', async ({ page }) => {
await page.goto('/projects/mock-project/instances')
diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts
index fa7455bc4..5e6b19f30 100644
--- a/test/e2e/ip-pools.e2e.ts
+++ b/test/e2e/ip-pools.e2e.ts
@@ -23,7 +23,7 @@ test('IP pool list', async ({ page }) => {
await expectRowVisible(table, {
name: 'ip-pool-1',
- 'IPs REMAINING': '16 / 24',
+ 'IPs REMAINING': '15 / 24',
})
await expectRowVisible(table, {
name: 'ip-pool-2',
@@ -424,7 +424,7 @@ test('remove range', async ({ page }) => {
await expect(table.getByRole('row')).toHaveCount(2)
// utilization updates in properties table
- await expect(page.getByText('13 / 21')).toBeVisible()
+ await expect(page.getByText('12 / 21')).toBeVisible()
// go back to the pool and verify the remaining/capacity columns changed
// use the topbar breadcrumb to get there
@@ -432,7 +432,7 @@ test('remove range', async ({ page }) => {
await breadcrumbs.getByRole('link', { name: 'IP Pools' }).click()
await expectRowVisible(table, {
name: 'ip-pool-1',
- 'IPs REMAINING': '13 / 21',
+ 'IPs REMAINING': '12 / 21',
})
})
@@ -441,7 +441,7 @@ test('deleting floating IP decrements utilization', async ({ page }) => {
const table = page.getByRole('table')
await expectRowVisible(table, {
name: 'ip-pool-1',
- 'IPs REMAINING': '16 / 24',
+ 'IPs REMAINING': '15 / 24',
})
// go delete a floating IP
@@ -458,7 +458,7 @@ test('deleting floating IP decrements utilization', async ({ page }) => {
await page.getByRole('link', { name: 'IP Pools' }).click()
await expectRowVisible(table, {
name: 'ip-pool-1',
- 'IPs REMAINING': '17 / 24',
+ 'IPs REMAINING': '16 / 24',
})
})
@@ -469,7 +469,7 @@ test('IPs remaining in properties table', async ({ page }) => {
// 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('15 / 24')).toBeVisible()
// large IPv6 pool shows abbreviated bignum
await page.goto('/system/networking/ip-pools/ip-pool-4')