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 auto restart #2644

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
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
4 changes: 2 additions & 2 deletions app/components/DocsPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ export const DocsPopover = ({ heading, icon, summary, links }: DocsPopoverProps)
<Info16Icon aria-label="Links to docs" className="shrink-0" />
</PopoverButton>
<PopoverPanel
// DocsPopoverPanel needed for enter animation
className="DocsPopoverPanel z-10 w-96 rounded-lg border bg-raise border-secondary elevation-2"
// popover-panel needed for enter animation
className="popover-panel z-10 w-96 rounded-lg border bg-raise border-secondary elevation-2"
benjaminleonard marked this conversation as resolved.
Show resolved Hide resolved
anchor={{ to: 'bottom end', gap: 12 }}
>
<div className="px-4">
Expand Down
150 changes: 150 additions & 0 deletions app/components/InstanceAutoRestartPopover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { CloseButton, Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
import cn from 'classnames'
import { formatDistanceToNow } from 'date-fns'
import { useState, type ReactNode } from 'react'
import { Link } from 'react-router'

import {
AutoRestart12Icon,
NextArrow12Icon,
OpenLink12Icon,
} from '@oxide/design-system/icons/react'

import type { InstanceAutoRestartPolicy } from '~/api'
import { HL } from '~/components/HL'
import { useInstanceSelector } from '~/hooks/use-params'
import { Badge } from '~/ui/lib/Badge'
import { Spinner } from '~/ui/lib/Spinner'
import { useInterval } from '~/ui/lib/use-interval'
import { pb } from '~/util/path-builder'

const helpText = {
enabled: (
<>
The control plane will attempt to automatically restart instance this instance after
entering the <HL>failed</HL> state.
</>
),
disabled: (
<>
The control plane will not attempt to automatically restart it after entering the{' '}
<HL>failed</HL> state.
</>
),
never: (
<>
Instance auto-restart policy is set to never. The control plane will not attempt to
automatically restart it after entering the <HL>failed</HL> state.
</>
),
starting: (
<>
Instance auto-restart policy is queued to start. The control plane will begin the
restart process shortly.
</>
),
}

export const InstanceAutoRestartPopover = ({
enabled,
policy,
cooldownExpiration,
}: {
enabled: boolean
policy?: InstanceAutoRestartPolicy
cooldownExpiration: Date | undefined
}) => {
const instanceSelector = useInstanceSelector()
const [now, setNow] = useState(new Date())

useInterval({ fn: () => setNow(new Date()), delay: 1000 })

const isQueued = cooldownExpiration && new Date(cooldownExpiration) < now

let helpTextState: keyof typeof helpText = 'disabled'
if (isQueued) helpTextState = 'starting' // Expiration is in the past and queued for restart
if (policy === 'never') helpTextState = 'never' // Will never auto-restart
if (enabled) helpTextState = 'enabled' // Restart enabled and cooldown as not expired

return (
<Popover>
<PopoverButton className="group flex h-6 w-6 items-center justify-center rounded border border-default hover:bg-hover">
<AutoRestart12Icon
className={cn('shrink-0 transition-transform', enabled && 'animate-spin-slow')}
/>
</PopoverButton>
<PopoverPanel
// popover-panel needed for enter animation
className="popover-panel z-10 w-96 rounded-lg border bg-raise border-secondary elevation-2"
anchor={{ to: 'bottom start', gap: 12 }}
>
<PopoverRow label="Auto Restart">
{enabled ? <Badge>Enabled</Badge> : <Badge color="neutral">Disabled</Badge>}
</PopoverRow>
<PopoverRow label="Policy">
<CloseButton
as={Link}
to={pb.instanceSettings(instanceSelector)}
className="group -m-1 flex w-full items-center justify-between rounded px-1"
>
{policy ? (
policy === 'never' ? (
<Badge color="neutral" variant="solid">
never
</Badge>
) : (
<Badge>best effort</Badge>
)
) : (
<Badge color="neutral">Default</Badge>
)}
<div className="transition-transform group-hover:translate-x-1">
<NextArrow12Icon />
</div>
</CloseButton>
</PopoverRow>
{cooldownExpiration && (
<PopoverRow label="Cooldown">
{isQueued ? (
<>
<Spinner /> Queued for restart…
</>
) : (
<div>
Waiting{' '}
<span className="text-tertiary">
({formatDistanceToNow(cooldownExpiration)})
</span>
</div>
)}
</PopoverRow>
)}
<div className="p-3 text-sans-md text-default">
<p className="mb-2 pr-4">{helpText[helpTextState]}</p>
<a href="/">
<span className="inline-block max-w-[300px] truncate align-middle">
Learn about <span className="text-raise">Instance Auto-Restart</span>
</span>
<OpenLink12Icon className="ml-1 translate-y-[1px] text-secondary" />
</a>
</div>
</PopoverPanel>
</Popover>
)
}

const PopoverRow = ({ label, children }: { label: string; children: ReactNode }) => (
<div className="flex h-10 items-center border-b border-b-secondary">
<div className="w-32 pl-3 pr-2 text-mono-sm text-tertiary">{label}</div>
<div className="flex h-10 flex-grow items-center gap-1.5 pr-2 text-sans-md">
{children}
</div>
</div>
)
14 changes: 13 additions & 1 deletion app/pages/project/instances/instance/InstancePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import { ExternalIps } from '~/components/ExternalIps'
import { NumberField } from '~/components/form/fields/NumberField'
import { HL } from '~/components/HL'
import { InstanceAutoRestartPopover } from '~/components/InstanceAutoRestartPopover'
import { InstanceDocsPopover } from '~/components/InstanceDocsPopover'
import { MoreActionsMenu } from '~/components/MoreActionsMenu'
import { RefreshButton } from '~/components/RefreshButton'
Expand Down Expand Up @@ -168,6 +169,9 @@ export function InstancePage() {

const memory = filesize(instance.memory, { output: 'object', base: 2 })

// Document when this popover is showing
const hasAutoRestart = !!instance.autoRestartCooldownExpiration

return (
<>
<PageHeader>
Expand Down Expand Up @@ -203,7 +207,7 @@ export function InstancePage() {
<span className="ml-1 text-tertiary"> {memory.unit}</span>
</PropertiesTable.Row>
<PropertiesTable.Row label="state">
<div className="flex">
<div className="flex items-center gap-2">
<InstanceStateBadge state={instance.runState} />
{polling && (
<Tooltip content="Auto-refreshing while state changes" delay={150}>
Expand All @@ -212,6 +216,13 @@ export function InstancePage() {
</button>
</Tooltip>
)}
{hasAutoRestart && (
<InstanceAutoRestartPopover
enabled={instance.autoRestartEnabled}
cooldownExpiration={instance.autoRestartCooldownExpiration}
policy={instance.autoRestartPolicy}
/>
)}
</div>
</PropertiesTable.Row>
<PropertiesTable.Row label="vpc">
Expand Down Expand Up @@ -241,6 +252,7 @@ export function InstancePage() {
<Tab to={pb.instanceMetrics(instanceSelector)}>Metrics</Tab>
<Tab to={pb.instanceNetworking(instanceSelector)}>Networking</Tab>
<Tab to={pb.instanceConnect(instanceSelector)}>Connect</Tab>
<Tab to={pb.instanceSettings(instanceSelector)}>Settings</Tab>
</RouteTabs>
{resizeInstance && (
<ResizeInstanceModal
Expand Down
166 changes: 166 additions & 0 deletions app/pages/project/instances/instance/tabs/SettingsTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/

import { format, formatDistanceToNow } from 'date-fns'
import { useId, useState, type ReactNode } from 'react'
import { useForm } from 'react-hook-form'

import { apiQueryClient, useApiMutation, usePrefetchedApiQuery } from '~/api'
import { ListboxField } from '~/components/form/fields/ListboxField'
import { useInstanceSelector } from '~/hooks/use-params'
import { addToast } from '~/stores/toast'
import { Button } from '~/ui/lib/Button'
import { FieldLabel } from '~/ui/lib/FieldLabel'
import { LearnMore, SettingsGroup } from '~/ui/lib/SettingsGroup'
import { TipIcon } from '~/ui/lib/TipIcon'
import { useInterval } from '~/ui/lib/use-interval'
import { links } from '~/util/links'

Component.displayName = 'SettingsTab'
export function Component() {
const instanceSelector = useInstanceSelector()

const [now, setNow] = useState(new Date())

useInterval({ fn: () => setNow(new Date()), delay: 1000 })

const { data: instance } = usePrefetchedApiQuery('instanceView', {
path: { instance: instanceSelector.instance },
query: { project: instanceSelector.project },
})

const instanceUpdate = useApiMutation('instanceUpdate', {
onSuccess() {
apiQueryClient.invalidateQueries('instanceView')
addToast({ content: 'Instance auto-restart policy updated' })
},
})

const form = useForm({
defaultValues: {
autoRestartPolicy: instance.autoRestartPolicy,
},
})
const { isDirty } = form.formState

const onSubmit = form.handleSubmit((values) => {
instanceUpdate.mutate({
path: { instance: instanceSelector.instance },
query: { project: instanceSelector.project },
body: {
ncpus: instance.ncpus,
memory: instance.memory,
bootDisk: instance.bootDiskId,
benjaminleonard marked this conversation as resolved.
Show resolved Hide resolved
autoRestartPolicy: values.autoRestartPolicy,
},
})
})

return (
<form className="space-y-6" onSubmit={onSubmit}>
<SettingsGroup.Container>
<SettingsGroup.Header>
<SettingsGroup.Title>Auto-restart</SettingsGroup.Title>
<p>The auto-restart policy configured for this instance</p>
</SettingsGroup.Header>
<SettingsGroup.Body>
<ListboxField
control={form.control}
name="autoRestartPolicy"
label="Policy"
description="If unconfigured, this instance uses the default auto-restart policy, which may or may not allow it to be restarted."
placeholder="Default"
items={[
{ value: '', label: 'Default' },
{ value: 'never', label: 'Never' },
{ value: 'best_effort', label: 'Best effort' },
]}
required
className="max-w-none"
/>
<FormMeta
label="Enabled"
helpText="If enabled this instance will automatically restart it if it enters the Failed state."
>
{instance.autoRestartEnabled ? 'True' : 'False'}{' '}
benjaminleonard marked this conversation as resolved.
Show resolved Hide resolved
{instance.autoRestartEnabled && !instance.autoRestartPolicy && (
<span className="text-tertiary">(Project default)</span>
)}
</FormMeta>
<FormMeta
label="Cooldown expiration"
helpText="The time at which the auto-restart cooldown period for this instance completes. If n/a, then either the instance has never been automatically restarted, or the cooldown period has already expired"
>
{instance.autoRestartCooldownExpiration ? (
<>
{format(
new Date(instance.autoRestartCooldownExpiration),
'MMM d, yyyy HH:mm:ss zz'
)}{' '}
{new Date(instance.autoRestartCooldownExpiration) > now && (
<span className="text-tertiary">
({formatDistanceToNow(new Date(instance.autoRestartCooldownExpiration))}
)
</span>
)}
</>
) : (
<span className="text-tertiary">n/a</span>
)}
</FormMeta>
<FormMeta
label="Last auto-restarted"
helpText="When this instance was last automatically restarted. Empty if never auto-restarted."
>
{instance.timeLastAutoRestarted ? (
<>
{format(
new Date(instance.timeLastAutoRestarted),
'MMM d, yyyy HH:mm:ss zz'
)}
</>
) : (
<span className="text-tertiary">n/a</span>
)}
</FormMeta>
</SettingsGroup.Body>
<SettingsGroup.Footer>
<div>
<LearnMore text="Auto-Restart" href={links.sshDocs} />
</div>
<Button size="sm" type="submit" disabled={!isDirty}>
Save
</Button>
</SettingsGroup.Footer>
</SettingsGroup.Container>
</form>
)
}

const FormMeta = ({
label,
helpText,
children,
}: {
label: string
helpText?: string
children: ReactNode
}) => {
const id = useId()
return (
<div>
<div className="mb-2 flex items-center gap-1 border-b pb-2 border-secondary">
<FieldLabel id={`${id}-label`} htmlFor={id}>
{label}
</FieldLabel>
{helpText && <TipIcon>{helpText}</TipIcon>}
</div>
{children}
</div>
)
}
2 changes: 2 additions & 0 deletions app/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import * as SerialConsole from './pages/project/instances/instance/SerialConsole
import * as ConnectTab from './pages/project/instances/instance/tabs/ConnectTab'
import * as MetricsTab from './pages/project/instances/instance/tabs/MetricsTab'
import * as NetworkingTab from './pages/project/instances/instance/tabs/NetworkingTab'
import * as SettingsTab from './pages/project/instances/instance/tabs/SettingsTab'
import * as StorageTab from './pages/project/instances/instance/tabs/StorageTab'
import { InstancesPage } from './pages/project/instances/InstancesPage'
import { SnapshotsPage } from './pages/project/snapshots/SnapshotsPage'
Expand Down Expand Up @@ -295,6 +296,7 @@ export const routes = createRoutesFromElements(
/>
<Route {...MetricsTab} path="metrics" handle={{ crumb: 'Metrics' }} />
<Route {...ConnectTab} path="connect" handle={{ crumb: 'Connect' }} />
<Route {...SettingsTab} path="settings" handle={{ crumb: 'Settings' }} />
</Route>
</Route>
</Route>
Expand Down
Loading
Loading