Skip to content

Commit 8ac318b

Browse files
Updated badges with integrated transitional spinners (#2742)
* First pass on new instance badges * Disk and snapshot badges * Reduce layout jumping as time changes initially * Improve mock random states * Mock data tweaks * Missing license * Test fix * Fix missing instance with mock data * Replace `_` in disk states * Remove non-transitional states
1 parent 36e415c commit 8ac318b

File tree

14 files changed

+221
-121
lines changed

14 files changed

+221
-121
lines changed

app/api/util.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,13 @@ export const instanceCan = R.mapValues(instanceActions, (states: InstanceState[]
145145
return test
146146
})
147147

148-
export function instanceTransitioning({ runState }: Instance) {
148+
export function instanceTransitioning(runState: InstanceState) {
149149
return (
150150
runState === 'creating' ||
151151
runState === 'starting' ||
152-
runState === 'stopping' ||
153-
runState === 'rebooting'
152+
runState === 'rebooting' ||
153+
runState === 'migrating' ||
154+
runState === 'stopping'
154155
)
155156
}
156157

@@ -188,6 +189,15 @@ const diskActions = {
188189
setAsBootDisk: ['attached'],
189190
} satisfies Record<string, DiskState['state'][]>
190191

192+
export function diskTransitioning(diskState: DiskState['state']) {
193+
return (
194+
diskState === 'attaching' ||
195+
diskState === 'creating' ||
196+
diskState === 'detaching' ||
197+
diskState === 'finalizing'
198+
)
199+
}
200+
191201
export const diskCan = R.mapValues(diskActions, (states: DiskState['state'][]) => {
192202
// only have to Pick because we want this to work for both Disk and
193203
// Json<Disk>, which we pass to it in the MSW handlers

app/components/StateBadge.tsx

Lines changed: 51 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,61 +5,81 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8-
import type { DiskState, InstanceState, SnapshotState } from '@oxide/api'
8+
import cn from 'classnames'
99

10-
import { Badge, type BadgeColor, type BadgeProps } from '~/ui/lib/Badge'
10+
import {
11+
diskTransitioning,
12+
instanceTransitioning,
13+
type DiskState,
14+
type InstanceState,
15+
type SnapshotState,
16+
} from '@oxide/api'
1117

12-
const INSTANCE_COLORS: Record<InstanceState, Pick<BadgeProps, 'color' | 'variant'>> = {
13-
creating: { color: 'purple', variant: 'solid' },
14-
starting: { color: 'blue', variant: 'solid' },
15-
running: { color: 'default' },
16-
rebooting: { color: 'notice' },
17-
stopping: { color: 'notice' },
18-
stopped: { color: 'neutral', variant: 'solid' },
19-
repairing: { color: 'notice', variant: 'solid' },
20-
migrating: { color: 'notice', variant: 'solid' },
21-
failed: { color: 'destructive', variant: 'solid' },
22-
destroyed: { color: 'neutral', variant: 'solid' },
18+
import { Badge, type BadgeColor } from '~/ui/lib/Badge'
19+
import { Spinner } from '~/ui/lib/Spinner'
20+
21+
const INSTANCE_COLORS: Record<InstanceState, BadgeColor> = {
22+
running: 'default',
23+
stopped: 'neutral',
24+
failed: 'destructive',
25+
destroyed: 'destructive',
26+
creating: 'default',
27+
starting: 'blue',
28+
rebooting: 'blue',
29+
migrating: 'purple',
30+
repairing: 'notice',
31+
stopping: 'neutral',
2332
}
2433

34+
const badgeClasses = 'children:flex children:items-center children:gap-1'
35+
2536
export const InstanceStateBadge = (props: { state: InstanceState; className?: string }) => (
26-
<Badge {...INSTANCE_COLORS[props.state]} className={props.className}>
37+
<Badge color={INSTANCE_COLORS[props.state]} className={cn(props.className, badgeClasses)}>
38+
{instanceTransitioning(props.state) && (
39+
<Spinner size="sm" variant={INSTANCE_COLORS[props.state]} />
40+
)}
2741
{props.state}
2842
</Badge>
2943
)
3044

3145
type DiskStateStr = DiskState['state']
3246

33-
const DISK_COLORS: Record<DiskStateStr, Pick<BadgeProps, 'color' | 'variant'>> = {
34-
attached: { color: 'default' },
35-
attaching: { color: 'blue', variant: 'solid' },
36-
creating: { color: 'purple', variant: 'solid' },
37-
detaching: { color: 'notice', variant: 'solid' },
38-
detached: { color: 'neutral', variant: 'solid' },
39-
destroyed: { color: 'destructive', variant: 'solid' }, // should we ever see this?
40-
faulted: { color: 'destructive', variant: 'solid' },
41-
maintenance: { color: 'notice', variant: 'solid' },
42-
import_ready: { color: 'blue', variant: 'solid' },
43-
importing_from_url: { color: 'purple', variant: 'solid' },
44-
importing_from_bulk_writes: { color: 'purple', variant: 'solid' },
45-
finalizing: { color: 'blue', variant: 'solid' },
47+
const DISK_COLORS: Record<DiskStateStr, BadgeColor> = {
48+
attached: 'default',
49+
attaching: 'blue',
50+
creating: 'default',
51+
detaching: 'blue',
52+
detached: 'neutral',
53+
destroyed: 'destructive', // should we ever see this?
54+
faulted: 'destructive',
55+
maintenance: 'notice',
56+
import_ready: 'blue',
57+
importing_from_url: 'purple',
58+
importing_from_bulk_writes: 'purple',
59+
finalizing: 'blue',
4660
}
4761

4862
export const DiskStateBadge = (props: { state: DiskStateStr; className?: string }) => (
49-
<Badge {...DISK_COLORS[props.state]} className={props.className}>
50-
{props.state}
63+
<Badge color={DISK_COLORS[props.state]} className={cn(props.className, badgeClasses)}>
64+
{diskTransitioning(props.state) && (
65+
<Spinner size="sm" variant={DISK_COLORS[props.state]} />
66+
)}
67+
{props.state.replace(/_/g, ' ')}
5168
</Badge>
5269
)
5370

5471
const SNAPSHOT_COLORS: Record<SnapshotState, BadgeColor> = {
55-
creating: 'notice',
72+
creating: 'default',
5673
destroyed: 'neutral',
5774
faulted: 'destructive',
5875
ready: 'default',
5976
}
6077

6178
export const SnapshotStateBadge = (props: { state: SnapshotState; className?: string }) => (
62-
<Badge color={SNAPSHOT_COLORS[props.state]} className={props.className}>
79+
<Badge color={SNAPSHOT_COLORS[props.state]} className={cn(props.className, badgeClasses)}>
80+
{props.state === 'creating' && (
81+
<Spinner size="sm" variant={SNAPSHOT_COLORS[props.state]} />
82+
)}
6383
{props.state}
6484
</Badge>
6585
)

app/components/TimeAgo.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const TimeAgo = ({
2828
)
2929
return (
3030
<Tooltip content={content} placement={placement}>
31-
<span className="text-sans-sm text-secondary">{timeAgoAbbr(datetime)}</span>
31+
<span className="min-w-6 text-sans-sm text-secondary">{timeAgoAbbr(datetime)}</span>
3232
</Tooltip>
3333
)
3434
}

app/pages/project/instances/InstancePage.tsx

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,6 @@ import { Message } from '~/ui/lib/Message'
5050
import { Modal } from '~/ui/lib/Modal'
5151
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
5252
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
53-
import { Spinner } from '~/ui/lib/Spinner'
54-
import { Tooltip } from '~/ui/lib/Tooltip'
5553
import { truncate } from '~/ui/lib/Truncate'
5654
import { instanceMetricsBase, pb } from '~/util/path-builder'
5755
import { pluralize } from '~/util/str'
@@ -117,14 +115,6 @@ const sec = 1000 // ms, obviously
117115
const POLL_INTERVAL_FAST = 2 * sec
118116
const POLL_INTERVAL_SLOW = 30 * sec
119117

120-
const PollingSpinner = () => (
121-
<Tooltip content="Auto-refreshing while state changes" delay={150}>
122-
<button type="button">
123-
<Spinner className="ml-2" />
124-
</button>
125-
</Tooltip>
126-
)
127-
128118
export default function InstancePage() {
129119
const instanceSelector = useInstanceSelector()
130120
const [resizeInstance, setResizeInstance] = useState(false)
@@ -156,7 +146,7 @@ export default function InstancePage() {
156146
// polling on the list page.
157147
refetchInterval: ({ state: { data: instance } }) => {
158148
if (!instance) return false
159-
if (instanceTransitioning(instance)) return POLL_INTERVAL_FAST
149+
if (instanceTransitioning(instance.runState)) return POLL_INTERVAL_FAST
160150

161151
if (instance.runState === 'failed' && instance.autoRestartEnabled) {
162152
return instanceAutoRestartingSoon(instance)
@@ -243,7 +233,6 @@ export default function InstancePage() {
243233
<PropertiesTable.Row label="state">
244234
<div className="flex items-center gap-2">
245235
<InstanceStateBadge state={instance.runState} />
246-
{instanceTransitioning(instance) && <PollingSpinner />}
247236
<InstanceAutoRestartPopover instance={instance} />
248237
</div>
249238
</PropertiesTable.Row>

app/pages/project/instances/InstancesPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export default function InstancesPage() {
157157
const nextTransitioning = new Set(
158158
// Data will never actually be undefined because of the prefetch but whatever
159159
(data?.items || [])
160-
.filter(instanceTransitioning)
160+
.filter((instance) => instanceTransitioning(instance.runState))
161161
// These are strings of instance ID + current state. This is done because
162162
// of the case where an instance is stuck in starting (for example), polling
163163
// times out, and then you manually stop it. Without putting the state in the

app/ui/lib/Badge.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export const Badge = ({
5353
className={cn(
5454
'ox-badge',
5555
`variant-${variant}`,
56-
'inline-flex h-4 items-center whitespace-nowrap rounded-sm px-[3px] py-[1px] uppercase text-mono-sm',
56+
'inline-flex h-[18px] items-center whitespace-nowrap rounded px-1 uppercase text-mono-sm',
5757
badgeColors[variant][color],
5858
className
5959
)}

app/ui/lib/Button.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { Spinner } from '~/ui/lib/Spinner'
1313
import { Tooltip } from '~/ui/lib/Tooltip'
1414
import { Wrap } from '~/ui/util/wrap'
1515

16+
import { type BadgeColor } from './Badge'
17+
1618
export const buttonSizes = ['sm', 'icon', 'base'] as const
1719
export const variants = ['primary', 'secondary', 'ghost', 'danger'] as const
1820

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

31+
const variantToBadgeColorMap: Record<Variant, BadgeColor> = {
32+
primary: 'default',
33+
danger: 'destructive',
34+
secondary: 'neutral',
35+
ghost: 'neutral',
36+
}
37+
2938
type ButtonStyleProps = {
3039
size?: ButtonSize
3140
variant?: Variant
@@ -115,9 +124,9 @@ export const Button = ({
115124
animate={{ opacity: 1, y: '-50%', x: '-50%' }}
116125
initial={{ opacity: 0, y: 'calc(-50% - 25px)', x: '-50%' }}
117126
transition={{ type: 'spring', duration: 0.3, bounce: 0 }}
118-
className="absolute left-1/2 top-1/2"
127+
className="absolute left-1/2 top-1/2 flex items-center justify-center"
119128
>
120-
<Spinner variant={variant} />
129+
<Spinner variant={variantToBadgeColorMap[variant || 'primary']} />
121130
</m.span>
122131
)}
123132
<m.span

app/ui/lib/Spinner.tsx

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,24 @@
88
import cn from 'classnames'
99
import { useEffect, useRef, useState, type ReactNode } from 'react'
1010

11-
export const spinnerSizes = ['base', 'md', 'lg'] as const
12-
export const spinnerVariants = ['primary', 'secondary', 'ghost', 'danger'] as const
11+
import { type BadgeColor } from './Badge'
12+
13+
export const spinnerSizes = ['sm', 'base', 'md', 'lg'] as const
1314
export type SpinnerSize = (typeof spinnerSizes)[number]
14-
export type SpinnerVariant = (typeof spinnerVariants)[number]
1515

1616
interface SpinnerProps {
1717
className?: string
1818
size?: SpinnerSize
19-
variant?: SpinnerVariant
19+
variant?: BadgeColor
2020
}
2121

2222
const SPINNER_DIMENSIONS = {
23+
sm: {
24+
frameSize: 10,
25+
center: 5,
26+
radius: 4,
27+
strokeWidth: 1.5,
28+
},
2329
base: {
2430
frameSize: 12,
2531
center: 6,
@@ -40,10 +46,19 @@ const SPINNER_DIMENSIONS = {
4046
},
4147
} as const
4248

49+
const SPINNER_COLORS: Record<BadgeColor, string> = {
50+
default: 'text-accent-secondary',
51+
neutral: 'text-secondary',
52+
destructive: 'text-destructive-secondary',
53+
notice: 'text-notice-secondary',
54+
purple: 'text-[--base-purple-700]',
55+
blue: 'text-[--base-blue-700]',
56+
}
57+
4358
export const Spinner = ({
4459
className,
4560
size = 'base',
46-
variant = 'primary',
61+
variant = 'default',
4762
}: SpinnerProps) => {
4863
const dimensions = SPINNER_DIMENSIONS[size]
4964
const { frameSize, center, radius, strokeWidth } = dimensions
@@ -56,7 +71,7 @@ export const Spinner = ({
5671
fill="none"
5772
xmlns="http://www.w3.org/2000/svg"
5873
aria-label="Spinner"
59-
className={cn('spinner', `spinner-${variant}`, `spinner-${size}`, className)}
74+
className={cn('spinner', SPINNER_COLORS[variant], `spinner-${size}`, className)}
6075
>
6176
<circle
6277
fill="none"

app/ui/styles/components/spinner.css

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@
1313
animation: rotate 5s linear infinite;
1414
}
1515

16+
.spinner .path,
17+
.spinner .bg {
18+
stroke: currentColor;
19+
}
20+
21+
.spinner.spinner-sm {
22+
--radius: 5;
23+
--circumference: calc(var(--PI) * var(--radius) * 1.5px);
24+
}
25+
1626
.spinner.spinner-md {
1727
--radius: 8;
1828
--circumference: calc(var(--PI) * var(--radius) * 2px);
@@ -27,7 +37,6 @@
2737
stroke-dasharray: var(--circumference);
2838
transform-origin: center;
2939
animation: dash 8s ease-in-out infinite;
30-
stroke: var(--content-accent-tertiary);
3140
}
3241

3342
@media (prefers-reduced-motion) {
@@ -50,24 +59,6 @@
5059
}
5160
}
5261

53-
.spinner-ghost .bg,
54-
.spinner-secondary .bg {
55-
stroke: var(--content-default);
56-
}
57-
58-
.spinner-secondary .path {
59-
stroke: var(--content-secondary);
60-
}
61-
62-
.spinner-primary .bg {
63-
stroke: var(--content-accent);
64-
}
65-
66-
.spinner-danger .bg,
67-
.spinner-danger .path {
68-
stroke: var(--content-destructive-tertiary);
69-
}
70-
7162
@keyframes rotate {
7263
100% {
7364
transform: rotate(360deg);

0 commit comments

Comments
 (0)