diff --git a/app/api/util.ts b/app/api/util.ts index 4018e4041..305ee90a0 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -142,12 +142,13 @@ export const instanceCan = R.mapValues(instanceActions, (states: InstanceState[] return test }) -export function instanceTransitioning({ runState }: Instance) { +export function instanceTransitioning(runState: InstanceState) { return ( runState === 'creating' || runState === 'starting' || - runState === 'stopping' || - runState === 'rebooting' + runState === 'rebooting' || + runState === 'migrating' || + runState === 'stopping' ) } @@ -185,6 +186,15 @@ const diskActions = { setAsBootDisk: ['attached'], } satisfies Record +export function diskTransitioning(diskState: DiskState['state']) { + return ( + diskState === 'attaching' || + diskState === 'creating' || + diskState === 'detaching' || + diskState === 'finalizing' + ) +} + export const diskCan = R.mapValues(diskActions, (states: DiskState['state'][]) => { // only have to Pick because we want this to work for both Disk and // Json, which we pass to it in the MSW handlers diff --git a/app/components/StateBadge.tsx b/app/components/StateBadge.tsx index 79d933e1a..a53cab8b6 100644 --- a/app/components/StateBadge.tsx +++ b/app/components/StateBadge.tsx @@ -5,61 +5,81 @@ * * Copyright Oxide Computer Company */ -import type { DiskState, InstanceState, SnapshotState } from '@oxide/api' +import cn from 'classnames' -import { Badge, type BadgeColor, type BadgeProps } from '~/ui/lib/Badge' +import { + diskTransitioning, + instanceTransitioning, + type DiskState, + type InstanceState, + type SnapshotState, +} from '@oxide/api' -const INSTANCE_COLORS: Record> = { - creating: { color: 'purple', variant: 'solid' }, - starting: { color: 'blue', variant: 'solid' }, - running: { color: 'default' }, - rebooting: { color: 'notice' }, - stopping: { color: 'notice' }, - stopped: { color: 'neutral', variant: 'solid' }, - repairing: { color: 'notice', variant: 'solid' }, - migrating: { color: 'notice', variant: 'solid' }, - failed: { color: 'destructive', variant: 'solid' }, - destroyed: { color: 'neutral', variant: 'solid' }, +import { Badge, type BadgeColor } from '~/ui/lib/Badge' +import { Spinner } from '~/ui/lib/Spinner' + +const INSTANCE_COLORS: Record = { + running: 'default', + stopped: 'neutral', + failed: 'destructive', + destroyed: 'destructive', + creating: 'default', + starting: 'blue', + rebooting: 'blue', + migrating: 'purple', + repairing: 'notice', + stopping: 'neutral', } +const badgeClasses = 'children:flex children:items-center children:gap-1' + export const InstanceStateBadge = (props: { state: InstanceState; className?: string }) => ( - + + {instanceTransitioning(props.state) && ( + + )} {props.state} ) type DiskStateStr = DiskState['state'] -const DISK_COLORS: Record> = { - attached: { color: 'default' }, - attaching: { color: 'blue', variant: 'solid' }, - creating: { color: 'purple', variant: 'solid' }, - detaching: { color: 'notice', variant: 'solid' }, - detached: { color: 'neutral', variant: 'solid' }, - destroyed: { color: 'destructive', variant: 'solid' }, // should we ever see this? - faulted: { color: 'destructive', variant: 'solid' }, - maintenance: { color: 'notice', variant: 'solid' }, - import_ready: { color: 'blue', variant: 'solid' }, - importing_from_url: { color: 'purple', variant: 'solid' }, - importing_from_bulk_writes: { color: 'purple', variant: 'solid' }, - finalizing: { color: 'blue', variant: 'solid' }, +const DISK_COLORS: Record = { + attached: 'default', + attaching: 'blue', + creating: 'default', + detaching: 'blue', + detached: 'neutral', + destroyed: 'destructive', // should we ever see this? + faulted: 'destructive', + maintenance: 'notice', + import_ready: 'blue', + importing_from_url: 'purple', + importing_from_bulk_writes: 'purple', + finalizing: 'blue', } export const DiskStateBadge = (props: { state: DiskStateStr; className?: string }) => ( - - {props.state} + + {diskTransitioning(props.state) && ( + + )} + {props.state.replace(/_/g, ' ')} ) const SNAPSHOT_COLORS: Record = { - creating: 'notice', + creating: 'default', destroyed: 'neutral', faulted: 'destructive', ready: 'default', } export const SnapshotStateBadge = (props: { state: SnapshotState; className?: string }) => ( - + + {props.state === 'creating' && ( + + )} {props.state} ) diff --git a/app/components/TimeAgo.tsx b/app/components/TimeAgo.tsx index afa22d828..612579198 100644 --- a/app/components/TimeAgo.tsx +++ b/app/components/TimeAgo.tsx @@ -28,7 +28,7 @@ export const TimeAgo = ({ ) return ( - {timeAgoAbbr(datetime)} + {timeAgoAbbr(datetime)} ) } diff --git a/app/pages/project/instances/InstancePage.tsx b/app/pages/project/instances/InstancePage.tsx index d1f5b4599..83560178c 100644 --- a/app/pages/project/instances/InstancePage.tsx +++ b/app/pages/project/instances/InstancePage.tsx @@ -50,8 +50,6 @@ import { Message } from '~/ui/lib/Message' import { Modal } from '~/ui/lib/Modal' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { PropertiesTable } from '~/ui/lib/PropertiesTable' -import { Spinner } from '~/ui/lib/Spinner' -import { Tooltip } from '~/ui/lib/Tooltip' import { truncate } from '~/ui/lib/Truncate' import { instanceMetricsBase, pb } from '~/util/path-builder' import { pluralize } from '~/util/str' @@ -114,14 +112,6 @@ const sec = 1000 // ms, obviously const POLL_INTERVAL_FAST = 2 * sec const POLL_INTERVAL_SLOW = 30 * sec -const PollingSpinner = () => ( - - - -) - export default function InstancePage() { const instanceSelector = useInstanceSelector() const [resizeInstance, setResizeInstance] = useState(false) @@ -153,7 +143,7 @@ export default function InstancePage() { // polling on the list page. refetchInterval: ({ state: { data: instance } }) => { if (!instance) return false - if (instanceTransitioning(instance)) return POLL_INTERVAL_FAST + if (instanceTransitioning(instance.runState)) return POLL_INTERVAL_FAST if (instance.runState === 'failed' && instance.autoRestartEnabled) { return instanceAutoRestartingSoon(instance) @@ -240,7 +230,6 @@ export default function InstancePage() {
- {instanceTransitioning(instance) && }
diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index 2c4977ce9..785c66615 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -153,7 +153,7 @@ export default function InstancesPage() { const nextTransitioning = new Set( // Data will never actually be undefined because of the prefetch but whatever (data?.items || []) - .filter(instanceTransitioning) + .filter((instance) => instanceTransitioning(instance.runState)) // These are strings of instance ID + current state. This is done because // of the case where an instance is stuck in starting (for example), polling // times out, and then you manually stop it. Without putting the state in the diff --git a/app/ui/lib/Badge.tsx b/app/ui/lib/Badge.tsx index 92ffc05cd..746c0bc9e 100644 --- a/app/ui/lib/Badge.tsx +++ b/app/ui/lib/Badge.tsx @@ -53,7 +53,7 @@ export const Badge = ({ className={cn( 'ox-badge', `variant-${variant}`, - 'inline-flex h-4 items-center whitespace-nowrap rounded-sm px-[3px] py-[1px] uppercase text-mono-sm', + 'inline-flex h-[18px] items-center whitespace-nowrap rounded px-1 uppercase text-mono-sm', badgeColors[variant][color], className )} diff --git a/app/ui/lib/Button.tsx b/app/ui/lib/Button.tsx index e7b27f383..bdca8d48c 100644 --- a/app/ui/lib/Button.tsx +++ b/app/ui/lib/Button.tsx @@ -13,6 +13,8 @@ import { Spinner } from '~/ui/lib/Spinner' import { Tooltip } from '~/ui/lib/Tooltip' import { Wrap } from '~/ui/util/wrap' +import { type BadgeColor } from './Badge' + export const buttonSizes = ['sm', 'icon', 'base'] as const export const variants = ['primary', 'secondary', 'ghost', 'danger'] as const @@ -26,6 +28,13 @@ const sizeStyle: Record = { base: 'h-10 px-4 text-mono-sm [&>svg]:w-5', } +const variantToBadgeColorMap: Record = { + primary: 'default', + danger: 'destructive', + secondary: 'neutral', + ghost: 'neutral', +} + type ButtonStyleProps = { size?: ButtonSize variant?: Variant @@ -115,9 +124,9 @@ export const Button = ({ animate={{ opacity: 1, y: '-50%', x: '-50%' }} initial={{ opacity: 0, y: 'calc(-50% - 25px)', x: '-50%' }} transition={{ type: 'spring', duration: 0.3, bounce: 0 }} - className="absolute left-1/2 top-1/2" + className="absolute left-1/2 top-1/2 flex items-center justify-center" > - + )} = { + default: 'text-accent-secondary', + neutral: 'text-secondary', + destructive: 'text-destructive-secondary', + notice: 'text-notice-secondary', + purple: 'text-[--base-purple-700]', + blue: 'text-[--base-blue-700]', +} + export const Spinner = ({ className, size = 'base', - variant = 'primary', + variant = 'default', }: SpinnerProps) => { const dimensions = SPINNER_DIMENSIONS[size] const { frameSize, center, radius, strokeWidth } = dimensions @@ -56,7 +71,7 @@ export const Spinner = ({ fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="Spinner" - className={cn('spinner', `spinner-${variant}`, `spinner-${size}`, className)} + className={cn('spinner', SPINNER_COLORS[variant], `spinner-${size}`, className)} > { + const states: DiskState['state'][] = [ + 'attached', + 'attaching', + 'creating', + 'detaching', + 'detached', + 'destroyed', + 'faulted', + 'maintenance', + 'import_ready', + 'importing_from_url', + 'importing_from_bulk_writes', + 'finalizing', + ] + + const state = states[Math.floor(rando.next() * states.length)] + + switch (state) { + case 'attached': + case 'attaching': + case 'detaching': + return { state, instance: '32a0249f-3a5c-4d30-a154-2476e372aa62' } + case 'detached': + case 'creating': + case 'destroyed': + case 'faulted': + case 'maintenance': + case 'import_ready': + case 'importing_from_url': + case 'importing_from_bulk_writes': + case 'finalizing': + return { state } + } +} + export const disk1: Json = { id: '7f2309a5-13e3-47e0-8a4c-2a3b3bc992fd', name: 'disk-1', @@ -161,7 +201,7 @@ export const disks: Json[] = [ project_id: project2.id, time_created: new Date().toISOString(), time_modified: new Date().toISOString(), - state: { state: 'detached' as const }, + state: randomDiskState(), device_path: '/jkl', size: 12 * GiB, block_size: 2048, diff --git a/mock-api/msw/rando.ts b/mock-api/msw/rando.ts new file mode 100644 index 000000000..437ad617e --- /dev/null +++ b/mock-api/msw/rando.ts @@ -0,0 +1,25 @@ +/* + * 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 + */ +export class Rando { + private a: number + private c: number + private m: number + private seed: number + + constructor(seed: number, a = 1664525, c = 1013904223, m = 2 ** 32) { + this.seed = seed + this.a = a + this.c = c + this.m = m + } + + public next(): number { + this.seed = (this.a * this.seed + this.c) % this.m + return this.seed / this.m + } +} diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index 0ab4b437d..e469492c4 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -35,6 +35,7 @@ import { GiB, TiB } from '~/util/units' import type { DbRoleAssignmentResourceType } from '..' import { genI64Data } from '../metrics' import { db } from './db' +import { Rando } from './rando' interface PaginateOptions { limit?: number @@ -151,25 +152,6 @@ export const errIfInvalidDiskSize = (disk: Json) => { } } -class Rando { - private a: number - private c: number - private m: number - private seed: number - - constructor(seed: number, a = 1664525, c = 1013904223, m = 2 ** 32) { - this.seed = seed - this.a = a - this.c = c - this.m = m - } - - public next(): number { - this.seed = (this.a * this.seed + this.c) % this.m - return this.seed / this.m - } -} - export function generateUtilization( metricName: SystemMetricName, startTime: Date, diff --git a/mock-api/snapshot.ts b/mock-api/snapshot.ts index 6bf129ddb..0efea2e42 100644 --- a/mock-api/snapshot.ts +++ b/mock-api/snapshot.ts @@ -14,8 +14,41 @@ import { GiB } from '~/util/units' import { disks } from './disk' import type { Json } from './json-type' +import { Rando } from './msw/rando' import { project } from './project' +// Use seeded random for consistent states across runs +const rando = new Rando(0) + +function randomSnapshotState() { + const num = rando.next() + + // We still want it to be mostly ready states + if (num > 0.1) { + return 'ready' + } else if (num > 0.066) { + return 'destroyed' + } else if (num > 0.033) { + return 'faulted' + } else { + return 'creating' + } +} + +function generateSnapshot(index: number): Json { + return { + id: uuid(), + name: `disk-1-snapshot-${index + 8}`, + description: '', + project_id: project.id, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), + size: 1024 * (index + 1), + disk_id: disks[0].id, + state: randomSnapshotState(), + } +} + const generatedSnapshots: Json[] = Array.from({ length: 80 }, (_, i) => generateSnapshot(i) ) @@ -100,17 +133,3 @@ export const snapshots: Json[] = [ }, ...generatedSnapshots, ] - -function generateSnapshot(index: number): Json { - return { - id: uuid(), - name: `disk-1-snapshot-${index + 8}`, - description: '', - project_id: project.id, - time_created: new Date().toISOString(), - time_modified: new Date().toISOString(), - size: 1024 * (index + 1), - disk_id: disks[0].id, - state: 'ready', - } -} diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 45d757284..57231e16d 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -510,25 +510,25 @@ test('attaching additional disks allows for combobox filtering', async ({ page } await attachExistingDiskButton.click() await selectADisk.click() // several disks should be shown - await expect(page.getByRole('option', { name: 'disk-0001' })).toBeVisible() - await expect(page.getByRole('option', { name: 'disk-0002' })).toBeVisible() - await expect(page.getByRole('option', { name: 'disk-1000' })).toBeVisible() + await expect(page.getByRole('option', { name: 'disk-0005' })).toBeVisible() + await expect(page.getByRole('option', { name: 'disk-0007' })).toBeVisible() + await expect(page.getByRole('option', { name: 'disk-0988' })).toBeVisible() // type in a string to use as a filter - await selectADisk.fill('disk-010') + await selectADisk.fill('disk-02') // only disks with that substring should be shown - await expect(page.getByRole('option', { name: 'disk-0100' })).toBeVisible() - await expect(page.getByRole('option', { name: 'disk-0101' })).toBeVisible() - await expect(page.getByRole('option', { name: 'disk-0102' })).toBeVisible() - await expect(page.getByRole('option', { name: 'disk-0001' })).toBeHidden() + await expect(page.getByRole('option', { name: 'disk-0023' })).toBeVisible() + await expect(page.getByRole('option', { name: 'disk-0125' })).toBeVisible() + await expect(page.getByRole('option', { name: 'disk-0211' })).toBeVisible() + await expect(page.getByRole('option', { name: 'disk-0220' })).toBeHidden() await expect(page.getByRole('option', { name: 'disk-1000' })).toBeHidden() // select one - await page.getByRole('option', { name: 'disk-0102' }).click() + await page.getByRole('option', { name: 'disk-0211' }).click() // now options hidden and only the selected one is visible in the button/input await expect(page.getByRole('option')).toBeHidden() - await expect(page.getByRole('combobox', { name: 'Disk name' })).toHaveValue('disk-0102') + await expect(page.getByRole('combobox', { name: 'Disk name' })).toHaveValue('disk-0211') // a random string should give a disabled option await selectADisk.click()