66 * Copyright Oxide Computer Company
77 */
88import { filesize } from 'filesize'
9- import { useMemo } from 'react'
9+ import { useMemo , useState } from 'react'
10+ import { useForm } from 'react-hook-form'
1011import { Link , useNavigate , type LoaderFunctionArgs } from 'react-router-dom'
1112
1213import {
1314 apiQueryClient ,
15+ useApiMutation ,
1416 useApiQuery ,
1517 usePrefetchedApiQuery ,
18+ type Instance ,
1619 type InstanceNetworkInterface ,
1720} from '@oxide/api'
1821import { Instances24Icon } from '@oxide/design-system/icons/react'
1922
20- import { instanceTransitioning } from '~/api/util'
23+ import {
24+ INSTANCE_MAX_CPU ,
25+ INSTANCE_MAX_RAM_GiB ,
26+ instanceCan ,
27+ instanceTransitioning ,
28+ } from '~/api/util'
2129import { ExternalIps } from '~/components/ExternalIps'
30+ import { NumberField } from '~/components/form/fields/NumberField'
31+ import { HL } from '~/components/HL'
2232import { InstanceDocsPopover } from '~/components/InstanceDocsPopover'
2333import { MoreActionsMenu } from '~/components/MoreActionsMenu'
2434import { RefreshButton } from '~/components/RefreshButton'
2535import { RouteTabs , Tab } from '~/components/RouteTabs'
2636import { InstanceStateBadge } from '~/components/StateBadge'
27- import { getInstanceSelector , useInstanceSelector } from '~/hooks/use-params'
37+ import {
38+ getInstanceSelector ,
39+ useInstanceSelector ,
40+ useProjectSelector ,
41+ } from '~/hooks/use-params'
42+ import { addToast } from '~/stores/toast'
2843import { EmptyCell } from '~/table/cells/EmptyCell'
2944import { Button } from '~/ui/lib/Button'
3045import { DateTime } from '~/ui/lib/DateTime'
46+ import { Message } from '~/ui/lib/Message'
47+ import { Modal } from '~/ui/lib/Modal'
3148import { PageHeader , PageTitle } from '~/ui/lib/PageHeader'
3249import { PropertiesTable } from '~/ui/lib/PropertiesTable'
3350import { Spinner } from '~/ui/lib/Spinner'
3451import { Tooltip } from '~/ui/lib/Tooltip'
35- import { Truncate } from '~/ui/lib/Truncate'
52+ import { truncate , Truncate } from '~/ui/lib/Truncate'
3653import { pb } from '~/util/path-builder'
54+ import { GiB } from '~/util/units'
3755
3856import { useMakeInstanceActions } from '../actions'
3957
@@ -91,6 +109,7 @@ const POLL_INTERVAL = 1000
91109
92110export function InstancePage ( ) {
93111 const instanceSelector = useInstanceSelector ( )
112+ const [ resizeInstance , setResizeInstance ] = useState ( false )
94113
95114 const navigate = useNavigate ( )
96115
@@ -101,6 +120,7 @@ export function InstancePage() {
101120 apiQueryClient . invalidateQueries ( 'instanceList' )
102121 navigate ( pb . instances ( instanceSelector ) )
103122 } ,
123+ onResizeClick : ( ) => setResizeInstance ( true ) ,
104124 } )
105125
106126 const { data : instance } = usePrefetchedApiQuery (
@@ -233,6 +253,142 @@ export function InstancePage() {
233253 < Tab to = { pb . instanceNetworking ( instanceSelector ) } > Networking</ Tab >
234254 < Tab to = { pb . instanceConnect ( instanceSelector ) } > Connect</ Tab >
235255 </ RouteTabs >
256+ { resizeInstance && (
257+ < ResizeInstanceModal
258+ instance = { instance }
259+ onDismiss = { ( ) => setResizeInstance ( false ) }
260+ />
261+ ) }
236262 </ >
237263 )
238264}
265+
266+ export function ResizeInstanceModal ( {
267+ instance,
268+ onDismiss,
269+ onListView = false ,
270+ } : {
271+ instance : Instance
272+ onDismiss : ( ) => void
273+ onListView ?: boolean
274+ } ) {
275+ const { project } = useProjectSelector ( )
276+ const instanceUpdate = useApiMutation ( 'instanceUpdate' , {
277+ onSuccess ( _updatedInstance ) {
278+ if ( onListView ) {
279+ apiQueryClient . invalidateQueries ( 'instanceList' )
280+ } else {
281+ apiQueryClient . invalidateQueries ( 'instanceView' )
282+ }
283+ onDismiss ( )
284+ addToast ( {
285+ content : (
286+ < >
287+ Instance < HL > { instance . name } </ HL > resized
288+ </ >
289+ ) ,
290+ cta : onListView
291+ ? {
292+ text : `View instance` ,
293+ link : pb . instance ( { project, instance : instance . name } ) ,
294+ }
295+ : undefined , // Only link to the instance if we're not already on that page
296+ } )
297+ } ,
298+ onError : ( err ) => {
299+ addToast ( { title : 'Error' , content : err . message , variant : 'error' } )
300+ } ,
301+ onSettled : onDismiss ,
302+ } )
303+
304+ const form = useForm ( {
305+ defaultValues : {
306+ ncpus : instance . ncpus ,
307+ memory : instance . memory / GiB , // memory is stored as bytes
308+ } ,
309+ mode : 'onChange' ,
310+ } )
311+
312+ const canResize = instanceCan . update ( instance )
313+ const willChange =
314+ form . watch ( 'ncpus' ) !== instance . ncpus || form . watch ( 'memory' ) !== instance . memory / GiB
315+ const isDisabled = ! form . formState . isValid || ! canResize || ! willChange
316+
317+ const onAction = form . handleSubmit ( ( { ncpus, memory } ) => {
318+ instanceUpdate . mutate ( {
319+ path : { instance : instance . name } ,
320+ query : { project } ,
321+ body : { ncpus, memory : memory * GiB , bootDisk : instance . bootDiskId } ,
322+ } )
323+ } )
324+
325+ return (
326+ < Modal title = "Resize instance" isOpen onDismiss = { onDismiss } >
327+ < Modal . Body >
328+ < Modal . Section >
329+ { ! canResize ? (
330+ < Message variant = "error" content = "An instance must be stopped to be resized" />
331+ ) : (
332+ < Message
333+ variant = "info"
334+ content = {
335+ < div >
336+ Current (
337+ < span className = "text-sans-semi-md" > { truncate ( instance . name , 20 ) } </ span >
338+ ): { instance . ncpus } vCPUs / { instance . memory / GiB } GiB
339+ </ div >
340+ }
341+ />
342+ ) }
343+ < form autoComplete = "off" className = "space-y-4" >
344+ < NumberField
345+ required
346+ label = "vCPUs"
347+ name = "ncpus"
348+ min = { 1 }
349+ control = { form . control }
350+ validate = { ( cpus ) => {
351+ if ( cpus < 1 ) {
352+ return `Must be at least 1 vCPU`
353+ }
354+ if ( cpus > INSTANCE_MAX_CPU ) {
355+ return `CPUs capped to ${ INSTANCE_MAX_CPU } `
356+ }
357+ // We can show this error and therefore inform the user
358+ // of the limit rather than preventing it completely
359+ } }
360+ disabled = { ! canResize }
361+ />
362+ < NumberField
363+ units = "GiB"
364+ required
365+ label = "Memory"
366+ name = "memory"
367+ min = { 1 }
368+ control = { form . control }
369+ validate = { ( memory ) => {
370+ if ( memory < 1 ) {
371+ return `Must be at least 1 GiB`
372+ }
373+ if ( memory > INSTANCE_MAX_RAM_GiB ) {
374+ return `Can be at most ${ INSTANCE_MAX_RAM_GiB } GiB`
375+ }
376+ } }
377+ disabled = { ! canResize }
378+ />
379+ </ form >
380+ { instanceUpdate . error && (
381+ < p className = "mt-4 text-error" > { instanceUpdate . error . message } </ p >
382+ ) }
383+ </ Modal . Section >
384+ </ Modal . Body >
385+ < Modal . Footer
386+ onDismiss = { onDismiss }
387+ onAction = { onAction }
388+ actionText = "Resize"
389+ actionLoading = { instanceUpdate . isPending }
390+ disabled = { isDisabled }
391+ />
392+ </ Modal >
393+ )
394+ }
0 commit comments