From 556b2605c6579b0913b2ec4b8a036cf881453b89 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 7 Jul 2026 14:02:54 -0700 Subject: [PATCH 1/5] Add External IPs column to Instances table --- app/components/ExternalIps.tsx | 8 +--- app/components/ListPlusCell.tsx | 41 ++++++++++++------ app/pages/project/instances/InstancesPage.tsx | 20 ++++++++- app/pages/project/instances/NetworkingTab.tsx | 2 +- app/table/cells/ExternalIpsCell.tsx | 42 +++++++++++++++++++ app/util/ip.ts | 6 +++ test/e2e/instance.e2e.ts | 28 +++++++++++++ 7 files changed, 127 insertions(+), 20 deletions(-) create mode 100644 app/table/cells/ExternalIpsCell.tsx diff --git a/app/components/ExternalIps.tsx b/app/components/ExternalIps.tsx index 45fc96331a..2ce91ba276 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 e6fd1f8016..691391abf2 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 19af2687db..dfad17216f 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,23 @@ 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, + // but don't block render on them: ExternalIpsCell reads with useQuery and shows + // a skeleton until its list arrives. This keeps the page responsive no matter + // how many instances there are. + for (const { name: instance } of instances.items) { + queryClient.prefetchQuery( + q(api.instanceExternalIpList, { path: { instance }, query: { project } }) + ) + } return null } const refetchInstances = () => Promise.all([ queryClient.invalidateEndpoint('instanceList'), + queryClient.invalidateEndpoint('instanceExternalIpList'), queryClient.invalidateEndpoint('antiAffinityGroupMemberList'), ]) @@ -121,6 +132,13 @@ export default function InstancesPage() { ) }, }), + colHelper.display({ + id: 'externalIps', + header: 'External IPs', + cell: (info) => ( + + ), + }), colHelper.accessor( (i) => ({ runState: i.runState, timeRunStateUpdated: i.timeRunStateUpdated }), { diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index b2ffbccce6..6aef91adc4 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 0000000000..c0a1d3c441 --- /dev/null +++ b/app/table/cells/ExternalIpsCell.tsx @@ -0,0 +1,42 @@ +/* + * 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 } }) + ) + 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 7e1f6e7680..b1452531b6 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/test/e2e/instance.e2e.ts b/test/e2e/instance.e2e.ts index ec18e67412..aa7b352364 100644 --- a/test/e2e/instance.e2e.ts +++ b/test/e2e/instance.e2e.ts @@ -38,6 +38,34 @@ 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': '—' }) +}) + test('can start a failed instance', async ({ page }) => { await page.goto('/projects/mock-project/instances') From f1e3558207ae174e057ca572ddbb95ba7f2880a1 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 7 Jul 2026 16:29:51 -0700 Subject: [PATCH 2/5] protection against 404 taking down page --- app/table/cells/ExternalIpsCell.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/table/cells/ExternalIpsCell.tsx b/app/table/cells/ExternalIpsCell.tsx index c0a1d3c441..d3e9b94ff9 100644 --- a/app/table/cells/ExternalIpsCell.tsx +++ b/app/table/cells/ExternalIpsCell.tsx @@ -21,7 +21,15 @@ import type * as PP from '~/util/path-params' // 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 } }) + 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 From 3b4f4edf4ccaf8e384c4df7f87e96fd838692ad2 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 8 Jul 2026 10:46:03 -0500 Subject: [PATCH 3/5] await the first 6 external IP fetches to avoid pop-in --- app/pages/project/instances/InstancesPage.tsx | 21 +++++++++++++------ mock-api/external-ip.ts | 16 +++++++++++++- mock-api/instance.ts | 14 +++++++++++++ test/e2e/instance.e2e.ts | 4 ++++ 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index dfad17216f..723c0d18cb 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -69,15 +69,24 @@ const instanceList = ( export async function clientLoader({ params }: LoaderFunctionArgs) { const { project } = getProjectSelector(params) const instances = await queryClient.fetchQuery(instanceList(project).optionsFn()) - // Warm the external IP cache for each instance in parallel as the route loads, - // but don't block render on them: ExternalIpsCell reads with useQuery and shows - // a skeleton until its list arrives. This keeps the page responsive no matter - // how many instances there are. - for (const { name: instance } of instances.items) { + // 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 } diff --git a/mock-api/external-ip.ts b/mock-api/external-ip.ts index 0874c7b487..4a5038b084 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,14 @@ export const ephemeralIps: DbExternalIp[] = [ kind: 'ephemeral', }, }, + { + instance_id: instanceDb3.id, + external_ip: { + ip: '123.4.56.4', + 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 7fb5aa029a..60d6c37153 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/instance.e2e.ts b/test/e2e/instance.e2e.ts index aa7b352364..da2a184472 100644 --- a/test/e2e/instance.e2e.ts +++ b/test/e2e/instance.e2e.ts @@ -64,6 +64,10 @@ test('shows external IPs on the instances table', async ({ page }) => { // 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.4' }) }) test('can start a failed instance', async ({ page }) => { From f7600af86f12d7b1e54841c8aab7b29f3497fb9e Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 8 Jul 2026 12:02:07 -0500 Subject: [PATCH 4/5] fix e2e tests I broke --- mock-api/external-ip.ts | 4 +++- test/e2e/combobox.e2e.ts | 3 +++ test/e2e/instance.e2e.ts | 2 +- test/e2e/ip-pools.e2e.ts | 12 ++++++------ 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/mock-api/external-ip.ts b/mock-api/external-ip.ts index 4a5038b084..dd04a197e4 100644 --- a/mock-api/external-ip.ts +++ b/mock-api/external-ip.ts @@ -72,7 +72,9 @@ export const ephemeralIps: DbExternalIp[] = [ { instance_id: instanceDb3.id, external_ip: { - ip: '123.4.56.4', + // 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', }, diff --git a/test/e2e/combobox.e2e.ts b/test/e2e/combobox.e2e.ts index 4e05adc57b..45aa43b90c 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 da2a184472..256e6d2277 100644 --- a/test/e2e/instance.e2e.ts +++ b/test/e2e/instance.e2e.ts @@ -67,7 +67,7 @@ test('shows external IPs on the instances table', async ({ page }) => { // 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.4' }) + await expectRowVisible(table, { name: 'db3', 'External IPs': '123.4.56.7' }) }) test('can start a failed instance', async ({ page }) => { diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index fa7455bc41..5e6b19f30f 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') From 620a2deefce719de306063e800acca93c66d0b4d Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 8 Jul 2026 12:58:04 -0500 Subject: [PATCH 5/5] move state column left, next to name --- app/pages/project/instances/InstancesPage.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index 723c0d18cb..21f7205262 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -121,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) => ( @@ -148,13 +155,6 @@ export default function InstancesPage() { ), }), - colHelper.accessor( - (i) => ({ runState: i.runState, timeRunStateUpdated: i.timeRunStateUpdated }), - { - header: 'state', - cell: (info) => , - } - ), colHelper.accessor('timeCreated', Columns.timeCreated), getActionsCol((instance: Instance) => [ ...makeButtonActions(instance),