Skip to content

Commit 059c551

Browse files
david-crespobenjaminleonardcharliepark
authored
Enable instance resizing (#2487)
Co-authored-by: Benjamin Leonard <benji@oxide.computer> Co-authored-by: Charlie Park <charlie@oxidecomputer.com> Co-authored-by: Benjamin Leonard <hello@benleonard.co.uk>
1 parent 25106d3 commit 059c551

File tree

5 files changed

+257
-18
lines changed

5 files changed

+257
-18
lines changed

app/pages/project/instances/InstancesPage.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import { type UseQueryOptions } from '@tanstack/react-query'
99
import { createColumnHelper } from '@tanstack/react-table'
1010
import { filesize } from 'filesize'
11-
import { useMemo, useRef } from 'react'
11+
import { useMemo, useRef, useState } from 'react'
1212
import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom'
1313

1414
import {
@@ -41,6 +41,7 @@ import { toLocaleTimeString } from '~/util/date'
4141
import { pb } from '~/util/path-builder'
4242

4343
import { useMakeInstanceActions } from './actions'
44+
import { ResizeInstanceModal } from './instance/InstancePage'
4445

4546
const EmptyState = () => (
4647
<EmptyMessage
@@ -77,9 +78,15 @@ const POLL_INTERVAL_SLOW = 60 * sec
7778

7879
export function InstancesPage() {
7980
const { project } = useProjectSelector()
81+
const [resizeInstance, setResizeInstance] = useState<Instance | null>(null)
82+
8083
const { makeButtonActions, makeMenuActions } = useMakeInstanceActions(
8184
{ project },
82-
{ onSuccess: refetchInstances, onDelete: refetchInstances }
85+
{
86+
onSuccess: refetchInstances,
87+
onDelete: refetchInstances,
88+
onResizeClick: setResizeInstance,
89+
}
8390
)
8491

8592
const columns = useMemo(
@@ -221,6 +228,13 @@ export function InstancesPage() {
221228
<CreateLink to={pb.instancesNew({ project })}>New Instance</CreateLink>
222229
</TableActions>
223230
{table}
231+
{resizeInstance && (
232+
<ResizeInstanceModal
233+
instance={resizeInstance}
234+
onDismiss={() => setResizeInstance(null)}
235+
onListView
236+
/>
237+
)}
224238
</>
225239
)
226240
}

app/pages/project/instances/actions.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,13 @@
66
* Copyright Oxide Computer Company
77
*/
88
import { useCallback } from 'react'
9-
import { useNavigate } from 'react-router-dom'
109

1110
import { instanceCan, useApiMutation, type Instance } from '@oxide/api'
1211

1312
import { HL } from '~/components/HL'
1413
import { confirmAction } from '~/stores/confirm-action'
1514
import { confirmDelete } from '~/stores/confirm-delete'
1615
import { addToast } from '~/stores/toast'
17-
import { pb } from '~/util/path-builder'
1816

1917
import { fancifyStates } from './instance/tabs/common'
2018

@@ -25,13 +23,13 @@ type Options = {
2523
// hook has to expand to encompass the sum of all the APIs of these hooks it
2624
// call internally, the abstraction is not good
2725
onDelete?: () => void
26+
onResizeClick?: (instance: Instance) => void
2827
}
2928

3029
export const useMakeInstanceActions = (
3130
{ project }: { project: string },
3231
options: Options = {}
3332
) => {
34-
const navigate = useNavigate()
3533
// if you also pass onSuccess to mutate(), this one is not overridden — this
3634
// one runs first, then the one passed to mutate().
3735
//
@@ -47,6 +45,8 @@ export const useMakeInstanceActions = (
4745
onSuccess: options.onDelete,
4846
})
4947

48+
const { onResizeClick } = options
49+
5050
const makeButtonActions = useCallback(
5151
(instance: Instance) => {
5252
const instanceParams = { path: { instance: instance.name }, query: { project } }
@@ -116,7 +116,6 @@ export const useMakeInstanceActions = (
116116

117117
const makeMenuActions = useCallback(
118118
(instance: Instance) => {
119-
const instanceSelector = { project, instance: instance.name }
120119
const instanceParams = { path: { instance: instance.name }, query: { project } }
121120
return [
122121
{
@@ -143,10 +142,11 @@ export const useMakeInstanceActions = (
143142
),
144143
},
145144
{
146-
label: 'View serial console',
147-
onActivate() {
148-
navigate(pb.serialConsole(instanceSelector))
149-
},
145+
label: 'Resize',
146+
onActivate: () => onResizeClick?.(instance),
147+
disabled: !instanceCan.update(instance) && (
148+
<>Only {fancifyStates(instanceCan.update.states)} instances can be resized</>
149+
),
150150
},
151151
{
152152
label: 'Delete',
@@ -167,7 +167,10 @@ export const useMakeInstanceActions = (
167167
},
168168
]
169169
},
170-
[project, deleteInstanceAsync, navigate, rebootInstanceAsync]
170+
// Do not put `options` in here, refer to the property. options is not ref
171+
// stable. Extra renders here cause the row actions menu to close when it
172+
// shouldn't, like during polling on instance list.
173+
[project, deleteInstanceAsync, rebootInstanceAsync, onResizeClick]
171174
)
172175

173176
return { makeButtonActions, makeMenuActions }

app/pages/project/instances/instance/InstancePage.tsx

Lines changed: 160 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,52 @@
66
* Copyright Oxide Computer Company
77
*/
88
import { filesize } from 'filesize'
9-
import { useMemo } from 'react'
9+
import { useMemo, useState } from 'react'
10+
import { useForm } from 'react-hook-form'
1011
import { Link, useNavigate, type LoaderFunctionArgs } from 'react-router-dom'
1112

1213
import {
1314
apiQueryClient,
15+
useApiMutation,
1416
useApiQuery,
1517
usePrefetchedApiQuery,
18+
type Instance,
1619
type InstanceNetworkInterface,
1720
} from '@oxide/api'
1821
import { 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'
2129
import { ExternalIps } from '~/components/ExternalIps'
30+
import { NumberField } from '~/components/form/fields/NumberField'
31+
import { HL } from '~/components/HL'
2232
import { InstanceDocsPopover } from '~/components/InstanceDocsPopover'
2333
import { MoreActionsMenu } from '~/components/MoreActionsMenu'
2434
import { RefreshButton } from '~/components/RefreshButton'
2535
import { RouteTabs, Tab } from '~/components/RouteTabs'
2636
import { 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'
2843
import { EmptyCell } from '~/table/cells/EmptyCell'
2944
import { Button } from '~/ui/lib/Button'
3045
import { DateTime } from '~/ui/lib/DateTime'
46+
import { Message } from '~/ui/lib/Message'
47+
import { Modal } from '~/ui/lib/Modal'
3148
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
3249
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
3350
import { Spinner } from '~/ui/lib/Spinner'
3451
import { Tooltip } from '~/ui/lib/Tooltip'
35-
import { Truncate } from '~/ui/lib/Truncate'
52+
import { truncate, Truncate } from '~/ui/lib/Truncate'
3653
import { pb } from '~/util/path-builder'
54+
import { GiB } from '~/util/units'
3755

3856
import { useMakeInstanceActions } from '../actions'
3957

@@ -91,6 +109,7 @@ const POLL_INTERVAL = 1000
91109

92110
export 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+
}

app/ui/lib/Modal.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ export function Modal({
7070

7171
<AnimatedDialogContent
7272
className={cn(
73-
'pointer-events-auto fixed left-1/2 top-1/2 z-modal m-0 flex max-h-[min(800px,80vh)] w-auto min-w-[24rem] flex-col justify-between rounded-lg border p-0 bg-raise border-secondary elevation-2',
74-
narrow ? 'max-w-[24rem]' : 'max-w-[32rem]'
73+
'pointer-events-auto fixed left-1/2 top-1/2 z-modal m-0 flex max-h-[min(800px,80vh)] w-full flex-col justify-between rounded-lg border p-0 bg-raise border-secondary elevation-2',
74+
narrow ? 'max-w-[24rem]' : 'max-w-[28rem]'
7575
)}
7676
aria-labelledby={titleId}
7777
style={{
@@ -89,7 +89,7 @@ export function Modal({
8989
</Dialog.Title>
9090
{children}
9191
<Dialog.Close
92-
className="absolute right-2 top-3 flex rounded p-2 hover:bg-hover"
92+
className="absolute right-2 top-4 flex items-center justify-center rounded p-2 hover:bg-hover"
9393
aria-label="Close"
9494
>
9595
<Close12Icon className="text-secondary" />

0 commit comments

Comments
 (0)