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
50 changes: 50 additions & 0 deletions app/components/ExternalIps.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 { useApiQuery } from '@oxide/api'
import { EmptyCell, SkeletonCell } from '@oxide/table'
import { CopyToClipboard } from '@oxide/ui'
import { intersperse } from '@oxide/util'

type InstanceSelector = { project: string; instance: string }

export function ExternalIps({ project, instance }: InstanceSelector) {
const { data, isPending } = useApiQuery('instanceExternalIpList', {
path: { instance },
query: { project },
})
if (isPending) return <SkeletonCell />

const ips = data?.items
? intersperse(
data.items.map((eip) => <IpLink ip={eip.ip} key={eip.ip} />),
<span className="text-quinary"> / </span>
)
: undefined

return (
<div className="flex items-center gap-1 text-secondary">
{ips && ips.length > 0 ? ips : <EmptyCell />}
{/* If there's exactly one IP here, render a copy to clipboard button */}
{data?.items.length === 1 && <CopyToClipboard text={data.items[0].ip} />}
</div>
)
}

function IpLink({ ip }: { ip: string }) {
return (
<a
className="underline text-sans-semi-md text-secondary hover:text-default"
href={`https://${ip}`}
target="_blank"
rel="noreferrer"
>
{ip}
</a>
)
}
67 changes: 59 additions & 8 deletions app/pages/project/instances/instance/InstancePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ import filesize from 'filesize'
import { useMemo } from 'react'
import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom'

