Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions app/components/ImageDetailSideModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ReadOnlySideModalForm
title="Image details"
onDismiss={onDismiss}
animate
subtitle={
<ResourceLabel>
<Images16Icon /> {image.name}
</ResourceLabel>
}
>
<PropertiesTable>
<PropertiesTable.IdRow id={image.id} />
<PropertiesTable.DescriptionRow description={image.description} sideModal />
<PropertiesTable.Row label="Visibility">{visibility}</PropertiesTable.Row>
<PropertiesTable.Row label="OS">{image.os}</PropertiesTable.Row>
<PropertiesTable.Row label="Version">{image.version}</PropertiesTable.Row>
<PropertiesTable.Row label="Size">{bytesToGiB(image.size)} GiB</PropertiesTable.Row>
<PropertiesTable.Row label="Block size">
{image.blockSize.toLocaleString()} bytes
</PropertiesTable.Row>
<PropertiesTable.DateRow label="Created" date={image.timeCreated} />
<PropertiesTable.DateRow label="Last Modified" date={image.timeModified} />
</PropertiesTable>
<SideModalFormDocs docs={[docLinks.images]} />
</ReadOnlySideModalForm>
)
}
80 changes: 80 additions & 0 deletions app/components/SnapshotDetailSideModal.tsx
Original file line number Diff line number Diff line change
@@ -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 <SkeletonCell />
if (data.type === 'error') return <Badge color="neutral">Deleted</Badge>
return <>{data.data.name}</>
}

type SnapshotDetailSideModalProps = {
snapshot: Snapshot
onDismiss: () => void
}

export function SnapshotDetailSideModal({
snapshot,
onDismiss,
}: SnapshotDetailSideModalProps) {
return (
<ReadOnlySideModalForm
title="Snapshot details"
onDismiss={onDismiss}
animate
subtitle={
<ResourceLabel>
<Snapshots16Icon /> {snapshot.name}
</ResourceLabel>
}
>
<PropertiesTable>
<PropertiesTable.IdRow id={snapshot.id} />
<PropertiesTable.DescriptionRow description={snapshot.description} sideModal />
<PropertiesTable.Row label="State">
<SnapshotStateBadge state={snapshot.state} />
</PropertiesTable.Row>
<PropertiesTable.Row label="Size">
{bytesToGiB(snapshot.size)} GiB
</PropertiesTable.Row>
<PropertiesTable.Row label="Source disk">
<DiskNameFromId diskId={snapshot.diskId} />
</PropertiesTable.Row>
<PropertiesTable.DateRow label="Created" date={snapshot.timeCreated} />
<PropertiesTable.DateRow label="Last Modified" date={snapshot.timeModified} />
</PropertiesTable>
<SideModalFormDocs docs={[docLinks.snapshots]} />
</ReadOnlySideModalForm>
)
}
8 changes: 7 additions & 1 deletion app/forms/image-upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -259,6 +264,7 @@ export default function ImageCreate() {
const deleteSnapshotCleanup = useApiMutation(api.snapshotDelete, {
onSuccess() {
queryClient.invalidateEndpoint('snapshotList')
queryClient.invalidateEndpoint('snapshotView')
},
})

Expand Down
6 changes: 6 additions & 0 deletions app/pages/SiloImagesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ export default function SiloImagesPage() {
// prettier-ignore
addToast(<>Image <HL>{variables.path.image}</HL> deleted</>)
queryClient.invalidateEndpoint('imageList')
// also drops per-id imageView entries seeded for Source-column lookups
queryClient.invalidateEndpoint('imageView')
},
})

