From f527b625cb2bb13cabf6244fa7736103c012f0b8 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 4 Aug 2026 19:12:16 -0700 Subject: [PATCH 01/36] First stab at support bundles in console --- app/api/selectors.ts | 1 + app/components/StateBadge.tsx | 23 ++ app/forms/support-bundle-create.tsx | 75 ++++++ app/forms/support-bundle-edit.tsx | 83 +++++++ app/hooks/use-params.ts | 2 + app/layouts/SystemLayout.tsx | 5 + app/pages/system/SupportBundleFilesModal.tsx | 195 +++++++++++++++ app/pages/system/SupportBundlesPage.tsx | 219 +++++++++++++++++ app/routes.tsx | 16 ++ .../__snapshots__/path-builder.spec.ts.snap | 24 ++ app/util/links.ts | 4 + app/util/path-builder.spec.ts | 5 + app/util/path-builder.ts | 7 + app/util/path-params.ts | 1 + app/util/support-bundle.spec.ts | 84 +++++++ app/util/support-bundle.ts | 119 ++++++++++ mock-api/index.ts | 1 + mock-api/msw/db.ts | 1 + mock-api/msw/handlers.ts | 156 +++++++++++- mock-api/support-bundle.ts | 84 +++++++ test/e2e/support-bundles.e2e.ts | 223 ++++++++++++++++++ vite.config.ts | 6 + 22 files changed, 1324 insertions(+), 10 deletions(-) create mode 100644 app/forms/support-bundle-create.tsx create mode 100644 app/forms/support-bundle-edit.tsx create mode 100644 app/pages/system/SupportBundleFilesModal.tsx create mode 100644 app/pages/system/SupportBundlesPage.tsx create mode 100644 app/util/support-bundle.spec.ts create mode 100644 app/util/support-bundle.ts create mode 100644 mock-api/support-bundle.ts create mode 100644 test/e2e/support-bundles.e2e.ts diff --git a/app/api/selectors.ts b/app/api/selectors.ts index 0dd0bc122..be933451c 100644 --- a/app/api/selectors.ts +++ b/app/api/selectors.ts @@ -31,6 +31,7 @@ export type IdentityProvider = Readonly> export type SystemUpdate = Readonly<{ version: string }> export type SshKey = Readonly<{ sshKey: string }> export type Sled = Readonly<{ sledId?: string }> +export type SupportBundle = Readonly<{ bundleId?: string }> export type IpPool = Readonly<{ pool?: string }> export type SubnetPool = Readonly<{ subnetPool?: string }> export type ExternalSubnet = Readonly> diff --git a/app/components/StateBadge.tsx b/app/components/StateBadge.tsx index 877a09915..400d11764 100644 --- a/app/components/StateBadge.tsx +++ b/app/components/StateBadge.tsx @@ -14,6 +14,7 @@ import { type DiskType, type InstanceState, type SnapshotState, + type SupportBundleState, } from '@oxide/api' import { Badge, type BadgeColor } from '@oxide/design-system/ui' @@ -85,6 +86,28 @@ export const SnapshotStateBadge = (props: { state: SnapshotState; className?: st ) +const SUPPORT_BUNDLE_COLORS: Record = { + collecting: 'blue', + active: 'default', + destroying: 'neutral', + failed: 'destructive', +} + +export const SupportBundleStateBadge = (props: { + state: SupportBundleState + className?: string +}) => ( + + {(props.state === 'collecting' || props.state === 'destroying') && ( + + )} + {props.state} + +) + export const DiskTypeBadge = (props: { diskType: DiskType; className?: string }) => ( {props.diskType} diff --git a/app/forms/support-bundle-create.tsx b/app/forms/support-bundle-create.tsx new file mode 100644 index 000000000..de28a664f --- /dev/null +++ b/app/forms/support-bundle-create.tsx @@ -0,0 +1,75 @@ +/* + * 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 { api, queryClient, useApiMutation } from '@oxide/api' + +import { TextField } from '~/components/form/fields/TextField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { titleCrumb } from '~/hooks/use-crumbs' +import { addToast } from '~/stores/toast' +import { Message } from '~/ui/lib/Message' +import { pb } from '~/util/path-builder' + +// the API only enforces this on update, but apply it at create time too so +// the comment doesn't become uneditable later +// https://github.com/oxidecomputer/omicron/blob/99249b4/nexus/db-queries/src/db/datastore/support_bundle.rs#L736-L742 +export const MAX_COMMENT_LENGTH = 4096 + +const defaultValues = { userComment: '' } + +export const handle = titleCrumb('New support bundle') + +export default function CreateSupportBundleSideModalForm() { + const navigate = useNavigate() + + const onDismiss = () => navigate(pb.supportBundles()) + + const createBundle = useApiMutation(api.supportBundleCreate, { + onSuccess() { + queryClient.invalidateEndpoint('supportBundleList') + addToast('Support bundle created') + navigate(pb.supportBundles()) + }, + }) + + const form = useForm({ defaultValues }) + + return ( + { + createBundle.mutate({ body: { userComment: userComment || null } }) + }} + loading={createBundle.isPending} + submitError={createBundle.error} + > + + + value.length > MAX_COMMENT_LENGTH + ? `Comment cannot exceed ${MAX_COMMENT_LENGTH} characters` + : true + } + /> + + ) +} diff --git a/app/forms/support-bundle-edit.tsx b/app/forms/support-bundle-edit.tsx new file mode 100644 index 000000000..776ee7f5d --- /dev/null +++ b/app/forms/support-bundle-edit.tsx @@ -0,0 +1,83 @@ +/* + * 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, type LoaderFunctionArgs } from 'react-router' + +import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' + +import { TextField } from '~/components/form/fields/TextField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { titleCrumb } from '~/hooks/use-crumbs' +import { getSupportBundleSelector, useSupportBundleSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +import { MAX_COMMENT_LENGTH } from './support-bundle-create' + +const bundleView = ({ bundleId }: PP.SupportBundle) => + q(api.supportBundleView, { path: { bundleId } }) + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const selector = getSupportBundleSelector(params) + await queryClient.prefetchQuery(bundleView(selector)) + return null +} + +export const handle = titleCrumb('Edit support bundle') + +export default function EditSupportBundleSideModalForm() { + const navigate = useNavigate() + const selector = useSupportBundleSelector() + + const { data: bundle } = usePrefetchedQuery(bundleView(selector)) + + const form = useForm({ defaultValues: { userComment: bundle.userComment || '' } }) + + 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 ( + { + editBundle.mutate({ + path: { bundleId: selector.bundleId }, + body: { userComment: userComment || null }, + }) + }} + loading={editBundle.isPending} + submitError={editBundle.error} + > + + value.length > MAX_COMMENT_LENGTH + ? `Comment cannot exceed ${MAX_COMMENT_LENGTH} characters` + : true + } + /> + + ) +} diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts index 5298181d9..9318078d5 100644 --- a/app/hooks/use-params.ts +++ b/app/hooks/use-params.ts @@ -53,6 +53,7 @@ export const requireSledParams = requireParams('sledId') export const requireUpdateParams = requireParams('version') export const getIpPoolSelector = requireParams('pool') export const getSubnetPoolSelector = requireParams('subnetPool') +export const getSupportBundleSelector = requireParams('bundleId') export const getAffinityGroupSelector = requireParams('project', 'affinityGroup') export const getAntiAffinityGroupSelector = requireParams('project', 'antiAffinityGroup') @@ -104,6 +105,7 @@ export const useSledParams = () => useSelectedParams(requireSledParams) export const useUpdateParams = () => useSelectedParams(requireUpdateParams) export const useIpPoolSelector = () => useSelectedParams(getIpPoolSelector) export const useSubnetPoolSelector = () => useSelectedParams(getSubnetPoolSelector) +export const useSupportBundleSelector = () => useSelectedParams(getSupportBundleSelector) export const useAffinityGroupSelector = () => useSelectedParams(getAffinityGroupSelector) export const useAntiAffinityGroupSelector = () => useSelectedParams(getAntiAffinityGroupSelector) diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index fca0d33b8..f25d20f32 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -12,6 +12,7 @@ import { Access16Icon, Cloud16Icon, IpGlobal16Icon, + Logs16Icon, Metrics16Icon, Servers16Icon, SoftwareUpdate16Icon, @@ -56,6 +57,7 @@ export default function SystemLayout() { { value: 'IP Pools', path: pb.ipPools() }, { value: 'Subnet Pools', path: pb.subnetPools() }, { value: 'System Update', path: pb.systemUpdate() }, + { value: 'Support Bundles', path: pb.supportBundles() }, { value: 'Fleet Access', path: pb.fleetAccess() }, ] // filter out the entry for the path we're currently on @@ -104,6 +106,9 @@ export default function SystemLayout() { System Update + + Support Bundles + Fleet Access diff --git a/app/pages/system/SupportBundleFilesModal.tsx b/app/pages/system/SupportBundleFilesModal.tsx new file mode 100644 index 000000000..4e4af0181 --- /dev/null +++ b/app/pages/system/SupportBundleFilesModal.tsx @@ -0,0 +1,195 @@ +/* + * 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 { Fragment, useState } from 'react' +import { useNavigate } from 'react-router' + +import { + Document16Icon, + Folder16Icon, + Logs16Icon, + PrevArrow12Icon, +} from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { titleCrumb } from '~/hooks/use-crumbs' +import { useSupportBundleSelector } from '~/hooks/use-params' +import { Button } from '~/ui/lib/Button' +import { Message } from '~/ui/lib/Message' +import { ResourceLabel, SideModal } from '~/ui/lib/SideModal' +import { Spinner } from '~/ui/lib/Spinner' +import { truncate } from '~/ui/lib/Truncate' +import { pb } from '~/util/path-builder' +import { + bundleDownloadUrl, + bundleFileQuery, + bundleFileUrl, + bundleIndexQuery, + isViewable, + lsBundleDir, + triggerDownload, +} from '~/util/support-bundle' + +export const handle = titleCrumb('Support bundle files') + +const entryRowStyle = + 'flex w-full items-center gap-2 rounded px-2 py-1.5 text-sans-md text-default hover:bg-hover' + +function FileContent({ bundleId, filePath }: { bundleId: string; filePath: string }) { + const { data, isError } = useQuery(bundleFileQuery(bundleId, filePath)) + + if (isError) return + if (!data) return + if (data.kind === 'tooLarge') { + return ( + + ) + } + return ( +
+      {data.text}
+    
+ ) +} + +export default function SupportBundleFilesModal() { + const navigate = useNavigate() + const { bundleId } = useSupportBundleSelector() + + const [dir, setDir] = useState('') + const [file, setFile] = useState(null) + + const { data: entries, isError } = useQuery(bundleIndexQuery(bundleId)) + + const onDismiss = () => navigate(pb.supportBundles()) + + // dir is '' (root) or a path with a trailing slash, so the last segment is empty + const dirSegments = dir.split('/').slice(0, -1) + + return ( + + {truncate(bundleId, 14, 'middle')} + + } + > + + {isError ? ( + + ) : !entries ? ( + + ) : file ? ( +
+
+ +
{file}
+
+ +
+ ) : ( +
+ +
+ + {dirSegments.map((segment, i) => ( + + + {i < dirSegments.length - 1 && /} + + ))} +
+
+ {lsBundleDir(entries, dir).map((entry) => + entry.isDir ? ( + + ) : isViewable(entry.path) ? ( + + ) : ( + + ) + )} +
+
+ )} +
+ + {file ? ( + + ) : null} + + +
+ ) +} diff --git a/app/pages/system/SupportBundlesPage.tsx b/app/pages/system/SupportBundlesPage.tsx new file mode 100644 index 000000000..b11a731fa --- /dev/null +++ b/app/pages/system/SupportBundlesPage.tsx @@ -0,0 +1,219 @@ +/* + * 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 { createColumnHelper } from '@tanstack/react-table' +import { useCallback } from 'react' +import { Outlet, useNavigate } from 'react-router' + +import { + api, + getListQFn, + q, + queryClient, + useApiMutation, + type SupportBundleInfo, +} from '@oxide/api' +import { Logs16Icon, Logs24Icon } from '@oxide/design-system/icons/react' + +import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' +import { SupportBundleStateBadge } from '~/components/StateBadge' +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 { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { Columns } from '~/table/columns/common' +import { useQueryTable } from '~/table/QueryTable' +import { CreateLink } from '~/ui/lib/CreateButton' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { TableActions } from '~/ui/lib/Table' +import { Tooltip } from '~/ui/lib/Tooltip' +import { truncate, Truncate } from '~/ui/lib/Truncate' +import { Size } from '~/ui/lib/ValueUnit' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' +import { bundleDownloadUrl, bundleSizeQuery, triggerDownload } from '~/util/support-bundle' + +const EmptyState = () => ( + } + title="No support bundles" + body="Create a support bundle to see it here" + buttonText="New support bundle" + buttonTo={pb.supportBundlesNew()} + /> +) + +const StateCell = ({ bundle }: { bundle: SupportBundleInfo }) => { + const badge = + if (!bundle.reasonForFailure) return badge + return ( + +
{badge}
+
+ ) +} + +function SizeCell({ bundle }: { bundle: SupportBundleInfo }) { + const active = bundle.state === 'active' + // only active bundles have a zip backing them, so there's nothing to HEAD otherwise + const { data: size } = useQuery({ ...bundleSizeQuery(bundle.id), enabled: active }) + if (!active) return + if (size === undefined) return + return +} + +const colHelper = createColumnHelper() + +const staticColumns = [ + colHelper.accessor('id', { + header: 'ID', + cell: (info) => ( + + ), + }), + colHelper.accessor('state', { + cell: (info) => , + }), + colHelper.display({ + id: 'size', + header: 'Size', + cell: (info) => , + }), + colHelper.accessor('reasonForCreation', { + header: 'Reason', + cell: (info) => , + }), + colHelper.accessor('userComment', { + header: 'Comment', + cell: (info) => , + }), + colHelper.accessor('timeCreated', Columns.timeCreated), +] + +const SEC = 1000 // ms +/** Poll fast while any bundle is in a transitional state */ +const POLL_INTERVAL = 10 * SEC + +const bundleList = getListQFn( + api.supportBundleList, + {}, + { + refetchInterval: ({ state: { data } }) => + data?.items.some((b) => b.state === 'collecting' || b.state === 'destroying') + ? POLL_INTERVAL + : false, + } +) + +export async function clientLoader() { + await queryClient.prefetchQuery(bundleList.optionsFn()) + return null +} + +export const handle = { crumb: 'Support Bundles' } + +export default function SupportBundlesPage() { + const navigate = useNavigate() + + const { mutateAsync: deleteBundle } = useApiMutation(api.supportBundleDelete, { + onSuccess(_data, variables) { + queryClient.invalidateEndpoint('supportBundleList') + // prettier-ignore + addToast(<>Support bundle {truncate(variables.path.bundleId, 14, 'middle')} deleted) + }, + }) + + const makeActions = useCallback( + (bundle: SupportBundleInfo): MenuAction[] => [ + { + label: 'View files', + onActivate() { + navigate(pb.supportBundleFiles({ bundleId: bundle.id })) + }, + disabled: + bundle.state !== 'active' && + 'Only bundles that have completed collection can be viewed', + }, + { + label: 'Download', + onActivate() { + triggerDownload(bundleDownloadUrl(bundle.id), `support-bundle-${bundle.id}.zip`) + }, + disabled: + bundle.state !== 'active' && + 'Only bundles that have completed collection can be downloaded', + }, + { + label: 'Edit comment', + onActivate() { + const bundleView = q(api.supportBundleView, { + path: { bundleId: bundle.id }, + }) + queryClient.setQueryData(bundleView.queryKey, bundle) + navigate(pb.supportBundleEdit({ bundleId: bundle.id })) + }, + }, + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => deleteBundle({ path: { bundleId: bundle.id } }), + label: truncate(bundle.id, 14, 'middle'), + resourceKind: 'support bundle', + extraContent: + bundle.state === 'collecting' + ? 'This bundle is still being collected. Deleting it will cancel collection.' + : undefined, + }), + disabled: bundle.state === 'destroying' && 'Bundle is already being destroyed', + }, + ], + [deleteBundle, navigate] + ) + + const columns = useColsWithActions(staticColumns, makeActions) + const { table } = useQueryTable({ + query: bundleList, + columns, + emptyState: , + }) + + useQuickActions( + () => [ + { + value: 'New support bundle', + navGroup: 'Actions', + action: pb.supportBundlesNew(), + }, + ], + [] + ) + + return ( + <> + + }>Support Bundles + } + summary="Support bundles capture diagnostic data from the rack to share with Oxide Support. They consume rack storage, so delete them when no longer needed." + links={[docLinks.supportBundles]} + /> + + + New Support Bundle + + {table} + + + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 2fdaadc22..7f5733103 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -269,6 +269,22 @@ export const routes = createRoutesFromElements( path="update" lazy={() => import('./pages/system/UpdatePage').then(convert)} /> + import('./pages/system/SupportBundlesPage').then(convert)}> + + import('./forms/support-bundle-edit').then(convert)} + /> + import('./pages/system/SupportBundleFilesModal').then(convert)} + /> + + import('./forms/support-bundle-create').then(convert)} + /> + import('./pages/system/FleetAccessPage').then(convert)} diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 300fee583..83659ee46 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -897,6 +897,30 @@ exports[`breadcrumbs 2`] = ` "path": "/system/networking/", }, ], + "supportBundleEdit (/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31/edit)": [ + { + "label": "Support Bundles", + "path": "/system/", + }, + ], + "supportBundleFiles (/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31/files)": [ + { + "label": "Support Bundles", + "path": "/system/", + }, + ], + "supportBundles (/system/support-bundles)": [ + { + "label": "Support Bundles", + "path": "/system/", + }, + ], + "supportBundlesNew (/system/support-bundles-new)": [ + { + "label": "Support Bundles", + "path": "/system/", + }, + ], "systemUpdate (/system/update)": [ { "label": "System Update", diff --git a/app/util/links.ts b/app/util/links.ts index 7c9fcfbf5..5e355a692 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -152,6 +152,10 @@ export const docLinks = { href: 'https://docs.oxide.computer/guides/operator/ip-pool-management#_using_subnet_pools', linkText: 'Subnet Pools', }, + supportBundles: { + href: 'https://docs.oxide.computer/guides/troubleshooting#_support_bundles', + linkText: 'Support Bundles', + }, systemMetrics: { href: 'https://docs.oxide.computer/guides/operator/system-metrics', linkText: 'Metrics', diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 9fc90181e..c8750c54e 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -28,6 +28,7 @@ const params = { version: 'vs', provider: 'pr', sledId: '5c56b522-c9b8-49e4-9f9a-8d52a89ec3e0', + bundleId: 'ccdac005-66a8-4921-9e8b-30531c359c31', image: 'im', disk: 'd', sshKey: 'ss', @@ -114,6 +115,10 @@ test('path builder', () => { "subnetPoolMemberAdd": "/system/networking/subnet-pools/sp/members-add", "subnetPools": "/system/networking/subnet-pools", "subnetPoolsNew": "/system/networking/subnet-pools-new", + "supportBundleEdit": "/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31/edit", + "supportBundleFiles": "/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31/files", + "supportBundles": "/system/support-bundles", + "supportBundlesNew": "/system/support-bundles-new", "systemUpdate": "/system/update", "systemUtilization": "/system/utilization", "vpc": "/projects/p/vpcs/v/firewall-rules", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index e09ad45aa..d0fa28449 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -149,6 +149,13 @@ export const pb = { systemUpdate: () => '/system/update', + supportBundles: () => '/system/support-bundles', + supportBundlesNew: () => '/system/support-bundles-new', + supportBundleEdit: (params: PP.SupportBundle) => + `${pb.supportBundles()}/${params.bundleId}/edit`, + supportBundleFiles: (params: PP.SupportBundle) => + `${pb.supportBundles()}/${params.bundleId}/files`, + profile: () => '/settings/profile', sshKeys: () => '/settings/ssh-keys', sshKeysNew: () => '/settings/ssh-keys-new', diff --git a/app/util/path-params.ts b/app/util/path-params.ts index 011afa41c..f9d3dac2f 100644 --- a/app/util/path-params.ts +++ b/app/util/path-params.ts @@ -30,4 +30,5 @@ export type SshKey = Required export type AffinityGroup = Required export type AntiAffinityGroup = Required export type SubnetPool = Required +export type SupportBundle = Required export type Disk = Required diff --git a/app/util/support-bundle.spec.ts b/app/util/support-bundle.spec.ts new file mode 100644 index 000000000..b5ee51378 --- /dev/null +++ b/app/util/support-bundle.spec.ts @@ -0,0 +1,84 @@ +/* + * 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 { describe, expect, it } from 'vitest' + +import { bundleFileUrl, isViewable, lsBundleDir, parseBundleIndex } from './support-bundle' + +const index = parseBundleIndex( + [ + 'bundle_id.txt', + 'meta/', + 'meta/reason_for_creation.txt', + 'meta/report.json', + 'rack/', + 'rack/a5b3/', + 'rack/a5b3/sled/', + 'rack/a5b3/sled/0/', + 'rack/a5b3/sled/0/zpool.json', + 'reconfigurator_state.json', + '', // trailing newline produces an empty entry + ].join('\n') +) + +describe('parseBundleIndex', () => { + it('drops empty lines', () => { + expect(index).toHaveLength(10) + }) +}) + +describe('lsBundleDir', () => { + it('lists the root with dirs first', () => { + expect(lsBundleDir(index, '')).toEqual([ + { name: 'meta', path: 'meta/', isDir: true }, + { name: 'rack', path: 'rack/', isDir: true }, + { name: 'bundle_id.txt', path: 'bundle_id.txt', isDir: false }, + { + name: 'reconfigurator_state.json', + path: 'reconfigurator_state.json', + isDir: false, + }, + ]) + }) + + it('lists a subdirectory', () => { + expect(lsBundleDir(index, 'meta/')).toEqual([ + { + name: 'reason_for_creation.txt', + path: 'meta/reason_for_creation.txt', + isDir: false, + }, + { name: 'report.json', path: 'meta/report.json', isDir: false }, + ]) + }) + + it('shows only the immediate child of a deep tree', () => { + expect(lsBundleDir(index, 'rack/')).toEqual([ + { name: 'a5b3', path: 'rack/a5b3/', isDir: true }, + ]) + }) + + it('derives directories even without explicit dir entries', () => { + const noDirs = ['meta/report.json', 'bundle_id.txt'] + expect(lsBundleDir(noDirs, '')).toEqual([ + { name: 'meta', path: 'meta/', isDir: true }, + { name: 'bundle_id.txt', path: 'bundle_id.txt', isDir: false }, + ]) + }) +}) + +it('isViewable matches text-like extensions only', () => { + expect(isViewable('bundle_id.txt')).toBe(true) + expect(isViewable('meta/report.json')).toBe(true) + expect(isViewable('logs/oxz_switch/logs.zip')).toBe(false) +}) + +it('bundleFileUrl encodes slashes in the file path', () => { + expect(bundleFileUrl('abc', 'meta/report.json')).toBe( + '/experimental/v1/system/support-bundles/abc/download/meta%2Freport.json' + ) +}) diff --git a/app/util/support-bundle.ts b/app/util/support-bundle.ts new file mode 100644 index 000000000..05ec777a1 --- /dev/null +++ b/app/util/support-bundle.ts @@ -0,0 +1,119 @@ +/* + * 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 * as R from 'remeda' + +import { MiB } from './units' + +/* + * The generated API client only handles JSON responses, so the binary and + * plain-text support bundle endpoints (download, index, per-file download) are + * fetched directly. The browser sends the session cookie the same as any API + * request. + */ + +export const bundleDownloadUrl = (bundleId: string) => + `/experimental/v1/system/support-bundles/${bundleId}/download` + +// file paths contain slashes, which must be encoded to fit in one path segment +export const bundleFileUrl = (bundleId: string, filePath: string) => + `${bundleDownloadUrl(bundleId)}/${encodeURIComponent(filePath)}` + +const bundleIndexUrl = (bundleId: string) => + `/experimental/v1/system/support-bundles/${bundleId}/index` + +/** + * Parse the plain-text bundle index: newline-separated zip entry names, where + * directories have a trailing slash. + */ +export const parseBundleIndex = (text: string): string[] => + text.split('\n').filter((line) => line.length > 0) + +export type BundleDirEntry = { name: string; path: string; isDir: boolean } + +/** + * List the entries directly under `dir` (`''` for the root, otherwise a path + * with a trailing slash). Directories sort before files. Directories are + * derived from deeper entries too, so the listing is correct even if the index + * omits explicit directory entries. + */ +export function lsBundleDir(entries: string[], dir: string): BundleDirEntry[] { + const children = new Map() + for (const entry of entries) { + if (!entry.startsWith(dir) || entry === dir) continue + const rest = entry.slice(dir.length) + const slash = rest.indexOf('/') + if (slash === -1) { + children.set(rest, { name: rest, path: entry, isDir: false }) + } else { + const name = rest.slice(0, slash) + children.set(`${name}/`, { name, path: `${dir}${name}/`, isDir: true }) + } + } + return R.sortBy( + [...children.values()], + (e) => (e.isDir ? 0 : 1), + (e) => e.name + ) +} + +/** Files we render inline. Everything else (e.g., nested log zips) is download-only. */ +export const isViewable = (filePath: string) => /\.(txt|json|log)$/.test(filePath) + +export function triggerDownload(url: string, filename: string) { + const link = document.createElement('a') + link.href = url + link.download = filename + link.click() +} + +export const MAX_INLINE_FILE_BYTES = 1 * MiB + +export const bundleIndexQuery = (bundleId: string) => ({ + queryKey: ['supportBundleIndex', bundleId], + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const res = await fetch(bundleIndexUrl(bundleId), { signal }) + if (!res.ok) throw new Error(`Error fetching bundle index (${res.status})`) + return parseBundleIndex(await res.text()) + }, + // bundle contents never change once collection is complete + staleTime: Infinity, +}) + +export type BundleFileContent = { kind: 'text'; text: string } | { kind: 'tooLarge' } + +export const bundleFileQuery = (bundleId: string, filePath: string) => ({ + queryKey: ['supportBundleFile', bundleId, filePath], + queryFn: async ({ signal }: { signal: AbortSignal }): Promise => { + const res = await fetch(bundleFileUrl(bundleId, filePath), { signal }) + if (!res.ok) throw new Error(`Error fetching file (${res.status})`) + if (Number(res.headers.get('content-length')) > MAX_INLINE_FILE_BYTES) { + await res.body?.cancel() + return { kind: 'tooLarge' } + } + let text = await res.text() + if (filePath.endsWith('.json')) { + try { + text = JSON.stringify(JSON.parse(text), null, 2) + } catch { + // not valid JSON, show it raw + } + } + return { kind: 'text', text } + }, + staleTime: Infinity, +}) + +export const bundleSizeQuery = (bundleId: string) => ({ + queryKey: ['supportBundleSize', bundleId], + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const res = await fetch(bundleDownloadUrl(bundleId), { method: 'HEAD', signal }) + if (!res.ok) throw new Error(`Error fetching bundle size (${res.status})`) + return Number(res.headers.get('content-length')) + }, + staleTime: Infinity, +}) diff --git a/mock-api/index.ts b/mock-api/index.ts index 3620d30c2..0b7094318 100644 --- a/mock-api/index.ts +++ b/mock-api/index.ts @@ -25,6 +25,7 @@ export * from './sled' export * from './snapshot' export * from './subnet-pool' export * from './sshKeys' +export * from './support-bundle' export * from './switch' export * from './system-update' export * from './token' diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 9986205ed..2360821c0 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -642,6 +642,7 @@ const initDb = { snapshots: [...mock.snapshots], snatIps: [...mock.snatIps], sshKeys: [...mock.sshKeys], + supportBundles: [...mock.supportBundles], tufRepos: [...mock.tufRepos], updateStatus: mock.updateStatus, users: [...mock.users], diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 5f2b05637..7cc4f7b1d 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ import { addHours } from 'date-fns' -import { delay } from 'msw' +import { delay, HttpResponse } from 'msw' import * as R from 'remeda' import { lt as semverLessThan, rcompare as semverRCompare } from 'semver' import { match } from 'ts-pattern' @@ -36,6 +36,11 @@ import { commaSeries } from '~/util/str' import { GiB } from '~/util/units' import { defaultSilo, toIdp } from '../silo' +import { + supportBundleFiles, + supportBundleIndexText, + supportBundleSizes, +} from '../support-bundle' import { getTimestamps } from '../util' import { defaultFirewallRules } from '../vpc' import { @@ -2012,6 +2017,146 @@ export const handlers = makeHandlers({ return paginated(query, db.users) }, + supportBundleList({ query, cookies }) { + requireFleetViewer(cookies) + return paginated(query, db.supportBundles) + }, + supportBundleView({ path, cookies }) { + requireFleetViewer(cookies) + return lookupById(db.supportBundles, path.bundleId) + }, + supportBundleCreate({ body, cookies }) { + requireFleetAdmin(cookies) + + // sentinel for testing the one-bundle-per-external-disk policy error + // https://github.com/oxidecomputer/omicron/blob/99249b4/nexus/db-queries/src/db/datastore/support_bundle.rs#L47-L49 + if (body.user_comment === 'no space') { + throw json( + { + error_code: 'InsufficientCapacity', + message: + "Insufficient capacity: Current policy limits support bundle creation to 'one per external disk', and no disks are available. You must delete old support bundles before new ones can be created", + }, + { status: 507 } + ) + } + + const newBundle: Json = { + id: uuid(), + reason_for_creation: 'Created by external API', + state: 'collecting', + time_created: new Date().toISOString(), + user_comment: body.user_comment, + } + db.supportBundles.push(newBundle) + + // simulate collection finishing, with a sentinel to exercise failure + setTimeout(() => { + if (body.user_comment === 'fail collection') { + newBundle.state = 'failed' + newBundle.reason_for_failure = 'Bundle collection failed' + } else { + newBundle.state = 'active' + } + }, 3000) + + return json(newBundle, { status: 201 }) + }, + supportBundleUpdate({ path, body, cookies }) { + requireFleetAdmin(cookies) + const bundle = lookupById(db.supportBundles, path.bundleId) + // https://github.com/oxidecomputer/omicron/blob/99249b4/nexus/db-queries/src/db/datastore/support_bundle.rs#L736-L742 + if (body.user_comment && body.user_comment.length > 4096) { + throw invalidRequest('User comment cannot exceed 4096 bytes') + } + bundle.user_comment = body.user_comment + return bundle + }, + supportBundleDelete({ path, cookies }) { + requireFleetAdmin(cookies) + const bundle = lookupById(db.supportBundles, path.bundleId) + + // a failed bundle's storage is already reclaimed, so it's deleted + // immediately. otherwise the bundle sits in state 'destroying' until a + // background task frees its storage, which we simulate with a timeout + if (bundle.state === 'failed') { + db.supportBundles = db.supportBundles.filter((b) => b.id !== bundle.id) + } else { + bundle.state = 'destroying' + setTimeout(() => { + db.supportBundles = db.supportBundles.filter((b) => b.id !== bundle.id) + }, 3000) + } + + return 204 + }, + // the generated handler type only allows status code returns for binary + // endpoints, but the dispatcher passes Response instances through untouched + // @ts-expect-error + supportBundleDownload({ path, cookies }) { + requireFleetViewer(cookies) + const bundle = lookupById(db.supportBundles, path.bundleId) + if (bundle.state !== 'active') { + throw invalidRequest('Cannot download bundle in non-active state') + } + // smallest valid zip: an empty end-of-central-directory record + const emptyZip = new Uint8Array(22) + emptyZip.set([0x50, 0x4b, 0x05, 0x06]) + return new HttpResponse(emptyZip, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="support-bundle-${bundle.id}.zip"`, + }, + }) + }, + // the generated handler type only allows status code returns for binary + // endpoints, but the dispatcher passes Response instances through untouched + // @ts-expect-error + supportBundleHead({ path, cookies }) { + requireFleetViewer(cookies) + const bundle = lookupById(db.supportBundles, path.bundleId) + if (bundle.state !== 'active') { + throw invalidRequest('Cannot download bundle in non-active state') + } + const size = supportBundleSizes[bundle.id] ?? GiB + return new HttpResponse(null, { + headers: { + 'Content-Length': size.toString(), + 'Content-Type': 'application/zip', + }, + }) + }, + // @ts-expect-error Response passthrough, see supportBundleHead + supportBundleIndex({ path, cookies }) { + requireFleetViewer(cookies) + const bundle = lookupById(db.supportBundles, path.bundleId) + if (bundle.state !== 'active') { + throw invalidRequest('Cannot download bundle in non-active state') + } + return new HttpResponse(supportBundleIndexText, { + headers: { 'Content-Type': 'text/plain' }, + }) + }, + // @ts-expect-error Response passthrough, see supportBundleHead + supportBundleDownloadFile({ path, cookies }) { + requireFleetViewer(cookies) + const bundle = lookupById(db.supportBundles, path.bundleId) + if (bundle.state !== 'active') { + throw invalidRequest('Cannot download bundle in non-active state') + } + // the client encodes slashes in the file path so it fits in one segment + const file = decodeURIComponent(path.file) + const content = supportBundleFiles[file] + if (content === undefined) throw notFoundErr(`file '${file}' in support bundle`) + if (file.endsWith('.zip')) { + const emptyZip = new Uint8Array(22) + emptyZip.set([0x50, 0x4b, 0x05, 0x06]) + return new HttpResponse(emptyZip, { + headers: { 'Content-Type': 'application/zip' }, + }) + } + return new HttpResponse(content, { headers: { 'Content-Type': 'text/plain' } }) + }, switchList: ({ query, cookies }) => { requireFleetViewer(cookies) return paginated(query, db.switches) @@ -2725,16 +2870,7 @@ export const handlers = makeHandlers({ siloUserView: NotImplemented, sledListUninitialized: NotImplemented, sledSetProvisionPolicy: NotImplemented, - supportBundleCreate: NotImplemented, - supportBundleDelete: NotImplemented, - supportBundleDownload: NotImplemented, - supportBundleDownloadFile: NotImplemented, - supportBundleHead: NotImplemented, supportBundleHeadFile: NotImplemented, - supportBundleIndex: NotImplemented, - supportBundleList: NotImplemented, - supportBundleUpdate: NotImplemented, - supportBundleView: NotImplemented, switchView: NotImplemented, systemNetworkingSettingsUpdate: NotImplemented, systemNetworkingSettingsView: NotImplemented, diff --git a/mock-api/support-bundle.ts b/mock-api/support-bundle.ts new file mode 100644 index 000000000..5346ea00c --- /dev/null +++ b/mock-api/support-bundle.ts @@ -0,0 +1,84 @@ +/* + * 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 * as R from 'remeda' + +import type { SupportBundleInfo } from '@oxide/api' + +import { GiB } from '~/util/units' + +import type { Json } from './json-type' + +export const supportBundles: Json[] = [ + { + id: 'ccdac005-66a8-4921-9e8b-30531c359c31', + reason_for_creation: 'Created by external API', + state: 'active', + time_created: new Date('2025-07-30T14:30:00Z').toISOString(), + user_comment: 'Investigating slow instance start times', + }, + { + // created by fault management rather than an operator, hence the + // diagnosis-style reason and lack of comment + id: '7bdd4ef3-8183-46fe-9e9f-81b34bf6b2c5', + reason_for_creation: 'Diagnosis: fan failure on sled BRM42220031', + state: 'collecting', + time_created: new Date('2025-08-01T09:15:00Z').toISOString(), + }, + { + id: 'bfc48b0c-68bb-4366-98a7-c15e0afe3a7c', + reason_for_creation: 'Created by external API', + reason_for_failure: 'Allocated dataset no longer exists', + state: 'failed', + time_created: new Date('2025-07-28T11:00:00Z').toISOString(), + }, +] + +/** Zip sizes reported by the HEAD handler. Bundles not listed get 1 GiB. */ +export const supportBundleSizes: Record = { + 'ccdac005-66a8-4921-9e8b-30531c359c31': Math.floor(2.4 * GiB), +} + +/** + * Contents served by the index and per-file download handlers for any active + * bundle. A tiny slice of a real bundle's layout, including a nested zip to + * exercise the download-only path in the file viewer. + */ +export const supportBundleFiles: Record = { + 'bundle_id.txt': 'ccdac005-66a8-4921-9e8b-30531c359c31', + 'meta/reason_for_creation.txt': 'Created by external API', + 'meta/report.json': JSON.stringify( + { + bundle: 'ccdac005-66a8-4921-9e8b-30531c359c31', + steps: [ + { name: 'reconfigurator state', duration_ms: 132 }, + { name: 'host info: sled 0', duration_ms: 4189 }, + ], + }, + null, + 2 + ), + 'rack/a5b3fd8a/sled/0/zpool.json': JSON.stringify({ pools: ['oxp_ccdac005'] }), + 'reconfigurator_state.json': JSON.stringify({ blueprint: 'b6034a15' }), + 'sp_task_dumps/switch_0/dump-0.zip': '', +} + +/** Zip entry list in the format the real index endpoint returns: sorted names, one per line, dirs with trailing slashes */ +export const supportBundleIndexText = R.pipe( + Object.keys(supportBundleFiles), + R.flatMap((path) => { + const entries = [path] + // add an explicit entry for each ancestor directory + const segments = path.split('/') + for (let i = 1; i < segments.length; i++) { + entries.push(`${segments.slice(0, i).join('/')}/`) + } + return entries + }), + R.unique(), + R.sortBy((x) => x) +).join('\n') diff --git a/test/e2e/support-bundles.e2e.ts b/test/e2e/support-bundles.e2e.ts new file mode 100644 index 000000000..da12db137 --- /dev/null +++ b/test/e2e/support-bundles.e2e.ts @@ -0,0 +1,223 @@ +/* + * 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 { expect, test } from '@playwright/test' + +import { clickRowAction, expectRowVisible, expectToast, getPageAsUser } from './utils' + +test('support bundle list', async ({ page }) => { + await page.goto('/system/support-bundles') + await expect(page).toHaveTitle('Support Bundles / Oxide Console') + await expect(page.getByRole('heading', { name: 'Support Bundles' })).toBeVisible() + + const table = page.getByRole('table') + await expect(table.getByRole('row')).toHaveCount(4) // header + 3 bundles + + await expectRowVisible(table, { + state: 'active', + Size: '2.4 GiB', + Reason: 'Created by external API', + Comment: 'Investigating slow instance start times', + }) + await expectRowVisible(table, { + state: 'collecting', + Size: '—', + Reason: 'Diagnosis: fan failure on sled BRM42220031', + }) + await expectRowVisible(table, { state: 'failed', Size: '—' }) + + // docs popover links to the troubleshooting guide. filter to external links + // because the sidebar and breadcrumb links have the same name + await page.getByRole('button', { name: 'Learn about support bundles' }).click() + const docsLink = page + .getByRole('link', { name: 'Support Bundles' }) + .and(page.locator('[target="_blank"]')) + await expect(docsLink).toHaveAttribute( + 'href', + 'https://docs.oxide.computer/guides/troubleshooting#_support_bundles' + ) +}) + +test('failed bundle state badge shows failure reason on hover', async ({ page }) => { + await page.goto('/system/support-bundles') + + const row = page.getByRole('row', { name: 'failed' }) + await row.getByText('failed').hover() + await expect(page.getByRole('tooltip')).toHaveText('Allocated dataset no longer exists') +}) + +test('download only available for active bundles', async ({ page }) => { + await page.goto('/system/support-bundles') + + // collecting bundle: download disabled with reason + const collectingRow = page.getByRole('row', { name: 'fan failure' }) + await collectingRow.getByRole('button', { name: 'Row actions' }).click() + const downloadItem = page.getByRole('menuitem', { name: 'Download' }) + await expect(downloadItem).toBeDisabled() + await downloadItem.hover() + await expect(page.getByRole('tooltip')).toHaveText( + 'Only bundles that have completed collection can be downloaded' + ) + await page.keyboard.press('Escape') + + // active bundle: download works and produces a zip + const activeRow = page.getByRole('row', { name: 'Investigating slow' }) + await activeRow.getByRole('button', { name: 'Row actions' }).click() + const downloadPromise = page.waitForEvent('download') + await page.getByRole('menuitem', { name: 'Download' }).click() + const download = await downloadPromise + expect(download.suggestedFilename()).toBe( + 'support-bundle-ccdac005-66a8-4921-9e8b-30531c359c31.zip' + ) +}) + +test('view files in an active bundle', async ({ page }) => { + await page.goto('/system/support-bundles') + + await clickRowAction(page, 'Investigating slow', 'View files') + await expect(page).toHaveURL( + '/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31/files' + ) + + const modal = page.getByRole('dialog', { name: 'Support bundle files' }) + await expect(modal).toBeVisible() + + // root listing: dirs first, then files + await expect(modal.getByRole('button', { name: 'meta' })).toBeVisible() + await expect(modal.getByRole('button', { name: 'bundle_id.txt' })).toBeVisible() + + // Download bundle button at top fetches the whole bundle zip + const bundleDownloadPromise = page.waitForEvent('download') + await modal.getByRole('button', { name: 'Download bundle', exact: true }).click() + const bundleDownload = await bundleDownloadPromise + expect(bundleDownload.suggestedFilename()).toBe( + 'support-bundle-ccdac005-66a8-4921-9e8b-30531c359c31.zip' + ) + + // drill into meta/ and view a JSON file inline + await modal.getByRole('button', { name: 'meta', exact: true }).click() + await modal.getByRole('button', { name: 'report.json' }).click() + await expect(modal.getByText('"host info: sled 0"')).toBeVisible() + + // back returns to the meta/ listing + await modal.getByRole('button', { name: 'Back' }).click() + await expect(modal.getByRole('button', { name: 'reason_for_creation.txt' })).toBeVisible() + + // breadcrumb root button returns to the root listing, where the nested + // zip is download-only + await modal.getByRole('button', { name: '/', exact: true }).click() + await modal.getByRole('button', { name: 'sp_task_dumps' }).click() + await modal.getByRole('button', { name: 'switch_0' }).click() + const downloadPromise = page.waitForEvent('download') + await modal.getByRole('button', { name: 'dump-0.zip' }).click() + const download = await downloadPromise + expect(download.suggestedFilename()).toBe('dump-0.zip') +}) + +test('view files disabled for collecting bundle', async ({ page }) => { + await page.goto('/system/support-bundles') + + const row = page.getByRole('row', { name: 'fan failure' }) + await row.getByRole('button', { name: 'Row actions' }).click() + await expect(page.getByRole('menuitem', { name: 'View files' })).toBeDisabled() +}) + +test('create support bundle and poll to active', async ({ page }) => { + await page.goto('/system/support-bundles') + + await page.getByRole('link', { name: 'New Support Bundle' }).click() + await expect(page).toHaveURL('/system/support-bundles-new') + + await page.getByRole('textbox', { name: 'Comment' }).fill('test bundle') + await page.getByRole('button', { name: 'Create support bundle' }).click() + + await expectToast(page, 'Support bundle created') + + const table = page.getByRole('table') + await expectRowVisible(table, { state: 'collecting', Comment: 'test bundle' }) + + // mock API flips it to active after 3s; list polls every 10s while any + // bundle is transitioning + const row = table.getByRole('row', { name: 'test bundle' }) + await expect(row.getByText('active')).toBeVisible({ timeout: 20_000 }) +}) + +test('create shows insufficient capacity error in modal', async ({ page }) => { + await page.goto('/system/support-bundles-new') + + await page.getByRole('textbox', { name: 'Comment' }).fill('no space') + await page.getByRole('button', { name: 'Create support bundle' }).click() + + // error renders in the modal, which stays open + const modal = page.getByRole('dialog', { name: 'Create support bundle' }) + await expect(modal.getByText(/one per external disk/)).toBeVisible() +}) + +test('edit support bundle comment', async ({ page }) => { + await page.goto('/system/support-bundles') + + await clickRowAction(page, 'Investigating slow', 'Edit comment') + await expect(page).toHaveURL( + '/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31/edit' + ) + + const comment = page.getByRole('textbox', { name: 'Comment' }) + await expect(comment).toHaveValue('Investigating slow instance start times') + await comment.fill('Resolved, keeping for reference') + await page.getByRole('button', { name: 'Update support bundle' }).click() + + await expectToast(page, 'Support bundle updated') + await expectRowVisible(page.getByRole('table'), { + Comment: 'Resolved, keeping for reference', + }) +}) + +test('delete failed bundle removes it immediately', async ({ page }) => { + await page.goto('/system/support-bundles') + + const table = page.getByRole('table') + await expect(table.getByRole('row')).toHaveCount(4) + + await clickRowAction(page, 'failed', 'Delete') + await page.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, /deleted/) + + await expect(table.getByRole('row')).toHaveCount(3) +}) + +test('delete active bundle transitions to destroying', async ({ page }) => { + await page.goto('/system/support-bundles') + + await clickRowAction(page, 'Investigating slow', 'Delete') + await page.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, /deleted/) + + const table = page.getByRole('table') + await expectRowVisible(table, { state: 'destroying' }) + + // mock API removes the bundle 3s later; polling picks it up + await expect(table.getByRole('row', { name: 'destroying' })).toBeHidden({ + timeout: 20_000, + }) +}) + +test('delete collecting bundle warns about cancellation', async ({ page }) => { + await page.goto('/system/support-bundles') + + await clickRowAction(page, 'fan failure', 'Delete') + await expect( + page.getByText('This bundle is still being collected', { exact: false }) + ).toBeVisible() + await page.getByRole('button', { name: 'Cancel' }).click() +}) + +test('dev user gets 404 on support bundles page', async ({ browser }) => { + const page = await getPageAsUser(browser, 'Hans Jonas') + await page.goto('/system/support-bundles') + await expect(page.getByText('Page not found')).toBeVisible() +}) diff --git a/vite.config.ts b/vite.config.ts index 1c747b23e..be8f42881 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -155,6 +155,12 @@ export default defineConfig(({ mode }) => ({ target: apiMode === 'remote' ? `https://${EXT_HOST}` : 'http://localhost:12220', changeOrigin: true, }, + // Support Bundle downloads hit /experimental/v1 directly via an anchor. + // Revise this if we drop /experimental from the URL path in the future. + '/experimental': { + target: apiMode === 'remote' ? `https://${EXT_HOST}` : 'http://localhost:12220', + changeOrigin: true, + }, }, }, resolve: { tsconfigPaths: true }, From 382ed4732fb2577b79a83c3d54f5aec2829aa59c Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 4 Aug 2026 19:29:23 -0700 Subject: [PATCH 02/36] Dropped size column --- app/pages/system/SupportBundleFilesModal.tsx | 3 +- app/pages/system/SupportBundlesPage.tsx | 24 ++++------------ .../__snapshots__/path-builder.spec.ts.snap | 8 +++--- app/util/support-bundle.ts | 10 ------- mock-api/msw/handlers.ts | 28 +++---------------- mock-api/support-bundle.ts | 7 ----- test/e2e/support-bundles.e2e.ts | 4 +-- 7 files changed, 16 insertions(+), 68 deletions(-) diff --git a/app/pages/system/SupportBundleFilesModal.tsx b/app/pages/system/SupportBundleFilesModal.tsx index 4e4af0181..641816ab3 100644 --- a/app/pages/system/SupportBundleFilesModal.tsx +++ b/app/pages/system/SupportBundleFilesModal.tsx @@ -122,8 +122,9 @@ export default function SupportBundleFilesModal() { > / + {/* key by index because segment names can repeat within a path */} {dirSegments.map((segment, i) => ( - + -
{file}
- - - - ) : ( -
- -
- - {/* key by index because segment names can repeat within a path */} - {dirSegments.map((segment, i) => ( - - - {i < dirSegments.length - 1 && /} - - ))} -
-
- {lsBundleDir(entries, dir).map((entry) => - entry.isDir ? ( - - ) : isViewable(entry.path) ? ( - - ) : ( - - ) - )} -
-
- )} - - - {file ? ( - - ) : null} - - - - ) -} diff --git a/app/pages/system/SupportBundlesPage.tsx b/app/pages/system/SupportBundlesPage.tsx index 31d57a82f..3efe490e9 100644 --- a/app/pages/system/SupportBundlesPage.tsx +++ b/app/pages/system/SupportBundlesPage.tsx @@ -35,7 +35,7 @@ import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { TableActions } from '~/ui/lib/Table' -import { Tooltip } from '~/ui/lib/Tooltip' +import { TipIcon } from '~/ui/lib/TipIcon' import { truncate, Truncate } from '~/ui/lib/Truncate' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' @@ -51,15 +51,12 @@ const EmptyState = () => ( /> ) -const StateCell = ({ bundle }: { bundle: SupportBundleInfo }) => { - const badge = - if (!bundle.reasonForFailure) return badge - return ( - -
{badge}
-
- ) -} +const StateCell = ({ bundle }: { bundle: SupportBundleInfo }) => ( +
+ + {bundle.reasonForFailure && {bundle.reasonForFailure}} +
+) const colHelper = createColumnHelper() @@ -73,14 +70,14 @@ const staticColumns = [ colHelper.accessor('state', { cell: (info) => , }), - colHelper.accessor('reasonForCreation', { - header: 'Reason', - cell: (info) => , - }), colHelper.accessor('userComment', { header: 'Comment', cell: (info) => , }), + colHelper.accessor('reasonForCreation', { + header: 'Reason', + cell: (info) => , + }), colHelper.accessor('timeCreated', Columns.timeCreated), ] @@ -90,7 +87,7 @@ const POLL_INTERVAL = 10 * SEC const bundleList = getListQFn( api.supportBundleList, - {}, + { query: { sortBy: 'time_and_id_descending' } }, { refetchInterval: ({ state: { data } }) => data?.items.some((b) => b.state === 'collecting' || b.state === 'destroying') @@ -121,15 +118,6 @@ export default function SupportBundlesPage() { const makeActions = useCallback( (bundle: SupportBundleInfo): MenuAction[] => [ - { - label: 'View files', - onActivate() { - navigate(pb.supportBundleFiles({ bundleId: bundle.id })) - }, - disabled: - bundle.state !== 'active' && - 'Only bundles that have completed collection can be viewed', - }, { label: 'Download', onActivate() { diff --git a/app/routes.tsx b/app/routes.tsx index 7f5733103..5cb689269 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -275,10 +275,6 @@ export const routes = createRoutesFromElements( path=":bundleId/edit" lazy={() => import('./forms/support-bundle-edit').then(convert)} /> - import('./pages/system/SupportBundleFilesModal').then(convert)} - /> { "subnetPools": "/system/networking/subnet-pools", "subnetPoolsNew": "/system/networking/subnet-pools-new", "supportBundleEdit": "/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31/edit", - "supportBundleFiles": "/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31/files", "supportBundles": "/system/support-bundles", "supportBundlesNew": "/system/support-bundles-new", "systemUpdate": "/system/update", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index d0fa28449..523184faf 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -153,8 +153,6 @@ export const pb = { supportBundlesNew: () => '/system/support-bundles-new', supportBundleEdit: (params: PP.SupportBundle) => `${pb.supportBundles()}/${params.bundleId}/edit`, - supportBundleFiles: (params: PP.SupportBundle) => - `${pb.supportBundles()}/${params.bundleId}/files`, profile: () => '/settings/profile', sshKeys: () => '/settings/ssh-keys', diff --git a/app/util/support-bundle.spec.ts b/app/util/support-bundle.spec.ts deleted file mode 100644 index b5ee51378..000000000 --- a/app/util/support-bundle.spec.ts +++ /dev/null @@ -1,84 +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 { describe, expect, it } from 'vitest' - -import { bundleFileUrl, isViewable, lsBundleDir, parseBundleIndex } from './support-bundle' - -const index = parseBundleIndex( - [ - 'bundle_id.txt', - 'meta/', - 'meta/reason_for_creation.txt', - 'meta/report.json', - 'rack/', - 'rack/a5b3/', - 'rack/a5b3/sled/', - 'rack/a5b3/sled/0/', - 'rack/a5b3/sled/0/zpool.json', - 'reconfigurator_state.json', - '', // trailing newline produces an empty entry - ].join('\n') -) - -describe('parseBundleIndex', () => { - it('drops empty lines', () => { - expect(index).toHaveLength(10) - }) -}) - -describe('lsBundleDir', () => { - it('lists the root with dirs first', () => { - expect(lsBundleDir(index, '')).toEqual([ - { name: 'meta', path: 'meta/', isDir: true }, - { name: 'rack', path: 'rack/', isDir: true }, - { name: 'bundle_id.txt', path: 'bundle_id.txt', isDir: false }, - { - name: 'reconfigurator_state.json', - path: 'reconfigurator_state.json', - isDir: false, - }, - ]) - }) - - it('lists a subdirectory', () => { - expect(lsBundleDir(index, 'meta/')).toEqual([ - { - name: 'reason_for_creation.txt', - path: 'meta/reason_for_creation.txt', - isDir: false, - }, - { name: 'report.json', path: 'meta/report.json', isDir: false }, - ]) - }) - - it('shows only the immediate child of a deep tree', () => { - expect(lsBundleDir(index, 'rack/')).toEqual([ - { name: 'a5b3', path: 'rack/a5b3/', isDir: true }, - ]) - }) - - it('derives directories even without explicit dir entries', () => { - const noDirs = ['meta/report.json', 'bundle_id.txt'] - expect(lsBundleDir(noDirs, '')).toEqual([ - { name: 'meta', path: 'meta/', isDir: true }, - { name: 'bundle_id.txt', path: 'bundle_id.txt', isDir: false }, - ]) - }) -}) - -it('isViewable matches text-like extensions only', () => { - expect(isViewable('bundle_id.txt')).toBe(true) - expect(isViewable('meta/report.json')).toBe(true) - expect(isViewable('logs/oxz_switch/logs.zip')).toBe(false) -}) - -it('bundleFileUrl encodes slashes in the file path', () => { - expect(bundleFileUrl('abc', 'meta/report.json')).toBe( - '/experimental/v1/system/support-bundles/abc/download/meta%2Freport.json' - ) -}) diff --git a/app/util/support-bundle.ts b/app/util/support-bundle.ts index 13b7c1107..ac39e351c 100644 --- a/app/util/support-bundle.ts +++ b/app/util/support-bundle.ts @@ -5,105 +5,19 @@ * * Copyright Oxide Computer Company */ -import * as R from 'remeda' - -import { MiB } from './units' /* - * The generated API client only handles JSON responses, so the binary and - * plain-text support bundle endpoints (download, index, per-file download) are - * fetched directly. The browser sends the session cookie the same as any API - * request. + * The generated API client only handles JSON responses, so the binary bundle + * download endpoint is hit directly with an anchor. The browser sends the + * session cookie the same as any API request. */ export const bundleDownloadUrl = (bundleId: string) => `/experimental/v1/system/support-bundles/${bundleId}/download` -// file paths contain slashes, which must be encoded to fit in one path segment -export const bundleFileUrl = (bundleId: string, filePath: string) => - `${bundleDownloadUrl(bundleId)}/${encodeURIComponent(filePath)}` - -const bundleIndexUrl = (bundleId: string) => - `/experimental/v1/system/support-bundles/${bundleId}/index` - -/** - * Parse the plain-text bundle index: newline-separated zip entry names, where - * directories have a trailing slash. - */ -export const parseBundleIndex = (text: string): string[] => - text.split('\n').filter((line) => line.length > 0) - -export type BundleDirEntry = { name: string; path: string; isDir: boolean } - -/** - * List the entries directly under `dir` (`''` for the root, otherwise a path - * with a trailing slash). Directories sort before files. Directories are - * derived from deeper entries too, so the listing is correct even if the index - * omits explicit directory entries. - */ -export function lsBundleDir(entries: string[], dir: string): BundleDirEntry[] { - const children = new Map() - for (const entry of entries) { - if (!entry.startsWith(dir) || entry === dir) continue - const rest = entry.slice(dir.length) - const slash = rest.indexOf('/') - if (slash === -1) { - children.set(rest, { name: rest, path: entry, isDir: false }) - } else { - const name = rest.slice(0, slash) - children.set(`${name}/`, { name, path: `${dir}${name}/`, isDir: true }) - } - } - return R.sortBy( - [...children.values()], - (e) => (e.isDir ? 0 : 1), - (e) => e.name - ) -} - -/** Files we render inline. Everything else (e.g., nested log zips) is download-only. */ -export const isViewable = (filePath: string) => /\.(txt|json|log)$/.test(filePath) - export function triggerDownload(url: string, filename: string) { const link = document.createElement('a') link.href = url link.download = filename link.click() } - -export const MAX_INLINE_FILE_BYTES = 1 * MiB - -export const bundleIndexQuery = (bundleId: string) => ({ - queryKey: ['supportBundleIndex', bundleId], - queryFn: async ({ signal }: { signal: AbortSignal }) => { - const res = await fetch(bundleIndexUrl(bundleId), { signal }) - if (!res.ok) throw new Error(`Error fetching bundle index (${res.status})`) - return parseBundleIndex(await res.text()) - }, - // bundle contents never change once collection is complete - staleTime: Infinity, -}) - -export type BundleFileContent = { kind: 'text'; text: string } | { kind: 'tooLarge' } - -export const bundleFileQuery = (bundleId: string, filePath: string) => ({ - queryKey: ['supportBundleFile', bundleId, filePath], - queryFn: async ({ signal }: { signal: AbortSignal }): Promise => { - const res = await fetch(bundleFileUrl(bundleId, filePath), { signal }) - if (!res.ok) throw new Error(`Error fetching file (${res.status})`) - if (Number(res.headers.get('content-length')) > MAX_INLINE_FILE_BYTES) { - await res.body?.cancel() - return { kind: 'tooLarge' } - } - let text = await res.text() - if (filePath.endsWith('.json')) { - try { - text = JSON.stringify(JSON.parse(text), null, 2) - } catch { - // not valid JSON, show it raw - } - } - return { kind: 'text', text } - }, - staleTime: Infinity, -}) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 584a30ac0..eba8715d9 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -36,7 +36,6 @@ import { commaSeries } from '~/util/str' import { GiB } from '~/util/units' import { defaultSilo, toIdp } from '../silo' -import { supportBundleFiles, supportBundleIndexText } from '../support-bundle' import { getTimestamps } from '../util' import { defaultFirewallRules } from '../vpc' import { @@ -2015,7 +2014,15 @@ export const handlers = makeHandlers({ supportBundleList({ query, cookies }) { requireFleetViewer(cookies) - return paginated(query, db.supportBundles) + const bundles = + query.sortBy === 'time_and_id_descending' + ? R.sortBy( + db.supportBundles, + [(b) => b.time_created, 'desc'], + [(b) => b.id, 'desc'] + ) + : db.supportBundles + return paginated(query, bundles) }, supportBundleView({ path, cookies }) { requireFleetViewer(cookies) @@ -2105,37 +2112,6 @@ export const handlers = makeHandlers({ }, }) }, - // @ts-expect-error Response passthrough, see supportBundleDownload - supportBundleIndex({ path, cookies }) { - requireFleetViewer(cookies) - const bundle = lookupById(db.supportBundles, path.bundleId) - if (bundle.state !== 'active') { - throw invalidRequest('Cannot download bundle in non-active state') - } - return new HttpResponse(supportBundleIndexText, { - headers: { 'Content-Type': 'text/plain' }, - }) - }, - // @ts-expect-error Response passthrough, see supportBundleDownload - supportBundleDownloadFile({ path, cookies }) { - requireFleetViewer(cookies) - const bundle = lookupById(db.supportBundles, path.bundleId) - if (bundle.state !== 'active') { - throw invalidRequest('Cannot download bundle in non-active state') - } - // the client encodes slashes in the file path so it fits in one segment - const file = decodeURIComponent(path.file) - const content = supportBundleFiles[file] - if (content === undefined) throw notFoundErr(`file '${file}' in support bundle`) - if (file.endsWith('.zip')) { - const emptyZip = new Uint8Array(22) - emptyZip.set([0x50, 0x4b, 0x05, 0x06]) - return new HttpResponse(emptyZip, { - headers: { 'Content-Type': 'application/zip' }, - }) - } - return new HttpResponse(content, { headers: { 'Content-Type': 'text/plain' } }) - }, switchList: ({ query, cookies }) => { requireFleetViewer(cookies) return paginated(query, db.switches) @@ -2849,8 +2825,10 @@ export const handlers = makeHandlers({ siloUserView: NotImplemented, sledListUninitialized: NotImplemented, sledSetProvisionPolicy: NotImplemented, + supportBundleDownloadFile: NotImplemented, supportBundleHead: NotImplemented, supportBundleHeadFile: NotImplemented, + supportBundleIndex: NotImplemented, switchView: NotImplemented, systemNetworkingSettingsUpdate: NotImplemented, systemNetworkingSettingsView: NotImplemented, diff --git a/mock-api/support-bundle.ts b/mock-api/support-bundle.ts index f3c35f2a6..493c839f2 100644 --- a/mock-api/support-bundle.ts +++ b/mock-api/support-bundle.ts @@ -5,8 +5,6 @@ * * Copyright Oxide Computer Company */ -import * as R from 'remeda' - import type { SupportBundleInfo } from '@oxide/api' import type { Json } from './json-type' @@ -30,112 +28,9 @@ export const supportBundles: Json[] = [ { id: 'bfc48b0c-68bb-4366-98a7-c15e0afe3a7c', reason_for_creation: 'Created by external API', + // verbatim FAILURE_REASON_NO_DATASET from omicron reason_for_failure: 'Allocated dataset no longer exists', state: 'failed', time_created: new Date('2025-07-28T11:00:00Z').toISOString(), }, ] - -/** - * One ereport JSON body as the collector writes it: the `Ereport` struct with - * its id, data, and reporter fields flattened to the top level, serialized - * compactly. Stored at ereports/{part}-{serial}/{restart_id}/{ena}.json, with - * the ENA hex-formatted in the filename but numeric in the body. - * https://github.com/oxidecomputer/omicron/blob/f0c48d9/support-bundle-collection/src/steps/ereports.rs#L133 - */ -const ereport = ( - restartId: string, - ena: number, - cls: string, - report: Record, - reporter: Record, - serialNumber = 'BRM42220031', - partNumber = '9130000019' -) => - JSON.stringify({ - restart_id: restartId, - ena, - time_collected: '2025-07-29T18:04:12.331829Z', - collector_id: '10a7c394-5c79-4bba-b295-81179efc3086', - serial_number: serialNumber, - part_number: partNumber, - class: cls, - ...report, - ...reporter, - marked_seen_in: null, - }) - -const sledSpRestart = '3f7d938a-71b0-4707-b020-ba05526e84ee' -const switchSpRestart = '89b5774e-31f6-4137-bf85-037f1b4a4ba4' -const hostOsRestart = 'e4888dc8-69e2-499d-a8e3-9be74d4950ed' - -/** - * Contents served by the index and per-file download handlers for any active - * bundle. A tiny slice of a real bundle's layout, including a nested zip to - * exercise the download-only path in the file viewer. - */ -export const supportBundleFiles: Record = { - 'bundle_id.txt': 'ccdac005-66a8-4921-9e8b-30531c359c31', - [`ereports/9130000019-BRM42220031/${sledSpRestart}/0x1.json`]: ereport( - sledSpRestart, - 1, - 'ereport.sp.fan.speed_out_of_range', - { fan: 2, rpm: 2113, threshold_rpm: 2500 }, - { reporter: 'Sp', sp_type: 'sled', slot: 8 } - ), - [`ereports/9130000019-BRM42220031/${sledSpRestart}/0x2.json`]: ereport( - sledSpRestart, - 2, - 'ereport.sp.thermal.sensor_read_timeout', - { sensor: 't_dimm_b0' }, - { reporter: 'Sp', sp_type: 'sled', slot: 8 } - ), - // host OS ereport from the same sled, so this board dir has two restart dirs - [`ereports/9130000019-BRM42220031/${hostOsRestart}/0x1.json`]: ereport( - hostOsRestart, - 1, - 'ereport.host.zfs.checksum_errors', - { pool: 'oxp_ccdac005', errors: 3 }, - { reporter: 'HostOs', sled: '6e06fb3d-b0cf-4236-a736-18875c020a01', slot: 8 } - ), - [`ereports/9130000006-BRM41000555/${switchSpRestart}/0x1.json`]: ereport( - switchSpRestart, - 1, - 'ereport.sp.power.rail_fault', - { rail: 'v12_sys_a2' }, - { reporter: 'Sp', sp_type: 'switch', slot: 1 }, - 'BRM41000555', - '9130000006' - ), - 'meta/reason_for_creation.txt': 'Created by external API', - 'meta/report.json': JSON.stringify( - { - bundle: 'ccdac005-66a8-4921-9e8b-30531c359c31', - steps: [ - { name: 'reconfigurator state', duration_ms: 132 }, - { name: 'host info: sled 0', duration_ms: 4189 }, - ], - }, - null, - 2 - ), - 'rack/a5b3fd8a/sled/0/zpool.json': JSON.stringify({ pools: ['oxp_ccdac005'] }), - 'reconfigurator_state.json': JSON.stringify({ blueprint: 'b6034a15' }), - 'sp_task_dumps/switch_0/dump-0.zip': '', -} - -/** Zip entry list in the format the real index endpoint returns: sorted names, one per line, dirs with trailing slashes */ -export const supportBundleIndexText = R.pipe( - Object.keys(supportBundleFiles), - R.flatMap((path) => { - const entries = [path] - // add an explicit entry for each ancestor directory - const segments = path.split('/') - for (let i = 1; i < segments.length; i++) { - entries.push(`${segments.slice(0, i).join('/')}/`) - } - return entries - }), - R.unique(), - R.sortBy((x) => x) -).join('\n') diff --git a/test/e2e/support-bundles.e2e.ts b/test/e2e/support-bundles.e2e.ts index 63f48f038..cc9416f71 100644 --- a/test/e2e/support-bundles.e2e.ts +++ b/test/e2e/support-bundles.e2e.ts @@ -29,6 +29,12 @@ test('support bundle list', async ({ page }) => { }) await expectRowVisible(table, { state: 'failed' }) + // sorted newest first: collecting (Aug 1), active (Jul 30), failed (Jul 28) + const rows = table.getByRole('row') + await expect(rows.nth(1)).toContainText('collecting') + await expect(rows.nth(2)).toContainText('active') + await expect(rows.nth(3)).toContainText('failed') + // docs popover links to the troubleshooting guide. filter to external links // because the sidebar and breadcrumb links have the same name await page.getByRole('button', { name: 'Learn about support bundles' }).click() @@ -41,11 +47,11 @@ test('support bundle list', async ({ page }) => { ) }) -test('failed bundle state badge shows failure reason on hover', async ({ page }) => { +test('failed bundle shows failure reason on tip icon hover', async ({ page }) => { await page.goto('/system/support-bundles') const row = page.getByRole('row', { name: 'failed' }) - await row.getByText('failed').hover() + await row.getByRole('button', { name: 'Tip' }).hover() await expect(page.getByRole('tooltip')).toHaveText('Allocated dataset no longer exists') }) @@ -74,57 +80,6 @@ test('download only available for active bundles', async ({ page }) => { ) }) -test('view files in an active bundle', async ({ page }) => { - await page.goto('/system/support-bundles') - - await clickRowAction(page, 'Investigating slow', 'View files') - await expect(page).toHaveURL( - '/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31/files' - ) - - const modal = page.getByRole('dialog', { name: 'Support bundle files' }) - await expect(modal).toBeVisible() - - // root listing: dirs first, then files - await expect(modal.getByRole('button', { name: 'meta' })).toBeVisible() - await expect(modal.getByRole('button', { name: 'bundle_id.txt' })).toBeVisible() - - // Download bundle button at top fetches the whole bundle zip - const bundleDownloadPromise = page.waitForEvent('download') - await modal.getByRole('button', { name: 'Download bundle', exact: true }).click() - const bundleDownload = await bundleDownloadPromise - expect(bundleDownload.suggestedFilename()).toBe( - 'support-bundle-ccdac005-66a8-4921-9e8b-30531c359c31.zip' - ) - - // drill into meta/ and view a JSON file inline - await modal.getByRole('button', { name: 'meta', exact: true }).click() - await modal.getByRole('button', { name: 'report.json' }).click() - await expect(modal.getByText('"host info: sled 0"')).toBeVisible() - - // back returns to the meta/ listing - await modal.getByRole('button', { name: 'Back' }).click() - await expect(modal.getByRole('button', { name: 'reason_for_creation.txt' })).toBeVisible() - - // breadcrumb root button returns to the root listing, where the nested - // zip is download-only - await modal.getByRole('button', { name: '/', exact: true }).click() - await modal.getByRole('button', { name: 'sp_task_dumps' }).click() - await modal.getByRole('button', { name: 'switch_0' }).click() - const downloadPromise = page.waitForEvent('download') - await modal.getByRole('button', { name: 'dump-0.zip' }).click() - const download = await downloadPromise - expect(download.suggestedFilename()).toBe('dump-0.zip') -}) - -test('view files disabled for collecting bundle', async ({ page }) => { - await page.goto('/system/support-bundles') - - const row = page.getByRole('row', { name: 'fan failure' }) - await row.getByRole('button', { name: 'Row actions' }).click() - await expect(page.getByRole('menuitem', { name: 'View files' })).toBeDisabled() -}) - test('create support bundle and poll to active', async ({ page }) => { await page.goto('/system/support-bundles') From a406be195e3333378e6c7ec4cc09655a5e9ad032 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 6 Aug 2026 10:20:44 -0700 Subject: [PATCH 05/36] simplify e2e test --- test/e2e/support-bundles.e2e.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/test/e2e/support-bundles.e2e.ts b/test/e2e/support-bundles.e2e.ts index cc9416f71..6ac5c7704 100644 --- a/test/e2e/support-bundles.e2e.ts +++ b/test/e2e/support-bundles.e2e.ts @@ -69,15 +69,9 @@ test('download only available for active bundles', async ({ page }) => { ) await page.keyboard.press('Escape') - // active bundle: download works and produces a zip const activeRow = page.getByRole('row', { name: 'Investigating slow' }) await activeRow.getByRole('button', { name: 'Row actions' }).click() - const downloadPromise = page.waitForEvent('download') - await page.getByRole('menuitem', { name: 'Download' }).click() - const download = await downloadPromise - expect(download.suggestedFilename()).toBe( - 'support-bundle-ccdac005-66a8-4921-9e8b-30531c359c31.zip' - ) + await expect(page.getByRole('menuitem', { name: 'Download' })).toBeEnabled() }) test('create support bundle and poll to active', async ({ page }) => { From 6653f251ad1c340591d2567e4d84249d9d3205b0 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 6 Aug 2026 10:57:33 -0700 Subject: [PATCH 06/36] copy change --- app/pages/system/SupportBundlesPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/system/SupportBundlesPage.tsx b/app/pages/system/SupportBundlesPage.tsx index 3efe490e9..7d01b58d5 100644 --- a/app/pages/system/SupportBundlesPage.tsx +++ b/app/pages/system/SupportBundlesPage.tsx @@ -179,7 +179,7 @@ export default function SupportBundlesPage() { } - summary="Support bundles capture diagnostic data from the rack to share with Oxide Support. They consume rack storage, so delete them when no longer needed." + summary="Support bundles capture diagnostic data from the rack to share with Oxide Support." links={[docLinks.supportBundles]} /> From b6a34eee7a0dc67db097999d9b96a435de6b4757 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 6 Aug 2026 17:00:14 -0700 Subject: [PATCH 07/36] pre-review tweaks --- app/api/util.ts | 8 ++++++++ app/forms/support-bundle-create.tsx | 17 +++++++++-------- app/forms/support-bundle-edit.tsx | 16 +++++++++++----- app/pages/system/SupportBundlesPage.tsx | 5 +++-- mock-api/msw/handlers.ts | 3 ++- test/e2e/support-bundles.e2e.ts | 4 ++-- 6 files changed, 35 insertions(+), 18 deletions(-) diff --git a/app/api/util.ts b/app/api/util.ts index f3091f865..4f5971743 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -46,6 +46,14 @@ export const MIN_DISK_SIZE_GiB = 1 */ export const MAX_DISK_SIZE_GiB = 1023 +// the API only enforces this on update, but apply it at create time too so +// the comment doesn't become uneditable later +// https://github.com/oxidecomputer/omicron/blob/99249b4/nexus/db-queries/src/db/datastore/support_bundle.rs#L736-L742 +export const MAX_BUNDLE_COMMENT_BYTES = 4096 + +/** Nexus limits by UTF-8 byte length, not JS string length */ +export const utf8ByteLength = (s: string) => new TextEncoder().encode(s).length + type PortRange = [number, number] /** Parse '1234' into [1234, 1234] and '80-100' into [80, 100] */ diff --git a/app/forms/support-bundle-create.tsx b/app/forms/support-bundle-create.tsx index de28a664f..0c2f1ae28 100644 --- a/app/forms/support-bundle-create.tsx +++ b/app/forms/support-bundle-create.tsx @@ -8,7 +8,13 @@ import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' -import { api, queryClient, useApiMutation } from '@oxide/api' +import { + api, + MAX_BUNDLE_COMMENT_BYTES, + queryClient, + useApiMutation, + utf8ByteLength, +} from '@oxide/api' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' @@ -17,11 +23,6 @@ import { addToast } from '~/stores/toast' import { Message } from '~/ui/lib/Message' import { pb } from '~/util/path-builder' -// the API only enforces this on update, but apply it at create time too so -// the comment doesn't become uneditable later -// https://github.com/oxidecomputer/omicron/blob/99249b4/nexus/db-queries/src/db/datastore/support_bundle.rs#L736-L742 -export const MAX_COMMENT_LENGTH = 4096 - const defaultValues = { userComment: '' } export const handle = titleCrumb('New support bundle') @@ -65,8 +66,8 @@ export default function CreateSupportBundleSideModalForm() { rows={4} control={form.control} validate={(value) => - value.length > MAX_COMMENT_LENGTH - ? `Comment cannot exceed ${MAX_COMMENT_LENGTH} characters` + utf8ByteLength(value) > MAX_BUNDLE_COMMENT_BYTES + ? `Comment cannot exceed ${MAX_BUNDLE_COMMENT_BYTES} bytes` : true } /> diff --git a/app/forms/support-bundle-edit.tsx b/app/forms/support-bundle-edit.tsx index 776ee7f5d..3a27b59f7 100644 --- a/app/forms/support-bundle-edit.tsx +++ b/app/forms/support-bundle-edit.tsx @@ -8,7 +8,15 @@ import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router' -import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' +import { + api, + MAX_BUNDLE_COMMENT_BYTES, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + utf8ByteLength, +} from '@oxide/api' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' @@ -18,8 +26,6 @@ import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' -import { MAX_COMMENT_LENGTH } from './support-bundle-create' - const bundleView = ({ bundleId }: PP.SupportBundle) => q(api.supportBundleView, { path: { bundleId } }) @@ -73,8 +79,8 @@ export default function EditSupportBundleSideModalForm() { rows={4} control={form.control} validate={(value) => - value.length > MAX_COMMENT_LENGTH - ? `Comment cannot exceed ${MAX_COMMENT_LENGTH} characters` + utf8ByteLength(value) > MAX_BUNDLE_COMMENT_BYTES + ? `Comment cannot exceed ${MAX_BUNDLE_COMMENT_BYTES} bytes` : true } /> diff --git a/app/pages/system/SupportBundlesPage.tsx b/app/pages/system/SupportBundlesPage.tsx index 7d01b58d5..0532552b3 100644 --- a/app/pages/system/SupportBundlesPage.tsx +++ b/app/pages/system/SupportBundlesPage.tsx @@ -82,7 +82,6 @@ const staticColumns = [ ] const SEC = 1000 // ms -/** Poll fast while any bundle is in a transitional state */ const POLL_INTERVAL = 10 * SEC const bundleList = getListQFn( @@ -111,8 +110,10 @@ export default function SupportBundlesPage() { const { mutateAsync: deleteBundle } = useApiMutation(api.supportBundleDelete, { onSuccess(_data, variables) { queryClient.invalidateEndpoint('supportBundleList') + // "deleting" rather than "deleted" because the bundle sits in state + // 'destroying' until a background task frees its backing storage // prettier-ignore - addToast(<>Support bundle {truncate(variables.path.bundleId, 14, 'middle')} deleted) + addToast(<>Deleting support bundle {truncate(variables.path.bundleId, 14, 'middle')}) }, }) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index eba8715d9..9e1fb6943 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -2069,7 +2069,8 @@ export const handlers = makeHandlers({ requireFleetAdmin(cookies) const bundle = lookupById(db.supportBundles, path.bundleId) // https://github.com/oxidecomputer/omicron/blob/99249b4/nexus/db-queries/src/db/datastore/support_bundle.rs#L736-L742 - if (body.user_comment && body.user_comment.length > 4096) { + // byte length, not string length, to match Nexus + if (body.user_comment && new TextEncoder().encode(body.user_comment).length > 4096) { throw invalidRequest('User comment cannot exceed 4096 bytes') } bundle.user_comment = body.user_comment diff --git a/test/e2e/support-bundles.e2e.ts b/test/e2e/support-bundles.e2e.ts index 6ac5c7704..24f4bf34c 100644 --- a/test/e2e/support-bundles.e2e.ts +++ b/test/e2e/support-bundles.e2e.ts @@ -132,7 +132,7 @@ test('delete failed bundle removes it immediately', async ({ page }) => { await clickRowAction(page, 'failed', 'Delete') await page.getByRole('button', { name: 'Confirm' }).click() - await expectToast(page, /deleted/) + await expectToast(page, /Deleting support bundle/) await expect(table.getByRole('row')).toHaveCount(3) }) @@ -142,7 +142,7 @@ test('delete active bundle transitions to destroying', async ({ page }) => { await clickRowAction(page, 'Investigating slow', 'Delete') await page.getByRole('button', { name: 'Confirm' }).click() - await expectToast(page, /deleted/) + await expectToast(page, /Deleting support bundle/) const table = page.getByRole('table') await expectRowVisible(table, { state: 'destroying' }) From 8a6c9451b3c57a733beada073d542f126fda08e4 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 18 Aug 2026 11:41:07 +0100 Subject: [PATCH 08/36] Support bundle detail modal (#3324) --- .../form/fields/BundleCommentField.tsx | 34 ++++ app/forms/support-bundle-create.tsx | 24 +-- app/forms/support-bundle-edit.tsx | 89 --------- app/pages/system/SupportBundleDetail.tsx | 177 ++++++++++++++++++ app/pages/system/SupportBundlesPage.tsx | 19 +- app/routes.tsx | 4 +- app/table/cells/DescriptionCell.tsx | 2 +- .../__snapshots__/path-builder.spec.ts.snap | 2 +- app/util/path-builder.spec.ts | 2 +- app/util/path-builder.ts | 3 +- app/util/support-bundle.ts | 43 ++++- mock-api/msw/handlers.ts | 28 ++- mock-api/support-bundle.ts | 29 +++ test/e2e/support-bundles.e2e.ts | 80 +++++++- 14 files changed, 404 insertions(+), 132 deletions(-) create mode 100644 app/components/form/fields/BundleCommentField.tsx delete mode 100644 app/forms/support-bundle-edit.tsx create mode 100644 app/pages/system/SupportBundleDetail.tsx diff --git a/app/components/form/fields/BundleCommentField.tsx b/app/components/form/fields/BundleCommentField.tsx new file mode 100644 index 000000000..eeef92cac --- /dev/null +++ b/app/components/form/fields/BundleCommentField.tsx @@ -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 ( + + utf8ByteLength(value) > MAX_BUNDLE_COMMENT_BYTES + ? `Comment cannot exceed ${MAX_BUNDLE_COMMENT_BYTES} bytes` + : true + } + /> + ) +} diff --git a/app/forms/support-bundle-create.tsx b/app/forms/support-bundle-create.tsx index 0c2f1ae28..b274dfb1f 100644 --- a/app/forms/support-bundle-create.tsx +++ b/app/forms/support-bundle-create.tsx @@ -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' @@ -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." /> - - utf8ByteLength(value) > MAX_BUNDLE_COMMENT_BYTES - ? `Comment cannot exceed ${MAX_BUNDLE_COMMENT_BYTES} bytes` - : true - } - /> + ) } diff --git a/app/forms/support-bundle-edit.tsx b/app/forms/support-bundle-edit.tsx deleted file mode 100644 index 3a27b59f7..000000000 --- a/app/forms/support-bundle-edit.tsx +++ /dev/null @@ -1,89 +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, type LoaderFunctionArgs } from 'react-router' - -import { - api, - MAX_BUNDLE_COMMENT_BYTES, - q, - queryClient, - useApiMutation, - usePrefetchedQuery, - utf8ByteLength, -} from '@oxide/api' - -import { TextField } from '~/components/form/fields/TextField' -import { SideModalForm } from '~/components/form/SideModalForm' -import { titleCrumb } from '~/hooks/use-crumbs' -import { getSupportBundleSelector, useSupportBundleSelector } from '~/hooks/use-params' -import { addToast } from '~/stores/toast' -import { pb } from '~/util/path-builder' -import type * as PP from '~/util/path-params' - -const bundleView = ({ bundleId }: PP.SupportBundle) => - q(api.supportBundleView, { path: { bundleId } }) - -export async function clientLoader({ params }: LoaderFunctionArgs) { - const selector = getSupportBundleSelector(params) - await queryClient.prefetchQuery(bundleView(selector)) - return null -} - -export const handle = titleCrumb('Edit support bundle') - -export default function EditSupportBundleSideModalForm() { - const navigate = useNavigate() - const selector = useSupportBundleSelector() - - const { data: bundle } = usePrefetchedQuery(bundleView(selector)) - - const form = useForm({ defaultValues: { userComment: bundle.userComment || '' } }) - - 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 ( - { - editBundle.mutate({ - path: { bundleId: selector.bundleId }, - body: { userComment: userComment || null }, - }) - }} - loading={editBundle.isPending} - submitError={editBundle.error} - > - - utf8ByteLength(value) > MAX_BUNDLE_COMMENT_BYTES - ? `Comment cannot exceed ${MAX_BUNDLE_COMMENT_BYTES} bytes` - : true - } - /> - - ) -} diff --git a/app/pages/system/SupportBundleDetail.tsx b/app/pages/system/SupportBundleDetail.tsx new file mode 100644 index 000000000..d57b59ad2 --- /dev/null +++ b/app/pages/system/SupportBundleDetail.tsx @@ -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({ + query, + children, +}: { + query: UseQueryResult + children: (data: T) => ReactNode +}) { + if (query.isPending) return + if (query.isError) return + 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 ( + + {truncate(bundle.id, 14, 'middle')} + + } + onDismiss={onDismiss} + onSubmit={({ userComment }) => { + editBundle.mutate({ + path: { bundleId }, + body: { userComment: userComment || null }, + }) + }} + loading={editBundle.isPending} + submitError={editBundle.error} + > +
+ + + + + + {bundle.reasonForFailure && ( + + + + )} + + + + + {isActive && ( + + + {(entries) => + // directory entries have a trailing slash; count files only + entries.filter((e) => !e.endsWith('/')).length.toLocaleString() + } + + + )} + {isActive && ( + + {(bytes) => } + + )} + + +
+ + + +
+ ) +} diff --git a/app/pages/system/SupportBundlesPage.tsx b/app/pages/system/SupportBundlesPage.tsx index 0532552b3..35017d1cf 100644 --- a/app/pages/system/SupportBundlesPage.tsx +++ b/app/pages/system/SupportBundlesPage.tsx @@ -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' @@ -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 = () => ( ( - + + {truncate(info.getValue(), 14, 'middle')} + ), }), colHelper.accessor('state', { @@ -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 })) }, }, { diff --git a/app/routes.tsx b/app/routes.tsx index 5cb689269..9b30280ac 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -272,8 +272,8 @@ export const routes = createRoutesFromElements( import('./pages/system/SupportBundlesPage').then(convert)}> import('./forms/support-bundle-edit').then(convert)} + path=":bundleId" + lazy={() => import('./pages/system/SupportBundleDetail').then(convert)} /> text ? ( - + ) : ( ) diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 278c1815f..559def2ff 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -897,7 +897,7 @@ exports[`breadcrumbs 2`] = ` "path": "/system/networking/", }, ], - "supportBundleEdit (/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31/edit)": [ + "supportBundle (/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31)": [ { "label": "Support Bundles", "path": "/system/support-bundles", diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 4f2807827..63a7e6d98 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -115,7 +115,7 @@ test('path builder', () => { "subnetPoolMemberAdd": "/system/networking/subnet-pools/sp/members-add", "subnetPools": "/system/networking/subnet-pools", "subnetPoolsNew": "/system/networking/subnet-pools-new", - "supportBundleEdit": "/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31/edit", + "supportBundle": "/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31", "supportBundles": "/system/support-bundles", "supportBundlesNew": "/system/support-bundles-new", "systemUpdate": "/system/update", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 523184faf..294557f9b 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -151,8 +151,7 @@ export const pb = { supportBundles: () => '/system/support-bundles', supportBundlesNew: () => '/system/support-bundles-new', - supportBundleEdit: (params: PP.SupportBundle) => - `${pb.supportBundles()}/${params.bundleId}/edit`, + supportBundle: (params: PP.SupportBundle) => `${pb.supportBundles()}/${params.bundleId}`, profile: () => '/settings/profile', sshKeys: () => '/settings/ssh-keys', diff --git a/app/util/support-bundle.ts b/app/util/support-bundle.ts index ac39e351c..8ed7ca2c9 100644 --- a/app/util/support-bundle.ts +++ b/app/util/support-bundle.ts @@ -5,6 +5,7 @@ * * Copyright Oxide Computer Company */ +import { queryOptions } from '@tanstack/react-query' /* * The generated API client only handles JSON responses, so the binary bundle @@ -15,9 +16,49 @@ export const bundleDownloadUrl = (bundleId: string) => `/experimental/v1/system/support-bundles/${bundleId}/download` -export function triggerDownload(url: string, filename: string) { +const bundleIndexUrl = (bundleId: string) => + `/experimental/v1/system/support-bundles/${bundleId}/index` + +export const DOWNLOAD_DISABLED_REASON = + 'Only bundles that have completed collection can be downloaded' + +function triggerDownload(url: string, filename: string) { const link = document.createElement('a') link.href = url link.download = filename link.click() } + +export function downloadBundle(bundleId: string) { + triggerDownload(bundleDownloadUrl(bundleId), `support-bundle-${bundleId}.zip`) +} + +/** + * The index is the bundle zip's entry names, one per line, where directory + * entries have a trailing slash. + * https://github.com/oxidecomputer/omicron/blob/99249b4/sled-agent/src/support_bundle/storage.rs#L1029-L1035 + */ +export const bundleIndexQuery = (bundleId: string) => + queryOptions({ + queryKey: ['supportBundleIndex', bundleId], + queryFn: async ({ signal }) => { + const res = await fetch(bundleIndexUrl(bundleId), { signal }) + if (!res.ok) throw new Error(`Error fetching bundle index (${res.status})`) + const text = await res.text() + return text.split('\n').filter((line) => line.length > 0) + }, + // bundle contents never change once collection is complete + staleTime: Infinity, + }) + +/** Total bundle size from `Content-Length` on a HEAD of the download endpoint */ +export const bundleSizeQuery = (bundleId: string) => + queryOptions({ + queryKey: ['supportBundleSize', bundleId], + queryFn: async ({ signal }) => { + const res = await fetch(bundleDownloadUrl(bundleId), { method: 'HEAD', signal }) + if (!res.ok) throw new Error(`Error fetching bundle size (${res.status})`) + return Number(res.headers.get('content-length')) + }, + staleTime: Infinity, + }) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 9e1fb6943..6e12b8b6b 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -36,6 +36,7 @@ import { commaSeries } from '~/util/str' import { GiB } from '~/util/units' import { defaultSilo, toIdp } from '../silo' +import { SUPPORT_BUNDLE_SIZE, supportBundleIndexText } from '../support-bundle' import { getTimestamps } from '../util' import { defaultFirewallRules } from '../vpc' import { @@ -2113,6 +2114,31 @@ export const handlers = makeHandlers({ }, }) }, + // @ts-expect-error Response passthrough, see supportBundleDownload + supportBundleHead({ path, cookies }) { + requireFleetViewer(cookies) + const bundle = lookupById(db.supportBundles, path.bundleId) + if (bundle.state !== 'active') { + throw invalidRequest('Cannot download bundle in non-active state') + } + return new HttpResponse(null, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Length': SUPPORT_BUNDLE_SIZE.toString(), + }, + }) + }, + // @ts-expect-error Response passthrough, see supportBundleDownload + supportBundleIndex({ path, cookies }) { + requireFleetViewer(cookies) + const bundle = lookupById(db.supportBundles, path.bundleId) + if (bundle.state !== 'active') { + throw invalidRequest('Cannot download bundle in non-active state') + } + return new HttpResponse(supportBundleIndexText, { + headers: { 'Content-Type': 'text/plain' }, + }) + }, switchList: ({ query, cookies }) => { requireFleetViewer(cookies) return paginated(query, db.switches) @@ -2827,9 +2853,7 @@ export const handlers = makeHandlers({ sledListUninitialized: NotImplemented, sledSetProvisionPolicy: NotImplemented, supportBundleDownloadFile: NotImplemented, - supportBundleHead: NotImplemented, supportBundleHeadFile: NotImplemented, - supportBundleIndex: NotImplemented, switchView: NotImplemented, systemNetworkingSettingsUpdate: NotImplemented, systemNetworkingSettingsView: NotImplemented, diff --git a/mock-api/support-bundle.ts b/mock-api/support-bundle.ts index 493c839f2..8f656a1e0 100644 --- a/mock-api/support-bundle.ts +++ b/mock-api/support-bundle.ts @@ -34,3 +34,32 @@ export const supportBundles: Json[] = [ time_created: new Date('2025-07-28T11:00:00Z').toISOString(), }, ] + +/** + * Served by the index handler for any active bundle: zip entry names in the + * format the real endpoint returns — sorted, one per line, directories with + * trailing slashes. A tiny slice of a real bundle's layout. 8 files. + */ +export const supportBundleIndexText = [ + 'bundle_id.txt', + 'ereports/', + 'ereports/9130000019-BRM42220031/', + 'ereports/9130000019-BRM42220031/3f7d938a-71b0-4707-b020-ba05526e84ee/', + 'ereports/9130000019-BRM42220031/3f7d938a-71b0-4707-b020-ba05526e84ee/0x1.json', + 'ereports/9130000019-BRM42220031/3f7d938a-71b0-4707-b020-ba05526e84ee/0x2.json', + 'meta/', + 'meta/reason_for_creation.txt', + 'meta/report.json', + 'rack/', + 'rack/a5b3fd8a/', + 'rack/a5b3fd8a/sled/', + 'rack/a5b3fd8a/sled/0/', + 'rack/a5b3fd8a/sled/0/zpool.json', + 'reconfigurator_state.json', + 'sp_task_dumps/', + 'sp_task_dumps/switch_0/', + 'sp_task_dumps/switch_0/dump-0.zip', +].join('\n') + +// Fake `Content-Length` for the HEAD handler +export const SUPPORT_BUNDLE_SIZE = 2_576_980_378 diff --git a/test/e2e/support-bundles.e2e.ts b/test/e2e/support-bundles.e2e.ts index 24f4bf34c..2100207c3 100644 --- a/test/e2e/support-bundles.e2e.ts +++ b/test/e2e/support-bundles.e2e.ts @@ -74,6 +74,80 @@ test('download only available for active bundles', async ({ page }) => { await expect(page.getByRole('menuitem', { name: 'Download' })).toBeEnabled() }) +test('bundle detail modal shows metadata for active bundle', async ({ page }) => { + await page.goto('/system/support-bundles') + + // ID cell links to the detail modal + await page.getByRole('link', { name: 'ccdac0…359c31' }).click() + await expect(page).toHaveURL( + '/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31' + ) + + const modal = page.getByRole('dialog', { name: 'Support bundle' }) + await expect(modal.getByText(/^ccdac005/)).toBeVisible() + await expect(modal.getByText('active')).toBeVisible() + + // file count comes from the index endpoint, size from a HEAD of download + await expect(modal.getByText('8', { exact: true })).toBeVisible() + await expect(modal.getByText('2.4 GiB')).toBeVisible() + + await expect(modal.getByRole('button', { name: 'Download bundle' })).toBeEnabled() + + // comment is editable in place; save is disabled until it changes + await expect(modal.getByRole('textbox', { name: 'Comment' })).toHaveValue( + 'Investigating slow instance start times' + ) + await expect(modal.getByRole('button', { name: 'Update comment' })).toBeDisabled() + + await modal.getByRole('button', { name: 'Cancel' }).click() + await expect(modal).toBeHidden() + await expect(page).toHaveURL('/system/support-bundles') +}) + +test('bundle detail modal for failed bundle', async ({ page }) => { + await page.goto('/system/support-bundles') + + await page.getByRole('link', { name: 'bfc48b…fe3a7c' }).click() + + const modal = page.getByRole('dialog', { name: 'Support bundle' }) + await expect(modal.getByText('failed')).toBeVisible() + await expect(modal.getByText(/Allocated dataset/)).toBeVisible() + + // no zip exists, so no file count or size rows and no download + await expect(modal.getByText('Files')).toBeHidden() + await expect(modal.getByText('Size')).toBeHidden() + const download = modal.getByRole('button', { name: 'Download bundle' }) + await expect(download).toBeDisabled() + await download.hover() + // getByText rather than role=tooltip: the open modal makes the portaled + // tooltip aria-hidden, so it has no role, but it is still visible + await expect( + page.getByText('Only bundles that have completed collection can be downloaded') + ).toBeVisible() +}) + +test('detail modal polls a collecting bundle to active', async ({ page }) => { + await page.goto('/system/support-bundles') + + await page.getByRole('link', { name: 'New Support Bundle' }).click() + await page.getByRole('textbox', { name: 'Comment' }).fill('poll me') + await page.getByRole('button', { name: 'Create support bundle' }).click() + await expectToast(page, 'Support bundle created') + + // open the new bundle's detail modal while it's still collecting. the ID + // link is the only link in the row + await page.getByRole('row', { name: 'poll me' }).getByRole('link').click() + + const modal = page.getByRole('dialog', { name: 'Support bundle' }) + await expect(modal.getByText('collecting')).toBeVisible() + await expect(modal.getByRole('button', { name: 'Download bundle' })).toBeDisabled() + + // mock flips the bundle to active after 3s; the modal's view query polls + // every 10s, so the open modal updates in place + await expect(modal.getByText('active')).toBeVisible({ timeout: 20_000 }) + await expect(modal.getByRole('button', { name: 'Download bundle' })).toBeEnabled() +}) + test('create support bundle and poll to active', async ({ page }) => { await page.goto('/system/support-bundles') @@ -108,15 +182,15 @@ test('create shows insufficient capacity error in modal', async ({ page }) => { test('edit support bundle comment', async ({ page }) => { await page.goto('/system/support-bundles') - await clickRowAction(page, 'Investigating slow', 'Edit comment') + await clickRowAction(page, 'Investigating slow', 'View details') await expect(page).toHaveURL( - '/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31/edit' + '/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31' ) const comment = page.getByRole('textbox', { name: 'Comment' }) await expect(comment).toHaveValue('Investigating slow instance start times') await comment.fill('Resolved, keeping for reference') - await page.getByRole('button', { name: 'Update support bundle' }).click() + await page.getByRole('button', { name: 'Update comment' }).click() await expectToast(page, 'Support bundle updated') await expectRowVisible(page.getByRole('table'), { From af2a545b92d44e1ac03f476eb67989a4e8a8290d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 18 Aug 2026 09:12:17 -0400 Subject: [PATCH 09/36] use exhaustive matching on bundle states --- app/api/util.ts | 58 ++++++++++++++++++------ app/components/StateBadge.tsx | 6 ++- app/pages/system/SupportBundleDetail.tsx | 4 +- app/pages/system/SupportBundlesPage.tsx | 5 +- 4 files changed, 53 insertions(+), 20 deletions(-) diff --git a/app/api/util.ts b/app/api/util.ts index 4f5971743..962933566 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -22,6 +22,8 @@ import type { SiloIpPool, SiloUtilization, Sled, + SnapshotState, + SupportBundleState, VpcFirewallRule, VpcFirewallRuleUpdate, } from './__generated__/Api' @@ -181,14 +183,15 @@ export const instanceCan = R.mapValues(instanceActions, (states: InstanceState[] return test }) +/** + * States the instance is expected to leave on its own, so the UI should poll + * and show a spinner. Exhaustive match so new states have to be classified. + */ export function instanceTransitioning(runState: InstanceState) { - return ( - runState === 'creating' || - runState === 'starting' || - runState === 'rebooting' || - runState === 'migrating' || - runState === 'stopping' - ) + return match(runState) + .with('creating', 'starting', 'rebooting', 'migrating', 'stopping', () => true) + .with('running', 'stopped', 'repairing', 'failed', 'destroyed', () => false) + .exhaustive() } /** @@ -246,13 +249,42 @@ const canSnapshot = (d: SnapshotDisk) => { } canSnapshot.states = snapshotStates +/** See {@link instanceTransitioning} */ export function diskTransitioning(diskState: DiskState['state']) { - return ( - diskState === 'attaching' || - diskState === 'creating' || - diskState === 'detaching' || - diskState === 'finalizing' - ) + return match(diskState) + .with('attaching', 'creating', 'detaching', 'finalizing', () => true) + .with( + 'attached', + 'detached', + 'destroyed', + 'faulted', + 'maintenance', + 'import_ready', + 'importing_from_url', + 'importing_from_bulk_writes', + () => false + ) + .exhaustive() +} + +/** See {@link instanceTransitioning} */ +export function snapshotTransitioning(state: SnapshotState) { + return match(state) + .with('creating', () => true) + .with('ready', 'faulted', 'destroyed', () => false) + .exhaustive() +} + +/** + * See {@link instanceTransitioning}. 'active' and 'failed' are terminal, and + * 'destroying' resolves by the bundle record going away. + * https://github.com/oxidecomputer/omicron/blob/6db4c7e/nexus/db-model/src/support_bundle.rs#L53-L66 + */ +export function supportBundleTransitioning(state: SupportBundleState) { + return match(state) + .with('collecting', 'destroying', () => true) + .with('active', 'failed', () => false) + .exhaustive() } export const diskCan = { diff --git a/app/components/StateBadge.tsx b/app/components/StateBadge.tsx index 400d11764..002a42e60 100644 --- a/app/components/StateBadge.tsx +++ b/app/components/StateBadge.tsx @@ -10,6 +10,8 @@ import cn from 'classnames' import { diskTransitioning, instanceTransitioning, + snapshotTransitioning, + supportBundleTransitioning, type DiskState, type DiskType, type InstanceState, @@ -79,7 +81,7 @@ const SNAPSHOT_COLORS: Record = { export const SnapshotStateBadge = (props: { state: SnapshotState; className?: string }) => ( - {props.state === 'creating' && ( + {snapshotTransitioning(props.state) && ( )} {props.state} @@ -101,7 +103,7 @@ export const SupportBundleStateBadge = (props: { color={SUPPORT_BUNDLE_COLORS[props.state]} className={cn(props.className, badgeClasses)} > - {(props.state === 'collecting' || props.state === 'destroying') && ( + {supportBundleTransitioning(props.state) && ( )} {props.state} diff --git a/app/pages/system/SupportBundleDetail.tsx b/app/pages/system/SupportBundleDetail.tsx index f4ffcfcf5..ee87d35cb 100644 --- a/app/pages/system/SupportBundleDetail.tsx +++ b/app/pages/system/SupportBundleDetail.tsx @@ -14,6 +14,7 @@ import { api, q, queryClient, + supportBundleTransitioning, useApiMutation, usePrefetchedQuery, type SupportBundleInfo, @@ -56,8 +57,7 @@ const bundleView = ({ bundleId }: PP.SupportBundle) => ({ state: { data }, }: { state: { data: SupportBundleInfo | undefined } - }) => - data?.state === 'collecting' || data?.state === 'destroying' ? POLL_INTERVAL : false, + }) => (data && supportBundleTransitioning(data.state) ? POLL_INTERVAL : false), }) export async function clientLoader({ params }: LoaderFunctionArgs) { diff --git a/app/pages/system/SupportBundlesPage.tsx b/app/pages/system/SupportBundlesPage.tsx index 35017d1cf..1c509a92b 100644 --- a/app/pages/system/SupportBundlesPage.tsx +++ b/app/pages/system/SupportBundlesPage.tsx @@ -15,6 +15,7 @@ import { getListQFn, q, queryClient, + supportBundleTransitioning, useApiMutation, type SupportBundleInfo, } from '@oxide/api' @@ -92,9 +93,7 @@ const bundleList = getListQFn( { query: { sortBy: 'time_and_id_descending' } }, { refetchInterval: ({ state: { data } }) => - data?.items.some((b) => b.state === 'collecting' || b.state === 'destroying') - ? POLL_INTERVAL - : false, + data?.items.some((b) => supportBundleTransitioning(b.state)) ? POLL_INTERVAL : false, } ) From ff4ce4d9d35b4a73d6107ed091f67a446d16f474 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 18 Aug 2026 11:23:02 -0400 Subject: [PATCH 10/36] remove experimental from URL --- app/util/support-bundle.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/util/support-bundle.ts b/app/util/support-bundle.ts index 8ed7ca2c9..ff5503383 100644 --- a/app/util/support-bundle.ts +++ b/app/util/support-bundle.ts @@ -14,10 +14,10 @@ import { queryOptions } from '@tanstack/react-query' */ export const bundleDownloadUrl = (bundleId: string) => - `/experimental/v1/system/support-bundles/${bundleId}/download` + `/v1/system/support-bundles/${bundleId}/download` const bundleIndexUrl = (bundleId: string) => - `/experimental/v1/system/support-bundles/${bundleId}/index` + `/v1/system/support-bundles/${bundleId}/index` export const DOWNLOAD_DISABLED_REASON = 'Only bundles that have completed collection can be downloaded' From e3b7bb108235fb1af1aabd66679b388db9716dba Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 18 Aug 2026 14:06:41 -0400 Subject: [PATCH 11/36] small cleanup --- app/util/support-bundle.ts | 3 +-- vite.config.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/util/support-bundle.ts b/app/util/support-bundle.ts index ff5503383..fff8762ba 100644 --- a/app/util/support-bundle.ts +++ b/app/util/support-bundle.ts @@ -16,8 +16,7 @@ import { queryOptions } from '@tanstack/react-query' export const bundleDownloadUrl = (bundleId: string) => `/v1/system/support-bundles/${bundleId}/download` -const bundleIndexUrl = (bundleId: string) => - `/v1/system/support-bundles/${bundleId}/index` +const bundleIndexUrl = (bundleId: string) => `/v1/system/support-bundles/${bundleId}/index` export const DOWNLOAD_DISABLED_REASON = 'Only bundles that have completed collection can be downloaded' diff --git a/vite.config.ts b/vite.config.ts index 86cefdc2e..d9b2d86f0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -156,8 +156,8 @@ export default defineConfig(({ mode }) => ({ target: apiMode === 'remote' ? `https://${EXT_HOST}` : 'http://localhost:12220', changeOrigin: true, }, - // Support Bundle downloads hit /experimental/v1 directly via an anchor. - // Revise this if we drop /experimental from the URL path in the future. + // probes are the only /experimental endpoints left (support bundles moved + // to /v1 in omicron#11097); the console doesn't call them yet '/experimental': { target: apiMode === 'remote' ? `https://${EXT_HOST}` : 'http://localhost:12220', changeOrigin: true, From 6b5e1ef08157aaaffd2f2bb27f279d5774238f3b Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 18 Aug 2026 17:03:11 -0400 Subject: [PATCH 12/36] RefreshButton; fixed test --- app/pages/system/SupportBundlesPage.tsx | 26 ++++++++++++++++++++++--- test/e2e/support-bundles.e2e.ts | 2 +- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/app/pages/system/SupportBundlesPage.tsx b/app/pages/system/SupportBundlesPage.tsx index 1c509a92b..8b95b7fd2 100644 --- a/app/pages/system/SupportBundlesPage.tsx +++ b/app/pages/system/SupportBundlesPage.tsx @@ -23,6 +23,7 @@ import { Logs16Icon, Logs24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' import { HL } from '~/components/HL' +import { RefreshButton } from '~/components/RefreshButton' import { SupportBundleStateBadge } from '~/components/StateBadge' import { makeCrumb } from '~/hooks/use-crumbs' import { useQuickActions } from '~/hooks/use-quick-actions' @@ -38,10 +39,12 @@ 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 { Tooltip } from '~/ui/lib/Tooltip' import { truncate } from '~/ui/lib/Truncate' +import { toLocaleTimeString } from '~/util/date' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' -import { downloadBundle, DOWNLOAD_DISABLED_REASON } from '~/util/support-bundle' +import { DOWNLOAD_DISABLED_REASON, downloadBundle } from '~/util/support-bundle' const EmptyState = () => ( , }) + const { dataUpdatedAt } = query + useQuickActions( () => [ { @@ -184,7 +189,22 @@ export default function SupportBundlesPage() { links={[docLinks.supportBundles]} /> - + {/* Avoid changing justify-end on TableActions for this one case. We can + * fix this properly when we add refresh and filtering for all tables. */} + +
+ queryClient.invalidateEndpoint('supportBundleList')} + /> + + + Updated {toLocaleTimeString(new Date(dataUpdatedAt))} + + +
New Support Bundle
{table} diff --git a/test/e2e/support-bundles.e2e.ts b/test/e2e/support-bundles.e2e.ts index 2100207c3..ce854bd27 100644 --- a/test/e2e/support-bundles.e2e.ts +++ b/test/e2e/support-bundles.e2e.ts @@ -84,7 +84,7 @@ test('bundle detail modal shows metadata for active bundle', async ({ page }) => ) const modal = page.getByRole('dialog', { name: 'Support bundle' }) - await expect(modal.getByText(/^ccdac005/)).toBeVisible() + await expect(modal.getByLabel('ccdac005-66a8-4921-9e8b-30531c359c31')).toBeVisible() await expect(modal.getByText('active')).toBeVisible() // file count comes from the index endpoint, size from a HEAD of download From f5aa807993c52e3e9ef1af660d7464f88224f824 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 18 Aug 2026 19:31:56 -0400 Subject: [PATCH 13/36] Remove unused supportBundleDownload from MSW --- app/util/support-bundle.ts | 4 ++++ mock-api/msw/handlers.ts | 25 ++++++------------------- 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/app/util/support-bundle.ts b/app/util/support-bundle.ts index fff8762ba..4654b6f9a 100644 --- a/app/util/support-bundle.ts +++ b/app/util/support-bundle.ts @@ -11,6 +11,10 @@ import { queryOptions } from '@tanstack/react-query' * The generated API client only handles JSON responses, so the binary bundle * download endpoint is hit directly with an anchor. The browser sends the * session cookie the same as any API request. + * + * Note this means downloads do not work against the mock API: the anchor + * click is a download navigation, which MSW's service worker does not + * intercept, so the request falls through to the dev server. */ export const bundleDownloadUrl = (bundleId: string) => diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 0081c6981..f8a21062b 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -14,8 +14,8 @@ import { validate as isUuid, v4 as uuid } from 'uuid' import { diskCan, - fleetRoles, FLEET_ID, + fleetRoles, INSTANCE_MAX_CPU, INSTANCE_MAX_RAM_GiB, INSTANCE_MIN_RAM_GiB, @@ -2102,23 +2102,6 @@ export const handlers = makeHandlers({ // the generated handler type only allows status code returns for binary // endpoints, but the dispatcher passes Response instances through untouched // @ts-expect-error - supportBundleDownload({ path, cookies }) { - requireFleetViewer(cookies) - const bundle = lookupById(db.supportBundles, path.bundleId) - if (bundle.state !== 'active') { - throw invalidRequest('Cannot download bundle in non-active state') - } - // smallest valid zip: an empty end-of-central-directory record - const emptyZip = new Uint8Array(22) - emptyZip.set([0x50, 0x4b, 0x05, 0x06]) - return new HttpResponse(emptyZip, { - headers: { - 'Content-Type': 'application/zip', - 'Content-Disposition': `attachment; filename="support-bundle-${bundle.id}.zip"`, - }, - }) - }, - // @ts-expect-error Response passthrough, see supportBundleDownload supportBundleHead({ path, cookies }) { requireFleetViewer(cookies) const bundle = lookupById(db.supportBundles, path.bundleId) @@ -2132,7 +2115,7 @@ export const handlers = makeHandlers({ }, }) }, - // @ts-expect-error Response passthrough, see supportBundleDownload + // @ts-expect-error Response passthrough, see supportBundleHead supportBundleIndex({ path, cookies }) { requireFleetViewer(cookies) const bundle = lookupById(db.supportBundles, path.bundleId) @@ -2854,6 +2837,10 @@ export const handlers = makeHandlers({ siloUserView: NotImplemented, sledListUninitialized: NotImplemented, sledSetProvisionPolicy: NotImplemented, + // unreachable in the mock: the console downloads bundles with an + // navigation, which MSW's service worker can't intercept (see + // app/util/support-bundle.ts) + supportBundleDownload: NotImplemented, supportBundleDownloadFile: NotImplemented, supportBundleHeadFile: NotImplemented, switchView: NotImplemented, From d7182e4e63b460ce8594a8249252ac7bc4858e4e Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 18 Aug 2026 19:36:56 -0400 Subject: [PATCH 14/36] refactor --- mock-api/msw/handlers.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index f8a21062b..0301b366a 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -30,7 +30,12 @@ import { } from '@oxide/api' import { json, makeHandlers, type Json } from '~/api/__generated__/msw-handlers' -import { instanceCan, OXQL_GROUP_BY_ERROR } from '~/api/util' +import { + instanceCan, + MAX_BUNDLE_COMMENT_BYTES, + OXQL_GROUP_BY_ERROR, + utf8ByteLength, +} from '~/api/util' import { parseIpNet } from '~/util/ip' import { commaSeries } from '~/util/str' import { GiB } from '~/util/units' @@ -2073,10 +2078,8 @@ export const handlers = makeHandlers({ supportBundleUpdate({ path, body, cookies }) { requireFleetAdmin(cookies) const bundle = lookupById(db.supportBundles, path.bundleId) - // https://github.com/oxidecomputer/omicron/blob/99249b4/nexus/db-queries/src/db/datastore/support_bundle.rs#L736-L742 - // byte length, not string length, to match Nexus - if (body.user_comment && new TextEncoder().encode(body.user_comment).length > 4096) { - throw invalidRequest('User comment cannot exceed 4096 bytes') + if (body.user_comment && utf8ByteLength(body.user_comment) > MAX_BUNDLE_COMMENT_BYTES) { + throw invalidRequest(`User comment cannot exceed ${MAX_BUNDLE_COMMENT_BYTES} bytes`) } bundle.user_comment = body.user_comment return bundle From 104a1c5bc1a6308c8dcc790417ff7540a00a404e Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 26 Aug 2026 09:09:09 -0400 Subject: [PATCH 15/36] a bit more refactoring --- app/pages/system/SupportBundleDetail.tsx | 18 ++++++++++++++++-- app/util/support-bundle.ts | 5 ++++- test/e2e/support-bundles.e2e.ts | 20 ++++++++++++++++++++ vite.config.ts | 6 ------ 4 files changed, 40 insertions(+), 9 deletions(-) diff --git a/app/pages/system/SupportBundleDetail.tsx b/app/pages/system/SupportBundleDetail.tsx index ee87d35cb..02e6709d9 100644 --- a/app/pages/system/SupportBundleDetail.tsx +++ b/app/pages/system/SupportBundleDetail.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ import { useQuery, type UseQueryResult } from '@tanstack/react-query' -import type { ReactNode } from 'react' +import { useEffect, type ReactNode } from 'react' import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router' @@ -51,6 +51,8 @@ const POLL_INTERVAL = 10 * SEC const bundleView = ({ bundleId }: PP.SupportBundle) => ({ ...q(api.supportBundleView, { path: { bundleId } }), + // a mid-poll 404 means the bundle was deleted; handled in the component + throwOnError: false, // keep transitional states moving while the modal is open, matching the // list's polling, so a collecting bundle flips to active in place refetchInterval: ({ @@ -83,7 +85,19 @@ function AsyncValue({ export default function SupportBundleDetail() { const navigate = useNavigate() const { bundleId } = useSupportBundleSelector() - const { data: bundle } = usePrefetchedQuery(bundleView({ bundleId })) + const { data: bundle, error } = usePrefetchedQuery(bundleView({ bundleId })) + + // a destroying bundle's record is deleted outright when storage reclamation + // finishes, so a 404 mid-poll means the bundle is gone for good: close the + // modal rather than keep showing (and polling) stale data. other errors are + // left alone — polling continues and can recover from a transient failure + useEffect(() => { + if (error?.statusCode === 404) { + queryClient.invalidateEndpoint('supportBundleList') + addToast('Support bundle no longer exists') + navigate(pb.supportBundles()) + } + }, [error, navigate]) // the index and bundle zip only exist once collection has completed const isActive = bundle.state === 'active' diff --git a/app/util/support-bundle.ts b/app/util/support-bundle.ts index 4654b6f9a..441a1dbc0 100644 --- a/app/util/support-bundle.ts +++ b/app/util/support-bundle.ts @@ -61,7 +61,10 @@ export const bundleSizeQuery = (bundleId: string) => queryFn: async ({ signal }) => { const res = await fetch(bundleDownloadUrl(bundleId), { method: 'HEAD', signal }) if (!res.ok) throw new Error(`Error fetching bundle size (${res.status})`) - return Number(res.headers.get('content-length')) + // handle missing/malformed headers, rather than showing `0 B` + const size = Number(res.headers.get('content-length')) + if (!size) throw new Error('Bundle size missing from response') + return size }, staleTime: Infinity, }) diff --git a/test/e2e/support-bundles.e2e.ts b/test/e2e/support-bundles.e2e.ts index ce854bd27..6b843c3eb 100644 --- a/test/e2e/support-bundles.e2e.ts +++ b/test/e2e/support-bundles.e2e.ts @@ -227,6 +227,26 @@ test('delete active bundle transitions to destroying', async ({ page }) => { }) }) +test('detail modal closes when bundle is deleted mid-view', async ({ page }) => { + await page.goto('/system/support-bundles') + + // start deleting the active bundle, then open its detail modal while it + // sits in 'destroying' + await clickRowAction(page, 'Investigating slow', 'Delete') + await page.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, /Deleting support bundle/) + + await page.getByRole('link', { name: 'ccdac0…359c31' }).click() + const modal = page.getByRole('dialog', { name: 'Support bundle' }) + await expect(modal.getByText('destroying')).toBeVisible() + + // mock API deletes the record 3s after the delete call; the modal's next + // poll (10s) gets a 404, which closes the modal and toasts + await expect(modal).toBeHidden({ timeout: 20_000 }) + await expectToast(page, 'Support bundle no longer exists') + await expect(page).toHaveURL('/system/support-bundles') +}) + test('delete collecting bundle warns about cancellation', async ({ page }) => { await page.goto('/system/support-bundles') diff --git a/vite.config.ts b/vite.config.ts index d9b2d86f0..3bd34e1db 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -156,12 +156,6 @@ export default defineConfig(({ mode }) => ({ target: apiMode === 'remote' ? `https://${EXT_HOST}` : 'http://localhost:12220', changeOrigin: true, }, - // probes are the only /experimental endpoints left (support bundles moved - // to /v1 in omicron#11097); the console doesn't call them yet - '/experimental': { - target: apiMode === 'remote' ? `https://${EXT_HOST}` : 'http://localhost:12220', - changeOrigin: true, - }, }, }, resolve: { tsconfigPaths: true }, From 77dcedb968e64838bf1ab6e01235a017e792125d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 26 Aug 2026 12:41:36 -0400 Subject: [PATCH 16/36] follow polling precedent --- app/pages/system/SupportBundleDetail.tsx | 18 ++---------------- test/e2e/support-bundles.e2e.ts | 9 ++++----- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/app/pages/system/SupportBundleDetail.tsx b/app/pages/system/SupportBundleDetail.tsx index 02e6709d9..ee87d35cb 100644 --- a/app/pages/system/SupportBundleDetail.tsx +++ b/app/pages/system/SupportBundleDetail.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ import { useQuery, type UseQueryResult } from '@tanstack/react-query' -import { useEffect, type ReactNode } from 'react' +import type { ReactNode } from 'react' import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router' @@ -51,8 +51,6 @@ const POLL_INTERVAL = 10 * SEC const bundleView = ({ bundleId }: PP.SupportBundle) => ({ ...q(api.supportBundleView, { path: { bundleId } }), - // a mid-poll 404 means the bundle was deleted; handled in the component - throwOnError: false, // keep transitional states moving while the modal is open, matching the // list's polling, so a collecting bundle flips to active in place refetchInterval: ({ @@ -85,19 +83,7 @@ function AsyncValue({ export default function SupportBundleDetail() { const navigate = useNavigate() const { bundleId } = useSupportBundleSelector() - const { data: bundle, error } = usePrefetchedQuery(bundleView({ bundleId })) - - // a destroying bundle's record is deleted outright when storage reclamation - // finishes, so a 404 mid-poll means the bundle is gone for good: close the - // modal rather than keep showing (and polling) stale data. other errors are - // left alone — polling continues and can recover from a transient failure - useEffect(() => { - if (error?.statusCode === 404) { - queryClient.invalidateEndpoint('supportBundleList') - addToast('Support bundle no longer exists') - navigate(pb.supportBundles()) - } - }, [error, navigate]) + const { data: bundle } = usePrefetchedQuery(bundleView({ bundleId })) // the index and bundle zip only exist once collection has completed const isActive = bundle.state === 'active' diff --git a/test/e2e/support-bundles.e2e.ts b/test/e2e/support-bundles.e2e.ts index 6b843c3eb..4d3591c81 100644 --- a/test/e2e/support-bundles.e2e.ts +++ b/test/e2e/support-bundles.e2e.ts @@ -227,7 +227,7 @@ test('delete active bundle transitions to destroying', async ({ page }) => { }) }) -test('detail modal closes when bundle is deleted mid-view', async ({ page }) => { +test('bundle deleted mid-view 404s', async ({ page }) => { await page.goto('/system/support-bundles') // start deleting the active bundle, then open its detail modal while it @@ -241,10 +241,9 @@ test('detail modal closes when bundle is deleted mid-view', async ({ page }) => await expect(modal.getByText('destroying')).toBeVisible() // mock API deletes the record 3s after the delete call; the modal's next - // poll (10s) gets a 404, which closes the modal and toasts - await expect(modal).toBeHidden({ timeout: 20_000 }) - await expectToast(page, 'Support bundle no longer exists') - await expect(page).toHaveURL('/system/support-bundles') + // poll (10s) gets a 404, which throws to the error boundary, same as + // instance detail when an instance is deleted mid-poll + await expect(page.getByText('Page not found')).toBeVisible({ timeout: 20_000 }) }) test('delete collecting bundle warns about cancellation', async ({ page }) => { From 4ed126fb501f12411c2e5f5c1f1141378a21abfd Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 26 Aug 2026 14:34:03 -0400 Subject: [PATCH 17/36] simplify fetching --- app/util/support-bundle.spec.ts | 36 +++++++++++++++++++++++++++++++++ app/util/support-bundle.ts | 28 ++++++++++++++++--------- 2 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 app/util/support-bundle.spec.ts diff --git a/app/util/support-bundle.spec.ts b/app/util/support-bundle.spec.ts new file mode 100644 index 000000000..4c3ef23c0 --- /dev/null +++ b/app/util/support-bundle.spec.ts @@ -0,0 +1,36 @@ +/* + * 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 { afterEach, expect, it, vi } from 'vitest' + +import { api } from '@oxide/api' + +import { bundleDownloadUrl, bundleIndexUrl } from './support-bundle' + +afterEach(() => vi.unstubAllGlobals()) + +// The download URL is used in an anchor navigation and the index is plain +// text, so neither request can go through the generated client, and their +// paths are restated in support-bundle.ts. Catch drift by comparing against +// the URLs the generated client actually requests. +it('hand-built bundle URLs match the generated client', async () => { + const urls: string[] = [] + // the generated client always calls fetch with a URL string + vi.stubGlobal('fetch', (url: string) => { + urls.push(url) + return Promise.resolve(new Response(null, { status: 204 })) + }) + + await api.supportBundleDownload({ path: { bundleId: 'bundle-id' } }) + await api.supportBundleIndex({ path: { bundleId: 'bundle-id' } }) + + // 'http://testhost' is the client host under NODE_ENV=test (app/api/client.ts) + expect(urls).toEqual([ + 'http://testhost' + bundleDownloadUrl('bundle-id'), + 'http://testhost' + bundleIndexUrl('bundle-id'), + ]) +}) diff --git a/app/util/support-bundle.ts b/app/util/support-bundle.ts index 441a1dbc0..9ee5b50be 100644 --- a/app/util/support-bundle.ts +++ b/app/util/support-bundle.ts @@ -7,10 +7,14 @@ */ import { queryOptions } from '@tanstack/react-query' +import { api } from '@oxide/api' + /* - * The generated API client only handles JSON responses, so the binary bundle - * download endpoint is hit directly with an anchor. The browser sends the - * session cookie the same as any API request. + * The generated API client only handles JSON responses, so the zip download + * is a plain anchor navigation and the plain-text index is a raw fetch. The + * browser sends the session cookie the same as any API request. These URLs + * restate paths from the generated client; the spec next to this file guards + * against them drifting when the API is regenerated. * * Note this means downloads do not work against the mock API: the anchor * click is a download navigation, which MSW's service worker does not @@ -20,7 +24,8 @@ import { queryOptions } from '@tanstack/react-query' export const bundleDownloadUrl = (bundleId: string) => `/v1/system/support-bundles/${bundleId}/download` -const bundleIndexUrl = (bundleId: string) => `/v1/system/support-bundles/${bundleId}/index` +export const bundleIndexUrl = (bundleId: string) => + `/v1/system/support-bundles/${bundleId}/index` export const DOWNLOAD_DISABLED_REASON = 'Only bundles that have completed collection can be downloaded' @@ -54,15 +59,20 @@ export const bundleIndexQuery = (bundleId: string) => staleTime: Infinity, }) -/** Total bundle size from `Content-Length` on a HEAD of the download endpoint */ +/** + * Total bundle size from `Content-Length` on a HEAD of the download endpoint. + * A HEAD response has no body, so the JSON-only generated client handles it. + */ export const bundleSizeQuery = (bundleId: string) => queryOptions({ queryKey: ['supportBundleSize', bundleId], - queryFn: async ({ signal }) => { - const res = await fetch(bundleDownloadUrl(bundleId), { method: 'HEAD', signal }) - if (!res.ok) throw new Error(`Error fetching bundle size (${res.status})`) + queryFn: async () => { + const result = await api.supportBundleHead({ path: { bundleId } }) + if (result.type !== 'success') { + throw new Error(`Error fetching bundle size (${result.response.status})`) + } // handle missing/malformed headers, rather than showing `0 B` - const size = Number(res.headers.get('content-length')) + const size = Number(result.response.headers.get('content-length')) if (!size) throw new Error('Bundle size missing from response') return size }, From 65114e46c61c86febf08d49fc59df9b61db8838d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 8 Sep 2026 17:07:32 -0400 Subject: [PATCH 18/36] prevent modal button weirdness on successful submission --- app/forms/support-bundle-create.tsx | 2 +- app/pages/system/SupportBundleDetail.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/forms/support-bundle-create.tsx b/app/forms/support-bundle-create.tsx index b274dfb1f..590d3a403 100644 --- a/app/forms/support-bundle-create.tsx +++ b/app/forms/support-bundle-create.tsx @@ -45,7 +45,7 @@ export default function CreateSupportBundleSideModalForm() { onSubmit={({ userComment }) => { createBundle.mutate({ body: { userComment: userComment || null } }) }} - loading={createBundle.isPending} + loading={createBundle.isPending || createBundle.isSuccess} submitError={createBundle.error} >
From 22ab81e900ea080f7471b152d84a0f78849cb113 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 9 Sep 2026 19:10:33 -0400 Subject: [PATCH 19/36] Use folder icon for now --- app/layouts/SystemLayout.tsx | 7 ++++--- app/pages/system/SupportBundleDetail.tsx | 4 ++-- app/pages/system/SupportBundlesPage.tsx | 8 ++++---- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index 9aecaf4c0..5f62783a6 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -11,6 +11,7 @@ import { api, q, queryClient } from '@oxide/api' import { Access16Icon, Cloud16Icon, + Folder16Icon, IpGlobal16Icon, Logs16Icon, Metrics16Icon, @@ -107,15 +108,15 @@ export default function SystemLayout() { System Update - - Support Bundles - Fleet Access Audit Log + + Support Bundles + diff --git a/app/pages/system/SupportBundleDetail.tsx b/app/pages/system/SupportBundleDetail.tsx index 84091178b..5bdd7ec06 100644 --- a/app/pages/system/SupportBundleDetail.tsx +++ b/app/pages/system/SupportBundleDetail.tsx @@ -19,7 +19,7 @@ import { usePrefetchedQuery, type SupportBundleInfo, } from '@oxide/api' -import { Logs16Icon } from '@oxide/design-system/icons/react' +import { Folder16Icon } from '@oxide/design-system/icons/react' import { BundleCommentField } from '~/components/form/fields/BundleCommentField' import { SideModalForm } from '~/components/form/SideModalForm' @@ -115,7 +115,7 @@ export default function SupportBundleDetail() { submitDisabled={isDirty ? undefined : 'No changes to save'} subtitle={ - {truncate(bundle.id, 14, 'middle')} + {truncate(bundle.id, 14, 'middle')} } onDismiss={onDismiss} diff --git a/app/pages/system/SupportBundlesPage.tsx b/app/pages/system/SupportBundlesPage.tsx index 8b95b7fd2..020fc4559 100644 --- a/app/pages/system/SupportBundlesPage.tsx +++ b/app/pages/system/SupportBundlesPage.tsx @@ -19,7 +19,7 @@ import { useApiMutation, type SupportBundleInfo, } from '@oxide/api' -import { Logs16Icon, Logs24Icon } from '@oxide/design-system/icons/react' +import { Folder16Icon, Folder24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' import { HL } from '~/components/HL' @@ -48,7 +48,7 @@ import { DOWNLOAD_DISABLED_REASON, downloadBundle } from '~/util/support-bundle' const EmptyState = () => ( } + icon={} title="No support bundles" body="Create a support bundle to see it here" buttonText="New support bundle" @@ -181,10 +181,10 @@ export default function SupportBundlesPage() { return ( <> - }>Support Bundles + }>Support Bundles } + icon={} summary="Support bundles capture diagnostic data from the rack to share with Oxide Support." links={[docLinks.supportBundles]} /> From e863a9fbe20db1091f9441d86548fda2c6de332d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 10 Sep 2026 11:22:28 -0400 Subject: [PATCH 20/36] actually: flag icon (for now) --- app/layouts/SystemLayout.tsx | 4 ++-- app/pages/system/SupportBundleDetail.tsx | 4 ++-- app/pages/system/SupportBundlesPage.tsx | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index 5f62783a6..fa66ee0df 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -11,7 +11,7 @@ import { api, q, queryClient } from '@oxide/api' import { Access16Icon, Cloud16Icon, - Folder16Icon, + Issues16Icon, IpGlobal16Icon, Logs16Icon, Metrics16Icon, @@ -115,7 +115,7 @@ export default function SystemLayout() { Audit Log - Support Bundles + Support Bundles diff --git a/app/pages/system/SupportBundleDetail.tsx b/app/pages/system/SupportBundleDetail.tsx index 5bdd7ec06..a82bd0ff1 100644 --- a/app/pages/system/SupportBundleDetail.tsx +++ b/app/pages/system/SupportBundleDetail.tsx @@ -19,7 +19,7 @@ import { usePrefetchedQuery, type SupportBundleInfo, } from '@oxide/api' -import { Folder16Icon } from '@oxide/design-system/icons/react' +import { Issues16Icon } from '@oxide/design-system/icons/react' import { BundleCommentField } from '~/components/form/fields/BundleCommentField' import { SideModalForm } from '~/components/form/SideModalForm' @@ -115,7 +115,7 @@ export default function SupportBundleDetail() { submitDisabled={isDirty ? undefined : 'No changes to save'} subtitle={ - {truncate(bundle.id, 14, 'middle')} + {truncate(bundle.id, 14, 'middle')} } onDismiss={onDismiss} diff --git a/app/pages/system/SupportBundlesPage.tsx b/app/pages/system/SupportBundlesPage.tsx index 020fc4559..cb54d9718 100644 --- a/app/pages/system/SupportBundlesPage.tsx +++ b/app/pages/system/SupportBundlesPage.tsx @@ -19,7 +19,7 @@ import { useApiMutation, type SupportBundleInfo, } from '@oxide/api' -import { Folder16Icon, Folder24Icon } from '@oxide/design-system/icons/react' +import { Issues16Icon, Issues24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' import { HL } from '~/components/HL' @@ -48,7 +48,7 @@ import { DOWNLOAD_DISABLED_REASON, downloadBundle } from '~/util/support-bundle' const EmptyState = () => ( } + icon={} title="No support bundles" body="Create a support bundle to see it here" buttonText="New support bundle" @@ -181,10 +181,10 @@ export default function SupportBundlesPage() { return ( <> - }>Support Bundles + }>Support Bundles } + icon={} summary="Support bundles capture diagnostic data from the rack to share with Oxide Support." links={[docLinks.supportBundles]} /> From 65e2b18af3bcaa0849f172cd0f5c090d3ff4c40e Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 14 Sep 2026 15:29:18 -0500 Subject: [PATCH 21/36] Fix ProjectAccessPage tsc error by typing rows explicitly --- app/pages/project/access/ProjectAccessPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 644cc6638..e9c07fe97 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -104,7 +104,7 @@ export default function ProjectAccessPage() { const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView(projectSelector)) const projectRows = useUserRows(projectPolicy.roleAssignments, 'project') - const rows = useMemo(() => { + const rows: UserRow[] = useMemo(() => { return groupBy(siloRows.concat(projectRows), (u) => u.id) .map(([userId, userAssignments]) => { const { name, identityType } = userAssignments[0] @@ -123,7 +123,7 @@ export default function ProjectAccessPage() { name, projectRole: projectAccessRow?.roleName, roleBadges, - } satisfies UserRow + } }) .sort(byGroupThenName) }, [siloRows, projectRows]) From bf3666531a96767571c650b0ec6a865bb0e42fd8 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 14 Sep 2026 16:08:09 -0500 Subject: [PATCH 22/36] tweak docs popover copy --- app/pages/system/SupportBundlesPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/system/SupportBundlesPage.tsx b/app/pages/system/SupportBundlesPage.tsx index cb54d9718..64a58b194 100644 --- a/app/pages/system/SupportBundlesPage.tsx +++ b/app/pages/system/SupportBundlesPage.tsx @@ -185,7 +185,7 @@ export default function SupportBundlesPage() { } - summary="Support bundles capture diagnostic data from the rack to share with Oxide Support." + summary="Support bundles capture diagnostic data to share with Oxide support." links={[docLinks.supportBundles]} /> From 5351dae039f31a7061db506a40104bbb4ec85997 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 14 Sep 2026 16:30:23 -0500 Subject: [PATCH 23/36] Drop file count from bundle detail --- app/pages/system/SupportBundleDetail.tsx | 14 +----------- app/util/support-bundle.spec.ts | 17 +++++--------- app/util/support-bundle.ts | 29 ++++-------------------- mock-api/msw/handlers.ts | 14 ++---------- mock-api/support-bundle.ts | 26 --------------------- test/e2e/support-bundles.e2e.ts | 3 +-- 6 files changed, 14 insertions(+), 89 deletions(-) diff --git a/app/pages/system/SupportBundleDetail.tsx b/app/pages/system/SupportBundleDetail.tsx index a82bd0ff1..fba068827 100644 --- a/app/pages/system/SupportBundleDetail.tsx +++ b/app/pages/system/SupportBundleDetail.tsx @@ -40,7 +40,6 @@ 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, @@ -85,9 +84,8 @@ export default function SupportBundleDetail() { const { bundleId } = useSupportBundleSelector() const { data: bundle } = usePrefetchedQuery(bundleView({ bundleId })) - // the index and bundle zip only exist once collection has completed + // the bundle zip only exists 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 || '' } }) @@ -143,16 +141,6 @@ export default function SupportBundleDetail() { - {isActive && ( - - - {(entries) => - // directory entries have a trailing slash; count files only - entries.filter((e) => !e.endsWith('/')).length.toLocaleString() - } - - - )} {isActive && ( {(bytes) => } diff --git a/app/util/support-bundle.spec.ts b/app/util/support-bundle.spec.ts index 4c3ef23c0..4a34551ef 100644 --- a/app/util/support-bundle.spec.ts +++ b/app/util/support-bundle.spec.ts @@ -9,15 +9,14 @@ import { afterEach, expect, it, vi } from 'vitest' import { api } from '@oxide/api' -import { bundleDownloadUrl, bundleIndexUrl } from './support-bundle' +import { bundleDownloadUrl } from './support-bundle' afterEach(() => vi.unstubAllGlobals()) -// The download URL is used in an anchor navigation and the index is plain -// text, so neither request can go through the generated client, and their -// paths are restated in support-bundle.ts. Catch drift by comparing against -// the URLs the generated client actually requests. -it('hand-built bundle URLs match the generated client', async () => { +// The download URL is used in an anchor navigation, so it can't go through +// the generated client and its path is restated in support-bundle.ts. Catch +// drift by comparing against the URL the generated client actually requests. +it('hand-built download URL matches the generated client', async () => { const urls: string[] = [] // the generated client always calls fetch with a URL string vi.stubGlobal('fetch', (url: string) => { @@ -26,11 +25,7 @@ it('hand-built bundle URLs match the generated client', async () => { }) await api.supportBundleDownload({ path: { bundleId: 'bundle-id' } }) - await api.supportBundleIndex({ path: { bundleId: 'bundle-id' } }) // 'http://testhost' is the client host under NODE_ENV=test (app/api/client.ts) - expect(urls).toEqual([ - 'http://testhost' + bundleDownloadUrl('bundle-id'), - 'http://testhost' + bundleIndexUrl('bundle-id'), - ]) + expect(urls).toEqual(['http://testhost' + bundleDownloadUrl('bundle-id')]) }) diff --git a/app/util/support-bundle.ts b/app/util/support-bundle.ts index 9ee5b50be..498fd7085 100644 --- a/app/util/support-bundle.ts +++ b/app/util/support-bundle.ts @@ -11,10 +11,10 @@ import { api } from '@oxide/api' /* * The generated API client only handles JSON responses, so the zip download - * is a plain anchor navigation and the plain-text index is a raw fetch. The - * browser sends the session cookie the same as any API request. These URLs - * restate paths from the generated client; the spec next to this file guards - * against them drifting when the API is regenerated. + * is a plain anchor navigation. The browser sends the session cookie the same + * as any API request. The URL restates a path from the generated client; the + * spec next to this file guards against it drifting when the API is + * regenerated. * * Note this means downloads do not work against the mock API: the anchor * click is a download navigation, which MSW's service worker does not @@ -24,9 +24,6 @@ import { api } from '@oxide/api' export const bundleDownloadUrl = (bundleId: string) => `/v1/system/support-bundles/${bundleId}/download` -export const bundleIndexUrl = (bundleId: string) => - `/v1/system/support-bundles/${bundleId}/index` - export const DOWNLOAD_DISABLED_REASON = 'Only bundles that have completed collection can be downloaded' @@ -41,24 +38,6 @@ export function downloadBundle(bundleId: string) { triggerDownload(bundleDownloadUrl(bundleId), `support-bundle-${bundleId}.zip`) } -/** - * The index is the bundle zip's entry names, one per line, where directory - * entries have a trailing slash. - * https://github.com/oxidecomputer/omicron/blob/99249b4/sled-agent/src/support_bundle/storage.rs#L1029-L1035 - */ -export const bundleIndexQuery = (bundleId: string) => - queryOptions({ - queryKey: ['supportBundleIndex', bundleId], - queryFn: async ({ signal }) => { - const res = await fetch(bundleIndexUrl(bundleId), { signal }) - if (!res.ok) throw new Error(`Error fetching bundle index (${res.status})`) - const text = await res.text() - return text.split('\n').filter((line) => line.length > 0) - }, - // bundle contents never change once collection is complete - staleTime: Infinity, - }) - /** * Total bundle size from `Content-Length` on a HEAD of the download endpoint. * A HEAD response has no body, so the JSON-only generated client handles it. diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 3e96524d9..af936836c 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -44,7 +44,7 @@ import { GiB } from '~/util/units' import { alertClasses, PROBE_ALERT_ID } from '../alert' import { defaultSilo, toIdp } from '../silo' -import { SUPPORT_BUNDLE_SIZE, supportBundleIndexText } from '../support-bundle' +import { SUPPORT_BUNDLE_SIZE } from '../support-bundle' import { getTimestamps } from '../util' import { defaultFirewallRules } from '../vpc' import { resendableAlerts, retryPendingDeliveries, validateSubscription } from './alert' @@ -2146,17 +2146,6 @@ export const handlers = makeHandlers({ }, }) }, - // @ts-expect-error Response passthrough, see supportBundleHead - supportBundleIndex({ path, cookies }) { - requireFleetViewer(cookies) - const bundle = lookupById(db.supportBundles, path.bundleId) - if (bundle.state !== 'active') { - throw invalidRequest('Cannot download bundle in non-active state') - } - return new HttpResponse(supportBundleIndexText, { - headers: { 'Content-Type': 'text/plain' }, - }) - }, switchList: ({ query, cookies }) => { requireFleetViewer(cookies) return paginated(query, db.switches) @@ -3118,6 +3107,7 @@ export const handlers = makeHandlers({ supportBundleDownload: NotImplemented, supportBundleDownloadFile: NotImplemented, supportBundleHeadFile: NotImplemented, + supportBundleIndex: NotImplemented, switchView: NotImplemented, systemIpPoolAssign: NotImplemented, systemNetworkingSettingsUpdate: NotImplemented, diff --git a/mock-api/support-bundle.ts b/mock-api/support-bundle.ts index 8f656a1e0..07f4bf844 100644 --- a/mock-api/support-bundle.ts +++ b/mock-api/support-bundle.ts @@ -35,31 +35,5 @@ export const supportBundles: Json[] = [ }, ] -/** - * Served by the index handler for any active bundle: zip entry names in the - * format the real endpoint returns — sorted, one per line, directories with - * trailing slashes. A tiny slice of a real bundle's layout. 8 files. - */ -export const supportBundleIndexText = [ - 'bundle_id.txt', - 'ereports/', - 'ereports/9130000019-BRM42220031/', - 'ereports/9130000019-BRM42220031/3f7d938a-71b0-4707-b020-ba05526e84ee/', - 'ereports/9130000019-BRM42220031/3f7d938a-71b0-4707-b020-ba05526e84ee/0x1.json', - 'ereports/9130000019-BRM42220031/3f7d938a-71b0-4707-b020-ba05526e84ee/0x2.json', - 'meta/', - 'meta/reason_for_creation.txt', - 'meta/report.json', - 'rack/', - 'rack/a5b3fd8a/', - 'rack/a5b3fd8a/sled/', - 'rack/a5b3fd8a/sled/0/', - 'rack/a5b3fd8a/sled/0/zpool.json', - 'reconfigurator_state.json', - 'sp_task_dumps/', - 'sp_task_dumps/switch_0/', - 'sp_task_dumps/switch_0/dump-0.zip', -].join('\n') - // Fake `Content-Length` for the HEAD handler export const SUPPORT_BUNDLE_SIZE = 2_576_980_378 diff --git a/test/e2e/support-bundles.e2e.ts b/test/e2e/support-bundles.e2e.ts index 4d3591c81..c2bf15790 100644 --- a/test/e2e/support-bundles.e2e.ts +++ b/test/e2e/support-bundles.e2e.ts @@ -87,8 +87,7 @@ test('bundle detail modal shows metadata for active bundle', async ({ page }) => await expect(modal.getByLabel('ccdac005-66a8-4921-9e8b-30531c359c31')).toBeVisible() await expect(modal.getByText('active')).toBeVisible() - // file count comes from the index endpoint, size from a HEAD of download - await expect(modal.getByText('8', { exact: true })).toBeVisible() + // size comes from a HEAD of the download endpoint await expect(modal.getByText('2.4 GiB')).toBeVisible() await expect(modal.getByRole('button', { name: 'Download bundle' })).toBeEnabled() From e3edd2d6cd256ab6e96c46620748a8869c3ab336 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 14 Sep 2026 16:43:42 -0500 Subject: [PATCH 24/36] Replace AsyncValue with a BundleSize component --- app/pages/system/SupportBundleDetail.tsx | 47 ++++++++++++++---------- app/util/support-bundle.ts | 24 ------------ 2 files changed, 27 insertions(+), 44 deletions(-) diff --git a/app/pages/system/SupportBundleDetail.tsx b/app/pages/system/SupportBundleDetail.tsx index fba068827..e8288f9cd 100644 --- a/app/pages/system/SupportBundleDetail.tsx +++ b/app/pages/system/SupportBundleDetail.tsx @@ -5,8 +5,7 @@ * * Copyright Oxide Computer Company */ -import { useQuery, type UseQueryResult } from '@tanstack/react-query' -import type { ReactNode } from 'react' +import { useQuery } from '@tanstack/react-query' import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router' @@ -39,11 +38,7 @@ 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 { - bundleSizeQuery, - downloadBundle, - DOWNLOAD_DISABLED_REASON, -} from '~/util/support-bundle' +import { downloadBundle, DOWNLOAD_DISABLED_REASON } from '~/util/support-bundle' const SEC = 1000 // ms const POLL_INTERVAL = 10 * SEC @@ -66,17 +61,30 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { export const handle = titleCrumb('Support bundle') -/** Skeleton while the query is in flight, em dash if it failed */ -function AsyncValue({ - query, - children, -}: { - query: UseQueryResult - children: (data: T) => ReactNode -}) { - if (query.isPending) return - if (query.isError) return - return <>{children(query.data)} +/** + * Total bundle size from `Content-Length` on a HEAD of the download endpoint. + * Calls the generated client directly rather than through `q`, which unwraps + * the result to `data` and drops the response headers. + */ +function BundleSize({ bundleId }: { bundleId: string }) { + const { data: size, isPending } = useQuery({ + queryKey: ['supportBundleSize', bundleId], + queryFn: async () => { + const result = await api.supportBundleHead({ path: { bundleId } }) + if (result.type !== 'success') { + throw new Error(`Error fetching bundle size (${result.response.status})`) + } + // handle missing/malformed headers, rather than showing `0 B` + const size = Number(result.response.headers.get('content-length')) + if (!size) throw new Error('Bundle size missing from response') + return size + }, + // bundle contents never change once collection is complete + staleTime: Infinity, + }) + if (isPending) return + if (!size) return + return } export default function SupportBundleDetail() { @@ -86,7 +94,6 @@ export default function SupportBundleDetail() { // the bundle zip only exists once collection has completed const isActive = bundle.state === 'active' - const sizeQuery = useQuery({ ...bundleSizeQuery(bundleId), enabled: isActive }) const form = useForm({ defaultValues: { userComment: bundle.userComment || '' } }) // must destructure to subscribe to changes; inlining does not work @@ -143,7 +150,7 @@ export default function SupportBundleDetail() { {isActive && ( - {(bytes) => } + )} diff --git a/app/util/support-bundle.ts b/app/util/support-bundle.ts index 498fd7085..9ca2b4753 100644 --- a/app/util/support-bundle.ts +++ b/app/util/support-bundle.ts @@ -5,10 +5,6 @@ * * Copyright Oxide Computer Company */ -import { queryOptions } from '@tanstack/react-query' - -import { api } from '@oxide/api' - /* * The generated API client only handles JSON responses, so the zip download * is a plain anchor navigation. The browser sends the session cookie the same @@ -37,23 +33,3 @@ function triggerDownload(url: string, filename: string) { export function downloadBundle(bundleId: string) { triggerDownload(bundleDownloadUrl(bundleId), `support-bundle-${bundleId}.zip`) } - -/** - * Total bundle size from `Content-Length` on a HEAD of the download endpoint. - * A HEAD response has no body, so the JSON-only generated client handles it. - */ -export const bundleSizeQuery = (bundleId: string) => - queryOptions({ - queryKey: ['supportBundleSize', bundleId], - queryFn: async () => { - const result = await api.supportBundleHead({ path: { bundleId } }) - if (result.type !== 'success') { - throw new Error(`Error fetching bundle size (${result.response.status})`) - } - // handle missing/malformed headers, rather than showing `0 B` - const size = Number(result.response.headers.get('content-length')) - if (!size) throw new Error('Bundle size missing from response') - return size - }, - staleTime: Infinity, - }) From 3160e72262ff838e3ff42fdede455c96e81ab668 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 14 Sep 2026 17:02:29 -0500 Subject: [PATCH 25/36] Reorder system sidebar and quick actions --- app/layouts/SystemLayout.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index 6341caf3e..e56b0f964 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -62,8 +62,8 @@ export default function SystemLayout() { { value: 'Alerts', path: pb.alerts() }, { value: 'System Update', path: pb.systemUpdate() }, { value: 'Support Bundles', path: pb.supportBundles() }, - { value: 'Fleet Access', path: pb.fleetAccess() }, { value: 'Audit Log', path: pb.auditLog() }, + { value: 'Fleet Access', path: pb.fleetAccess() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -114,14 +114,14 @@ export default function SystemLayout() { System Update - - Fleet Access + + Support Bundles Audit Log - - Support Bundles + + Fleet Access From 9d3a69a448fea7a47777cebc9b3e744057dfb915 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 14 Sep 2026 17:11:14 -0500 Subject: [PATCH 26/36] Drop redundant View details row action --- app/pages/system/SupportBundlesPage.tsx | 17 ++--------------- test/e2e/support-bundles.e2e.ts | 2 +- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/app/pages/system/SupportBundlesPage.tsx b/app/pages/system/SupportBundlesPage.tsx index 64a58b194..1c5ea0ad6 100644 --- a/app/pages/system/SupportBundlesPage.tsx +++ b/app/pages/system/SupportBundlesPage.tsx @@ -8,12 +8,11 @@ import { createColumnHelper } from '@tanstack/react-table' import { useCallback } from 'react' -import { Outlet, useNavigate } from 'react-router' +import { Outlet } from 'react-router' import { api, getListQFn, - q, queryClient, supportBundleTransitioning, useApiMutation, @@ -110,8 +109,6 @@ export async function clientLoader() { export const handle = makeCrumb('Support Bundles', pb.supportBundles()) export default function SupportBundlesPage() { - const navigate = useNavigate() - const { mutateAsync: deleteBundle } = useApiMutation(api.supportBundleDelete, { onSuccess(_data, variables) { queryClient.invalidateEndpoint('supportBundleList') @@ -131,16 +128,6 @@ export default function SupportBundlesPage() { }, disabled: bundle.state !== 'active' && DOWNLOAD_DISABLED_REASON, }, - { - label: 'View details', - onActivate() { - const bundleView = q(api.supportBundleView, { - path: { bundleId: bundle.id }, - }) - queryClient.setQueryData(bundleView.queryKey, bundle) - navigate(pb.supportBundle({ bundleId: bundle.id })) - }, - }, { label: 'Delete', onActivate: confirmDelete({ @@ -155,7 +142,7 @@ export default function SupportBundlesPage() { disabled: bundle.state === 'destroying' && 'Bundle is already being destroyed', }, ], - [deleteBundle, navigate] + [deleteBundle] ) const columns = useColsWithActions(staticColumns, makeActions) diff --git a/test/e2e/support-bundles.e2e.ts b/test/e2e/support-bundles.e2e.ts index c2bf15790..e5297d31d 100644 --- a/test/e2e/support-bundles.e2e.ts +++ b/test/e2e/support-bundles.e2e.ts @@ -181,7 +181,7 @@ test('create shows insufficient capacity error in modal', async ({ page }) => { test('edit support bundle comment', async ({ page }) => { await page.goto('/system/support-bundles') - await clickRowAction(page, 'Investigating slow', 'View details') + await page.getByRole('link', { name: 'ccdac0…359c31' }).click() await expect(page).toHaveURL( '/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31' ) From 94b120278fb515269ab97a09b26020e98a862df0 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 14 Sep 2026 17:06:18 -0500 Subject: [PATCH 27/36] Per-state download disabled reasons, document polling logic --- app/pages/system/SupportBundleDetail.tsx | 40 ++++++++++++------------ app/pages/system/SupportBundlesPage.tsx | 11 ++++--- app/util/support-bundle.ts | 31 ++++++++++++++++-- test/e2e/support-bundles.e2e.ts | 11 ++----- 4 files changed, 58 insertions(+), 35 deletions(-) diff --git a/app/pages/system/SupportBundleDetail.tsx b/app/pages/system/SupportBundleDetail.tsx index e8288f9cd..d57d03ab4 100644 --- a/app/pages/system/SupportBundleDetail.tsx +++ b/app/pages/system/SupportBundleDetail.tsx @@ -16,7 +16,6 @@ import { supportBundleTransitioning, useApiMutation, usePrefetchedQuery, - type SupportBundleInfo, } from '@oxide/api' import { Issues16Icon } from '@oxide/design-system/icons/react' @@ -38,21 +37,23 @@ 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 { downloadBundle, DOWNLOAD_DISABLED_REASON } from '~/util/support-bundle' - -const SEC = 1000 // ms -const POLL_INTERVAL = 10 * SEC +import { + downloadBundle, + downloadDisabledReason, + POLL_INTERVAL, +} from '~/util/support-bundle' -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 && supportBundleTransitioning(data.state) ? POLL_INTERVAL : false), -}) +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 } }) => + data && supportBundleTransitioning(data.state) ? POLL_INTERVAL : false, + } + ) export async function clientLoader({ params }: LoaderFunctionArgs) { await queryClient.prefetchQuery(bundleView(getSupportBundleSelector(params))) @@ -92,8 +93,7 @@ export default function SupportBundleDetail() { const { bundleId } = useSupportBundleSelector() const { data: bundle } = usePrefetchedQuery(bundleView({ bundleId })) - // the bundle zip only exists once collection has completed - const isActive = bundle.state === 'active' + const downloadDisabled = downloadDisabledReason(bundle.state) const form = useForm({ defaultValues: { userComment: bundle.userComment || '' } }) // must destructure to subscribe to changes; inlining does not work @@ -148,7 +148,7 @@ export default function SupportBundleDetail() { - {isActive && ( + {bundle.state === 'active' && ( @@ -157,8 +157,8 @@ export default function SupportBundleDetail() {
- New Support Bundle + New support bundle
{table} diff --git a/test/e2e/support-bundles.e2e.ts b/test/e2e/support-bundles.e2e.ts index e9a9f6dfc..f4cfc31f5 100644 --- a/test/e2e/support-bundles.e2e.ts +++ b/test/e2e/support-bundles.e2e.ts @@ -20,12 +20,12 @@ test('support bundle list', async ({ page }) => { await expectRowVisible(table, { state: 'active', - Reason: 'Created by external API', + 'Creation reason': 'Created by external API', Comment: 'Investigating slow instance start times', }) await expectRowVisible(table, { state: 'collecting', - Reason: 'Diagnosis: fan failure on sled BRM42220031', + 'Creation reason': 'Diagnosis: fan failure on sled BRM42220031', }) await expectRowVisible(table, { state: 'failed' }) @@ -123,7 +123,7 @@ test('bundle detail modal for failed bundle', async ({ page }) => { test('detail modal polls a collecting bundle to active', async ({ page }) => { await page.goto('/system/support-bundles') - await page.getByRole('link', { name: 'New Support Bundle' }).click() + await page.getByRole('link', { name: 'New support bundle' }).click() await page.getByRole('textbox', { name: 'Comment' }).fill('poll me') await page.getByRole('button', { name: 'Create support bundle' }).click() await expectToast(page, 'Support bundle created') @@ -145,7 +145,7 @@ test('detail modal polls a collecting bundle to active', async ({ page }) => { test('create support bundle and poll to active', async ({ page }) => { await page.goto('/system/support-bundles') - await page.getByRole('link', { name: 'New Support Bundle' }).click() + await page.getByRole('link', { name: 'New support bundle' }).click() await expect(page).toHaveURL('/system/support-bundles-new') await page.getByRole('textbox', { name: 'Comment' }).fill('test bundle') From 53c6df849e3ae49d68e2cea71c0381136f5210cb Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 14 Sep 2026 17:57:34 -0500 Subject: [PATCH 29/36] Bump design system, use Archive icon for support bundles --- app/layouts/SystemLayout.tsx | 4 ++-- app/pages/system/SupportBundleDetail.tsx | 4 ++-- app/pages/system/SupportBundlesPage.tsx | 8 ++++---- package-lock.json | 8 ++++---- package.json | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index e56b0f964..536b137d5 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -10,8 +10,8 @@ import { useLocation } from 'react-router' import { api, q, queryClient } from '@oxide/api' import { Access16Icon, + Archive16Icon, Cloud16Icon, - Issues16Icon, IpGlobal16Icon, Logs16Icon, Metrics16Icon, @@ -115,7 +115,7 @@ export default function SystemLayout() { System Update - Support Bundles + Support Bundles Audit Log diff --git a/app/pages/system/SupportBundleDetail.tsx b/app/pages/system/SupportBundleDetail.tsx index 6cca6ddbe..c3bbdcb1f 100644 --- a/app/pages/system/SupportBundleDetail.tsx +++ b/app/pages/system/SupportBundleDetail.tsx @@ -17,7 +17,7 @@ import { useApiMutation, usePrefetchedQuery, } from '@oxide/api' -import { Issues16Icon } from '@oxide/design-system/icons/react' +import { Archive16Icon } from '@oxide/design-system/icons/react' import { BundleCommentField } from '~/components/form/fields/BundleCommentField' import { SideModalForm } from '~/components/form/SideModalForm' @@ -120,7 +120,7 @@ export default function SupportBundleDetail() { submitDisabled={isDirty ? undefined : 'No changes to save'} subtitle={ - {truncate(bundle.id, 14, 'middle')} + {truncate(bundle.id, 14, 'middle')} } onDismiss={onDismiss} diff --git a/app/pages/system/SupportBundlesPage.tsx b/app/pages/system/SupportBundlesPage.tsx index b012bb807..b11fc81e1 100644 --- a/app/pages/system/SupportBundlesPage.tsx +++ b/app/pages/system/SupportBundlesPage.tsx @@ -18,7 +18,7 @@ import { useApiMutation, type SupportBundleInfo, } from '@oxide/api' -import { Issues16Icon, Issues24Icon } from '@oxide/design-system/icons/react' +import { Archive16Icon, Archive24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' import { HL } from '~/components/HL' @@ -51,7 +51,7 @@ import { const EmptyState = () => ( } + icon={} title="No support bundles" body="Create a support bundle to see it here" buttonText="New support bundle" @@ -169,10 +169,10 @@ export default function SupportBundlesPage() { return ( <> - }>Support Bundles + }>Support Bundles } + icon={} summary="Support bundles capture diagnostic data to share with Oxide support." links={[docLinks.supportBundles]} /> diff --git a/package-lock.json b/package-lock.json index 499375486..c6b10d6c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@base-ui/react": "^1.1.0", "@floating-ui/react": "^0.26.23", "@headlessui/react": "^2.2.9", - "@oxide/design-system": "^6.7.1", + "@oxide/design-system": "^6.7.2", "@peculiar/x509": "^1.12.3", "@react-aria/live-announcer": "^3.3.4", "@tailwindcss/container-queries": "^0.1.1", @@ -1287,9 +1287,9 @@ } }, "node_modules/@oxide/design-system": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-6.7.1.tgz", - "integrity": "sha512-ghOOWy15Sx8XFa2akrZOk6+YnPTmPel5NQW2H3Sp5C1KmLO2lzBE4fJ5bghr82PRaj4mIpSsz5U2nv8pXOrd7Q==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-6.7.2.tgz", + "integrity": "sha512-KKnQ9Y3B9h32wK+utaDJPnAWXwbIli79hZzybjiuF+yL7bUJaRRn6e5tOQBALMKj63Htl6aULVvlyLPQGOEJoQ==", "license": "MPL 2.0", "dependencies": { "@floating-ui/react": "^0.27.16", diff --git a/package.json b/package.json index dfe907766..d39946440 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@base-ui/react": "^1.1.0", "@floating-ui/react": "^0.26.23", "@headlessui/react": "^2.2.9", - "@oxide/design-system": "^6.7.1", + "@oxide/design-system": "^6.7.2", "@peculiar/x509": "^1.12.3", "@react-aria/live-announcer": "^3.3.4", "@tailwindcss/container-queries": "^0.1.1", From 4ca8bb37120e43a53bf01df5b9625fbeb7caab53 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Tue, 15 Sep 2026 10:24:31 +0100 Subject: [PATCH 30/36] Use 16 icon for empty state --- app/pages/system/SupportBundlesPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/system/SupportBundlesPage.tsx b/app/pages/system/SupportBundlesPage.tsx index b11fc81e1..082b2d423 100644 --- a/app/pages/system/SupportBundlesPage.tsx +++ b/app/pages/system/SupportBundlesPage.tsx @@ -51,7 +51,7 @@ import { const EmptyState = () => ( } + icon={} title="No support bundles" body="Create a support bundle to see it here" buttonText="New support bundle" From af1e3f23d043251ebd9651b62807522173f2ae9f Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Tue, 15 Sep 2026 10:34:39 +0100 Subject: [PATCH 31/36] Switch to 24 for consistency ... --- app/pages/settings/AccessTokensPage.tsx | 2 +- app/pages/settings/SSHKeysPage.tsx | 2 +- app/pages/system/SupportBundlesPage.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/pages/settings/AccessTokensPage.tsx b/app/pages/settings/AccessTokensPage.tsx index fd2f0c32c..018ac275b 100644 --- a/app/pages/settings/AccessTokensPage.tsx +++ b/app/pages/settings/AccessTokensPage.tsx @@ -103,7 +103,7 @@ export default function AccessTokensPage() { const emptyState = ( } + icon={} title="No access tokens" body="Your access tokens will appear here when they are created" /> diff --git a/app/pages/settings/SSHKeysPage.tsx b/app/pages/settings/SSHKeysPage.tsx index 6662600c3..36126a3fa 100644 --- a/app/pages/settings/SSHKeysPage.tsx +++ b/app/pages/settings/SSHKeysPage.tsx @@ -83,7 +83,7 @@ export default function SSHKeysPage() { const emptyState = ( } + icon={} title="No SSH keys" body="Add an SSH key to see it here" buttonText="Add SSH key" diff --git a/app/pages/system/SupportBundlesPage.tsx b/app/pages/system/SupportBundlesPage.tsx index 082b2d423..b11fc81e1 100644 --- a/app/pages/system/SupportBundlesPage.tsx +++ b/app/pages/system/SupportBundlesPage.tsx @@ -51,7 +51,7 @@ import { const EmptyState = () => ( } + icon={} title="No support bundles" body="Create a support bundle to see it here" buttonText="New support bundle" From 534dd077e3679c55af7fbb6ddea7ddca0b40d076 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 14 Sep 2026 18:18:07 -0500 Subject: [PATCH 32/36] Test collection failure and comment byte limit --- test/e2e/support-bundles.e2e.ts | 36 ++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/test/e2e/support-bundles.e2e.ts b/test/e2e/support-bundles.e2e.ts index f4cfc31f5..d5b9ca7da 100644 --- a/test/e2e/support-bundles.e2e.ts +++ b/test/e2e/support-bundles.e2e.ts @@ -25,7 +25,8 @@ test('support bundle list', async ({ page }) => { }) await expectRowVisible(table, { state: 'collecting', - 'Creation reason': 'Diagnosis: fan failure on sled BRM42220031', + 'Creation reason': + 'Requested by PhysicalDisk diagnosis engine for case ffae3627-d3c5-4b80-a05a-37139dcf9ef5', }) await expectRowVisible(table, { state: 'failed' }) @@ -59,7 +60,7 @@ test('download only available for active bundles', async ({ page }) => { await page.goto('/system/support-bundles') // collecting bundle: download disabled with reason - const collectingRow = page.getByRole('row', { name: 'fan failure' }) + const collectingRow = page.getByRole('row', { name: 'PhysicalDisk' }) await collectingRow.getByRole('button', { name: 'Row actions' }).click() const downloadItem = page.getByRole('menuitem', { name: 'Download' }) await expect(downloadItem).toBeDisabled() @@ -162,6 +163,35 @@ test('create support bundle and poll to active', async ({ page }) => { await expect(row.getByText('active')).toBeVisible({ timeout: 20_000 }) }) +test('create bundle whose collection fails', async ({ page }) => { + await page.goto('/system/support-bundles-new') + + // mock sentinel: collection fails instead of completing + await page.getByRole('textbox', { name: 'Comment' }).fill('fail collection') + await page.getByRole('button', { name: 'Create support bundle' }).click() + await expectToast(page, 'Support bundle created') + + const row = page.getByRole('table').getByRole('row', { name: 'fail collection' }) + await expect(row.getByText('collecting')).toBeVisible() + // mock flips it to failed after 3s; list polls every 10s + await expect(row.getByText('failed')).toBeVisible({ timeout: 20_000 }) + await row.getByRole('button', { name: 'Tip' }).hover() + await expect(page.getByRole('tooltip')).toHaveText('Bundle collection failed') +}) + +test('comment length validation', async ({ page }) => { + await page.goto('/system/support-bundles-new') + + // 2049 two-byte characters is 4098 bytes, over the limit despite being + // well under 4096 characters + await page.getByRole('textbox', { name: 'Comment' }).fill('é'.repeat(2049)) + await page.getByRole('button', { name: 'Create support bundle' }).click() + // scope to the dialog: the message is also announced in a live region + const modal = page.getByRole('dialog', { name: 'Create support bundle' }) + await expect(modal.getByText('Comment cannot exceed 4096 bytes')).toBeVisible() + await expect(page).toHaveURL('/system/support-bundles-new') +}) + test('create shows insufficient capacity error in modal', async ({ page }) => { await page.goto('/system/support-bundles-new') @@ -243,7 +273,7 @@ test('bundle deleted mid-view 404s', async ({ page }) => { test('delete collecting bundle warns about cancellation', async ({ page }) => { await page.goto('/system/support-bundles') - await clickRowAction(page, 'fan failure', 'Delete') + await clickRowAction(page, 'PhysicalDisk', 'Delete') await expect( page.getByText('This bundle is still being collected', { exact: false }) ).toBeVisible() From 9ac206c7729dfda4311d06d801c3693771826a66 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 14 Sep 2026 18:24:25 -0500 Subject: [PATCH 33/36] Fix comments, use real FM reason format in mock --- app/components/form/SideModalForm.tsx | 7 +++++-- app/pages/system/SupportBundleDetail.tsx | 4 +--- app/pages/system/SupportBundlesPage.tsx | 4 ++-- app/util/support-bundle.ts | 2 +- mock-api/support-bundle.ts | 8 +++++--- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/app/components/form/SideModalForm.tsx b/app/components/form/SideModalForm.tsx index dc2ba135a..8a00a4ca6 100644 --- a/app/components/form/SideModalForm.tsx +++ b/app/components/form/SideModalForm.tsx @@ -77,8 +77,11 @@ export function SideModalForm({ ? `Update ${resourceName}` : submitLabel || title || `Create ${resourceName}` - // must be destructured up here to subscribe to changes. inlining - // form.formState.isDirty does not work + // formState is a proxy whose getters register a subscription, and RHF only + // re-renders for keys that were read during render. isDirty is used in the + // onDismiss callback below, so it has to be read up here first, or the + // callback would see a stale value. See the Rules section of the docs: + // https://react-hook-form.com/docs/useform/formstate const { isDirty, isSubmitting } = form.formState const [showNavGuard, setShowNavGuard] = useState(false) diff --git a/app/pages/system/SupportBundleDetail.tsx b/app/pages/system/SupportBundleDetail.tsx index c3bbdcb1f..4e2d3f727 100644 --- a/app/pages/system/SupportBundleDetail.tsx +++ b/app/pages/system/SupportBundleDetail.tsx @@ -96,8 +96,6 @@ export default function SupportBundleDetail() { const downloadDisabled = downloadDisabledReason(bundle.state) 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()) @@ -117,7 +115,7 @@ export default function SupportBundleDetail() { // scoped to the one editable field, like access forms' "Update role" resourceName="comment" title="Support bundle" - submitDisabled={isDirty ? undefined : 'No changes to save'} + submitDisabled={form.formState.isDirty ? undefined : 'No changes to save'} subtitle={ {truncate(bundle.id, 14, 'middle')} diff --git a/app/pages/system/SupportBundlesPage.tsx b/app/pages/system/SupportBundlesPage.tsx index b11fc81e1..6f1ad7ad7 100644 --- a/app/pages/system/SupportBundlesPage.tsx +++ b/app/pages/system/SupportBundlesPage.tsx @@ -177,8 +177,8 @@ export default function SupportBundlesPage() { links={[docLinks.supportBundles]} /> - {/* Avoid changing justify-end on TableActions for this one case. We can - * fix this properly when we add refresh and filtering for all tables. */} + {/* Same override as the instances page. Fix properly when refresh and + * filtering come to all tables. */}
[] = [ user_comment: 'Investigating slow instance start times', }, { - // created by fault management rather than an operator, hence the - // diagnosis-style reason and lack of comment + // created by fault management rather than an operator, hence no comment. + // reason format is the fallback FM uses when the diagnosis engine gives none + // https://github.com/oxidecomputer/omicron/blob/9d95e0c/nexus/src/app/background/tasks/fm_rendezvous.rs#L433-L436 id: '7bdd4ef3-8183-46fe-9e9f-81b34bf6b2c5', - reason_for_creation: 'Diagnosis: fan failure on sled BRM42220031', + reason_for_creation: + 'Requested by PhysicalDisk diagnosis engine for case ffae3627-d3c5-4b80-a05a-37139dcf9ef5', state: 'collecting', time_created: new Date('2025-08-01T09:15:00Z').toISOString(), }, From 723f1a7ba8fe87f37236e58ceec7d0fcb22901f9 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 15 Sep 2026 08:28:19 -0500 Subject: [PATCH 34/36] Test bundle download from row action and modal --- test/e2e/support-bundles.e2e.ts | 34 ++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/test/e2e/support-bundles.e2e.ts b/test/e2e/support-bundles.e2e.ts index d5b9ca7da..b90a6d681 100644 --- a/test/e2e/support-bundles.e2e.ts +++ b/test/e2e/support-bundles.e2e.ts @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ -import { expect, test } from '@playwright/test' +import { expect, test, type Download, type Page } from '@playwright/test' import { clickRowAction, expectRowVisible, expectToast, getPageAsUser } from './utils' @@ -73,6 +73,38 @@ test('download only available for active bundles', async ({ page }) => { await expect(page.getByRole('menuitem', { name: 'Download' })).toBeEnabled() }) +const BUNDLE_ID = 'ccdac005-66a8-4921-9e8b-30531c359c31' + +/** + * Download is an navigation, which bypasses MSW, so the request + * falls through to the proxy and the download itself fails (see + * app/util/support-bundle.ts). The browser still starts it, so we can check + * the parts the console controls: URL, filename, and no navigation. + */ +async function expectBundleDownload(page: Page, download: Download) { + expect(download.url()).toBe( + `http://localhost:4009/v1/system/support-bundles/${BUNDLE_ID}/download` + ) + expect(download.suggestedFilename()).toBe(`support-bundle-${BUNDLE_ID}.zip`) + // download navigation doesn't leave the page + await expect(page).toHaveURL(/\/system\/support-bundles/) +} + +test('download from row action and detail modal', async ({ page }) => { + await page.goto('/system/support-bundles') + + let downloadPromise = page.waitForEvent('download') + await clickRowAction(page, 'Investigating slow', 'Download') + await expectBundleDownload(page, await downloadPromise) + + await page.getByRole('link', { name: 'ccdac0…359c31' }).click() + const modal = page.getByRole('dialog', { name: 'Support bundle' }) + downloadPromise = page.waitForEvent('download') + await modal.getByRole('button', { name: 'Download bundle' }).click() + await expectBundleDownload(page, await downloadPromise) + await expect(modal).toBeVisible() +}) + test('bundle detail modal shows metadata for active bundle', async ({ page }) => { await page.goto('/system/support-bundles') From bc8a479ef2918873fcf22961038cf515fb63dd03 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 15 Sep 2026 11:10:12 -0500 Subject: [PATCH 35/36] last code review tweaks --- app/forms/support-bundle-create.tsx | 6 ++---- app/pages/system/SupportBundleDetail.tsx | 4 ++-- test/e2e/support-bundles.e2e.ts | 4 ++-- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/app/forms/support-bundle-create.tsx b/app/forms/support-bundle-create.tsx index 590d3a403..7fbaf18e9 100644 --- a/app/forms/support-bundle-create.tsx +++ b/app/forms/support-bundle-create.tsx @@ -24,8 +24,6 @@ export const handle = titleCrumb('New support bundle') export default function CreateSupportBundleSideModalForm() { const navigate = useNavigate() - const onDismiss = () => navigate(pb.supportBundles()) - const createBundle = useApiMutation(api.supportBundleCreate, { onSuccess() { queryClient.invalidateEndpoint('supportBundleList') @@ -41,9 +39,9 @@ export default function CreateSupportBundleSideModalForm() { form={form} formType="create" resourceName="support bundle" - onDismiss={onDismiss} + onDismiss={() => navigate(pb.supportBundles())} onSubmit={({ userComment }) => { - createBundle.mutate({ body: { userComment: userComment || null } }) + createBundle.mutate({ body: { userComment: userComment.trim() || null } }) }} loading={createBundle.isPending || createBundle.isSuccess} submitError={createBundle.error} diff --git a/app/pages/system/SupportBundleDetail.tsx b/app/pages/system/SupportBundleDetail.tsx index 4e2d3f727..a35e6a199 100644 --- a/app/pages/system/SupportBundleDetail.tsx +++ b/app/pages/system/SupportBundleDetail.tsx @@ -112,7 +112,7 @@ export default function SupportBundleDetail() { { editBundle.mutate({ path: { bundleId }, - body: { userComment: userComment || null }, + body: { userComment: userComment.trim() || null }, }) }} loading={editBundle.isPending || editBundle.isSuccess} diff --git a/test/e2e/support-bundles.e2e.ts b/test/e2e/support-bundles.e2e.ts index b90a6d681..e9e8d6483 100644 --- a/test/e2e/support-bundles.e2e.ts +++ b/test/e2e/support-bundles.e2e.ts @@ -153,7 +153,7 @@ test('bundle detail modal for failed bundle', async ({ page }) => { await expect(page.getByText('Bundle collection failed')).toBeVisible() }) -test('detail modal polls a collecting bundle to active', async ({ page }) => { +test('detail modal polls a collecting bundle until active', async ({ page }) => { await page.goto('/system/support-bundles') await page.getByRole('link', { name: 'New support bundle' }).click() @@ -175,7 +175,7 @@ test('detail modal polls a collecting bundle to active', async ({ page }) => { await expect(modal.getByRole('button', { name: 'Download bundle' })).toBeEnabled() }) -test('create support bundle and poll to active', async ({ page }) => { +test('create support bundle and poll until active', async ({ page }) => { await page.goto('/system/support-bundles') await page.getByRole('link', { name: 'New support bundle' }).click() From 7982878f124f54d4966a0b34b4f0d674840e0416 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 15 Sep 2026 12:05:13 -0500 Subject: [PATCH 36/36] Serve empty zip for bundle download in mock mode Firefox does not fire a download event for a failed response, so the e2e test needs the dev server to actually answer the download URL. --- app/util/support-bundle.ts | 8 +++++--- mock-api/msw/handlers.ts | 4 ++-- test/e2e/support-bundles.e2e.ts | 14 ++++++++------ vite.config.ts | 20 ++++++++++++++++++++ 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/app/util/support-bundle.ts b/app/util/support-bundle.ts index e7d68ffd2..989a0b941 100644 --- a/app/util/support-bundle.ts +++ b/app/util/support-bundle.ts @@ -16,9 +16,11 @@ import type { SupportBundleState } from '@oxide/api' * spec next to this file guards against it drifting when the API is * regenerated. * - * Note this means downloads do not work against the mock API: the anchor - * click is a download navigation, which MSW's service worker does not - * intercept, so the request falls through to the dev server. + * Note the anchor click is a navigation-mode request, and MSW's service + * worker deliberately passes navigation requests through to the network: + * https://github.com/mswjs/msw/blob/b1c2a13/src/mockServiceWorker.js#L100-L103 + * So in mock mode the request falls through to the dev server, which serves + * an empty zip (see vite.config.ts). */ export const bundleDownloadUrl = (bundleId: string) => diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index af936836c..2dc72a30a 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -3102,8 +3102,8 @@ export const handlers = makeHandlers({ sledListUninitialized: NotImplemented, sledSetProvisionPolicy: NotImplemented, // unreachable in the mock: the console downloads bundles with an - // navigation, which MSW's service worker can't intercept (see - // app/util/support-bundle.ts) + // navigation, which MSW's service worker can't intercept. The dev server + // handles it instead (see vite.config.ts and app/util/support-bundle.ts) supportBundleDownload: NotImplemented, supportBundleDownloadFile: NotImplemented, supportBundleHeadFile: NotImplemented, diff --git a/test/e2e/support-bundles.e2e.ts b/test/e2e/support-bundles.e2e.ts index e9e8d6483..12cc52b74 100644 --- a/test/e2e/support-bundles.e2e.ts +++ b/test/e2e/support-bundles.e2e.ts @@ -76,10 +76,10 @@ test('download only available for active bundles', async ({ page }) => { const BUNDLE_ID = 'ccdac005-66a8-4921-9e8b-30531c359c31' /** - * Download is an navigation, which bypasses MSW, so the request - * falls through to the proxy and the download itself fails (see - * app/util/support-bundle.ts). The browser still starts it, so we can check - * the parts the console controls: URL, filename, and no navigation. + * Download is an navigation, which bypasses MSW. The dev server + * answers it with an empty zip (see vite.config.ts) so the browser starts a + * real download, and we can check the parts the console controls: URL, + * filename, and no navigation. */ async function expectBundleDownload(page: Page, download: Download) { expect(download.url()).toBe( @@ -87,7 +87,7 @@ async function expectBundleDownload(page: Page, download: Download) { ) expect(download.suggestedFilename()).toBe(`support-bundle-${BUNDLE_ID}.zip`) // download navigation doesn't leave the page - await expect(page).toHaveURL(/\/system\/support-bundles/) + await expect(page).toHaveURL('/system/support-bundles') } test('download from row action and detail modal', async ({ page }) => { @@ -101,7 +101,9 @@ test('download from row action and detail modal', async ({ page }) => { const modal = page.getByRole('dialog', { name: 'Support bundle' }) downloadPromise = page.waitForEvent('download') await modal.getByRole('button', { name: 'Download bundle' }).click() - await expectBundleDownload(page, await downloadPromise) + expect((await downloadPromise).suggestedFilename()).toBe( + `support-bundle-${BUNDLE_ID}.zip` + ) await expect(modal).toBeVisible() }) diff --git a/vite.config.ts b/vite.config.ts index bfc1eee17..372d029b3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -147,6 +147,26 @@ export default defineConfig(({ mode }) => ({ }, react(), apiMode === 'remote' && basicSsl(), + apiMode === 'msw' && { + // The console downloads support bundles with an navigation. + // MSW's service worker bypasses navigation requests (see + // app/util/support-bundle.ts), so the request would otherwise hit the + // /v1 proxy and fail. Serve an empty zip so the download works in the + // mock dev server and in e2e tests. Only GET: the HEAD the detail modal + // uses for size goes through MSW as a normal fetch. + name: 'mock-support-bundle-download', + configureServer(server) { + server.middlewares.use((req, res, next) => { + const isDownload = + req.method === 'GET' && + /^\/v1\/system\/support-bundles\/[^/]+\/download$/.test(req.url || '') + if (!isDownload) return next() + res.writeHead(200, { 'Content-Type': 'application/zip' }) + // end-of-central-directory record: the smallest valid (empty) zip + res.end(Buffer.from('504b0506' + '00'.repeat(18), 'hex')) + }) + }, + }, ], html: { // don't include a placeholder nonce in production.