diff --git a/app/components/form/fields/BundleCommentField.tsx b/app/components/form/fields/BundleCommentField.tsx new file mode 100644 index 000000000..eeef92cac --- /dev/null +++ b/app/components/form/fields/BundleCommentField.tsx @@ -0,0 +1,34 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import type { Control } from 'react-hook-form' + +import { MAX_BUNDLE_COMMENT_BYTES, utf8ByteLength } from '@oxide/api' + +import { TextField } from './TextField' + +/** Support bundle comment textarea, shared by the create and edit forms */ +export function BundleCommentField({ + control, +}: { + control: Control<{ userComment: string }> +}) { + return ( + + utf8ByteLength(value) > MAX_BUNDLE_COMMENT_BYTES + ? `Comment cannot exceed ${MAX_BUNDLE_COMMENT_BYTES} bytes` + : true + } + /> + ) +} diff --git a/app/forms/support-bundle-create.tsx b/app/forms/support-bundle-create.tsx index 0c2f1ae28..b274dfb1f 100644 --- a/app/forms/support-bundle-create.tsx +++ b/app/forms/support-bundle-create.tsx @@ -8,15 +8,9 @@ import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' -import { - api, - MAX_BUNDLE_COMMENT_BYTES, - queryClient, - useApiMutation, - utf8ByteLength, -} from '@oxide/api' +import { api, queryClient, useApiMutation } from '@oxide/api' -import { TextField } from '~/components/form/fields/TextField' +import { BundleCommentField } from '~/components/form/fields/BundleCommentField' import { SideModalForm } from '~/components/form/SideModalForm' import { titleCrumb } from '~/hooks/use-crumbs' import { addToast } from '~/stores/toast' @@ -58,19 +52,7 @@ export default function CreateSupportBundleSideModalForm() { variant="info" content="Bundle collection runs in the background and can take several minutes. The bundle can be downloaded once collection is complete." /> - - utf8ByteLength(value) > MAX_BUNDLE_COMMENT_BYTES - ? `Comment cannot exceed ${MAX_BUNDLE_COMMENT_BYTES} bytes` - : true - } - /> + ) } diff --git a/app/forms/support-bundle-edit.tsx b/app/forms/support-bundle-edit.tsx deleted file mode 100644 index 3a27b59f7..000000000 --- a/app/forms/support-bundle-edit.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ -import { useForm } from 'react-hook-form' -import { useNavigate, type LoaderFunctionArgs } from 'react-router' - -import { - api, - MAX_BUNDLE_COMMENT_BYTES, - q, - queryClient, - useApiMutation, - usePrefetchedQuery, - utf8ByteLength, -} from '@oxide/api' - -import { TextField } from '~/components/form/fields/TextField' -import { SideModalForm } from '~/components/form/SideModalForm' -import { titleCrumb } from '~/hooks/use-crumbs' -import { getSupportBundleSelector, useSupportBundleSelector } from '~/hooks/use-params' -import { addToast } from '~/stores/toast' -import { pb } from '~/util/path-builder' -import type * as PP from '~/util/path-params' - -const bundleView = ({ bundleId }: PP.SupportBundle) => - q(api.supportBundleView, { path: { bundleId } }) - -export async function clientLoader({ params }: LoaderFunctionArgs) { - const selector = getSupportBundleSelector(params) - await queryClient.prefetchQuery(bundleView(selector)) - return null -} - -export const handle = titleCrumb('Edit support bundle') - -export default function EditSupportBundleSideModalForm() { - const navigate = useNavigate() - const selector = useSupportBundleSelector() - - const { data: bundle } = usePrefetchedQuery(bundleView(selector)) - - const form = useForm({ defaultValues: { userComment: bundle.userComment || '' } }) - - const onDismiss = () => navigate(pb.supportBundles()) - - const editBundle = useApiMutation(api.supportBundleUpdate, { - onSuccess() { - queryClient.invalidateEndpoint('supportBundleList') - queryClient.invalidateEndpoint('supportBundleView') - addToast('Support bundle updated') - navigate(pb.supportBundles()) - }, - }) - - return ( - { - editBundle.mutate({ - path: { bundleId: selector.bundleId }, - body: { userComment: userComment || null }, - }) - }} - loading={editBundle.isPending} - submitError={editBundle.error} - > - - utf8ByteLength(value) > MAX_BUNDLE_COMMENT_BYTES - ? `Comment cannot exceed ${MAX_BUNDLE_COMMENT_BYTES} bytes` - : true - } - /> - - ) -} diff --git a/app/pages/system/SupportBundleDetail.tsx b/app/pages/system/SupportBundleDetail.tsx new file mode 100644 index 000000000..d57b59ad2 --- /dev/null +++ b/app/pages/system/SupportBundleDetail.tsx @@ -0,0 +1,177 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useQuery, type UseQueryResult } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import { useForm } from 'react-hook-form' +import { useNavigate, type LoaderFunctionArgs } from 'react-router' + +import { + api, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type SupportBundleInfo, +} from '@oxide/api' +import { Logs16Icon } from '@oxide/design-system/icons/react' + +import { BundleCommentField } from '~/components/form/fields/BundleCommentField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { SupportBundleStateBadge } from '~/components/StateBadge' +import { titleCrumb } from '~/hooks/use-crumbs' +import { getSupportBundleSelector, useSupportBundleSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { DescriptionCell } from '~/table/cells/DescriptionCell' +import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' +import { Button } from '~/ui/lib/Button' +import { FormDivider } from '~/ui/lib/Divider' +import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { ResourceLabel } from '~/ui/lib/SideModal' +import { truncate } from '~/ui/lib/Truncate' +import { Size } from '~/ui/lib/ValueUnit' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' +import { + bundleIndexQuery, + bundleSizeQuery, + downloadBundle, + DOWNLOAD_DISABLED_REASON, +} from '~/util/support-bundle' + +const SEC = 1000 // ms +const POLL_INTERVAL = 10 * SEC + +const bundleView = ({ bundleId }: PP.SupportBundle) => ({ + ...q(api.supportBundleView, { path: { bundleId } }), + // keep transitional states moving while the modal is open, matching the + // list's polling, so a collecting bundle flips to active in place + refetchInterval: ({ + state: { data }, + }: { + state: { data: SupportBundleInfo | undefined } + }) => + data?.state === 'collecting' || data?.state === 'destroying' ? POLL_INTERVAL : false, +}) + +export async function clientLoader({ params }: LoaderFunctionArgs) { + await queryClient.prefetchQuery(bundleView(getSupportBundleSelector(params))) + return null +} + +export const handle = titleCrumb('Support bundle') + +/** Skeleton while the query is in flight, em dash if it failed */ +function AsyncValue({ + query, + children, +}: { + query: UseQueryResult + children: (data: T) => ReactNode +}) { + if (query.isPending) return + if (query.isError) return + return <>{children(query.data)} +} + +export default function SupportBundleDetail() { + const navigate = useNavigate() + const { bundleId } = useSupportBundleSelector() + const { data: bundle } = usePrefetchedQuery(bundleView({ bundleId })) + + // the index and bundle zip only exist once collection has completed + const isActive = bundle.state === 'active' + const indexQuery = useQuery({ ...bundleIndexQuery(bundleId), enabled: isActive }) + const sizeQuery = useQuery({ ...bundleSizeQuery(bundleId), enabled: isActive }) + + const form = useForm({ defaultValues: { userComment: bundle.userComment || '' } }) + // must destructure to subscribe to changes; inlining does not work + const { isDirty } = form.formState + + const onDismiss = () => navigate(pb.supportBundles()) + + const editBundle = useApiMutation(api.supportBundleUpdate, { + onSuccess() { + queryClient.invalidateEndpoint('supportBundleList') + queryClient.invalidateEndpoint('supportBundleView') + addToast('Support bundle updated') + navigate(pb.supportBundles()) + }, + }) + + return ( + + {truncate(bundle.id, 14, 'middle')} + + } + onDismiss={onDismiss} + onSubmit={({ userComment }) => { + editBundle.mutate({ + path: { bundleId }, + body: { userComment: userComment || null }, + }) + }} + loading={editBundle.isPending} + submitError={editBundle.error} + > +
+ + + + + + {bundle.reasonForFailure && ( + + + + )} + + + + + {isActive && ( + + + {(entries) => + // directory entries have a trailing slash; count files only + entries.filter((e) => !e.endsWith('/')).length.toLocaleString() + } + + + )} + {isActive && ( + + {(bytes) => } + + )} + + +
+ + + +
+ ) +} diff --git a/app/pages/system/SupportBundlesPage.tsx b/app/pages/system/SupportBundlesPage.tsx index 0532552b3..35017d1cf 100644 --- a/app/pages/system/SupportBundlesPage.tsx +++ b/app/pages/system/SupportBundlesPage.tsx @@ -28,6 +28,7 @@ import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { DescriptionCell } from '~/table/cells/DescriptionCell' +import { LinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' import { useQueryTable } from '~/table/QueryTable' @@ -36,10 +37,10 @@ import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { TableActions } from '~/ui/lib/Table' import { TipIcon } from '~/ui/lib/TipIcon' -import { truncate, Truncate } from '~/ui/lib/Truncate' +import { truncate } from '~/ui/lib/Truncate' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' -import { bundleDownloadUrl, triggerDownload } from '~/util/support-bundle' +import { downloadBundle, DOWNLOAD_DISABLED_REASON } from '~/util/support-bundle' const EmptyState = () => ( ( - + + {truncate(info.getValue(), 14, 'middle')} + ), }), colHelper.accessor('state', { @@ -122,20 +125,18 @@ export default function SupportBundlesPage() { { label: 'Download', onActivate() { - triggerDownload(bundleDownloadUrl(bundle.id), `support-bundle-${bundle.id}.zip`) + downloadBundle(bundle.id) }, - disabled: - bundle.state !== 'active' && - 'Only bundles that have completed collection can be downloaded', + disabled: bundle.state !== 'active' && DOWNLOAD_DISABLED_REASON, }, { - label: 'Edit comment', + label: 'View details', onActivate() { const bundleView = q(api.supportBundleView, { path: { bundleId: bundle.id }, }) queryClient.setQueryData(bundleView.queryKey, bundle) - navigate(pb.supportBundleEdit({ bundleId: bundle.id })) + navigate(pb.supportBundle({ bundleId: bundle.id })) }, }, { diff --git a/app/routes.tsx b/app/routes.tsx index 5cb689269..9b30280ac 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -272,8 +272,8 @@ export const routes = createRoutesFromElements( import('./pages/system/SupportBundlesPage').then(convert)}> import('./forms/support-bundle-edit').then(convert)} + path=":bundleId" + lazy={() => import('./pages/system/SupportBundleDetail').then(convert)} /> text ? ( - + ) : ( ) diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 278c1815f..559def2ff 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -897,7 +897,7 @@ exports[`breadcrumbs 2`] = ` "path": "/system/networking/", }, ], - "supportBundleEdit (/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31/edit)": [ + "supportBundle (/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31)": [ { "label": "Support Bundles", "path": "/system/support-bundles", diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 4f2807827..63a7e6d98 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -115,7 +115,7 @@ test('path builder', () => { "subnetPoolMemberAdd": "/system/networking/subnet-pools/sp/members-add", "subnetPools": "/system/networking/subnet-pools", "subnetPoolsNew": "/system/networking/subnet-pools-new", - "supportBundleEdit": "/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31/edit", + "supportBundle": "/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31", "supportBundles": "/system/support-bundles", "supportBundlesNew": "/system/support-bundles-new", "systemUpdate": "/system/update", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 523184faf..294557f9b 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -151,8 +151,7 @@ export const pb = { supportBundles: () => '/system/support-bundles', supportBundlesNew: () => '/system/support-bundles-new', - supportBundleEdit: (params: PP.SupportBundle) => - `${pb.supportBundles()}/${params.bundleId}/edit`, + supportBundle: (params: PP.SupportBundle) => `${pb.supportBundles()}/${params.bundleId}`, profile: () => '/settings/profile', sshKeys: () => '/settings/ssh-keys', diff --git a/app/util/support-bundle.ts b/app/util/support-bundle.ts index ac39e351c..8ed7ca2c9 100644 --- a/app/util/support-bundle.ts +++ b/app/util/support-bundle.ts @@ -5,6 +5,7 @@ * * Copyright Oxide Computer Company */ +import { queryOptions } from '@tanstack/react-query' /* * The generated API client only handles JSON responses, so the binary bundle @@ -15,9 +16,49 @@ export const bundleDownloadUrl = (bundleId: string) => `/experimental/v1/system/support-bundles/${bundleId}/download` -export function triggerDownload(url: string, filename: string) { +const bundleIndexUrl = (bundleId: string) => + `/experimental/v1/system/support-bundles/${bundleId}/index` + +export const DOWNLOAD_DISABLED_REASON = + 'Only bundles that have completed collection can be downloaded' + +function triggerDownload(url: string, filename: string) { const link = document.createElement('a') link.href = url link.download = filename link.click() } + +export function downloadBundle(bundleId: string) { + triggerDownload(bundleDownloadUrl(bundleId), `support-bundle-${bundleId}.zip`) +} + +/** + * The index is the bundle zip's entry names, one per line, where directory + * entries have a trailing slash. + * https://github.com/oxidecomputer/omicron/blob/99249b4/sled-agent/src/support_bundle/storage.rs#L1029-L1035 + */ +export const bundleIndexQuery = (bundleId: string) => + queryOptions({ + queryKey: ['supportBundleIndex', bundleId], + queryFn: async ({ signal }) => { + const res = await fetch(bundleIndexUrl(bundleId), { signal }) + if (!res.ok) throw new Error(`Error fetching bundle index (${res.status})`) + const text = await res.text() + return text.split('\n').filter((line) => line.length > 0) + }, + // bundle contents never change once collection is complete + staleTime: Infinity, + }) + +/** Total bundle size from `Content-Length` on a HEAD of the download endpoint */ +export const bundleSizeQuery = (bundleId: string) => + queryOptions({ + queryKey: ['supportBundleSize', bundleId], + queryFn: async ({ signal }) => { + const res = await fetch(bundleDownloadUrl(bundleId), { method: 'HEAD', signal }) + if (!res.ok) throw new Error(`Error fetching bundle size (${res.status})`) + return Number(res.headers.get('content-length')) + }, + staleTime: Infinity, + }) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 9e1fb6943..6e12b8b6b 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -36,6 +36,7 @@ import { commaSeries } from '~/util/str' import { GiB } from '~/util/units' import { defaultSilo, toIdp } from '../silo' +import { SUPPORT_BUNDLE_SIZE, supportBundleIndexText } from '../support-bundle' import { getTimestamps } from '../util' import { defaultFirewallRules } from '../vpc' import { @@ -2113,6 +2114,31 @@ export const handlers = makeHandlers({ }, }) }, + // @ts-expect-error Response passthrough, see supportBundleDownload + supportBundleHead({ path, cookies }) { + requireFleetViewer(cookies) + const bundle = lookupById(db.supportBundles, path.bundleId) + if (bundle.state !== 'active') { + throw invalidRequest('Cannot download bundle in non-active state') + } + return new HttpResponse(null, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Length': SUPPORT_BUNDLE_SIZE.toString(), + }, + }) + }, + // @ts-expect-error Response passthrough, see supportBundleDownload + supportBundleIndex({ path, cookies }) { + requireFleetViewer(cookies) + const bundle = lookupById(db.supportBundles, path.bundleId) + if (bundle.state !== 'active') { + throw invalidRequest('Cannot download bundle in non-active state') + } + return new HttpResponse(supportBundleIndexText, { + headers: { 'Content-Type': 'text/plain' }, + }) + }, switchList: ({ query, cookies }) => { requireFleetViewer(cookies) return paginated(query, db.switches) @@ -2827,9 +2853,7 @@ export const handlers = makeHandlers({ sledListUninitialized: NotImplemented, sledSetProvisionPolicy: NotImplemented, supportBundleDownloadFile: NotImplemented, - supportBundleHead: NotImplemented, supportBundleHeadFile: NotImplemented, - supportBundleIndex: NotImplemented, switchView: NotImplemented, systemNetworkingSettingsUpdate: NotImplemented, systemNetworkingSettingsView: NotImplemented, diff --git a/mock-api/support-bundle.ts b/mock-api/support-bundle.ts index 493c839f2..8f656a1e0 100644 --- a/mock-api/support-bundle.ts +++ b/mock-api/support-bundle.ts @@ -34,3 +34,32 @@ export const supportBundles: Json[] = [ time_created: new Date('2025-07-28T11:00:00Z').toISOString(), }, ] + +/** + * Served by the index handler for any active bundle: zip entry names in the + * format the real endpoint returns — sorted, one per line, directories with + * trailing slashes. A tiny slice of a real bundle's layout. 8 files. + */ +export const supportBundleIndexText = [ + 'bundle_id.txt', + 'ereports/', + 'ereports/9130000019-BRM42220031/', + 'ereports/9130000019-BRM42220031/3f7d938a-71b0-4707-b020-ba05526e84ee/', + 'ereports/9130000019-BRM42220031/3f7d938a-71b0-4707-b020-ba05526e84ee/0x1.json', + 'ereports/9130000019-BRM42220031/3f7d938a-71b0-4707-b020-ba05526e84ee/0x2.json', + 'meta/', + 'meta/reason_for_creation.txt', + 'meta/report.json', + 'rack/', + 'rack/a5b3fd8a/', + 'rack/a5b3fd8a/sled/', + 'rack/a5b3fd8a/sled/0/', + 'rack/a5b3fd8a/sled/0/zpool.json', + 'reconfigurator_state.json', + 'sp_task_dumps/', + 'sp_task_dumps/switch_0/', + 'sp_task_dumps/switch_0/dump-0.zip', +].join('\n') + +// Fake `Content-Length` for the HEAD handler +export const SUPPORT_BUNDLE_SIZE = 2_576_980_378 diff --git a/test/e2e/support-bundles.e2e.ts b/test/e2e/support-bundles.e2e.ts index 24f4bf34c..2100207c3 100644 --- a/test/e2e/support-bundles.e2e.ts +++ b/test/e2e/support-bundles.e2e.ts @@ -74,6 +74,80 @@ test('download only available for active bundles', async ({ page }) => { await expect(page.getByRole('menuitem', { name: 'Download' })).toBeEnabled() }) +test('bundle detail modal shows metadata for active bundle', async ({ page }) => { + await page.goto('/system/support-bundles') + + // ID cell links to the detail modal + await page.getByRole('link', { name: 'ccdac0…359c31' }).click() + await expect(page).toHaveURL( + '/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31' + ) + + const modal = page.getByRole('dialog', { name: 'Support bundle' }) + await expect(modal.getByText(/^ccdac005/)).toBeVisible() + await expect(modal.getByText('active')).toBeVisible() + + // file count comes from the index endpoint, size from a HEAD of download + await expect(modal.getByText('8', { exact: true })).toBeVisible() + await expect(modal.getByText('2.4 GiB')).toBeVisible() + + await expect(modal.getByRole('button', { name: 'Download bundle' })).toBeEnabled() + + // comment is editable in place; save is disabled until it changes + await expect(modal.getByRole('textbox', { name: 'Comment' })).toHaveValue( + 'Investigating slow instance start times' + ) + await expect(modal.getByRole('button', { name: 'Update comment' })).toBeDisabled() + + await modal.getByRole('button', { name: 'Cancel' }).click() + await expect(modal).toBeHidden() + await expect(page).toHaveURL('/system/support-bundles') +}) + +test('bundle detail modal for failed bundle', async ({ page }) => { + await page.goto('/system/support-bundles') + + await page.getByRole('link', { name: 'bfc48b…fe3a7c' }).click() + + const modal = page.getByRole('dialog', { name: 'Support bundle' }) + await expect(modal.getByText('failed')).toBeVisible() + await expect(modal.getByText(/Allocated dataset/)).toBeVisible() + + // no zip exists, so no file count or size rows and no download + await expect(modal.getByText('Files')).toBeHidden() + await expect(modal.getByText('Size')).toBeHidden() + const download = modal.getByRole('button', { name: 'Download bundle' }) + await expect(download).toBeDisabled() + await download.hover() + // getByText rather than role=tooltip: the open modal makes the portaled + // tooltip aria-hidden, so it has no role, but it is still visible + await expect( + page.getByText('Only bundles that have completed collection can be downloaded') + ).toBeVisible() +}) + +test('detail modal polls a collecting bundle to active', async ({ page }) => { + await page.goto('/system/support-bundles') + + await page.getByRole('link', { name: 'New Support Bundle' }).click() + await page.getByRole('textbox', { name: 'Comment' }).fill('poll me') + await page.getByRole('button', { name: 'Create support bundle' }).click() + await expectToast(page, 'Support bundle created') + + // open the new bundle's detail modal while it's still collecting. the ID + // link is the only link in the row + await page.getByRole('row', { name: 'poll me' }).getByRole('link').click() + + const modal = page.getByRole('dialog', { name: 'Support bundle' }) + await expect(modal.getByText('collecting')).toBeVisible() + await expect(modal.getByRole('button', { name: 'Download bundle' })).toBeDisabled() + + // mock flips the bundle to active after 3s; the modal's view query polls + // every 10s, so the open modal updates in place + await expect(modal.getByText('active')).toBeVisible({ timeout: 20_000 }) + await expect(modal.getByRole('button', { name: 'Download bundle' })).toBeEnabled() +}) + test('create support bundle and poll to active', async ({ page }) => { await page.goto('/system/support-bundles') @@ -108,15 +182,15 @@ test('create shows insufficient capacity error in modal', async ({ page }) => { test('edit support bundle comment', async ({ page }) => { await page.goto('/system/support-bundles') - await clickRowAction(page, 'Investigating slow', 'Edit comment') + await clickRowAction(page, 'Investigating slow', 'View details') await expect(page).toHaveURL( - '/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31/edit' + '/system/support-bundles/ccdac005-66a8-4921-9e8b-30531c359c31' ) const comment = page.getByRole('textbox', { name: 'Comment' }) await expect(comment).toHaveValue('Investigating slow instance start times') await comment.fill('Resolved, keeping for reference') - await page.getByRole('button', { name: 'Update support bundle' }).click() + await page.getByRole('button', { name: 'Update comment' }).click() await expectToast(page, 'Support bundle updated') await expectRowVisible(page.getByRole('table'), {