diff --git a/app/components/DocsPopover.tsx b/app/components/DocsPopover.tsx index 82362feb14..2f00aff171 100644 --- a/app/components/DocsPopover.tsx +++ b/app/components/DocsPopover.tsx @@ -48,8 +48,8 @@ export const DocsPopover = ({ heading, icon, summary, links }: DocsPopoverProps)
diff --git a/app/components/InstanceAutoRestartPopover.tsx b/app/components/InstanceAutoRestartPopover.tsx new file mode 100644 index 0000000000..daca276832 --- /dev/null +++ b/app/components/InstanceAutoRestartPopover.tsx @@ -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 failed state. + + ), + disabled: ( + <> + The control plane will not attempt to automatically restart it after entering the{' '} + failed state. + + ), + never: ( + <> + Instance auto-restart policy is set to never. The control plane will not attempt to + automatically restart it after entering the failed 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 ( + + + + + + + {enabled ? Enabled : Disabled} + + + + {policy ? ( + policy === 'never' ? ( + + never + + ) : ( + best effort + ) + ) : ( + Default + )} +
+ +
+
+
+ {cooldownExpiration && ( + + {isQueued ? ( + <> + Queued for restart… + + ) : ( +
+ Waiting{' '} + + ({formatDistanceToNow(cooldownExpiration)}) + +
+ )} +
+ )} +
+

{helpText[helpTextState]}

+ + + Learn about Instance Auto-Restart + + + +
+
+
+ ) +} + +const PopoverRow = ({ label, children }: { label: string; children: ReactNode }) => ( +
+
{label}
+
+ {children} +
+
+) diff --git a/app/pages/project/instances/instance/InstancePage.tsx b/app/pages/project/instances/instance/InstancePage.tsx index 22acc7c97e..d882615bdb 100644 --- a/app/pages/project/instances/instance/InstancePage.tsx +++ b/app/pages/project/instances/instance/InstancePage.tsx @@ -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' @@ -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 ( <> @@ -203,7 +207,7 @@ export function InstancePage() { {memory.unit} -
+
{polling && ( @@ -212,6 +216,13 @@ export function InstancePage() { )} + {hasAutoRestart && ( + + )}
@@ -241,6 +252,7 @@ export function InstancePage() { Metrics Networking Connect + Settings {resizeInstance && ( 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, + autoRestartPolicy: values.autoRestartPolicy, + }, + }) + }) + + return ( +
+ + + Auto-restart +

The auto-restart policy configured for this instance

+
+ + + + {instance.autoRestartEnabled ? 'True' : 'False'}{' '} + {instance.autoRestartEnabled && !instance.autoRestartPolicy && ( + (Project default) + )} + + + {instance.autoRestartCooldownExpiration ? ( + <> + {format( + new Date(instance.autoRestartCooldownExpiration), + 'MMM d, yyyy HH:mm:ss zz' + )}{' '} + {new Date(instance.autoRestartCooldownExpiration) > now && ( + + ({formatDistanceToNow(new Date(instance.autoRestartCooldownExpiration))} + ) + + )} + + ) : ( + n/a + )} + + + {instance.timeLastAutoRestarted ? ( + <> + {format( + new Date(instance.timeLastAutoRestarted), + 'MMM d, yyyy HH:mm:ss zz' + )} + + ) : ( + n/a + )} + + + +
+ +
+ +
+
+
+ ) +} + +const FormMeta = ({ + label, + helpText, + children, +}: { + label: string + helpText?: string + children: ReactNode +}) => { + const id = useId() + return ( +
+
+ + {label} + + {helpText && {helpText}} +
+ {children} +
+ ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 780e603b5c..d71ffea989 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -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' @@ -295,6 +296,7 @@ export const routes = createRoutesFromElements( /> + diff --git a/app/ui/lib/DropdownMenu.tsx b/app/ui/lib/DropdownMenu.tsx index a6cf724d3c..f70954c30c 100644 --- a/app/ui/lib/DropdownMenu.tsx +++ b/app/ui/lib/DropdownMenu.tsx @@ -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 )} diff --git a/app/ui/lib/Listbox.tsx b/app/ui/lib/Listbox.tsx index 6cd7af0106..c1617bb758 100644 --- a/app/ui/lib/Listbox.tsx +++ b/app/ui/lib/Listbox.tsx @@ -82,7 +82,7 @@ export const Listbox = ({ {({ open }) => ( <> {label && ( -
+
{ "instanceConnect": "/projects/p/instances/i/connect", "instanceMetrics": "/projects/p/instances/i/metrics", "instanceNetworking": "/projects/p/instances/i/networking", + "instanceSettings": "/projects/p/instances/i/settings", "instanceStorage": "/projects/p/instances/i/storage", "instances": "/projects/p/instances", "instancesNew": "/projects/p/instances-new", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 5b5ab823f9..d227511a18 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -44,6 +44,7 @@ export const pb = { instanceConnect: (params: PP.Instance) => `${instanceBase(params)}/connect`, instanceNetworking: (params: PP.Instance) => `${instanceBase(params)}/networking`, serialConsole: (params: PP.Instance) => `${instanceBase(params)}/serial-console`, + instanceSettings: (params: PP.Instance) => `${instanceBase(params)}/settings`, disksNew: (params: PP.Project) => `${projectBase(params)}/disks-new`, disks: (params: PP.Project) => `${projectBase(params)}/disks`, diff --git a/mock-api/instance.ts b/mock-api/instance.ts index c274e1f16c..3f93b6a4f5 100644 --- a/mock-api/instance.ts +++ b/mock-api/instance.ts @@ -42,6 +42,8 @@ const failedInstance: Json = { hostname: 'oxide.com', project_id: project.id, run_state: 'failed', + auto_restart_cooldown_expiration: new Date(Date.now() + 5 * 60 * 1000).toISOString(), // 5 mins in the future + time_last_auto_restarted: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), // one month ago } const startingInstance: Json = { diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 376ad49714..36e0e7a263 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -584,7 +584,23 @@ export const handlers = makeHandlers({ return json(newInstance, { status: 201 }) }, - instanceView: ({ path, query }) => lookup.instance({ ...path, ...query }), + instanceView: ({ path, query }) => { + const instance = lookup.instance({ ...path, ...query }) + + // if empty uses default auto-restart behaviour + // if set, is based off of the policy + // https://github.com/oxidecomputer/omicron/blob/f63ed095e744fb8d2383fda6799eb0b2d6dfbd3c/nexus/db-queries/src/db/datastore/instance.rs#L228C26-L239 + if (instance.auto_restart_policy === 'never') { + instance.auto_restart_enabled = false + } else if ( + instance.auto_restart_policy === 'best_effort' || + !instance.auto_restart_policy // included for posterity but this has to be set in the mock data anyway + ) { + instance.auto_restart_enabled = true + } + + return instance + }, instanceUpdate({ path, query, body }) { const instance = lookup.instance({ ...path, ...query }) @@ -613,6 +629,10 @@ export const handlers = makeHandlers({ instance.boot_disk_id = undefined } + if (body.auto_restart_policy !== undefined) { + instance.auto_restart_policy = body.auto_restart_policy + } + // always present on the body, always set them instance.ncpus = body.ncpus instance.memory = body.memory diff --git a/package-lock.json b/package-lock.json index f646265b4d..a56de5dfb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@floating-ui/react": "^0.26.23", "@headlessui/react": "^2.2.0", - "@oxide/design-system": "^3.0.0", + "@oxide/design-system": "^2.1.0", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-focus-guards": "1.0.1", @@ -1547,9 +1547,9 @@ "license": "MIT" }, "node_modules/@oxide/design-system": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-3.0.0.tgz", - "integrity": "sha512-GB35TIZXD4zEjQjoYKvJqBPuuABD76QJr1pP+huJHKixrQY1UO83DZeqSNBQ6ifdBTTMqLtgHDxcroHLM7BXTQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-2.1.0.tgz", + "integrity": "sha512-aQpD+vZQIgkBzi9jcX35m1z1milAf4PzewVH9FlMMGniwUNCXw6G6KT8t3Unnwy3egUQ/qvhdQqm9VSdoDQwsA==", "license": "MPL 2.0", "dependencies": { "@floating-ui/react": "^0.25.1", diff --git a/package.json b/package.json index a1ed316fb4..0544ff0e31 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "dependencies": { "@floating-ui/react": "^0.26.23", "@headlessui/react": "^2.2.0", - "@oxide/design-system": "^3.0.0", + "@oxide/design-system": "^2.1.0", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-focus-guards": "1.0.1", diff --git a/tailwind.config.ts b/tailwind.config.ts index 4203962afd..e68c978a34 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -61,6 +61,9 @@ export default { transparent: 'transparent', current: 'currentColor', }, + animation: { + 'spin-slow': 'spin 5s linear infinite', + }, }, plugins: [ plugin(({ addVariant, addUtilities }) => {