Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to create image from snapshot #1663

Merged
merged 7 commits into from
Jul 17, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions app/forms/image-from-snapshot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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 { addToast } from 'app/stores/toast'
import { pb } from 'app/util/path-builder'

const defaultValues: Omit<ImageCreate, 'source'> = {
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 } })
addToast({
content: 'Your image has been created',
})
onDismiss()
},
})

const form = useForm({
mode: 'all',
defaultValues: {
...defaultValues,
name: data.name,
},
})

return (
<SideModalForm
id="create-image-from-snapshot-form"
form={form}
title={`Create image from snapshot`}
submitLabel="Create image"
onDismiss={onDismiss}
onSubmit={(body) =>
createImage.mutate({
query: { project },
body: { ...body, source: { type: 'snapshot', id: data.id } },
})
}
>
<PropertiesTable>
<PropertiesTable.Row label="Snapshot">{data.name}</PropertiesTable.Row>
<PropertiesTable.Row label="Shared with">{project}</PropertiesTable.Row>
<PropertiesTable.Row label="Size">{fileSize(data.size)}</PropertiesTable.Row>
</PropertiesTable>

<NameField name="name" control={form.control} required />
<DescriptionField name="description" control={form.control} required />
<TextField name="os" label="OS" control={form.control} required />
<TextField name="version" control={form.control} required />
</SideModalForm>
)
}
3 changes: 3 additions & 0 deletions app/hooks/use-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -62,6 +63,8 @@ function useSelectedParams<T>(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)
Expand Down
9 changes: 8 additions & 1 deletion app/pages/project/snapshots/SnapshotsPage.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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() {
Expand All @@ -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({
Expand Down
7 changes: 7 additions & 0 deletions app/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -313,6 +314,12 @@ export const routes = createRoutesFromElements(
element={<CreateSnapshotSideModalForm />}
handle={{ crumb: 'New snapshot' }}
/>
<Route
path="snapshots/:snapshot/image-new"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The path here is a little odd. I've patterned it after the other create endpoints though it doesn't necessarily need to be image-new. It could be something like create-image. There's really just no precedent for that.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is the most reasonable choice.

element={<CreateImageFromSnapshotSideModalForm />}
loader={CreateImageFromSnapshotSideModalForm.loader}
handle={{ crumb: 'Create image from snapshot' }}
/>
</Route>

<Route element={<ImagesPage />} loader={ImagesPage.loader}>
Expand Down
25 changes: 25 additions & 0 deletions app/test/e2e/snapshots.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})
})
2 changes: 2 additions & 0 deletions app/util/path-builder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const params = {
provider: 'pr',
sledId: 'sl',
image: 'im',
snapshot: 'sn',
}

test('path builder', () => {
Expand Down Expand Up @@ -56,6 +57,7 @@ test('path builder', () => {
"sled": "/system/inventory/sleds/sl",
"sledInstances": "/system/inventory/sleds/sl/instances",
"sledInventory": "/system/inventory/sleds",
"snapshotImageCreate": "/projects/p/snapshots/sn/image-new",
"snapshotNew": "/projects/p/snapshots-new",
"snapshots": "/projects/p/snapshots",
"sshKeyNew": "/settings/ssh-keys-new",
Expand Down
3 changes: 3 additions & 0 deletions app/util/path-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type Silo = Required<PP.Silo>
type IdentityProvider = Required<PP.IdentityProvider>
type Sled = Required<PP.Sled>
type Image = Required<PP.Image>
type Snapshot = Required<PP.Snapshot>
type SiloImage = Required<PP.SiloImage>

export const pb = {
Expand Down Expand Up @@ -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`,
Expand Down