diff --git a/app/components/AccordionItem.tsx b/app/components/AccordionItem.tsx new file mode 100644 index 0000000000..2ca660d416 --- /dev/null +++ b/app/components/AccordionItem.tsx @@ -0,0 +1,46 @@ +/* + * 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 * as Accordion from '@radix-ui/react-accordion' +import cn from 'classnames' +import { useEffect, useRef } from 'react' + +import { DirectionRightIcon } from '@oxide/design-system/icons/react' + +type AccordionItemProps = { + children: React.ReactNode + isOpen: boolean + label: string + value: string +} + +export const AccordionItem = ({ children, isOpen, label, value }: AccordionItemProps) => { + const contentRef = useRef(null) + useEffect(() => { + if (isOpen && contentRef.current) { + contentRef.current.scrollIntoView({ behavior: 'smooth' }) + } + }, [isOpen]) + + return ( + + + +
{label}
+ +
+
+ + {children} + +
+ ) +} diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx new file mode 100644 index 0000000000..73d46b44b2 --- /dev/null +++ b/app/forms/floating-ip-create.tsx @@ -0,0 +1,147 @@ +/* + * 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 * as Accordion from '@radix-ui/react-accordion' +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import type { SetRequired } from 'type-fest' + +import { + apiQueryClient, + useApiMutation, + useApiQueryClient, + usePrefetchedApiQuery, + type FloatingIpCreate, + type SiloIpPool, +} from '@oxide/api' +import { Badge, Message } from '@oxide/ui' +import { validateIp } from '@oxide/util' + +import { AccordionItem } from 'app/components/AccordionItem' +import { + DescriptionField, + ListboxField, + NameField, + SideModalForm, + TextField, +} from 'app/components/form' +import { useForm, useProjectSelector, useToast } from 'app/hooks' +import { pb } from 'app/util/path-builder' + +CreateFloatingIpSideModalForm.loader = async () => { + await apiQueryClient.prefetchQuery('projectIpPoolList', { + query: { limit: 1000 }, + }) + return null +} + +const toListboxItem = (p: SiloIpPool) => { + if (!p.isDefault) { + return { value: p.name, label: p.name } + } + // For the default pool, add a label to the dropdown + return { + value: p.name, + labelString: p.name, + label: ( + <> + {p.name}{' '} + + default + + + ), + } +} + +const defaultValues: SetRequired = { + name: '', + description: '', + pool: undefined, + address: '', +} + +export function CreateFloatingIpSideModalForm() { + // Fetch 1000 to we can be sure to get them all. + const { data: allPools } = usePrefetchedApiQuery('projectIpPoolList', { + query: { limit: 1000 }, + }) + + const queryClient = useApiQueryClient() + const projectSelector = useProjectSelector() + const addToast = useToast() + const navigate = useNavigate() + + const createFloatingIp = useApiMutation('floatingIpCreate', { + onSuccess() { + queryClient.invalidateQueries('floatingIpList') + addToast({ content: 'Your Floating IP has been created' }) + navigate(pb.floatingIps(projectSelector)) + }, + }) + + const form = useForm({ defaultValues }) + const isPoolSelected = !!form.watch('pool') + + const [openItems, setOpenItems] = useState([]) + + return ( + navigate(pb.floatingIps(projectSelector))} + onSubmit={({ address, ...rest }) => { + createFloatingIp.mutate({ + query: projectSelector, + // if address is '', evaluate as false and send as undefined + body: { address: address || undefined, ...rest }, + }) + }} + loading={createFloatingIp.isPending} + submitError={createFloatingIp.error} + > + + + + + + + + toListboxItem(p))} + label="IP pool" + control={form.control} + placeholder="Select pool" + /> + v.replace(/\s/g, '')} + validate={(ip) => + ip && !validateIp(ip).valid ? 'Not a valid IP address' : true + } + /> + + + + ) +} diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index e3d5b4bec5..28ef80a543 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -6,8 +6,7 @@ * Copyright Oxide Computer Company */ import * as Accordion from '@radix-ui/react-accordion' -import cn from 'classnames' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useWatch, type Control } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import type { SetRequired } from 'type-fest' @@ -23,7 +22,6 @@ import { type InstanceCreate, } from '@oxide/api' import { - DirectionRightIcon, EmptyMessage, FormDivider, Images16Icon, @@ -35,6 +33,7 @@ import { } from '@oxide/ui' import { GiB, invariant } from '@oxide/util' +import { AccordionItem } from 'app/components/AccordionItem' import { CheckboxField, DescriptionField, @@ -487,41 +486,6 @@ const AdvancedAccordion = ({ ) } -type AccordionItemProps = { - value: string - isOpen: boolean - label: string - children: React.ReactNode -} - -function AccordionItem({ value, label, children, isOpen }: AccordionItemProps) { - const contentRef = useRef(null) - - useEffect(() => { - if (isOpen && contentRef.current) { - contentRef.current.scrollIntoView({ behavior: 'smooth' }) - } - }, [isOpen]) - - return ( - - - -
{label}
- -
-
- - {children} - -
- ) -} - const renderLargeRadioCards = (category: string) => { return PRESETS.filter((option) => option.category === category).map((option) => ( diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts index 6a1293d8f1..a53e4ea30b 100644 --- a/app/hooks/use-params.ts +++ b/app/hooks/use-params.ts @@ -33,6 +33,7 @@ export const requireParams = } export const getProjectSelector = requireParams('project') +export const getFloatingIpSelector = requireParams('project', 'floatingIp') export const getInstanceSelector = requireParams('project', 'instance') export const getVpcSelector = requireParams('project', 'vpc') export const getSiloSelector = requireParams('silo') @@ -69,6 +70,7 @@ function useSelectedParams(getSelector: (params: AllParams) => T) { // params are present. Only the specified keys end up in the result object, but // we do not error if there are other params present in the query string. +export const useFloatingIpSelector = () => useSelectedParams(getFloatingIpSelector) export const useProjectSelector = () => useSelectedParams(getProjectSelector) export const useProjectImageSelector = () => useSelectedParams(getProjectImageSelector) export const useProjectSnapshotSelector = () => diff --git a/app/layouts/ProjectLayout.tsx b/app/layouts/ProjectLayout.tsx index 046ff29f22..8faa04ed0a 100644 --- a/app/layouts/ProjectLayout.tsx +++ b/app/layouts/ProjectLayout.tsx @@ -20,6 +20,7 @@ import { Folder16Icon, Images16Icon, Instances16Icon, + IpGlobal16Icon, Networking16Icon, Snapshots16Icon, Storage16Icon, @@ -67,7 +68,8 @@ function ProjectLayout({ overrideContentPane }: ProjectLayoutProps) { { value: 'Disks', path: pb.disks(projectSelector) }, { value: 'Snapshots', path: pb.snapshots(projectSelector) }, { value: 'Images', path: pb.projectImages(projectSelector) }, - { value: 'Networking', path: pb.vpcs(projectSelector) }, + { value: 'VPCs', path: pb.vpcs(projectSelector) }, + { value: 'Floating IPs', path: pb.floatingIps(projectSelector) }, { value: 'Access & IAM', path: pb.projectAccess(projectSelector) }, ] // filter out the entry for the path we're currently on @@ -111,7 +113,10 @@ function ProjectLayout({ overrideContentPane }: ProjectLayoutProps) { Images - Networking + VPCs + + + Floating IPs Access & IAM diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index f5ccc0a0b9..5c6fc1ae07 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -12,15 +12,13 @@ import { diskCan, genName, useApiMutation, - useApiQuery, useApiQueryClient, type Disk, } from '@oxide/api' import { DateCell, - LinkCell, + InstanceLinkCell, SizeCell, - SkeletonCell, useQueryTable, type MenuAction, } from '@oxide/table' @@ -40,24 +38,6 @@ import { pb } from 'app/util/path-builder' import { fancifyStates } from '../instances/instance/tabs/common' -function InstanceNameFromId({ value: instanceId }: { value: string | null }) { - const { project } = useProjectSelector() - const { data: instance } = useApiQuery( - 'instanceView', - { path: { instance: instanceId! } }, - { enabled: !!instanceId } - ) - - if (!instanceId) return null - if (!instance) return - - return ( - - {instance.name} - - ) -} - const EmptyState = () => ( } @@ -157,7 +137,7 @@ export function DisksPage() { // whether it has an instance field 'instance' in disk.state ? disk.state.instance : null } - cell={InstanceNameFromId} + cell={InstanceLinkCell} /> ( + } + title="No Floating IPs" + body="You need to create a Floating IP to be able to see it here" + buttonText="New Floating IP" + buttonTo={pb.floatingIpNew(useProjectSelector())} + /> +) + +FloatingIpsPage.loader = async ({ params }: LoaderFunctionArgs) => { + const { project } = getProjectSelector(params) + await Promise.all([ + apiQueryClient.prefetchQuery('floatingIpList', { + query: { project, limit: 25 }, + }), + apiQueryClient.prefetchQuery('instanceList', { + query: { project }, + }), + ]) + return null +} + +export function FloatingIpsPage() { + const [floatingIpToModify, setFloatingIpToModify] = useState(null) + const queryClient = useApiQueryClient() + const { project } = useProjectSelector() + const { data: instances } = usePrefetchedApiQuery('instanceList', { + query: { project }, + }) + const getInstanceName = (instanceId: string) => + instances.items.find((i) => i.id === instanceId)?.name + + const floatingIpDetach = useApiMutation('floatingIpDetach', { + onSuccess() { + queryClient.invalidateQueries('floatingIpList') + addToast({ content: 'Your Floating IP has been detached' }) + }, + onError: (err) => { + addToast({ title: 'Error', content: err.message, variant: 'error' }) + }, + }) + const deleteFloatingIp = useApiMutation('floatingIpDelete', { + onSuccess() { + queryClient.invalidateQueries('floatingIpList') + addToast({ content: 'Your Floating IP has been deleted' }) + }, + }) + + const makeActions = (floatingIp: FloatingIp): MenuAction[] => { + const isAttachedToAnInstance = !!floatingIp.instanceId + const attachOrDetachAction = isAttachedToAnInstance + ? { + label: 'Detach', + onActivate: () => + confirmAction({ + actionType: 'danger', + doAction: () => + floatingIpDetach.mutateAsync({ + path: { floatingIp: floatingIp.name }, + query: { project }, + }), + modalTitle: 'Detach Floating IP', + modalContent: ( +

+ Are you sure you want to detach floating IP {floatingIp.name}{' '} + from instance{' '} + + { + // instanceId is guaranteed to be non-null here + getInstanceName(floatingIp.instanceId!) + } + + ? The instance will no longer be reachable at {floatingIp.ip}. +

+ ), + errorTitle: 'Error detaching floating IP', + }), + } + : { + label: 'Attach', + onActivate() { + setFloatingIpToModify(floatingIp) + }, + } + return [ + attachOrDetachAction, + { + label: 'Delete', + disabled: isAttachedToAnInstance + ? 'This floating IP must be detached from the instance before it can be deleted' + : false, + onActivate: confirmDelete({ + doDelete: () => + deleteFloatingIp.mutateAsync({ + path: { floatingIp: floatingIp.name }, + query: { project }, + }), + label: floatingIp.name, + }), + }, + ] + } + + const { Table, Column } = useQueryTable('floatingIpList', { query: { project } }) + return ( + <> + + }>Floating IPs + + + + New Floating IP + + + } makeActions={makeActions}> + + + + +
+ + {floatingIpToModify && ( + setFloatingIpToModify(null)} + /> + )} + + ) +} + +const AttachFloatingIpModal = ({ + floatingIp, + address, + instances, + project, + onDismiss, +}: { + floatingIp: string + address: string + instances: Array + project: string + onDismiss: () => void +}) => { + const queryClient = useApiQueryClient() + const floatingIpAttach = useApiMutation('floatingIpAttach', { + onSuccess() { + queryClient.invalidateQueries('floatingIpList') + addToast({ content: 'Your Floating IP has been attached' }) + onDismiss() + }, + onError: (err) => { + addToast({ title: 'Error', content: err.message, variant: 'error' }) + }, + }) + const form = useForm({ defaultValues: { instanceId: '' } }) + + return ( + + + + + The selected instance will be reachable at {address} + + } + > +
+ ({ value: i.id, label: i.name }))} + label="Instance" + onChange={(e) => { + form.setValue('instanceId', e) + }} + required + placeholder="Select instance" + selected={form.watch('instanceId')} + /> + +
+
+ + floatingIpAttach.mutate({ + path: { floatingIp }, + query: { project }, + body: { kind: 'instance', parent: form.getValues('instanceId') }, + }) + } + onDismiss={onDismiss} + > +
+ ) +} diff --git a/app/pages/project/floating-ips/index.ts b/app/pages/project/floating-ips/index.ts new file mode 100644 index 0000000000..522c5a7cbd --- /dev/null +++ b/app/pages/project/floating-ips/index.ts @@ -0,0 +1,9 @@ +/* + * 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 + */ + +export * from './FloatingIpsPage' diff --git a/app/pages/project/index.tsx b/app/pages/project/index.tsx index 1a77061b66..73d86cb57f 100644 --- a/app/pages/project/index.tsx +++ b/app/pages/project/index.tsx @@ -10,5 +10,6 @@ export * from './access' export * from './disks' export * from './images' export * from './instances' -export * from './networking' +export * from './vpcs' +export * from './floating-ips' export * from './snapshots' diff --git a/app/pages/project/networking/VpcPage/VpcPage.tsx b/app/pages/project/vpcs/VpcPage/VpcPage.tsx similarity index 100% rename from app/pages/project/networking/VpcPage/VpcPage.tsx rename to app/pages/project/vpcs/VpcPage/VpcPage.tsx diff --git a/app/pages/project/networking/VpcPage/index.tsx b/app/pages/project/vpcs/VpcPage/index.tsx similarity index 100% rename from app/pages/project/networking/VpcPage/index.tsx rename to app/pages/project/vpcs/VpcPage/index.tsx diff --git a/app/pages/project/networking/VpcPage/tabs/VpcFirewallRulesTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab.tsx similarity index 100% rename from app/pages/project/networking/VpcPage/tabs/VpcFirewallRulesTab.tsx rename to app/pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab.tsx diff --git a/app/pages/project/networking/VpcPage/tabs/VpcGatewaysTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcGatewaysTab.tsx similarity index 100% rename from app/pages/project/networking/VpcPage/tabs/VpcGatewaysTab.tsx rename to app/pages/project/vpcs/VpcPage/tabs/VpcGatewaysTab.tsx diff --git a/app/pages/project/networking/VpcPage/tabs/VpcSubnetsTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx similarity index 100% rename from app/pages/project/networking/VpcPage/tabs/VpcSubnetsTab.tsx rename to app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx diff --git a/app/pages/project/networking/VpcsPage.tsx b/app/pages/project/vpcs/VpcsPage.tsx similarity index 100% rename from app/pages/project/networking/VpcsPage.tsx rename to app/pages/project/vpcs/VpcsPage.tsx diff --git a/app/pages/project/networking/index.tsx b/app/pages/project/vpcs/index.tsx similarity index 100% rename from app/pages/project/networking/index.tsx rename to app/pages/project/vpcs/index.tsx diff --git a/app/routes.tsx b/app/routes.tsx index 0da226e92f..cff0bd1921 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -10,6 +10,7 @@ import { createRoutesFromElements, Navigate, Route } from 'react-router-dom' import { RouterDataErrorBoundary } from './components/ErrorBoundary' import { NotFound } from './components/ErrorPage' import { CreateDiskSideModalForm } from './forms/disk-create' +import { CreateFloatingIpSideModalForm } from './forms/floating-ip-create' import { CreateIdpSideModalForm } from './forms/idp/create' import { EditIdpSideModalForm } from './forms/idp/edit' import { @@ -46,6 +47,7 @@ import { LoginPageSaml } from './pages/LoginPageSaml' import { instanceLookupLoader } from './pages/lookups' import { DisksPage, + FloatingIpsPage, ImagesPage, InstancePage, InstancesPage, @@ -350,6 +352,16 @@ export const routes = createRoutesFromElements( /> + }> + + } + handle={{ crumb: 'New Floating IP' }} + /> + + } loader={DisksPage.loader}> { + await page.goto(floatingIpsPage) + await page.locator('text="New Floating IP"').click() + + await expectVisible(page, [ + 'role=heading[name*="Create Floating IP"]', + 'role=textbox[name="Name"]', + 'role=textbox[name="Description"]', + 'role=button[name="Advanced"]', + 'role=button[name="Create Floating IP"]', + ]) + + const floatingIpName = 'my-floating-ip' + await page.fill('input[name=name]', floatingIpName) + await page + .getByRole('textbox', { name: 'Description' }) + .fill('A description for this Floating IP') + + // accordion content should be hidden + await expectNotVisible(page, ['role=textbox[name="Address"]']) + + // open accordion + await page.getByRole('button', { name: 'Advanced' }).click() + + const addressTextbox = page.getByRole('textbox', { name: 'Address' }) + + // accordion content should be visible + await expectVisible(page, [page.getByRole('button', { name: 'IP pool' }), addressTextbox]) + + // test that the IP validation works + await page.getByRole('button', { name: 'IP pool' }).click() + await page.getByRole('option', { name: 'ip-pool-1' }).click() + await addressTextbox.fill('256.256.256.256') + await page.getByRole('button', { name: 'Create Floating IP' }).click() + await expect(page.getByText('Not a valid IP address').first()).toBeVisible() + + // correct IP and submit + await addressTextbox.clear() + await addressTextbox.fill('12.34.56.78') + + await page.getByRole('button', { name: 'Create Floating IP' }).click() + + await expect(page).toHaveURL(floatingIpsPage) + + await expectRowVisible(page.getByRole('table'), { + name: floatingIpName, + description: 'A description for this Floating IP', + }) +}) + +test('can detach and attach a Floating IP', async ({ page }) => { + await page.goto(floatingIpsPage) + + await expectRowVisible(page.getByRole('table'), { + 'Attached to instance': 'db1', + }) + await clickRowAction(page, 'cola-float', 'Detach') + await page.getByRole('button', { name: 'Confirm' }).click() + + await expectNotVisible(page, ['role=heading[name*="Detach Floating IP"]']) + // Since we detached it, we don't expect to see db1 any longer + await expectNotVisible(page, ['text=db1']) + + // Reattach it to db1 + await clickRowAction(page, 'cola-float', 'Attach') + await page.getByRole('button', { name: 'Select instance' }).click() + await page.getByRole('option', { name: 'db1' }).click() + + await page.getByRole('button', { name: 'Attach' }).click() + + // The dialog should be gone + await expectNotVisible(page, ['role=heading[name*="Attach Floating IP"]']) + await expectRowVisible(page.getByRole('table'), { + 'Attached to instance': 'db1', + }) +}) diff --git a/app/test/e2e/networking.e2e.ts b/app/test/e2e/networking.e2e.ts index a71c7a00cb..999ef18907 100644 --- a/app/test/e2e/networking.e2e.ts +++ b/app/test/e2e/networking.e2e.ts @@ -12,7 +12,7 @@ import { expectNotVisible, expectVisible } from './utils' test('Create and edit VPC', async ({ page }) => { await page.goto('/projects/mock-project') - await page.click('role=link[name*="Networking"]') + await page.click('role=link[name*="VPCs"]') await expectVisible(page, [ 'role=heading[name*="VPCs"]', 'role=cell[name="mock-vpc"] >> nth=0', diff --git a/app/test/e2e/vpcs.e2e.ts b/app/test/e2e/vpcs.e2e.ts index e0b5f2e0ad..b9a35c053b 100644 --- a/app/test/e2e/vpcs.e2e.ts +++ b/app/test/e2e/vpcs.e2e.ts @@ -10,7 +10,7 @@ import { expect, test } from '@playwright/test' test('can nav to VpcPage from /', async ({ page }) => { await page.goto('/') await page.click('table :text("mock-project")') - await page.click('a:has-text("Networking")') + await page.click('a:has-text("VPCs")') await page.click('a:has-text("mock-vpc")') await expect(page.locator('text=mock-subnet')).toBeVisible() expect(await page.title()).toEqual('mock-vpc / VPCs / mock-project / Oxide Console') diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 35b955a681..6476b096a3 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -31,6 +31,8 @@ test('path builder', () => { "diskInventory": "/system/inventory/disks", "diskNew": "/projects/p/disks-new", "disks": "/projects/p/disks", + "floatingIpNew": "/projects/p/floating-ips-new", + "floatingIps": "/projects/p/floating-ips", "instance": "/projects/p/instances/i", "instanceConnect": "/projects/p/instances/i/connect", "instanceMetrics": "/projects/p/instances/i/metrics", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index d4a5af14c5..4357f5df95 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -66,6 +66,8 @@ export const pb = { vpcs: (params: Project) => `${pb.project(params)}/vpcs`, vpc: (params: Vpc) => `${pb.vpcs(params)}/${params.vpc}`, vpcEdit: (params: Vpc) => `${pb.vpc(params)}/edit`, + floatingIps: (params: Project) => `${pb.project(params)}/floating-ips`, + floatingIpNew: (params: Project) => `${pb.project(params)}/floating-ips-new`, siloUtilization: () => '/utilization', siloAccess: () => '/access', diff --git a/libs/api-mocks/floating-ip.ts b/libs/api-mocks/floating-ip.ts new file mode 100644 index 0000000000..5a0456bfbd --- /dev/null +++ b/libs/api-mocks/floating-ip.ts @@ -0,0 +1,39 @@ +/* + * 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 { FloatingIp } from '@oxide/api' + +import { instance } from './instance' +import type { Json } from './json-type' +import { project } from './project' + +// A floating IP from the default pool +export const floatingIp: Json = { + id: '3ca0ccb7-d66d-4fde-a871-ab9855eaea8e', + name: 'rootbeer-float', + description: 'A classic.', + instance_id: undefined, + ip: '192.168.32.1', + project_id: project.id, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), +} + +// A floating IP attached to a particular instance +export const floatingIp2: Json = { + id: '0a00a6c3-4821-4bb8-af77-574468ac6651', + name: 'cola-float', + description: 'A favourite.', + instance_id: instance.id, + ip: '192.168.64.64', + project_id: project.id, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), +} + +export const floatingIps = [floatingIp, floatingIp2] diff --git a/libs/api-mocks/index.ts b/libs/api-mocks/index.ts index 3cbc07b773..129856fe9c 100644 --- a/libs/api-mocks/index.ts +++ b/libs/api-mocks/index.ts @@ -8,6 +8,7 @@ export * from './disk' export * from './external-ip' +export * from './floating-ip' export * from './image' export * from './instance' export * from './ip-pool' diff --git a/libs/api-mocks/msw/db.ts b/libs/api-mocks/msw/db.ts index a5d9d27c67..5c29f77aa2 100644 --- a/libs/api-mocks/msw/db.ts +++ b/libs/api-mocks/msw/db.ts @@ -81,6 +81,19 @@ export const lookup = { return disk }, + floatingIp({ floatingIp: id, ...projectSelector }: PP.FloatingIp): Json { + if (!id) throw notFoundErr + + if (isUuid(id)) return lookupById(db.floatingIps, id) + + const project = lookup.project(projectSelector) + const floatingIp = db.floatingIps.find( + (i) => i.project_id === project.id && i.name === id + ) + if (!floatingIp) throw notFoundErr + + return floatingIp + }, snapshot({ snapshot: id, ...projectSelector }: PP.Snapshot): Json { if (!id) throw notFoundErr @@ -245,6 +258,7 @@ type DiskBulkImport = { const initDb = { disks: [...mock.disks], diskBulkImportState: new Map(), + floatingIps: [...mock.floatingIps], userGroups: [...mock.userGroups], /** Join table for `users` and `userGroups` */ groupMemberships: [...mock.groupMemberships], diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index 0543cf74da..6044df9d0d 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -223,6 +223,50 @@ export const handlers = makeHandlers({ return 204 }, + floatingIpCreate({ body, query }) { + const project = lookup.project(query) + errIfExists(db.floatingIps, { name: body.name }) + + const newFloatingIp: Json = { + id: uuid(), + project_id: project.id, + ip: [...Array(4)].map(() => Math.floor(Math.random() * 256)).join('.'), + ...body, + ...getTimestamps(), + } + db.floatingIps.push(newFloatingIp) + return json(newFloatingIp, { status: 201 }) + }, + floatingIpList({ query }) { + const project = lookup.project(query) + const ips = db.floatingIps.filter((i) => i.project_id === project.id) + return paginated(query, ips) + }, + floatingIpView: ({ path, query }) => lookup.floatingIp({ ...path, ...query }), + floatingIpDelete({ path, query }) { + const floatingIp = lookup.floatingIp({ ...path, ...query }) + db.floatingIps = db.floatingIps.filter((i) => i.id !== floatingIp.id) + + return 204 + }, + floatingIpAttach({ path, query, body }) { + const floatingIp = lookup.floatingIp({ ...path, ...query }) + if (floatingIp.instance_id) { + throw 'floating IP cannot be attached to one instance while still attached to another' + } + const instance = lookup.instance({ ...path, ...query, instance: body.parent }) + floatingIp.instance_id = instance.id + + return floatingIp + }, + floatingIpDetach({ path, query }) { + const floatingIp = lookup.floatingIp({ ...path, ...query }) + db.floatingIps = db.floatingIps.map((ip) => + ip.id !== floatingIp.id ? ip : { ...ip, instance_id: undefined } + ) + + return floatingIp + }, imageList({ query }) { if (query.project) { const project = lookup.project(query) @@ -1129,12 +1173,6 @@ export const handlers = makeHandlers({ certificateDelete: NotImplemented, certificateList: NotImplemented, certificateView: NotImplemented, - floatingIpCreate: NotImplemented, - floatingIpDelete: NotImplemented, - floatingIpList: NotImplemented, - floatingIpView: NotImplemented, - floatingIpAttach: NotImplemented, - floatingIpDetach: NotImplemented, instanceEphemeralIpDetach: NotImplemented, instanceEphemeralIpAttach: NotImplemented, instanceMigrate: NotImplemented, diff --git a/libs/api/path-params.ts b/libs/api/path-params.ts index 8f2ccc8270..dc5c30a9af 100644 --- a/libs/api/path-params.ts +++ b/libs/api/path-params.ts @@ -22,5 +22,6 @@ export type SystemUpdate = { version: string } export type SshKey = { sshKey: string } export type Sled = { sledId?: string } export type IpPool = { pool?: string } +export type FloatingIp = Merge export type Id = { id: string } diff --git a/libs/table/cells/InstanceLinkCell.tsx b/libs/table/cells/InstanceLinkCell.tsx new file mode 100644 index 0000000000..a525b3d96f --- /dev/null +++ b/libs/table/cells/InstanceLinkCell.tsx @@ -0,0 +1,33 @@ +/* + * 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 { useProjectSelector } from 'app/hooks' +import { pb } from 'app/util/path-builder' + +import { SkeletonCell } from './EmptyCell' +import { LinkCell } from './LinkCell' + +export const InstanceLinkCell = ({ value: instanceId }: { value: string | null }) => { + const { project } = useProjectSelector() + const { data: instance } = useApiQuery( + 'instanceView', + { path: { instance: instanceId! } }, + { enabled: !!instanceId } + ) + + if (!instanceId) return null + if (!instance) return + + return ( + + {instance.name} + + ) +} diff --git a/libs/table/cells/index.ts b/libs/table/cells/index.ts index f6d81a5d57..1034543f0a 100644 --- a/libs/table/cells/index.ts +++ b/libs/table/cells/index.ts @@ -13,6 +13,7 @@ export * from './DefaultCell' export * from './EnabledCell' export * from './EmptyCell' export * from './FirewallFilterCell' +export * from './InstanceLinkCell' export * from './InstanceResourceCell' export * from './InstanceStatusCell' export * from './LabelCell'