diff --git a/app/components/ExternalIps.tsx b/app/components/ExternalIps.tsx new file mode 100644 index 0000000000..294882ca9b --- /dev/null +++ b/app/components/ExternalIps.tsx @@ -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 + + const ips = data?.items + ? intersperse( + data.items.map((eip) => ), + / + ) + : undefined + + return ( +
+ {ips && ips.length > 0 ? ips : } + {/* If there's exactly one IP here, render a copy to clipboard button */} + {data?.items.length === 1 && } +
+ ) +} + +function IpLink({ ip }: { ip: string }) { + return ( + + {ip} + + ) +} diff --git a/app/pages/project/instances/instance/InstancePage.tsx b/app/pages/project/instances/instance/InstancePage.tsx index 5a446332c4..9fa6dde26e 100644 --- a/app/pages/project/instances/instance/InstancePage.tsx +++ b/app/pages/project/instances/instance/InstancePage.tsx @@ -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, @@ -19,6 +25,7 @@ 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' @@ -26,13 +33,44 @@ import { getInstanceSelector, useInstanceSelector, useQuickActions } from 'app/h 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 } @@ -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( () => [ { @@ -100,6 +146,11 @@ export function InstancePage() { + + + {primaryVpcId ? VpcNameFromId({ value: primaryVpcId }) : } + + @@ -107,9 +158,6 @@ export function InstancePage() { - {/* - {instance.hostname || '–'} - */} {format(instance.timeCreated, 'MMM d, yyyy')}{' '} @@ -123,6 +171,9 @@ export function InstancePage() { {instance.id} + + {} + diff --git a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx index 218502e1a4..228ffdff73 100644 --- a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx +++ b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx @@ -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' @@ -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', @@ -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 Deleted - if (!vpc) return // loading + if (!vpc) return return ( {vpc.name} @@ -77,16 +80,6 @@ const SubnetNameFromId = ({ value }: { value: string }) => { return {subnet.name} } -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 {primary ? ips : <>—} -} - NetworkingTab.loader = async ({ params }: LoaderFunctionArgs) => { const { project, instance } = getInstanceSelector(params) await Promise.all([ @@ -202,15 +195,7 @@ export function NetworkingTab() { - {/* TODO: mark v4 or v6 explicitly? */} - {roleMapPairs.length === 0 ? ( -

+ ) : (
    {roleMapPairs.map(([siloRole, fleetRole]) => ( diff --git a/app/test/e2e/instance/networking.e2e.ts b/app/test/e2e/instance/networking.e2e.ts index 264f0eb790..c8811cb5c1 100644 --- a/app/test/e2e/instance/networking.e2e.ts +++ b/app/test/e2e/instance/networking.e2e.ts @@ -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"]') diff --git a/libs/api-mocks/external-ip.ts b/libs/api-mocks/external-ip.ts new file mode 100644 index 0000000000..09b2f08fd1 --- /dev/null +++ b/libs/api-mocks/external-ip.ts @@ -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 +} + +// 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', + }, + }, +] diff --git a/libs/api-mocks/index.ts b/libs/api-mocks/index.ts index 7a5b5fc160..6f5db2a9e7 100644 --- a/libs/api-mocks/index.ts +++ b/libs/api-mocks/index.ts @@ -7,6 +7,7 @@ */ export * from './disk' +export * from './external-ip' export * from './image' export * from './instance' export * from './network-interface' diff --git a/libs/api-mocks/msw/db.ts b/libs/api-mocks/msw/db.ts index 4f0babddb3..7047870da7 100644 --- a/libs/api-mocks/msw/db.ts +++ b/libs/api-mocks/msw/db.ts @@ -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], diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index f7e35eaf54..ec076a34be 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -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) diff --git a/libs/table/cells/EmptyCell.tsx b/libs/table/cells/EmptyCell.tsx new file mode 100644 index 0000000000..739d7bcbde --- /dev/null +++ b/libs/table/cells/EmptyCell.tsx @@ -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 = () => + +export const SkeletonCell = classed.div`h-4 w-12 rounded bg-tertiary motion-safe:animate-pulse` diff --git a/libs/table/cells/index.ts b/libs/table/cells/index.ts index 149a50240d..f6d81a5d57 100644 --- a/libs/table/cells/index.ts +++ b/libs/table/cells/index.ts @@ -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' diff --git a/libs/ui/lib/copy-to-clipboard/CopyToClipboard.tsx b/libs/ui/lib/copy-to-clipboard/CopyToClipboard.tsx index c521346eaf..2979d73629 100644 --- a/libs/ui/lib/copy-to-clipboard/CopyToClipboard.tsx +++ b/libs/ui/lib/copy-to-clipboard/CopyToClipboard.tsx @@ -6,9 +6,11 @@ * Copyright Oxide Computer Company */ +import { animated, config, useTransition } from '@react-spring/web' +import cn from 'classnames' import { useState } from 'react' -import { Clipboard16Icon, Success12Icon, useTimeout } from '@oxide/ui' +import { Copy12Icon, Success12Icon, useTimeout } from '@oxide/ui' export const CopyToClipboard = ({ ariaLabel = 'Click to copy this text', @@ -27,18 +29,35 @@ export const CopyToClipboard = ({ }) } + const transitions = useTransition(hasCopied, { + from: { opacity: 0, transform: 'scale(0.8)' }, + enter: { opacity: 1, transform: 'scale(1)' }, + leave: { opacity: 0, transform: 'scale(0.8)' }, + config: config.stiff, + trail: 100, + initial: null, + }) + return ( ) } diff --git a/libs/ui/lib/properties-table/PropertiesTable.tsx b/libs/ui/lib/properties-table/PropertiesTable.tsx index 26233b4b53..6154e7a883 100644 --- a/libs/ui/lib/properties-table/PropertiesTable.tsx +++ b/libs/ui/lib/properties-table/PropertiesTable.tsx @@ -28,7 +28,7 @@ export function PropertiesTable({ className, children }: PropertiesTableProps) {
    {children} @@ -45,7 +45,7 @@ PropertiesTable.Row = ({ label, children }: PropertiesTableRowProps) => ( {label} -
    +
    {children}
    diff --git a/package-lock.json b/package-lock.json index 24ded399aa..54b05e76a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@floating-ui/react": "^0.26.4", "@headlessui/react": "^1.7.17", - "@oxide/design-system": "^1.2.9", + "@oxide/design-system": "^1.2.10", "@oxide/identicon": "0.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", @@ -2543,9 +2543,9 @@ "dev": true }, "node_modules/@oxide/design-system": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-1.2.9.tgz", - "integrity": "sha512-uLKLFEmr7DTYXqTVhI1rdTRHR2x2tkkEGC2o2gzTse6fqvRxoKGAMhlWt2rMJMCI0hxgUIYuYPCaWO6/6smNBA==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-1.2.10.tgz", + "integrity": "sha512-L7KhX2rRYy/+QDnc7yW5ie/5IzmX+gCB8R6+zSjt7f+Za9oRkLHecw47KLUWr5lOTgJM4QaJubrlD0nUYb0cJw==", "dependencies": { "@figma-export/output-components-as-svgr": "^4.7.0", "@floating-ui/react": "^0.25.1", @@ -23098,9 +23098,9 @@ "dev": true }, "@oxide/design-system": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-1.2.9.tgz", - "integrity": "sha512-uLKLFEmr7DTYXqTVhI1rdTRHR2x2tkkEGC2o2gzTse6fqvRxoKGAMhlWt2rMJMCI0hxgUIYuYPCaWO6/6smNBA==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-1.2.10.tgz", + "integrity": "sha512-L7KhX2rRYy/+QDnc7yW5ie/5IzmX+gCB8R6+zSjt7f+Za9oRkLHecw47KLUWr5lOTgJM4QaJubrlD0nUYb0cJw==", "requires": { "@figma-export/output-components-as-svgr": "^4.7.0", "@floating-ui/react": "^0.25.1", diff --git a/package.json b/package.json index e89b7671e7..a3a98d812f 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "dependencies": { "@floating-ui/react": "^0.26.4", "@headlessui/react": "^1.7.17", - "@oxide/design-system": "^1.2.9", + "@oxide/design-system": "^1.2.10", "@oxide/identicon": "0.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6",