Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
f66afff
First pass instance auto-restart
benjaminleonard Jan 9, 2025
945b0bb
Merge branch 'main' into instance-auto-restart
benjaminleonard Jan 9, 2025
3be8500
Add comma to settings help text
benjaminleonard Jan 9, 2025
8d40e2f
Add cooldown and add copy tweaks
benjaminleonard Jan 14, 2025
bf37c96
Add license
benjaminleonard Jan 14, 2025
d81c471
Change none to default in listbox text
benjaminleonard Jan 14, 2025
06400f8
Update mock handler to match omicron
benjaminleonard Jan 14, 2025
a11042a
Use `useInterval` hook
benjaminleonard Jan 14, 2025
c03afd2
Use `design-system` icon
benjaminleonard Jan 14, 2025
3212357
Text tweak
benjaminleonard Jan 14, 2025
7ada68e
Simplify `helpTextState`
benjaminleonard Jan 14, 2025
a599745
`InstanceAutoRestartPolicy` can be undefined
benjaminleonard Jan 14, 2025
c773c3a
Merge branch 'main' into instance-auto-restart
benjaminleonard Jan 14, 2025
467b58e
Vitest fixes
benjaminleonard Jan 14, 2025
ebe8dc0
Upgrade `@oxide/design-system`
benjaminleonard Jan 21, 2025
9263a5e
merge main
david-crespo Jan 31, 2025
72f3ea3
uncontroversial copy tweaks
david-crespo Jan 31, 2025
de849ff
fix restart policy form unset bug
david-crespo Jan 31, 2025
9f9d9db
ts-pattern for nicer exhaustiveness
david-crespo Feb 1, 2025
7e9b771
Merge branch 'main' into instance-auto-restart
benjaminleonard Feb 3, 2025
b86cf88
Stronger highlight
benjaminleonard Feb 3, 2025
740fb8b
Stop icon spin
benjaminleonard Feb 3, 2025
9df1189
Add instance popover link state
benjaminleonard Feb 3, 2025
fb10a59
merge main (reduce e2e flake)
david-crespo Feb 5, 2025
d1a41b1
use instance polling instead of interval
david-crespo Feb 1, 2025
cbe9362
also rely on polling on settings tab
david-crespo Feb 5, 2025
d9a97c6
bump API client generator for date parsing fix
david-crespo Feb 7, 2025
db52d77
format dates for locale with existing helper
david-crespo Feb 5, 2025
2547ecc
take enabled out of settings form
david-crespo Feb 7, 2025
e2a231d
handle auto restart stuff in msw at update time
david-crespo Feb 7, 2025
690b788
update mock API to allow policy update any time
david-crespo Feb 7, 2025
fad694b
copy tweaks, fill in tests
david-crespo Feb 7, 2025
d707200
poll slower for failed instances
david-crespo Feb 7, 2025
a3871c2
simplify FormMeta, no Label needed
david-crespo Feb 10, 2025
a641b89
fix up polling and popover logic
david-crespo Feb 11, 2025
7cde120
copy and popover state polish, more e2es
david-crespo Feb 11, 2025
6c413e9
instanceAutoRestartingSoon helper
david-crespo Feb 11, 2025
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"
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)
}, [])

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 }) => (
<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,
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: 'None (Default)' },
{ value: 'never', label: 'Never' },
{ value: 'best_effort', label: 'Best effort' },
]}
required
className="max-w-none"
/>
<FormMeta label="Enabled" helpText="Help">
{instance.autoRestartEnabled ? 'True' : 'False'}{' '}
{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

Copy link
Collaborator

Choose a reason for hiding this comment

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

Neither of these solves the problem I ran into, which is that I didn't feel confident about whether/when my change is actually applied. Dimmed does look disabled. I don't think we have a pattern like this, but dotted line border or underline could make me think temporary/provisional. Another way would be to put the dropdown in a modal, but that feels very silly considering we have this perfect spot. What about just getting rid of the enabled part ? It's redundant with the listbox. Then all you need is a way of visually marking an unsaved change to the listbox. ...and I guess something to do with cooldown expiration while the listbox is changed to never but not saved. Kinda weird but figuring out a pattern here might be handy for inline editable table rows.

</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