From f527b625cb2bb13cabf6244fa7736103c012f0b8 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 4 Aug 2026 19:12:16 -0700 Subject: [PATCH 1/7] 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 2/7] 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 5/7] 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 6/7] 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 7/7] 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' })