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