import { apiQueryClient, useApiQueryClient, usePrefetchedApiQuery } from '@oxide/api'
import {
apiQueryClient,
useApiQueryClient,
usePrefetchedApiQuery,
type InstanceNetworkInterface,
} from '@oxide/api'
import { EmptyCell } from '@oxide/table'
import {
Instances24Icon,
PageHeader,
Expand All @@ -19,20 +25,52 @@ import {
Truncate,
} from '@oxide/ui'

import { ExternalIps } from 'app/components/ExternalIps'
import { MoreActionsMenu } from 'app/components/MoreActionsMenu'
import { RouteTabs, Tab } from 'app/components/RouteTabs'
import { InstanceStatusBadge } from 'app/components/StatusBadge'
import { getInstanceSelector, useInstanceSelector, useQuickActions } from 'app/hooks'
import { pb } from 'app/util/path-builder'

import { useMakeInstanceActions } from '../actions'
import { VpcNameFromId } from './tabs/NetworkingTab'

function getPrimaryVpcId(nics: InstanceNetworkInterface[]) {
const nic = nics.find((nic) => nic.primary)
return nic ? nic.vpcId : undefined
}

InstancePage.loader = async ({ params }: LoaderFunctionArgs) => {
const { project, instance } = getInstanceSelector(params)
await apiQueryClient.prefetchQuery('instanceView', {
path: { instance },
query: { project },
})
await Promise.all([
apiQueryClient.prefetchQuery('instanceView', {
path: { instance },
query: { project },
}),
apiQueryClient.prefetchQuery('instanceExternalIpList', {
path: { instance },
query: { project },
}),
// The VPC fetch here ensures that the VPC shows up at pageload time without
// a loading state. This is an unusual prefetch in that
//
// a) one call depends on the result of another, so they are in sequence
// b) the corresponding render-time query is not right next to the loader
// (which is what we usually prefer) but inside VpcNameFromId
//
// Using .then() like this instead of doing the NICs call before the
// entire Promise.all() means this whole *pair* of requests can happen in
// parallel with the other two instead of only the second one.
apiQueryClient
.fetchQuery('instanceNetworkInterfaceList', {
query: { project, instance },
})
.then((nics) => {
const vpc = getPrimaryVpcId(nics.items)
if (!vpc) return Promise.resolve()
return apiQueryClient.prefetchQuery('vpcView', { path: { vpc } })
}),
])
return null
}

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

const { data: nics } = usePrefetchedApiQuery('instanceNetworkInterfaceList', {
query: {
project: instanceSelector.project,
instance: instanceSelector.instance,
},
})
const primaryVpcId = getPrimaryVpcId(nics.items)

const actions = useMemo(
() => [
{
Expand Down Expand Up @@ -100,16 +146,18 @@ export function InstancePage() {
<PropertiesTable.Row label="status">
<InstanceStatusBadge status={instance.runState} />
</PropertiesTable.Row>
<PropertiesTable.Row label="vpc">
<span className="text-secondary">
{primaryVpcId ? VpcNameFromId({ value: primaryVpcId }) : <EmptyCell />}
</span>
</PropertiesTable.Row>
</PropertiesTable>
<PropertiesTable>
<PropertiesTable.Row label="description">
<span className="text-secondary">
<Truncate text={instance.description} maxLength={40} />
</span>
</PropertiesTable.Row>
{/* <PropertiesTable.Row label="dns name">
<span className="text-secondary">{instance.hostname || '–'}</span>
</PropertiesTable.Row> */}
<PropertiesTable.Row label="created">
<span className="text-secondary">
{format(instance.timeCreated, 'MMM d, yyyy')}{' '}
Expand All @@ -123,6 +171,9 @@ export function InstancePage() {
{instance.id}
</span>
</PropertiesTable.Row>
<PropertiesTable.Row label="external IP">
{<ExternalIps {...instanceSelector} />}
</PropertiesTable.Row>
</PropertiesTable>
</PropertiesTable.Group>
<RouteTabs fullWidth>
Expand Down
27 changes: 6 additions & 21 deletions app/pages/project/instances/instance/tabs/NetworkingTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
Spinner,
Success12Icon,
} from '@oxide/ui'
import { classed } from '@oxide/util'

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

import { fancifyStates } from './common'

const VpcNameFromId = ({ value }: { value: string }) => {
export const Skeleton = classed.div`h-4 w-12 rounded bg-tertiary motion-safe:animate-pulse`

export const VpcNameFromId = ({ value }: { value: string }) => {
const projectSelector = useProjectSelector()
const { data: vpc, isError } = useApiQuery(
'vpcView',
Expand All @@ -52,10 +55,10 @@ const VpcNameFromId = ({ value }: { value: string }) => {
// possible because you can't delete a VPC that has child resources, but let's
// be safe
if (isError) return <Badge color="neutral">Deleted</Badge>
if (!vpc) return <Spinner /> // loading
if (!vpc) return <Skeleton />
return (
<Link
className="text-sans-semi-md text-default hover:underline"
className="underline text-sans-semi-md text-secondary hover:text-default"
to={pb.vpc({ ...projectSelector, vpc: vpc.name })}
>
{vpc.name}
Expand All @@ -77,16 +80,6 @@ const SubnetNameFromId = ({ value }: { value: string }) => {
return <span className="text-secondary">{subnet.name}</span>
}

function ExternalIpsFromInstanceName({ value: primary }: { value: boolean }) {
const { project, instance } = useInstanceSelector()
const { data } = useApiQuery('instanceExternalIpList', {
path: { instance },
query: { project },
})
const ips = data?.items.map((eip) => eip.ip).join(', ')
return <span className="text-secondary">{primary ? ips : <>&mdash;</>}</span>
}

NetworkingTab.loader = async ({ params }: LoaderFunctionArgs) => {
const { project, instance } = getInstanceSelector(params)
await Promise.all([
Expand Down Expand Up @@ -202,15 +195,7 @@ export function NetworkingTab() {
<Table labeled-by="nic-label" makeActions={makeActions} emptyState={emptyState}>
<Column accessor="name" />
<Column accessor="description" />
{/* TODO: mark v4 or v6 explicitly? */}
<Column accessor="ip" />
<Column
header="External IP"
// we use primary to decide whether to show the IP in that row
accessor="primary"
id="external_ip"
cell={ExternalIpsFromInstanceName}
/>
<Column header="vpc" accessor="vpcId" cell={VpcNameFromId} />
<Column header="subnet" accessor="subnetId" cell={SubnetNameFromId} />
<Column
Expand Down
11 changes: 9 additions & 2 deletions app/pages/system/SiloPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@
import { Link, Outlet, type LoaderFunctionArgs } from 'react-router-dom'

import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api'
import { DateCell, DefaultCell, linkCell, TruncateCell, useQueryTable } from '@oxide/table'
import {
DateCell,
DefaultCell,
EmptyCell,
linkCell,
TruncateCell,
useQueryTable,
} from '@oxide/table'
import {
Badge,
buttonStyle,
Expand Down Expand Up @@ -73,7 +80,7 @@ export function SiloPage() {
Fleet role mapping <RoleMappingTooltip />
</h2>
{roleMapPairs.length === 0 ? (
<p className="text-secondary">&mdash;</p>
<EmptyCell />
) : (
<ul className="space-y-3">
{roleMapPairs.map(([siloRole, fleetRole]) => (
Expand Down
4 changes: 4 additions & 0 deletions app/test/e2e/instance/networking.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { expectNotVisible, expectRowVisible, expectVisible, stopInstance } from
test('Instance networking tab', async ({ page }) => {
await page.goto('/projects/mock-project/instances/db1')

// links to VPC and external IPs appear in table
await expect(page.getByRole('link', { name: 'mock-vpc' })).toBeVisible()
await expect(page.getByRole('link', { name: '123.4.56.0' })).toBeVisible()

// Instance networking tab
await page.click('role=tab[name="Network Interfaces"]')

Expand Down
50 changes: 50 additions & 0 deletions libs/api-mocks/external-ip.ts
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 type { ExternalIp } from '@oxide/api'

import { instances } from './instance'
import type { Json } from './json-type'

type DbExternalIp = {
instance_id: string
external_ip: Json<ExternalIp>
}

// TODO: this type represents the API response, but we need to mock more
// structure in order to be able to look up IPs for a particular instance
export const externalIps: DbExternalIp[] = [
{
instance_id: instances[0].id,
external_ip: {
ip: `123.4.56.0`,
kind: 'ephemeral',
},
},
// middle one has no IPs
{
instance_id: instances[2].id,
external_ip: {
ip: `123.4.56.1`,
kind: 'ephemeral',
},
},
{
instance_id: instances[2].id,
external_ip: {
ip: `123.4.56.2`,
kind: 'ephemeral',
},
},
{
instance_id: instances[2].id,
external_ip: {
ip: `123.4.56.3`,
kind: 'ephemeral',
},
},
]
1 change: 1 addition & 0 deletions libs/api-mocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

export * from './disk'
export * from './external-ip'
export * from './image'
export * from './instance'
export * from './network-interface'
Expand Down
1 change: 1 addition & 0 deletions libs/api-mocks/msw/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ const initDb = {
/** Join table for `users` and `userGroups` */
groupMemberships: [...mock.groupMemberships],
images: [...mock.images],
externalIps: [...mock.externalIps],
instances: [...mock.instances],
networkInterfaces: [mock.networkInterface],
physicalDisks: [...mock.physicalDisks],
Expand Down
17 changes: 6 additions & 11 deletions libs/api-mocks/msw/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,17 +444,12 @@ export const handlers = makeHandlers({
return disk
},
instanceExternalIpList({ path, query }) {
lookup.instance({ ...path, ...query })

// TODO: proper mock table
return {
items: [
{
ip: '123.4.56.7',
kind: 'ephemeral',
} as const,
],
}
const instance = lookup.instance({ ...path, ...query })
const externalIps = db.externalIps
.filter((eip) => eip.instance_id === instance.id)
.map((eip) => eip.external_ip)
// endpoint is not paginated. or rather, it's fake paginated
return { items: externalIps }
},
instanceNetworkInterfaceList({ query }) {
const instance = lookup.instance(query)
Expand Down
13 changes: 13 additions & 0 deletions libs/table/cells/EmptyCell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* 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 { classed } from '@oxide/util'

export const EmptyCell = () => <span className="text-sans-md text-quinary">&mdash;</span>

export const SkeletonCell = classed.div`h-4 w-12 rounded bg-tertiary motion-safe:animate-pulse`
1 change: 1 addition & 0 deletions libs/table/cells/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from './Cell'
export * from './DateCell'
export * from './DefaultCell'
export * from './EnabledCell'
export * from './EmptyCell'
export * from './FirewallFilterCell'
export * from './InstanceResourceCell'
export * from './InstanceStatusCell'
Expand Down
Loading