Skip to content

Commit c258574

Browse files
Instance resize modal (#2495)
* Switch to regular modal * Improve modal close button alignment * Semi 'currently using:' and fix semi weight * Revert: fix semi weight Handled in #2496 instead * Switch route for modal * Disable submit if the specs are the same * Improve toast * Fix lint error `no-unused-expressions`
1 parent c71671f commit c258574

File tree

8 files changed

+176
-102
lines changed

8 files changed

+176
-102
lines changed

app/forms/instance-resize.tsx

Lines changed: 0 additions & 75 deletions
This file was deleted.

app/pages/project/instances/InstancesPage.tsx

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

1313
import { apiQueryClient, usePrefetchedApiQuery, type Instance } from '@oxide/api'
@@ -33,6 +33,7 @@ import { toLocaleTimeString } from '~/util/date'
3333
import { pb } from '~/util/path-builder'
3434

3535
import { useMakeInstanceActions } from './actions'
36+
import { ResizeInstanceModal } from './instance/InstancePage'
3637

3738
const EmptyState = () => (
3839
<EmptyMessage
@@ -64,10 +65,15 @@ const POLL_INTERVAL_SLOW = 60 * sec
6465

6566
export function InstancesPage() {
6667
const { project } = useProjectSelector()
68+
const [resizeInstance, setResizeInstance] = useState<Instance | null>(null)
6769

6870
const makeActions = useMakeInstanceActions(
6971
{ project },
70-
{ onSuccess: refetchInstances, onDelete: refetchInstances }
72+
{
73+
onSuccess: refetchInstances,
74+
onDelete: refetchInstances,
75+
onResizeClick: (instance) => setResizeInstance(instance),
76+
}
7177
)
7278

7379
// this is a whole thing. sit down.
@@ -212,6 +218,14 @@ export function InstancesPage() {
212218
<CreateLink to={pb.instancesNew({ project })}>New Instance</CreateLink>
213219
</TableActions>
214220
<Table columns={columns} emptyState={<EmptyState />} />
221+
{resizeInstance && (
222+
<ResizeInstanceModal
223+
instance={resizeInstance}
224+
project={project}
225+
onDismiss={() => setResizeInstance(null)}
226+
onListView
227+
/>
228+
)}
215229
</>
216230
)
217231
}

app/pages/project/instances/actions.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
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

@@ -15,7 +14,6 @@ import { confirmAction } from '~/stores/confirm-action'
1514
import { confirmDelete } from '~/stores/confirm-delete'
1615
import { addToast } from '~/stores/toast'
1716
import type { MakeActions } from '~/table/columns/action-col'
18-
import { pb } from '~/util/path-builder'
1917

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

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

3130
export const useMakeInstanceActions = (
3231
{ project }: { project: string },
3332
options: Options = {}
3433
): MakeActions<Instance> => {
35-
const navigate = useNavigate()
36-
3734
// if you also pass onSuccess to mutate(), this one is not overridden — this
3835
// one runs first, then the one passed to mutate().
3936
//
@@ -51,7 +48,6 @@ export const useMakeInstanceActions = (
5148

5249
return useCallback(
5350
(instance) => {
54-
const instanceSelector = { project, instance: instance.name }
5551
const instanceParams = { path: { instance: instance.name }, query: { project } }
5652
return [
5753
{
@@ -119,8 +115,10 @@ export const useMakeInstanceActions = (
119115
},
120116
{
121117
label: 'Resize',
122-
onActivate() {
123-
navigate(pb.instanceResize(instanceSelector))
118+
onActivate: () => {
119+
if (options.onResizeClick) {
120+
options.onResizeClick(instance)
121+
}
124122
},
125123
disabled: !instanceCan.update(instance) && (
126124
<>Only {fancifyStates(instanceCan.update.states)} instances can be resized</>
@@ -147,11 +145,11 @@ export const useMakeInstanceActions = (
147145
},
148146
[
149147
project,
150-
navigate,
151148
deleteInstanceAsync,
152149
rebootInstance,
153150
startInstance,
154151
stopInstanceAsync,
152+
options,
155153
]
156154
)
157155
}

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

Lines changed: 153 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,46 @@
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'
2231
import { InstanceDocsPopover } from '~/components/InstanceDocsPopover'
2332
import { MoreActionsMenu } from '~/components/MoreActionsMenu'
2433
import { RefreshButton } from '~/components/RefreshButton'
2534
import { RouteTabs, Tab } from '~/components/RouteTabs'
2635
import { InstanceStateBadge } from '~/components/StateBadge'
2736
import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params'
37+
import { addToast } from '~/stores/toast'
2838
import { EmptyCell } from '~/table/cells/EmptyCell'
2939
import { DateTime } from '~/ui/lib/DateTime'
40+
import { Message } from '~/ui/lib/Message'
41+
import { Modal } from '~/ui/lib/Modal'
3042
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
3143
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
3244
import { Spinner } from '~/ui/lib/Spinner'
3345
import { Tooltip } from '~/ui/lib/Tooltip'
3446
import { Truncate } from '~/ui/lib/Truncate'
3547
import { pb } from '~/util/path-builder'
48+
import { GiB } from '~/util/units'
3649

3750
import { useMakeInstanceActions } from '../actions'
3851

@@ -90,6 +103,7 @@ const POLL_INTERVAL = 1000
90103

91104
export function InstancePage() {
92105
const instanceSelector = useInstanceSelector()
106+
const [resizeInstance, setResizeInstance] = useState(false)
93107

94108
const navigate = useNavigate()
95109
const makeActions = useMakeInstanceActions(instanceSelector, {
@@ -99,6 +113,7 @@ export function InstancePage() {
99113
apiQueryClient.invalidateQueries('instanceList')
100114
navigate(pb.instances(instanceSelector))
101115
},
116+
onResizeClick: () => setResizeInstance(true),
102117
})
103118

104119
const { data: instance } = usePrefetchedApiQuery(
@@ -217,6 +232,142 @@ export function InstancePage() {
217232
<Tab to={pb.instanceNetworking(instanceSelector)}>Networking</Tab>
218233
<Tab to={pb.instanceConnect(instanceSelector)}>Connect</Tab>
219234
</RouteTabs>
235+
{resizeInstance && (
236+
<ResizeInstanceModal
237+
instance={instance}
238+
project={instanceSelector.project}
239+
onDismiss={() => setResizeInstance(false)}
240+
/>
241+
)}
220242
</>
221243
)
222244
}
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

Comments
 (0)