Skip to content

Prototype: Stop instance disk modal #2747

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

Draft
wants to merge 19 commits into
base: main
Choose a base branch
from
Draft
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
72 changes: 36 additions & 36 deletions app/components/AttachFloatingIpModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ import { ListboxField } from '~/components/form/fields/ListboxField'
import { HL } from '~/components/HL'
import { addToast } from '~/stores/toast'
import { Message } from '~/ui/lib/Message'
import { Modal } from '~/ui/lib/Modal'
import { Slash } from '~/ui/lib/Slash'

import { ModalForm } from './form/ModalForm'

function FloatingIpLabel({ fip }: { fip: FloatingIp }) {
return (
<div className="text-secondary selected:text-accent-secondary">
Expand Down Expand Up @@ -60,40 +61,39 @@ export const AttachFloatingIpModal = ({
const floatingIp = form.watch('floatingIp')

return (
<Modal isOpen title="Attach floating IP" onDismiss={onDismiss}>
<Modal.Body>
<Modal.Section>
<Message
variant="info"
content={`Instance ‘${instance.name}’ will be reachable at the selected IP address`}
/>
<form>
<ListboxField
control={form.control}
name="floatingIp"
label="Floating IP"
placeholder="Select a floating IP"
items={floatingIps.map((ip) => ({
value: ip.id,
label: <FloatingIpLabel fip={ip} />,
selectedLabel: ip.name,
}))}
required
/>
</form>
</Modal.Section>
</Modal.Body>
<Modal.Footer
actionText="Attach"
disabled={!floatingIp}
onAction={() =>
floatingIpAttach.mutate({
path: { floatingIp }, // note that this is an ID!
body: { kind: 'instance', parent: instance.id },
})
}
onDismiss={onDismiss}
></Modal.Footer>
</Modal>
<ModalForm
form={form}
onDismiss={onDismiss}
submitLabel="Attach floating IP"
submitError={floatingIpAttach.error}
loading={floatingIpAttach.isPending}
title="Attach floating IP"
onSubmit={() =>
floatingIpAttach.mutate({
path: { floatingIp }, // note that this is an ID!
body: { kind: 'instance', parent: instance.id },
})
}
submitDisabled={!floatingIp}
>
<Message
variant="info"
content={`Instance ‘${instance.name}’ will be reachable at the selected IP address`}
/>
<form>
<ListboxField
control={form.control}
name="floatingIp"
label="Floating IP"
placeholder="Select a floating IP"
items={floatingIps.map((ip) => ({
value: ip.id,
label: <FloatingIpLabel fip={ip} />,
selectedLabel: ip.name,
}))}
required
/>
</form>
</ModalForm>
)
}
64 changes: 64 additions & 0 deletions app/components/StopInstancePrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* 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 ReactNode } from 'react'

import { apiQueryClient, useApiMutation, type Instance } from '@oxide/api'

import { HL } from '~/components/HL'
import { addToast } from '~/stores/toast'
import { Button } from '~/ui/lib/Button'
import { Message } from '~/ui/lib/Message'

type StopInstancePromptProps = {
instance: Instance
children: ReactNode
}

export function StopInstancePrompt({ instance, children }: StopInstancePromptProps) {
const isStoppingInstance = instance.runState === 'stopping'

const stopInstance = useApiMutation('instanceStop', {
onSuccess: () => {
// trigger polling by the top level InstancePage one
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh this is very clever

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only downside is that you have a delay in the loading state because it doesn't change to loading while the initial stop request goes through. I tried this

const isStoppingInstance = stopInstance.isPending || instance.runState === 'stopping'

and it does respond immediately, but it flashes back to off briefly in between the stop request completing and the invalidation running and flipping the instance to 'stopping'. I can't think of a fix other than adding back in the explicit state thing you had. Those things make me nervous because the logic has to be bulletproof to avoid accidentally getting stuck in a loading state, but I'm sure we can do it.

apiQueryClient.invalidateQueries('instanceView')
addToast(<>Stopping instance <HL>{instance.name}</HL></>) // prettier-ignore
},
onError: (error) => {
addToast({
variant: 'error',
title: `Error stopping instance '${instance.name}'`,
content: error.message,
})
},
})

return (
<Message
variant="notice"
content={
<>
{children}{' '}
<Button
size="xs"
className="mt-3"
variant="notice"
onClick={() =>
stopInstance.mutateAsync({
path: { instance: instance.name },
query: { project: instance.projectId },
})
}
loading={isStoppingInstance}
>
Stop instance
</Button>
</>
}
/>
)
}
85 changes: 85 additions & 0 deletions app/components/form/ModalForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* 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 { useId, type ReactNode } from 'react'
import type { FieldValues, UseFormReturn } from 'react-hook-form'

import type { ApiError } from '@oxide/api'

import { Message } from '~/ui/lib/Message'
import { Modal, type ModalProps } from '~/ui/lib/Modal'

type ModalFormProps<TFieldValues extends FieldValues> = {
form: UseFormReturn<TFieldValues>
children: ReactNode

/** Must be provided with a reason describing why it's disabled */
submitDisabled?: boolean
onSubmit: (values: TFieldValues) => void
submitLabel: string

// require loading and error so we can't forget to hook them up. there are a
// few forms that don't need them, so we'll use dummy values

/** Error from the API call */
submitError: ApiError | null
loading: boolean
} & Omit<ModalProps, 'isOpen'>

export function ModalForm<TFieldValues extends FieldValues>({
form,
children,
onDismiss,
submitDisabled = false,
submitError,
title,
onSubmit,
submitLabel = 'Save',
loading,
width = 'medium',
overlay = true,
}: ModalFormProps<TFieldValues>) {
const id = useId()
const { isSubmitting } = form.formState

return (
<Modal isOpen onDismiss={onDismiss} title={title} width={width} overlay={overlay}>
<Modal.Body>
<Modal.Section>
{submitError && (
<Message variant="error" title="Error" content={submitError.message} />
)}
<form
id={id}
className="ox-form"
autoComplete="off"
onSubmit={(e) => {
if (!onSubmit) return
// This modal being in a portal doesn't prevent the submit event
// from bubbling up out of the portal. Normally that's not a
// problem, but sometimes (e.g., instance create) we render the
// SideModalForm from inside another form, in which case submitting
// the inner form submits the outer form unless we stop propagation
e.stopPropagation()
form.handleSubmit(onSubmit)(e)
}}
>
{children}
</form>
</Modal.Section>
</Modal.Body>
<Modal.Footer
onDismiss={onDismiss}
formId={id}
actionText={submitLabel}
disabled={submitDisabled}
actionLoading={loading || isSubmitting}
/>
</Modal>
)
}
8 changes: 0 additions & 8 deletions app/components/form/SideModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,6 @@ type EditFormProps = {

type SideModalFormProps<TFieldValues extends FieldValues> = {
form: UseFormReturn<TFieldValues>
/**
* A function that returns the fields.
*
* Implemented as a function so we can pass `control` to the fields in the
* calling code. We could do that internally with `cloneElement` instead, but
* then in the calling code, the field would not infer `TFieldValues` and
* constrain the `name` prop to paths in the values object.
*/
children: ReactNode
onDismiss: () => void
resourceName: string
Expand Down
4 changes: 2 additions & 2 deletions app/components/form/fields/DisksTableField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { useController, type Control } from 'react-hook-form'

import type { DiskCreate } from '@oxide/api'

import { AttachDiskSideModalForm } from '~/forms/disk-attach'
import { AttachDiskModalForm } from '~/forms/disk-attach'
import { CreateDiskSideModalForm } from '~/forms/disk-create'
import type { InstanceCreateInput } from '~/forms/instance-create'
import { Badge } from '~/ui/lib/Badge'
Expand Down Expand Up @@ -115,7 +115,7 @@ export function DisksTableField({
/>
)}
{showDiskAttach && (
<AttachDiskSideModalForm
<AttachDiskModalForm
onDismiss={() => setShowDiskAttach(false)}
onSubmit={(values) => {
onChange([...items, { type: 'attach', ...values }])
Expand Down
34 changes: 22 additions & 12 deletions app/forms/disk-attach.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
import { useMemo } from 'react'
import { useForm } from 'react-hook-form'

import { useApiQuery, type ApiError } from '@oxide/api'
import { instanceCan, useApiQuery, type ApiError, type Instance } from '@oxide/api'

import { ComboboxField } from '~/components/form/fields/ComboboxField'
import { SideModalForm } from '~/components/form/SideModalForm'
import { ModalForm } from '~/components/form/ModalForm'
import { StopInstancePrompt } from '~/components/StopInstancePrompt'
import { useProjectSelector } from '~/hooks/use-params'
import { toComboboxItems } from '~/ui/lib/Combobox'
import { ALL_ISH } from '~/util/consts'
Expand All @@ -25,22 +26,24 @@ type AttachDiskProps = {
diskNamesToExclude?: string[]
loading?: boolean
submitError?: ApiError | null
instance?: Instance
}

/**
* Can be used with either a `setState` or a real mutation as `onSubmit`, hence
* the optional `loading` and `submitError`
*/
export function AttachDiskSideModalForm({
export function AttachDiskModalForm({
onSubmit,
onDismiss,
diskNamesToExclude = [],
loading = false,
submitError = null,
instance,
}: AttachDiskProps) {
const { project } = useProjectSelector()

const { data } = useApiQuery('diskList', {
const { data, isPending } = useApiQuery('diskList', {
query: { project, limit: ALL_ISH },
})
const detachedDisks = useMemo(
Expand All @@ -54,26 +57,33 @@ export function AttachDiskSideModalForm({
)

const form = useForm({ defaultValues })
const { control } = form

return (
<SideModalForm
<ModalForm
form={form}
formType="create"
resourceName="disk"
onDismiss={onDismiss}
submitLabel="Attach disk"
submitError={submitError}
loading={loading}
title="Attach disk"
onSubmit={onSubmit}
loading={loading}
submitError={submitError}
onDismiss={onDismiss}
submitDisabled={instance && !instanceCan.attachDisk(instance)}
>
{instance && ['stopping', 'running'].includes(instance.runState) && (
<StopInstancePrompt instance={instance}>
An instance must be stopped to attach a disk.
</StopInstancePrompt>
)}
<ComboboxField
label="Disk name"
placeholder="Select a disk"
name="name"
items={detachedDisks}
required
control={form.control}
control={control}
isLoading={isPending}
/>
</SideModalForm>
</ModalForm>
)
}
Loading
Loading