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
Open
8 changes: 5 additions & 3 deletions app/api/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,12 +142,14 @@ 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 === 'repairing' ||
runState === 'stopping'
)
}

Expand Down
40 changes: 27 additions & 13 deletions app/components/StateBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,39 @@
*
* Copyright Oxide Computer Company
*/
import type { DiskState, InstanceState, SnapshotState } from '@oxide/api'
import cn from 'classnames'

import {
instanceTransitioning,
type DiskState,
type InstanceState,
type SnapshotState,
} from '@oxide/api'

import { Badge, type BadgeColor, type BadgeProps } from '~/ui/lib/Badge'
import { Spinner } from '~/ui/lib/Spinner'

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' },
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',
}

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, 'children:flex children:items-center children:gap-1')}
>
{instanceTransitioning(props.state) && (
<Spinner size="sm" variant={INSTANCE_COLORS[props.state]} />
)}
{props.state}
</Badge>
)
Expand Down
13 changes: 1 addition & 12 deletions app/pages/project/instances/InstancePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,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 { pb } from '~/util/path-builder'
import { pluralize } from '~/util/str'
Expand Down Expand Up @@ -112,14 +110,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 @@ -151,7 +141,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 @@ -230,7 +220,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
11 changes: 10 additions & 1 deletion 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 @@ -117,7 +126,7 @@ export const Button = ({
transition={{ type: 'spring', duration: 0.3, bounce: 0 }}
className="absolute left-1/2 top-1/2"
>
<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-error-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
24 changes: 5 additions & 19 deletions app/ui/styles/components/spinner.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
animation: rotate 5s linear infinite;
}

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

.spinner.spinner-md {
--radius: 8;
--circumference: calc(var(--PI) * var(--radius) * 2px);
Expand All @@ -27,7 +32,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 +54,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