Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Instance resize #2487

Merged
merged 18 commits into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading