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
34 changes: 34 additions & 0 deletions app/components/form/fields/BundleCommentField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* 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 { Control } from 'react-hook-form'

import { MAX_BUNDLE_COMMENT_BYTES, utf8ByteLength } from '@oxide/api'

import { TextField } from './TextField'

/** Support bundle comment textarea, shared by the create and edit forms */
export function BundleCommentField({
control,
}: {
control: Control<{ userComment: string }>
}) {
return (
<TextField
as="textarea"
name="userComment"
label="Comment"
rows={4}
control={control}
validate={(value) =>
utf8ByteLength(value) > MAX_BUNDLE_COMMENT_BYTES
? `Comment cannot exceed ${MAX_BUNDLE_COMMENT_BYTES} bytes`
: true
}
/>
)
}
24 changes: 3 additions & 21 deletions app/forms/support-bundle-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,9 @@
import { useForm } from 'react-hook-form'
import { useNavigate } from 'react-router'

import {
api,
MAX_BUNDLE_COMMENT_BYTES,
queryClient,
useApiMutation,
utf8ByteLength,
} from '@oxide/api'
import { api, queryClient, useApiMutation } from '@oxide/api'

import { TextField } from '~/components/form/fields/TextField'
import { BundleCommentField } from '~/components/form/fields/BundleCommentField'
import { SideModalForm } from '~/components/form/SideModalForm'
import { titleCrumb } from '~/hooks/use-crumbs'
import { addToast } from '~/stores/toast'
Expand Down Expand Up @@ -58,19 +52,7 @@ export default function CreateSupportBundleSideModalForm() {
variant="info"
content="Bundle collection runs in the background and can take several minutes. The bundle can be downloaded once collection is complete."
/>
<TextField
as="textarea"
name="userComment"
label="Comment"
description="Note about why this bundle is being collected"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps unnecessary?

rows={4}
control={form.control}
validate={(value) =>
utf8ByteLength(value) > MAX_BUNDLE_COMMENT_BYTES
? `Comment cannot exceed ${MAX_BUNDLE_COMMENT_BYTES} bytes`
: true
}
/>
<BundleCommentField control={form.control} />
</SideModalForm>
)
}
89 changes: 0 additions & 89 deletions app/forms/support-bundle-edit.tsx

This file was deleted.

177 changes: 177 additions & 0 deletions app/pages/system/SupportBundleDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/*
* 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, type UseQueryResult } from '@tanstack/react-query'
import type { ReactNode } from 'react'
import { useForm } from 'react-hook-form'
import { useNavigate, type LoaderFunctionArgs } from 'react-router'

import {
api,
q,
queryClient,
useApiMutation,
usePrefetchedQuery,
type SupportBundleInfo,
} from '@oxide/api'
import { Logs16Icon } from '@oxide/design-system/icons/react'

import { BundleCommentField } from '~/components/form/fields/BundleCommentField'
import { SideModalForm } from '~/components/form/SideModalForm'
import { SupportBundleStateBadge } from '~/components/StateBadge'
import { titleCrumb } from '~/hooks/use-crumbs'
import { getSupportBundleSelector, useSupportBundleSelector } from '~/hooks/use-params'
import { addToast } from '~/stores/toast'
import { DescriptionCell } from '~/table/cells/DescriptionCell'
import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell'
import { Button } from '~/ui/lib/Button'
import { FormDivider } from '~/ui/lib/Divider'
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
import { ResourceLabel } from '~/ui/lib/SideModal'
import { truncate } from '~/ui/lib/Truncate'
import { Size } from '~/ui/lib/ValueUnit'
import { docLinks } from '~/util/links'
import { pb } from '~/util/path-builder'
import type * as PP from '~/util/path-params'
import {
bundleIndexQuery,
bundleSizeQuery,
downloadBundle,
DOWNLOAD_DISABLED_REASON,
} from '~/util/support-bundle'

const SEC = 1000 // ms
const POLL_INTERVAL = 10 * SEC

const bundleView = ({ bundleId }: PP.SupportBundle) => ({
...q(api.supportBundleView, { path: { bundleId } }),
// keep transitional states moving while the modal is open, matching the
// list's polling, so a collecting bundle flips to active in place
refetchInterval: ({
state: { data },
}: {
state: { data: SupportBundleInfo | undefined }
}) =>
data?.state === 'collecting' || data?.state === 'destroying' ? POLL_INTERVAL : false,
})

export async function clientLoader({ params }: LoaderFunctionArgs) {
await queryClient.prefetchQuery(bundleView(getSupportBundleSelector(params)))
return null
}

export const handle = titleCrumb('Support bundle')

/** Skeleton while the query is in flight, em dash if it failed */
function AsyncValue<T>({
query,
children,
}: {
query: UseQueryResult<T>
children: (data: T) => ReactNode
}) {
if (query.isPending) return <SkeletonCell />
if (query.isError) return <EmptyCell />
return <>{children(query.data)}</>
}

