Skip to content

Commit 644a45b

Browse files
Add VPC and instance external IP to instance page (#1882)
* Add VPC and instance external IP to instance page * Use slash as divider * Adds copy button on instance external IP (#1885) * Update copy to clipboard component Uses new icon and snazzy animation * Add copy to clipboard to external IP * `CopyOnTruncate` component * Add description to `CopyOnTruncate` * Revert "Add description to `CopyOnTruncate`" This reverts commit 4bf0bcc. * Revert "`CopyOnTruncate` component" This reverts commit e4a0107. * basic externalIps table. every instance still has the same IP though * make external IPs legit * add file license * fix fallback logic around no IPs * skeleton cell component * Give properties row a fixed height * `EmptyCellContent` component instead of `&mdash;` directly * extract ExternalIps component that doesn't know about primary * put https on IP link * remove external IP column from nics table, add asserts to e2e test * put prefetches in Promise.all * sneaky prefetch on VPC name * share primary VPC ID logic --------- Co-authored-by: David Crespo <david.crespo@oxidecomputer.com>
1 parent 69927db commit 644a45b

File tree

15 files changed

+236
-59
lines changed

15 files changed

+236
-59
lines changed

app/components/ExternalIps.tsx

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+
9+
import { useApiQuery } from '@oxide/api'
10+
import { EmptyCell, SkeletonCell } from '@oxide/table'
11+
import { CopyToClipboard } from '@oxide/ui'
12+
import { intersperse } from '@oxide/util'
13+
14+
type InstanceSelector = { project: string; instance: string }
15+
16+
export function ExternalIps({ project, instance }: InstanceSelector) {
17+
const { data, isPending } = useApiQuery('instanceExternalIpList', {
18+
path: { instance },
19+
query: { project },
20+
})
21+
if (isPending) return <SkeletonCell />
22+
23+
const ips = data?.items
24+
? intersperse(
25+
data.items.map((eip) => <IpLink ip={eip.ip} key={eip.ip} />),
26+
<span className="text-quinary"> / </span>
27+
)
28+
: undefined
29+
30+
return (
31+
<div className="flex items-center gap-1 text-secondary">
32+
{ips && ips.length > 0 ? ips : <EmptyCell />}
33+
{/* If there's exactly one IP here, render a copy to clipboard button */}
34+
{data?.items.length === 1 && <CopyToClipboard text={data.items[0].ip} />}
35+
</div>
36+
)
37+
}
38+
39+
function IpLink({ ip }: { ip: string }) {
40+
return (
41+
<a
42+
className="underline text-sans-semi-md text-secondary hover:text-default"
43+
href={`https://${ip}`}
44+
target="_blank"
45+
rel="noreferrer"
46+
>
47+
{ip}
48+
</a>
49+
)
50+
}

app/pages/project/instances/instance/InstancePage.tsx

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ import filesize from 'filesize'
1010
import { useMemo } from 'react'
1111
import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom'
1212

13-
import { apiQueryClient, useApiQueryClient, usePrefetchedApiQuery } from '@oxide/api'
13+
import {
14+
apiQueryClient,
15+
useApiQueryClient,
16+
usePrefetchedApiQuery,
17+
type InstanceNetworkInterface,
18+
} from '@oxide/api'
19+
import { EmptyCell } from '@oxide/table'
1420
import {
1521
Instances24Icon,
1622
PageHeader,
@@ -19,20 +25,52 @@ import {
1925
Truncate,
2026
} from '@oxide/ui'
2127

28+
import { ExternalIps } from 'app/components/ExternalIps'
2229
import { MoreActionsMenu } from 'app/components/MoreActionsMenu'
2330
import { RouteTabs, Tab } from 'app/components/RouteTabs'
2431
import { InstanceStatusBadge } from 'app/components/StatusBadge'
2532
import { getInstanceSelector, useInstanceSelector, useQuickActions } from 'app/hooks'
2633
import { pb } from 'app/util/path-builder'
2734

2835
import { useMakeInstanceActions } from '../actions'
36+
import { VpcNameFromId } from './tabs/NetworkingTab'
37+
38+
function getPrimaryVpcId(nics: InstanceNetworkInterface[]) {
39+
const nic = nics.find((nic) => nic.primary)
40+
return nic ? nic.vpcId : undefined
41+
}
2942

3043
InstancePage.loader = async ({ params }: LoaderFunctionArgs) => {
3144
const { project, instance } = getInstanceSelector(params)
32-
await apiQueryClient.prefetchQuery('instanceView', {
33-
path: { instance },
34-
query: { project },
35-
})
45+
await Promise.all([
46+
apiQueryClient.prefetchQuery('instanceView', {
47+
path: { instance },
48+
query: { project },
49+
}),
50+
apiQueryClient.prefetchQuery('instanceExternalIpList', {
51+
path: { instance },
52+
query: { project },
53+
}),
54+
// The VPC fetch here ensures that the VPC shows up at pageload time without
55+
// a loading state. This is an unusual prefetch in that
56+
//
57+
// a) one call depends on the result of another, so they are in sequence
58+
// b) the corresponding render-time query is not right next to the loader
59+
// (which is what we usually prefer) but inside VpcNameFromId
60+
//
61+
// Using .then() like this instead of doing the NICs call before the
62+
// entire Promise.all() means this whole *pair* of requests can happen in
63+
// parallel with the other two instead of only the second one.
64+
apiQueryClient
65+
.fetchQuery('instanceNetworkInterfaceList', {
66+
query: { project, instance },
67+
})
68+
.then((nics) => {
69+
const vpc = getPrimaryVpcId(nics.items)
70+
if (!vpc) return Promise.resolve()
71+
return apiQueryClient.prefetchQuery('vpcView', { path: { vpc } })
72+
}),
73+
])
3674
return null
3775
}
3876

@@ -54,6 +92,14 @@ export function InstancePage() {
5492
query: { project: instanceSelector.project },
5593
})
5694

95+
const { data: nics } = usePrefetchedApiQuery('instanceNetworkInterfaceList', {
96+
query: {
97+
project: instanceSelector.project,
98+
instance: instanceSelector.instance,
99+
},
100+
})
101+
const primaryVpcId = getPrimaryVpcId(nics.items)
102+
57103
const actions = useMemo(
58104
() => [
59105
{
@@ -100,16 +146,18 @@ export function InstancePage() {
100146
<PropertiesTable.Row label="status">
101147
<InstanceStatusBadge status={instance.runState} />
102148
</PropertiesTable.Row>
149+
<PropertiesTable.Row label="vpc">
150+
<span className="text-secondary">
151+
{primaryVpcId ? VpcNameFromId({ value: primaryVpcId }) : <EmptyCell />}
152+
</span>
153+
</PropertiesTable.Row>
103154
</PropertiesTable>
104155
<PropertiesTable>
105156
<PropertiesTable.Row label="description">
106157
<span className="text-secondary">
107158
<Truncate text={instance.description} maxLength={40} />
108159
</span>
109160
</PropertiesTable.Row>
110-
{/* <PropertiesTable.Row label="dns name">
111-
<span className="text-secondary">{instance.hostname || '–'}</span>
112-
</PropertiesTable.Row> */}
113161
<PropertiesTable.Row label="created">
114162
<span className="text-secondary">
115163
{format(instance.timeCreated, 'MMM d, yyyy')}{' '}
@@ -123,6 +171,9 @@ export function InstancePage() {
123171
{instance.id}
124172
</span>
125173
</PropertiesTable.Row>
174+
<PropertiesTable.Row label="external IP">
175+
{<ExternalIps {...instanceSelector} />}
176+
</PropertiesTable.Row>
126177
</PropertiesTable>
127178
</PropertiesTable.Group>
128179
<RouteTabs fullWidth>

app/pages/project/instances/instance/tabs/NetworkingTab.tsx

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
Spinner,
2727
Success12Icon,
2828
} from '@oxide/ui'
29+
import { classed } from '@oxide/util'
2930

3031
import CreateNetworkInterfaceForm from 'app/forms/network-interface-create'
3132
import EditNetworkInterfaceForm from 'app/forms/network-interface-edit'
@@ -40,7 +41,9 @@ import { pb } from 'app/util/path-builder'
4041

4142
import { fancifyStates } from './common'
4243

43-
const VpcNameFromId = ({ value }: { value: string }) => {
44+
export const Skeleton = classed.div`h-4 w-12 rounded bg-tertiary motion-safe:animate-pulse`
45+
46+
export const VpcNameFromId = ({ value }: { value: string }) => {
4447
const projectSelector = useProjectSelector()
4548
const { data: vpc, isError } = useApiQuery(
4649
'vpcView',
@@ -52,10 +55,10 @@ const VpcNameFromId = ({ value }: { value: string }) => {
5255
// possible because you can't delete a VPC that has child resources, but let's
5356
// be safe
5457
if (isError) return <Badge color="neutral">Deleted</Badge>
55-
if (!vpc) return <Spinner /> // loading
58+
if (!vpc) return <Skeleton />
5659
return (
5760
<Link
58-
className="text-sans-semi-md text-default hover:underline"
61+
className="underline text-sans-semi-md text-secondary hover:text-default"
5962
to={pb.vpc({ ...projectSelector, vpc: vpc.name })}
6063
>
6164
{vpc.name}
@@ -77,16 +80,6 @@ const SubnetNameFromId = ({ value }: { value: string }) => {
7780
return <span className="text-secondary">{subnet.name}</span>
7881
}
7982

80-
function ExternalIpsFromInstanceName({ value: primary }: { value: boolean }) {
81-
const { project, instance } = useInstanceSelector()
82-
const { data } = useApiQuery('instanceExternalIpList', {
83-
path: { instance },
84-
query: { project },
85-
})
86-
const ips = data?.items.map((eip) => eip.ip).join(', ')
87-
return <span className="text-secondary">{primary ? ips : <>&mdash;</>}</span>
88-
}
89-
9083
NetworkingTab.loader = async ({ params }: LoaderFunctionArgs) => {
9184
const { project, instance } = getInstanceSelector(params)
9285
await Promise.all([
@@ -202,15 +195,7 @@ export function NetworkingTab() {
202195
<Table labeled-by="nic-label" makeActions={makeActions} emptyState={emptyState}>
203196
<Column accessor="name" />
204197
<Column accessor="description" />
205-
{/* TODO: mark v4 or v6 explicitly? */}
206198
<Column accessor="ip" />
207-
<Column
208-
header="External IP"
209-
// we use primary to decide whether to show the IP in that row
210-
accessor="primary"
211-
id="external_ip"
212-
cell={ExternalIpsFromInstanceName}
213-
/>
214199
<Column header="vpc" accessor="vpcId" cell={VpcNameFromId} />
215200
<Column header="subnet" accessor="subnetId" cell={SubnetNameFromId} />
216201
<Column

app/pages/system/SiloPage.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@
88
import { Link, Outlet, type LoaderFunctionArgs } from 'react-router-dom'
99

1010
import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api'
11-
import { DateCell, DefaultCell, linkCell, TruncateCell, useQueryTable } from '@oxide/table'
11+
import {
12+
DateCell,
13+
DefaultCell,
14+
EmptyCell,
15+
linkCell,
16+
TruncateCell,
17+
useQueryTable,
18+
} from '@oxide/table'
1219
import {
1320
Badge,
1421
buttonStyle,
@@ -73,7 +80,7 @@ export function SiloPage() {
7380
Fleet role mapping <RoleMappingTooltip />
7481
</h2>
7582
{roleMapPairs.length === 0 ? (
76-
<p className="text-secondary">&mdash;</p>
83+
<EmptyCell />
7784
) : (
7885
<ul className="space-y-3">
7986
{roleMapPairs.map(([siloRole, fleetRole]) => (

app/test/e2e/instance/networking.e2e.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import { expectNotVisible, expectRowVisible, expectVisible, stopInstance } from
1212
test('Instance networking tab', async ({ page }) => {
1313
await page.goto('/projects/mock-project/instances/db1')
1414

15+
// links to VPC and external IPs appear in table
16+
await expect(page.getByRole('link', { name: 'mock-vpc' })).toBeVisible()
17+
await expect(page.getByRole('link', { name: '123.4.56.0' })).toBeVisible()
18+
1519
// Instance networking tab
1620
await page.click('role=tab[name="Network Interfaces"]')
1721

libs/api-mocks/external-ip.ts

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 type { ExternalIp } from '@oxide/api'
9+
10+
import { instances } from './instance'
11+
import type { Json } from './json-type'
12+
13+
type DbExternalIp = {
14+
instance_id: string
15+
external_ip: Json<ExternalIp>
16+
}
17+
18+
// TODO: this type represents the API response, but we need to mock more
19+
// structure in order to be able to look up IPs for a particular instance
20+
export const externalIps: DbExternalIp[] = [
21+
{
22+
instance_id: instances[0].id,
23+
external_ip: {
24+
ip: `123.4.56.0`,
25+
kind: 'ephemeral',
26+
},
27+
},
28+
// middle one has no IPs
29+
{
30+
instance_id: instances[2].id,
31+
external_ip: {
32+
ip: `123.4.56.1`,
33+
kind: 'ephemeral',
34+
},
35+
},
36+
{
37+
instance_id: instances[2].id,
38+
external_ip: {
39+
ip: `123.4.56.2`,
40+
kind: 'ephemeral',
41+
},
42+
},
43+
{
44+
instance_id: instances[2].id,
45+
external_ip: {
46+
ip: `123.4.56.3`,
47+
kind: 'ephemeral',
48+
},
49+
},
50+
]

libs/api-mocks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
export * from './disk'
10+
export * from './external-ip'
1011
export * from './image'
1112
export * from './instance'
1213
export * from './ip-pool'

libs/api-mocks/msw/db.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ const initDb = {
224224
/** Join table for `users` and `userGroups` */
225225
groupMemberships: [...mock.groupMemberships],
226226
images: [...mock.images],
227+
externalIps: [...mock.externalIps],
227228
instances: [...mock.instances],
228229
ipPools: [...mock.ipPools],
229230
ipPoolSilos: [...mock.ipPoolSilos],

libs/api-mocks/msw/handlers.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -444,17 +444,12 @@ export const handlers = makeHandlers({
444444
return disk
445445
},
446446
instanceExternalIpList({ path, query }) {
447-
lookup.instance({ ...path, ...query })
448-
449-
// TODO: proper mock table
450-
return {
451-
items: [
452-
{
453-
ip: '123.4.56.7',
454-
kind: 'ephemeral',
455-
} as const,
456-
],
457-
}
447+
const instance = lookup.instance({ ...path, ...query })
448+
const externalIps = db.externalIps
449+
.filter((eip) => eip.instance_id === instance.id)
450+
.map((eip) => eip.external_ip)
451+
// endpoint is not paginated. or rather, it's fake paginated
452+
return { items: externalIps }
458453
},
459454
instanceNetworkInterfaceList({ query }) {
460455
const instance = lookup.instance(query)

libs/table/cells/EmptyCell.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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+
9+
import { classed } from '@oxide/util'
10+
11+
export const EmptyCell = () => <span className="text-sans-md text-quinary">&mdash;</span>
12+
13+
export const SkeletonCell = classed.div`h-4 w-12 rounded bg-tertiary motion-safe:animate-pulse`

0 commit comments

Comments
 (0)