Skip to content

Updated badges with integrated transitional spinners #2742

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

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
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