diff --git a/app/components/StatusBadge.tsx b/app/components/StatusBadge.tsx index 084738eb0b..47301669cb 100644 --- a/app/components/StatusBadge.tsx +++ b/app/components/StatusBadge.tsx @@ -1,4 +1,4 @@ -import type { DiskState, InstanceState } from '@oxide/api' +import type { DiskState, InstanceState, SnapshotState } from '@oxide/api' import type { BadgeColor, BadgeProps } from '@oxide/ui' import { Badge } from '@oxide/ui' @@ -41,3 +41,23 @@ export const DiskStatusBadge = (props: { status: DiskStateStr; className?: strin {props.status} ) + +const SNAPSHOT_COLORS: Record = { + creating: 'notice', + destroyed: 'neutral', + faulted: 'destructive', + ready: 'default', +} + +export const SnapshotStatusBadge = (props: { + status: SnapshotState + className?: string +}) => ( + + {props.status} + +) diff --git a/app/forms/snapshot-create.tsx b/app/forms/snapshot-create.tsx new file mode 100644 index 0000000000..ffdae869fa --- /dev/null +++ b/app/forms/snapshot-create.tsx @@ -0,0 +1,90 @@ +import type { PathParams, Snapshot, SnapshotCreate } from '@oxide/api' +import { useApiQuery } from '@oxide/api' +import { useApiMutation } from '@oxide/api' +import { useApiQueryClient } from '@oxide/api' +import { Success16Icon } from '@oxide/ui' + +import { + DescriptionField, + ListboxField, + NameField, + SideModalForm, +} from 'app/components/form' +import { useRequiredParams, useToast } from 'app/hooks' + +import type { CreateSideModalFormProps } from '.' + +const useSnapshotDiskItems = (params: PathParams.Project) => { + const { data: disks } = useApiQuery('diskList', { ...params, limit: 1000 }) + return ( + disks?.items + .filter((disk) => disk.state.state === 'attached') + .map((disk) => ({ value: disk.name, label: disk.name })) || [] + ) +} + +const values: SnapshotCreate = { + description: '', + disk: '', + name: '', +} + +export function CreateSnapshotSideModalForm({ + id = 'create-snapshot-form', + title = 'Create Snapshot', + initialValues = values, + onSubmit, + onSuccess, + onError, + onDismiss, + ...props +}: CreateSideModalFormProps) { + const queryClient = useApiQueryClient() + const pathParams = useRequiredParams('orgName', 'projectName') + const addToast = useToast() + + const diskItems = useSnapshotDiskItems(pathParams) + + const createSnapshot = useApiMutation('snapshotCreate', { + onSuccess(data) { + queryClient.invalidateQueries('snapshotList', pathParams) + addToast({ + icon: , + title: 'Success!', + content: 'Your snapshot has been created.', + }) + onSuccess?.(data) + onDismiss() + }, + onError, + }) + + return ( + { + createSnapshot.mutate({ + ...pathParams, + body: values, + }) + }) + } + {...props} + > + + + + + ) +} diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index 8b79d40c00..718e78bda1 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -2,6 +2,7 @@ import type { LoaderFunctionArgs } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom' import type { Disk } from '@oxide/api' +import { genName } from '@oxide/api' import { apiQueryClient } from '@oxide/api' import { useApiMutation, useApiQueryClient } from '@oxide/api' import { useApiQuery } from '@oxide/api' @@ -14,13 +15,19 @@ import { PageHeader, PageTitle, Storage24Icon, + Success16Icon, TableActions, buttonStyle, } from '@oxide/ui' import { DiskStatusBadge } from 'app/components/StatusBadge' import CreateDiskSideModalForm from 'app/forms/disk-create' -import { requireProjectParams, useProjectParams, useRequiredParams } from 'app/hooks' +import { + requireProjectParams, + useProjectParams, + useRequiredParams, + useToast, +} from 'app/hooks' import { pb } from 'app/util/path-builder' function AttachedInstance({ @@ -70,6 +77,7 @@ export function DisksPage({ modal }: DisksPageProps) { const queryClient = useApiQueryClient() const { orgName, projectName } = useRequiredParams('orgName', 'projectName') const { Table, Column } = useQueryTable('diskList', { orgName, projectName }) + const addToast = useToast() const deleteDisk = useApiMutation('diskDelete', { onSuccess() { @@ -77,7 +85,33 @@ export function DisksPage({ modal }: DisksPageProps) { }, }) + const createSnapshot = useApiMutation('snapshotCreate', { + onSuccess() { + queryClient.invalidateQueries('snapshotList', { orgName, projectName }) + addToast({ + icon: , + title: 'Success!', + content: 'Snapshot successfully created', + }) + }, + }) + const makeActions = (disk: Disk): MenuAction[] => [ + { + label: 'Snapshot', + onActivate() { + createSnapshot.mutate({ + orgName, + projectName, + body: { + name: genName(disk.name), + disk: disk.name, + description: '', + }, + }) + }, + disabled: disk.state.state !== 'attached', + }, { label: 'Delete', onActivate: () => { diff --git a/app/pages/project/snapshots/SnapshotsPage.tsx b/app/pages/project/snapshots/SnapshotsPage.tsx index 9277d6ddcb..0be2b97c8b 100644 --- a/app/pages/project/snapshots/SnapshotsPage.tsx +++ b/app/pages/project/snapshots/SnapshotsPage.tsx @@ -1,18 +1,39 @@ import type { LoaderFunctionArgs } from 'react-router-dom' +import { useNavigate } from 'react-router-dom' +import { Link } from 'react-router-dom' -import { apiQueryClient } from '@oxide/api' +import type { Snapshot } from '@oxide/api' +import { useApiQuery } from '@oxide/api' +import { apiQueryClient, useApiMutation, useApiQueryClient } from '@oxide/api' +import type { MenuAction } from '@oxide/table' import { DateCell, SizeCell, useQueryTable } from '@oxide/table' -import { EmptyMessage, PageHeader, PageTitle, Snapshots24Icon } from '@oxide/ui' +import { + EmptyMessage, + PageHeader, + PageTitle, + Snapshots24Icon, + TableActions, + buttonStyle, +} from '@oxide/ui' -import { requireProjectParams, useRequiredParams } from 'app/hooks' +import { SnapshotStatusBadge } from 'app/components/StatusBadge' +import { CreateSnapshotSideModalForm } from 'app/forms/snapshot-create' +import { requireProjectParams, useProjectParams, useRequiredParams } from 'app/hooks' +import { pb } from 'app/util/path-builder' + +const DiskNameFromId = ({ value }: { value: string }) => { + const { data: disk } = useApiQuery('diskViewById', { id: value }) + if (!disk) return null + return <>{disk.name} +} const EmptyState = () => ( } title="No snapshots" body="You need to create a snapshot to be able to see it here" - // buttonText="New snapshot" - // buttonTo="new" + buttonText="New snapshot" + buttonTo={pb.snapshotNew(useProjectParams())} /> ) @@ -23,20 +44,60 @@ SnapshotsPage.loader = async ({ params }: LoaderFunctionArgs) => { }) } -export function SnapshotsPage() { +interface SnapshotsPageProps { + modal?: 'createSnapshot' +} + +export function SnapshotsPage({ modal }: SnapshotsPageProps) { + const navigate = useNavigate() + + const queryClient = useApiQueryClient() const projectParams = useRequiredParams('orgName', 'projectName') const { Table, Column } = useQueryTable('snapshotList', projectParams) + + const deleteSnapshot = useApiMutation('snapshotDelete', { + onSuccess() { + queryClient.invalidateQueries('snapshotList', projectParams) + }, + }) + + const makeActions = (snapshot: Snapshot): MenuAction[] => [ + { + label: 'Delete', + onActivate() { + deleteSnapshot.mutate({ ...projectParams, snapshotName: snapshot.name }) + }, + }, + ] + return ( <> }>Snapshots - }> + + + New Snapshot + + +
} makeActions={makeActions}> + + } + />
+ navigate(pb.snapshots(projectParams))} + /> ) } diff --git a/app/routes.tsx b/app/routes.tsx index 2b9f2e7517..1f4d6ba071 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -206,6 +206,12 @@ export const routes = createRoutesFromElements( loader={SnapshotsPage.loader} handle={{ crumb: 'Snapshots' }} /> + } + loader={SnapshotsPage.loader} + handle={{ crumb: 'New snapshot' }} + /> } diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 50221bb008..ca39d95f84 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -38,6 +38,7 @@ test('path builder', () => { "silo": "/sys/silos/s", "siloNew": "/sys/silos-new", "silos": "/sys/silos", + "snapshotNew": "/orgs/a/projects/b/snapshots-new", "snapshots": "/orgs/a/projects/b/snapshots", "sshKeys": "/settings/ssh-keys", "system": "/sys", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 5c84afe655..31ef35fbaa 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -14,7 +14,6 @@ export const pb = { projectEdit: (params: PP.Project) => `${pb.project(params)}/edit`, access: (params: PP.Project) => `${pb.project(params)}/access`, - snapshots: (params: PP.Project) => `${pb.project(params)}/snapshots`, images: (params: PP.Project) => `${pb.project(params)}/images`, instances: (params: PP.Project) => `${pb.project(params)}/instances`, @@ -24,8 +23,11 @@ export const pb = { diskNew: (params: PP.Project) => `${pb.project(params)}/disks-new`, disks: (params: PP.Project) => `${pb.project(params)}/disks`, - vpcNew: (params: PP.Project) => `${pb.project(params)}/vpcs-new`, + snapshotNew: (params: PP.Project) => `${pb.project(params)}/snapshots-new`, + snapshots: (params: PP.Project) => `${pb.project(params)}/snapshots`, + + vpcNew: (params: PP.Project) => `${pb.project(params)}/vpcs-new`, vpcs: (params: PP.Project) => `${pb.project(params)}/vpcs`, vpc: (params: PP.Vpc) => `${pb.vpcs(params)}/${params.vpcName}`, vpcEdit: (params: PP.Vpc) => `${pb.vpc(params)}/edit`, diff --git a/libs/api-mocks/msw/db.ts b/libs/api-mocks/msw/db.ts index ba1911457f..7607446b93 100644 --- a/libs/api-mocks/msw/db.ts +++ b/libs/api-mocks/msw/db.ts @@ -94,6 +94,18 @@ export function lookupDisk(params: PP.Disk): Result> { return Ok(disk) } +export function lookupSnapshot(params: PP.Snapshot): Result> { + const [project, err] = lookupProject(params) + if (err) return Err(err) + + const snapshot = db.snapshots.find( + (s) => s.project_id === project.id && s.name === params.snapshotName + ) + if (!snapshot) return Err(notFoundErr) + + return Ok(snapshot) +} + export function lookupVpcSubnet(params: PP.VpcSubnet): Result> { const [vpc, err] = lookupVpc(params) if (err) return Err(err) diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index 0fca53641e..eae7f91cbc 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -10,6 +10,7 @@ import { serial } from '../serial' import { sessionMe } from '../session' import { defaultSilo } from '../silo' import type { NotFound } from './db' +import { lookupSnapshot } from './db' import { lookupSilo } from './db' import { db, @@ -729,6 +730,64 @@ export const handlers = [ } ), + rest.post, PP.Project, Json | PostErr>( + '/organizations/:orgName/projects/:projectName/snapshots', + async (req, res) => { + const [project, err] = lookupProject(req.params) + if (err) return res(err) + + const body = await req.json() + const alreadyExists = db.snapshots.some((s) => s.name === body.name) + if (alreadyExists) return res(alreadyExistsErr) + + if (!body.name) { + return res(badRequest('name requires at least one character')) + } + + if (!body.disk) { + return res(badRequest('disk to snapshot is required')) + } + + const [disk, diskErr] = lookupDisk({ ...req.params, diskName: body.disk }) + + if (diskErr) { + return res(diskErr) + } + + const newSnapshot: Json = { + id: genId('snapshot'), + ...body, + ...getTimestamps(), + state: 'ready', + project_id: project.id, + disk_id: disk.id, + size: disk.size, + } + db.snapshots.push(newSnapshot) + return res(json(newSnapshot, { status: 201 })) + } + ), + + rest.get | GetErr>( + '/organizations/:orgName/projects/:projectName/snapshots/:snapshotName', + (req, res) => { + const [snapshot, err] = lookupSnapshot(req.params) + if (err) return res(err) + + return res(json(snapshot)) + } + ), + + rest.delete( + '/organizations/:orgName/projects/:projectName/snapshots/:snapshotName', + (req, res, ctx) => { + const [snapshot, err] = lookupSnapshot(req.params) + if (err) return res(err) + db.snapshots = db.snapshots.filter((s) => s.id !== snapshot.id) + return res(ctx.status(204)) + } + ), + rest.get | GetErr>( '/organizations/:orgName/projects/:projectName/vpcs', (req, res) => { diff --git a/libs/api/path-params.ts b/libs/api/path-params.ts index 7f87b1e1ee..db17186f1d 100644 --- a/libs/api/path-params.ts +++ b/libs/api/path-params.ts @@ -6,6 +6,7 @@ export type Vpc = Merge export type Instance = Merge export type NetworkInterface = Merge export type Disk = Merge +export type Snapshot = Merge export type DiskMetric = Merge export type VpcSubnet = Merge export type VpcRouter = Merge