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' },