diff --git a/app/components/AttachFloatingIpModal.tsx b/app/components/AttachFloatingIpModal.tsx
index 87fa708b7..a2022fe94 100644
--- a/app/components/AttachFloatingIpModal.tsx
+++ b/app/components/AttachFloatingIpModal.tsx
@@ -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 (
@@ -60,40 +61,39 @@ export const AttachFloatingIpModal = ({
const floatingIp = form.watch('floatingIp')
return (
-
-
-
-
-
-
-
-
- floatingIpAttach.mutate({
- path: { floatingIp }, // note that this is an ID!
- body: { kind: 'instance', parent: instance.id },
- })
- }
- onDismiss={onDismiss}
- >
-
+
+ floatingIpAttach.mutate({
+ path: { floatingIp }, // note that this is an ID!
+ body: { kind: 'instance', parent: instance.id },
+ })
+ }
+ submitDisabled={!floatingIp}
+ >
+
+
+
)
}
diff --git a/app/components/StopInstancePrompt.tsx b/app/components/StopInstancePrompt.tsx
new file mode 100644
index 000000000..162bb1d9f
--- /dev/null
+++ b/app/components/StopInstancePrompt.tsx
@@ -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
+ apiQueryClient.invalidateQueries('instanceView')
+ addToast(<>Stopping instance
{instance.name}>) // prettier-ignore
+ },
+ onError: (error) => {
+ addToast({
+ variant: 'error',
+ title: `Error stopping instance '${instance.name}'`,
+ content: error.message,
+ })
+ },
+ })
+
+ return (
+
+ {children}{' '}
+
+ >
+ }
+ />
+ )
+}
diff --git a/app/components/form/ModalForm.tsx b/app/components/form/ModalForm.tsx
new file mode 100644
index 000000000..be0897196
--- /dev/null
+++ b/app/components/form/ModalForm.tsx
@@ -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 = {
+ form: UseFormReturn
+ 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
+
+export function ModalForm({
+ form,
+ children,
+ onDismiss,
+ submitDisabled = false,
+ submitError,
+ title,
+ onSubmit,
+ submitLabel = 'Save',
+ loading,
+ width = 'medium',
+ overlay = true,
+}: ModalFormProps) {
+ const id = useId()
+ const { isSubmitting } = form.formState
+
+ return (
+
+
+
+ {submitError && (
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/app/components/form/SideModalForm.tsx b/app/components/form/SideModalForm.tsx
index 3e353052e..5d8f15ba5 100644
--- a/app/components/form/SideModalForm.tsx
+++ b/app/components/form/SideModalForm.tsx
@@ -30,14 +30,6 @@ type EditFormProps = {
type SideModalFormProps = {
form: UseFormReturn
- /**
- * 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
diff --git a/app/components/form/fields/DisksTableField.tsx b/app/components/form/fields/DisksTableField.tsx
index 2d9aa1270..6134b505f 100644
--- a/app/components/form/fields/DisksTableField.tsx
+++ b/app/components/form/fields/DisksTableField.tsx
@@ -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'
@@ -115,7 +115,7 @@ export function DisksTableField({
/>
)}
{showDiskAttach && (
- setShowDiskAttach(false)}
onSubmit={(values) => {
onChange([...items, { type: 'attach', ...values }])
diff --git a/app/forms/disk-attach.tsx b/app/forms/disk-attach.tsx
index 5a6eeaaaf..d9cf9808b 100644
--- a/app/forms/disk-attach.tsx
+++ b/app/forms/disk-attach.tsx
@@ -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'
@@ -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(
@@ -54,26 +57,33 @@ export function AttachDiskSideModalForm({
)
const form = useForm({ defaultValues })
+ const { control } = form
return (
-
+ {instance && ['stopping', 'running'].includes(instance.runState) && (
+
+ An instance must be stopped to attach a disk.
+
+ )}
-
+
)
}
diff --git a/app/pages/SiloImagesPage.tsx b/app/pages/SiloImagesPage.tsx
index 82f0ca89d..30b3a565b 100644
--- a/app/pages/SiloImagesPage.tsx
+++ b/app/pages/SiloImagesPage.tsx
@@ -7,7 +7,7 @@
*/
import { createColumnHelper } from '@tanstack/react-table'
import { useCallback, useMemo, useState } from 'react'
-import { useForm, type FieldValues } from 'react-hook-form'
+import { useForm } from 'react-hook-form'
import { Outlet } from 'react-router'
import {
@@ -24,6 +24,7 @@ import { DocsPopover } from '~/components/DocsPopover'
import { ComboboxField } from '~/components/form/fields/ComboboxField'
import { toImageComboboxItem } from '~/components/form/fields/ImageSelectField'
import { ListboxField } from '~/components/form/fields/ListboxField'
+import { ModalForm } from '~/components/form/ModalForm'
import { HL } from '~/components/HL'
import { confirmDelete } from '~/stores/confirm-delete'
import { addToast } from '~/stores/toast'
@@ -35,7 +36,6 @@ import { Button } from '~/ui/lib/Button'
import { toComboboxItems } from '~/ui/lib/Combobox'
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
import { Message } from '~/ui/lib/Message'
-import { Modal } from '~/ui/lib/Modal'
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
import { TableActions } from '~/ui/lib/Table'
import { docLinks } from '~/util/links'
@@ -129,7 +129,7 @@ type Values = { project: string | null; image: string | null }
const defaultValues: Values = { project: null, image: null }
const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => {
- const { control, handleSubmit, watch, resetField } = useForm({ defaultValues })
+ const form = useForm({ defaultValues })
const queryClient = useApiQueryClient()
@@ -146,7 +146,7 @@ const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => {
const projects = useApiQuery('projectList', {})
const projectItems = useMemo(() => toComboboxItems(projects.data?.items), [projects.data])
- const selectedProject = watch('project')
+ const selectedProject = form.watch('project')
// can only fetch images if a project is selected
const images = useApiQuery(
@@ -159,53 +159,52 @@ const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => {
[images.data]
)
- const onSubmit = ({ image, project }: Values) => {
- if (!image || !project) return
- promoteImage.mutate({ path: { image } })
- }
-
return (
-
-
-
-
-
-
-
- {
+ if (!image || !project) return // shouldn't happen because of validation
+ promoteImage.mutate({ path: { image } })
+ }}
+ onDismiss={onDismiss}
+ submitLabel="Promote"
+ >
+ {
+ form.resetField('image') // reset image field when the project changes
+ }}
+ isLoading={projects.isPending}
+ required
+ control={form.control}
+ />
+
-
+
+
)
}
+type DemoteFormValues = {
+ project: string | undefined
+}
+
const DemoteImageModal = ({
onDismiss,
image,
@@ -213,20 +212,17 @@ const DemoteImageModal = ({
onDismiss: () => void
image: Image
}) => {
- const { control, handleSubmit, watch } = useForm()
+ const defaultValues: DemoteFormValues = { project: undefined }
+ const form = useForm({ defaultValues })
- const selectedProject: string | undefined = watch('project')
+ const selectedProject: string | undefined = form.watch('project')
const queryClient = useApiQueryClient()
const demoteImage = useApiMutation('imageDemote', {
onSuccess(data) {
addToast({
- content: (
- <>
- Image {data.name} demoted
- >
- ),
+ content: <>Image {data.name} demoted>, // prettier-ignore
cta: selectedProject
? {
text: `View images in ${selectedProject}`,
@@ -243,51 +239,40 @@ const DemoteImageModal = ({
onSettled: onDismiss,
})
- const onSubmit = (data: FieldValues) => {
- demoteImage.mutate({ path: { image: image.id }, query: { project: data.project } })
- }
-
const projects = useApiQuery('projectList', {})
const projectItems = useMemo(() => toComboboxItems(projects.data?.items), [projects.data])
return (
-
-
-
-
-
-
-
-
+
)
}
diff --git a/app/pages/project/floating-ips/FloatingIpsPage.tsx b/app/pages/project/floating-ips/FloatingIpsPage.tsx
index a7c5f4d4b..b4a6caa74 100644
--- a/app/pages/project/floating-ips/FloatingIpsPage.tsx
+++ b/app/pages/project/floating-ips/FloatingIpsPage.tsx
@@ -23,6 +23,7 @@ import { IpGlobal16Icon, IpGlobal24Icon } from '@oxide/design-system/icons/react
import { DocsPopover } from '~/components/DocsPopover'
import { ListboxField } from '~/components/form/fields/ListboxField'
+import { ModalForm } from '~/components/form/ModalForm'
import { HL } from '~/components/HL'
import { makeCrumb } from '~/hooks/use-crumbs'
import { getProjectSelector, useProjectSelector } from '~/hooks/use-params'
@@ -38,7 +39,6 @@ import { CopyableIp } from '~/ui/lib/CopyableIp'
import { CreateLink } from '~/ui/lib/CreateButton'
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
import { Message } from '~/ui/lib/Message'
-import { Modal } from '~/ui/lib/Modal'
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
import { TableActions } from '~/ui/lib/Table'
import { ALL_ISH } from '~/util/consts'
@@ -258,45 +258,41 @@ const AttachFloatingIpModal = ({
addToast({ title: 'Error', content: err.message, variant: 'error' })
},
})
+
const form = useForm({ defaultValues: { instanceId: '' } })
- const instanceId = form.watch('instanceId')
return (
-
-
-
-
- The selected instance will be reachable at {address}
- >
- }
- />
-
-
-
-
- floatingIpAttach.mutate({
- path: { floatingIp },
- query: { project },
- body: { kind: 'instance', parent: instanceId },
- })
+ {
+ floatingIpAttach.mutate({
+ path: { floatingIp },
+ query: { project },
+ body: { kind: 'instance', parent: instanceId },
+ })
+ }}
+ submitLabel="Attach"
+ submitError={floatingIpAttach.error}
+ loading={floatingIpAttach.isPending}
+ onDismiss={onDismiss}
+ >
+
+ The selected instance will be reachable at {address}
+ >
}
- onDismiss={onDismiss}
- >
-
+ />
+ ({ value: i.id, label: i.name }))}
+ label="Instance"
+ required
+ placeholder="Select an instance"
+ />
+
)
}
diff --git a/app/pages/project/instances/StorageTab.tsx b/app/pages/project/instances/StorageTab.tsx
index fb58644b7..a1090c0be 100644
--- a/app/pages/project/instances/StorageTab.tsx
+++ b/app/pages/project/instances/StorageTab.tsx
@@ -25,7 +25,7 @@ import { Storage24Icon } from '@oxide/design-system/icons/react'
import { HL } from '~/components/HL'
import { DiskStateBadge } from '~/components/StateBadge'
-import { AttachDiskSideModalForm } from '~/forms/disk-attach'
+import { AttachDiskModalForm } from '~/forms/disk-attach'
import { CreateDiskSideModalForm } from '~/forms/disk-create'
import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params'
import { confirmAction } from '~/stores/confirm-action'
@@ -295,13 +295,6 @@ export default function StorageTab() {
setShowDiskCreate(false)
setShowDiskAttach(false)
},
- onError(err) {
- addToast({
- title: 'Failed to attach disk',
- content: err.message,
- variant: 'error',
- })
- },
})
const bootDisksTable = useReactTable({
@@ -341,35 +334,22 @@ export default function StorageTab() {
-
-
-
-
+
+
{otherDisks.length > 0 ? (
@@ -395,13 +375,14 @@ export default function StorageTab() {
/>
)}
{showDiskAttach && (
- setShowDiskAttach(false)}
onSubmit={({ name }) => {
attachDisk.mutate({ ...instancePathQuery, body: { disk: name } })
}}
loading={attachDisk.isPending}
submitError={attachDisk.error}
+ instance={instance}
/>
)}
diff --git a/app/ui/lib/Button.tsx b/app/ui/lib/Button.tsx
index e7b27f383..90141be0d 100644
--- a/app/ui/lib/Button.tsx
+++ b/app/ui/lib/Button.tsx
@@ -13,13 +13,14 @@ import { Spinner } from '~/ui/lib/Spinner'
import { Tooltip } from '~/ui/lib/Tooltip'
import { Wrap } from '~/ui/util/wrap'
-export const buttonSizes = ['sm', 'icon', 'base'] as const
-export const variants = ['primary', 'secondary', 'ghost', 'danger'] as const
+export const buttonSizes = ['xs', 'sm', 'icon', 'base'] as const
+export const variants = ['primary', 'secondary', 'ghost', 'danger', 'notice'] as const
export type ButtonSize = (typeof buttonSizes)[number]
export type Variant = (typeof variants)[number]
const sizeStyle: Record