From 91aebf49861e6ac701795af9049eba9231cc2f9a Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 4 Oct 2024 13:24:34 -0500 Subject: [PATCH 1/3] bump API for instance resize and avoid getting broken by it --- OMICRON_VERSION | 2 +- app/api/__generated__/Api.ts | 32 +++++++++++++------ app/api/__generated__/OMICRON_VERSION | 2 +- app/api/__generated__/validate.ts | 24 ++++++++------ .../instances/instance/tabs/StorageTab.tsx | 12 ++++--- mock-api/msw/handlers.ts | 4 +++ 6 files changed, 51 insertions(+), 25 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index f7a1d4ae6..4fb54663b 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -c50cf019cd9be35f98266a7f4acacab0236b3a3d +fd7e5a1387475d1ebec9880e3bbb854f69e5fcf6 diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index e64990f1f..1bd48cdfc 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -1710,6 +1710,16 @@ export type ImageResultsPage = { */ export type ImportBlocksBulkWrite = { base64EncodedData: string; offset: number } +/** + * A policy determining when an instance should be automatically restarted by the control plane. + */ +export type InstanceAutoRestartPolicy = + /** The instance should not be automatically restarted by the control plane if it fails. */ + | 'never' + + /** If this instance is running and unexpectedly fails (e.g. due to a host software crash or unexpected host reboot), the control plane will make a best-effort attempt to restart it. The control plane may choose not to restart the instance to preserve the overall availability of the system. */ + | 'best_effort' + /** * The number of CPUs in an Instance */ @@ -1761,6 +1771,10 @@ If this is not present, then either the instance has never been automatically re autoRestartCooldownExpiration?: Date /** `true` if this instance's auto-restart policy will permit the control plane to automatically restart it if it enters the `Failed` state. */ autoRestartEnabled: boolean + /** The auto-restart policy configured for this instance, or `None` if no explicit policy is configured. + +If this is not present, then this instance uses the default auto-restart policy, which may or may not allow it to be restarted. The `auto_restart_enabled` field indicates whether the instance will be automatically restarted. */ + autoRestartPolicy?: InstanceAutoRestartPolicy /** the ID of the disk used to boot this Instance, if a specific one is assigned. */ bootDiskId?: string /** human-readable free-form text about a resource */ @@ -1789,16 +1803,6 @@ If this is not present, then this instance has not been automatically restarted. timeRunStateUpdated: Date } -/** - * A policy determining when an instance should be automatically restarted by the control plane. - */ -export type InstanceAutoRestartPolicy = - /** The instance should not be automatically restarted by the control plane if it fails. */ - | 'never' - - /** If this instance is running and unexpectedly fails (e.g. due to a host software crash or unexpected host reboot), the control plane will make a best-effort attempt to restart it. The control plane may choose not to restart the instance to preserve the overall availability of the system. */ - | 'best_effort' - /** * Describe the instance's disks at creation time */ @@ -1976,10 +1980,18 @@ export type InstanceSerialConsoleData = { * Parameters of an `Instance` that can be reconfigured after creation. */ export type InstanceUpdate = { + /** The auto-restart policy for this instance. + +If not provided, unset the instance's auto-restart policy. */ + autoRestartPolicy?: InstanceAutoRestartPolicy /** Name or ID of the disk the instance should be instructed to boot from. If not provided, unset the instance's boot disk. */ bootDisk?: NameOrId + /** The amount of memory to assign to this instance. */ + memory: ByteCount + /** The number of CPUs to assign to this instance. */ + ncpus: InstanceCpuCount } /** diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index 2c1de3ebd..73f543dd9 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -c50cf019cd9be35f98266a7f4acacab0236b3a3d +fd7e5a1387475d1ebec9880e3bbb854f69e5fcf6 diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 77402ef0d..dd36c4372 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -1645,6 +1645,14 @@ export const ImportBlocksBulkWrite = z.preprocess( z.object({ base64EncodedData: z.string(), offset: z.number().min(0) }) ) +/** + * A policy determining when an instance should be automatically restarted by the control plane. + */ +export const InstanceAutoRestartPolicy = z.preprocess( + processResponseBody, + z.enum(['never', 'best_effort']) +) + /** * The number of CPUs in an Instance */ @@ -1682,6 +1690,7 @@ export const Instance = z.preprocess( z.object({ autoRestartCooldownExpiration: z.coerce.date().optional(), autoRestartEnabled: SafeBoolean, + autoRestartPolicy: InstanceAutoRestartPolicy.optional(), bootDiskId: z.string().uuid().optional(), description: z.string(), hostname: z.string(), @@ -1698,14 +1707,6 @@ export const Instance = z.preprocess( }) ) -/** - * A policy determining when an instance should be automatically restarted by the control plane. - */ -export const InstanceAutoRestartPolicy = z.preprocess( - processResponseBody, - z.enum(['never', 'best_effort']) -) - /** * Describe the instance's disks at creation time */ @@ -1852,7 +1853,12 @@ export const InstanceSerialConsoleData = z.preprocess( */ export const InstanceUpdate = z.preprocess( processResponseBody, - z.object({ bootDisk: NameOrId.optional() }) + z.object({ + autoRestartPolicy: InstanceAutoRestartPolicy.optional(), + bootDisk: NameOrId.optional(), + memory: ByteCount, + ncpus: InstanceCpuCount, + }) ) /** diff --git a/app/pages/project/instances/instance/tabs/StorageTab.tsx b/app/pages/project/instances/instance/tabs/StorageTab.tsx index 1083b509e..ec39b1d1c 100644 --- a/app/pages/project/instances/instance/tabs/StorageTab.tsx +++ b/app/pages/project/instances/instance/tabs/StorageTab.tsx @@ -145,6 +145,10 @@ export function StorageTab() { [disks.items, instance.bootDiskId] ) + // Needed to keep them the same while setting boot disk. + // Extracted to keep dep array appropriately zealous. + const { ncpus, memory } = instance + const makeBootDiskActions = useCallback( (disk: InstanceDisk): MenuAction[] => [ getSnapshotAction(disk), @@ -161,7 +165,7 @@ export function StorageTab() { doAction: () => instanceUpdate({ path: { instance: instance.id }, - body: { bootDisk: undefined }, + body: { bootDisk: undefined, ncpus, memory }, }), errorTitle: 'Could not unset boot disk', modalTitle: 'Confirm unset boot disk', @@ -189,7 +193,7 @@ export function StorageTab() { onActivate() {}, // it's always disabled, so noop is ok }, ], - [instanceUpdate, instance.id, getSnapshotAction] + [instanceUpdate, instance.id, getSnapshotAction, ncpus, memory] ) const makeOtherDiskActions = useCallback( @@ -210,7 +214,7 @@ export function StorageTab() { doAction: () => instanceUpdate({ path: { instance: instance.id }, - body: { bootDisk: disk.id }, + body: { bootDisk: disk.id, ncpus, memory }, }), errorTitle: `Could not ${verb} boot disk`, modalTitle: `Confirm ${verb} boot disk`, @@ -245,7 +249,7 @@ export function StorageTab() { }, }, ], - [detachDisk, instanceUpdate, instance.id, getSnapshotAction, bootDisks] + [detachDisk, instanceUpdate, instance.id, getSnapshotAction, bootDisks, ncpus, memory] ) const attachDisk = useApiMutation('instanceDiskAttach', { diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index c81f53d95..d2b80c2fa 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -613,6 +613,10 @@ export const handlers = makeHandlers({ instance.boot_disk_id = undefined } + // always present on the body, always set them + instance.ncpus = body.ncpus + instance.memory = body.memory + return instance }, instanceDelete({ path, query }) { From c71671f8d87edc2ae461682570232f957d613c3f Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 4 Oct 2024 14:41:44 -0500 Subject: [PATCH 2/3] clunky but functioning side modal form based resize UI --- app/forms/instance-resize.tsx | 75 +++++++++++++++++++++++++ app/pages/project/instances/actions.tsx | 7 ++- app/routes.tsx | 12 ++++ app/util/path-builder.spec.ts | 1 + app/util/path-builder.ts | 1 + 5 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 app/forms/instance-resize.tsx diff --git a/app/forms/instance-resize.tsx b/app/forms/instance-resize.tsx new file mode 100644 index 000000000..9d916a10e --- /dev/null +++ b/app/forms/instance-resize.tsx @@ -0,0 +1,75 @@ +/* + * 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 { useForm } from 'react-hook-form' +import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' +import * as R from 'remeda' + +import { + apiQueryClient, + useApiMutation, + useApiQueryClient, + usePrefetchedApiQuery, +} from '@oxide/api' + +import { NumberField } from '~/components/form/fields/NumberField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { pb } from '~/util/path-builder' + +InstanceResizeForm.loader = async ({ params }: LoaderFunctionArgs) => { + const { project, instance } = getInstanceSelector(params) + await apiQueryClient.prefetchQuery('instanceView', { + path: { instance }, + query: { project }, + }) + return null +} + +export function InstanceResizeForm() { + const { instance: instanceName, project } = useInstanceSelector() + const queryClient = useApiQueryClient() + const navigate = useNavigate() + + const { data: instance } = usePrefetchedApiQuery('instanceView', { + path: { instance: instanceName }, + query: { project }, + }) + + const instanceUpdate = useApiMutation('instanceUpdate', { + onSuccess(_updatedInstance) { + queryClient.invalidateQueries('instanceView') + navigate(pb.instance({ project, instance: instanceName })) + addToast({ title: 'Instance updated' }) + }, + }) + + const form = useForm({ defaultValues: R.pick(instance, ['ncpus', 'memory']) }) + + return ( + navigate(pb.instance({ project, instance: instanceName }))} + onSubmit={({ ncpus, memory }) => { + instanceUpdate.mutate({ + path: { instance: instanceName }, + query: { project }, + // very important to include the boot disk or it will be unset + body: { ncpus, memory, bootDisk: instance.bootDiskId }, + }) + }} + loading={instanceUpdate.isPending} + submitError={instanceUpdate.error} + > + + + + ) +} diff --git a/app/pages/project/instances/actions.tsx b/app/pages/project/instances/actions.tsx index df251f3ab..79d7a7acc 100644 --- a/app/pages/project/instances/actions.tsx +++ b/app/pages/project/instances/actions.tsx @@ -118,10 +118,13 @@ export const useMakeInstanceActions = ( ), }, { - label: 'View serial console', + label: 'Resize', onActivate() { - navigate(pb.serialConsole(instanceSelector)) + navigate(pb.instanceResize(instanceSelector)) }, + disabled: !instanceCan.update(instance) && ( + <>Only {fancifyStates(instanceCan.update.states)} instances can be resized + ), }, { label: 'Delete', diff --git a/app/routes.tsx b/app/routes.tsx index 86b159f5f..2f1377547 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -23,6 +23,7 @@ import { import { CreateImageFromSnapshotSideModalForm } from './forms/image-from-snapshot' import { CreateImageSideModalForm } from './forms/image-upload' import { CreateInstanceForm } from './forms/instance-create' +import { InstanceResizeForm } from './forms/instance-resize' import { CreateIpPoolSideModalForm } from './forms/ip-pool-create' import { EditIpPoolSideModalForm } from './forms/ip-pool-edit' import { IpPoolAddRangeSideModalForm } from './forms/ip-pool-range-add' @@ -319,6 +320,17 @@ export const routes = createRoutesFromElements( loader={StorageTab.loader} handle={{ crumb: 'Storage' }} /> + + + + + } + loader={StorageTab.loader} + handle={{ crumb: 'Resize' }} + /> } diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 76e39846e..0f5789256 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -44,6 +44,7 @@ test('path builder', () => { "instanceConnect": "/projects/p/instances/i/connect", "instanceMetrics": "/projects/p/instances/i/metrics", "instanceNetworking": "/projects/p/instances/i/networking", + "instanceResize": "/projects/p/instances/i/resize", "instanceStorage": "/projects/p/instances/i/storage", "instances": "/projects/p/instances", "instancesNew": "/projects/p/instances-new", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 1709b19c5..b135a17dd 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -62,6 +62,7 @@ export const pb = { instanceStorage: (params: Instance) => `${instanceBase(params)}/storage`, instanceConnect: (params: Instance) => `${instanceBase(params)}/connect`, instanceNetworking: (params: Instance) => `${instanceBase(params)}/networking`, + instanceResize: (params: Instance) => `${instanceBase(params)}/resize`, serialConsole: (params: Instance) => `${instanceBase(params)}/serial-console`, disksNew: (params: Project) => `${projectBase(params)}/disks-new`, From c25857457da037049fbac04e1a162d070382e14b Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Wed, 30 Oct 2024 17:02:51 +0000 Subject: [PATCH 3/3] Instance resize modal (#2495) * Switch to regular modal * Improve modal close button alignment * Semi 'currently using:' and fix semi weight * Revert: fix semi weight Handled in #2496 instead * Switch route for modal * Disable submit if the specs are the same * Improve toast * Fix lint error `no-unused-expressions` --- app/forms/instance-resize.tsx | 75 --------- app/pages/project/instances/InstancesPage.tsx | 18 +- app/pages/project/instances/actions.tsx | 14 +- .../instances/instance/InstancePage.tsx | 155 +++++++++++++++++- app/routes.tsx | 12 -- app/ui/lib/Modal.tsx | 2 +- app/util/path-builder.spec.ts | 1 - app/util/path-builder.ts | 1 - 8 files changed, 176 insertions(+), 102 deletions(-) delete mode 100644 app/forms/instance-resize.tsx diff --git a/app/forms/instance-resize.tsx b/app/forms/instance-resize.tsx deleted file mode 100644 index 9d916a10e..000000000 --- a/app/forms/instance-resize.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - * 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 { useForm } from 'react-hook-form' -import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' -import * as R from 'remeda' - -import { - apiQueryClient, - useApiMutation, - useApiQueryClient, - usePrefetchedApiQuery, -} from '@oxide/api' - -import { NumberField } from '~/components/form/fields/NumberField' -import { SideModalForm } from '~/components/form/SideModalForm' -import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' -import { addToast } from '~/stores/toast' -import { pb } from '~/util/path-builder' - -InstanceResizeForm.loader = async ({ params }: LoaderFunctionArgs) => { - const { project, instance } = getInstanceSelector(params) - await apiQueryClient.prefetchQuery('instanceView', { - path: { instance }, - query: { project }, - }) - return null -} - -export function InstanceResizeForm() { - const { instance: instanceName, project } = useInstanceSelector() - const queryClient = useApiQueryClient() - const navigate = useNavigate() - - const { data: instance } = usePrefetchedApiQuery('instanceView', { - path: { instance: instanceName }, - query: { project }, - }) - - const instanceUpdate = useApiMutation('instanceUpdate', { - onSuccess(_updatedInstance) { - queryClient.invalidateQueries('instanceView') - navigate(pb.instance({ project, instance: instanceName })) - addToast({ title: 'Instance updated' }) - }, - }) - - const form = useForm({ defaultValues: R.pick(instance, ['ncpus', 'memory']) }) - - return ( - navigate(pb.instance({ project, instance: instanceName }))} - onSubmit={({ ncpus, memory }) => { - instanceUpdate.mutate({ - path: { instance: instanceName }, - query: { project }, - // very important to include the boot disk or it will be unset - body: { ncpus, memory, bootDisk: instance.bootDiskId }, - }) - }} - loading={instanceUpdate.isPending} - submitError={instanceUpdate.error} - > - - - - ) -} diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index 343f4c187..9fce0627a 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -7,7 +7,7 @@ */ import { createColumnHelper } from '@tanstack/react-table' import { filesize } from 'filesize' -import { useMemo, useRef } from 'react' +import { useMemo, useRef, useState } from 'react' import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, usePrefetchedApiQuery, type Instance } from '@oxide/api' @@ -33,6 +33,7 @@ import { toLocaleTimeString } from '~/util/date' import { pb } from '~/util/path-builder' import { useMakeInstanceActions } from './actions' +import { ResizeInstanceModal } from './instance/InstancePage' const EmptyState = () => ( (null) const makeActions = useMakeInstanceActions( { project }, - { onSuccess: refetchInstances, onDelete: refetchInstances } + { + onSuccess: refetchInstances, + onDelete: refetchInstances, + onResizeClick: (instance) => setResizeInstance(instance), + } ) // this is a whole thing. sit down. @@ -212,6 +218,14 @@ export function InstancesPage() { New Instance } /> + {resizeInstance && ( + setResizeInstance(null)} + onListView + /> + )} ) } diff --git a/app/pages/project/instances/actions.tsx b/app/pages/project/instances/actions.tsx index 79d7a7acc..37e59a475 100644 --- a/app/pages/project/instances/actions.tsx +++ b/app/pages/project/instances/actions.tsx @@ -6,7 +6,6 @@ * Copyright Oxide Computer Company */ import { useCallback } from 'react' -import { useNavigate } from 'react-router-dom' import { instanceCan, useApiMutation, type Instance } from '@oxide/api' @@ -15,7 +14,6 @@ import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import type { MakeActions } from '~/table/columns/action-col' -import { pb } from '~/util/path-builder' import { fancifyStates } from './instance/tabs/common' @@ -26,14 +24,13 @@ type Options = { // hook has to expand to encompass the sum of all the APIs of these hooks it // call internally, the abstraction is not good onDelete?: () => void + onResizeClick?: (instance: Instance) => void } export const useMakeInstanceActions = ( { project }: { project: string }, options: Options = {} ): MakeActions => { - const navigate = useNavigate() - // if you also pass onSuccess to mutate(), this one is not overridden — this // one runs first, then the one passed to mutate(). // @@ -51,7 +48,6 @@ export const useMakeInstanceActions = ( return useCallback( (instance) => { - const instanceSelector = { project, instance: instance.name } const instanceParams = { path: { instance: instance.name }, query: { project } } return [ { @@ -119,8 +115,10 @@ export const useMakeInstanceActions = ( }, { label: 'Resize', - onActivate() { - navigate(pb.instanceResize(instanceSelector)) + onActivate: () => { + if (options.onResizeClick) { + options.onResizeClick(instance) + } }, disabled: !instanceCan.update(instance) && ( <>Only {fancifyStates(instanceCan.update.states)} instances can be resized @@ -147,11 +145,11 @@ export const useMakeInstanceActions = ( }, [ project, - navigate, deleteInstanceAsync, rebootInstance, startInstance, stopInstanceAsync, + options, ] ) } diff --git a/app/pages/project/instances/instance/InstancePage.tsx b/app/pages/project/instances/instance/InstancePage.tsx index 8e1a69ca3..6a892136c 100644 --- a/app/pages/project/instances/instance/InstancePage.tsx +++ b/app/pages/project/instances/instance/InstancePage.tsx @@ -6,33 +6,46 @@ * Copyright Oxide Computer Company */ import { filesize } from 'filesize' -import { useMemo } from 'react' +import { useMemo, useState } from 'react' +import { useForm } from 'react-hook-form' import { Link, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, + useApiMutation, useApiQuery, usePrefetchedApiQuery, + type Instance, type InstanceNetworkInterface, } from '@oxide/api' import { Instances24Icon } from '@oxide/design-system/icons/react' -import { instanceTransitioning } from '~/api/util' +import { + INSTANCE_MAX_CPU, + INSTANCE_MAX_RAM_GiB, + instanceCan, + instanceTransitioning, +} from '~/api/util' import { ExternalIps } from '~/components/ExternalIps' +import { NumberField } from '~/components/form/fields/NumberField' import { InstanceDocsPopover } from '~/components/InstanceDocsPopover' import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { RefreshButton } from '~/components/RefreshButton' import { RouteTabs, Tab } from '~/components/RouteTabs' import { InstanceStateBadge } from '~/components/StateBadge' import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' import { DateTime } from '~/ui/lib/DateTime' +import { Message } from '~/ui/lib/Message' +import { Modal } from '~/ui/lib/Modal' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { Spinner } from '~/ui/lib/Spinner' import { Tooltip } from '~/ui/lib/Tooltip' import { Truncate } from '~/ui/lib/Truncate' import { pb } from '~/util/path-builder' +import { GiB } from '~/util/units' import { useMakeInstanceActions } from '../actions' @@ -90,6 +103,7 @@ const POLL_INTERVAL = 1000 export function InstancePage() { const instanceSelector = useInstanceSelector() + const [resizeInstance, setResizeInstance] = useState(false) const navigate = useNavigate() const makeActions = useMakeInstanceActions(instanceSelector, { @@ -99,6 +113,7 @@ export function InstancePage() { apiQueryClient.invalidateQueries('instanceList') navigate(pb.instances(instanceSelector)) }, + onResizeClick: () => setResizeInstance(true), }) const { data: instance } = usePrefetchedApiQuery( @@ -217,6 +232,142 @@ export function InstancePage() { Networking Connect + {resizeInstance && ( + setResizeInstance(false)} + /> + )} ) } + +export function ResizeInstanceModal({ + instance, + project, + onDismiss, + onListView = false, +}: { + instance: Instance + project: string + onDismiss: () => void + onListView?: boolean +}) { + const instanceUpdate = useApiMutation('instanceUpdate', { + onSuccess(_updatedInstance) { + if (onListView) { + apiQueryClient.invalidateQueries('instanceList') + } else { + apiQueryClient.invalidateQueries('instanceView') + } + onDismiss() + addToast({ + content: `${instance.name} has been resized`, + cta: onListView + ? { + text: `View instance`, + link: pb.instance({ project, instance: instance.name }), + } + : undefined, // Only link to the instance if we're not already on that page + }) + }, + onError: (err) => { + addToast({ title: 'Error', content: err.message, variant: 'error' }) + }, + onSettled: onDismiss, + }) + + const form = useForm({ + defaultValues: { + ncpus: instance.ncpus, + memory: instance.memory / GiB, // memory is stored as bytes + }, + mode: 'onChange', + }) + + const canResize = instanceCan.update(instance) + const willChange = + form.watch('ncpus') !== instance.ncpus || form.watch('memory') !== instance.memory / GiB + const isDisabled = !form.formState.isValid || !canResize || !willChange + + const onAction = form.handleSubmit(({ ncpus, memory }) => { + instanceUpdate.mutate({ + path: { instance: instance.name }, + query: { project }, + body: { ncpus, memory: memory * GiB, bootDisk: instance.bootDiskId }, + }) + }) + + return ( + + + + {!canResize ? ( + + ) : ( + +
+ {instance.ncpus}{' '} + vCPUs / {instance.memory / GiB} GiB +
+ + } + /> + )} +
+ { + if (cpus < 1) { + return `Must be at least 1 vCPU` + } + if (cpus > INSTANCE_MAX_CPU) { + return `CPUs capped to ${INSTANCE_MAX_CPU}` + } + // We can show this error and therefore inform the user + // of the limit rather than preventing it completely + }} + disabled={!canResize} + /> + { + if (memory < 1) { + return `Must be at least 1 GiB` + } + if (memory > INSTANCE_MAX_RAM_GiB) { + return `Can be at most ${INSTANCE_MAX_RAM_GiB} GiB` + } + }} + disabled={!canResize} + /> + + {instanceUpdate.error && ( +

{instanceUpdate.error.message}

+ )} +
+
+ +
+ ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 2f1377547..86b159f5f 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -23,7 +23,6 @@ import { import { CreateImageFromSnapshotSideModalForm } from './forms/image-from-snapshot' import { CreateImageSideModalForm } from './forms/image-upload' import { CreateInstanceForm } from './forms/instance-create' -import { InstanceResizeForm } from './forms/instance-resize' import { CreateIpPoolSideModalForm } from './forms/ip-pool-create' import { EditIpPoolSideModalForm } from './forms/ip-pool-edit' import { IpPoolAddRangeSideModalForm } from './forms/ip-pool-range-add' @@ -320,17 +319,6 @@ export const routes = createRoutesFromElements( loader={StorageTab.loader} handle={{ crumb: 'Storage' }} /> - - - - - } - loader={StorageTab.loader} - handle={{ crumb: 'Resize' }} - /> } diff --git a/app/ui/lib/Modal.tsx b/app/ui/lib/Modal.tsx index 3994420c0..6f444baf9 100644 --- a/app/ui/lib/Modal.tsx +++ b/app/ui/lib/Modal.tsx @@ -75,7 +75,7 @@ export function Modal({ children, onDismiss, title, isOpen }: ModalProps) { )} {children} diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 0f5789256..76e39846e 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -44,7 +44,6 @@ test('path builder', () => { "instanceConnect": "/projects/p/instances/i/connect", "instanceMetrics": "/projects/p/instances/i/metrics", "instanceNetworking": "/projects/p/instances/i/networking", - "instanceResize": "/projects/p/instances/i/resize", "instanceStorage": "/projects/p/instances/i/storage", "instances": "/projects/p/instances", "instancesNew": "/projects/p/instances-new", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index b135a17dd..1709b19c5 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -62,7 +62,6 @@ export const pb = { instanceStorage: (params: Instance) => `${instanceBase(params)}/storage`, instanceConnect: (params: Instance) => `${instanceBase(params)}/connect`, instanceNetworking: (params: Instance) => `${instanceBase(params)}/networking`, - instanceResize: (params: Instance) => `${instanceBase(params)}/resize`, serialConsole: (params: Instance) => `${instanceBase(params)}/serial-console`, disksNew: (params: Project) => `${projectBase(params)}/disks-new`,