Skip to content
Open
16 changes: 13 additions & 3 deletions app/api/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)
}

Expand Down Expand Up @@ -185,6 +186,15 @@ const diskActions = {
setAsBootDisk: ['attached'],
} satisfies Record<string, DiskState['state'][]>

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<Disk>, which we pass to it in the MSW handlers
Expand Down
82 changes: 51 additions & 31 deletions app/components/StateBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<InstanceState, Pick<BadgeProps, 'color' | 'variant'>> = {
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<InstanceState, BadgeColor> = {
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 }) => (
<Badge {...INSTANCE_COLORS[props.state]} className={props.className}>
<Badge color={INSTANCE_COLORS[props.state]} className={cn(props.className, badgeClasses)}>
{instanceTransitioning(props.state) && (
<Spinner size="sm" variant={INSTANCE_COLORS[props.state]} />
)}
{props.state}
</Badge>
)

type DiskStateStr = DiskState['state']

const DISK_COLORS: Record<DiskStateStr, Pick<BadgeProps, 'color' | 'variant'>> = {
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<DiskStateStr, BadgeColor> = {
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 }) => (
<Badge {...DISK_COLORS[props.state]} className={props.className}>
{props.state}
<Badge color={DISK_COLORS[props.state]} className={cn(props.className, badgeClasses)}>
{diskTransitioning(props.state) && (
<Spinner size="sm" variant={DISK_COLORS[props.state]} />
)}
{props.state.replace(/_/g, ' ')}
</Badge>
)

const SNAPSHOT_COLORS: Record<SnapshotState, BadgeColor> = {
creating: 'notice',
creating: 'default',
destroyed: 'neutral',
faulted: 'destructive',
ready: 'default',
}

export const SnapshotStateBadge = (props: { state: SnapshotState; className?: string }) => (
<Badge color={SNAPSHOT_COLORS[props.state]} className={props.className}>
<Badge color={SNAPSHOT_COLORS[props.state]} className={cn(props.className, badgeClasses)}>
{props.state === 'creating' && (
<Spinner size="sm" variant={SNAPSHOT_COLORS[props.state]} />
)}
{props.state}
</Badge>
)
2 changes: 1 addition & 1 deletion app/components/TimeAgo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const TimeAgo = ({
)
return (
<Tooltip content={content} placement={placement}>
<span className="text-sans-sm text-secondary">{timeAgoAbbr(datetime)}</span>
<span className="min-w-6 text-sans-sm text-secondary">{timeAgoAbbr(datetime)}</span>
</Tooltip>
)
}
13 changes: 1 addition & 12 deletions app/pages/project/instances/InstancePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -114,14 +112,6 @@ const sec = 1000 // ms, obviously
const POLL_INTERVAL_FAST = 2 * sec
const POLL_INTERVAL_SLOW = 30 * sec

const PollingSpinner = () => (
<Tooltip content="Auto-refreshing while state changes" delay={150}>
<button type="button">
<Spinner className="ml-2" />
</button>
</Tooltip>
)

export default function InstancePage() {
const instanceSelector = useInstanceSelector()
const [resizeInstance, setResizeInstance] = useState(false)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -240,7 +230,6 @@ export default function InstancePage() {
<PropertiesTable.Row label="state">
<div className="flex items-center gap-2">
<InstanceStateBadge state={instance.runState} />
{instanceTransitioning(instance) && <PollingSpinner />}
<InstanceAutoRestartPopover instance={instance} />
</div>
</PropertiesTable.Row>
Expand Down
2 changes: 1 addition & 1 deletion app/pages/project/instances/InstancesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/ui/lib/Badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
)}
Expand Down
13 changes: 11 additions & 2 deletions app/ui/lib/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -26,6 +28,13 @@ const sizeStyle: Record<ButtonSize, string> = {
base: 'h-10 px-4 text-mono-sm [&>svg]:w-5',
}

const variantToBadgeColorMap: Record<Variant, BadgeColor> = {
primary: 'default',
danger: 'destructive',
secondary: 'neutral',
ghost: 'neutral',
}

type ButtonStyleProps = {
size?: ButtonSize
variant?: Variant
Expand Down Expand Up @@ -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"
>
<Spinner variant={variant} />
<Spinner variant={variantToBadgeColorMap[variant || 'primary']} />
</m.span>
)}
<m.span
Expand Down
27 changes: 21 additions & 6 deletions app/ui/lib/Spinner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,24 @@
import cn from 'classnames'
import { useEffect, useRef, useState, type ReactNode } from 'react'

export const spinnerSizes = ['base', 'md', 'lg'] as const
export const spinnerVariants = ['primary', 'secondary', 'ghost', 'danger'] as const
import { type BadgeColor } from './Badge'

export const spinnerSizes = ['sm', 'base', 'md', 'lg'] as const
export type SpinnerSize = (typeof spinnerSizes)[number]
export type SpinnerVariant = (typeof spinnerVariants)[number]

interface SpinnerProps {
className?: string
size?: SpinnerSize
variant?: SpinnerVariant
variant?: BadgeColor
}

const SPINNER_DIMENSIONS = {
sm: {
frameSize: 10,
center: 5,
radius: 4,
strokeWidth: 1.5,
},
base: {
frameSize: 12,
center: 6,
Expand All @@ -40,10 +46,19 @@ const SPINNER_DIMENSIONS = {
},
} as const

const SPINNER_COLORS: Record<BadgeColor, string> = {
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
Expand All @@ -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)}
>
<circle
fill="none"
Expand Down
29 changes: 10 additions & 19 deletions app/ui/styles/components/spinner.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@
animation: rotate 5s linear infinite;
}

.spinner .path,
.spinner .bg {
stroke: currentColor;
}

.spinner.spinner-sm {
--radius: 5;
--circumference: calc(var(--PI) * var(--radius) * 1.5px);
}

.spinner.spinner-md {
--radius: 8;
--circumference: calc(var(--PI) * var(--radius) * 2px);
Expand All @@ -27,7 +37,6 @@
stroke-dasharray: var(--circumference);
transform-origin: center;
animation: dash 8s ease-in-out infinite;
stroke: var(--content-accent-tertiary);
}

@media (prefers-reduced-motion) {
Expand All @@ -50,24 +59,6 @@
}
}

.spinner-ghost .bg,
.spinner-secondary .bg {
stroke: var(--content-default);
}

.spinner-secondary .path {
stroke: var(--content-secondary);
}

.spinner-primary .bg {
stroke: var(--content-accent);
}

.spinner-danger .bg,
.spinner-danger .path {
stroke: var(--content-destructive-tertiary);
}

@keyframes rotate {
100% {
transform: rotate(360deg);
Expand Down
Loading
Loading