-
Notifications
You must be signed in to change notification settings - Fork 21
Instance auto restart #2644
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
Changes from 2 commits
f66afff
945b0bb
3be8500
8d40e2f
bf37c96
d81c471
06400f8
a11042a
c03afd2
3212357
7ada68e
a599745
c773c3a
467b58e
ebe8dc0
9263a5e
72f3ea3
de849ff
9f9d9db
7e9b771
b86cf88
740fb8b
9df1189
fb10a59
d1a41b1
cbe9362
d9a97c6
db52d77
2547ecc
e2a231d
690b788
fad694b
d707200
a3871c2
a641b89
7cde120
6c413e9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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.
Outdated
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.
Outdated
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> | ||
| ) | ||
| 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.
Outdated
Show resolved
Hide resolved
|
||
| placeholder="Default" | ||
| items={[ | ||
| { value: '', label: 'None (Default)' }, | ||
benjaminleonard marked this conversation as resolved.
Outdated
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.
Outdated
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> | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| </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> | ||
| ) | ||
| } | ||



Uh oh!
There was an error while loading. Please reload this page.