diff --git a/app/forms/image-upload.tsx b/app/forms/image-upload.tsx index bade80684..3945c5604 100644 --- a/app/forms/image-upload.tsx +++ b/app/forms/image-upload.tsx @@ -153,6 +153,7 @@ function getTmpDiskName(imageName: string) { 'import-start-500', 'import-stop-500', 'disk-finalize-500', + 'cancel-upload', ]) if (specialNames.has(imageName)) return imageName } diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index 956c29182..bb6d4d0c6 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -9,6 +9,7 @@ import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo } from 'react' import { Outlet, type LoaderFunctionArgs } from 'react-router' +import { match } from 'ts-pattern' import { api, @@ -28,6 +29,7 @@ import { DiskStateBadge, DiskTypeBadge, ReadOnlyBadge } from '~/components/State import { makeCrumb } from '~/hooks/use-crumbs' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { useQuickActions } from '~/hooks/use-quick-actions' +import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { DiskSourceName } from '~/table/cells/DiskSourceCell' @@ -113,6 +115,17 @@ export default function DisksPage() { }, }) + const { mutateAsync: finalize } = useApiMutation(api.diskFinalizeImport, { + onSuccess() { + queryClient.invalidateEndpoint('diskList') + }, + }) + const { mutateAsync: stopBulkWriteImport } = useApiMutation(api.diskBulkWriteImportStop, { + onSuccess() { + queryClient.invalidateEndpoint('diskList') + }, + }) + const makeActions = useCallback( (disk: Disk): MenuAction[] => [ { @@ -131,23 +144,55 @@ export default function DisksPage() { }, disabled: snapshotDisabledReason(disk), }, - { - label: 'Delete', - onActivate: confirmDelete({ - doDelete: () => deleteDisk({ path: { disk: disk.name }, query: { project } }), - label: disk.name, - resourceKind: 'disk', - }), - disabled: - !diskCan.delete(disk) && - (disk.state.state === 'attached' ? ( - 'Disk must be detached before it can be deleted' - ) : ( - <>Only disks in state {fancifyStates(diskCan.delete.states)} can be deleted - )), - }, + match(disk.state.state) + .with('import_ready', 'importing_from_bulk_writes', () => ({ + label: 'Cancel import', + onActivate() { + confirmAction({ + doAction: async () => { + if (disk.state.state === 'importing_from_bulk_writes') { + await stopBulkWriteImport({ + path: { disk: disk.name }, + query: { project }, + }) + } + + await finalize({ + path: { disk: disk.name }, + query: { project }, + body: {}, + }) + + addToast( + <> + Import canceled for {disk.name} + + ) + }, + modalTitle: 'Cancel import', + modalContent: `Are you sure you want to cancel import for ${disk.name}?`, + errorTitle: 'Failed to cancel import', + actionType: 'danger', + }) + }, + })) + .otherwise(() => ({ + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => deleteDisk({ path: { disk: disk.name }, query: { project } }), + label: disk.name, + resourceKind: 'disk', + }), + disabled: + !diskCan.delete(disk) && + (disk.state.state === 'attached' ? ( + 'Disk must be detached before it can be deleted' + ) : ( + <>Only disks in state {fancifyStates(diskCan.delete.states)} can be deleted + )), + })), ], - [createSnapshot, deleteDisk, project] + [createSnapshot, deleteDisk, stopBulkWriteImport, finalize, project] ) const columns = useColsWithActions( diff --git a/mock-api/disk.ts b/mock-api/disk.ts index 577cbddce..23a3887fb 100644 --- a/mock-api/disk.ts +++ b/mock-api/disk.ts @@ -239,6 +239,49 @@ export const disks: Json[] = [ disk_type: 'distributed', read_only: false, }, + { + id: '7b898827-35a1-4459-a4e3-34db90640b74', + name: 'tmp-for-image-29884739', + description: 'stuck in import_ready after bailing on an image upload early', + project_id: project.id, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), + state: { state: 'import_ready' }, + device_path: '/import', + size: 8 * GiB, + block_size: 2048, + disk_type: 'distributed', + read_only: false, + }, + { + id: 'f874c0b9-72ad-4eac-8e55-e6090e10366a', + name: 'tmp-for-image-59986861', + description: 'stuck in bulk-write after bailing on an image upload early', + project_id: project.id, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), + state: { state: 'importing_from_bulk_writes' }, + device_path: '/import', + size: 8 * GiB, + block_size: 2048, + disk_type: 'distributed', + read_only: false, + }, + { + id: '0f60c28e-ead0-48f0-aab9-e74b917dc8e4', + name: 'disk-finalize-fail', + description: + "stuck in bulk-write after bailing on an image upload early, but can't be finalized", + project_id: project.id, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), + state: { state: 'importing_from_bulk_writes' }, + device_path: '/import', + size: 8 * GiB, + block_size: 2048, + disk_type: 'distributed', + read_only: false, + }, { id: '3f23c80f-c523-4d86-8292-2ca3f807bb12', name: 'disk-snapshot-error', diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index d0ce152a1..7ead44793 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -284,7 +284,9 @@ export const handlers = makeHandlers({ diskFinalizeImport: ({ path, query, body }) => { const disk = lookup.disk({ ...path, ...query }) - if (disk.name === 'disk-finalize-500') throw internalError('disk finalize failed') + if (disk.name === 'disk-finalize-500' || disk.name === 'disk-finalize-fail') { + throw internalError('disk finalize failed') + } if (disk.state.state !== 'import_ready') { throw `Cannot finalize disk in state ${disk.state.state}. Must be import_ready.` diff --git a/test/e2e/disks.e2e.ts b/test/e2e/disks.e2e.ts index 0b9724df1..8115c822a 100644 --- a/test/e2e/disks.e2e.ts +++ b/test/e2e/disks.e2e.ts @@ -7,6 +7,7 @@ */ import { clickRowAction, + clickRowActions, expect, expectNoToast, expectRowVisible, @@ -93,7 +94,7 @@ test('List disks and snapshot', async ({ page }) => { await page.goto('/projects/mock-project/disks') const table = page.getByRole('table') - await expect(table.getByRole('row')).toHaveCount(16) // 15 + header + await expect(table.getByRole('row')).toHaveCount(19) // 18 + header // check one attached and one not attached await expectRowVisible(table, { @@ -166,6 +167,68 @@ test('Read-only disk snapshot disabled', async ({ page }) => { ) }) +test('Cancel import from import_ready', async ({ page }) => { + const diskImportReadyName = 'tmp-for-image-29884739' + await page.goto('/projects/mock-project/disks') + const table = page.getByRole('table') + await expectRowVisible(table, { name: diskImportReadyName, state: 'import ready' }) + + await clickRowActions(page, diskImportReadyName) + await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeHidden() + await page.getByRole('menuitem', { name: 'Cancel import' }).click() + + const modal = page.getByRole('dialog', { name: 'Cancel import' }) + await expect(modal).toBeVisible() + await modal.getByRole('button', { name: 'Confirm' }).click() + + await expectToast(page, `Import canceled for ${diskImportReadyName}`) + await expectRowVisible(table, { name: diskImportReadyName, state: 'detached' }) + await clickRowActions(page, diskImportReadyName) + await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible() +}) + +test('Cancel import from importing_from_bulk_writes', async ({ page }) => { + const diskImportingName = 'tmp-for-image-59986861' + await page.goto('/projects/mock-project/disks') + const table = page.getByRole('table') + await expectRowVisible(table, { + name: diskImportingName, + state: 'importing from bulk writes', + }) + + await clickRowActions(page, diskImportingName) + await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeHidden() + await page.getByRole('menuitem', { name: 'Cancel import' }).click() + + const modal = page.getByRole('dialog', { name: 'Cancel import' }) + await expect(modal).toBeVisible() + await modal.getByRole('button', { name: 'Confirm' }).click() + + await expectToast(page, `Import canceled for ${diskImportingName}`) + await expectRowVisible(table, { name: diskImportingName, state: 'detached' }) + await clickRowActions(page, diskImportingName) + await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible() +}) + +test('Cancel import from importing_from_bulk_writes shows error and refreshes state when finalize fails', async ({ + page, +}) => { + const diskName = 'disk-finalize-fail' + await page.goto('/projects/mock-project/disks') + const table = page.getByRole('table') + await expectRowVisible(table, { name: diskName, state: 'importing from bulk writes' }) + + await clickRowActions(page, diskName) + await page.getByRole('menuitem', { name: 'Cancel import' }).click() + await page + .getByRole('dialog', { name: 'Cancel import' }) + .getByRole('button', { name: 'Confirm' }) + .click() + + await expectToast(page, 'Failed to cancel import') + await expectRowVisible(table, { name: diskName, state: 'import ready' }) +}) + test.describe('Disk create', () => { test.beforeEach(async ({ page }) => { await page.goto('/projects/mock-project/disks-new') diff --git a/test/e2e/image-upload.e2e.ts b/test/e2e/image-upload.e2e.ts index 34df27799..314e6acb1 100644 --- a/test/e2e/image-upload.e2e.ts +++ b/test/e2e/image-upload.e2e.ts @@ -192,7 +192,7 @@ test.describe('Image upload', () => { for (const state of cancelStates) { test(`cancel in state '${state}'`, async ({ page }) => { - await fillForm(page, 'new-image') + await fillForm(page, 'cancel-upload') await page.getByRole('button', { name: 'Upload image' }).click() @@ -219,7 +219,9 @@ test.describe('Image upload', () => { await page.getByRole('button', { name: 'Cancel' }).click() await page.getByRole('link', { name: 'Disks' }).click() await expect(page.getByRole('cell', { name: 'disk-1', exact: true })).toBeVisible() - await expect(page.getByRole('cell', { name: 'tmp' })).toBeHidden() + await expect( + page.getByRole('cell', { name: 'cancel-upload', exact: true }) + ).toBeHidden() }) }