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
8 changes: 2 additions & 6 deletions app/components/ExternalIps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } })
Expand Down
41 changes: 29 additions & 12 deletions app/components/ListPlusCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
<div>
<div className="text-sans-semi-md text-raise mb-2">{tooltipTitle}</div>
<div className="flex flex-col items-start gap-2">{...rest}</div>
</div>
)
return (
<Tooltip content={content} placement="bottom">
<div className="text-mono-sm">+{rest.length}</div>
</Tooltip>
)
}

type ListPlusCellProps = {
tooltipTitle: string
children: React.ReactNode
Expand All @@ -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,
Expand All @@ -35,20 +62,10 @@ export const ListPlusCell = ({
}
const inCell = array.slice(0, numInCell)
const rest = array.slice(numInCell)
const content = (
<div>
<div className="text-sans-semi-md text-raise mb-2">{tooltipTitle}</div>
<div className="flex flex-col items-start gap-2">{...rest}</div>
</div>
)
return (
<div className="flex items-baseline gap-2">
{inCell}
{rest.length > 0 && (
<Tooltip content={content} placement="bottom">
<div className="text-mono-sm">+{rest.length}</div>
</Tooltip>
)}
<ListPlusOverflow tooltipTitle={tooltipTitle}>{rest}</ListPlusOverflow>
</div>
)
}
43 changes: 35 additions & 8 deletions app/pages/project/instances/InstancesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'),
])

Expand Down Expand Up @@ -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) => <InstanceStateCell value={info.getValue()} />,
}
),
colHelper.accessor('ncpus', {
header: 'CPU',
cell: (info) => (
Expand All @@ -121,13 +148,13 @@ export default function InstancesPage() {
)
},
}),
colHelper.accessor(
(i) => ({ runState: i.runState, timeRunStateUpdated: i.timeRunStateUpdated }),
{
header: 'state',
cell: (info) => <InstanceStateCell value={info.getValue()} />,
}
),
colHelper.display({
id: 'externalIps',
header: 'External IPs',
cell: (info) => (
<ExternalIpsCell project={project} instance={info.row.original.name} />
),
}),
colHelper.accessor('timeCreated', Columns.timeCreated),
getActionsCol((instance: Instance) => [
...makeButtonActions(instance),
Expand Down
2 changes: 1 addition & 1 deletion app/pages/project/instances/NetworkingTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -68,6 +67,7 @@ import {
getCompatibleVersionsFromNics,
getEphemeralIpSlots,
ipHasVersion,
orderIps,
parseIp,
} from '~/util/ip'
import { pb } from '~/util/path-builder'
Expand Down
50 changes: 50 additions & 0 deletions app/table/cells/ExternalIpsCell.tsx
Original file line number Diff line number Diff line change
@@ -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 <SkeletonCell />

const [first, ...rest] = orderIps((data?.items || []).filter((ip) => ip.kind !== 'snat'))
if (!first) return <EmptyCell />

return (
<div className="flex items-center gap-1">
{/* only the leading IP is copyable; the rest live in the +N tooltip */}
<CopyableIp ip={first.ip} />
<ListPlusOverflow tooltipTitle="Other external IPs">
{rest.map((ip) => (
<div key={ip.ip}>{ip.ip}</div>
))}
</ListPlusOverflow>
</div>
)
}
6 changes: 6 additions & 0 deletions app/util/ip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion mock-api/external-ip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions mock-api/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,19 @@ export const stoppedInstance: Json<Instance> = {
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<Instance> = {
...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>[] = [
instance,
failedInstance,
Expand All @@ -154,4 +167,5 @@ export const instances: Json<Instance>[] = [
instanceUpdateError,
instanceDb2,
stoppedInstance,
instanceDb3,
]
3 changes: 3 additions & 0 deletions test/e2e/combobox.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -186,6 +187,7 @@ test('arbitrary-values combobox keeps typed values and resets submitted fields',
'instance-update-error',
'db2',
'db-stopped',
'db3',
'Custom: d',
])

Expand All @@ -200,6 +202,7 @@ test('arbitrary-values combobox keeps typed values and resets submitted fields',
'instance-update-error',
'db2',
'db-stopped',
'db3',
'Custom: d',
])

Expand Down
32 changes: 32 additions & 0 deletions test/e2e/instance.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down
Loading
Loading