From b4ce08d86e60c3bdce0772ddf110bd7041d52b6d Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Wed, 5 Mar 2025 12:14:47 +0000 Subject: [PATCH 01/16] Attach disk modal instead of side modal --- app/components/form/ModalForm.tsx | 105 ++++++++++++++++++ .../form/fields/DisksTableField.tsx | 4 +- app/forms/disk-attach.tsx | 23 ++-- app/pages/project/instances/StorageTab.tsx | 68 +++++------- 4 files changed, 150 insertions(+), 50 deletions(-) create mode 100644 app/components/form/ModalForm.tsx diff --git a/app/components/form/ModalForm.tsx b/app/components/form/ModalForm.tsx new file mode 100644 index 0000000000..1054a55105 --- /dev/null +++ b/app/components/form/ModalForm.tsx @@ -0,0 +1,105 @@ +/* + * 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 + /** + * 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 + resourceName: string + /** Must be provided with a reason describing why it's disabled */ + submitDisabled?: 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 + + /** Only needed if you need to override the default title (Create/Edit ${resourceName}) */ + subtitle?: ReactNode + onSubmit: (values: TFieldValues) => void + + submitLabel?: string +} & Omit + +export function ModalForm({ + form, + children, + onDismiss, + resourceName, + submitDisabled, + submitError, + title, + onSubmit, + submitLabel = 'Save', + loading, + subtitle, + width = 'medium', + overlay = true, +}: ModalFormProps) { + const id = useId() + + const { isSubmitting } = form.formState + + const modalTitle = title || `Create ${resourceName}` + + return ( + <> + + + + {subtitle &&
{subtitle}
} + {submitError && ( + + )} +
{ + if (!onSubmit) return + form.handleSubmit(onSubmit)(e) + }} + > + {children} +
+
+
+ +
+ + ) +} diff --git a/app/components/form/fields/DisksTableField.tsx b/app/components/form/fields/DisksTableField.tsx index 2d9aa12708..6134b505f3 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 5a6eeaaaf7..9dd6a695f8 100644 --- a/app/forms/disk-attach.tsx +++ b/app/forms/disk-attach.tsx @@ -11,7 +11,7 @@ import { useForm } from 'react-hook-form' import { useApiQuery, type ApiError } from '@oxide/api' import { ComboboxField } from '~/components/form/fields/ComboboxField' -import { SideModalForm } from '~/components/form/SideModalForm' +import { ModalForm } from '~/components/form/ModalForm' import { useProjectSelector } from '~/hooks/use-params' import { toComboboxItems } from '~/ui/lib/Combobox' import { ALL_ISH } from '~/util/consts' @@ -31,7 +31,7 @@ type AttachDiskProps = { * 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 = [], @@ -40,7 +40,7 @@ export function AttachDiskSideModalForm({ }: AttachDiskProps) { const { project } = useProjectSelector() - const { data } = useApiQuery('diskList', { + const { data, isPending } = useApiQuery('diskList', { query: { project, limit: ALL_ISH }, }) const detachedDisks = useMemo( @@ -54,17 +54,19 @@ export function AttachDiskSideModalForm({ ) const form = useForm({ defaultValues }) + const { control } = form return ( - - + ) } diff --git a/app/pages/project/instances/StorageTab.tsx b/app/pages/project/instances/StorageTab.tsx index fb58644b78..42a094dfa2 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,33 @@ export default function StorageTab() { -
- - -
+ +
{otherDisks.length > 0 ? ( @@ -395,13 +386,14 @@ export default function StorageTab() { /> )} {showDiskAttach && ( - setShowDiskAttach(false)} onSubmit={({ name }) => { attachDisk.mutate({ ...instancePathQuery, body: { disk: name } }) }} loading={attachDisk.isPending} submitError={attachDisk.error} + instance={instance} /> )} From 61438c6845f0a34eaaaa015c9c35533a90aafbd0 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Wed, 5 Mar 2025 12:16:42 +0000 Subject: [PATCH 02/16] Experimental stop instance in modal --- app/components/StopInstancePrompt.tsx | 110 ++++++++++++++++++++++++++ app/forms/disk-attach.tsx | 11 ++- app/ui/lib/Button.tsx | 7 +- app/ui/styles/components/button.css | 4 + app/ui/styles/components/spinner.css | 24 ++---- 5 files changed, 133 insertions(+), 23 deletions(-) create mode 100644 app/components/StopInstancePrompt.tsx diff --git a/app/components/StopInstancePrompt.tsx b/app/components/StopInstancePrompt.tsx new file mode 100644 index 0000000000..54d129f44f --- /dev/null +++ b/app/components/StopInstancePrompt.tsx @@ -0,0 +1,110 @@ +/* + * 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 { useEffect, useState, type ReactNode } from 'react' + +import { + instanceTransitioning, + useApiMutation, + useApiQuery, + useApiQueryClient, + 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' + +const POLL_INTERVAL_FAST = 2000 // 2 seconds + +type StopInstancePromptProps = { + instance: Instance + children: ReactNode +} + +export function StopInstancePrompt({ instance, children }: StopInstancePromptProps) { + const queryClient = useApiQueryClient() + const [isStoppingInstance, setIsStoppingInstance] = useState(false) + + const { data } = useApiQuery( + 'instanceView', + { + path: { instance: instance.name }, + query: { project: instance.projectId }, + }, + { + refetchInterval: + isStoppingInstance || instanceTransitioning(instance) ? POLL_INTERVAL_FAST : false, + } + ) + + const { mutateAsync: stopInstanceAsync } = useApiMutation('instanceStop', { + onSuccess: () => { + setIsStoppingInstance(true) + addToast( + <> + Stopping instance {instance.name} + + ) + }, + onError: (error) => { + addToast({ + variant: 'error', + title: `Error stopping instance '${instance.name}'`, + content: error.message, + }) + setIsStoppingInstance(false) + }, + }) + + const handleStopInstance = () => { + stopInstanceAsync({ + path: { instance: instance.name }, + query: { project: instance.projectId }, + }) + } + + const currentInstance = data || instance + + useEffect(() => { + if (!data) { + return + } + if (isStoppingInstance && data.runState === 'stopped') { + queryClient.invalidateQueries('instanceView') + setIsStoppingInstance(false) + } + }, [isStoppingInstance, data, queryClient]) + + if ( + !currentInstance || + (currentInstance.runState !== 'stopping' && currentInstance.runState !== 'running') + ) { + return null + } + + return ( + + {children}{' '} + + + } + /> + ) +} diff --git a/app/forms/disk-attach.tsx b/app/forms/disk-attach.tsx index 9dd6a695f8..2e2007d46c 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 { useApiQuery, type ApiError, type Instance } from '@oxide/api' import { ComboboxField } from '~/components/form/fields/ComboboxField' 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,6 +26,7 @@ type AttachDiskProps = { diskNamesToExclude?: string[] loading?: boolean submitError?: ApiError | null + instance: Instance } /** @@ -37,6 +39,7 @@ export function AttachDiskModalForm({ diskNamesToExclude = [], loading = false, submitError = null, + instance, }: AttachDiskProps) { const { project } = useProjectSelector() @@ -67,7 +70,13 @@ export function AttachDiskModalForm({ title="Attach disk" onSubmit={onSubmit} width="medium" + submitDisabled={ + instance.runState !== 'stopped' ? 'Instance must be stopped' : undefined + } > + + An instance must be stopped to attach a disk. + = { + xs: 'h-6 px-2 text-mono-xs', sm: 'h-8 px-3 text-mono-sm [&>svg]:w-4', // meant for buttons that only contain a single icon icon: 'h-8 w-8 text-mono-sm [&>svg]:w-4', @@ -115,7 +116,7 @@ export const Button = ({ animate={{ opacity: 1, y: '-50%', x: '-50%' }} initial={{ opacity: 0, y: 'calc(-50% - 25px)', x: '-50%' }} transition={{ type: 'spring', duration: 0.3, bounce: 0 }} - className="absolute left-1/2 top-1/2" + className="absolute left-1/2 top-1/2 flex items-center justify-center" > diff --git a/app/ui/styles/components/button.css b/app/ui/styles/components/button.css index 9575fde921..9ad91e8caa 100644 --- a/app/ui/styles/components/button.css +++ b/app/ui/styles/components/button.css @@ -44,6 +44,10 @@ @apply hidden; } +.btn-notice.ox-button:after { + @apply opacity-40; +} + /** * A class to make it very visually obvious that a button style is missing */ diff --git a/app/ui/styles/components/spinner.css b/app/ui/styles/components/spinner.css index 495e45363e..636bc94bdd 100644 --- a/app/ui/styles/components/spinner.css +++ b/app/ui/styles/components/spinner.css @@ -13,6 +13,11 @@ animation: rotate 5s linear infinite; } +.spinner .path, +.spinner .bg { + stroke: currentColor; +} + .spinner.spinner-md { --radius: 8; --circumference: calc(var(--PI) * var(--radius) * 2px); @@ -27,7 +32,6 @@ stroke-dasharray: var(--circumference); transform-origin: center; animation: dash 8s ease-in-out infinite; - stroke: var(--content-accent-tertiary); } @media (prefers-reduced-motion) { @@ -50,24 +54,6 @@ } } -.spinner-ghost .bg, -.spinner-secondary .bg { - stroke: var(--content-default); -} - -.spinner-secondary .path { - stroke: var(--content-secondary); -} - -.spinner-primary .bg { - stroke: var(--content-accent); -} - -.spinner-danger .bg, -.spinner-danger .path { - stroke: var(--content-destructive-tertiary); -} - @keyframes rotate { 100% { transform: rotate(360deg); From 036b2956179a4d44f0daa80ca5d064187afa575c Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Wed, 5 Mar 2025 12:17:03 +0000 Subject: [PATCH 03/16] Tweak disabled text button colour --- app/ui/styles/components/button.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ui/styles/components/button.css b/app/ui/styles/components/button.css index 9575fde921..0ad83873ef 100644 --- a/app/ui/styles/components/button.css +++ b/app/ui/styles/components/button.css @@ -22,7 +22,7 @@ } .btn-secondary { - @apply text-default bg-secondary hover:bg-hover disabled:text-tertiary disabled:bg-secondary; + @apply text-default bg-secondary hover:bg-hover disabled:text-disabled disabled:bg-secondary; } .btn-secondary:disabled > .spinner, .btn-secondary.visually-disabled > .spinner { From 5bdb549fe86e117d4094f7fb9c88ec60b0043150 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Wed, 5 Mar 2025 12:20:18 +0000 Subject: [PATCH 04/16] Type fix --- app/pages/project/instances/StorageTab.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/pages/project/instances/StorageTab.tsx b/app/pages/project/instances/StorageTab.tsx index 42a094dfa2..b02b81f6db 100644 --- a/app/pages/project/instances/StorageTab.tsx +++ b/app/pages/project/instances/StorageTab.tsx @@ -393,7 +393,6 @@ export default function StorageTab() { }} loading={attachDisk.isPending} submitError={attachDisk.error} - instance={instance} /> )} From b1dc6054ddeab0061d40eaf1f350f2230b8fb9f1 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Wed, 5 Mar 2025 12:27:54 +0000 Subject: [PATCH 05/16] Cleanup --- app/forms/disk-attach.tsx | 4 ++-- app/pages/project/instances/StorageTab.tsx | 13 +------------ 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/app/forms/disk-attach.tsx b/app/forms/disk-attach.tsx index 2e2007d46c..a6bafcc4c0 100644 --- a/app/forms/disk-attach.tsx +++ b/app/forms/disk-attach.tsx @@ -8,7 +8,7 @@ import { useMemo } from 'react' import { useForm } from 'react-hook-form' -import { useApiQuery, type ApiError, type Instance } from '@oxide/api' +import { instanceCan, useApiQuery, type ApiError, type Instance } from '@oxide/api' import { ComboboxField } from '~/components/form/fields/ComboboxField' import { ModalForm } from '~/components/form/ModalForm' @@ -71,7 +71,7 @@ export function AttachDiskModalForm({ onSubmit={onSubmit} width="medium" submitDisabled={ - instance.runState !== 'stopped' ? 'Instance must be stopped' : undefined + !instanceCan.attachDisk(instance) ? 'Instance must be stopped' : undefined } > diff --git a/app/pages/project/instances/StorageTab.tsx b/app/pages/project/instances/StorageTab.tsx index 42a094dfa2..a1090c0bea 100644 --- a/app/pages/project/instances/StorageTab.tsx +++ b/app/pages/project/instances/StorageTab.tsx @@ -334,18 +334,7 @@ export default function StorageTab() { -