diff --git a/app/api/util.ts b/app/api/util.ts index f3091f865..e3380543c 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -26,6 +26,8 @@ import type { VpcFirewallRuleUpdate, } from './__generated__/Api' +export { snakeify } from './__generated__/util' + // API limits encoded in https://github.com/oxidecomputer/omicron/blob/9dd23096de93c7d6d05ea21f6323de4410060652/nexus/src/app/mod.rs#L142 // These are not actually used in app code, just the mock server. In the app we diff --git a/app/components/CopyCode.tsx b/app/components/CopyCode.tsx index 3f0da9348..d2cb9fa47 100644 --- a/app/components/CopyCode.tsx +++ b/app/components/CopyCode.tsx @@ -11,6 +11,7 @@ import { useState, type ReactNode } from 'react' import { Success12Icon } from '@oxide/design-system/icons/react' import { Button } from '~/ui/lib/Button' +import { CopyToClipboard } from '~/ui/lib/CopyToClipboard' import { Modal } from '~/ui/lib/Modal' import { useTimeout } from '~/ui/lib/use-timeout' @@ -114,3 +115,65 @@ export function EquivalentCliCommand({ project, instance }: EquivProps) { ) } + +type CodeBlock = { + label: string + copyAriaLabel: string + /** Plain text that gets copied to the clipboard */ + code: string + /** Optional rendered representation; falls back to `code` */ + rendered?: ReactNode +} + +type CliCommandModalProps = { + isOpen: boolean + onDismiss: () => void + title: string + description?: ReactNode + blocks: [CodeBlock, ...CodeBlock[]] + footer?: ReactNode +} + +/** + * Modal that stacks one or more code blocks, each with its own copy-to-clipboard + * button. + */ +export function CliCommandModal({ + isOpen, + onDismiss, + title, + description, + blocks, + footer, +}: CliCommandModalProps) { + return ( + + + {description && ( + +
{description}
+
+ )} + {blocks.map((block) => ( + +
+
{block.label}
+ +
+
+              {block.rendered ?? block.code}
+            
+
+ ))} +
+ + {footer} + +
+ ) +} diff --git a/app/components/ImageDetailSideModal.tsx b/app/components/ImageDetailSideModal.tsx new file mode 100644 index 000000000..362d84c70 --- /dev/null +++ b/app/components/ImageDetailSideModal.tsx @@ -0,0 +1,52 @@ +/* + * 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 Image } from '@oxide/api' +import { Images16Icon } from '@oxide/design-system/icons/react' + +import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' +import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { ResourceLabel } from '~/ui/lib/SideModal' +import { docLinks } from '~/util/links' + +type ImageDetailSideModalProps = { + image: Image + onDismiss: () => void +} + +export function ImageDetailSideModal({ image, onDismiss }: ImageDetailSideModalProps) { + // projectId is only set on project images; silo images leave it null + const visibility = image.projectId ? 'Project' : 'Silo' + return ( + + {image.name} + + } + > + + + + {visibility} + {image.os} + {image.version} + + + {image.blockSize.toLocaleString()} bytes + + + + + + + ) +} diff --git a/app/components/SnapshotDetailSideModal.tsx b/app/components/SnapshotDetailSideModal.tsx new file mode 100644 index 000000000..a808630d4 --- /dev/null +++ b/app/components/SnapshotDetailSideModal.tsx @@ -0,0 +1,77 @@ +/* + * 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 { useQuery } from '@tanstack/react-query' + +import { api, qErrorsAllowed, type Snapshot } from '@oxide/api' +import { Snapshots16Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' +import { SnapshotStateBadge } from '~/components/StateBadge' +import { SkeletonCell } from '~/table/cells/EmptyCell' +import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { ResourceLabel } from '~/ui/lib/SideModal' +import { docLinks } from '~/util/links' + +const sourceDiskQ = (disk: string) => + qErrorsAllowed( + api.diskView, + { path: { disk } }, + { + errorsExpected: { + explanation: 'the source disk may have been deleted.', + statusCode: 404, + }, + } + ) + +const DiskNameFromId = ({ diskId }: { diskId: string }) => { + const { data } = useQuery(sourceDiskQ(diskId)) + if (!data) return + if (data.type === 'error') return Deleted + return <>{data.data.name} +} + +type SnapshotDetailSideModalProps = { + snapshot: Snapshot + onDismiss: () => void +} + +export function SnapshotDetailSideModal({ + snapshot, + onDismiss, +}: SnapshotDetailSideModalProps) { + return ( + + {snapshot.name} + + } + > + + + + + + + + + + + + + + + + ) +} diff --git a/app/forms/image-upload.tsx b/app/forms/image-upload.tsx index d434ae4cc..a7e407558 100644 --- a/app/forms/image-upload.tsx +++ b/app/forms/image-upload.tsx @@ -246,7 +246,12 @@ export default function ImageCreate() { const finalizeDisk = useApiMutation(api.diskFinalizeImport) const createImage = useApiMutation(api.imageCreate) const deleteDisk = useApiMutation(api.diskDelete) - const deleteSnapshot = useApiMutation(api.snapshotDelete) + const deleteSnapshot = useApiMutation(api.snapshotDelete, { + onSuccess() { + queryClient.invalidateEndpoint('snapshotList') + queryClient.invalidateEndpoint('snapshotView') + }, + }) // TODO: Distinguish cleanup mutations being called after successful run vs. // due to error. In the former case, they have their own steps to highlight as @@ -277,6 +282,7 @@ export default function ImageCreate() { const deleteSnapshotCleanup = useApiMutation(api.snapshotDelete, { onSuccess() { queryClient.invalidateEndpoint('snapshotList') + queryClient.invalidateEndpoint('snapshotView') }, }) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 33fc7ab8d..2654340f4 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -23,6 +23,7 @@ import { poolHasIpVersion, q, queryClient, + snakeify, useApiMutation, usePrefetchedQuery, type ExternalIpCreate, @@ -43,6 +44,7 @@ import { Storage16Icon, } from '@oxide/design-system/icons/react' +import { CliCommandModal } from '~/components/CopyCode' import { DocsPopover } from '~/components/DocsPopover' import { CheckboxField } from '~/components/form/fields/CheckboxField' import { ComboboxField } from '~/components/form/fields/ComboboxField' @@ -70,6 +72,7 @@ import { Button } from '~/ui/lib/Button' import { toComboboxItems } from '~/ui/lib/Combobox' import { FormDivider } from '~/ui/lib/Divider' import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { InlineCode } from '~/ui/lib/InlineCode' import { Listbox } from '~/ui/lib/Listbox' import { Message } from '~/ui/lib/Message' import { MiniTable } from '~/ui/lib/MiniTable' @@ -121,6 +124,62 @@ const getBootDiskAttachment = ( } } +const buildInstanceCreateBody = ( + values: InstanceCreateInput, + images: Array, + userData: string | undefined +): InstanceCreate => { + // we should never have a presetId that's not in the list + const preset = PRESETS.find((option) => option.id === values.presetId)! + const { memory, ncpus } = + values.presetId === 'custom' + ? { memory: values.memory, ncpus: values.ncpus } + : { memory: preset.memory, ncpus: preset.ncpus } + + const externalIps: ExternalIpCreate[] = [] + if (values.ephemeralIpv4) { + externalIps.push({ + type: 'ephemeral', + poolSelector: { type: 'explicit', pool: values.ephemeralIpv4Pool }, + }) + } + if (values.ephemeralIpv6) { + externalIps.push({ + type: 'ephemeral', + poolSelector: { type: 'explicit', pool: values.ephemeralIpv6Pool }, + }) + } + for (const floatingIp of values.floatingIps) { + externalIps.push({ type: 'floating', floatingIp }) + } + + return { + name: values.name, + hostname: values.name, + description: values.description, + memory: memory * GiB, + ncpus, + disks: values.otherDisks.map( + (d): InstanceDiskAttachment => + d.action === 'attach' + ? { type: 'attach', name: d.name } + : { + type: 'create', + name: d.name, + description: d.description, + size: d.size, + diskBackend: d.diskBackend, + } + ), + bootDisk: getBootDiskAttachment(values, images), + externalIps, + start: values.start, + networkInterfaces: values.networkInterfaces, + sshPublicKeys: values.sshPublicKeys, + userData, + } +} + type BootDiskSourceType = 'siloImage' | 'projectImage' | 'disk' export type InstanceCreateInput = Assign< @@ -524,6 +583,32 @@ export default function CreateInstanceForm() { const bootDiskName = useWatch({ control, name: 'bootDiskName' }) + const [cliModal, setCliModal] = useState<{ + open: boolean + jsonBody: string + command: string + }>({ open: false, jsonBody: '', command: '' }) + + const openCliModal = async () => { + // surface validation errors inline before opening the preview, so the + // generated JSON reflects a valid configuration + if (!(await form.trigger())) return + const values = form.getValues() + // userData is a File; the CLI consumes the raw base64. Show a placeholder + // string instead of synchronously reading the file. + const userDataPlaceholder = values.userData + ? '' + : undefined + const body = buildInstanceCreateBody(values, allImages, userDataPlaceholder) + const jsonBody = JSON.stringify(snakeify(body), null, 2) + const command = [ + 'oxide instance create', + `--project ${project}`, + '--json-body instance.json', + ].join(' \\\n ') + setCliModal({ open: true, jsonBody, command }) + } + return ( <> @@ -541,63 +626,12 @@ export default function CreateInstanceForm() { form={form} onSubmit={async (values) => { setIsSubmitting(true) - // we should never have a presetId that's not in the list - const preset = PRESETS.find((option) => option.id === values.presetId)! - const instance = - values.presetId === 'custom' - ? { memory: values.memory, ncpus: values.ncpus } - : { memory: preset.memory, ncpus: preset.ncpus } - - const bootDisk = getBootDiskAttachment(values, allImages) - - const externalIps: ExternalIpCreate[] = [] - if (values.ephemeralIpv4) { - externalIps.push({ - type: 'ephemeral', - poolSelector: { type: 'explicit', pool: values.ephemeralIpv4Pool }, - }) - } - if (values.ephemeralIpv6) { - externalIps.push({ - type: 'ephemeral', - poolSelector: { type: 'explicit', pool: values.ephemeralIpv6Pool }, - }) - } - for (const floatingIp of values.floatingIps) { - externalIps.push({ type: 'floating', floatingIp }) - } - const userData = values.userData ? await readBlobAsBase64(values.userData) : undefined - await createInstance.mutateAsync({ query: { project }, - body: { - name: values.name, - hostname: values.name, - description: values.description, - memory: instance.memory * GiB, - ncpus: instance.ncpus, - disks: values.otherDisks.map( - (d): InstanceDiskAttachment => - d.action === 'attach' - ? { type: 'attach', name: d.name } - : { - type: 'create', - name: d.name, - description: d.description, - size: d.size, - diskBackend: d.diskBackend, - } - ), - bootDisk, - externalIps, - start: values.start, - networkInterfaces: values.networkInterfaces, - sshPublicKeys: values.sshPublicKeys, - userData, - }, + body: buildInstanceCreateBody(values, allImages, userData), }) }} loading={createInstance.isPending} @@ -850,8 +884,46 @@ export default function CreateInstanceForm() { Create instance navigate(pb.instances({ project }))} /> + + setCliModal((s) => ({ ...s, open: false }))} + title="CLI command" + description={ + <> + Save the JSON below as instance.json, then run the + command in the same directory to create this instance from the CLI. + + } + blocks={[ + { + label: 'instance.json', + copyAriaLabel: 'Copy instance JSON', + code: cliModal.jsonBody, + }, + { + label: 'command', + copyAriaLabel: 'Copy CLI command', + code: cliModal.command, + rendered: ( + <> + $ + {cliModal.command} + + ), + }, + ]} + /> ) } diff --git a/app/pages/SiloImagesPage.tsx b/app/pages/SiloImagesPage.tsx index 6a830e377..8d9757c44 100644 --- a/app/pages/SiloImagesPage.tsx +++ b/app/pages/SiloImagesPage.tsx @@ -73,6 +73,7 @@ export default function SiloImagesPage() { // prettier-ignore addToast(<>Image {variables.path.image} deleted) queryClient.invalidateEndpoint('imageList') + queryClient.invalidateEndpoint('imageView') }, }) @@ -152,6 +153,8 @@ const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => { // prettier-ignore addToast(<>Image {data.name} promoted) queryClient.invalidateEndpoint('imageList') + // promotion flips projectId; refetch the per-id view + queryClient.invalidateEndpoint('imageView') onDismiss() }, onError: (err) => { @@ -248,6 +251,8 @@ const DemoteImageModal = ({ }) queryClient.invalidateEndpoint('imageList') + // demotion flips projectId; refetch the per-id view + queryClient.invalidateEndpoint('imageView') onDismiss() }, onError: (err) => { diff --git a/app/pages/project/disks/DiskDetailSideModal.tsx b/app/pages/project/disks/DiskDetailSideModal.tsx index 990695f98..4441fa10c 100644 --- a/app/pages/project/disks/DiskDetailSideModal.tsx +++ b/app/pages/project/disks/DiskDetailSideModal.tsx @@ -15,13 +15,13 @@ import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' import { DiskStateBadge, DiskTypeBadge } from '~/components/StateBadge' import { titleCrumb } from '~/hooks/use-crumbs' import { getDiskSelector, useDiskSelector } from '~/hooks/use-params' +import { DiskSourceName } from '~/table/cells/DiskSourceCell' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { ResourceLabel } from '~/ui/lib/SideModal' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' -import { bytesToGiB } from '~/util/units' const diskView = ({ disk, project }: PP.Disk) => q(api.diskView, { path: { disk }, query: { project } }) @@ -75,7 +75,7 @@ export function DiskDetailSideModal({ - {bytesToGiB(disk.size)} GiB + @@ -83,8 +83,9 @@ export function DiskDetailSideModal({ {/* TODO: show attached instance by name like the table does? */} - - + + + {disk.readOnly ? 'True' : 'False'} diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index 469c4c8e9..21cf28c66 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -30,6 +30,7 @@ import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' +import { DiskSourceName } from '~/table/cells/DiskSourceCell' import { InstanceLink } from '~/table/cells/InstanceLinkCell' import { LinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' @@ -176,6 +177,14 @@ export default function DisksPage() { cell: (info) => , }), colHelper.accessor('size', Columns.size), + colHelper.accessor( + (row) => ({ imageId: row.imageId, snapshotId: row.snapshotId }), + { + id: 'source', + header: 'Source', + cell: (info) => , + } + ), colHelper.accessor('state.state', { header: 'state', cell: (info) => , diff --git a/app/pages/project/images/ImagesPage.tsx b/app/pages/project/images/ImagesPage.tsx index f3e788007..6260511d6 100644 --- a/app/pages/project/images/ImagesPage.tsx +++ b/app/pages/project/images/ImagesPage.tsx @@ -67,6 +67,7 @@ export default function ImagesPage() { // prettier-ignore addToast(<>Image {variables.path.image} deleted) queryClient.invalidateEndpoint('imageList') + queryClient.invalidateEndpoint('imageView') }, }) @@ -175,6 +176,9 @@ const PromoteImageModal = ({ onDismiss, imageName }: PromoteModalProps) => { }, }) queryClient.invalidateEndpoint('imageList') + // promotion flips projectId; refetch the per-id view so cached entries + // reflect the new visibility + queryClient.invalidateEndpoint('imageView') onDismiss() }, onError: (err) => { diff --git a/app/pages/project/instances/StorageTab.tsx b/app/pages/project/instances/StorageTab.tsx index 86bb123e2..ab42021b3 100644 --- a/app/pages/project/instances/StorageTab.tsx +++ b/app/pages/project/instances/StorageTab.tsx @@ -31,6 +31,7 @@ import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' import { DiskDetailSideModal } from '~/pages/project/disks/DiskDetailSideModal' import { confirmAction } from '~/stores/confirm-action' import { addToast } from '~/stores/toast' +import { DiskSourceName } from '~/table/cells/DiskSourceCell' import { ButtonCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' @@ -99,6 +100,11 @@ export default function StorageTab() { cell: (info) => , }), colHelper.accessor('size', Columns.size), + colHelper.accessor((row) => ({ imageId: row.imageId, snapshotId: row.snapshotId }), { + id: 'source', + header: 'Source', + cell: (info) => , + }), colHelper.accessor((row) => row.state.state, { header: 'state', cell: (info) => , diff --git a/app/pages/project/snapshots/SnapshotsPage.tsx b/app/pages/project/snapshots/SnapshotsPage.tsx index 3e2f29ce4..47bda59cb 100644 --- a/app/pages/project/snapshots/SnapshotsPage.tsx +++ b/app/pages/project/snapshots/SnapshotsPage.tsx @@ -134,6 +134,7 @@ export default function SnapshotsPage() { const { mutateAsync: deleteSnapshot } = useApiMutation(api.snapshotDelete, { onSuccess() { queryClient.invalidateEndpoint('snapshotList') + queryClient.invalidateEndpoint('snapshotView') }, }) diff --git a/app/table/cells/DiskSourceCell.tsx b/app/table/cells/DiskSourceCell.tsx new file mode 100644 index 000000000..433ed6910 --- /dev/null +++ b/app/table/cells/DiskSourceCell.tsx @@ -0,0 +1,102 @@ +/* + * 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 { useQuery } from '@tanstack/react-query' +import { useState } from 'react' + +import { api, qErrorsAllowed } from '@oxide/api' +import { Badge } from '@oxide/design-system/ui' + +import { ImageDetailSideModal } from '~/components/ImageDetailSideModal' +import { SnapshotDetailSideModal } from '~/components/SnapshotDetailSideModal' +import { useIsInSideModal } from '~/ui/lib/modal-context' + +import { EmptyCell, SkeletonCell } from './EmptyCell' +import { ButtonCell } from './LinkCell' + +// Use qErrorsAllowed so deletion of the source resource is a cacheable result +// rather than an error that blows up the page. Tables and the disk detail +// modal both render a "Deleted" badge in that case. + +const sourceImageQ = (image: string) => + qErrorsAllowed( + api.imageView, + { path: { image } }, + { + errorsExpected: { + explanation: 'the source image may have been deleted.', + statusCode: 404, + }, + } + ) + +const sourceSnapshotQ = (snapshot: string) => + qErrorsAllowed( + api.snapshotView, + { path: { snapshot } }, + { + errorsExpected: { + explanation: 'the source snapshot may have been deleted.', + statusCode: 404, + }, + } + ) + +type Props = { + imageId?: string | null + snapshotId?: string | null +} + +/** + * Renders the source resource's name. In a table cell the name is a + * `ButtonCell` that opens a detail side modal; inside a side modal it falls + * back to plain text to avoid stacking modals. Falls back to a skeleton while + * loading and a "Deleted" badge when the source no longer exists. + */ +export const DiskSourceName = ({ imageId, snapshotId }: Props) => { + const inSideModal = useIsInSideModal() + const [showDetail, setShowDetail] = useState(false) + const image = useQuery({ ...sourceImageQ(imageId!), enabled: !!imageId }) + const snapshot = useQuery({ ...sourceSnapshotQ(snapshotId!), enabled: !!snapshotId }) + + if (!imageId && !snapshotId) return + + // Nexus populates exactly one of imageId/snapshotId per disk, so a disk won't have both, + // though the Disk type in the API just lists both as optional + // https://github.com/oxidecomputer/omicron/blob/254a0c5/nexus/db-model/src/disk_type_crucible.rs#L49-L78 + const result = imageId ? image.data : snapshot.data + if (!result) return + if (result.type === 'error') return Deleted + + const name = result.data.name + if (inSideModal) { + return ( + + {imageId ? 'Image' : 'Snapshot'} + {name} + + ) + } + return ( + <> + setShowDetail(true)}>{name} + {showDetail && + (imageId && image.data?.type === 'success' ? ( + setShowDetail(false)} + /> + ) : snapshotId && snapshot.data?.type === 'success' ? ( + setShowDetail(false)} + /> + ) : null)} + + ) +} diff --git a/app/ui/lib/PropertiesTable.tsx b/app/ui/lib/PropertiesTable.tsx index 8315bff80..2bfb2c7be 100644 --- a/app/ui/lib/PropertiesTable.tsx +++ b/app/ui/lib/PropertiesTable.tsx @@ -12,6 +12,7 @@ import { DescriptionCell } from '~/table/cells/DescriptionCell' import { EmptyCell } from '~/table/cells/EmptyCell' import { isOneOf } from '~/util/children' import { invariant } from '~/util/invariant' +import { formatBytes } from '~/util/units' import { DateTime } from './DateTime' import { Truncate } from './Truncate' @@ -33,6 +34,7 @@ export function PropertiesTable({ PropertiesTable.IdRow, PropertiesTable.DescriptionRow, PropertiesTable.DateRow, + PropertiesTable.SizeRow, ]), 'PropertiesTable only accepts specific Row components as children' ) @@ -99,3 +101,22 @@ PropertiesTable.DateRow = ({ ) + +PropertiesTable.SizeRow = ({ + bytes, + label = 'Size', +}: { + bytes: number + label?: string +}) => { + const size = formatBytes(bytes) + // wrap in a span so flex treats value+unit as one item; otherwise the browser + // collapses the trailing space at the flex-item boundary, rendering "1GiB" + return ( + + + {size.value} {size.unit} + + + ) +} diff --git a/mock-api/disk.ts b/mock-api/disk.ts index e496474b4..2dffdae01 100644 --- a/mock-api/disk.ts +++ b/mock-api/disk.ts @@ -81,6 +81,8 @@ export const disk2: Json = { block_size: 2048, disk_type: 'distributed', read_only: false, + // ubuntu-22-04 silo image (see ./image.ts) — exercises Source column + image_id: 'ae46ddf5-a8d5-40fa-bcda-fcac606e3f9b', } export const stoppedBootDisk: Json = { @@ -132,6 +134,8 @@ export const disks: Json[] = [ block_size: 2048, disk_type: 'distributed', read_only: false, + // snapshot-1 (see ./snapshot.ts) — exercises Source column + snapshot_id: 'ab805e59-b6b8-4c73-8081-6a224b6b0698', }, { id: '5695b16d-e1d6-44b0-a75c-7b4299831540', @@ -217,6 +221,9 @@ export const disks: Json[] = [ block_size: 2048, disk_type: 'distributed', read_only: false, + // intentionally references an image that doesn't exist so the Source + // column renders the "Deleted" badge for missing source resources + image_id: '2a5412c2-d109-45d9-8cc2-e0868cced259', }, { id: 'a028160f-603c-4562-bb71-d2d76f1ac2a8', @@ -273,6 +280,8 @@ export const disks: Json[] = [ block_size: 4096, disk_type: 'distributed', read_only: true, + // snapshot-2 (see ./snapshot.ts) + snapshot_id: '9a29813d-e94b-4c6a-82a0-672af3f78a6f', }, // put a ton of disks in project 2 so we can use it to test comboboxes ...Array.from({ length: 1010 }).map((_, i) => { diff --git a/test/e2e/disks.e2e.ts b/test/e2e/disks.e2e.ts index 9e8b0b9d4..8305ff919 100644 --- a/test/e2e/disks.e2e.ts +++ b/test/e2e/disks.e2e.ts @@ -29,6 +29,41 @@ test('Disk detail side modal', async ({ page }) => { await expect(propertiesTableValue(modal, 'Read only')).toHaveText('False') }) +test('Source links open detail side modals from disk list', async ({ page }) => { + await page.goto('/projects/mock-project/disks') + + const table = page.getByRole('table') + + // Snapshot source: clicking snapshot-1 opens the snapshot side modal + const disk3 = table.getByRole('row', { name: /disk-3/ }) + await disk3.getByRole('button', { name: 'snapshot-1' }).click() + const snapshotModal = page.getByRole('dialog', { name: 'Snapshot details' }) + await expect(snapshotModal).toBeVisible() + await expect(propertiesTableValue(snapshotModal, 'Source disk')).toHaveText('disk-1') + await snapshotModal.getByRole('button', { name: 'Close' }).first().click() + await expect(snapshotModal).toBeHidden() + + // Image source: clicking ubuntu-22-04 opens the image side modal as silo image + const disk2 = table.getByRole('row', { name: /disk-2/ }) + await disk2.getByRole('button', { name: 'ubuntu-22-04' }).click() + const imageModal = page.getByRole('dialog', { name: 'Image details' }) + await expect(imageModal).toBeVisible() + await expect(propertiesTableValue(imageModal, 'Visibility')).toHaveText('Silo') + await expect(propertiesTableValue(imageModal, 'OS')).toHaveText('ubuntu') +}) + +test('Source name in disk side modal is plain text, not a link', async ({ page }) => { + await page.goto('/projects/mock-project/disks') + + // Open disk-3, which has a snapshot source. Inside the side modal the source + // name should not be a clickable button (no nested modal stacking). + await page.getByRole('link', { name: 'disk-3', exact: true }).click() + const modal = page.getByRole('dialog', { name: 'Disk details' }) + await expect(modal).toBeVisible() + await expect(propertiesTableValue(modal, 'Source')).toHaveText('Snapshotsnapshot-1') + await expect(modal.getByRole('button', { name: 'snapshot-1' })).toBeHidden() +}) + test('Read-only disk shows badge in table and detail', async ({ page }) => { await page.goto('/projects/mock-project/disks') @@ -60,13 +95,19 @@ test('List disks and snapshot', async ({ page }) => { name: 'disk-1', size: '2 GiB', state: 'attached', + Source: '—', }) await expectRowVisible(table, { Instance: '—', name: 'disk-3', size: '6 GiB', state: 'detached', + Source: 'snapshot-1', }) + // disk-2 is sourced from the ubuntu-22-04 silo image + await expectRowVisible(table, { name: 'disk-2', Source: 'ubuntu-22-04' }) + // disk-9 references an image that does not exist, so we render "Deleted" + await expectRowVisible(table, { name: 'disk-9', Source: 'Deleted' }) await clickRowAction(page, 'disk-1 db1', 'Snapshot') await expectToast(page, 'Creating snapshot of disk disk-1') @@ -252,11 +293,10 @@ test('Create disk from snapshot with read-only', async ({ page }) => { const row = page.getByRole('row', { name: /a-new-disk/ }) await expect(row.getByText('Read only', { exact: true })).toBeVisible() - // Verify snapshot ID in detail modal (now truncated) + // Verify the resolved source name appears in the detail modal await page.getByRole('link', { name: 'a-new-disk' }).click() const modal = page.getByRole('dialog', { name: 'Disk details' }) - // The ID is truncated to 32 chars, but full ID is in aria-label - await expect(modal.getByLabel('e6c58826-62fb-4205-820e-620407cd04e7')).toBeVisible() + await expect(propertiesTableValue(modal, 'Source')).toHaveText('Snapshotdelete-500') }) test('Create disk from image with read-only', async ({ page }) => { @@ -273,9 +313,8 @@ test('Create disk from image with read-only', async ({ page }) => { const row = page.getByRole('row', { name: /a-new-disk/ }) await expect(row.getByText('Read only', { exact: true })).toBeVisible() - // Verify image ID in detail modal (now truncated) + // Verify the resolved source name appears in the detail modal await page.getByRole('link', { name: 'a-new-disk' }).click() const modal = page.getByRole('dialog', { name: 'Disk details' }) - // The ID is truncated to 32 chars, but full ID is in aria-label - await expect(modal.getByLabel('4700ecf1-8f48-4ecf-b78e-816ddb76aaca')).toBeVisible() + await expect(propertiesTableValue(modal, 'Source')).toHaveText('Imageimage-3') }) diff --git a/test/e2e/images.e2e.ts b/test/e2e/images.e2e.ts index 1c3ef736c..98fd15e93 100644 --- a/test/e2e/images.e2e.ts +++ b/test/e2e/images.e2e.ts @@ -12,6 +12,7 @@ import { clipboardText, expect, expectNotVisible, + expectRowVisible, expectToast, expectVisible, getPageAsUser, @@ -143,19 +144,29 @@ test('can delete an image from a project', async ({ page }) => { test('can delete an image from a silo', async ({ page }) => { await page.goto('/images') - const cell = page.getByRole('cell', { name: 'ubuntu-20-04' }) + // ubuntu-22-04 is the silo image referenced by mock-project/disks/disk-2, so + // we use it here to also verify the disk's Source cell flips to "Deleted" + // after the source image is removed. + const cell = page.getByRole('cell', { name: 'ubuntu-22-04' }) await expect(cell).toBeVisible() - await clickRowAction(page, 'ubuntu-20-04', 'Delete') + await clickRowAction(page, 'ubuntu-22-04', 'Delete') const spinner = page.getByRole('dialog').getByLabel('Spinner') await expect(spinner).toBeHidden() await page.getByRole('button', { name: 'Confirm' }).click() await expect(spinner).toBeVisible() // Check deletion was successful - await expectToast(page, 'Image ubuntu-20-04 deleted') + await expectToast(page, 'Image ubuntu-22-04 deleted') await expect(cell).toBeHidden() await expect(spinner).toBeHidden() + + // Navigate client-side (preserves MSW db) to disk-2's row and verify the + // Source column now shows "Deleted" instead of the image name. + await page.getByRole('link', { name: 'Projects', exact: true }).click() + await page.getByRole('table').getByRole('link', { name: 'mock-project' }).click() + await page.getByRole('link', { name: 'Disks' }).click() + await expectRowVisible(page.getByRole('table'), { name: 'disk-2', Source: 'Deleted' }) }) // this is to some extent a test of our mock server implementation, but I want