Skip to content

Commit 80f1167

Browse files
Create Floating IPs and attach them to instances (#1957)
* Add Floating IP create / attach / detach / delete to UI --------- Co-authored-by: David Crespo <david-crespo@users.noreply.github.com>
1 parent 5d989a7 commit 80f1167

29 files changed

Lines changed: 710 additions & 71 deletions

app/components/AccordionItem.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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 * as Accordion from '@radix-ui/react-accordion'
9+
import cn from 'classnames'
10+
import { useEffect, useRef } from 'react'
11+
12+
import { DirectionRightIcon } from '@oxide/design-system/icons/react'
13+
14+
type AccordionItemProps = {
15+
children: React.ReactNode
16+
isOpen: boolean
17+
label: string
18+
value: string
19+
}
20+
21+
export const AccordionItem = ({ children, isOpen, label, value }: AccordionItemProps) => {
22+
const contentRef = useRef<HTMLDivElement>(null)
23+
useEffect(() => {
24+
if (isOpen && contentRef.current) {
25+
contentRef.current.scrollIntoView({ behavior: 'smooth' })
26+
}
27+
}, [isOpen])
28+
29+
return (
30+
<Accordion.Item value={value}>
31+
<Accordion.Header className="max-w-lg">
32+
<Accordion.Trigger className="group flex w-full items-center justify-between border-t pt-2 text-sans-xl border-secondary [&>svg]:data-[state=open]:rotate-90">
33+
<div className="text-secondary">{label}</div>
34+
<DirectionRightIcon className="transition-all text-secondary" />
35+
</Accordion.Trigger>
36+
</Accordion.Header>
37+
<Accordion.Content
38+
ref={contentRef}
39+
forceMount
40+
className={cn('ox-accordion-content overflow-hidden py-8', { hidden: !isOpen })}
41+
>
42+
{children}
43+
</Accordion.Content>
44+
</Accordion.Item>
45+
)
46+
}

app/forms/floating-ip-create.tsx

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
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 * as Accordion from '@radix-ui/react-accordion'
9+
import { useState } from 'react'
10+
import { useNavigate } from 'react-router-dom'
11+
import type { SetRequired } from 'type-fest'
12+
13+
import {
14+
apiQueryClient,
15+
useApiMutation,
16+
useApiQueryClient,
17+
usePrefetchedApiQuery,
18+
type FloatingIpCreate,
19+
type SiloIpPool,
20+
} from '@oxide/api'
21+
import { Badge, Message } from '@oxide/ui'
22+
import { validateIp } from '@oxide/util'
23+
24+
import { AccordionItem } from 'app/components/AccordionItem'
25+
import {
26+
DescriptionField,
27+
ListboxField,
28+
NameField,
29+
SideModalForm,
30+
TextField,
31+
} from 'app/components/form'
32+
import { useForm, useProjectSelector, useToast } from 'app/hooks'
33+
import { pb } from 'app/util/path-builder'
34+
35+
CreateFloatingIpSideModalForm.loader = async () => {
36+
await apiQueryClient.prefetchQuery('projectIpPoolList', {
37+
query: { limit: 1000 },
38+
})
39+
return null
40+
}
41+
42+
const toListboxItem = (p: SiloIpPool) => {
43+
if (!p.isDefault) {
44+
return { value: p.name, label: p.name }
45+
}
46+
// For the default pool, add a label to the dropdown
47+
return {
48+
value: p.name,
49+
labelString: p.name,
50+
label: (
51+
<>
52+
{p.name}{' '}
53+
<Badge className="ml-1" color="neutral">
54+
default
55+
</Badge>
56+
</>
57+
),
58+
}
59+
}
60+
61+
const defaultValues: SetRequired<FloatingIpCreate, 'address'> = {
62+
name: '',
63+
description: '',
64+
pool: undefined,
65+
address: '',
66+
}
67+
68+
export function CreateFloatingIpSideModalForm() {
69+
// Fetch 1000 to we can be sure to get them all.
70+
const { data: allPools } = usePrefetchedApiQuery('projectIpPoolList', {
71+
query: { limit: 1000 },
72+
})
73+
74+
const queryClient = useApiQueryClient()
75+
const projectSelector = useProjectSelector()
76+
const addToast = useToast()
77+
const navigate = useNavigate()
78+
79+
const createFloatingIp = useApiMutation('floatingIpCreate', {
80+
onSuccess() {
81+
queryClient.invalidateQueries('floatingIpList')
82+
addToast({ content: 'Your Floating IP has been created' })
83+
navigate(pb.floatingIps(projectSelector))
84+
},
85+
})
86+
87+
const form = useForm({ defaultValues })
88+
const isPoolSelected = !!form.watch('pool')
89+
90+
const [openItems, setOpenItems] = useState<string[]>([])
91+
92+
return (
93+
<SideModalForm
94+
id="create-floating-ip-form"
95+
title="Create Floating IP"
96+
form={form}
97+
onDismiss={() => navigate(pb.floatingIps(projectSelector))}
98+
onSubmit={({ address, ...rest }) => {
99+
createFloatingIp.mutate({
100+
query: projectSelector,
101+
// if address is '', evaluate as false and send as undefined
102+
body: { address: address || undefined, ...rest },
103+
})
104+
}}
105+
loading={createFloatingIp.isPending}
106+
submitError={createFloatingIp.error}
107+
>
108+
<NameField name="name" control={form.control} />
109+
<DescriptionField name="description" control={form.control} />
110+
111+
<Accordion.Root
112+
type="multiple"
113+
className="mt-12 max-w-lg"
114+
value={openItems}
115+
onValueChange={setOpenItems}
116+
>
117+
<AccordionItem
118+
isOpen={openItems.includes('advanced')}
119+
label="Advanced"
120+
value="advanced"
121+
>
122+
<Message
123+
variant="info"
124+
content="If you don’t specify a pool, the default will be used"
125+
/>
126+
127+
<ListboxField
128+
name="pool"
129+
items={allPools.items.map((p) => toListboxItem(p))}
130+
label="IP pool"
131+
control={form.control}
132+
placeholder="Select pool"
133+
/>
134+
<TextField
135+
name="address"
136+
control={form.control}
137+
disabled={!isPoolSelected}
138+
transform={(v) => v.replace(/\s/g, '')}
139+
validate={(ip) =>
140+
ip && !validateIp(ip).valid ? 'Not a valid IP address' : true
141+
}
142+
/>
143+
</AccordionItem>
144+
</Accordion.Root>
145+
</SideModalForm>
146+
)
147+
}

app/forms/instance-create.tsx

Lines changed: 2 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
* Copyright Oxide Computer Company
77
*/
88
import * as Accordion from '@radix-ui/react-accordion'
9-
import cn from 'classnames'
10-
import { useEffect, useMemo, useRef, useState } from 'react'
9+
import { useEffect, useMemo, useState } from 'react'
1110
import { useWatch, type Control } from 'react-hook-form'
1211
import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom'
1312
import type { SetRequired } from 'type-fest'
@@ -23,7 +22,6 @@ import {
2322
type InstanceCreate,
2423
} from '@oxide/api'
2524
import {
26-
DirectionRightIcon,
2725
EmptyMessage,
2826
FormDivider,
2927
Images16Icon,
@@ -35,6 +33,7 @@ import {
3533
} from '@oxide/ui'
3634
import { GiB, invariant } from '@oxide/util'
3735

36+
import { AccordionItem } from 'app/components/AccordionItem'
3837
import {
3938
CheckboxField,
4039
DescriptionField,
@@ -487,41 +486,6 @@ const AdvancedAccordion = ({
487486
)
488487
}
489488

490-
type AccordionItemProps = {
491-
value: string
492-
isOpen: boolean
493-
label: string
494-
children: React.ReactNode
495-
}
496-
497-
function AccordionItem({ value, label, children, isOpen }: AccordionItemProps) {
498-
const contentRef = useRef<HTMLDivElement>(null)
499-
500-
useEffect(() => {
501-
if (isOpen && contentRef.current) {
502-
contentRef.current.scrollIntoView({ behavior: 'smooth' })
503-
}
504-
}, [isOpen])
505-
506-
return (
507-
<Accordion.Item value={value}>
508-
<Accordion.Header className="max-w-lg">
509-
<Accordion.Trigger className="group flex w-full items-center justify-between border-t py-2 text-sans-xl border-secondary [&>svg]:data-[state=open]:rotate-90">
510-
<div className="text-secondary">{label}</div>
511-
<DirectionRightIcon className="transition-all text-secondary" />
512-
</Accordion.Trigger>
513-
</Accordion.Header>
514-
<Accordion.Content
515-
ref={contentRef}
516-
forceMount
517-
className={cn('ox-accordion-content overflow-hidden py-8', { hidden: !isOpen })}
518-
>
519-
{children}
520-
</Accordion.Content>
521-
</Accordion.Item>
522-
)
523-
}
524-
525489
const renderLargeRadioCards = (category: string) => {
526490
return PRESETS.filter((option) => option.category === category).map((option) => (
527491
<RadioCard key={option.id} value={option.id}>

app/hooks/use-params.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const requireParams =
3333
}
3434

3535
export const getProjectSelector = requireParams('project')
36+
export const getFloatingIpSelector = requireParams('project', 'floatingIp')
3637
export const getInstanceSelector = requireParams('project', 'instance')
3738
export const getVpcSelector = requireParams('project', 'vpc')
3839
export const getSiloSelector = requireParams('silo')
@@ -69,6 +70,7 @@ function useSelectedParams<T>(getSelector: (params: AllParams) => T) {
6970
// params are present. Only the specified keys end up in the result object, but
7071
// we do not error if there are other params present in the query string.
7172

73+
export const useFloatingIpSelector = () => useSelectedParams(getFloatingIpSelector)
7274
export const useProjectSelector = () => useSelectedParams(getProjectSelector)
7375
export const useProjectImageSelector = () => useSelectedParams(getProjectImageSelector)
7476
export const useProjectSnapshotSelector = () =>

app/layouts/ProjectLayout.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
Folder16Icon,
2121
Images16Icon,
2222
Instances16Icon,
23+
IpGlobal16Icon,
2324
Networking16Icon,
2425
Snapshots16Icon,
2526
Storage16Icon,
@@ -67,7 +68,8 @@ function ProjectLayout({ overrideContentPane }: ProjectLayoutProps) {
6768
{ value: 'Disks', path: pb.disks(projectSelector) },
6869
{ value: 'Snapshots', path: pb.snapshots(projectSelector) },
6970
{ value: 'Images', path: pb.projectImages(projectSelector) },
70-
{ value: 'Networking', path: pb.vpcs(projectSelector) },
71+
{ value: 'VPCs', path: pb.vpcs(projectSelector) },
72+
{ value: 'Floating IPs', path: pb.floatingIps(projectSelector) },
7173
{ value: 'Access & IAM', path: pb.projectAccess(projectSelector) },
7274
]
7375
// filter out the entry for the path we're currently on
@@ -111,7 +113,10 @@ function ProjectLayout({ overrideContentPane }: ProjectLayoutProps) {
111113
<Images16Icon title="images" /> Images
112114
</NavLinkItem>
113115
<NavLinkItem to={pb.vpcs(projectSelector)}>
114-
<Networking16Icon /> Networking
116+
<Networking16Icon /> VPCs
117+
</NavLinkItem>
118+
<NavLinkItem to={pb.floatingIps(projectSelector)}>
119+
<IpGlobal16Icon /> Floating IPs
115120
</NavLinkItem>
116121
<NavLinkItem to={pb.projectAccess(projectSelector)}>
117122
<Access16Icon title="Access & IAM" /> Access &amp; IAM

app/pages/project/disks/DisksPage.tsx

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,13 @@ import {
1212
diskCan,
1313
genName,
1414
useApiMutation,
15-
useApiQuery,
1615
useApiQueryClient,
1716
type Disk,
1817
} from '@oxide/api'
1918
import {
2019
DateCell,
21-
LinkCell,
20+
InstanceLinkCell,
2221
SizeCell,
23-
SkeletonCell,
2422
useQueryTable,
2523
type MenuAction,
2624
} from '@oxide/table'
@@ -40,24 +38,6 @@ import { pb } from 'app/util/path-builder'
4038

4139
import { fancifyStates } from '../instances/instance/tabs/common'
4240

43-
function InstanceNameFromId({ value: instanceId }: { value: string | null }) {
44-
const { project } = useProjectSelector()
45-
const { data: instance } = useApiQuery(
46-
'instanceView',
47-
{ path: { instance: instanceId! } },
48-
{ enabled: !!instanceId }
49-
)
50-
51-
if (!instanceId) return null
52-
if (!instance) return <SkeletonCell />
53-
54-
return (
55-
<LinkCell to={pb.instancePage({ project, instance: instance.name })}>
56-
{instance.name}
57-
</LinkCell>
58-
)
59-
}
60-
6141
const EmptyState = () => (
6242
<EmptyMessage
6343
icon={<Storage24Icon />}
@@ -157,7 +137,7 @@ export function DisksPage() {
157137
// whether it has an instance field
158138
'instance' in disk.state ? disk.state.instance : null
159139
}
160-
cell={InstanceNameFromId}
140+
cell={InstanceLinkCell}
161141
/>
162142
<Column header="Size" accessor="size" cell={SizeCell} />
163143
<Column

0 commit comments

Comments
 (0)