Skip to content
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
75 changes: 0 additions & 75 deletions app/forms/instance-resize.tsx

This file was deleted.

18 changes: 16 additions & 2 deletions app/pages/project/instances/InstancesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/
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 { apiQueryClient, usePrefetchedApiQuery, type Instance } from '@oxide/api'
Expand All @@ -33,6 +33,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 @@ -64,10 +65,15 @@ const POLL_INTERVAL_SLOW = 60 * sec

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

const makeActions = useMakeInstanceActions(
{ project },
{ onSuccess: refetchInstances, onDelete: refetchInstances }
{
onSuccess: refetchInstances,
onDelete: refetchInstances,
onResizeClick: (instance) => setResizeInstance(instance),
}
)

// this is a whole thing. sit down.
Expand Down Expand Up @@ -212,6 +218,14 @@ export function InstancesPage() {
<CreateLink to={pb.instancesNew({ project })}>New Instance</CreateLink>
</TableActions>
<Table columns={columns} emptyState={<EmptyState />} />
{resizeInstance && (
<ResizeInstanceModal
instance={resizeInstance}
project={project}
onDismiss={() => setResizeInstance(null)}
onListView
/>
)}
</>
)
}
14 changes: 6 additions & 8 deletions app/pages/project/instances/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
* Copyright Oxide Computer Company
*/
import { useCallback } from 'react'
import { useNavigate } from 'react-router-dom'

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

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

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

Expand All @@ -26,14 +24,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 = {}
): MakeActions<Instance> => {
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 @@ -51,7 +48,6 @@ export const useMakeInstanceActions = (

return useCallback(
(instance) => {
const instanceSelector = { project, instance: instance.name }
const instanceParams = { path: { instance: instance.name }, query: { project } }
return [
{
Expand Down Expand Up @@ -119,8 +115,10 @@ export const useMakeInstanceActions = (
},
{
label: 'Resize',
onActivate() {
navigate(pb.instanceResize(instanceSelector))
onActivate: () => {
if (options.onResizeClick) {
options.onResizeClick(instance)
}
},
disabled: !instanceCan.update(instance) && (
<>Only {fancifyStates(instanceCan.update.states)} instances can be resized</>
Expand All @@ -147,11 +145,11 @@ export const useMakeInstanceActions = (
},
[
project,
navigate,
deleteInstanceAsync,
rebootInstance,
startInstance,
stopInstanceAsync,
options,
]
)
}
155 changes: 153 additions & 2 deletions app/pages/project/instances/instance/InstancePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,46 @@
* 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 { 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 { addToast } from '~/stores/toast'
import { EmptyCell } from '~/table/cells/EmptyCell'
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 { pb } from '~/util/path-builder'
import { GiB } from '~/util/units'

import { useMakeInstanceActions } from '../actions'

Expand Down Expand Up @@ -90,6 +103,7 @@ const POLL_INTERVAL = 1000

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

const navigate = useNavigate()
const makeActions = useMakeInstanceActions(instanceSelector, {
Expand All @@ -99,6 +113,7 @@ export function InstancePage() {
apiQueryClient.invalidateQueries('instanceList')
navigate(pb.instances(instanceSelector))
},
onResizeClick: () => setResizeInstance(true),
})

const { data: instance } = usePrefetchedApiQuery(
Expand Down Expand Up @@ -217,6 +232,142 @@ export function InstancePage() {
<Tab to={pb.instanceNetworking(instanceSelector)}>Networking</Tab>
<Tab to={pb.instanceConnect(instanceSelector)}>Connect</Tab>
</RouteTabs>
{resizeInstance && (
<ResizeInstanceModal
instance={instance}
project={instanceSelector.project}
onDismiss={() => setResizeInstance(false)}
/>
)}
</>
)
}

export function ResizeInstanceModal({
instance,
project,
onDismiss,
onListView = false,
}: {
instance: Instance
project: string
onDismiss: () => void
onListView?: boolean
}) {
const instanceUpdate = useApiMutation('instanceUpdate', {
onSuccess(_updatedInstance) {
if (onListView) {
apiQueryClient.invalidateQueries('instanceList')
} else {
apiQueryClient.invalidateQueries('instanceView')
}
onDismiss()
addToast({
content: `${instance.name} has been 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"
title={`Currently (${instance.name}):`}
content={
<>
<div>
<span className="text-sans-semi-md text-info"></span> {instance.ncpus}{' '}
vCPUs / {instance.memory / GiB} GiB
</div>
</>
}
/>
)}
<form autoComplete="off" className="space-y-4">
<NumberField
required
label="CPUs"
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>
)
}
Loading
Loading