diff --git a/app/pages/project/instances/actions.tsx b/app/pages/project/instances/actions.tsx index 6b50afd038..b59e09d45a 100644 --- a/app/pages/project/instances/actions.tsx +++ b/app/pages/project/instances/actions.tsx @@ -41,7 +41,7 @@ export const useMakeInstanceActions = ( const opts = { onSuccess: options.onSuccess } const { mutateAsync: startInstanceAsync } = useApiMutation('instanceStart', opts) const { mutateAsync: stopInstanceAsync } = useApiMutation('instanceStop', opts) - const { mutate: rebootInstance } = useApiMutation('instanceReboot', opts) + const { mutateAsync: rebootInstanceAsync } = useApiMutation('instanceReboot', opts) // delete has its own const { mutateAsync: deleteInstanceAsync } = useApiMutation('instanceDelete', { onSuccess: options.onDelete, @@ -122,15 +122,20 @@ export const useMakeInstanceActions = ( { label: 'Reboot', onActivate() { - rebootInstance(instanceParams, { - onSuccess: () => - addToast(<>Rebooting instance {instance.name}), // prettier-ignore - onError: (error) => - addToast({ - variant: 'error', - title: `Error rebooting instance '${instance.name}'`, - content: error.message, + confirmAction({ + actionType: 'danger', + doAction: () => + rebootInstanceAsync(instanceParams, { + onSuccess: () => + addToast(<>Rebooting instance {instance.name}), // prettier-ignore }), + modalTitle: 'Confirm reboot instance', + modalContent: ( +

+ Are you sure you want to reboot {instance.name}? +

+ ), + errorTitle: `Error rebooting ${instance.name}`, }) }, disabled: !instanceCan.reboot(instance) && ( @@ -162,7 +167,7 @@ export const useMakeInstanceActions = ( }, ] }, - [project, deleteInstanceAsync, navigate, rebootInstance] + [project, deleteInstanceAsync, navigate, rebootInstanceAsync] ) return { makeButtonActions, makeMenuActions } diff --git a/test/e2e/instance.e2e.ts b/test/e2e/instance.e2e.ts index 8da3ba836f..e011157bbf 100644 --- a/test/e2e/instance.e2e.ts +++ b/test/e2e/instance.e2e.ts @@ -5,7 +5,14 @@ * * Copyright Oxide Computer Company */ -import { clickRowAction, expect, expectRowVisible, test, type Page } from './utils' +import { + clickRowAction, + expect, + expectRowVisible, + openRowActions, + test, + type Page, +} from './utils' const expectInstanceState = async (page: Page, instance: string, state: string) => { await expectRowVisible(page.getByRole('table'), { @@ -33,10 +40,7 @@ test('can start a failed instance', async ({ page }) => { await page.goto('/projects/mock-project/instances') // check the start button disabled message on a running instance - await page - .getByRole('row', { name: 'db1', exact: false }) - .getByRole('button', { name: 'Row actions' }) - .click() + await openRowActions(page, 'db1') await page.getByRole('menuitem', { name: 'Start' }).hover() await expect( page.getByText('Only stopped or failed instances can be started') @@ -99,6 +103,42 @@ test('can stop a starting instance, then start it again', async ({ page }) => { await expectInstanceState(page, 'not-there-yet', 'running') }) +test('can reboot a running instance', async ({ page }) => { + await page.goto('/projects/mock-project/instances') + await expect(page).toHaveTitle('Instances / mock-project / Projects / Oxide Console') + + await expectInstanceState(page, 'db1', 'running') + await clickRowAction(page, 'db1', 'Reboot') + await page.getByRole('button', { name: 'Confirm' }).click() + await expectInstanceState(page, 'db1', 'rebooting') + await expectInstanceState(page, 'db1', 'running') +}) + +test('cannot reboot a failed instance', async ({ page }) => { + await page.goto('/projects/mock-project/instances') + await expectInstanceState(page, 'you-fail', 'failed') + await openRowActions(page, 'you-fail') + await expect(page.getByRole('menuitem', { name: 'Reboot' })).toBeDisabled() +}) + +test('cannot reboot a starting instance, or a stopped instance', async ({ page }) => { + await page.goto('/projects/mock-project/instances') + await expectInstanceState(page, 'not-there-yet', 'starting') + await openRowActions(page, 'not-there-yet') + await expect(page.getByRole('menuitem', { name: 'Reboot' })).toBeDisabled() + // hit escape to close the menu so clickRowAction succeeds + await page.keyboard.press('Escape') + + // stop it so we can try rebooting a stopped instance + await clickRowAction(page, 'not-there-yet', 'Stop') + await page.getByRole('button', { name: 'Confirm' }).click() + await expectInstanceState(page, 'not-there-yet', 'stopping') + await expectInstanceState(page, 'not-there-yet', 'stopped') + // reboot is still disabled for a stopped instance + await openRowActions(page, 'not-there-yet') + await expect(page.getByRole('menuitem', { name: 'Reboot' })).toBeDisabled() +}) + test('delete from instance detail', async ({ page }) => { await page.goto('/projects/mock-project/instances/you-fail') diff --git a/test/e2e/utils.ts b/test/e2e/utils.ts index 9cb7864751..2f8437d0cd 100644 --- a/test/e2e/utils.ts +++ b/test/e2e/utils.ts @@ -145,12 +145,16 @@ export async function closeToast(page: Page) { export const clipboardText = async (page: Page) => page.evaluate(() => navigator.clipboard.readText()) -/** Select row by `rowText`, click the row actions button, and click `actionName` */ -export async function clickRowAction(page: Page, rowText: string, actionName: string) { +export const openRowActions = async (page: Page, name: string) => { await page - .getByRole('row', { name: rowText, exact: false }) + .getByRole('row', { name, exact: false }) .getByRole('button', { name: 'Row actions' }) .click() +} + +/** Select row by `rowName`, click the row actions button, and click `actionName` */ +export async function clickRowAction(page: Page, rowName: string, actionName: string) { + await openRowActions(page, rowName) await page.getByRole('menuitem', { name: actionName }).click() }