From 9dae24dcb033c7b529178794aa445adf270d8b61 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Fri, 14 Jul 2023 19:54:21 -0400 Subject: [PATCH 1/7] Add create snapshot from image form --- app/forms/image-from-snapshot.tsx | 86 +++++++++++++++++++ app/hooks/use-params.ts | 3 + app/pages/project/snapshots/SnapshotsPage.tsx | 9 +- app/routes.tsx | 7 ++ app/util/path-builder.ts | 3 + 5 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 app/forms/image-from-snapshot.tsx diff --git a/app/forms/image-from-snapshot.tsx b/app/forms/image-from-snapshot.tsx new file mode 100644 index 000000000..c06319eee --- /dev/null +++ b/app/forms/image-from-snapshot.tsx @@ -0,0 +1,86 @@ +import fileSize from 'filesize' +import { useForm } from 'react-hook-form' +import type { LoaderFunctionArgs } from 'react-router-dom' +import { useNavigate } from 'react-router-dom' +import invariant from 'tiny-invariant' + +import { + type ImageCreate, + apiQueryClient, + useApiMutation, + useApiQuery, + useApiQueryClient, +} from '@oxide/api' +import { PropertiesTable } from '@oxide/ui' + +import { DescriptionField, NameField, SideModalForm, TextField } from 'app/components/form' +import { getProjectSnapshotSelector, useProjectSnapshotSelector } from 'app/hooks' +import { pb } from 'app/util/path-builder' + +const defaultValues: Omit = { + name: '', + description: '', + os: '', + version: '', +} + +CreateImageFromSnapshotSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { + const { project, snapshot } = getProjectSnapshotSelector(params) + await apiQueryClient.prefetchQuery('snapshotView', { + path: { snapshot }, + query: { project }, + }) + return null +} + +export function CreateImageFromSnapshotSideModalForm() { + const { snapshot, project } = useProjectSnapshotSelector() + const { data } = useApiQuery('snapshotView', { path: { snapshot }, query: { project } }) + invariant(data, 'Snapshot must be prefetched in loader') + const navigate = useNavigate() + const queryClient = useApiQueryClient() + + const onDismiss = () => navigate(pb.snapshots({ project })) + + const createImage = useApiMutation('imageCreate', { + onSuccess() { + queryClient.invalidateQueries('imageList', { query: { project } }) + onDismiss() + }, + }) + + const form = useForm({ + mode: 'all', + defaultValues: { + ...defaultValues, + name: data.name, + }, + }) + + return ( + + createImage.mutate({ + query: { project }, + body: { ...body, source: { type: 'snapshot', id: data.id } }, + }) + } + > + + {data.name} + {project} + {fileSize(data.size)} + + + + + + + + ) +} diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts index 4f4bf3ef8..5d6244f72 100644 --- a/app/hooks/use-params.ts +++ b/app/hooks/use-params.ts @@ -32,6 +32,7 @@ export const getSiloSelector = requireParams('silo') export const getSiloImageSelector = requireParams('image') export const getIdpSelector = requireParams('silo', 'provider') export const getProjectImageSelector = requireParams('project', 'image') +export const getProjectSnapshotSelector = requireParams('project', 'snapshot') export const requireSledParams = requireParams('sledId') export const requireUpdateParams = requireParams('version') @@ -62,6 +63,8 @@ function useSelectedParams(getSelector: (params: AllParams) => T) { export const useProjectSelector = () => useSelectedParams(getProjectSelector) export const useProjectImageSelector = () => useSelectedParams(getProjectImageSelector) +export const useProjectSnapshotSelector = () => + useSelectedParams(getProjectSnapshotSelector) export const useInstanceSelector = () => useSelectedParams(getInstanceSelector) export const useVpcSelector = () => useSelectedParams(getVpcSelector) export const useSiloSelector = () => useSelectedParams(getSiloSelector) diff --git a/app/pages/project/snapshots/SnapshotsPage.tsx b/app/pages/project/snapshots/SnapshotsPage.tsx index 8eeec4a17..497e44261 100644 --- a/app/pages/project/snapshots/SnapshotsPage.tsx +++ b/app/pages/project/snapshots/SnapshotsPage.tsx @@ -1,5 +1,5 @@ import type { LoaderFunctionArgs } from 'react-router-dom' -import { Link, Outlet } from 'react-router-dom' +import { Link, Outlet, useNavigate } from 'react-router-dom' import type { Snapshot } from '@oxide/api' import { @@ -55,6 +55,7 @@ export function SnapshotsPage() { const queryClient = useApiQueryClient() const projectSelector = useProjectSelector() const { Table, Column } = useQueryTable('snapshotList', { query: projectSelector }) + const navigate = useNavigate() const deleteSnapshot = useApiMutation('snapshotDelete', { onSuccess() { @@ -63,6 +64,12 @@ export function SnapshotsPage() { }) const makeActions = (snapshot: Snapshot): MenuAction[] => [ + { + label: 'Create image', + onActivate() { + navigate(pb.snapshotImageCreate({ ...projectSelector, snapshot: snapshot.name })) + }, + }, { label: 'Delete', onActivate: confirmDelete({ diff --git a/app/routes.tsx b/app/routes.tsx index 690f14aba..8efd6ae7d 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -8,6 +8,7 @@ import { EditProjectImageSideModalForm, EditSiloImageSideModalForm, } from './forms/image-edit' +import { CreateImageFromSnapshotSideModalForm } from './forms/image-from-snapshot' import { CreateImageSideModalForm } from './forms/image-upload' import { CreateInstanceForm } from './forms/instance-create' import { CreateProjectSideModalForm } from './forms/project-create' @@ -313,6 +314,12 @@ export const routes = createRoutesFromElements( element={} handle={{ crumb: 'New snapshot' }} /> + } + loader={CreateImageFromSnapshotSideModalForm.loader} + handle={{ crumb: 'Create image from snapshot' }} + /> } loader={ImagesPage.loader}> diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index a64811b9c..bdf64e5e7 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -10,6 +10,7 @@ type Silo = Required type IdentityProvider = Required type Sled = Required type Image = Required +type Snapshot = Required type SiloImage = Required export const pb = { @@ -50,6 +51,8 @@ export const pb = { snapshotNew: (params: Project) => `${pb.project(params)}/snapshots-new`, snapshots: (params: Project) => `${pb.project(params)}/snapshots`, + snapshotImageCreate: (params: Snapshot) => + `${pb.project(params)}/snapshots/${params.snapshot}/image-new`, vpcNew: (params: Project) => `${pb.project(params)}/vpcs-new`, vpcs: (params: Project) => `${pb.project(params)}/vpcs`, From 781c05760c0ef81b87012f9105fa5ea999a8bbc7 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Fri, 14 Jul 2023 20:16:59 -0400 Subject: [PATCH 2/7] Add toast on success --- app/forms/image-from-snapshot.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/forms/image-from-snapshot.tsx b/app/forms/image-from-snapshot.tsx index c06319eee..086bdc0dd 100644 --- a/app/forms/image-from-snapshot.tsx +++ b/app/forms/image-from-snapshot.tsx @@ -15,6 +15,7 @@ import { PropertiesTable } from '@oxide/ui' import { DescriptionField, NameField, SideModalForm, TextField } from 'app/components/form' import { getProjectSnapshotSelector, useProjectSnapshotSelector } from 'app/hooks' +import { addToast } from 'app/stores/toast' import { pb } from 'app/util/path-builder' const defaultValues: Omit = { @@ -45,6 +46,9 @@ export function CreateImageFromSnapshotSideModalForm() { const createImage = useApiMutation('imageCreate', { onSuccess() { queryClient.invalidateQueries('imageList', { query: { project } }) + addToast({ + content: 'Your image has been created', + }) onDismiss() }, }) From a5c86407d14aa5928c9f13d027b4d9a68dc6d2b0 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Fri, 14 Jul 2023 20:21:39 -0400 Subject: [PATCH 3/7] Fix failing snapshot test --- app/util/path-builder.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 5e3d0787a..34ff785a4 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -56,6 +56,7 @@ test('path builder', () => { "sled": "/system/inventory/sleds/sl", "sledInstances": "/system/inventory/sleds/sl/instances", "sledInventory": "/system/inventory/sleds", + "snapshotImageCreate": "/projects/p/snapshots/undefined/image-new", "snapshotNew": "/projects/p/snapshots-new", "snapshots": "/projects/p/snapshots", "sshKeyNew": "/settings/ssh-keys-new", From 9edebc6625a8c49816ac39a4705bbdd0b54919db Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Fri, 14 Jul 2023 20:54:03 -0400 Subject: [PATCH 4/7] Add e2e test for creating image from snapshot --- app/test/e2e/snapshots.e2e.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/app/test/e2e/snapshots.e2e.ts b/app/test/e2e/snapshots.e2e.ts index b0c642a6d..d2253ca7c 100644 --- a/app/test/e2e/snapshots.e2e.ts +++ b/app/test/e2e/snapshots.e2e.ts @@ -65,3 +65,28 @@ test('Error on delete snapshot', async ({ page }) => { page.getByText('Could not delete resource', { exact: true }), ]) }) + +test('Create image from snapshot', async ({ page }) => { + await page.goto('/projects/mock-project/snapshots') + + const row = page.getByRole('row', { name: 'snapshot-1' }) + await row.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Create image' }).click() + + await expectVisible(page, ['role=dialog[name="Create image from snapshot"]']) + + await page.fill('role=textbox[name="Name"]', 'image-from-snapshot-1') + await page.fill('role=textbox[name="Description"]', 'image description') + await page.fill('role=textbox[name="OS"]', 'Ubuntu') + await page.fill('role=textbox[name="Version"]', '20.02') + + await page.click('role=button[name="Create image"]') + + await expect(page).toHaveURL('/projects/mock-project/snapshots') + + await page.click('role=link[name*="Images"]') + await expectRowVisible(page.getByRole('table'), { + name: 'image-from-snapshot-1', + description: 'image description', + }) +}) From 38c5cadb708aab5c4a1e043b0771e62cc976c72a Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Fri, 14 Jul 2023 20:58:28 -0400 Subject: [PATCH 5/7] Fix type failure, update snapshot test --- app/util/path-builder.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 34ff785a4..88a6d9a08 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -12,6 +12,7 @@ const params = { provider: 'pr', sledId: 'sl', image: 'im', + snapshot: 'sn', } test('path builder', () => { @@ -56,7 +57,7 @@ test('path builder', () => { "sled": "/system/inventory/sleds/sl", "sledInstances": "/system/inventory/sleds/sl/instances", "sledInventory": "/system/inventory/sleds", - "snapshotImageCreate": "/projects/p/snapshots/undefined/image-new", + "snapshotImageCreate": "/projects/p/snapshots/sn/image-new", "snapshotNew": "/projects/p/snapshots-new", "snapshots": "/projects/p/snapshots", "sshKeyNew": "/settings/ssh-keys-new", From a9e56e41978a3b350327e9317cbbbf4cd45ad0b8 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Mon, 17 Jul 2023 13:54:31 -0400 Subject: [PATCH 6/7] bytes to widdle bibbies --- app/forms/image-from-snapshot.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/forms/image-from-snapshot.tsx b/app/forms/image-from-snapshot.tsx index 086bdc0dd..797df5eb2 100644 --- a/app/forms/image-from-snapshot.tsx +++ b/app/forms/image-from-snapshot.tsx @@ -78,7 +78,9 @@ export function CreateImageFromSnapshotSideModalForm() { {data.name} {project} - {fileSize(data.size)} + + {fileSize(data.size, { base: 2 })} + From 52b8dc72b6ca397f0344084ba85b77feeb4659e8 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Mon, 17 Jul 2023 13:55:30 -0400 Subject: [PATCH 7/7] shared with to project --- app/forms/image-from-snapshot.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/forms/image-from-snapshot.tsx b/app/forms/image-from-snapshot.tsx index 797df5eb2..cfba503d1 100644 --- a/app/forms/image-from-snapshot.tsx +++ b/app/forms/image-from-snapshot.tsx @@ -77,7 +77,7 @@ export function CreateImageFromSnapshotSideModalForm() { > {data.name} - {project} + {project} {fileSize(data.size, { base: 2 })}