diff --git a/app/components/ImageDetailSideModal.tsx b/app/components/ImageDetailSideModal.tsx
new file mode 100644
index 0000000000..0629e28104
--- /dev/null
+++ b/app/components/ImageDetailSideModal.tsx
@@ -0,0 +1,58 @@
+/*
+ * 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
+ /** Pass `true` for state-driven usage (e.g., DiskSourceCell). Omit for route usage. */
+ animate?: boolean
+}
+
+export function ImageDetailSideModal({
+ image,
+ onDismiss,
+ animate,
+}: 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 0000000000..317f8b0050
--- /dev/null
+++ b/app/components/SnapshotDetailSideModal.tsx
@@ -0,0 +1,55 @@
+/*
+ * 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 Snapshot } from '@oxide/api'
+import { Snapshots16Icon } from '@oxide/design-system/icons/react'
+
+import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm'
+import { SnapshotStateBadge } from '~/components/StateBadge'
+import { DiskNameFromId } from '~/table/cells/SourceNameCell'
+import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
+import { PropertiesTable } from '~/ui/lib/PropertiesTable'
+import { ResourceLabel } from '~/ui/lib/SideModal'
+import { docLinks } from '~/util/links'
+
+type SnapshotDetailSideModalProps = {
+ snapshot: Snapshot
+ onDismiss: () => void
+}
+
+export function SnapshotDetailSideModal({
+ snapshot,
+ onDismiss,
+}: SnapshotDetailSideModalProps) {
+ return (
+
+ {snapshot.name}
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/forms/image-edit.tsx b/app/forms/image-edit.tsx
deleted file mode 100644
index 2db4ca3f31..0000000000
--- a/app/forms/image-edit.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- * 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 { useForm } from 'react-hook-form'
-import { useNavigate } from 'react-router'
-
-import { type Image } from '@oxide/api'
-import { Images16Icon } from '@oxide/design-system/icons/react'
-
-import { DescriptionField } from '~/components/form/fields/DescriptionField'
-import { NameField } from '~/components/form/fields/NameField'
-import { TextField } from '~/components/form/fields/TextField'
-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 { capitalize } from '~/util/str'
-import { bytesToGiB } from '~/util/units'
-
-export function EditImageSideModalForm({
- image,
- dismissLink,
- type,
-}: {
- image: Image
- dismissLink: string
- type: 'Project' | 'Silo'
-}) {
- const navigate = useNavigate()
- const form = useForm({ defaultValues: image })
- const resourceName = type === 'Project' ? 'project image' : 'silo image'
- const onDismiss = () => navigate(dismissLink)
-
- return (
-
- {image.name}
-
- }
- >
-
- {type}
-
-
- {bytesToGiB(image.size)}
- GiB
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/app/forms/image-upload.tsx b/app/forms/image-upload.tsx
index d434ae4ccb..f5c3982667 100644
--- a/app/forms/image-upload.tsx
+++ b/app/forms/image-upload.tsx
@@ -246,6 +246,8 @@ export default function ImageCreate() {
const finalizeDisk = useApiMutation(api.diskFinalizeImport)
const createImage = useApiMutation(api.imageCreate)
const deleteDisk = useApiMutation(api.diskDelete)
+ // no invalidation needed: the deleted snapshot is the transient one created
+ // by this flow, so nothing can be displaying it
const deleteSnapshot = useApiMutation(api.snapshotDelete)
// TODO: Distinguish cleanup mutations being called after successful run vs.
diff --git a/app/pages/SiloImageEdit.tsx b/app/pages/SiloImageDetail.tsx
similarity index 72%
rename from app/pages/SiloImageEdit.tsx
rename to app/pages/SiloImageDetail.tsx
index 268ab0feaf..d4f96dd039 100644
--- a/app/pages/SiloImageEdit.tsx
+++ b/app/pages/SiloImageDetail.tsx
@@ -5,11 +5,11 @@
*
* Copyright Oxide Computer Company
*/
-import type { LoaderFunctionArgs } from 'react-router'
+import { useNavigate, type LoaderFunctionArgs } from 'react-router'
import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api'
-import { EditImageSideModalForm } from '~/forms/image-edit'
+import { ImageDetailSideModal } from '~/components/ImageDetailSideModal'
import { titleCrumb } from '~/hooks/use-crumbs'
import { getSiloImageSelector, useSiloImageSelector } from '~/hooks/use-params'
import { pb } from '~/util/path-builder'
@@ -23,11 +23,12 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
return null
}
-export const handle = titleCrumb('Edit Image')
+export const handle = titleCrumb('Image')
-export default function SiloImageEdit() {
+export default function SiloImageDetail() {
const selector = useSiloImageSelector()
+ const navigate = useNavigate()
const { data } = usePrefetchedQuery(imageView(selector))
- return
+ return navigate(pb.siloImages())} />
}
diff --git a/app/pages/SiloImagesPage.tsx b/app/pages/SiloImagesPage.tsx
index d723ac882c..ff522771b6 100644
--- a/app/pages/SiloImagesPage.tsx
+++ b/app/pages/SiloImagesPage.tsx
@@ -58,7 +58,7 @@ export const handle = { crumb: 'Images' }
const colHelper = createColumnHelper()
const staticCols = [
colHelper.accessor('name', {
- cell: makeLinkCell((image) => pb.siloImageEdit({ image })),
+ cell: makeLinkCell((image) => pb.siloImage({ image })),
}),
colHelper.accessor('description', Columns.description),
colHelper.accessor('os', {
@@ -81,6 +81,7 @@ export default function SiloImagesPage() {
// prettier-ignore
addToast(<>Image {variables.path.image} deleted>)
queryClient.invalidateEndpoint('imageList')
+ queryClient.invalidateEndpoint('imageView')
},
})
@@ -116,7 +117,7 @@ export default function SiloImagesPage() {
},
...(allImages?.items || []).map((i) => ({
value: i.name,
- action: pb.siloImageEdit({ image: i.name }),
+ action: pb.siloImage({ image: i.name }),
navGroup: 'Go to silo image',
})),
],
@@ -160,6 +161,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) => {
@@ -256,6 +259,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 990695f987..4441fa10c0 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 469c4c8e95..956c29182e 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'
@@ -90,6 +91,8 @@ export default function DisksPage() {
const { mutateAsync: deleteDisk } = useApiMutation(api.diskDelete, {
onSuccess(_data, variables) {
queryClient.invalidateEndpoint('diskList')
+ // deleted disk may be a snapshot's source, shown in the snapshot detail modal
+ queryClient.invalidateEndpoint('diskView')
// prettier-ignore
addToast(<>Disk {variables.path.disk} deleted>)
},
@@ -176,6 +179,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 6c61da609b..fc36637aac 100644
--- a/app/pages/project/images/ImagesPage.tsx
+++ b/app/pages/project/images/ImagesPage.tsx
@@ -68,6 +68,7 @@ export default function ImagesPage() {
// prettier-ignore
addToast(<>Image {variables.path.image} deleted>)
queryClient.invalidateEndpoint('imageList')
+ queryClient.invalidateEndpoint('imageView')
},
})
@@ -96,7 +97,7 @@ export default function ImagesPage() {
const columns = useMemo(() => {
return [
colHelper.accessor('name', {
- cell: makeLinkCell((image) => pb.projectImageEdit({ project, image })),
+ cell: makeLinkCell((image) => pb.projectImage({ project, image })),
}),
colHelper.accessor('description', Columns.description),
colHelper.accessor('os', {
@@ -131,7 +132,7 @@ export default function ImagesPage() {
},
...(allImages?.items || []).map((i) => ({
value: i.name,
- action: pb.projectImageEdit({ project, image: i.name }),
+ action: pb.projectImage({ project, image: i.name }),
navGroup: 'Go to project image',
})),
],
@@ -183,6 +184,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/images/ProjectImageEdit.tsx b/app/pages/project/images/ProjectImageDetail.tsx
similarity index 74%
rename from app/pages/project/images/ProjectImageEdit.tsx
rename to app/pages/project/images/ProjectImageDetail.tsx
index 2ed549019f..cb32963b59 100644
--- a/app/pages/project/images/ProjectImageEdit.tsx
+++ b/app/pages/project/images/ProjectImageDetail.tsx
@@ -5,11 +5,11 @@
*
* Copyright Oxide Computer Company
*/
-import type { LoaderFunctionArgs } from 'react-router'
+import { useNavigate, type LoaderFunctionArgs } from 'react-router'
import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api'
-import { EditImageSideModalForm } from '~/forms/image-edit'
+import { ImageDetailSideModal } from '~/components/ImageDetailSideModal'
import { titleCrumb } from '~/hooks/use-crumbs'
import { getProjectImageSelector, useProjectImageSelector } from '~/hooks/use-params'
import { pb } from '~/util/path-builder'
@@ -24,12 +24,13 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
return null
}
-export const handle = titleCrumb('Edit Image')
+export const handle = titleCrumb('Image')
-export default function ProjectImageEdit() {
+export default function ProjectImageDetail() {
const selector = useProjectImageSelector()
+ const navigate = useNavigate()
const { data } = usePrefetchedQuery(imageView(selector))
const dismissLink = pb.projectImages({ project: selector.project })
- return
+ return navigate(dismissLink)} />
}
diff --git a/app/pages/project/instances/StorageTab.tsx b/app/pages/project/instances/StorageTab.tsx
index fc795f4180..23922a4db3 100644
--- a/app/pages/project/instances/StorageTab.tsx
+++ b/app/pages/project/instances/StorageTab.tsx
@@ -32,6 +32,7 @@ import { useQuickActions } from '~/hooks/use-quick-actions'
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'
@@ -100,6 +101,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 3e2f29ce46..53177fac46 100644
--- a/app/pages/project/snapshots/SnapshotsPage.tsx
+++ b/app/pages/project/snapshots/SnapshotsPage.tsx
@@ -5,7 +5,6 @@
*
* Copyright Oxide Computer Company
*/
-import { useQuery } from '@tanstack/react-query'
import { createColumnHelper } from '@tanstack/react-table'
import { useCallback, useState } from 'react'
import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router'
@@ -14,14 +13,12 @@ import {
api,
getListQFn,
q,
- qErrorsAllowed,
queryClient,
useApiMutation,
type Disk,
type Snapshot,
} from '@oxide/api'
import { Snapshots16Icon, Snapshots24Icon } from '@oxide/design-system/icons/react'
-import { Badge } from '@oxide/design-system/ui'
import { DocsPopover } from '~/components/DocsPopover'
import { SnapshotStateBadge } from '~/components/StateBadge'
@@ -30,8 +27,7 @@ import { getProjectSelector, useProjectSelector } from '~/hooks/use-params'
import { useQuickActions } from '~/hooks/use-quick-actions'
import { DiskDetailSideModal } from '~/pages/project/disks/DiskDetailSideModal'
import { confirmDelete } from '~/stores/confirm-delete'
-import { SkeletonCell } from '~/table/cells/EmptyCell'
-import { ButtonCell } from '~/table/cells/LinkCell'
+import { DiskNameFromId, sourceDiskQ } from '~/table/cells/SourceNameCell'
import { useColsWithActions, type MenuAction } from '~/table/columns/action-col'
import { Columns } from '~/table/columns/common'
import { useQueryTable } from '~/table/QueryTable'
@@ -42,32 +38,6 @@ import { TableActions } from '~/ui/lib/Table'
import { docLinks } from '~/util/links'
import { pb } from '~/util/path-builder'
-const diskViewErrorsAllowedQ = (disk: string) =>
- qErrorsAllowed(
- api.diskView,
- { path: { disk } },
- {
- errorsExpected: {
- explanation: 'the source disk may have been deleted.',
- statusCode: 404,
- },
- }
- )
-
-const DiskNameFromId = ({
- value,
- onClick,
-}: {
- value: string
- onClick: (disk: Disk) => void
-}) => {
- const { data } = useQuery(diskViewErrorsAllowedQ(value))
-
- if (!data) return
- if (data.type === 'error') return Deleted
- return onClick(data.data)}>{data.data.name}
-}
-
const EmptyState = () => (
}
@@ -98,7 +68,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
.fetchQuery(q(api.diskList, { query: { project, limit: 200 } }))
.then((disks) => {
for (const disk of disks.items) {
- queryClient.setQueryData(diskViewErrorsAllowedQ(disk.id).queryKey, {
+ queryClient.setQueryData(sourceDiskQ(disk.id).queryKey, {
type: 'success',
data: disk,
})
@@ -122,7 +92,7 @@ export default function SnapshotsPage() {
colHelper.accessor('description', Columns.description),
colHelper.accessor('diskId', {
header: 'disk',
- cell: (info) => ,
+ cell: (info) => ,
}),
colHelper.accessor('state', {
cell: (info) => ,
@@ -134,6 +104,7 @@ export default function SnapshotsPage() {
const { mutateAsync: deleteSnapshot } = useApiMutation(api.snapshotDelete, {
onSuccess() {
queryClient.invalidateEndpoint('snapshotList')
+ queryClient.invalidateEndpoint('snapshotView')
},
})
diff --git a/app/routes.tsx b/app/routes.tsx
index 5d07ce94bd..2fdaadc22f 100644
--- a/app/routes.tsx
+++ b/app/routes.tsx
@@ -11,6 +11,7 @@ import {
Navigate,
redirect,
Route,
+ useLocation,
type LoaderFunctionArgs,
} from 'react-router'
@@ -52,6 +53,12 @@ const redirectWithLoader = (to: string) => (mod: RouteModule) => ({
Component: () => ,
})
+/** Redirect a renamed `.../edit` detail route to its parent by dropping the trailing segment. */
+function DropEditRedirect() {
+ const { pathname } = useLocation()
+ return
+}
+
export const routes = createRoutesFromElements(
import('./layouts/RootLayout').then(convert)}
@@ -276,9 +283,11 @@ export const routes = createRoutesFromElements(
lazy={() => import('./pages/SiloImagesPage.tsx').then(convert)}
>
import('./pages/SiloImageEdit.tsx').then(convert)}
+ path=":image"
+ lazy={() => import('./pages/SiloImageDetail.tsx').then(convert)}
/>
+ {/* redirect the old edit URL to the renamed detail route */}
+ } />
import('./forms/image-upload').then(convert)}
/>
import('./pages/project/images/ProjectImageEdit').then(convert)}
+ path="images/:image"
+ lazy={() => import('./pages/project/images/ProjectImageDetail').then(convert)}
/>
+ {/* redirect the old edit URL to the renamed detail route */}
+ } />
{
+ const inSideModal = useIsInSideModal()
+ const [showDetail, setShowDetail] = useState(false)
+ // the `!` is safe because the query only runs when the id is present (enabled)
+ 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
+ // include the source type, which comes from the disk itself, so it survives
+ // deletion of the source resource
+ if (result.type === 'error') {
+ return {imageId ? 'Image' : 'Snapshot'} 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)}
+ animate
+ />
+ ) : snapshotId && snapshot.data?.type === 'success' ? (
+ setShowDetail(false)}
+ />
+ ) : null)}
+ >
+ )
+}
diff --git a/app/table/cells/SourceNameCell.tsx b/app/table/cells/SourceNameCell.tsx
new file mode 100644
index 0000000000..967e2b5c27
--- /dev/null
+++ b/app/table/cells/SourceNameCell.tsx
@@ -0,0 +1,56 @@
+/*
+ * 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 Disk } from '@oxide/api'
+import { Badge } from '@oxide/design-system/ui'
+
+import { SkeletonCell } from './EmptyCell'
+import { ButtonCell } from './LinkCell'
+
+// Views of a resource another resource was created from. Use qErrorsAllowed so
+// deletion of the source is a cacheable result rather than an error that blows
+// up the page; consumers render a "Deleted" badge in that case.
+
+const deletedOk = (resource: string) => ({
+ errorsExpected: {
+ explanation: `the source ${resource} may have been deleted.`,
+ statusCode: 404,
+ },
+})
+
+export const sourceDiskQ = (disk: string) =>
+ qErrorsAllowed(api.diskView, { path: { disk } }, deletedOk('disk'))
+
+export const sourceImageQ = (image: string) =>
+ qErrorsAllowed(api.imageView, { path: { image } }, deletedOk('image'))
+
+export const sourceSnapshotQ = (snapshot: string) =>
+ qErrorsAllowed(api.snapshotView, { path: { snapshot } }, deletedOk('snapshot'))
+
+type DiskNameFromIdProps = {
+ diskId: string
+ /** When present, the name is a button. Otherwise it's plain text. */
+ onClick?: (disk: Disk) => void
+}
+
+/**
+ * Disk name resolved from ID. Renders a skeleton while loading and a "Deleted"
+ * badge if the disk no longer exists.
+ */
+export const DiskNameFromId = ({ diskId, onClick }: DiskNameFromIdProps) => {
+ const { data } = useQuery(sourceDiskQ(diskId))
+
+ if (!data) return
+ if (data.type === 'error') return Deleted
+
+ const disk = data.data
+ if (!onClick) return <>{disk.name}>
+ return onClick(disk)}>{disk.name}
+}
diff --git a/app/ui/lib/PropertiesTable.tsx b/app/ui/lib/PropertiesTable.tsx
index 6762d26f06..3322900d34 100644
--- a/app/ui/lib/PropertiesTable.tsx
+++ b/app/ui/lib/PropertiesTable.tsx
@@ -10,6 +10,7 @@ import type { ReactNode } from 'react'
import { DescriptionCell } from '~/table/cells/DescriptionCell'
import { EmptyCell } from '~/table/cells/EmptyCell'
+import { sizeCellInner } from '~/table/columns/common'
import { isOneOf } from '~/util/children'
import { invariant } from '~/util/invariant'
@@ -34,6 +35,7 @@ export function PropertiesTable({
PropertiesTable.IdRow,
PropertiesTable.DescriptionRow,
PropertiesTable.DateRow,
+ PropertiesTable.SizeRow,
PropertiesTable.CopyableRow,
]),
'PropertiesTable only accepts specific Row components as children'
@@ -102,6 +104,14 @@ PropertiesTable.DateRow = ({
)
+PropertiesTable.SizeRow = ({
+ bytes,
+ label = 'Size',
+}: {
+ bytes: number
+ label?: string
+}) => {sizeCellInner(bytes)}
+
PropertiesTable.CopyableRow = ({ label, text }: { label: string; text: string }) => (
{text}
diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap
index 0ac260b92d..300fee5831 100644
--- a/app/util/__snapshots__/path-builder.spec.ts.snap
+++ b/app/util/__snapshots__/path-builder.spec.ts.snap
@@ -513,7 +513,7 @@ exports[`breadcrumbs 2`] = `
"path": "/projects",
},
],
- "projectImageEdit (/projects/p/images/im/edit)": [
+ "projectImage (/projects/p/images/im)": [
{
"label": "Projects",
"path": "/projects",
@@ -665,7 +665,7 @@ exports[`breadcrumbs 2`] = `
"path": "/system/silos/s/idps",
},
],
- "siloImageEdit (/images/im/edit)": [
+ "siloImage (/images/im)": [
{
"label": "Images",
"path": "/images",
diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts
index ebd7d3e3d0..9fc90181e0 100644
--- a/app/util/path-builder.spec.ts
+++ b/app/util/path-builder.spec.ts
@@ -80,7 +80,7 @@ test('path builder', () => {
"project": "/projects/p/instances",
"projectAccess": "/projects/p/access",
"projectEdit": "/projects/p/edit",
- "projectImageEdit": "/projects/p/images/im/edit",
+ "projectImage": "/projects/p/images/im",
"projectImages": "/projects/p/images",
"projectImagesNew": "/projects/p/images-new",
"projects": "/projects",
@@ -92,7 +92,7 @@ test('path builder', () => {
"siloFleetRoles": "/system/silos/s/fleet-roles",
"siloIdps": "/system/silos/s/idps",
"siloIdpsNew": "/system/silos/s/idps-new",
- "siloImageEdit": "/images/im/edit",
+ "siloImage": "/images/im",
"siloImages": "/images",
"siloIpPools": "/system/silos/s/ip-pools",
"siloQuotas": "/system/silos/s/quotas",
diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts
index 468a4e7141..e09ad45aa7 100644
--- a/app/util/path-builder.ts
+++ b/app/util/path-builder.ts
@@ -29,8 +29,7 @@ export const pb = {
projectAccess: (params: PP.Project) => `${projectBase(params)}/access`,
projectImages: (params: PP.Project) => `${projectBase(params)}/images`,
projectImagesNew: (params: PP.Project) => `${projectBase(params)}/images-new`,
- projectImageEdit: (params: PP.Image) =>
- `${pb.projectImages(params)}/${params.image}/edit`,
+ projectImage: (params: PP.Image) => `${pb.projectImages(params)}/${params.image}`,
instances: (params: PP.Project) => `${projectBase(params)}/instances`,
instancesNew: (params: PP.Project) => `${projectBase(params)}/instances-new`,
@@ -113,7 +112,7 @@ export const pb = {
siloUtilization: () => '/utilization',
siloAccess: () => '/access',
siloImages: () => '/images',
- siloImageEdit: (params: PP.SiloImage) => `${pb.siloImages()}/${params.image}/edit`,
+ siloImage: (params: PP.SiloImage) => `${pb.siloImages()}/${params.image}`,
fleetAccess: () => '/system/access',
systemUtilization: () => '/system/utilization',
diff --git a/mock-api/disk.ts b/mock-api/disk.ts
index 76f3cb3dbe..d1bb983203 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 9e8b0b9d43..2ad7c48c86 100644
--- a/test/e2e/disks.e2e.ts
+++ b/test/e2e/disks.e2e.ts
@@ -27,6 +27,47 @@ test('Disk detail side modal', async ({ page }) => {
await expect(modal.getByText('2 GiB')).toBeVisible()
await expect(modal.getByText('2,048 bytes')).toBeVisible() // block size
await expect(propertiesTableValue(modal, 'Read only')).toHaveText('False')
+
+ // the ID is truncated for display, but the full ID is in the aria-label,
+ // next to a copy button
+ const idCell = propertiesTableValue(modal, 'ID')
+ await expect(idCell.getByLabel('7f2309a5-13e3-47e0-8a4c-2a3b3bc992fd')).toBeVisible()
+ await expect(idCell.getByRole('button', { name: 'Click to copy' })).toBeVisible()
+})
+
+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 }) => {
@@ -60,13 +101,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 "Image deleted"
+ await expectRowVisible(table, { name: 'disk-9', Source: 'Image deleted' })
await clickRowAction(page, 'disk-1 db1', 'Snapshot')
await expectToast(page, 'Creating snapshot of disk disk-1')
@@ -252,11 +299,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 +319,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 278f3a8316..a8f269c861 100644
--- a/test/e2e/images.e2e.ts
+++ b/test/e2e/images.e2e.ts
@@ -37,6 +37,32 @@ test('shows OS and Version columns', async ({ page }) => {
})
})
+test('image detail modal opens at the image URL, not /edit', async ({ page }) => {
+ // silo image
+ await page.goto('/images')
+ await page.getByRole('link', { name: 'ubuntu-22-04' }).click()
+ await expect(page).toHaveURL('/images/ubuntu-22-04')
+ const siloModal = page.getByRole('dialog', { name: 'Image details' })
+ await expect(siloModal).toBeVisible()
+ await expect(siloModal.getByRole('heading', { name: 'ubuntu-22-04' })).toBeVisible()
+
+ // project image
+ await page.goto('/projects/mock-project/images')
+ await page.getByRole('link', { name: 'image-1' }).click()
+ await expect(page).toHaveURL('/projects/mock-project/images/image-1')
+ await expect(page.getByRole('dialog', { name: 'Image details' })).toBeVisible()
+})
+
+test('old image /edit URL redirects to the detail URL', async ({ page }) => {
+ await page.goto('/images/arch-2022-06-01/edit')
+ await expect(page).toHaveURL('/images/arch-2022-06-01')
+ await expect(page.getByRole('dialog', { name: 'Image details' })).toBeVisible()
+
+ await page.goto('/projects/mock-project/images/image-1/edit')
+ await expect(page).toHaveURL('/projects/mock-project/images/image-1')
+ await expect(page.getByRole('dialog', { name: 'Image details' })).toBeVisible()
+})
+
test('can promote an image from silo', async ({ page }) => {
await page.goto('/images')
await page.click('role=button[name="Promote image"]')
@@ -162,19 +188,32 @@ 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 "Image 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: 'Image deleted',
+ })
})
// this is to some extent a test of our mock server implementation, but I want
diff --git a/test/visual/regression.e2e.ts b/test/visual/regression.e2e.ts
index 6c8008dd61..bd0b5c8e77 100644
--- a/test/visual/regression.e2e.ts
+++ b/test/visual/regression.e2e.ts
@@ -54,9 +54,9 @@ const pages = [
// Silo
{ name: 'projects list', url: '/projects', heading: 'Projects' },
{
- name: 'silo image edit',
- url: '/images/arch-2022-06-01/edit',
- heading: 'Silo image',
+ name: 'silo image detail',
+ url: '/images/arch-2022-06-01',
+ heading: 'Image details',
exact: true,
},
{ name: 'silo access', url: '/access', heading: 'Silo Access' },