diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 35331adfb1..79a7b8070f 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -53,7 +53,13 @@ import { TextField, type DiskTableItem, } from 'app/components/form' -import { getProjectSelector, useForm, useProjectSelector, useToast } from 'app/hooks' +import { + getProjectSelector, + useForm, + useInstanceTemplate, + useProjectSelector, + useToast, +} from 'app/hooks' import { readBlobAsBase64 } from 'app/util/file' import { links } from 'app/util/links' import { pb } from 'app/util/path-builder' @@ -71,7 +77,7 @@ export type InstanceCreateInput = Assign< } > -const baseDefaultValues: InstanceCreateInput = { +export const baseDefaultValues: InstanceCreateInput = { name: '', description: '', /** @@ -144,6 +150,8 @@ export function CreateInstanceForm() { const form = useForm({ defaultValues }) const { control, setValue } = form + useInstanceTemplate(setValue) + const imageInput = useWatch({ control: control, name: 'image' }) const image = allImages.find((i) => i.id === imageInput) const imageSize = image?.size ? Math.ceil(image.size / GiB) : undefined @@ -560,7 +568,7 @@ const renderLargeRadioCards = (category: string) => { )) } -const PRESETS = [ +export const PRESETS = [ { category: 'general', id: 'general-xs', memory: 8, ncpus: 2 }, { category: 'general', id: 'general-sm', memory: 16, ncpus: 4 }, { category: 'general', id: 'general-md', memory: 32, ncpus: 8 }, diff --git a/app/hooks/index.ts b/app/hooks/index.ts index 1a4d9ce75f..42894d3642 100644 --- a/app/hooks/index.ts +++ b/app/hooks/index.ts @@ -13,3 +13,4 @@ export * from './use-params' export * from './use-quick-actions' export * from './use-reduce-motion' export * from './use-toast' +export * from './use-template' diff --git a/app/hooks/use-template.tsx b/app/hooks/use-template.tsx new file mode 100644 index 0000000000..44fd0d320d --- /dev/null +++ b/app/hooks/use-template.tsx @@ -0,0 +1,119 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useEffect } from 'react' +import { type FieldValues, type Path, type UseFormSetValue } from 'react-hook-form' +import { useSearchParams } from 'react-router-dom' + +import { genName, useApiQuery, type BlockSize } from '@oxide/api' +import { bytesToGiB } from '@oxide/util' + +import type { DiskTableItem } from 'app/components/form' +import { + baseDefaultValues as instanceDefaultValues, + type InstanceCreateInput, +} from 'app/forms/instance-create' + +function incrementName(str: string) { + let name = str + const match = name.match(/(.*)-(\d+)$/) + + if (match) { + const base = match[1] + const num = parseInt(match[2], 10) + name = `${base}-${num + 1}` + } else { + name = `${name}-1` + } + + return name +} + +export function useInstanceTemplate(setValue: UseFormSetValue) { + const [searchParams, setSearchParams] = useSearchParams() + const instanceId = searchParams.get('template') || '' + + const { data: instance, isFetched: instanceFetched } = useApiQuery( + 'instanceView', + { + path: { instance: instanceId }, + }, + { enabled: !!instanceId } + ) + + const { data: disksData, isFetched: disksFetched } = useApiQuery( + 'instanceDiskList', + { + path: { instance: instanceId }, + }, + { enabled: !!instanceId } + ) + + useEffect(() => { + if (!instanceFetched || !disksFetched || !instance) return + + const disks = disksData?.items || [] + const bootDisk = disks.length > 0 ? disks[0] : null + + const additionalDisks: DiskTableItem[] = disks.slice(1).map((disk) => ({ + description: disk.description, + diskSource: { + type: 'blank', + blockSize: disk.blockSize as BlockSize, + }, + name: disk.name, + size: disk.size, + type: 'create', + })) + + const template: InstanceCreateInput = { + name: incrementName(instance.name), + description: instanceDefaultValues.description, + presetId: 'custom', + memory: bytesToGiB(instance.memory), + ncpus: instance.ncpus, + hostname: instance.hostname, + + bootDiskName: bootDisk ? genName(bootDisk.name) : instanceDefaultValues.bootDiskName, + bootDiskSize: bootDisk?.size + ? bytesToGiB(bootDisk?.size) + : instanceDefaultValues.bootDiskSize, + image: bootDisk?.imageId || '', + + disks: additionalDisks, + networkInterfaces: { type: 'default' }, + + start: instanceDefaultValues.start, + + userData: instanceDefaultValues.userData, + } + + setTemplateFormValues(setValue, template) + + searchParams.delete('template') + setSearchParams(searchParams) + }, [ + instance, + instanceFetched, + disksFetched, + disksData, + setValue, + setSearchParams, + searchParams, + ]) +} + +export function setTemplateFormValues( + setValue: UseFormSetValue, + values: TFieldValues +) { + Object.keys(values).forEach((key) => { + if (values[key]) { + setValue(key as Path, values[key]) + } + }) +} diff --git a/app/pages/project/instances/actions.tsx b/app/pages/project/instances/actions.tsx index c2ee1d3ab1..dae23060d1 100644 --- a/app/pages/project/instances/actions.tsx +++ b/app/pages/project/instances/actions.tsx @@ -102,6 +102,12 @@ export const useMakeInstanceActions = ( navigate(pb.serialConsole(instanceSelector)) }, }, + { + label: 'New similar instance', + onActivate() { + navigate(`${pb.instanceNew(projectSelector)}?template=${instance.id}`) + }, + }, { label: 'Delete', onActivate: confirmDelete({ diff --git a/libs/api-mocks/disk.ts b/libs/api-mocks/disk.ts index c3e4b53f88..2a943d810f 100644 --- a/libs/api-mocks/disk.ts +++ b/libs/api-mocks/disk.ts @@ -8,6 +8,7 @@ import type { Disk } from '@oxide/api' import { GiB } from '@oxide/util' +import { images } from './image' import { instance } from './instance' import type { Json } from './json-type' import { project } from './project' @@ -22,6 +23,7 @@ export const disks: Json[] = [ time_modified: new Date().toISOString(), state: { state: 'attached', instance: instance.id }, device_path: '/abc', + image_id: images[0].id, size: 2 * GiB, block_size: 2048, }, diff --git a/libs/api-mocks/instance.ts b/libs/api-mocks/instance.ts index bfdfe5597f..44ff5917ef 100644 --- a/libs/api-mocks/instance.ts +++ b/libs/api-mocks/instance.ts @@ -13,8 +13,8 @@ import { project } from './project' export const instance: Json = { id: '935499b3-fd96-432a-9c21-83a3dc1eece4', name: 'db1', - ncpus: 7, - memory: 1024 * 1024 * 256, + ncpus: 4, + memory: 1024 * 1024 * 1024 * 16, description: 'an instance', hostname: 'oxide.com', project_id: project.id, @@ -27,8 +27,8 @@ export const instance: Json = { const failedInstance: Json = { id: 'b5946edc-5bed-4597-88ab-9a8beb9d32a4', name: 'you-fail', - ncpus: 7, - memory: 1024 * 1024 * 256, + ncpus: 8, + memory: 1024 * 1024 * 1024 * 16, description: 'a failed instance', hostname: 'oxide.com', project_id: project.id, @@ -41,8 +41,8 @@ const failedInstance: Json = { const startingInstance: Json = { id: '16737f54-1f76-4c96-8b7c-9d24971c1d62', name: 'not-there-yet', - ncpus: 7, - memory: 1024 * 1024 * 256, + ncpus: 2, + memory: 1024 * 1024 * 1024 * 8, description: 'a starting instance', hostname: 'oxide.com', project_id: project.id,