Expand Down Expand Up @@ -152,6 +154,8 @@ const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => {
// prettier-ignore
addToast(<>Image <HL>{data.name}</HL> promoted</>)
queryClient.invalidateEndpoint('imageList')
// promotion flips projectId; refetch the per-id view
queryClient.invalidateEndpoint('imageView')
onDismiss()
},
onError: (err) => {
Expand Down Expand Up @@ -248,6 +252,8 @@ const DemoteImageModal = ({
})

queryClient.invalidateEndpoint('imageList')
// demotion flips projectId; refetch the per-id view
queryClient.invalidateEndpoint('imageView')
onDismiss()
},
onError: (err) => {
Expand Down
6 changes: 4 additions & 2 deletions app/pages/project/disks/DiskDetailSideModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -83,8 +84,9 @@ export function DiskDetailSideModal({
<DiskTypeBadge diskType={disk.diskType} />
</PropertiesTable.Row>
{/* TODO: show attached instance by name like the table does? */}
<PropertiesTable.IdRow id={disk.imageId} label="Image ID" />
<PropertiesTable.IdRow id={disk.snapshotId} label="Snapshot ID" />
<PropertiesTable.Row label="Source">
<DiskSourceName imageId={disk.imageId} snapshotId={disk.snapshotId} />
</PropertiesTable.Row>
<PropertiesTable.Row label="Read only">
<Badge color="neutral">{disk.readOnly ? 'True' : 'False'}</Badge>
</PropertiesTable.Row>
Expand Down
9 changes: 9 additions & 0 deletions app/pages/project/disks/DisksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -176,6 +177,14 @@ export default function DisksPage() {
cell: (info) => <DiskTypeBadge diskType={info.getValue()} />,
}),
colHelper.accessor('size', Columns.size),
colHelper.accessor(
(row) => ({ imageId: row.imageId, snapshotId: row.snapshotId }),
{
id: 'source',
header: 'Source',
cell: (info) => <DiskSourceName {...info.getValue()} />,
}
),
colHelper.accessor('state.state', {
header: 'state',
cell: (info) => <DiskStateBadge state={info.getValue()} />,
Expand Down
5 changes: 5 additions & 0 deletions app/pages/project/images/ImagesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export default function ImagesPage() {
// prettier-ignore
addToast(<>Image <HL>{variables.path.image}</HL> deleted</>)
queryClient.invalidateEndpoint('imageList')
// also drops per-id imageView entries seeded for Source-column lookups
queryClient.invalidateEndpoint('imageView')
},
})

Expand Down Expand Up @@ -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) => {
Expand Down
6 changes: 6 additions & 0 deletions app/pages/project/instances/StorageTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -99,6 +100,11 @@ export default function StorageTab() {
cell: (info) => <DiskTypeBadge diskType={info.getValue()} />,
}),
colHelper.accessor('size', Columns.size),
colHelper.accessor((row) => ({ imageId: row.imageId, snapshotId: row.snapshotId }), {
id: 'source',
header: 'Source',
cell: (info) => <DiskSourceName {...info.getValue()} />,
}),
colHelper.accessor((row) => row.state.state, {
header: 'state',
cell: (info) => <DiskStateBadge state={info.getValue()} />,
Expand Down
2 changes: 2 additions & 0 deletions app/pages/project/snapshots/SnapshotsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
},
})

Expand Down
102 changes: 102 additions & 0 deletions app/table/cells/DiskSourceCell.tsx
Original file line number Diff line number Diff line change
@@ -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 <EmptyCell />

// 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 <SkeletonCell />
if (result.type === 'error') return <Badge color="neutral">Deleted</Badge>

const name = result.data.name
if (inSideModal) {
return (
<span className="flex items-center gap-1">
<Badge color="neutral">{imageId ? 'Image' : 'Snapshot'}</Badge>
{name}
</span>
)
}
return (
<>
<ButtonCell onClick={() => setShowDetail(true)}>{name}</ButtonCell>
{showDetail &&
(imageId && image.data?.type === 'success' ? (
<ImageDetailSideModal
image={image.data.data}
onDismiss={() => setShowDetail(false)}
/>
) : snapshotId && snapshot.data?.type === 'success' ? (
<SnapshotDetailSideModal
snapshot={snapshot.data.data}
onDismiss={() => setShowDetail(false)}
/>
) : null)}
</>
)
}
Loading
Loading