diff --git a/app/pages/project/instances/instance/tabs/StorageTab.tsx b/app/pages/project/instances/instance/tabs/StorageTab.tsx index a92f68217..dccfbae66 100644 --- a/app/pages/project/instances/instance/tabs/StorageTab.tsx +++ b/app/pages/project/instances/instance/tabs/StorageTab.tsx @@ -11,6 +11,8 @@ import type { LoaderFunctionArgs } from 'react-router-dom' import { type Disk, apiQueryClient, + diskCan, + genName, instanceCan, useApiMutation, useApiQueryClient, @@ -60,11 +62,35 @@ export function StorageTab() { ) const detachDisk = useApiMutation('instanceDiskDetach', {}) + const createSnapshot = useApiMutation('snapshotCreate', { + onSuccess() { + queryClient.invalidateQueries('snapshotList') + addToast({ content: 'Snapshot successfully created' }) + }, + }) const { data: instance } = usePrefetchedApiQuery('instanceView', instancePathQuery) const makeActions = useCallback( (disk: Disk): MenuAction[] => [ + { + label: 'Snapshot', + disabled: !diskCan.snapshot(disk) && ( + <> + Only disks in state {fancifyStates(diskCan.snapshot.states)} can be snapshotted + + ), + onActivate() { + createSnapshot.mutate({ + query: { project }, + body: { + name: genName(disk.name), + disk: disk.name, + description: '', + }, + }) + }, + }, { label: 'Detach', disabled: !instanceCan.detachDisk(instance) && ( @@ -82,7 +108,7 @@ export function StorageTab() { }, }, ], - [detachDisk, instance, queryClient, instancePathQuery] + [detachDisk, instance, queryClient, instancePathQuery, createSnapshot, project] ) const attachDisk = useApiMutation('instanceDiskAttach', { diff --git a/app/test/e2e/instance/attach-disk.e2e.ts b/app/test/e2e/instance/disks.e2e.ts similarity index 72% rename from app/test/e2e/instance/attach-disk.e2e.ts rename to app/test/e2e/instance/disks.e2e.ts index e7d651ece..99d08a3d0 100644 --- a/app/test/e2e/instance/attach-disk.e2e.ts +++ b/app/test/e2e/instance/disks.e2e.ts @@ -5,7 +5,15 @@ * * Copyright Oxide Computer Company */ -import { expect, expectNotVisible, expectVisible, stopInstance, test } from '../utils' +import { + clickRowAction, + expect, + expectNotVisible, + expectRowVisible, + expectVisible, + stopInstance, + test, +} from '../utils' test('Attach disk', async ({ page }) => { await page.goto('/projects/mock-project/instances/db1') @@ -57,3 +65,23 @@ test('Attach disk', async ({ page }) => { await page.click('role=button[name="Attach Disk"]') await expectVisible(page, ['role=cell[name="disk-3"]']) }) + +test('Snapshot disk', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1') + + // have to use nth with toasts because the text shows up in multiple spots + const successMsg = page.getByText('Snapshot successfully created').nth(0) + await expect(successMsg).toBeHidden() + + await clickRowAction(page, 'disk-2', 'Snapshot') + + await expect(successMsg).toBeVisible() // we see the toast! + + // now go see the snapshot on the snapshots page + await page.getByRole('link', { name: 'Snapshots' }).click() + const table = page.getByRole('table') + await expectRowVisible(table, { + name: expect.stringMatching(/^disk-2-/), + disk: 'disk-2', + }) +}) diff --git a/app/test/e2e/utils.ts b/app/test/e2e/utils.ts index 60c6583ce..0554391f3 100644 --- a/app/test/e2e/utils.ts +++ b/app/test/e2e/utils.ts @@ -47,6 +47,12 @@ export async function expectNotVisible(page: Page, selectors: Selector[]) { } } +// Technically this has type AsymmetricMatcher, which is not exported by +// Playwright and is (surprisingly) just Record. Rather than use +// that, I think it's smarter to do the following in case they ever make the +// type more interesting; this will still do what it's supposed to. +type StringMatcher = ReturnType + /** * Assert that a row matching `expectedRow` is present in `table`. The match * uses `objectContaining`, so `expectedRow` does not need to contain every @@ -55,7 +61,7 @@ export async function expectNotVisible(page: Page, selectors: Selector[]) { */ export async function expectRowVisible( table: Locator, - expectedRow: Record + expectedRow: Record ) { // wait for header and rows to avoid flake town const headerLoc = table.locator('thead >> role=cell')