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/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/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..0c2f1ae28 --- /dev/null +++ b/app/forms/support-bundle-create.tsx @@ -0,0 +1,76 @@ +/* + * 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, + MAX_BUNDLE_COMMENT_BYTES, + queryClient, + useApiMutation, + utf8ByteLength, +} 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' + +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} + > + + + 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 new file mode 100644 index 000000000..3a27b59f7 --- /dev/null +++ b/app/forms/support-bundle-edit.tsx @@ -0,0 +1,89 @@ +/* + * 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/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/SupportBundlesPage.tsx b/app/pages/system/SupportBundlesPage.tsx new file mode 100644 index 000000000..0532552b3 --- /dev/null +++ b/app/pages/system/SupportBundlesPage.tsx @@ -0,0 +1,194 @@ +/* + * 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 { 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 { makeCrumb } from '~/hooks/use-crumbs' +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 { 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 { TipIcon } from '~/ui/lib/TipIcon' +import { truncate, Truncate } from '~/ui/lib/Truncate' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' +import { bundleDownloadUrl, 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 }) => ( +
+ + {bundle.reasonForFailure && {bundle.reasonForFailure}} +
+) + +const colHelper = createColumnHelper() + +const staticColumns = [ + colHelper.accessor('id', { + header: 'ID', + cell: (info) => ( + + ), + }), + colHelper.accessor('state', { + cell: (info) => , + }), + colHelper.accessor('userComment', { + header: 'Comment', + cell: (info) => , + }), + colHelper.accessor('reasonForCreation', { + header: 'Reason', + cell: (info) => , + }), + colHelper.accessor('timeCreated', Columns.timeCreated), +] + +const SEC = 1000 // ms +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') + ? POLL_INTERVAL + : false, + } +) + +export async function clientLoader() { + await queryClient.prefetchQuery(bundleList.optionsFn()) + return null +} + +// path is needed because the crumb attaches to a pathless route, whose +// pathname is /system/ +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') + // "deleting" rather than "deleted" because the bundle sits in state + // 'destroying' until a background task frees its backing storage + // prettier-ignore + addToast(<>Deleting support bundle {truncate(variables.path.bundleId, 14, 'middle')}) + }, + }) + + const makeActions = useCallback( + (bundle: SupportBundleInfo): MenuAction[] => [ + { + 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." + links={[docLinks.supportBundles]} + /> + + + New Support Bundle + + {table} + + + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 2fdaadc22..5cb689269 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -269,6 +269,18 @@ 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('./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..278c1815f 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -897,6 +897,24 @@ exports[`breadcrumbs 2`] = ` "path": "/system/networking/", }, ], + "supportBundleEdit (/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31/edit)": [ + { + "label": "Support Bundles", + "path": "/system/support-bundles", + }, + ], + "supportBundles (/system/support-bundles)": [ + { + "label": "Support Bundles", + "path": "/system/support-bundles", + }, + ], + "supportBundlesNew (/system/support-bundles-new)": [ + { + "label": "Support Bundles", + "path": "/system/support-bundles", + }, + ], "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..4f2807827 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,9 @@ 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", + "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..523184faf 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -149,6 +149,11 @@ export const pb = { systemUpdate: () => '/system/update', + supportBundles: () => '/system/support-bundles', + supportBundlesNew: () => '/system/support-bundles-new', + supportBundleEdit: (params: PP.SupportBundle) => + `${pb.supportBundles()}/${params.bundleId}/edit`, + 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.ts b/app/util/support-bundle.ts new file mode 100644 index 000000000..ac39e351c --- /dev/null +++ b/app/util/support-bundle.ts @@ -0,0 +1,23 @@ +/* + * 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 + */ + +/* + * 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` + +export function triggerDownload(url: string, filename: string) { + const link = document.createElement('a') + link.href = url + link.download = filename + link.click() +} 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..9e1fb6943 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' @@ -2012,6 +2012,107 @@ export const handlers = makeHandlers({ return paginated(query, db.users) }, + supportBundleList({ query, cookies }) { + requireFleetViewer(cookies) + 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) + 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 + // 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 + 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"`, + }, + }) + }, switchList: ({ query, cookies }) => { requireFleetViewer(cookies) return paginated(query, db.switches) @@ -2725,16 +2826,10 @@ 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..493c839f2 --- /dev/null +++ b/mock-api/support-bundle.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 type { SupportBundleInfo } from '@oxide/api' + +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', + // 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(), + }, +] diff --git a/test/e2e/support-bundles.e2e.ts b/test/e2e/support-bundles.e2e.ts new file mode 100644 index 000000000..24f4bf34c --- /dev/null +++ b/test/e2e/support-bundles.e2e.ts @@ -0,0 +1,170 @@ +/* + * 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', + Reason: 'Created by external API', + Comment: 'Investigating slow instance start times', + }) + await expectRowVisible(table, { + state: 'collecting', + Reason: 'Diagnosis: fan failure on sled BRM42220031', + }) + 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() + 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 shows failure reason on tip icon hover', async ({ page }) => { + await page.goto('/system/support-bundles') + + const row = page.getByRole('row', { name: 'failed' }) + await row.getByRole('button', { name: 'Tip' }).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') + + const activeRow = page.getByRole('row', { name: 'Investigating slow' }) + await activeRow.getByRole('button', { name: 'Row actions' }).click() + await expect(page.getByRole('menuitem', { name: 'Download' })).toBeEnabled() +}) + +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, /Deleting support bundle/) + + 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, /Deleting support bundle/) + + 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 },