diff --git a/app/components/ImageDetailSideModal.tsx b/app/components/ImageDetailSideModal.tsx new file mode 100644 index 000000000..0047808bb --- /dev/null +++ b/app/components/ImageDetailSideModal.tsx @@ -0,0 +1,53 @@ +/* + * 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' +import { bytesToGiB } from '~/util/units' + +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} + {bytesToGiB(image.size)} GiB + + {image.blockSize.toLocaleString()} bytes + + + + + + + ) +} diff --git a/app/components/SnapshotDetailSideModal.tsx b/app/components/SnapshotDetailSideModal.tsx new file mode 100644 index 000000000..d0057930c --- /dev/null +++ b/app/components/SnapshotDetailSideModal.tsx @@ -0,0 +1,80 @@ +/* + * 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' +import { bytesToGiB } from '~/util/units' + +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} + + } + > + + + + + + + + {bytesToGiB(snapshot.size)} GiB + + + + + + + + + + ) +} diff --git a/app/forms/image-upload.tsx b/app/forms/image-upload.tsx index 93e92573d..09b64d920 100644 --- a/app/forms/image-upload.tsx +++ b/app/forms/image-upload.tsx @@ -228,7 +228,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 @@ -259,6 +264,7 @@ export default function ImageCreate() { const deleteSnapshotCleanup = useApiMutation(api.snapshotDelete, { onSuccess() { queryClient.invalidateEndpoint('snapshotList') + queryClient.invalidateEndpoint('snapshotView') }, }) diff --git a/app/pages/SiloImagesPage.tsx b/app/pages/SiloImagesPage.tsx index 6a830e377..d274f29cd 100644 --- a/app/pages/SiloImagesPage.tsx +++ b/app/pages/SiloImagesPage.tsx @@ -73,6 +73,8 @@ export default function SiloImagesPage() { // prettier-ignore addToast(<>Image {variables.path.image} deleted) queryClient.invalidateEndpoint('imageList') + // also drops per-id imageView entries seeded for Source-column lookups + queryClient.invalidateEndpoint('imageView') }, }) @@ -152,6 +154,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 +252,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..912c5af61 100644 --- a/app/pages/project/disks/DiskDetailSideModal.tsx +++ b/app/pages/project/disks/DiskDetailSideModal.tsx @@ -15,6 +15,7 @@ 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' @@ -83,8 +84,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..8390722d6 100644 --- a/app/pages/project/images/ImagesPage.tsx +++ b/app/pages/project/images/ImagesPage.tsx @@ -67,6 +67,8 @@ export default function ImagesPage() { // prettier-ignore addToast(<>Image {variables.path.image} deleted) queryClient.invalidateEndpoint('imageList') + // also drops per-id imageView entries seeded for Source-column lookups + queryClient.invalidateEndpoint('imageView') }, }) @@ -175,6 +177,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..693f64b5a 100644 --- a/app/pages/project/snapshots/SnapshotsPage.tsx +++ b/app/pages/project/snapshots/SnapshotsPage.tsx @@ -134,6 +134,8 @@ export default function SnapshotsPage() { const { mutateAsync: deleteSnapshot } = useApiMutation(api.snapshotDelete, { onSuccess() { queryClient.invalidateEndpoint('snapshotList') + // also drops per-id snapshotView entries seeded for Source-column lookups + 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/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