export default function SupportBundleDetail() {
const navigate = useNavigate()
const { bundleId } = useSupportBundleSelector()
const { data: bundle } = usePrefetchedQuery(bundleView({ bundleId }))

// the index and bundle zip only exist once collection has completed
const isActive = bundle.state === 'active'
const indexQuery = useQuery({ ...bundleIndexQuery(bundleId), enabled: isActive })
const sizeQuery = useQuery({ ...bundleSizeQuery(bundleId), enabled: isActive })

const form = useForm({ defaultValues: { userComment: bundle.userComment || '' } })
// must destructure to subscribe to changes; inlining does not work
const { isDirty } = form.formState

const onDismiss = () => navigate(pb.supportBundles())

const editBundle = useApiMutation(api.supportBundleUpdate, {
onSuccess() {
queryClient.invalidateEndpoint('supportBundleList')
queryClient.invalidateEndpoint('supportBundleView')
addToast('Support bundle updated')
navigate(pb.supportBundles())
},
})

return (
<SideModalForm
form={form}
formType="edit"
// scoped to the one editable field, like access forms' "Update role"
resourceName="comment"
title="Support bundle"
submitDisabled={isDirty ? undefined : 'No changes to save'}
subtitle={
<ResourceLabel>
<Logs16Icon /> {truncate(bundle.id, 14, 'middle')}
</ResourceLabel>
}
onDismiss={onDismiss}
onSubmit={({ userComment }) => {
editBundle.mutate({
path: { bundleId },
body: { userComment: userComment || null },
})
}}
loading={editBundle.isPending}
submitError={editBundle.error}
>
<div className="flex flex-col gap-4">
<PropertiesTable>
<PropertiesTable.IdRow id={bundle.id} />
<PropertiesTable.Row label="State">
<SupportBundleStateBadge state={bundle.state} />
</PropertiesTable.Row>
{bundle.reasonForFailure && (
<PropertiesTable.Row label="Failure reason">
<DescriptionCell text={bundle.reasonForFailure} sideModal />
</PropertiesTable.Row>
)}
<PropertiesTable.Row label="Reason">
<DescriptionCell text={bundle.reasonForCreation} sideModal />
</PropertiesTable.Row>
<PropertiesTable.DateRow label="Created" date={bundle.timeCreated} />
{isActive && (
<PropertiesTable.Row label="Files">
<AsyncValue query={indexQuery}>
{(entries) =>
// directory entries have a trailing slash; count files only
entries.filter((e) => !e.endsWith('/')).length.toLocaleString()
}
</AsyncValue>
</PropertiesTable.Row>
)}
{isActive && (
<PropertiesTable.Row label="Size">
<AsyncValue query={sizeQuery}>{(bytes) => <Size bytes={bytes} />}</AsyncValue>
</PropertiesTable.Row>
)}
</PropertiesTable>
<Button
className="w-full"
size="sm"
disabled={!isActive}
disabledReason={DOWNLOAD_DISABLED_REASON}
onClick={() => downloadBundle(bundle.id)}
>
Download bundle
</Button>
</div>
<FormDivider />
<BundleCommentField control={form.control} />
<SideModalFormDocs docs={[docLinks.supportBundles]} />
</SideModalForm>
)
}
19 changes: 10 additions & 9 deletions app/pages/system/SupportBundlesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { useQuickActions } from '~/hooks/use-quick-actions'
import { confirmDelete } from '~/stores/confirm-delete'
import { addToast } from '~/stores/toast'
import { DescriptionCell } from '~/table/cells/DescriptionCell'
import { LinkCell } from '~/table/cells/LinkCell'
import { useColsWithActions, type MenuAction } from '~/table/columns/action-col'
import { Columns } from '~/table/columns/common'
import { useQueryTable } from '~/table/QueryTable'
Expand All @@ -36,10 +37,10 @@ import { EmptyMessage } from '~/ui/lib/EmptyMessage'
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
import { TableActions } from '~/ui/lib/Table'
import { TipIcon } from '~/ui/lib/TipIcon'
import { truncate, Truncate } from '~/ui/lib/Truncate'
import { truncate } from '~/ui/lib/Truncate'
import { docLinks } from '~/util/links'
import { pb } from '~/util/path-builder'
import { bundleDownloadUrl, triggerDownload } from '~/util/support-bundle'
import { downloadBundle, DOWNLOAD_DISABLED_REASON } from '~/util/support-bundle'

const EmptyState = () => (
<EmptyMessage
Expand All @@ -64,7 +65,9 @@ const staticColumns = [
colHelper.accessor('id', {
header: 'ID',
cell: (info) => (
<Truncate text={info.getValue()} maxLength={14} position="middle" hasCopyButton />
<LinkCell to={pb.supportBundle({ bundleId: info.getValue() })}>
{truncate(info.getValue(), 14, 'middle')}
</LinkCell>
),
}),
colHelper.accessor('state', {
Expand Down Expand Up @@ -122,20 +125,18 @@ export default function SupportBundlesPage() {
{
label: 'Download',
onActivate() {
triggerDownload(bundleDownloadUrl(bundle.id), `support-bundle-${bundle.id}.zip`)
downloadBundle(bundle.id)
},
disabled:
bundle.state !== 'active' &&
'Only bundles that have completed collection can be downloaded',
disabled: bundle.state !== 'active' && DOWNLOAD_DISABLED_REASON,
},
{
label: 'Edit comment',
label: 'View details',
onActivate() {
const bundleView = q(api.supportBundleView, {
path: { bundleId: bundle.id },
})
queryClient.setQueryData(bundleView.queryKey, bundle)
navigate(pb.supportBundleEdit({ bundleId: bundle.id }))
navigate(pb.supportBundle({ bundleId: bundle.id }))
},
},
{
Expand Down
Loading
Loading