diff --git a/app/forms/instance-resize.tsx b/app/forms/instance-resize.tsx deleted file mode 100644 index 9d916a10e0..0000000000 --- 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 343f4c1871..9fce0627a1 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 79d7a7accc..37e59a4757 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 8e1a69ca3e..6a892136c4 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 2f1377547b..86b159f5fa 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 3994420c02..6f444baf97 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 0f5789256d..76e39846ef 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 b135a17dd6..1709b19c59 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`,