6
6
* Copyright Oxide Computer Company
7
7
*/
8
8
import { filesize } from 'filesize'
9
- import { useMemo } from 'react'
9
+ import { useMemo , useState } from 'react'
10
+ import { useForm } from 'react-hook-form'
10
11
import { Link , useNavigate , type LoaderFunctionArgs } from 'react-router-dom'
11
12
12
13
import {
13
14
apiQueryClient ,
15
+ useApiMutation ,
14
16
useApiQuery ,
15
17
usePrefetchedApiQuery ,
18
+ type Instance ,
16
19
type InstanceNetworkInterface ,
17
20
} from '@oxide/api'
18
21
import { Instances24Icon } from '@oxide/design-system/icons/react'
19
22
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'
21
29
import { ExternalIps } from '~/components/ExternalIps'
30
+ import { NumberField } from '~/components/form/fields/NumberField'
22
31
import { InstanceDocsPopover } from '~/components/InstanceDocsPopover'
23
32
import { MoreActionsMenu } from '~/components/MoreActionsMenu'
24
33
import { RefreshButton } from '~/components/RefreshButton'
25
34
import { RouteTabs , Tab } from '~/components/RouteTabs'
26
35
import { InstanceStateBadge } from '~/components/StateBadge'
27
36
import { getInstanceSelector , useInstanceSelector } from '~/hooks/use-params'
37
+ import { addToast } from '~/stores/toast'
28
38
import { EmptyCell } from '~/table/cells/EmptyCell'
29
39
import { DateTime } from '~/ui/lib/DateTime'
40
+ import { Message } from '~/ui/lib/Message'
41
+ import { Modal } from '~/ui/lib/Modal'
30
42
import { PageHeader , PageTitle } from '~/ui/lib/PageHeader'
31
43
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
32
44
import { Spinner } from '~/ui/lib/Spinner'
33
45
import { Tooltip } from '~/ui/lib/Tooltip'
34
46
import { Truncate } from '~/ui/lib/Truncate'
35
47
import { pb } from '~/util/path-builder'
48
+ import { GiB } from '~/util/units'
36
49
37
50
import { useMakeInstanceActions } from '../actions'
38
51
@@ -90,6 +103,7 @@ const POLL_INTERVAL = 1000
90
103
91
104
export function InstancePage ( ) {
92
105
const instanceSelector = useInstanceSelector ( )
106
+ const [ resizeInstance , setResizeInstance ] = useState ( false )
93
107
94
108
const navigate = useNavigate ( )
95
109
const makeActions = useMakeInstanceActions ( instanceSelector , {
@@ -99,6 +113,7 @@ export function InstancePage() {
99
113
apiQueryClient . invalidateQueries ( 'instanceList' )
100
114
navigate ( pb . instances ( instanceSelector ) )
101
115
} ,
116
+ onResizeClick : ( ) => setResizeInstance ( true ) ,
102
117
} )
103
118
104
119
const { data : instance } = usePrefetchedApiQuery (
@@ -217,6 +232,142 @@ export function InstancePage() {
217
232
< Tab to = { pb . instanceNetworking ( instanceSelector ) } > Networking</ Tab >
218
233
< Tab to = { pb . instanceConnect ( instanceSelector ) } > Connect</ Tab >
219
234
</ RouteTabs >
235
+ { resizeInstance && (
236
+ < ResizeInstanceModal
237
+ instance = { instance }
238
+ project = { instanceSelector . project }
239
+ onDismiss = { ( ) => setResizeInstance ( false ) }
240
+ />
241
+ ) }
220
242
</ >
221
243
)
222
244
}
245
+
246
+ export function ResizeInstanceModal ( {
247
+ instance,
248
+ project,
249
+ onDismiss,
250
+ onListView = false ,
251
+ } : {
252
+ instance : Instance
253
+ project : string
254
+ onDismiss : ( ) => void
255
+ onListView ?: boolean
256
+ } ) {
257
+ const instanceUpdate = useApiMutation ( 'instanceUpdate' , {
258
+ onSuccess ( _updatedInstance ) {
259
+ if ( onListView ) {
260
+ apiQueryClient . invalidateQueries ( 'instanceList' )
261
+ } else {
262
+ apiQueryClient . invalidateQueries ( 'instanceView' )
263
+ }
264
+ onDismiss ( )
265
+ addToast ( {
266
+ content : `${ instance . name } has been resized` ,
267
+ cta : onListView
268
+ ? {
269
+ text : `View instance` ,
270
+ link : pb . instance ( { project, instance : instance . name } ) ,
271
+ }
272
+ : undefined , // Only link to the instance if we're not already on that page
273
+ } )
274
+ } ,
275
+ onError : ( err ) => {
276
+ addToast ( { title : 'Error' , content : err . message , variant : 'error' } )
277
+ } ,
278
+ onSettled : onDismiss ,
279
+ } )
280
+
281
+ const form = useForm ( {
282
+ defaultValues : {
283
+ ncpus : instance . ncpus ,
284
+ memory : instance . memory / GiB , // memory is stored as bytes
285
+ } ,
286
+ mode : 'onChange' ,
287
+ } )
288
+
289
+ const canResize = instanceCan . update ( instance )
290
+ const willChange =
291
+ form . watch ( 'ncpus' ) !== instance . ncpus || form . watch ( 'memory' ) !== instance . memory / GiB
292
+ const isDisabled = ! form . formState . isValid || ! canResize || ! willChange
293
+
294
+ const onAction = form . handleSubmit ( ( { ncpus, memory } ) => {
295
+ instanceUpdate . mutate ( {
296
+ path : { instance : instance . name } ,
297
+ query : { project } ,
298
+ body : { ncpus, memory : memory * GiB , bootDisk : instance . bootDiskId } ,
299
+ } )
300
+ } )
301
+
302
+ return (
303
+ < Modal title = "Resize instance" isOpen onDismiss = { onDismiss } >
304
+ < Modal . Body >
305
+ < Modal . Section >
306
+ { ! canResize ? (
307
+ < Message variant = "error" content = "An instance must be stopped to be resized" />
308
+ ) : (
309
+ < Message
310
+ variant = "info"
311
+ title = { `Currently (${ instance . name } ):` }
312
+ content = {
313
+ < >
314
+ < div >
315
+ < span className = "text-sans-semi-md text-info" > </ span > { instance . ncpus } { ' ' }
316
+ vCPUs / { instance . memory / GiB } GiB
317
+ </ div >
318
+ </ >
319
+ }
320
+ />
321
+ ) }
322
+ < form autoComplete = "off" className = "space-y-4" >
323
+ < NumberField
324
+ required
325
+ label = "CPUs"
326
+ name = "ncpus"
327
+ min = { 1 }
328
+ control = { form . control }
329
+ validate = { ( cpus ) => {
330
+ if ( cpus < 1 ) {
331
+ return `Must be at least 1 vCPU`
332
+ }
333
+ if ( cpus > INSTANCE_MAX_CPU ) {
334
+ return `CPUs capped to ${ INSTANCE_MAX_CPU } `
335
+ }
336
+ // We can show this error and therefore inform the user
337
+ // of the limit rather than preventing it completely
338
+ } }
339
+ disabled = { ! canResize }
340
+ />
341
+ < NumberField
342
+ units = "GiB"
343
+ required
344
+ label = "Memory"
345
+ name = "memory"
346
+ min = { 1 }
347
+ control = { form . control }
348
+ validate = { ( memory ) => {
349
+ if ( memory < 1 ) {
350
+ return `Must be at least 1 GiB`
351
+ }
352
+ if ( memory > INSTANCE_MAX_RAM_GiB ) {
353
+ return `Can be at most ${ INSTANCE_MAX_RAM_GiB } GiB`
354
+ }
355
+ } }
356
+ disabled = { ! canResize }
357
+ />
358
+ </ form >
359
+ { instanceUpdate . error && (
360
+ < p className = "mt-4 text-error" > { instanceUpdate . error . message } </ p >
361
+ ) }
362
+ </ Modal . Section >
363
+ </ Modal . Body >
364
+ < Modal . Footer
365
+ onDismiss = { onDismiss }
366
+ onAction = { onAction }
367
+ actionText = "Resize"
368
+ actionLoading = { instanceUpdate . isPending }
369
+ disabled = { isDisabled }
370
+ />
371
+ </ Modal >
372
+ )
373
+ }
0 commit comments