diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index 7bdd3c7eb..e737cc97e 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -8,7 +8,7 @@ import { type UseQueryOptions } from '@tanstack/react-query' 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 { @@ -41,6 +41,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 { makeButtonActions, makeMenuActions } = useMakeInstanceActions( { project }, - { onSuccess: refetchInstances, onDelete: refetchInstances } + { + onSuccess: refetchInstances, + onDelete: refetchInstances, + onResizeClick: setResizeInstance, + } ) const columns = useMemo( @@ -221,6 +228,13 @@ export function InstancesPage() { New Instance {table} + {resizeInstance && ( + setResizeInstance(null)} + onListView + /> + )} ) } diff --git a/app/pages/project/instances/actions.tsx b/app/pages/project/instances/actions.tsx index b59e09d45..b9eaf3c1f 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' @@ -14,7 +13,6 @@ import { HL } from '~/components/HL' import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' -import { pb } from '~/util/path-builder' import { fancifyStates } from './instance/tabs/common' @@ -25,13 +23,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 = {} ) => { - 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(). // @@ -47,6 +45,8 @@ export const useMakeInstanceActions = ( onSuccess: options.onDelete, }) + const { onResizeClick } = options + const makeButtonActions = useCallback( (instance: Instance) => { const instanceParams = { path: { instance: instance.name }, query: { project } } @@ -116,7 +116,6 @@ export const useMakeInstanceActions = ( const makeMenuActions = useCallback( (instance: Instance) => { - const instanceSelector = { project, instance: instance.name } const instanceParams = { path: { instance: instance.name }, query: { project } } return [ { @@ -143,10 +142,11 @@ export const useMakeInstanceActions = ( ), }, { - label: 'View serial console', - onActivate() { - navigate(pb.serialConsole(instanceSelector)) - }, + label: 'Resize', + onActivate: () => onResizeClick?.(instance), + disabled: !instanceCan.update(instance) && ( + <>Only {fancifyStates(instanceCan.update.states)} instances can be resized + ), }, { label: 'Delete', @@ -167,7 +167,10 @@ export const useMakeInstanceActions = ( }, ] }, - [project, deleteInstanceAsync, navigate, rebootInstanceAsync] + // Do not put `options` in here, refer to the property. options is not ref + // stable. Extra renders here cause the row actions menu to close when it + // shouldn't, like during polling on instance list. + [project, deleteInstanceAsync, rebootInstanceAsync, onResizeClick] ) return { makeButtonActions, makeMenuActions } diff --git a/app/pages/project/instances/instance/InstancePage.tsx b/app/pages/project/instances/instance/InstancePage.tsx index f9c7fc30c..376635558 100644 --- a/app/pages/project/instances/instance/InstancePage.tsx +++ b/app/pages/project/instances/instance/InstancePage.tsx @@ -6,34 +6,52 @@ * 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 { HL } from '~/components/HL' 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 { + getInstanceSelector, + useInstanceSelector, + useProjectSelector, +} from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' import { Button } from '~/ui/lib/Button' 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 { truncate, Truncate } from '~/ui/lib/Truncate' import { pb } from '~/util/path-builder' +import { GiB } from '~/util/units' import { useMakeInstanceActions } from '../actions' @@ -91,6 +109,7 @@ const POLL_INTERVAL = 1000 export function InstancePage() { const instanceSelector = useInstanceSelector() + const [resizeInstance, setResizeInstance] = useState(false) const navigate = useNavigate() @@ -101,6 +120,7 @@ export function InstancePage() { apiQueryClient.invalidateQueries('instanceList') navigate(pb.instances(instanceSelector)) }, + onResizeClick: () => setResizeInstance(true), }) const { data: instance } = usePrefetchedApiQuery( @@ -233,6 +253,142 @@ export function InstancePage() { Networking Connect + {resizeInstance && ( + setResizeInstance(false)} + /> + )} ) } + +export function ResizeInstanceModal({ + instance, + onDismiss, + onListView = false, +}: { + instance: Instance + onDismiss: () => void + onListView?: boolean +}) { + const { project } = useProjectSelector() + const instanceUpdate = useApiMutation('instanceUpdate', { + onSuccess(_updatedInstance) { + if (onListView) { + apiQueryClient.invalidateQueries('instanceList') + } else { + apiQueryClient.invalidateQueries('instanceView') + } + onDismiss() + addToast({ + content: ( + <> + Instance {instance.name} 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 ? ( + + ) : ( + + Current ( + {truncate(instance.name, 20)} + ): {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/ui/lib/Modal.tsx b/app/ui/lib/Modal.tsx index d6877b7d7..ab00a1a15 100644 --- a/app/ui/lib/Modal.tsx +++ b/app/ui/lib/Modal.tsx @@ -70,8 +70,8 @@ export function Modal({ {children} diff --git a/test/e2e/instance.e2e.ts b/test/e2e/instance.e2e.ts index e011157bb..338e2392f 100644 --- a/test/e2e/instance.e2e.ts +++ b/test/e2e/instance.e2e.ts @@ -139,6 +139,72 @@ test('cannot reboot a starting instance, or a stopped instance', async ({ page } await expect(page.getByRole('menuitem', { name: 'Reboot' })).toBeDisabled() }) +test('cannot resize a running or starting instance', async ({ page }) => { + await page.goto('/projects/mock-project/instances') + + await expectInstanceState(page, 'db1', 'running') + await openRowActions(page, 'db1') + await expect(page.getByRole('menuitem', { name: 'Resize' })).toBeDisabled() + + await page.keyboard.press('Escape') // get out of the menu + + await expectInstanceState(page, 'not-there-yet', 'starting') + await openRowActions(page, 'not-there-yet') + await expect(page.getByRole('menuitem', { name: 'Resize' })).toBeDisabled() +}) + +test('can resize a failed or stopped instance', async ({ page }) => { + await page.goto('/projects/mock-project/instances') + const table = page.getByRole('table') + + // resize 'you-fail', currently in a failed state + await expectRowVisible(table, { + name: 'you-fail', + CPU: '4 vCPU', + Memory: '6 GiB', + state: expect.stringMatching(/^failed\d+s$/), + }) + await clickRowAction(page, 'you-fail', 'Resize') + const resizeModal = page.getByRole('dialog', { name: 'Resize instance' }) + await expect(resizeModal).toBeVisible() + await resizeModal.getByRole('textbox', { name: 'vCPUs' }).fill('10') + await resizeModal.getByRole('textbox', { name: 'Memory' }).fill('20') + await resizeModal.getByRole('button', { name: 'Resize' }).click() + await expectRowVisible(table, { + name: 'you-fail', + CPU: '10 vCPU', + Memory: '20 GiB', + state: expect.stringMatching(/^failed\d+s$/), + }) + + // resize 'db1', which needs to be stopped first + await expectRowVisible(table, { + name: 'db1', + CPU: '2 vCPU', + Memory: '4 GiB', + state: expect.stringMatching(/^running\d+s$/), + }) + + await clickRowAction(page, 'db1', 'Stop') + await page.getByRole('button', { name: 'Confirm' }).click() + await expectInstanceState(page, 'db1', 'stopping') + await expectInstanceState(page, 'db1', 'stopped') + + await clickRowAction(page, 'db1', 'Resize') + await expect(resizeModal).toBeVisible() + await expect(resizeModal.getByText('Current (db1): 2 vCPUs / 4 GiB')).toBeVisible() + + await resizeModal.getByRole('textbox', { name: 'vCPUs' }).fill('8') + await resizeModal.getByRole('textbox', { name: 'Memory' }).fill('16') + await resizeModal.getByRole('button', { name: 'Resize' }).click() + await expectRowVisible(table, { + name: 'db1', + CPU: '8 vCPU', + Memory: '16 GiB', + state: expect.stringMatching(/^stopped\d+s$/), + }) +}) + test('delete from instance detail', async ({ page }) => { await page.goto('/projects/mock-project/instances/you-fail')