Skip to content

Commit 398e00c

Browse files
Add External IPs column to Instances page (#3284)
This adds a column to the Instances table, showing an external IP for each instance. Any extra external IPs are included in a tooltip. We don't show SNAT IPs, but do list Floating IPs first, then Ephemeral IPs. The sort order is the same as the "External IPs" cell in the header of the Instance Page. We include a CopyToClipboard icon/button in the cell as well. Closes #2484 <img width="1175" height="448" alt="Screenshot 2026-07-07 at 3 57 46 PM" src="https://github.com/user-attachments/assets/688072b3-4844-4c2e-8b63-18e4850a16ce" /> --------- Co-authored-by: David Crespo <david.crespo@oxidecomputer.com>
1 parent d83024e commit 398e00c

11 files changed

Lines changed: 195 additions & 34 deletions

File tree

app/components/ExternalIps.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,17 @@
88

99
import { useQuery } from '@tanstack/react-query'
1010
import { Link } from 'react-router'
11-
import * as R from 'remeda'
1211

13-
import { api, q, type ExternalIp } from '@oxide/api'
12+
import { api, q } from '@oxide/api'
1413

1514
import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell'
1615
import { CopyableIp } from '~/ui/lib/CopyableIp'
1716
import { Slash } from '~/ui/lib/Slash'
1817
import { intersperse } from '~/util/array'
18+
import { orderIps } from '~/util/ip'
1919
import { pb } from '~/util/path-builder'
2020
import type * as PP from '~/util/path-params'
2121

22-
/** Order IPs: floating first, then ephemeral, then SNAT */
23-
const IP_ORDER = { floating: 0, ephemeral: 1, snat: 2 } as const
24-
export const orderIps = (ips: ExternalIp[]) => R.sortBy(ips, (a) => IP_ORDER[a.kind])
25-
2622
export function ExternalIps({ project, instance }: PP.Instance) {
2723
const { data, isPending } = useQuery(
2824
q(api.instanceExternalIpList, { path: { instance }, query: { project } })

app/components/ListPlusCell.tsx

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,33 @@ import React from 'react'
1111
import { EmptyCell } from '~/table/cells/EmptyCell'
1212
import { Tooltip } from '~/ui/lib/Tooltip'
1313

14+
type ListPlusOverflowProps = {
15+
tooltipTitle: string
16+
/** The overflow items, shown in the tooltip. Renders nothing when empty. */
17+
children: React.ReactNode
18+
}
19+
20+
/**
21+
* A `+N` count whose tooltip lists the overflow `children` on hover. Rendered on
22+
* its own so a cell can pair it with a richer (e.g. copyable) leading item that
23+
* lives outside the overflow group. Renders nothing when there are no children.
24+
*/
25+
export const ListPlusOverflow = ({ tooltipTitle, children }: ListPlusOverflowProps) => {
26+
const rest = React.Children.toArray(children)
27+
if (rest.length === 0) return null
28+
const content = (
29+
<div>
30+
<div className="text-sans-semi-md text-raise mb-2">{tooltipTitle}</div>
31+
<div className="flex flex-col items-start gap-2">{...rest}</div>
32+
</div>
33+
)
34+
return (
35+
<Tooltip content={content} placement="bottom">
36+
<div className="text-mono-sm">+{rest.length}</div>
37+
</Tooltip>
38+
)
39+
}
40+
1441
type ListPlusCellProps = {
1542
tooltipTitle: string
1643
children: React.ReactNode
@@ -22,7 +49,7 @@ type ListPlusCellProps = {
2249
* Gives a count with a tooltip that expands to show details when the user hovers over it.
2350
* The ReactNode children are split into two groups: the first `numInCell` are shown in the cell,
2451
* and the rest are shown in the tooltip. If the number of children is less than or equal to
25-
* `numInCell`, no tooltip (or `+N` target) is shown.
52+
* `numInCell`, no tooltip or `+N` is shown.
2653
*/
2754
export const ListPlusCell = ({
2855
tooltipTitle,
@@ -35,20 +62,10 @@ export const ListPlusCell = ({
3562
}
3663
const inCell = array.slice(0, numInCell)
3764
const rest = array.slice(numInCell)
38-
const content = (
39-
<div>
40-
<div className="text-sans-semi-md text-raise mb-2">{tooltipTitle}</div>
41-
<div className="flex flex-col items-start gap-2">{...rest}</div>
42-
</div>
43-
)
4465
return (
4566
<div className="flex items-baseline gap-2">
4667
{inCell}
47-
{rest.length > 0 && (
48-
<Tooltip content={content} placement="bottom">
49-
<div className="text-mono-sm">+{rest.length}</div>
50-
</Tooltip>
51-
)}
68+
<ListPlusOverflow tooltipTitle={tooltipTitle}>{rest}</ListPlusOverflow>
5269
</div>
5370
)
5471
}

app/pages/project/instances/InstancesPage.tsx

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { InstanceDocsPopover } from '~/components/InstanceDocsPopover'
2626
import { RefreshButton } from '~/components/RefreshButton'
2727
import { getProjectSelector, useProjectSelector } from '~/hooks/use-params'
2828
import { useQuickActions } from '~/hooks/use-quick-actions'
29+
import { ExternalIpsCell } from '~/table/cells/ExternalIpsCell'
2930
import { InstanceStateCell } from '~/table/cells/InstanceStateCell'
3031
import { makeLinkCell } from '~/table/cells/LinkCell'
3132
import { getActionsCol } from '~/table/columns/action-col'
@@ -67,13 +68,32 @@ const instanceList = (
6768

6869
export async function clientLoader({ params }: LoaderFunctionArgs) {
6970
const { project } = getProjectSelector(params)
70-
await queryClient.prefetchQuery(instanceList(project).optionsFn())
71+
const instances = await queryClient.fetchQuery(instanceList(project).optionsFn())
72+
// Warm the external IP cache for each instance in parallel as the route
73+
// loads. This doesn't add requests: ExternalIpsCell would issue the same
74+
// ones on mount, showing a skeleton until its list arrives. Prefetching
75+
// just starts them earlier.
76+
const ipPrefetches = instances.items.map(({ name: instance }) =>
77+
queryClient.prefetchQuery(
78+
q(api.instanceExternalIpList, { path: { instance }, query: { project } })
79+
)
80+
)
81+
// Block render on the first few so the top of the table doesn't flash
82+
// skeletons, but let the rest roll in after. Awaiting all of them would
83+
// block render on the slowest of up to 50 requests, and the odds of hitting
84+
// a slow one (while low) grow with N. 6 is a magic number: enough to usually
85+
// paint the top of the table without skeletons, but still small. It happens
86+
// to match the browser's per-host connection limit, but that only applies
87+
// over HTTP/1.1; in production, Nexus uses HTTP/2, so requests multiplex and
88+
// that limit shouldn't matter.
89+
await Promise.all(ipPrefetches.slice(0, 6))
7190
return null
7291
}
7392

7493
const refetchInstances = () =>
7594
Promise.all([
7695
queryClient.invalidateEndpoint('instanceList'),
96+
queryClient.invalidateEndpoint('instanceExternalIpList'),
7797
queryClient.invalidateEndpoint('antiAffinityGroupMemberList'),
7898
])
7999

@@ -101,6 +121,13 @@ export default function InstancesPage() {
101121
colHelper.accessor('name', {
102122
cell: makeLinkCell((instance) => pb.instance({ project, instance })),
103123
}),
124+
colHelper.accessor(
125+
(i) => ({ runState: i.runState, timeRunStateUpdated: i.timeRunStateUpdated }),
126+
{
127+
header: 'state',
128+
cell: (info) => <InstanceStateCell value={info.getValue()} />,
129+
}
130+
),
104131
colHelper.accessor('ncpus', {
105132
header: 'CPU',
106133
cell: (info) => (
@@ -121,13 +148,13 @@ export default function InstancesPage() {
121148
)
122149
},
123150
}),
124-
colHelper.accessor(
125-
(i) => ({ runState: i.runState, timeRunStateUpdated: i.timeRunStateUpdated }),
126-
{
127-
header: 'state',
128-
cell: (info) => <InstanceStateCell value={info.getValue()} />,
129-
}
130-
),
151+
colHelper.display({
152+
id: 'externalIps',
153+
header: 'External IPs',
154+
cell: (info) => (
155+
<ExternalIpsCell project={project} instance={info.row.original.name} />
156+
),
157+
}),
131158
colHelper.accessor('timeCreated', Columns.timeCreated),
132159
getActionsCol((instance: Instance) => [
133160
...makeButtonActions(instance),

app/pages/project/instances/NetworkingTab.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ import { Badge } from '@oxide/design-system/ui'
3232

3333
import { AttachEphemeralIpModal } from '~/components/AttachEphemeralIpModal'
3434
import { AttachFloatingIpModal } from '~/components/AttachFloatingIpModal'
35-
import { orderIps } from '~/components/ExternalIps'
3635
import { ListboxField } from '~/components/form/fields/ListboxField'
3736
import { ModalForm } from '~/components/form/ModalForm'
3837
import { HL } from '~/components/HL'
@@ -68,6 +67,7 @@ import {
6867
getCompatibleVersionsFromNics,
6968
getEphemeralIpSlots,
7069
ipHasVersion,
70+
orderIps,
7171
parseIp,
7272
} from '~/util/ip'
7373
import { pb } from '~/util/path-builder'
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
import { useQuery } from '@tanstack/react-query'
9+
10+
import { api, q } from '@oxide/api'
11+
12+
import { ListPlusOverflow } from '~/components/ListPlusCell'
13+
import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell'
14+
import { CopyableIp } from '~/ui/lib/CopyableIp'
15+
import { orderIps } from '~/util/ip'
16+
import type * as PP from '~/util/path-params'
17+
18+
// Shows the instance's first external IP (copyable), plus a `+N` tooltip.
19+
// SNAT IPs are excluded because they can't receive inbound traffic, so are
20+
// rarely the "external IP" a user is looking for. This might change with
21+
// https://github.com/oxidecomputer/omicron/issues/4317
22+
export function ExternalIpsCell({ project, instance }: PP.Instance) {
23+
const { data, isPending } = useQuery(
24+
q(
25+
api.instanceExternalIpList,
26+
{ path: { instance }, query: { project } },
27+
// The instance may have just been deleted while its row is still
28+
// rendered (e.g., delete invalidates this list concurrently with the
29+
// instance list). This prevents a 404 from taking down the whole
30+
// table. Instead, the cell will just render as empty.
31+
{ throwOnError: false }
32+
)
33+
)
34+
if (isPending) return <SkeletonCell />
35+
36+
const [first, ...rest] = orderIps((data?.items || []).filter((ip) => ip.kind !== 'snat'))
37+
if (!first) return <EmptyCell />
38+
39+
return (
40+
<div className="flex items-center gap-1">
41+
{/* only the leading IP is copyable; the rest live in the +N tooltip */}
42+
<CopyableIp ip={first.ip} />
43+
<ListPlusOverflow tooltipTitle="Other external IPs">
44+
{rest.map((ip) => (
45+
<div key={ip.ip}>{ip.ip}</div>
46+
))}
47+
</ListPlusOverflow>
48+
</div>
49+
)
50+
}

app/util/ip.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,15 @@
66
* Copyright Oxide Computer Company
77
*/
88

9+
import * as R from 'remeda'
10+
911
import type { ExternalIp, InstanceNetworkInterface, IpVersion, UnicastIpPool } from '~/api'
1012
import { setDiff, setIntersection } from '~/util/array'
1113

14+
/** Order IPs: floating first, then ephemeral, then SNAT */
15+
const IP_ORDER = { floating: 0, ephemeral: 1, snat: 2 } as const
16+
export const orderIps = (ips: ExternalIp[]) => R.sortBy(ips, (a) => IP_ORDER[a.kind])
17+
1218
// Borrowed from Valibot. I tried some from Zod and an O'Reilly regex cookbook
1319
// but they didn't match results with std::net on simple test cases
1420
// https://github.com/fabian-hiller/valibot/blob/2554aea5/library/src/regex.ts#L43-L54

mock-api/external-ip.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@
77
*/
88
import type { ExternalIp } from '@oxide/api'
99

10-
import { failedInstance, instance, instanceDb2, startingInstance } from './instance'
10+
import {
11+
failedInstance,
12+
instance,
13+
instanceDb2,
14+
instanceDb3,
15+
startingInstance,
16+
} from './instance'
1117
import { ipPool1 } from './ip-pool'
1218
import type { Json } from './json-type'
1319

@@ -63,6 +69,16 @@ export const ephemeralIps: DbExternalIp[] = [
6369
kind: 'ephemeral',
6470
},
6571
},
72+
{
73+
instance_id: instanceDb3.id,
74+
external_ip: {
75+
// careful: addresses in this file must not collide with the floating IPs,
76+
// or the pool utilization numbers get confusing (they dedupe by address)
77+
ip: '123.4.56.7',
78+
ip_pool_id: ipPool1.id,
79+
kind: 'ephemeral',
80+
},
81+
},
6682
]
6783

6884
// Note that SNAT IPs are subdivided into four ranges of ports,

mock-api/instance.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,19 @@ export const stoppedInstance: Json<Instance> = {
144144
boot_disk_id: 'f5bc2085-d18e-4698-86ab-69c62a74e541', // disk-stopped-boot
145145
}
146146

147+
// 7th instance in mock-project: the instances page loader only awaits external
148+
// IP prefetches for the first 6 instances, so this one exercises the
149+
// unawaited-prefetch path in ExternalIpsCell
150+
export const instanceDb3: Json<Instance> = {
151+
...base,
152+
id: 'a7abaacd-0721-4885-8db5-e743ee061d2b',
153+
name: 'db3',
154+
description: 'a third database instance',
155+
hostname: 'oxide.com',
156+
project_id: project.id,
157+
run_state: 'running',
158+
}
159+
147160
export const instances: Json<Instance>[] = [
148161
instance,
149162
failedInstance,
@@ -154,4 +167,5 @@ export const instances: Json<Instance>[] = [
154167
instanceUpdateError,
155168
instanceDb2,
156169
stoppedInstance,
170+
instanceDb3,
157171
]

test/e2e/combobox.e2e.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ test('arbitrary-values combobox keeps typed values and resets submitted fields',
178178
'instance-update-error',
179179
'db2',
180180
'db-stopped',
181+
'db3',
181182
])
182183

183184
await instanceInput.fill('d')
@@ -186,6 +187,7 @@ test('arbitrary-values combobox keeps typed values and resets submitted fields',
186187
'instance-update-error',
187188
'db2',
188189
'db-stopped',
190+
'db3',
189191
'Custom: d',
190192
])
191193

@@ -200,6 +202,7 @@ test('arbitrary-values combobox keeps typed values and resets submitted fields',
200202
'instance-update-error',
201203
'db2',
202204
'db-stopped',
205+
'db3',
203206
'Custom: d',
204207
])
205208

test/e2e/instance.e2e.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,38 @@ test('can delete a failed instance', async ({ page }) => {
3838
await expect(cell).toBeHidden() // bye
3939
})
4040

41+
test('shows external IPs on the instances table', async ({ page }) => {
42+
await page.goto('/projects/mock-project/instances')
43+
const table = page.getByRole('table')
44+
45+
// db1 has a floating IP (123.4.56.5, sorted first) and an ephemeral one, so it
46+
// shows the first plus a +1 overflow
47+
await expectRowVisible(table, {
48+
name: 'db1',
49+
'External IPs': expect.stringMatching(/123\.4\.56\.5.*\+1/),
50+
})
51+
// only the leading IP is copyable; the overflow IPs live in the tooltip
52+
await expect(
53+
table.getByRole('row', { name: 'db1' }).getByRole('button', { name: 'Click to copy' })
54+
).toHaveCount(1)
55+
// hovering the +1 reveals the other external IP in a tooltip
56+
await table.getByRole('row', { name: 'db1' }).getByText('+1').hover()
57+
await expect(page.getByText('Other external IPs')).toBeVisible()
58+
await expect(page.getByText('123.4.56.0')).toBeVisible()
59+
60+
// not-there-yet has three ephemeral IPs, so it shows the first plus a +2 overflow
61+
await expect(
62+
table.getByRole('row', { name: 'not-there-yet' }).getByText('+2')
63+
).toBeVisible()
64+
65+
// you-fail has only a SNAT IP, which is excluded, so the cell is empty
66+
await expectRowVisible(table, { name: 'you-fail', 'External IPs': '—' })
67+
68+
// db3 is the 7th instance, so its IP prefetch is not awaited by the loader:
69+
// the cell renders a skeleton first and fills in when the query lands
70+
await expectRowVisible(table, { name: 'db3', 'External IPs': '123.4.56.7' })
71+
})
72+
4173
test('can start a failed instance', async ({ page }) => {
4274
await page.goto('/projects/mock-project/instances')
4375

0 commit comments

Comments
 (0)