-
Notifications
You must be signed in to change notification settings - Fork 19
Add floating ips to instance create #2252
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a1cf1bc
1a02de0
76001bf
2a98024
c38c181
597ab34
9f4b7f6
a920aea
7e840b0
6d211ea
de80c5c
cc66203
6679cf9
0074c40
d6eebce
09adbcc
9c519ce
37912dd
59b27d1
ecbf0c5
64cb620
3f1296a
779d301
497f186
4de9b85
cdcd2b2
9c63149
9b6bcfe
5a45161
da18dcc
8e41cf7
14a2e74
9a535c9
c1af1d1
5de89a4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -20,13 +20,17 @@ import { | |||||||||||||
| useApiMutation, | ||||||||||||||
| useApiQueryClient, | ||||||||||||||
| usePrefetchedApiQuery, | ||||||||||||||
| type ExternalIpCreate, | ||||||||||||||
| type FloatingIp, | ||||||||||||||
| type InstanceCreate, | ||||||||||||||
| type InstanceDiskAttachment, | ||||||||||||||
| type NameOrId, | ||||||||||||||
| } from '@oxide/api' | ||||||||||||||
| import { | ||||||||||||||
| Images16Icon, | ||||||||||||||
| Instances16Icon, | ||||||||||||||
| Instances24Icon, | ||||||||||||||
| IpGlobal16Icon, | ||||||||||||||
| Storage16Icon, | ||||||||||||||
| } from '@oxide/design-system/icons/react' | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -50,19 +54,25 @@ import { SshKeysField } from '~/components/form/fields/SshKeysField' | |||||||||||||
| import { TextField } from '~/components/form/fields/TextField' | ||||||||||||||
| import { Form } from '~/components/form/Form' | ||||||||||||||
| import { FullPageForm } from '~/components/form/FullPageForm' | ||||||||||||||
| import { HL } from '~/components/HL' | ||||||||||||||
| import { getProjectSelector, useForm, useProjectSelector } from '~/hooks' | ||||||||||||||
| import { addToast } from '~/stores/toast' | ||||||||||||||
| import { Badge } from '~/ui/lib/Badge' | ||||||||||||||
| import { Button } from '~/ui/lib/Button' | ||||||||||||||
| import { Checkbox } from '~/ui/lib/Checkbox' | ||||||||||||||
| import { FormDivider } from '~/ui/lib/Divider' | ||||||||||||||
| import { EmptyMessage } from '~/ui/lib/EmptyMessage' | ||||||||||||||
| import { Listbox } from '~/ui/lib/Listbox' | ||||||||||||||
| import { Message } from '~/ui/lib/Message' | ||||||||||||||
| import * as MiniTable from '~/ui/lib/MiniTable' | ||||||||||||||
| import { Modal } from '~/ui/lib/Modal' | ||||||||||||||
| import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' | ||||||||||||||
| import { RadioCard } from '~/ui/lib/Radio' | ||||||||||||||
| import { Slash } from '~/ui/lib/Slash' | ||||||||||||||
| import { Tabs } from '~/ui/lib/Tabs' | ||||||||||||||
| import { TextInputHint } from '~/ui/lib/TextInput' | ||||||||||||||
| import { TipIcon } from '~/ui/lib/TipIcon' | ||||||||||||||
| import { isTruthy } from '~/util/array' | ||||||||||||||
| import { readBlobAsBase64 } from '~/util/file' | ||||||||||||||
| import { docLinks, links } from '~/util/links' | ||||||||||||||
| import { nearest10 } from '~/util/math' | ||||||||||||||
|
|
@@ -153,6 +163,7 @@ CreateInstanceForm.loader = async ({ params }: LoaderFunctionArgs) => { | |||||||||||||
| }), | ||||||||||||||
| apiQueryClient.prefetchQuery('currentUserSshKeyList', {}), | ||||||||||||||
| apiQueryClient.prefetchQuery('projectIpPoolList', { query: { limit: 1000 } }), | ||||||||||||||
| apiQueryClient.prefetchQuery('floatingIpList', { query: { project, limit: 1000 } }), | ||||||||||||||
| ]) | ||||||||||||||
| return null | ||||||||||||||
| } | ||||||||||||||
|
|
@@ -573,6 +584,28 @@ export function CreateInstanceForm() { | |||||||||||||
| ) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // `ip is …` guard is necessary until we upgrade to 5.5, which handles this automatically | ||||||||||||||
| const isFloating = ( | ||||||||||||||
| ip: ExternalIpCreate | ||||||||||||||
| ): ip is { type: 'floating'; floatingIp: NameOrId } => ip.type === 'floating' | ||||||||||||||
|
|
||||||||||||||
| const FloatingIpLabel = ({ ip }: { ip: FloatingIp }) => ( | ||||||||||||||
| <div> | ||||||||||||||
| <div>{ip.name}</div> | ||||||||||||||
| <div className="flex gap-0.5 text-tertiary selected:text-accent-secondary"> | ||||||||||||||
| <div>{ip.ip}</div> | ||||||||||||||
| {ip.description && ( | ||||||||||||||
| <> | ||||||||||||||
| <Slash /> | ||||||||||||||
| <div className="grow overflow-hidden overflow-ellipsis whitespace-pre text-left"> | ||||||||||||||
| {ip.description} | ||||||||||||||
| </div> | ||||||||||||||
| </> | ||||||||||||||
| )} | ||||||||||||||
| </div> | ||||||||||||||
| </div> | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| const AdvancedAccordion = ({ | ||||||||||||||
| control, | ||||||||||||||
| isSubmitting, | ||||||||||||||
|
|
@@ -586,11 +619,65 @@ const AdvancedAccordion = ({ | |||||||||||||
| // tell, inside AccordionItem, when an accordion is opened so we can scroll its | ||||||||||||||
| // contents into view | ||||||||||||||
| const [openItems, setOpenItems] = useState<string[]>([]) | ||||||||||||||
| const [floatingIpModalOpen, setFloatingIpModalOpen] = useState(false) | ||||||||||||||
| const [selectedFloatingIp, setSelectedFloatingIp] = useState<FloatingIp | undefined>() | ||||||||||||||
| const externalIps = useController({ control, name: 'externalIps' }) | ||||||||||||||
| const ephemeralIp = externalIps.field.value?.find((ip) => ip.type === 'ephemeral') | ||||||||||||||
| const assignEphemeralIp = !!ephemeralIp | ||||||||||||||
| const selectedPool = ephemeralIp && 'pool' in ephemeralIp ? ephemeralIp.pool : undefined | ||||||||||||||
| const defaultPool = siloPools.find((pool) => pool.isDefault)?.name | ||||||||||||||
| const attachedFloatingIps = (externalIps.field.value || []).filter(isFloating) | ||||||||||||||
|
|
||||||||||||||
| const { project } = useProjectSelector() | ||||||||||||||
| const { data: floatingIpList } = usePrefetchedApiQuery('floatingIpList', { | ||||||||||||||
| query: { project, limit: 1000 }, | ||||||||||||||
| }) | ||||||||||||||
|
|
||||||||||||||
| // Filter out the IPs that are already attached to an instance | ||||||||||||||
| const attachableFloatingIps = useMemo( | ||||||||||||||
| () => floatingIpList.items.filter((ip) => !ip.instanceId), | ||||||||||||||
| [floatingIpList] | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| // To find available floating IPs, we remove the ones that are already committed to this instance | ||||||||||||||
| const availableFloatingIps = attachableFloatingIps.filter( | ||||||||||||||
| (ip) => !attachedFloatingIps.find((attachedIp) => attachedIp.floatingIp === ip.name) | ||||||||||||||
| ) | ||||||||||||||
| const attachedFloatingIpsData = attachedFloatingIps | ||||||||||||||
| .map((ip) => attachableFloatingIps.find((fip) => fip.name === ip.floatingIp)) | ||||||||||||||
| .filter(isTruthy) | ||||||||||||||
|
|
||||||||||||||
| const closeFloatingIpModal = () => { | ||||||||||||||
| setFloatingIpModalOpen(false) | ||||||||||||||
| setSelectedFloatingIp(undefined) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const attachFloatingIp = () => { | ||||||||||||||
| if (selectedFloatingIp) { | ||||||||||||||
| externalIps.field.onChange([ | ||||||||||||||
| ...(externalIps.field.value || []), | ||||||||||||||
| { type: 'floating', floatingIp: selectedFloatingIp.name }, | ||||||||||||||
| ]) | ||||||||||||||
| } | ||||||||||||||
| closeFloatingIpModal() | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const detachFloatingIp = (name: string) => { | ||||||||||||||
| externalIps.field.onChange( | ||||||||||||||
| externalIps.field.value?.filter( | ||||||||||||||
| (ip) => !(ip.type === 'floating' && ip.floatingIp === name) | ||||||||||||||
| ) | ||||||||||||||
| ) | ||||||||||||||
| } | ||||||||||||||
david-crespo marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||
|
|
||||||||||||||
| const isFloatingIpAttached = attachedFloatingIps.some((ip) => ip.floatingIp !== '') | ||||||||||||||
|
|
||||||||||||||
| const selectedFloatingIpMessage = ( | ||||||||||||||
| <> | ||||||||||||||
| This instance will be reachable at{' '} | ||||||||||||||
| {selectedFloatingIp ? <HL>{selectedFloatingIp.ip}</HL> : 'the selected IP'} | ||||||||||||||
| </> | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| return ( | ||||||||||||||
| <Accordion.Root | ||||||||||||||
|
|
@@ -669,6 +756,101 @@ const AdvancedAccordion = ({ | |||||||||||||
| /> | ||||||||||||||
| )} | ||||||||||||||
| </div> | ||||||||||||||
|
|
||||||||||||||
| <div className="flex flex-1 flex-col gap-4"> | ||||||||||||||
| <h2 className="text-sans-md"> | ||||||||||||||
| Floating IPs{' '} | ||||||||||||||
| <TipIcon> | ||||||||||||||
| Floating IPs exist independently of instances and can be attached to and | ||||||||||||||
| detached from them as needed. | ||||||||||||||
| </TipIcon> | ||||||||||||||
| </h2> | ||||||||||||||
| {isFloatingIpAttached && ( | ||||||||||||||
| <MiniTable.Table> | ||||||||||||||
| <MiniTable.Header> | ||||||||||||||
| <MiniTable.HeadCell>Name</MiniTable.HeadCell> | ||||||||||||||
| <MiniTable.HeadCell>IP</MiniTable.HeadCell> | ||||||||||||||
| {/* For remove button */} | ||||||||||||||
| <MiniTable.HeadCell className="w-12" /> | ||||||||||||||
| </MiniTable.Header> | ||||||||||||||
| <MiniTable.Body> | ||||||||||||||
| {attachedFloatingIpsData.map((item, index) => ( | ||||||||||||||
| <MiniTable.Row | ||||||||||||||
| tabIndex={0} | ||||||||||||||
| aria-rowindex={index + 1} | ||||||||||||||
| aria-label={`Name: ${item.name}, IP: ${item.ip}`} | ||||||||||||||
| key={item.name} | ||||||||||||||
| > | ||||||||||||||
| <MiniTable.Cell>{item.name}</MiniTable.Cell> | ||||||||||||||
| <MiniTable.Cell>{item.ip}</MiniTable.Cell> | ||||||||||||||
| <MiniTable.RemoveCell | ||||||||||||||
| onClick={() => detachFloatingIp(item.name)} | ||||||||||||||
| label={`remove floating IP ${item.name}`} | ||||||||||||||
| /> | ||||||||||||||
| </MiniTable.Row> | ||||||||||||||
| ))} | ||||||||||||||
| </MiniTable.Body> | ||||||||||||||
| </MiniTable.Table> | ||||||||||||||
| )} | ||||||||||||||
| {floatingIpList.items.length === 0 ? ( | ||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be available IPs? You still can't do anything if floating IPs exist but are taken.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The idea here is that if the list of floating IPs coming in from the API query has 0 items, we show them the empty message; if there are any items in that list at all (attached or not), we show them the
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think what felt funny to me is that within this form, I'm not sure the distinction between "there are no floating IPs at all" and "there are floating IPs but they're all taken" is meaningful to the user. We are treating it as meaningful by representing those two states differently. Here are all the states:
I'm wondering if we should give the first two rows the same representation. This can't matter that much because the user is never going to see both versions next to each other and wonder why they're different, but I'm finding it helpful to think through it.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we consolidated them, which of the two treatments feels better to you? Just the disabled button (with no empty state)? That would certainly be less "involved" on an already pretty-lengthy page. Though the empty state does call your eye to it, and for something as foundational as a floating IP, I could see it making sense to have it called out like that.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree there is something nice about the full empty state almost inviting the user to create one. @benjaminleonard since you weighed in before, what do you think? |
||||||||||||||
| <div className="flex max-w-lg items-center justify-center rounded-lg border p-6 border-default"> | ||||||||||||||
| <EmptyMessage | ||||||||||||||
| icon={<IpGlobal16Icon />} | ||||||||||||||
| title="No floating IPs found" | ||||||||||||||
| body="Create a floating IP to attach it to this instance" | ||||||||||||||
| /> | ||||||||||||||
| </div> | ||||||||||||||
| ) : ( | ||||||||||||||
| <div> | ||||||||||||||
| <Button | ||||||||||||||
| size="sm" | ||||||||||||||
| className="shrink-0" | ||||||||||||||
| disabled={availableFloatingIps.length === 0} | ||||||||||||||
| disabledReason="No floating IPs available" | ||||||||||||||
| onClick={() => setFloatingIpModalOpen(true)} | ||||||||||||||
| > | ||||||||||||||
| Attach floating IP | ||||||||||||||
| </Button> | ||||||||||||||
| </div> | ||||||||||||||
| )} | ||||||||||||||
|
|
||||||||||||||
| <Modal | ||||||||||||||
| isOpen={floatingIpModalOpen} | ||||||||||||||
| onDismiss={closeFloatingIpModal} | ||||||||||||||
| title="Attach floating IP" | ||||||||||||||
| > | ||||||||||||||
| <Modal.Body> | ||||||||||||||
| <Modal.Section> | ||||||||||||||
| <Message variant="info" content={selectedFloatingIpMessage} /> | ||||||||||||||
| <form> | ||||||||||||||
| <Listbox | ||||||||||||||
| name="floatingIp" | ||||||||||||||
| items={availableFloatingIps.map((i) => ({ | ||||||||||||||
| value: i.name, | ||||||||||||||
| label: <FloatingIpLabel ip={i} />, | ||||||||||||||
| selectedLabel: `${i.name} (${i.ip})`, | ||||||||||||||
| }))} | ||||||||||||||
| label="Floating IP" | ||||||||||||||
| onChange={(name) => { | ||||||||||||||
| setSelectedFloatingIp( | ||||||||||||||
| availableFloatingIps.find((i) => i.name === name) | ||||||||||||||
| ) | ||||||||||||||
| }} | ||||||||||||||
| required | ||||||||||||||
| placeholder="Select floating IP" | ||||||||||||||
| selected={selectedFloatingIp?.name || ''} | ||||||||||||||
| /> | ||||||||||||||
| </form> | ||||||||||||||
| </Modal.Section> | ||||||||||||||
| </Modal.Body> | ||||||||||||||
| <Modal.Footer | ||||||||||||||
| actionText="Attach" | ||||||||||||||
| disabled={!selectedFloatingIp} | ||||||||||||||
| onAction={attachFloatingIp} | ||||||||||||||
| onDismiss={closeFloatingIpModal} | ||||||||||||||
| ></Modal.Footer> | ||||||||||||||
| </Modal> | ||||||||||||||
| </div> | ||||||||||||||
| </AccordionItem> | ||||||||||||||
| <AccordionItem | ||||||||||||||
| value="configuration" | ||||||||||||||
|
|
||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| /* | ||
| * 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 const Slash = () => ( | ||
| <span className="mx-1 text-quinary selected:text-accent-disabled">/</span> | ||
| ) |
Uh oh!
There was an error while loading. Please reload this page.