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 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 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
158 changes: 158 additions & 0 deletions app/components/InstanceAutoRestartPopover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { CloseButton, Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
import cn from 'classnames'
import { formatDistanceToNow } from 'date-fns'
import { useEffect, useState, type ReactNode } from 'react'
import { Link } from 'react-router'

import { 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 { pb } from '~/util/path-builder'

const helpText = {
enabled: (
<>The control plane will attempt to automatically restart instance this instance.</>
),
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())

useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(timer)
}, [])
benjaminleonard marked this conversation as resolved.
Show resolved Hide resolved

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

// todo: untangle this web
const helpTextState = isQueued
? 'starting'
: policy === 'never'
? 'never'
: enabled
? 'enabled'
: ('disabled' as const)

return (
<Popover>
<PopoverButton className="group flex h-6 w-6 items-center justify-center rounded border border-default hover:bg-hover">
<AutoRestartIcon12
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="State">
{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 AutoRestartIcon12 = ({ className }: { className?: string }) => (
benjaminleonard marked this conversation as resolved.
Show resolved Hide resolved
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M6 10.5C6.44357 10.5 6.87214 10.4358 7.27695 10.3162C7.59688 10.2217 7.95371 10.3259 8.13052 10.6088L8.22881 10.7661C8.43972 11.1035 8.31208 11.5527 7.9354 11.681C7.32818 11.8878 6.67719 12 6 12C2.68629 12 0 9.31371 0 6C0 2.68629 2.68629 0 6 0C9.31371 0 12 2.68629 12 6C12 7.2371 11.6119 8.42336 10.9652 9.39169C10.74 9.72899 10.2624 9.72849 9.99535 9.42325L7.9723 7.1112C7.59324 6.67799 7.90089 6 8.47652 6H10.5C10.5 3.51472 8.48528 1.5 6 1.5C3.51472 1.5 1.5 3.51472 1.5 6C1.5 8.48528 3.51472 10.5 6 10.5Z"
fill="currentColor"
/>
</svg>
)

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-2 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 { 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 @@

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 @@
<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 @@
</button>
</Tooltip>
)}
{hasAutoRestart && (
<InstanceAutoRestartPopover
enabled={instance.autoRestartEnabled}
cooldownExpiration={instance.autoRestartCooldownExpiration}
policy={instance.autoRestartPolicy}

Check failure on line 223 in app/pages/project/instances/instance/InstancePage.tsx

View workflow job for this annotation

GitHub Actions / ci

Type 'InstanceAutoRestartPolicy | undefined' is not assignable to type 'InstanceAutoRestartPolicy'.
/>
)}
</div>
</PropertiesTable.Row>
<PropertiesTable.Row label="vpc">
Expand Down Expand Up @@ -241,6 +252,7 @@
<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
147 changes: 147 additions & 0 deletions app/pages/project/instances/instance/tabs/SettingsTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/*
* 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 { useEffect, 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 { links } from '~/util/links'

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

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

useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(timer)
}, [])

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."
benjaminleonard marked this conversation as resolved.
Show resolved Hide resolved
placeholder="Default"
items={[
{ value: '', label: 'None (Default)' },
benjaminleonard marked this conversation as resolved.
Show resolved Hide resolved
{ value: 'never', label: 'Never' },
{ value: 'best_effort', label: 'Best effort' },
]}
required
className="max-w-none"
/>
<FormMeta label="Enabled" helpText="Help">
{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="Help">
{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>
</SettingsGroup.Body>
<SettingsGroup.Footer>
<div>
<LearnMore text="Auto-Restart" href={links.sshDocs} />
</div>
<Button size="sm" type="submit" disabled={!isDirty}>
Save
</Button>
Copy link
Collaborator

@david-crespo david-crespo Jan 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the relationship between the select and the table underneath could be clearer. The fact that the save button is at the bottom might be the problem. It makes it feel like the preview is inside the form and should therefore update live as a preview of what I'm saving. If the save button was next to the input and the state was below, it might be clearer that it would only update after I hit save.

image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's fair, though I'd prefer not to move the save button. This is the UX pattern for this settings form block universally, and it'll be a bit strange to do something different.

Perhaps I can try something like a treatment on the the enabled bit whilst the form has been changed. Cooldown expiration and auto-restart timestamp should not be affected by the policy change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CleanShot 2025-02-03 at 11 03 14

Gave this a try. It might read as the policy is disabling the "enabled" field?

The best UX would be for us to infer what the field would be, but that might get a bit dicey. Especially if the behaviour changes in the future.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, we know based on the policy, it's either: true, false or project default. It's the inferred autoRestartEnabled that we're not sure of until the form is saved. So perhaps we hide the inferred value until it's submitted.

CleanShot 2025-02-03 at 11 21 39

</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
2 changes: 1 addition & 1 deletion app/ui/lib/DropdownMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function Content({ className, children, anchor = 'bottom end', gap }: Con
anchor={anchor}
// goofy gap because tailwind hates string interpolation
className={cn(
'DropdownMenuContent elevation-2',
'dropdown-menu-content elevation-2',
gap === 8 && `[--anchor-gap:8px]`,
className
)}
Expand Down
Loading
Loading