Skip to content

Commit

Permalink
Enable instance resizing (#2487)
Browse files Browse the repository at this point in the history
Co-authored-by: Benjamin Leonard <[email protected]>
Co-authored-by: Charlie Park <[email protected]>
Co-authored-by: Benjamin Leonard <[email protected]>
  • Loading branch information
4 people authored Nov 27, 2024
1 parent 25106d3 commit 059c551
Show file tree
Hide file tree
Showing 5 changed files with 257 additions and 18 deletions.
18 changes: 16 additions & 2 deletions app/pages/project/instances/InstancesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 = () => (
<EmptyMessage
Expand Down Expand Up @@ -77,9 +78,15 @@ const POLL_INTERVAL_SLOW = 60 * sec

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

const { makeButtonActions, makeMenuActions } = useMakeInstanceActions(
{ project },
{ onSuccess: refetchInstances, onDelete: refetchInstances }
{
onSuccess: refetchInstances,
onDelete: refetchInstances,
onResizeClick: setResizeInstance,
}
)

const columns = useMemo(
Expand Down Expand Up @@ -221,6 +228,13 @@ export function InstancesPage() {
<CreateLink to={pb.instancesNew({ project })}>New Instance</CreateLink>
</TableActions>
{table}
{resizeInstance && (
<ResizeInstanceModal
instance={resizeInstance}
onDismiss={() => setResizeInstance(null)}
onListView
/>
)}
</>
)
}
21 changes: 12 additions & 9 deletions app/pages/project/instances/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,13 @@
* Copyright Oxide Computer Company
*/
import { useCallback } from 'react'
import { useNavigate } from 'react-router-dom'

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

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'

Expand All @@ -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().
//
Expand All @@ -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 } }
Expand Down Expand Up @@ -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 [
{
Expand All @@ -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',
Expand All @@ -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 }
Expand Down
164 changes: 160 additions & 4 deletions app/pages/project/instances/instance/InstancePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -91,6 +109,7 @@ const POLL_INTERVAL = 1000

export function InstancePage() {
const instanceSelector = useInstanceSelector()
const [resizeInstance, setResizeInstance] = useState(false)

const navigate = useNavigate()

Expand All @@ -101,6 +120,7 @@ export function InstancePage() {
apiQueryClient.invalidateQueries('instanceList')
navigate(pb.instances(instanceSelector))
},
onResizeClick: () => setResizeInstance(true),
})

const { data: instance } = usePrefetchedApiQuery(
Expand Down Expand Up @@ -233,6 +253,142 @@ export function InstancePage() {
<Tab to={pb.instanceNetworking(instanceSelector)}>Networking</Tab>
<Tab to={pb.instanceConnect(instanceSelector)}>Connect</Tab>
</RouteTabs>
{resizeInstance && (
<ResizeInstanceModal
instance={instance}
onDismiss={() => 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 <HL>{instance.name}</HL> 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 (
<Modal title="Resize instance" isOpen onDismiss={onDismiss}>
<Modal.Body>
<Modal.Section>
{!canResize ? (
<Message variant="error" content="An instance must be stopped to be resized" />
) : (
<Message
variant="info"
content={
<div>
Current (
<span className="text-sans-semi-md">{truncate(instance.name, 20)}</span>
): {instance.ncpus} vCPUs / {instance.memory / GiB} GiB
</div>
}
/>
)}
<form autoComplete="off" className="space-y-4">
<NumberField
required
label="vCPUs"
name="ncpus"
min={1}
control={form.control}
validate={(cpus) => {
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}
/>
<NumberField
units="GiB"
required
label="Memory"
name="memory"
min={1}
control={form.control}
validate={(memory) => {
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}
/>
</form>
{instanceUpdate.error && (
<p className="mt-4 text-error">{instanceUpdate.error.message}</p>
)}
</Modal.Section>
</Modal.Body>
<Modal.Footer
onDismiss={onDismiss}
onAction={onAction}
actionText="Resize"
actionLoading={instanceUpdate.isPending}
disabled={isDisabled}
/>
</Modal>
)
}
6 changes: 3 additions & 3 deletions app/ui/lib/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ export function Modal({

<AnimatedDialogContent
className={cn(
'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',
narrow ? 'max-w-[24rem]' : 'max-w-[32rem]'
'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',
narrow ? 'max-w-[24rem]' : 'max-w-[28rem]'
)}
aria-labelledby={titleId}
style={{
Expand All @@ -89,7 +89,7 @@ export function Modal({
</Dialog.Title>
{children}
<Dialog.Close
className="absolute right-2 top-3 flex rounded p-2 hover:bg-hover"
className="absolute right-2 top-4 flex items-center justify-center rounded p-2 hover:bg-hover"
aria-label="Close"
>
<Close12Icon className="text-secondary" />
Expand Down
Loading

0 comments on commit 059c551

Please sign in to comment.