Skip to content
Closed
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
22 changes: 18 additions & 4 deletions app/components/form/fields/DisksTableField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { useState } from 'react'
import { useController, type Control } from 'react-hook-form'

import type { DiskCreate } from '@oxide/api'
import type { Disk, DiskCreate } from '@oxide/api'
import { Error16Icon } from '@oxide/design-system/icons/react'

import { AttachDiskSideModalForm } from '~/forms/disk-attach'
Expand All @@ -30,10 +30,12 @@ export type DiskTableItem =
*/
export function DisksTableField({
control,
disabled,
isSubmitting,
availableDisks,
}: {
control: Control<InstanceCreateInput>
disabled: boolean
isSubmitting: boolean
availableDisks: Array<Disk>
}) {
const [showDiskCreate, setShowDiskCreate] = useState(false)
const [showDiskAttach, setShowDiskAttach] = useState(false)
Expand All @@ -42,6 +44,16 @@ export function DisksTableField({
field: { value: items, onChange },
} = useController({ control, name: 'disks' })

const attachedDiskNames = items.map((disk) => disk.name)
const availableDiskNames = availableDisks
.filter((disk) => !attachedDiskNames.includes(disk.name))
.map((disk) => disk.name)

const noDisksAvailable = availableDiskNames.length === 0

const disabled = isSubmitting || noDisksAvailable
const disabledReason = noDisksAvailable ? 'No unattached disks are available' : undefined

return (
<>
<div className="max-w-lg">
Expand Down Expand Up @@ -91,14 +103,15 @@ export function DisksTableField({
)}

<div className="space-x-3">
<Button size="sm" onClick={() => setShowDiskCreate(true)} disabled={disabled}>
<Button size="sm" onClick={() => setShowDiskCreate(true)} disabled={isSubmitting}>
Create new disk
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDiskAttach(true)}
disabled={disabled}
disabledReason={disabledReason}
>
Attach existing disk
</Button>
Expand All @@ -116,6 +129,7 @@ export function DisksTableField({
)}
{showDiskAttach && (
<AttachDiskSideModalForm
availableDiskNames={availableDiskNames}
onDismiss={() => setShowDiskAttach(false)}
onSubmit={(values) => {
onChange([...items, { type: 'attach', ...values }])
Expand Down
19 changes: 5 additions & 14 deletions app/forms/disk-attach.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@
*
* Copyright Oxide Computer Company
*/
import { useApiQuery, type ApiError } from '@oxide/api'
import { type ApiError } from '@oxide/api'

import { ListboxField } from '~/components/form/fields/ListboxField'
import { SideModalForm } from '~/components/form/SideModalForm'
import { useForm, useProjectSelector } from '~/hooks'
import { useForm } from '~/hooks'

const defaultValues = { name: '' }

type AttachDiskProps = {
availableDiskNames: string[]
/** If defined, this overrides the usual mutation */
onSubmit: (diskAttach: { name: string }) => void
onDismiss: () => void
Expand All @@ -26,22 +27,12 @@ type AttachDiskProps = {
* the optional `loading` and `submitError`
*/
export function AttachDiskSideModalForm({
availableDiskNames,
onSubmit,
onDismiss,
loading,
submitError = null,
}: AttachDiskProps) {
const projectSelector = useProjectSelector()

// TODO: loading state? because this fires when the modal opens and not when
// they focus the combobox, it will almost always be done by the time they
// click in
// TODO: error handling
const detachedDisks =
useApiQuery('diskList', { query: projectSelector }).data?.items.filter(
(d) => d.state.state === 'detached'
) || []

const form = useForm({ defaultValues })

return (
Expand All @@ -58,7 +49,7 @@ export function AttachDiskSideModalForm({
<ListboxField
label="Disk name"
name="name"
items={detachedDisks.map(({ name }) => ({ value: name, label: name }))}
items={availableDiskNames.map((name) => ({ value: name, label: name }))}
required
control={form.control}
/>
Expand Down
8 changes: 6 additions & 2 deletions app/forms/instance-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ export function CreateInstanceForm() {
() => allDisks.filter(diskCan.attach).map(({ name }) => ({ value: name, label: name })),
[allDisks]
)

const availableDisks = allDisks.filter((d) => d.state.state === 'detached') || []
const { data: sshKeys } = usePrefetchedApiQuery('currentUserSshKeyList', {})
const allKeys = useMemo(() => sshKeys.items.map((key) => key.id), [sshKeys])

Expand Down Expand Up @@ -553,7 +553,11 @@ export function CreateInstanceForm() {
</Tabs.Root>
<FormDivider />
<Form.Heading id="additional-disks">Additional disks</Form.Heading>
<DisksTableField control={control} disabled={isSubmitting} />
<DisksTableField
control={control}
isSubmitting={isSubmitting}
availableDisks={availableDisks}
/>
<FormDivider />
<Form.Heading id="authentication">Authentication</Form.Heading>
<SshKeysField control={control} isSubmitting={isSubmitting} />
Expand Down
30 changes: 23 additions & 7 deletions app/pages/project/instances/instance/tabs/StorageTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ StorageTab.loader = async ({ params }: LoaderFunctionArgs) => {
path: { instance },
query: { project },
}),
apiQueryClient.prefetchQuery('diskList', { query: { project } }),
])
return null
}
Expand Down Expand Up @@ -101,6 +102,16 @@ export function StorageTab() {
})

const { data: instance } = usePrefetchedApiQuery('instanceView', instancePathQuery)
const { data: disks } = usePrefetchedApiQuery('instanceDiskList', {
path: { instance: instanceName },
query: { project, limit: PAGE_SIZE },
})
const attachedDisks = disks.items.map((d) => d.name)
const { data: allDisks } = usePrefetchedApiQuery('diskList', { query: { project } })
const availableDisks =
allDisks.items.filter(
(d) => d.state.state === 'detached' && !attachedDisks.includes(d.name)
) || []

const makeActions = useCallback(
(disk: Disk): MenuAction[] => [
Expand Down Expand Up @@ -166,6 +177,15 @@ export function StorageTab() {

const columns = useColsWithActions(staticCols, makeActions)

const isAttachDisabled = !instanceCan.attachDisk(instance) || availableDisks.length === 0
const attachDisabledReason = !instanceCan.attachDisk(instance) ? (
<>
Instance must be <span className="text-default">stopped</span> to attach a disk
</>
) : (
'No unattached disks are available'
)

return (
<>
<Table emptyState={emptyState} columns={columns} />
Expand All @@ -188,13 +208,8 @@ export function StorageTab() {
variant="secondary"
size="sm"
onClick={() => setShowDiskAttach(true)}
disabledReason={
<>
Instance must be <span className="text-default">stopped</span> to attach a
disk
</>
}
disabled={!instanceCan.attachDisk(instance)}
disabled={isAttachDisabled}
disabledReason={attachDisabledReason}
>
Attach existing disk
</Button>
Expand All @@ -218,6 +233,7 @@ export function StorageTab() {
)}
{showDiskAttach && (
<AttachDiskSideModalForm
availableDiskNames={availableDisks.map((d) => d.name)}
onDismiss={() => setShowDiskAttach(false)}
onSubmit={({ name }) => {
attachDisk.mutate({ ...instancePathQuery, body: { disk: name } })
Expand Down
3 changes: 3 additions & 0 deletions test/e2e/instance-disks.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ test('Attach disk', async ({ page }) => {
await expectVisible(page, ['role=dialog >> text="Disk name is required"'])

await page.click('role=button[name*="Disk name"]')

// disk-1 is already attached, so should not be visible in the list
await expectNotVisible(page, ['role=option[name="disk-1"]'])
await expectVisible(page, ['role=option[name="disk-3"]', 'role=option[name="disk-4"]'])
await page.click('role=option[name="disk-3"]')

Expand Down