@spunto/design-system 0.7.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -0
- package/package.json +6 -1
- package/src/components/image-pull.tsx +642 -0
- package/src/components/workers/creator-avatar.tsx +38 -0
- package/src/components/workers/git-branch-chips.tsx +120 -0
- package/src/components/workers/index.ts +35 -0
- package/src/components/workers/resource-bars.tsx +100 -0
- package/src/components/workers/setup-progress.tsx +130 -0
- package/src/components/workers/tag-chips.tsx +132 -0
- package/src/components/workers/tag-color.ts +28 -0
- package/src/components/workers/types.ts +44 -0
- package/src/components/workers/worker-card.tsx +305 -0
- package/src/components/workers/worker-status.tsx +181 -0
- package/src/index.ts +10 -1
- package/src/utils.ts +23 -0
- package/styles.css +20 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
import {
|
|
5
|
+
AlertTriangleIcon,
|
|
6
|
+
ChevronRightIcon,
|
|
7
|
+
ClockIcon,
|
|
8
|
+
ServerIcon,
|
|
9
|
+
TimerIcon,
|
|
10
|
+
WifiOffIcon,
|
|
11
|
+
XIcon,
|
|
12
|
+
} from "lucide-react"
|
|
13
|
+
|
|
14
|
+
import { cn, formatDuration, formatRelativeTime } from "../../utils"
|
|
15
|
+
import { buttonVariants } from "../button-variants"
|
|
16
|
+
import { CreatorAvatar } from "./creator-avatar"
|
|
17
|
+
import { GitBranchChips, type GitRepoStatus } from "./git-branch-chips"
|
|
18
|
+
import { ResourceBars, type WorkerStats } from "./resource-bars"
|
|
19
|
+
import { SetupProgress, setupTotalMs } from "./setup-progress"
|
|
20
|
+
import { TagChips } from "./tag-chips"
|
|
21
|
+
import type { WorkerCardWorker, WorkerCreator } from "./types"
|
|
22
|
+
import { WorkerStatusDot, WorkerStatusPill, resolveWorkerStatus } from "./worker-status"
|
|
23
|
+
|
|
24
|
+
/** Renders the "View" affordance. A slot, so the package never imports `next/link`. */
|
|
25
|
+
export type LinkRender = (props: { href: string; className?: string; children: ReactNode }) => ReactNode
|
|
26
|
+
|
|
27
|
+
export interface WorkerCardProps {
|
|
28
|
+
worker: WorkerCardWorker
|
|
29
|
+
|
|
30
|
+
/** Target of the title + footer button. Omitted → the card isn't a link. */
|
|
31
|
+
href?: string
|
|
32
|
+
/**
|
|
33
|
+
* Escape hatches for app-specific rendering. `link` receives `{ href, className,
|
|
34
|
+
* children }` and must return an anchor-ish node (`next/link`, a router link…);
|
|
35
|
+
* without it the card falls back to a plain `<a>`.
|
|
36
|
+
*/
|
|
37
|
+
render?: { link?: LinkRender }
|
|
38
|
+
|
|
39
|
+
creator?: WorkerCreator
|
|
40
|
+
nodeName?: string
|
|
41
|
+
/** `false` draws the "Node offline" banner. `undefined` = unknown, no banner. */
|
|
42
|
+
nodeOnline?: boolean
|
|
43
|
+
/** Live container stats. Absent → no resource bars (the usual case for a stopped worker). */
|
|
44
|
+
stats?: WorkerStats | null
|
|
45
|
+
/** Checked-out repositories. Absent/empty → no chips. */
|
|
46
|
+
gitStatus?: GitRepoStatus[] | null
|
|
47
|
+
/** Chip → URL (e.g. code-server at that folder). Returning undefined renders a static chip. */
|
|
48
|
+
repoHref?: (repo: GitRepoStatus) => string | undefined
|
|
49
|
+
|
|
50
|
+
/** Tags are read-only unless at least one of these is given. */
|
|
51
|
+
onAddTag?: (tag: string) => void
|
|
52
|
+
onRemoveTag?: (tag: string) => void
|
|
53
|
+
|
|
54
|
+
/** Project's current config version — a worker below it gets the "Rebuild" banner. */
|
|
55
|
+
currentProjectVersion?: number
|
|
56
|
+
onRebuild?: () => void
|
|
57
|
+
rebuilding?: boolean
|
|
58
|
+
|
|
59
|
+
/** Error banner inside the card (a failed mutation, typically). */
|
|
60
|
+
error?: string | null
|
|
61
|
+
onDismissError?: () => void
|
|
62
|
+
|
|
63
|
+
/** The `⋯` menu, top-right. A slot: every entry in it calls the API. */
|
|
64
|
+
actions?: ReactNode
|
|
65
|
+
/** Replaces the default "View" footer entirely (extra buttons, a VS Code link…). */
|
|
66
|
+
footer?: ReactNode
|
|
67
|
+
/** Label of the default footer button. */
|
|
68
|
+
viewLabel?: string
|
|
69
|
+
|
|
70
|
+
className?: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One worker, as a card: state, who spawned it, where it runs, what it's working
|
|
75
|
+
* on, and what you can do with it.
|
|
76
|
+
*
|
|
77
|
+
* **Purely presentational.** It receives a snapshot and draws it — no query, no
|
|
78
|
+
* mutation, no router. Everything that would talk to an API is a slot
|
|
79
|
+
* (`actions`, `footer`) or a callback (`onAddTag`, `onRebuild`…). That rule is
|
|
80
|
+
* what makes the same card renderable by the dashboard, by Spunto Lite, and by
|
|
81
|
+
* the showcase with hand-written fixtures. The card is the assembly; each brick
|
|
82
|
+
* (`WorkerStatusDot`, `SetupProgress`, `ResourceBars`, `GitBranchChips`,
|
|
83
|
+
* `TagChips`) is exported on its own because the table view needs them without
|
|
84
|
+
* the card.
|
|
85
|
+
*
|
|
86
|
+
* The layout is a fixed sequence of optional bands — header, setup bar, git
|
|
87
|
+
* chips, resource bars, banners, footer — and each band disappears when its data
|
|
88
|
+
* is absent, so a stopped worker collapses to a two-line card without any
|
|
89
|
+
* caller-side conditional.
|
|
90
|
+
*/
|
|
91
|
+
export function WorkerCard({
|
|
92
|
+
worker,
|
|
93
|
+
href,
|
|
94
|
+
render,
|
|
95
|
+
creator,
|
|
96
|
+
nodeName,
|
|
97
|
+
nodeOnline,
|
|
98
|
+
stats,
|
|
99
|
+
gitStatus,
|
|
100
|
+
repoHref,
|
|
101
|
+
onAddTag,
|
|
102
|
+
onRemoveTag,
|
|
103
|
+
currentProjectVersion,
|
|
104
|
+
onRebuild,
|
|
105
|
+
rebuilding = false,
|
|
106
|
+
error,
|
|
107
|
+
onDismissError,
|
|
108
|
+
actions,
|
|
109
|
+
footer,
|
|
110
|
+
viewLabel = "View",
|
|
111
|
+
className,
|
|
112
|
+
}: WorkerCardProps) {
|
|
113
|
+
const status = resolveWorkerStatus(worker)
|
|
114
|
+
const setup = worker.setupStatus ?? null
|
|
115
|
+
const startupMs = setupTotalMs(setup)
|
|
116
|
+
const title = worker.name || (worker.index != null ? `Workspace #${worker.index}` : worker.id)
|
|
117
|
+
|
|
118
|
+
// Live detail is only meaningful once the container is up *and* done setting
|
|
119
|
+
// up: mid-setup a stale "running" from docker would show stats for a worker
|
|
120
|
+
// that isn't usable yet.
|
|
121
|
+
const settled = status.running && !status.settingUp
|
|
122
|
+
const repos = settled ? (gitStatus ?? []) : []
|
|
123
|
+
const outdated =
|
|
124
|
+
!status.settingUp &&
|
|
125
|
+
worker.projectVersion != null &&
|
|
126
|
+
currentProjectVersion != null &&
|
|
127
|
+
worker.projectVersion < currentProjectVersion
|
|
128
|
+
|
|
129
|
+
const Link: LinkRender =
|
|
130
|
+
render?.link ?? (({ href: to, className: cls, children }) => <a href={to} className={cls}>{children}</a>)
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div
|
|
134
|
+
data-slot="worker-card"
|
|
135
|
+
className={cn("overflow-hidden rounded-xl border border-border bg-card transition-colors hover:shadow-sm", className)}
|
|
136
|
+
>
|
|
137
|
+
{/* ── Header : état, nom, pills, tags, méta ── */}
|
|
138
|
+
<div className="flex items-start justify-between gap-3 px-4 pt-3.5 pb-3">
|
|
139
|
+
<div className="flex min-w-0 items-start gap-3">
|
|
140
|
+
<WorkerStatusDot status={status} className="mt-1" />
|
|
141
|
+
|
|
142
|
+
<div className="min-w-0 flex-1">
|
|
143
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
144
|
+
<span className="text-sm leading-none font-semibold">{title}</span>
|
|
145
|
+
<WorkerStatusPill status={status} />
|
|
146
|
+
{worker.projectVersion != null && (
|
|
147
|
+
<span className="inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5 font-mono text-[10px] leading-none text-muted-foreground">
|
|
148
|
+
v{worker.projectVersion}
|
|
149
|
+
</span>
|
|
150
|
+
)}
|
|
151
|
+
<TagChips tags={worker.tags ?? []} onAdd={onAddTag} onRemove={onRemoveTag} />
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
|
155
|
+
{creator && (
|
|
156
|
+
<span className="flex items-center gap-1.5">
|
|
157
|
+
<CreatorAvatar creator={creator} />
|
|
158
|
+
<span className="text-[11px] text-muted-foreground">{creator.name}</span>
|
|
159
|
+
</span>
|
|
160
|
+
)}
|
|
161
|
+
{nodeName && (
|
|
162
|
+
<>
|
|
163
|
+
<Dot />
|
|
164
|
+
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/60">
|
|
165
|
+
<ServerIcon className="h-3 w-3" />
|
|
166
|
+
{nodeName}
|
|
167
|
+
</span>
|
|
168
|
+
</>
|
|
169
|
+
)}
|
|
170
|
+
{worker.createdAt && (
|
|
171
|
+
<>
|
|
172
|
+
{creator || nodeName ? <Dot /> : null}
|
|
173
|
+
<span
|
|
174
|
+
className="flex items-center gap-1 text-[11px] text-muted-foreground/60"
|
|
175
|
+
title={new Date(worker.createdAt).toLocaleString()}
|
|
176
|
+
>
|
|
177
|
+
<ClockIcon className="h-3 w-3" />
|
|
178
|
+
{formatRelativeTime(worker.createdAt)}
|
|
179
|
+
</span>
|
|
180
|
+
</>
|
|
181
|
+
)}
|
|
182
|
+
{/* Only worth showing once it's over — during setup the live bar says it better. */}
|
|
183
|
+
{!status.settingUp && startupMs != null && (
|
|
184
|
+
<>
|
|
185
|
+
<Dot />
|
|
186
|
+
<span
|
|
187
|
+
className="flex items-center gap-1 text-[11px] text-muted-foreground/60"
|
|
188
|
+
title="Worker startup time (clone, features, postCreate…)"
|
|
189
|
+
>
|
|
190
|
+
<TimerIcon className="h-3 w-3" />
|
|
191
|
+
started in {formatDuration(startupMs)}
|
|
192
|
+
</span>
|
|
193
|
+
</>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
{actions}
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
{status.settingUp && (
|
|
203
|
+
<div className="px-4 pb-3">
|
|
204
|
+
<SetupProgress status={setup} />
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
{repos.length > 0 && (
|
|
209
|
+
<div className="px-4 pb-3">
|
|
210
|
+
<GitBranchChips repos={repos} repoHref={repoHref} />
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
{settled && stats && (
|
|
215
|
+
<div className="px-4 pb-3">
|
|
216
|
+
<ResourceBars stats={stats} />
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
|
|
220
|
+
{nodeOnline === false && (
|
|
221
|
+
<Banner tone="offline">
|
|
222
|
+
<WifiOffIcon className="h-3.5 w-3.5 shrink-0" />
|
|
223
|
+
Node offline
|
|
224
|
+
</Banner>
|
|
225
|
+
)}
|
|
226
|
+
|
|
227
|
+
{outdated && (
|
|
228
|
+
<Banner tone="warn">
|
|
229
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
230
|
+
<AlertTriangleIcon className="h-3.5 w-3.5 shrink-0" />
|
|
231
|
+
Config <span className="font-mono font-semibold">v{worker.projectVersion}</span> → project at{" "}
|
|
232
|
+
<span className="font-mono font-semibold">v{currentProjectVersion}</span>
|
|
233
|
+
</span>
|
|
234
|
+
{onRebuild && (
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
disabled={rebuilding}
|
|
238
|
+
onClick={onRebuild}
|
|
239
|
+
className="shrink-0 cursor-pointer text-[11px] font-medium hover:underline disabled:opacity-50"
|
|
240
|
+
>
|
|
241
|
+
{rebuilding ? "Rebuilding…" : "Rebuild"}
|
|
242
|
+
</button>
|
|
243
|
+
)}
|
|
244
|
+
</Banner>
|
|
245
|
+
)}
|
|
246
|
+
|
|
247
|
+
{error && (
|
|
248
|
+
<Banner tone="error">
|
|
249
|
+
<span className="truncate font-mono">{error}</span>
|
|
250
|
+
{onDismissError && (
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
onClick={onDismissError}
|
|
254
|
+
aria-label="Dismiss error"
|
|
255
|
+
className="shrink-0 cursor-pointer opacity-60 transition-opacity hover:opacity-100"
|
|
256
|
+
>
|
|
257
|
+
<XIcon className="h-3.5 w-3.5" />
|
|
258
|
+
</button>
|
|
259
|
+
)}
|
|
260
|
+
</Banner>
|
|
261
|
+
)}
|
|
262
|
+
|
|
263
|
+
{(footer ?? href) && (
|
|
264
|
+
<div className="border-t border-border/50 bg-muted/20 px-3 py-2">
|
|
265
|
+
{footer ?? (
|
|
266
|
+
<Link href={href!} className={cn(buttonVariants({ variant: "default", size: "sm" }), "h-7 w-full gap-1.5 text-xs")}>
|
|
267
|
+
{viewLabel}
|
|
268
|
+
<ChevronRightIcon className="h-3.5 w-3.5" />
|
|
269
|
+
</Link>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** The faint separator between two meta items. */
|
|
278
|
+
function Dot() {
|
|
279
|
+
return <span className="text-[11px] text-muted-foreground/30">·</span>
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The card's inline bands. Warm tones = something to know (an unreachable node,
|
|
284
|
+
* a stale config), red = something went wrong. They keep the card's horizontal
|
|
285
|
+
* margin so they read as *inside* the card, not as a strip glued across it.
|
|
286
|
+
*/
|
|
287
|
+
const BANNER_TONES = {
|
|
288
|
+
/** Infrastructure problem — orange, one notch louder than a stale config. */
|
|
289
|
+
offline: "border-orange-500/25 bg-orange-500/5 text-orange-600 dark:text-orange-400",
|
|
290
|
+
warn: "border-amber-500/25 bg-amber-500/5 text-amber-600 dark:text-amber-400",
|
|
291
|
+
error: "border-destructive/25 bg-destructive/5 text-destructive",
|
|
292
|
+
} as const
|
|
293
|
+
|
|
294
|
+
function Banner({ tone, children }: { tone: keyof typeof BANNER_TONES; children: ReactNode }) {
|
|
295
|
+
return (
|
|
296
|
+
<div
|
|
297
|
+
className={cn(
|
|
298
|
+
"mx-4 mb-3 flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-xs",
|
|
299
|
+
BANNER_TONES[tone]
|
|
300
|
+
)}
|
|
301
|
+
>
|
|
302
|
+
{children}
|
|
303
|
+
</div>
|
|
304
|
+
)
|
|
305
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// The single state → colours/label table both worker views read from.
|
|
2
|
+
//
|
|
3
|
+
// It used to live twice — `STATUS_CONFIG` in the dashboard and its hand-copied
|
|
4
|
+
// twin in Spunto Lite — and the two had already drifted (different labels for
|
|
5
|
+
// the same docker state, transient states handled in one and not the other).
|
|
6
|
+
// This module is the union of both, plus the transient states (`pulling`,
|
|
7
|
+
// `stopping`, `deleting`) that only ever existed as ad-hoc `if`s above the table.
|
|
8
|
+
//
|
|
9
|
+
// No "use client": pure functions and hookless components, so a React Server
|
|
10
|
+
// Component can call them.
|
|
11
|
+
|
|
12
|
+
import { cn } from "../../utils"
|
|
13
|
+
import type { WorkerCardWorker } from "./types"
|
|
14
|
+
|
|
15
|
+
export type WorkerStatusKey =
|
|
16
|
+
| "running"
|
|
17
|
+
| "stopped"
|
|
18
|
+
| "exited"
|
|
19
|
+
| "created"
|
|
20
|
+
| "not_found"
|
|
21
|
+
| "error"
|
|
22
|
+
| "pending"
|
|
23
|
+
| "setup"
|
|
24
|
+
| "pulling"
|
|
25
|
+
| "stopping"
|
|
26
|
+
| "deleting"
|
|
27
|
+
|
|
28
|
+
export interface WorkerStatusConfig {
|
|
29
|
+
label: string
|
|
30
|
+
/** Class for the 8 px dot. */
|
|
31
|
+
dotClass: string
|
|
32
|
+
/** Classes for the rounded state pill (background + text + border). */
|
|
33
|
+
pillClass: string
|
|
34
|
+
/** Left border accent, for surfaces that stripe the card by state. */
|
|
35
|
+
accent: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Status semantics follow docs/design-system.md: green = running/healthy,
|
|
39
|
+
// amber/yellow = in progress or warning, red = failure, neutral = idle. Errors
|
|
40
|
+
// go through the `destructive` token rather than a raw red so they track the
|
|
41
|
+
// theme; green/yellow have no token, so the Tailwind palette stays.
|
|
42
|
+
const RUNNING: WorkerStatusConfig = {
|
|
43
|
+
label: "Running",
|
|
44
|
+
dotClass: "bg-green-500",
|
|
45
|
+
pillClass: "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20",
|
|
46
|
+
accent: "border-l-green-500",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const IDLE: WorkerStatusConfig = {
|
|
50
|
+
label: "Stopped",
|
|
51
|
+
dotClass: "bg-muted-foreground/50",
|
|
52
|
+
pillClass: "bg-muted text-muted-foreground border-border",
|
|
53
|
+
accent: "border-l-border",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const BUSY: WorkerStatusConfig = {
|
|
57
|
+
label: "Starting…",
|
|
58
|
+
dotClass: "bg-yellow-400",
|
|
59
|
+
pillClass: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/20",
|
|
60
|
+
accent: "border-l-yellow-400",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const workerStatusConfig: Record<WorkerStatusKey, WorkerStatusConfig> = {
|
|
64
|
+
running: RUNNING,
|
|
65
|
+
stopped: IDLE,
|
|
66
|
+
exited: { ...IDLE, label: "Exited" },
|
|
67
|
+
created: { ...BUSY, label: "Created" },
|
|
68
|
+
not_found: {
|
|
69
|
+
label: "Not found",
|
|
70
|
+
dotClass: "bg-orange-400",
|
|
71
|
+
pillClass: "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20",
|
|
72
|
+
accent: "border-l-orange-400",
|
|
73
|
+
},
|
|
74
|
+
error: {
|
|
75
|
+
label: "Error",
|
|
76
|
+
dotClass: "bg-destructive",
|
|
77
|
+
pillClass: "bg-destructive/10 text-destructive border-destructive/20",
|
|
78
|
+
accent: "border-l-destructive",
|
|
79
|
+
},
|
|
80
|
+
pending: BUSY,
|
|
81
|
+
setup: { ...BUSY, label: "Setting up…" },
|
|
82
|
+
pulling: { ...BUSY, label: "Pulling image…" },
|
|
83
|
+
stopping: { ...BUSY, label: "Stopping…" },
|
|
84
|
+
deleting: { ...BUSY, label: "Deleting…" },
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface WorkerStatus extends WorkerStatusConfig {
|
|
88
|
+
key: WorkerStatusKey
|
|
89
|
+
/** The worker is still being provisioned — the setup bar is the interesting part. */
|
|
90
|
+
settingUp: boolean
|
|
91
|
+
/** Container up and past its setup — stats, git chips and the pulsing dot apply. */
|
|
92
|
+
running: boolean
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Lifecycle states that mean "not usable yet, a setup is in flight". */
|
|
96
|
+
const SETUP_STATES = new Set(["provisioning", "starting", "setup"])
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Worker snapshot → one status, tolerant of shapes it has never seen.
|
|
100
|
+
*
|
|
101
|
+
* Order matters: the *lifecycle* state wins whenever it describes an action in
|
|
102
|
+
* flight (deleting, stopping, pulling, setting up), because docker still reports
|
|
103
|
+
* the container as `running` throughout — showing "Running" while a worker is
|
|
104
|
+
* being deleted is the bug this ordering exists to prevent. Past that, the
|
|
105
|
+
* docker state is the truth. Apps with a single `state` field (Spunto Lite) fall
|
|
106
|
+
* through to the last block, and anything unknown lands on `pending` rather than
|
|
107
|
+
* throwing.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveWorkerStatus(worker: WorkerCardWorker): WorkerStatus {
|
|
110
|
+
const state = worker.state ?? null
|
|
111
|
+
const docker = worker.dockerState ?? null
|
|
112
|
+
|
|
113
|
+
const of = (key: WorkerStatusKey, flags?: Partial<Pick<WorkerStatus, "settingUp" | "running">>): WorkerStatus => ({
|
|
114
|
+
key,
|
|
115
|
+
...workerStatusConfig[key],
|
|
116
|
+
settingUp: false,
|
|
117
|
+
running: key === "running",
|
|
118
|
+
...flags,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
if (state === "deleting") return of("deleting")
|
|
122
|
+
if (state === "stopping") return of("stopping")
|
|
123
|
+
if (state === "pulling") return of("pulling", { settingUp: true })
|
|
124
|
+
if (state && SETUP_STATES.has(state)) return of("setup", { settingUp: true })
|
|
125
|
+
|
|
126
|
+
if (docker && docker in workerStatusConfig) return of(docker as WorkerStatusKey)
|
|
127
|
+
if (docker) return of("pending")
|
|
128
|
+
|
|
129
|
+
// Single-`state` apps (Lite): no docker level to consult.
|
|
130
|
+
if (state === "ready") return of("running")
|
|
131
|
+
if (state === "error") return of("error")
|
|
132
|
+
if (state === "stopped") return of("stopped")
|
|
133
|
+
return of("pending")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The 8 px state dot. It pings while the worker runs — the one animation on the
|
|
138
|
+
* card, reserved for the one state where "it is alive right now" is the message.
|
|
139
|
+
*/
|
|
140
|
+
export function WorkerStatusDot({
|
|
141
|
+
status,
|
|
142
|
+
className,
|
|
143
|
+
}: {
|
|
144
|
+
status: WorkerStatus | WorkerStatusKey
|
|
145
|
+
className?: string
|
|
146
|
+
}) {
|
|
147
|
+
const cfg = typeof status === "string" ? { ...workerStatusConfig[status], running: status === "running" } : status
|
|
148
|
+
return (
|
|
149
|
+
<span className={cn("relative flex h-2 w-2 shrink-0", className)}>
|
|
150
|
+
<span className={cn("h-2 w-2 rounded-full", cfg.dotClass)} />
|
|
151
|
+
{cfg.running && (
|
|
152
|
+
<span className="absolute inset-0 h-2 w-2 animate-ping rounded-full bg-green-500 opacity-50 motion-reduce:animate-none" />
|
|
153
|
+
)}
|
|
154
|
+
</span>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The state chip that sits next to the worker name. */
|
|
159
|
+
export function WorkerStatusPill({
|
|
160
|
+
status,
|
|
161
|
+
label,
|
|
162
|
+
className,
|
|
163
|
+
}: {
|
|
164
|
+
status: WorkerStatus | WorkerStatusKey
|
|
165
|
+
/** Overrides the table's label — e.g. a live setup phase instead of "Setting up…". */
|
|
166
|
+
label?: string
|
|
167
|
+
className?: string
|
|
168
|
+
}) {
|
|
169
|
+
const cfg = typeof status === "string" ? workerStatusConfig[status] : status
|
|
170
|
+
return (
|
|
171
|
+
<span
|
|
172
|
+
className={cn(
|
|
173
|
+
"inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium leading-none",
|
|
174
|
+
cfg.pillClass,
|
|
175
|
+
className
|
|
176
|
+
)}
|
|
177
|
+
>
|
|
178
|
+
{label ?? cfg.label}
|
|
179
|
+
</span>
|
|
180
|
+
)
|
|
181
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
// @spunto/design-system — shared UI primitives, helpers, and color constants.
|
|
2
2
|
// Tokens ship as a CSS file: `import "@spunto/design-system/styles.css"`.
|
|
3
3
|
|
|
4
|
-
export { cn } from "./utils"
|
|
4
|
+
export { cn, formatDuration, formatRelativeTime } from "./utils"
|
|
5
5
|
export * from "./colors"
|
|
6
6
|
|
|
7
|
+
// Domain (Spunto-specific) components live behind their own entry point —
|
|
8
|
+
// `@spunto/design-system/workers` — so this root stays domain-free primitives.
|
|
9
|
+
// See README § "Domain components".
|
|
10
|
+
|
|
7
11
|
export { Button } from "./components/button"
|
|
8
12
|
// `buttonVariants` is exported from its own directive-free module (not through the
|
|
9
13
|
// "use client" button.tsx) so it stays callable from React Server Components.
|
|
@@ -26,6 +30,11 @@ export { Avatar, AvatarImage, AvatarFallback } from "./components/avatar"
|
|
|
26
30
|
export { Terminal } from "./components/terminal"
|
|
27
31
|
export type { TerminalHandle, TerminalProps, TerminalOptions } from "./components/terminal"
|
|
28
32
|
|
|
33
|
+
// Docker image pull as a real UI (one segmented bar, one block per layer) —
|
|
34
|
+
// presentational only: give it a snapshot, it draws it.
|
|
35
|
+
export { ImagePull } from "./components/image-pull"
|
|
36
|
+
export type { ImagePullProps, ImagePullState, PullLayer, PullLayerStatus } from "./components/image-pull"
|
|
37
|
+
|
|
29
38
|
// Inline, declarative status banner (persistent) — the counterpart to `toast()`.
|
|
30
39
|
// `alertVariants` ships from this directive-free module so it stays RSC-callable.
|
|
31
40
|
export { Alert, AlertTitle, AlertDescription, alertVariants } from "./components/alert"
|
package/src/utils.ts
CHANGED
|
@@ -5,3 +5,26 @@ import { twMerge } from "tailwind-merge"
|
|
|
5
5
|
export function cn(...inputs: ClassValue[]) {
|
|
6
6
|
return twMerge(clsx(inputs))
|
|
7
7
|
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Coarse "3h ago" stamp — minutes, then hours, then days. Deliberately not a
|
|
11
|
+
* live-ticking relative time: every surface using it (worker cards, tables)
|
|
12
|
+
* already re-renders on its own polling cadence, and a second timer per row
|
|
13
|
+
* would cost more than the precision is worth.
|
|
14
|
+
*/
|
|
15
|
+
export function formatRelativeTime(date: string | number | Date): string {
|
|
16
|
+
const ms = date instanceof Date ? date.getTime() : new Date(date).getTime()
|
|
17
|
+
if (!Number.isFinite(ms)) return ""
|
|
18
|
+
const minutes = Math.floor((Date.now() - ms) / 60_000)
|
|
19
|
+
if (minutes < 1) return "just now"
|
|
20
|
+
if (minutes < 60) return `${minutes}m ago`
|
|
21
|
+
const hours = Math.floor(minutes / 60)
|
|
22
|
+
if (hours < 24) return `${hours}h ago`
|
|
23
|
+
return `${Math.floor(hours / 24)}d ago`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Short duration, one significant decimal under 10 s ("840ms", "4.2s", "38s"). */
|
|
27
|
+
export function formatDuration(ms: number): string {
|
|
28
|
+
if (ms >= 1000) return `${(ms / 1000).toFixed(ms >= 10_000 ? 0 : 1)}s`
|
|
29
|
+
return `${Math.max(0, Math.round(ms))}ms`
|
|
30
|
+
}
|
package/styles.css
CHANGED
|
@@ -493,3 +493,23 @@
|
|
|
493
493
|
@media (prefers-reduced-motion: reduce) {
|
|
494
494
|
[data-slot="toast"] { transition-duration: 0.01ms; }
|
|
495
495
|
}
|
|
496
|
+
|
|
497
|
+
/* ---------------------------------------------------------------------------
|
|
498
|
+
ImagePull — indeterminate sweep.
|
|
499
|
+
Used by the `starting` state (registry contacted, no layer announced yet) and
|
|
500
|
+
by any layer still moving without a known size: there's no honest fraction to
|
|
501
|
+
fill, so the surface sweeps instead of inventing one.
|
|
502
|
+
--------------------------------------------------------------------------- */
|
|
503
|
+
@keyframes spunto-pull-sweep {
|
|
504
|
+
from { transform: translateX(-100%); }
|
|
505
|
+
to { transform: translateX(400%); }
|
|
506
|
+
}
|
|
507
|
+
[data-slot="image-pull-sweep"] {
|
|
508
|
+
animation: spunto-pull-sweep 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
@media (prefers-reduced-motion: reduce) {
|
|
512
|
+
/* No sweep, no gliding segment widths — the bar still reads, it just stops moving. */
|
|
513
|
+
[data-slot="image-pull-sweep"] { animation: none; opacity: 0.45; }
|
|
514
|
+
[data-slot="image-pull"] * { transition-duration: 0.01ms !important; }
|
|
515
|
+
}
|