diff --git a/app/api/selectors.ts b/app/api/selectors.ts index e2b3ead6e..73131242b 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 AlertReceiver = Readonly<{ receiver?: string }> diff --git a/app/api/util.ts b/app/api/util.ts index 9eb5b14af..22f61843f 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -23,6 +23,8 @@ import type { SiloIpPool, SiloUtilization, Sled, + SnapshotState, + SupportBundleState, Vpc, VpcFirewallRule, VpcFirewallRuleUpdate, @@ -111,6 +113,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 + /** * The `default_*` network interface attachment types resolve a VPC and VPC * subnet both named literally 'default', so they fail with a 404 if that VPC @@ -250,14 +260,15 @@ export const instanceCan = R.mapValues(instanceActions, (states: InstanceState[] return test }) +/** + * States the instance is expected to leave on its own, so the UI should poll + * and show a spinner. Exhaustive match so new states have to be classified. + */ export function instanceTransitioning(runState: InstanceState) { - return ( - runState === 'creating' || - runState === 'starting' || - runState === 'rebooting' || - runState === 'migrating' || - runState === 'stopping' - ) + return match(runState) + .with('creating', 'starting', 'rebooting', 'migrating', 'stopping', () => true) + .with('running', 'stopped', 'repairing', 'failed', 'destroyed', () => false) + .exhaustive() } /** @@ -315,13 +326,42 @@ const canSnapshot = (d: SnapshotDisk) => { } canSnapshot.states = snapshotStates +/** See {@link instanceTransitioning} */ export function diskTransitioning(diskState: DiskState['state']) { - return ( - diskState === 'attaching' || - diskState === 'creating' || - diskState === 'detaching' || - diskState === 'finalizing' - ) + return match(diskState) + .with('attaching', 'creating', 'detaching', 'finalizing', () => true) + .with( + 'attached', + 'detached', + 'destroyed', + 'faulted', + 'maintenance', + 'import_ready', + 'importing_from_url', + 'importing_from_bulk_writes', + () => false + ) + .exhaustive() +} + +/** See {@link instanceTransitioning} */ +export function snapshotTransitioning(state: SnapshotState) { + return match(state) + .with('creating', () => true) + .with('ready', 'faulted', 'destroyed', () => false) + .exhaustive() +} + +/** + * See {@link instanceTransitioning}. 'active' and 'failed' are terminal, and + * 'destroying' resolves by the bundle record going away. + * https://github.com/oxidecomputer/omicron/blob/6db4c7e/nexus/db-model/src/support_bundle.rs#L53-L66 + */ +export function supportBundleTransitioning(state: SupportBundleState) { + return match(state) + .with('collecting', 'destroying', () => true) + .with('active', 'failed', () => false) + .exhaustive() } export const diskCan = { diff --git a/app/components/StateBadge.tsx b/app/components/StateBadge.tsx index 877a09915..002a42e60 100644 --- a/app/components/StateBadge.tsx +++ b/app/components/StateBadge.tsx @@ -10,10 +10,13 @@ import cn from 'classnames' import { diskTransitioning, instanceTransitioning, + snapshotTransitioning, + supportBundleTransitioning, type DiskState, type DiskType, type InstanceState, type SnapshotState, + type SupportBundleState, } from '@oxide/api' import { Badge, type BadgeColor } from '@oxide/design-system/ui' @@ -78,13 +81,35 @@ const SNAPSHOT_COLORS: Record = { export const SnapshotStateBadge = (props: { state: SnapshotState; className?: string }) => ( - {props.state === 'creating' && ( + {snapshotTransitioning(props.state) && ( )} {props.state} ) +const SUPPORT_BUNDLE_COLORS: Record = { + collecting: 'blue', + active: 'default', + destroying: 'neutral', + failed: 'destructive', +} + +export const SupportBundleStateBadge = (props: { + state: SupportBundleState + className?: string +}) => ( + + {supportBundleTransitioning(props.state) && ( + + )} + {props.state} + +) + export const DiskTypeBadge = (props: { diskType: DiskType; className?: string }) => ( {props.diskType} diff --git a/app/components/form/SideModalForm.tsx b/app/components/form/SideModalForm.tsx index dc2ba135a..8a00a4ca6 100644 --- a/app/components/form/SideModalForm.tsx +++ b/app/components/form/SideModalForm.tsx @@ -77,8 +77,11 @@ export function SideModalForm({ ? `Update ${resourceName}` : submitLabel || title || `Create ${resourceName}` - // must be destructured up here to subscribe to changes. inlining - // form.formState.isDirty does not work + // formState is a proxy whose getters register a subscription, and RHF only + // re-renders for keys that were read during render. isDirty is used in the + // onDismiss callback below, so it has to be read up here first, or the + // callback would see a stale value. See the Rules section of the docs: + // https://react-hook-form.com/docs/useform/formstate const { isDirty, isSubmitting } = form.formState const [showNavGuard, setShowNavGuard] = useState(false) diff --git a/app/components/form/fields/BundleCommentField.tsx b/app/components/form/fields/BundleCommentField.tsx new file mode 100644 index 000000000..eeef92cac --- /dev/null +++ b/app/components/form/fields/BundleCommentField.tsx @@ -0,0 +1,34 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import type { Control } from 'react-hook-form' + +import { MAX_BUNDLE_COMMENT_BYTES, utf8ByteLength } from '@oxide/api' + +import { TextField } from './TextField' + +/** Support bundle comment textarea, shared by the create and edit forms */ +export function BundleCommentField({ + control, +}: { + control: Control<{ userComment: string }> +}) { + return ( + + utf8ByteLength(value) > MAX_BUNDLE_COMMENT_BYTES + ? `Comment cannot exceed ${MAX_BUNDLE_COMMENT_BYTES} bytes` + : true + } + /> + ) +} diff --git a/app/forms/support-bundle-create.tsx b/app/forms/support-bundle-create.tsx new file mode 100644 index 000000000..7fbaf18e9 --- /dev/null +++ b/app/forms/support-bundle-create.tsx @@ -0,0 +1,56 @@ +/* + * 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 { BundleCommentField } from '~/components/form/fields/BundleCommentField' +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 createBundle = useApiMutation(api.supportBundleCreate, { + onSuccess() { + queryClient.invalidateEndpoint('supportBundleList') + addToast('Support bundle created') + navigate(pb.supportBundles()) + }, + }) + + const form = useForm({ defaultValues }) + + return ( + navigate(pb.supportBundles())} + onSubmit={({ userComment }) => { + createBundle.mutate({ body: { userComment: userComment.trim() || null } }) + }} + loading={createBundle.isPending || createBundle.isSuccess} + submitError={createBundle.error} + > + + + + ) +} diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts index f5f5524eb..5906e7de4 100644 --- a/app/hooks/use-params.ts +++ b/app/hooks/use-params.ts @@ -54,6 +54,7 @@ export const requireUpdateParams = requireParams('version') export const getIpPoolSelector = requireParams('pool') export const getSubnetPoolSelector = requireParams('subnetPool') export const getAlertReceiverSelector = requireParams('receiver') +export const getSupportBundleSelector = requireParams('bundleId') export const getAffinityGroupSelector = requireParams('project', 'affinityGroup') export const getAntiAffinityGroupSelector = requireParams('project', 'antiAffinityGroup') @@ -106,6 +107,7 @@ export const useUpdateParams = () => useSelectedParams(requireUpdateParams) export const useIpPoolSelector = () => useSelectedParams(getIpPoolSelector) export const useSubnetPoolSelector = () => useSelectedParams(getSubnetPoolSelector) export const useAlertReceiverSelector = () => useSelectedParams(getAlertReceiverSelector) +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 637c9c697..536b137d5 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -10,6 +10,7 @@ import { useLocation } from 'react-router' import { api, q, queryClient } from '@oxide/api' import { Access16Icon, + Archive16Icon, Cloud16Icon, IpGlobal16Icon, Logs16Icon, @@ -60,8 +61,9 @@ export default function SystemLayout() { { value: 'Alerting', path: pb.alertReceivers() }, { value: 'Alerts', path: pb.alerts() }, { value: 'System Update', path: pb.systemUpdate() }, - { value: 'Fleet Access', path: pb.fleetAccess() }, + { value: 'Support Bundles', path: pb.supportBundles() }, { value: 'Audit Log', path: pb.auditLog() }, + { value: 'Fleet Access', path: pb.fleetAccess() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -112,12 +114,15 @@ export default function SystemLayout() { System Update - - Fleet Access + + Support Bundles Audit Log + + Fleet Access + diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 644cc6638..e9c07fe97 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -104,7 +104,7 @@ export default function ProjectAccessPage() { const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView(projectSelector)) const projectRows = useUserRows(projectPolicy.roleAssignments, 'project') - const rows = useMemo(() => { + const rows: UserRow[] = useMemo(() => { return groupBy(siloRows.concat(projectRows), (u) => u.id) .map(([userId, userAssignments]) => { const { name, identityType } = userAssignments[0] @@ -123,7 +123,7 @@ export default function ProjectAccessPage() { name, projectRole: projectAccessRow?.roleName, roleBadges, - } satisfies UserRow + } }) .sort(byGroupThenName) }, [siloRows, projectRows]) diff --git a/app/pages/settings/AccessTokensPage.tsx b/app/pages/settings/AccessTokensPage.tsx index fd2f0c32c..018ac275b 100644 --- a/app/pages/settings/AccessTokensPage.tsx +++ b/app/pages/settings/AccessTokensPage.tsx @@ -103,7 +103,7 @@ export default function AccessTokensPage() { const emptyState = ( } + icon={} title="No access tokens" body="Your access tokens will appear here when they are created" /> diff --git a/app/pages/settings/SSHKeysPage.tsx b/app/pages/settings/SSHKeysPage.tsx index 6662600c3..36126a3fa 100644 --- a/app/pages/settings/SSHKeysPage.tsx +++ b/app/pages/settings/SSHKeysPage.tsx @@ -83,7 +83,7 @@ export default function SSHKeysPage() { const emptyState = ( } + icon={} title="No SSH keys" body="Add an SSH key to see it here" buttonText="Add SSH key" diff --git a/app/pages/system/SupportBundleDetail.tsx b/app/pages/system/SupportBundleDetail.tsx new file mode 100644 index 000000000..a35e6a199 --- /dev/null +++ b/app/pages/system/SupportBundleDetail.tsx @@ -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 { useQuery } from '@tanstack/react-query' +import { useForm } from 'react-hook-form' +import { useNavigate, type LoaderFunctionArgs } from 'react-router' + +import { + api, + q, + queryClient, + supportBundleTransitioning, + useApiMutation, + usePrefetchedQuery, +} from '@oxide/api' +import { Archive16Icon } from '@oxide/design-system/icons/react' + +import { BundleCommentField } from '~/components/form/fields/BundleCommentField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { SupportBundleStateBadge } from '~/components/StateBadge' +import { titleCrumb } from '~/hooks/use-crumbs' +import { getSupportBundleSelector, useSupportBundleSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { DescriptionCell } from '~/table/cells/DescriptionCell' +import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' +import { Button } from '~/ui/lib/Button' +import { FormDivider } from '~/ui/lib/Divider' +import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { ResourceLabel } from '~/ui/lib/SideModal' +import { truncate } from '~/ui/lib/Truncate' +import { Size } from '~/ui/lib/ValueUnit' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' +import { + downloadBundle, + downloadDisabledReason, + POLL_INTERVAL, +} from '~/util/support-bundle' + +const bundleView = ({ bundleId }: PP.SupportBundle) => + q( + api.supportBundleView, + { path: { bundleId } }, + { + // keep transitional states moving while the modal is open, matching the + // list's polling, so a collecting bundle flips to active in place + refetchInterval: ({ state: { data } }) => + data && supportBundleTransitioning(data.state) ? POLL_INTERVAL : false, + } + ) + +export async function clientLoader({ params }: LoaderFunctionArgs) { + await queryClient.prefetchQuery(bundleView(getSupportBundleSelector(params))) + return null +} + +export const handle = titleCrumb('Support bundle') + +/** + * Total bundle size from `Content-Length` on a HEAD of the download endpoint. + * Calls the generated client directly rather than through `q`, which unwraps + * the result to `data` and drops the response headers. + */ +function BundleSize({ bundleId }: { bundleId: string }) { + const { data: size, isPending } = useQuery({ + queryKey: ['supportBundleSize', bundleId], + queryFn: async () => { + const result = await api.supportBundleHead({ path: { bundleId } }) + if (result.type !== 'success') { + throw new Error(`Error fetching bundle size (${result.response.status})`) + } + // handle missing/malformed headers, rather than showing `0 B` + const size = Number(result.response.headers.get('content-length')) + if (!size) throw new Error('Bundle size missing from response') + return size + }, + // bundle contents never change once collection is complete + staleTime: Infinity, + }) + if (isPending) return + if (!size) return + return +} + +export default function SupportBundleDetail() { + const navigate = useNavigate() + const { bundleId } = useSupportBundleSelector() + const { data: bundle } = usePrefetchedQuery(bundleView({ bundleId })) + + const downloadDisabled = downloadDisabledReason(bundle.state) + + 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 ( + + {truncate(bundle.id, 14, 'middle')} + + } + onDismiss={onDismiss} + onSubmit={({ userComment }) => { + editBundle.mutate({ + path: { bundleId }, + body: { userComment: userComment.trim() || null }, + }) + }} + loading={editBundle.isPending || editBundle.isSuccess} + submitError={editBundle.error} + > +
+ + + + + + {bundle.reasonForFailure && ( + + + + )} + + + + + {bundle.state === 'active' && ( + + + + )} + + +
+ + + +
+ ) +} diff --git a/app/pages/system/SupportBundlesPage.tsx b/app/pages/system/SupportBundlesPage.tsx new file mode 100644 index 000000000..6f1ad7ad7 --- /dev/null +++ b/app/pages/system/SupportBundlesPage.tsx @@ -0,0 +1,202 @@ +/* + * 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 } from 'react-router' + +import { + api, + getListQFn, + queryClient, + supportBundleTransitioning, + useApiMutation, + type SupportBundleInfo, +} from '@oxide/api' +import { Archive16Icon, Archive24Icon } from '@oxide/design-system/icons/react' + +import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' +import { RefreshButton } from '~/components/RefreshButton' +import { SupportBundleStateBadge } from '~/components/StateBadge' +import { makeCrumb } from '~/hooks/use-crumbs' +import { useQuickActions } from '~/hooks/use-quick-actions' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' +import { DescriptionCell } from '~/table/cells/DescriptionCell' +import { LinkCell } from '~/table/cells/LinkCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { Columns } from '~/table/columns/common' +import { useQueryTable } from '~/table/QueryTable' +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 { Tooltip } from '~/ui/lib/Tooltip' +import { truncate } from '~/ui/lib/Truncate' +import { toLocaleTimeString } from '~/util/date' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' +import { + downloadBundle, + downloadDisabledReason, + POLL_INTERVAL, +} 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) => ( + + {truncate(info.getValue(), 14, 'middle')} + + ), + }), + colHelper.accessor('state', { + cell: (info) => , + }), + colHelper.accessor('userComment', { + header: 'Comment', + cell: (info) => , + }), + colHelper.accessor('reasonForCreation', { + header: 'Creation reason', + cell: (info) => , + }), + colHelper.accessor('timeCreated', Columns.timeCreated), +] + +const bundleList = getListQFn( + api.supportBundleList, + { query: { sortBy: 'time_and_id_descending' } }, + { + refetchInterval: ({ state: { data } }) => + data?.items.some((b) => supportBundleTransitioning(b.state)) ? 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 { 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() { + downloadBundle(bundle.id) + }, + disabled: downloadDisabledReason(bundle.state), + }, + { + 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] + ) + + const columns = useColsWithActions(staticColumns, makeActions) + const { table, query } = useQueryTable({ + query: bundleList, + columns, + emptyState: , + }) + + const { dataUpdatedAt } = query + + useQuickActions( + () => [ + { + value: 'New support bundle', + navGroup: 'Actions', + action: pb.supportBundlesNew(), + }, + ], + [] + ) + + return ( + <> + + }>Support Bundles + } + summary="Support bundles capture diagnostic data to share with Oxide support." + links={[docLinks.supportBundles]} + /> + + {/* Same override as the instances page. Fix properly when refresh and + * filtering come to all tables. */} + +
+ queryClient.invalidateEndpoint('supportBundleList')} + /> + + + Updated {toLocaleTimeString(new Date(dataUpdatedAt))} + + +
+ New support bundle +
+ {table} + + + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 5a55a751d..ce7971472 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -314,6 +314,18 @@ export const routes = createRoutesFromElements( path="update" lazy={() => import('./pages/system/UpdatePage').then(convert)} /> + import('./pages/system/SupportBundlesPage').then(convert)}> + + import('./pages/system/SupportBundleDetail').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 d67b5d81f..1e9051da6 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -957,6 +957,24 @@ exports[`breadcrumbs 2`] = ` "path": "/system/networking/subnet-pools", }, ], + "supportBundle (/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31)": [ + { + "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 3a8cd6dff..26a46075d 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -169,6 +169,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/resource-management#_calculating_utilization', linkText: 'Utilization', diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 2c45aa54b..fc51bfad5 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', @@ -121,6 +122,9 @@ test('path builder', () => { "subnetPoolMemberAdd": "/system/networking/subnet-pools/sp/members-add", "subnetPools": "/system/networking/subnet-pools", "subnetPoolsNew": "/system/networking/subnet-pools-new", + "supportBundle": "/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31", + "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 e61fcfd24..1a2f773b3 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -156,6 +156,9 @@ export const pb = { systemUpdate: () => '/system/update', + supportBundles: () => '/system/support-bundles', + supportBundlesNew: () => '/system/support-bundles-new', + supportBundle: (params: PP.SupportBundle) => `${pb.supportBundles()}/${params.bundleId}`, auditLog: () => '/system/audit-log', profile: () => '/settings/profile', diff --git a/app/util/path-params.ts b/app/util/path-params.ts index 685ed59f9..7e16ba201 100644 --- a/app/util/path-params.ts +++ b/app/util/path-params.ts @@ -31,4 +31,5 @@ export type AffinityGroup = Required export type AntiAffinityGroup = Required export type SubnetPool = Required export type AlertReceiver = 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..4a34551ef --- /dev/null +++ b/app/util/support-bundle.spec.ts @@ -0,0 +1,31 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { afterEach, expect, it, vi } from 'vitest' + +import { api } from '@oxide/api' + +import { bundleDownloadUrl } from './support-bundle' + +afterEach(() => vi.unstubAllGlobals()) + +// The download URL is used in an anchor navigation, so it can't go through +// the generated client and its path is restated in support-bundle.ts. Catch +// drift by comparing against the URL the generated client actually requests. +it('hand-built download URL matches the generated client', async () => { + const urls: string[] = [] + // the generated client always calls fetch with a URL string + vi.stubGlobal('fetch', (url: string) => { + urls.push(url) + return Promise.resolve(new Response(null, { status: 204 })) + }) + + await api.supportBundleDownload({ path: { bundleId: 'bundle-id' } }) + + // 'http://testhost' is the client host under NODE_ENV=test (app/api/client.ts) + expect(urls).toEqual(['http://testhost' + bundleDownloadUrl('bundle-id')]) +}) diff --git a/app/util/support-bundle.ts b/app/util/support-bundle.ts new file mode 100644 index 000000000..989a0b941 --- /dev/null +++ b/app/util/support-bundle.ts @@ -0,0 +1,64 @@ +/* + * 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 { match } from 'ts-pattern' + +import type { SupportBundleState } from '@oxide/api' + +/* + * The generated API client only handles JSON responses, so the zip download + * is a plain anchor navigation. The browser sends the session cookie the same + * as any API request. The URL restates a path from the generated client; the + * spec next to this file guards against it drifting when the API is + * regenerated. + * + * Note the anchor click is a navigation-mode request, and MSW's service + * worker deliberately passes navigation requests through to the network: + * https://github.com/mswjs/msw/blob/b1c2a13/src/mockServiceWorker.js#L100-L103 + * So in mock mode the request falls through to the dev server, which serves + * an empty zip (see vite.config.ts). + */ + +export const bundleDownloadUrl = (bundleId: string) => + `/v1/system/support-bundles/${bundleId}/download` + +/** + * Why the download is unavailable, or undefined if it is. The zip only exists + * for an active bundle. + */ +export const downloadDisabledReason = (state: SupportBundleState) => + match(state) + .with('active', () => undefined) + .with('collecting', () => 'The bundle is still being collected') + .with('failed', () => 'Bundle collection failed') + .with('destroying', () => 'The bundle is being deleted') + .exhaustive() + +const SEC = 1000 // ms +/** + * The list and detail modal both poll at this rate while any bundle is + * `collecting` or `destroying`, and not at all otherwise. This is deliberately + * simpler than the instance list, which caps fast polling with a timeout and + * falls back to a slow poll. Collection takes minutes, but we don't really know + * ahead of time how long, and there is no slow tier because nothing changes a + * bundle's state without an operator action (unlike crashing or auto-restart + * for instances). Bundles created elsewhere will show up on refresh. The + * updated timestamp next to the refresh button makes clear when the data is + * really out of date. + */ +export const POLL_INTERVAL = 10 * SEC + +function triggerDownload(url: string, filename: string) { + const link = document.createElement('a') + link.href = url + link.download = filename + link.click() +} + +export function downloadBundle(bundleId: string) { + triggerDownload(bundleDownloadUrl(bundleId), `support-bundle-${bundleId}.zip`) +} diff --git a/mock-api/index.ts b/mock-api/index.ts index 0e10f59cc..ddf04471b 100644 --- a/mock-api/index.ts +++ b/mock-api/index.ts @@ -27,6 +27,7 @@ export * from './sled' export * from './snapshot' export * from './sshKeys' export * from './subnet-pool' +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 3a2dd3f02..7529cb93e 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -671,6 +671,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 d0ce152a1..2dc72a30a 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' @@ -15,8 +15,8 @@ import { validate as isUuid, v4 as uuid } from 'uuid' import { DEFAULT_VPC_NAME, diskCan, - fleetRoles, FLEET_ID, + fleetRoles, INSTANCE_MAX_CPU, INSTANCE_MAX_RAM_GiB, INSTANCE_MIN_RAM_GiB, @@ -31,13 +31,20 @@ import { } from '@oxide/api' import { json, makeHandlers, type Json } from '~/api/__generated__/msw-handlers' -import { instanceCan, OXQL_GROUP_BY_ERROR, subscriptionRegex } from '~/api/util' +import { + instanceCan, + MAX_BUNDLE_COMMENT_BYTES, + OXQL_GROUP_BY_ERROR, + subscriptionRegex, + utf8ByteLength, +} from '~/api/util' import { parseIpNet } from '~/util/ip' import { commaSeries } from '~/util/str' import { GiB } from '~/util/units' import { alertClasses, PROBE_ALERT_ID } from '../alert' import { defaultSilo, toIdp } from '../silo' +import { SUPPORT_BUNDLE_SIZE } from '../support-bundle' import { getTimestamps } from '../util' import { defaultFirewallRules } from '../vpc' import { resendableAlerts, retryPendingDeliveries, validateSubscription } from './alert' @@ -2043,6 +2050,102 @@ 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) + if (body.user_comment && utf8ByteLength(body.user_comment) > MAX_BUNDLE_COMMENT_BYTES) { + throw invalidRequest(`User comment cannot exceed ${MAX_BUNDLE_COMMENT_BYTES} bytes`) + } + bundle.user_comment = body.user_comment + return bundle + }, + 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 + supportBundleHead({ path, cookies }) { + requireFleetViewer(cookies) + const bundle = lookupById(db.supportBundles, path.bundleId) + if (bundle.state !== 'active') { + throw invalidRequest('Cannot download bundle in non-active state') + } + return new HttpResponse(null, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Length': SUPPORT_BUNDLE_SIZE.toString(), + }, + }) + }, switchList: ({ query, cookies }) => { requireFleetViewer(cookies) return paginated(query, db.switches) @@ -2998,16 +3101,13 @@ export const handlers = makeHandlers({ siloUserList: NotImplemented, sledListUninitialized: NotImplemented, sledSetProvisionPolicy: NotImplemented, - supportBundleCreate: NotImplemented, - supportBundleDelete: NotImplemented, + // unreachable in the mock: the console downloads bundles with an + // navigation, which MSW's service worker can't intercept. The dev server + // handles it instead (see vite.config.ts and app/util/support-bundle.ts) supportBundleDownload: NotImplemented, supportBundleDownloadFile: NotImplemented, - supportBundleHead: NotImplemented, supportBundleHeadFile: NotImplemented, supportBundleIndex: NotImplemented, - supportBundleList: NotImplemented, - supportBundleUpdate: NotImplemented, - supportBundleView: NotImplemented, switchView: NotImplemented, systemIpPoolAssign: NotImplemented, systemNetworkingSettingsUpdate: NotImplemented, diff --git a/mock-api/support-bundle.ts b/mock-api/support-bundle.ts new file mode 100644 index 000000000..5520b240f --- /dev/null +++ b/mock-api/support-bundle.ts @@ -0,0 +1,41 @@ +/* + * 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 no comment. + // reason format is the fallback FM uses when the diagnosis engine gives none + // https://github.com/oxidecomputer/omicron/blob/9d95e0c/nexus/src/app/background/tasks/fm_rendezvous.rs#L433-L436 + id: '7bdd4ef3-8183-46fe-9e9f-81b34bf6b2c5', + reason_for_creation: + 'Requested by PhysicalDisk diagnosis engine for case ffae3627-d3c5-4b80-a05a-37139dcf9ef5', + 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(), + }, +] + +// Fake `Content-Length` for the HEAD handler +export const SUPPORT_BUNDLE_SIZE = 2_576_980_378 diff --git a/package-lock.json b/package-lock.json index 499375486..c6b10d6c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@base-ui/react": "^1.1.0", "@floating-ui/react": "^0.26.23", "@headlessui/react": "^2.2.9", - "@oxide/design-system": "^6.7.1", + "@oxide/design-system": "^6.7.2", "@peculiar/x509": "^1.12.3", "@react-aria/live-announcer": "^3.3.4", "@tailwindcss/container-queries": "^0.1.1", @@ -1287,9 +1287,9 @@ } }, "node_modules/@oxide/design-system": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-6.7.1.tgz", - "integrity": "sha512-ghOOWy15Sx8XFa2akrZOk6+YnPTmPel5NQW2H3Sp5C1KmLO2lzBE4fJ5bghr82PRaj4mIpSsz5U2nv8pXOrd7Q==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-6.7.2.tgz", + "integrity": "sha512-KKnQ9Y3B9h32wK+utaDJPnAWXwbIli79hZzybjiuF+yL7bUJaRRn6e5tOQBALMKj63Htl6aULVvlyLPQGOEJoQ==", "license": "MPL 2.0", "dependencies": { "@floating-ui/react": "^0.27.16", diff --git a/package.json b/package.json index dfe907766..d39946440 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@base-ui/react": "^1.1.0", "@floating-ui/react": "^0.26.23", "@headlessui/react": "^2.2.9", - "@oxide/design-system": "^6.7.1", + "@oxide/design-system": "^6.7.2", "@peculiar/x509": "^1.12.3", "@react-aria/live-announcer": "^3.3.4", "@tailwindcss/container-queries": "^0.1.1", diff --git a/test/e2e/support-bundles.e2e.ts b/test/e2e/support-bundles.e2e.ts new file mode 100644 index 000000000..12cc52b74 --- /dev/null +++ b/test/e2e/support-bundles.e2e.ts @@ -0,0 +1,321 @@ +/* + * 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, type Download, type Page } 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', + 'Creation reason': 'Created by external API', + Comment: 'Investigating slow instance start times', + }) + await expectRowVisible(table, { + state: 'collecting', + 'Creation reason': + 'Requested by PhysicalDisk diagnosis engine for case ffae3627-d3c5-4b80-a05a-37139dcf9ef5', + }) + 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: 'PhysicalDisk' }) + 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('The bundle is still being collected') + 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() +}) + +const BUNDLE_ID = 'ccdac005-66a8-4921-9e8b-30531c359c31' + +/** + * Download is an navigation, which bypasses MSW. The dev server + * answers it with an empty zip (see vite.config.ts) so the browser starts a + * real download, and we can check the parts the console controls: URL, + * filename, and no navigation. + */ +async function expectBundleDownload(page: Page, download: Download) { + expect(download.url()).toBe( + `http://localhost:4009/v1/system/support-bundles/${BUNDLE_ID}/download` + ) + expect(download.suggestedFilename()).toBe(`support-bundle-${BUNDLE_ID}.zip`) + // download navigation doesn't leave the page + await expect(page).toHaveURL('/system/support-bundles') +} + +test('download from row action and detail modal', async ({ page }) => { + await page.goto('/system/support-bundles') + + let downloadPromise = page.waitForEvent('download') + await clickRowAction(page, 'Investigating slow', 'Download') + await expectBundleDownload(page, await downloadPromise) + + await page.getByRole('link', { name: 'ccdac0…359c31' }).click() + const modal = page.getByRole('dialog', { name: 'Support bundle' }) + downloadPromise = page.waitForEvent('download') + await modal.getByRole('button', { name: 'Download bundle' }).click() + expect((await downloadPromise).suggestedFilename()).toBe( + `support-bundle-${BUNDLE_ID}.zip` + ) + await expect(modal).toBeVisible() +}) + +test('bundle detail modal shows metadata for active bundle', async ({ page }) => { + await page.goto('/system/support-bundles') + + // ID cell links to the detail modal + await page.getByRole('link', { name: 'ccdac0…359c31' }).click() + await expect(page).toHaveURL( + '/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31' + ) + + const modal = page.getByRole('dialog', { name: 'Support bundle' }) + await expect(modal.getByLabel('ccdac005-66a8-4921-9e8b-30531c359c31')).toBeVisible() + await expect(modal.getByText('active')).toBeVisible() + + // size comes from a HEAD of the download endpoint + await expect(modal.getByText('2.4 GiB')).toBeVisible() + + await expect(modal.getByRole('button', { name: 'Download bundle' })).toBeEnabled() + + // comment is editable in place; save is disabled until it changes + await expect(modal.getByRole('textbox', { name: 'Comment' })).toHaveValue( + 'Investigating slow instance start times' + ) + await expect(modal.getByRole('button', { name: 'Update comment' })).toBeDisabled() + + await modal.getByRole('button', { name: 'Cancel' }).click() + await expect(modal).toBeHidden() + await expect(page).toHaveURL('/system/support-bundles') +}) + +test('bundle detail modal for failed bundle', async ({ page }) => { + await page.goto('/system/support-bundles') + + await page.getByRole('link', { name: 'bfc48b…fe3a7c' }).click() + + const modal = page.getByRole('dialog', { name: 'Support bundle' }) + await expect(modal.getByText('failed')).toBeVisible() + await expect(modal.getByText(/Allocated dataset/)).toBeVisible() + + // no zip exists, so no size row and no download + await expect(modal.getByText('Size')).toBeHidden() + const download = modal.getByRole('button', { name: 'Download bundle' }) + await expect(download).toBeDisabled() + await download.hover() + // getByText rather than role=tooltip: the open modal makes the portaled + // tooltip aria-hidden, so it has no role, but it is still visible + await expect(page.getByText('Bundle collection failed')).toBeVisible() +}) + +test('detail modal polls a collecting bundle until active', async ({ page }) => { + await page.goto('/system/support-bundles') + + await page.getByRole('link', { name: 'New support bundle' }).click() + await page.getByRole('textbox', { name: 'Comment' }).fill('poll me') + await page.getByRole('button', { name: 'Create support bundle' }).click() + await expectToast(page, 'Support bundle created') + + // open the new bundle's detail modal while it's still collecting. the ID + // link is the only link in the row + await page.getByRole('row', { name: 'poll me' }).getByRole('link').click() + + const modal = page.getByRole('dialog', { name: 'Support bundle' }) + await expect(modal.getByText('collecting')).toBeVisible() + await expect(modal.getByRole('button', { name: 'Download bundle' })).toBeDisabled() + + // mock flips the bundle to active after 3s; the modal's view query polls + // every 10s, so the open modal updates in place + await expect(modal.getByText('active')).toBeVisible({ timeout: 20_000 }) + await expect(modal.getByRole('button', { name: 'Download bundle' })).toBeEnabled() +}) + +test('create support bundle and poll until 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 bundle whose collection fails', async ({ page }) => { + await page.goto('/system/support-bundles-new') + + // mock sentinel: collection fails instead of completing + await page.getByRole('textbox', { name: 'Comment' }).fill('fail collection') + await page.getByRole('button', { name: 'Create support bundle' }).click() + await expectToast(page, 'Support bundle created') + + const row = page.getByRole('table').getByRole('row', { name: 'fail collection' }) + await expect(row.getByText('collecting')).toBeVisible() + // mock flips it to failed after 3s; list polls every 10s + await expect(row.getByText('failed')).toBeVisible({ timeout: 20_000 }) + await row.getByRole('button', { name: 'Tip' }).hover() + await expect(page.getByRole('tooltip')).toHaveText('Bundle collection failed') +}) + +test('comment length validation', async ({ page }) => { + await page.goto('/system/support-bundles-new') + + // 2049 two-byte characters is 4098 bytes, over the limit despite being + // well under 4096 characters + await page.getByRole('textbox', { name: 'Comment' }).fill('é'.repeat(2049)) + await page.getByRole('button', { name: 'Create support bundle' }).click() + // scope to the dialog: the message is also announced in a live region + const modal = page.getByRole('dialog', { name: 'Create support bundle' }) + await expect(modal.getByText('Comment cannot exceed 4096 bytes')).toBeVisible() + await expect(page).toHaveURL('/system/support-bundles-new') +}) + +test('create shows insufficient capacity error in modal', async ({ page }) => { + await page.goto('/system/support-bundles-new') + + 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 page.getByRole('link', { name: 'ccdac0…359c31' }).click() + await expect(page).toHaveURL( + '/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31' + ) + + const comment = page.getByRole('textbox', { name: 'Comment' }) + await expect(comment).toHaveValue('Investigating slow instance start times') + await comment.fill('Resolved, keeping for reference') + await page.getByRole('button', { name: 'Update comment' }).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('bundle deleted mid-view 404s', async ({ page }) => { + await page.goto('/system/support-bundles') + + // start deleting the active bundle, then open its detail modal while it + // sits in 'destroying' + await clickRowAction(page, 'Investigating slow', 'Delete') + await page.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, /Deleting support bundle/) + + await page.getByRole('link', { name: 'ccdac0…359c31' }).click() + const modal = page.getByRole('dialog', { name: 'Support bundle' }) + await expect(modal.getByText('destroying')).toBeVisible() + + // mock API deletes the record 3s after the delete call; the modal's next + // poll (10s) gets a 404, which throws to the error boundary, same as + // instance detail when an instance is deleted mid-poll + await expect(page.getByText('Page not found')).toBeVisible({ timeout: 20_000 }) +}) + +test('delete collecting bundle warns about cancellation', async ({ page }) => { + await page.goto('/system/support-bundles') + + await clickRowAction(page, 'PhysicalDisk', '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 bfc1eee17..372d029b3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -147,6 +147,26 @@ export default defineConfig(({ mode }) => ({ }, react(), apiMode === 'remote' && basicSsl(), + apiMode === 'msw' && { + // The console downloads support bundles with an navigation. + // MSW's service worker bypasses navigation requests (see + // app/util/support-bundle.ts), so the request would otherwise hit the + // /v1 proxy and fail. Serve an empty zip so the download works in the + // mock dev server and in e2e tests. Only GET: the HEAD the detail modal + // uses for size goes through MSW as a normal fetch. + name: 'mock-support-bundle-download', + configureServer(server) { + server.middlewares.use((req, res, next) => { + const isDownload = + req.method === 'GET' && + /^\/v1\/system\/support-bundles\/[^/]+\/download$/.test(req.url || '') + if (!isDownload) return next() + res.writeHead(200, { 'Content-Type': 'application/zip' }) + // end-of-central-directory record: the smallest valid (empty) zip + res.end(Buffer.from('504b0506' + '00'.repeat(18), 'hex')) + }) + }, + }, ], html: { // don't include a placeholder nonce in production.