@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,642 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from "react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "../utils"
|
|
6
|
+
|
|
7
|
+
// Same surface as `Terminal` (#09090b) and the same ANSI accent hues, so an
|
|
8
|
+
// ImagePull dropped right above a Terminal reads as *one* object — a build view
|
|
9
|
+
// becomes two stacked bricks rather than two unrelated widgets.
|
|
10
|
+
const pullTheme = {
|
|
11
|
+
background: "#09090b",
|
|
12
|
+
border: "#27272a",
|
|
13
|
+
/** Unfilled part of a segment / of a layer's thin bar. */
|
|
14
|
+
track: "#1f1f23",
|
|
15
|
+
text: "#e4e4e7",
|
|
16
|
+
dim: "#a1a1aa",
|
|
17
|
+
faint: "#71717a",
|
|
18
|
+
/** Tag of the image reference — the part that actually varies, so it's the bright one. */
|
|
19
|
+
tag: "#fdba74",
|
|
20
|
+
// Phase colors. Flame is the brand accent and downloading is the main event;
|
|
21
|
+
// yellow/green/red come straight from the Terminal's shared ANSI palette so
|
|
22
|
+
// both surfaces agree on what "in progress" / "done" / "failed" look like.
|
|
23
|
+
waiting: "#3f3f46",
|
|
24
|
+
downloading: "#ea5400",
|
|
25
|
+
verifying: "#fb923c",
|
|
26
|
+
extracting: "#facc15",
|
|
27
|
+
done: "#4ade80",
|
|
28
|
+
error: "#f87171",
|
|
29
|
+
} as const
|
|
30
|
+
|
|
31
|
+
export type PullLayerStatus = "waiting" | "downloading" | "verifying" | "extracting" | "done"
|
|
32
|
+
|
|
33
|
+
export interface PullLayer {
|
|
34
|
+
/** Docker layer id (full sha or short form) — the list shows the first 12 chars. */
|
|
35
|
+
id: string
|
|
36
|
+
status: PullLayerStatus
|
|
37
|
+
/** Bytes transferred (while downloading) or extracted (while extracting). */
|
|
38
|
+
current?: number
|
|
39
|
+
/** Layer size. Often unknown for the first moments of a layer's life — that's expected. */
|
|
40
|
+
total?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ImagePullState = "starting" | "pulling" | "done" | "cached" | "error"
|
|
44
|
+
|
|
45
|
+
export interface ImagePullProps {
|
|
46
|
+
/** Full image reference, e.g. `registry.spunto.net/spunto/worker-base:0.7.2`. */
|
|
47
|
+
image: string
|
|
48
|
+
layers: PullLayer[]
|
|
49
|
+
state?: ImagePullState
|
|
50
|
+
/** Failure message, shown in the red band when `state="error"`. */
|
|
51
|
+
error?: string
|
|
52
|
+
/** Epoch ms — drives the elapsed clock and the final "Pullée en 42 s". */
|
|
53
|
+
startedAt?: number
|
|
54
|
+
defaultExpanded?: boolean
|
|
55
|
+
className?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const STATUS_LABEL: Record<PullLayerStatus, string> = {
|
|
59
|
+
waiting: "en attente",
|
|
60
|
+
downloading: "téléchargement",
|
|
61
|
+
verifying: "vérification",
|
|
62
|
+
extracting: "extraction",
|
|
63
|
+
done: "terminé",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const STATUS_COLOR: Record<PullLayerStatus, string> = {
|
|
67
|
+
waiting: pullTheme.waiting,
|
|
68
|
+
downloading: pullTheme.downloading,
|
|
69
|
+
verifying: pullTheme.verifying,
|
|
70
|
+
extracting: pullTheme.extracting,
|
|
71
|
+
done: pullTheme.done,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const KIB = 1024
|
|
75
|
+
const MIB = 1024 * 1024
|
|
76
|
+
const GIB = 1024 * 1024 * 1024
|
|
77
|
+
|
|
78
|
+
/** French, base-1024 byte sizes ("148 Mo"). Fixed-width friendly: at most one decimal. */
|
|
79
|
+
function formatBytes(n: number): string {
|
|
80
|
+
if (!Number.isFinite(n) || n <= 0) return "0 o"
|
|
81
|
+
if (n < KIB) return `${Math.round(n)} o`
|
|
82
|
+
if (n < MIB) return `${Math.round(n / KIB)} Ko`
|
|
83
|
+
if (n < GIB) return `${(n / MIB).toFixed(n < 10 * MIB ? 1 : 0).replace(".", ",")} Mo`
|
|
84
|
+
return `${(n / GIB).toFixed(1).replace(".", ",")} Go`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function formatRate(bytesPerSecond: number): string {
|
|
88
|
+
return `${formatBytes(bytesPerSecond)}/s`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function formatDuration(ms: number): string {
|
|
92
|
+
const s = Math.max(0, Math.round(ms / 1000))
|
|
93
|
+
if (s < 60) return `${s} s`
|
|
94
|
+
const m = Math.floor(s / 60)
|
|
95
|
+
const rest = s % 60
|
|
96
|
+
return rest ? `${m} min ${rest} s` : `${m} min`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Splits an image reference into a *truncatable* head and a head-on tail, which
|
|
101
|
+
* is how the header gets middle-truncation with plain CSS: the head gets
|
|
102
|
+
* `truncate`, the tail never shrinks. The tag stays readable no matter how
|
|
103
|
+
* narrow the container gets — it's the part that actually tells you *what* is
|
|
104
|
+
* being pulled.
|
|
105
|
+
*/
|
|
106
|
+
function splitImageRef(image: string): { head: string; name: string; tag: string } {
|
|
107
|
+
const slash = image.lastIndexOf("/")
|
|
108
|
+
const head = slash >= 0 ? image.slice(0, slash + 1) : ""
|
|
109
|
+
const rest = slash >= 0 ? image.slice(slash + 1) : image
|
|
110
|
+
|
|
111
|
+
const at = rest.indexOf("@")
|
|
112
|
+
if (at >= 0) {
|
|
113
|
+
// Digest pin — keep it recognisable but don't let 71 chars of sha eat the row.
|
|
114
|
+
const digest = rest.slice(at)
|
|
115
|
+
return { head, name: rest.slice(0, at), tag: digest.length > 20 ? `${digest.slice(0, 20)}…` : digest }
|
|
116
|
+
}
|
|
117
|
+
const colon = rest.lastIndexOf(":")
|
|
118
|
+
if (colon > 0) return { head, name: rest.slice(0, colon), tag: rest.slice(colon) }
|
|
119
|
+
return { head, name: rest, tag: "" }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Bytes actually transferred for a layer: past the download, the transfer is complete. */
|
|
123
|
+
function downloadedBytes(layer: PullLayer): number {
|
|
124
|
+
if (layer.status === "waiting") return 0
|
|
125
|
+
if (layer.status === "downloading") {
|
|
126
|
+
const current = layer.current ?? 0
|
|
127
|
+
return layer.total ? Math.min(current, layer.total) : current
|
|
128
|
+
}
|
|
129
|
+
return layer.total ?? layer.current ?? 0
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Bytes unpacked for a layer — the second half of the work, after the transfer. */
|
|
133
|
+
function extractedBytes(layer: PullLayer): number {
|
|
134
|
+
if (layer.status === "done") return layer.total ?? layer.current ?? 0
|
|
135
|
+
if (layer.status === "extracting") {
|
|
136
|
+
const current = layer.current ?? 0
|
|
137
|
+
return layer.total ? Math.min(current, layer.total) : current
|
|
138
|
+
}
|
|
139
|
+
return 0
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function clamp01(n: number): number {
|
|
143
|
+
return Math.min(1, Math.max(0, n))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Docker image pull, rendered as a real UI instead of ANSI art redrawn in a
|
|
148
|
+
* terminal. Purely presentational: it takes a snapshot (`image` + `layers` +
|
|
149
|
+
* `state`) and draws it — no WebSocket, no docker, no polling. Throughput and
|
|
150
|
+
* ETA are derived internally from successive snapshots, so callers only have to
|
|
151
|
+
* forward what the daemon already tells them.
|
|
152
|
+
*
|
|
153
|
+
* The centrepiece is a *single* overall bar whose colour follows the phase:
|
|
154
|
+
* flame as bytes come down, amber growing over it as layers unpack, solid green
|
|
155
|
+
* at the end. It was segmented per layer at first — one block per layer, sized
|
|
156
|
+
* by weight — and the segments were arithmetically right but unreadable: the
|
|
157
|
+
* filled parts end up scattered across five or six blocks, so the eye can't add
|
|
158
|
+
* them up to the announced percentage, and a 148 kB layer next to a 75 MB one is
|
|
159
|
+
* a 2 px sliver that just looks like a glitch. Per-layer detail lives in the
|
|
160
|
+
* expandable list below, where each layer gets a full line to itself.
|
|
161
|
+
*
|
|
162
|
+
* Two rendering traps this component exists to avoid:
|
|
163
|
+
*
|
|
164
|
+
* 1. **Stable order.** Layer sizes arrive late; sorting by weight would make
|
|
165
|
+
* the list reshuffle for the entire pull. Order is first-appearance and
|
|
166
|
+
* frozen for good (see `orderRef`).
|
|
167
|
+
* 2. **Growing denominator.** The total is unknown at the start and grows as
|
|
168
|
+
* layers announce themselves, so the *percentage can go down*. Hence the
|
|
169
|
+
* absolute byte count printed next to it (that one never lies), a `~` marker
|
|
170
|
+
* while sizes are still missing, and damped width transitions so the bar
|
|
171
|
+
* never snaps backwards.
|
|
172
|
+
*/
|
|
173
|
+
function ImagePull({
|
|
174
|
+
image,
|
|
175
|
+
layers,
|
|
176
|
+
state = "pulling",
|
|
177
|
+
error,
|
|
178
|
+
startedAt,
|
|
179
|
+
defaultExpanded = false,
|
|
180
|
+
className,
|
|
181
|
+
}: ImagePullProps) {
|
|
182
|
+
const [expanded, setExpanded] = useState(defaultExpanded)
|
|
183
|
+
|
|
184
|
+
// First-appearance order, frozen. Mutating the ref during render is safe here
|
|
185
|
+
// because it's idempotent — re-running with the same `layers` yields the same
|
|
186
|
+
// map — and it has to happen before the sort below, not in an effect.
|
|
187
|
+
const orderRef = useRef<Map<string, number>>(new Map())
|
|
188
|
+
const ordered = useMemo(() => {
|
|
189
|
+
const order = orderRef.current
|
|
190
|
+
for (const layer of layers) if (!order.has(layer.id)) order.set(layer.id, order.size)
|
|
191
|
+
return [...layers].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0))
|
|
192
|
+
}, [layers])
|
|
193
|
+
|
|
194
|
+
const stats = useMemo(() => {
|
|
195
|
+
const known = ordered.filter((l) => l.total && l.total > 0)
|
|
196
|
+
const knownBytes = known.reduce((sum, l) => sum + (l.total ?? 0), 0)
|
|
197
|
+
// Layers whose size is still unknown count for an equal share, estimated
|
|
198
|
+
// from the ones we do know. Without it the total would jump every time a
|
|
199
|
+
// size lands, and the percentage would fall off a cliff each time.
|
|
200
|
+
const estimate = known.length ? knownBytes / known.length : MIB
|
|
201
|
+
const downloaded = ordered.reduce((sum, l) => sum + downloadedBytes(l), 0)
|
|
202
|
+
const extracted = ordered.reduce((sum, l) => sum + extractedBytes(l), 0)
|
|
203
|
+
const total = ordered.reduce((sum, l) => sum + (l.total && l.total > 0 ? l.total : estimate), 0)
|
|
204
|
+
return {
|
|
205
|
+
downloaded,
|
|
206
|
+
extracted,
|
|
207
|
+
total,
|
|
208
|
+
/** True while at least one layer hasn't announced its size — the total is a guess. */
|
|
209
|
+
estimated: known.length !== ordered.length,
|
|
210
|
+
}
|
|
211
|
+
}, [ordered])
|
|
212
|
+
|
|
213
|
+
const rate = useThroughput(stats.downloaded, state)
|
|
214
|
+
const elapsed = useElapsed(startedAt, state)
|
|
215
|
+
|
|
216
|
+
// Failures are the one case where the layer list is the answer, not a detail.
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
if (state === "error") setExpanded(true)
|
|
219
|
+
if (state === "done") setExpanded(false)
|
|
220
|
+
}, [state])
|
|
221
|
+
|
|
222
|
+
const isTerminal = state === "done" || state === "cached"
|
|
223
|
+
const hasLayers = ordered.length > 0
|
|
224
|
+
const percent = stats.total > 0 ? clamp01(stats.downloaded / stats.total) : 0
|
|
225
|
+
const extractPercent = stats.total > 0 ? clamp01(stats.extracted / stats.total) : 0
|
|
226
|
+
// Both phases overlap for most of a pull (layer 1 unpacks while layer 3 still
|
|
227
|
+
// downloads), so the headline number stays the download until every byte is
|
|
228
|
+
// in — only then does the unpacking become the thing you're waiting on.
|
|
229
|
+
const unpacking = hasLayers && percent >= 1 && extractPercent < 1
|
|
230
|
+
const remaining = Math.max(0, stats.total - stats.downloaded)
|
|
231
|
+
// Under a second there's nothing useful to announce — "0 s restantes" is noise.
|
|
232
|
+
const etaSeconds = rate && rate > 0 && remaining > 0 ? remaining / rate : 0
|
|
233
|
+
const eta = etaSeconds >= 1 ? etaSeconds : null
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<div
|
|
237
|
+
data-slot="image-pull"
|
|
238
|
+
className={cn(
|
|
239
|
+
// Square by default, like `Terminal`: this is a primitive, it doesn't
|
|
240
|
+
// impose a corner style. Consumers add `rounded-*` via className.
|
|
241
|
+
//
|
|
242
|
+
// `@container`, and every responsive rule below is a container query
|
|
243
|
+
// (`@sm:`, `@md:`…), never a viewport one: what has to fit is this box,
|
|
244
|
+
// and a 380 px-wide panel on a desktop is exactly as cramped as a phone.
|
|
245
|
+
"@container w-full min-w-0 overflow-hidden border font-mono text-xs leading-none",
|
|
246
|
+
className
|
|
247
|
+
)}
|
|
248
|
+
style={{
|
|
249
|
+
backgroundColor: pullTheme.background,
|
|
250
|
+
borderColor: state === "error" ? "#7f1d1d" : pullTheme.border,
|
|
251
|
+
color: pullTheme.text,
|
|
252
|
+
}}
|
|
253
|
+
>
|
|
254
|
+
{/* Narrow: image reference and chevron on one line, the figures on their
|
|
255
|
+
own line underneath (the `w-full` is what forces that wrap). Wide: all
|
|
256
|
+
four on a single line, figures back to their usual spot before the
|
|
257
|
+
chevron — hence the `order-*` swap rather than a second copy of the
|
|
258
|
+
markup. */}
|
|
259
|
+
<div className="flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-1.5 px-3 py-2.5">
|
|
260
|
+
<StateIcon state={state} />
|
|
261
|
+
<ImageRef image={image} />
|
|
262
|
+
{hasLayers && (
|
|
263
|
+
<button
|
|
264
|
+
type="button"
|
|
265
|
+
onClick={() => setExpanded((v) => !v)}
|
|
266
|
+
aria-expanded={expanded}
|
|
267
|
+
aria-label={expanded ? "Masquer les couches" : "Afficher les couches"}
|
|
268
|
+
className="order-3 -mr-1 shrink-0 cursor-pointer rounded p-1 transition-colors hover:bg-white/5 @sm:order-4"
|
|
269
|
+
style={{ color: pullTheme.faint }}
|
|
270
|
+
>
|
|
271
|
+
<Chevron open={expanded} />
|
|
272
|
+
</button>
|
|
273
|
+
)}
|
|
274
|
+
<span className="order-4 w-full min-w-0 @sm:order-3 @sm:w-auto">
|
|
275
|
+
<Aggregate
|
|
276
|
+
state={state}
|
|
277
|
+
percent={percent}
|
|
278
|
+
extractPercent={extractPercent}
|
|
279
|
+
unpacking={unpacking}
|
|
280
|
+
stats={stats}
|
|
281
|
+
rate={rate}
|
|
282
|
+
eta={eta}
|
|
283
|
+
elapsed={elapsed}
|
|
284
|
+
layerCount={ordered.length}
|
|
285
|
+
/>
|
|
286
|
+
</span>
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
{/* The bar itself. Skipped in the two terminal states that have nothing to
|
|
290
|
+
show: `done` collapses to the single success line above, and `cached`
|
|
291
|
+
must never draw a 100 % bar for a download that never happened. */}
|
|
292
|
+
{!isTerminal && (
|
|
293
|
+
<div className="px-3 pb-3">
|
|
294
|
+
{hasLayers ? (
|
|
295
|
+
<OverallBar percent={percent} extractPercent={extractPercent} state={state} />
|
|
296
|
+
) : (
|
|
297
|
+
<IndeterminateBar />
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
)}
|
|
301
|
+
|
|
302
|
+
{state === "error" && error && (
|
|
303
|
+
<div
|
|
304
|
+
className="flex items-start gap-2 border-t px-3 py-2 leading-relaxed"
|
|
305
|
+
style={{ borderColor: "#7f1d1d", backgroundColor: "rgba(127,29,29,0.15)", color: pullTheme.error }}
|
|
306
|
+
>
|
|
307
|
+
<span className="shrink-0 pt-px">✕</span>
|
|
308
|
+
<span className="min-w-0 break-words">{error}</span>
|
|
309
|
+
</div>
|
|
310
|
+
)}
|
|
311
|
+
|
|
312
|
+
{expanded && hasLayers && (
|
|
313
|
+
<div className="max-h-64 overflow-y-auto border-t" style={{ borderColor: pullTheme.border }}>
|
|
314
|
+
{ordered.map((layer) => (
|
|
315
|
+
<LayerRow key={layer.id} layer={layer} failed={state === "error" && layer.status !== "done"} />
|
|
316
|
+
))}
|
|
317
|
+
</div>
|
|
318
|
+
)}
|
|
319
|
+
</div>
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The overall bar: one fill for the whole image, colour by phase.
|
|
325
|
+
*
|
|
326
|
+
* Two stacked fills rather than one, because a pull is two jobs: bytes come down
|
|
327
|
+
* (flame) and layers unpack (amber). The amber grows *over* the flame instead of
|
|
328
|
+
* replacing it, so the bar only ever moves forward — resetting to zero for the
|
|
329
|
+
* unpack phase would read as a regression, and sitting frozen at 100 % through a
|
|
330
|
+
* long extraction would read as a hang.
|
|
331
|
+
*/
|
|
332
|
+
function OverallBar({
|
|
333
|
+
percent,
|
|
334
|
+
extractPercent,
|
|
335
|
+
state,
|
|
336
|
+
}: {
|
|
337
|
+
percent: number
|
|
338
|
+
extractPercent: number
|
|
339
|
+
state: ImagePullState
|
|
340
|
+
}) {
|
|
341
|
+
const failed = state === "error"
|
|
342
|
+
return (
|
|
343
|
+
<div className="relative h-2 w-full overflow-hidden rounded-full" style={{ backgroundColor: pullTheme.track }}>
|
|
344
|
+
<div
|
|
345
|
+
className="absolute inset-y-0 left-0 rounded-full transition-[width] duration-500 ease-out motion-reduce:transition-none"
|
|
346
|
+
style={{ width: `${percent * 100}%`, backgroundColor: failed ? pullTheme.error : pullTheme.downloading }}
|
|
347
|
+
/>
|
|
348
|
+
{!failed && extractPercent > 0 && (
|
|
349
|
+
<div
|
|
350
|
+
className="absolute inset-y-0 left-0 rounded-full transition-[width] duration-500 ease-out motion-reduce:transition-none"
|
|
351
|
+
style={{
|
|
352
|
+
width: `${extractPercent * 100}%`,
|
|
353
|
+
// Green once everything is unpacked: the last colour change is the
|
|
354
|
+
// signal that the image is actually usable, not merely downloaded.
|
|
355
|
+
backgroundColor: extractPercent >= 1 ? pullTheme.done : pullTheme.extracting,
|
|
356
|
+
}}
|
|
357
|
+
/>
|
|
358
|
+
)}
|
|
359
|
+
</div>
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Registry contacted, not a single layer announced yet — nothing to segment. */
|
|
364
|
+
function IndeterminateBar() {
|
|
365
|
+
return (
|
|
366
|
+
<div className="relative h-2 w-full overflow-hidden rounded-full" style={{ backgroundColor: pullTheme.track }}>
|
|
367
|
+
<Sweep />
|
|
368
|
+
</div>
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The moving highlight used by every indeterminate surface here. */
|
|
373
|
+
function Sweep() {
|
|
374
|
+
return (
|
|
375
|
+
<div
|
|
376
|
+
data-slot="image-pull-sweep"
|
|
377
|
+
className="absolute inset-y-0 w-1/3"
|
|
378
|
+
style={{
|
|
379
|
+
background: `linear-gradient(90deg, transparent, ${pullTheme.downloading}, transparent)`,
|
|
380
|
+
}}
|
|
381
|
+
/>
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function ImageRef({ image }: { image: string }) {
|
|
386
|
+
const { head, name, tag } = splitImageRef(image)
|
|
387
|
+
return (
|
|
388
|
+
// `overflow-hidden` is the safety net: whatever happens to the widths below,
|
|
389
|
+
// the reference gets clipped, never painted on top of the figures.
|
|
390
|
+
<div className="flex min-w-[6rem] flex-1 items-baseline overflow-hidden" title={image}>
|
|
391
|
+
{/* The registry/path head is the least useful part of a reference, so it's
|
|
392
|
+
the first thing to go: truncated when space gets tight, dropped
|
|
393
|
+
entirely on a narrow container. The name truncates too (long names
|
|
394
|
+
exist), the tag never does — it's the part that says *what* is being
|
|
395
|
+
pulled. */}
|
|
396
|
+
{head && (
|
|
397
|
+
// The head is the *only* shrinkable part, so it absorbs the whole
|
|
398
|
+
// squeeze on its own. Letting the name shrink too gives you
|
|
399
|
+
// "registry.spunt…worker-ba… :2.4.0" — two ellipses and no information;
|
|
400
|
+
// one ellipsis on the registry path reads fine.
|
|
401
|
+
<span className="hidden min-w-0 truncate @sm:inline" style={{ color: pullTheme.faint }}>
|
|
402
|
+
{head}
|
|
403
|
+
</span>
|
|
404
|
+
)}
|
|
405
|
+
<span className="shrink-0 whitespace-nowrap" style={{ color: pullTheme.dim }}>
|
|
406
|
+
{name}
|
|
407
|
+
</span>
|
|
408
|
+
{tag && (
|
|
409
|
+
<span className="shrink-0 whitespace-nowrap font-medium" style={{ color: pullTheme.tag }}>
|
|
410
|
+
{tag}
|
|
411
|
+
</span>
|
|
412
|
+
)}
|
|
413
|
+
</div>
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function Aggregate({
|
|
418
|
+
state,
|
|
419
|
+
percent,
|
|
420
|
+
extractPercent,
|
|
421
|
+
unpacking,
|
|
422
|
+
stats,
|
|
423
|
+
rate,
|
|
424
|
+
eta,
|
|
425
|
+
elapsed,
|
|
426
|
+
layerCount,
|
|
427
|
+
}: {
|
|
428
|
+
state: ImagePullState
|
|
429
|
+
percent: number
|
|
430
|
+
extractPercent: number
|
|
431
|
+
unpacking: boolean
|
|
432
|
+
stats: { downloaded: number; extracted: number; total: number; estimated: boolean }
|
|
433
|
+
rate: number | null
|
|
434
|
+
eta: number | null
|
|
435
|
+
elapsed: number | null
|
|
436
|
+
layerCount: number
|
|
437
|
+
}) {
|
|
438
|
+
if (state === "cached") {
|
|
439
|
+
return (
|
|
440
|
+
<span className="shrink-0 whitespace-nowrap" style={{ color: pullTheme.faint }}>
|
|
441
|
+
déjà présente localement
|
|
442
|
+
</span>
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (state === "done") {
|
|
447
|
+
return (
|
|
448
|
+
<span className="shrink-0 whitespace-nowrap tabular-nums" style={{ color: pullTheme.done }}>
|
|
449
|
+
{elapsed != null ? `Pullée en ${formatDuration(elapsed)} · ` : ""}
|
|
450
|
+
{formatBytes(stats.total)}
|
|
451
|
+
</span>
|
|
452
|
+
)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (state === "starting" || layerCount === 0) {
|
|
456
|
+
return (
|
|
457
|
+
<span className="shrink-0 whitespace-nowrap" style={{ color: pullTheme.faint }}>
|
|
458
|
+
contact du registre…
|
|
459
|
+
</span>
|
|
460
|
+
)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Once every byte is down, the number that matters is the unpacking — quoting
|
|
464
|
+
// "100 %" while the image still isn't usable is the same lie as `cached`.
|
|
465
|
+
const shown = unpacking ? extractPercent : percent
|
|
466
|
+
const shownBytes = unpacking ? stats.extracted : stats.downloaded
|
|
467
|
+
|
|
468
|
+
return (
|
|
469
|
+
<span className="flex shrink-0 items-baseline gap-2 whitespace-nowrap tabular-nums">
|
|
470
|
+
<span style={{ color: state === "error" ? pullTheme.error : pullTheme.text }}>
|
|
471
|
+
{unpacking && <span style={{ color: pullTheme.extracting }}>extraction · </span>}
|
|
472
|
+
{Math.round(shown * 100)} %
|
|
473
|
+
<span style={{ color: pullTheme.faint }}>
|
|
474
|
+
{" · "}
|
|
475
|
+
{/* The absolute count is the honest number: unlike the percentage, it
|
|
476
|
+
can't be pushed backwards by a total that keeps growing. */}
|
|
477
|
+
{formatBytes(shownBytes)} / {stats.estimated ? "~" : ""}
|
|
478
|
+
{formatBytes(stats.total)}
|
|
479
|
+
</span>
|
|
480
|
+
</span>
|
|
481
|
+
{state === "pulling" && (rate || eta) && (
|
|
482
|
+
// Débit et ETA sont du confort : les premiers sacrifiés quand la place manque.
|
|
483
|
+
<span className="hidden @md:inline" style={{ color: pullTheme.faint }}>
|
|
484
|
+
{"· "}
|
|
485
|
+
{rate ? formatRate(rate) : ""}
|
|
486
|
+
{rate && eta ? " · " : ""}
|
|
487
|
+
{eta ? formatDuration(eta * 1000) : ""}
|
|
488
|
+
</span>
|
|
489
|
+
)}
|
|
490
|
+
</span>
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** `failed` = the pull errored and this layer never finished — the same red the bar uses. */
|
|
495
|
+
function LayerRow({ layer, failed }: { layer: PullLayer; failed?: boolean }) {
|
|
496
|
+
const known = !!(layer.total && layer.total > 0)
|
|
497
|
+
const fraction = known ? clamp01((layer.current ?? 0) / layer.total!) : 0
|
|
498
|
+
const done = layer.status === "done"
|
|
499
|
+
const color = failed ? pullTheme.error : STATUS_COLOR[layer.status]
|
|
500
|
+
|
|
501
|
+
return (
|
|
502
|
+
<div className="flex items-center gap-2.5 px-3 py-1.5">
|
|
503
|
+
<span className="w-3 shrink-0 text-center" style={{ color }}>
|
|
504
|
+
{done ? "✓" : layer.status === "waiting" ? "·" : "↓"}
|
|
505
|
+
</span>
|
|
506
|
+
<span className="w-[5.5rem] shrink-0 truncate @sm:w-[6.5rem]" style={{ color: done ? pullTheme.done : pullTheme.dim }}>
|
|
507
|
+
{layer.id.replace(/^sha256:/, "").slice(0, 12)}
|
|
508
|
+
</span>
|
|
509
|
+
<div className="relative h-1 min-w-0 flex-1 overflow-hidden rounded-full" style={{ backgroundColor: pullTheme.track }}>
|
|
510
|
+
{known || done ? (
|
|
511
|
+
<div
|
|
512
|
+
className="absolute inset-y-0 left-0 transition-[width] duration-500 ease-out motion-reduce:transition-none"
|
|
513
|
+
style={{ width: `${(done ? 1 : fraction) * 100}%`, backgroundColor: color }}
|
|
514
|
+
/>
|
|
515
|
+
) : layer.status !== "waiting" ? (
|
|
516
|
+
<Sweep />
|
|
517
|
+
) : null}
|
|
518
|
+
</div>
|
|
519
|
+
{/* Narrow: only the bytes already in — the row's own bar carries the
|
|
520
|
+
fraction, so repeating "/ total" just squeezes that bar to nothing. */}
|
|
521
|
+
<span className="w-[4.5rem] shrink-0 text-right tabular-nums @sm:hidden" style={{ color: pullTheme.faint }}>
|
|
522
|
+
{known || done ? formatBytes(layer.current ?? 0) : "—"}
|
|
523
|
+
</span>
|
|
524
|
+
<span className="hidden w-[7.5rem] shrink-0 text-right tabular-nums @sm:inline" style={{ color: pullTheme.faint }}>
|
|
525
|
+
{known ? `${formatBytes(layer.current ?? 0)} / ${formatBytes(layer.total!)}` : done ? formatBytes(layer.current ?? 0) : "—"}
|
|
526
|
+
</span>
|
|
527
|
+
{/* The status dot already encodes the phase — the word is a wide-container bonus. */}
|
|
528
|
+
<span className="hidden w-24 shrink-0 text-right @lg:inline" style={{ color: done ? pullTheme.done : pullTheme.faint }}>
|
|
529
|
+
{STATUS_LABEL[layer.status]}
|
|
530
|
+
</span>
|
|
531
|
+
</div>
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function StateIcon({ state }: { state: ImagePullState }) {
|
|
536
|
+
const common = "size-3.5 shrink-0"
|
|
537
|
+
if (state === "done")
|
|
538
|
+
return (
|
|
539
|
+
<svg viewBox="0 0 16 16" fill="none" className={common} style={{ color: pullTheme.done }} aria-hidden>
|
|
540
|
+
<circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5" />
|
|
541
|
+
<path d="m5 8.2 2.1 2.1L11 6.4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
542
|
+
</svg>
|
|
543
|
+
)
|
|
544
|
+
if (state === "cached")
|
|
545
|
+
return (
|
|
546
|
+
// A disk, not a checkmark: nothing was downloaded, and the icon shouldn't
|
|
547
|
+
// suggest otherwise.
|
|
548
|
+
<svg viewBox="0 0 16 16" fill="none" className={common} style={{ color: pullTheme.faint }} aria-hidden>
|
|
549
|
+
<ellipse cx="8" cy="4" rx="5.5" ry="2.2" stroke="currentColor" strokeWidth="1.4" />
|
|
550
|
+
<path d="M2.5 4v8c0 1.2 2.5 2.2 5.5 2.2s5.5-1 5.5-2.2V4" stroke="currentColor" strokeWidth="1.4" />
|
|
551
|
+
<path d="M2.5 8c0 1.2 2.5 2.2 5.5 2.2s5.5-1 5.5-2.2" stroke="currentColor" strokeWidth="1.4" />
|
|
552
|
+
</svg>
|
|
553
|
+
)
|
|
554
|
+
if (state === "error")
|
|
555
|
+
return (
|
|
556
|
+
<svg viewBox="0 0 16 16" fill="none" className={common} style={{ color: pullTheme.error }} aria-hidden>
|
|
557
|
+
<circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5" />
|
|
558
|
+
<path d="M8 4.5v4.2M8 11.2v.6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
559
|
+
</svg>
|
|
560
|
+
)
|
|
561
|
+
return (
|
|
562
|
+
<svg viewBox="0 0 16 16" fill="none" className={common} style={{ color: pullTheme.downloading }} aria-hidden>
|
|
563
|
+
<path d="M8 2.5v7.5m0 0L5 7.2M8 10l3-2.8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
564
|
+
<path d="M2.8 12.2h10.4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
565
|
+
</svg>
|
|
566
|
+
)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function Chevron({ open }: { open: boolean }) {
|
|
570
|
+
return (
|
|
571
|
+
<svg
|
|
572
|
+
viewBox="0 0 16 16"
|
|
573
|
+
fill="none"
|
|
574
|
+
className={cn("size-3.5 transition-transform duration-200 motion-reduce:transition-none", open && "rotate-180")}
|
|
575
|
+
aria-hidden
|
|
576
|
+
>
|
|
577
|
+
<path d="m4 6.5 4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
578
|
+
</svg>
|
|
579
|
+
)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Bytes/second over a short sliding window of successive snapshots. Derived
|
|
584
|
+
* here rather than asked of the caller: the component already sees every
|
|
585
|
+
* snapshot, and a plain `current/elapsed` average would keep quoting a stale
|
|
586
|
+
* number long after the connection changed pace.
|
|
587
|
+
*/
|
|
588
|
+
function useThroughput(downloaded: number, state: ImagePullState): number | null {
|
|
589
|
+
const samplesRef = useRef<Array<{ t: number; b: number }>>([])
|
|
590
|
+
const [rate, setRate] = useState<number | null>(null)
|
|
591
|
+
|
|
592
|
+
useEffect(() => {
|
|
593
|
+
if (state !== "pulling" && state !== "starting") {
|
|
594
|
+
samplesRef.current = []
|
|
595
|
+
setRate(null)
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
const now = Date.now()
|
|
599
|
+
const samples = samplesRef.current
|
|
600
|
+
samples.push({ t: now, b: downloaded })
|
|
601
|
+
while (samples.length > 2 && now - samples[0].t > 5000) samples.shift()
|
|
602
|
+
|
|
603
|
+
const first = samples[0]
|
|
604
|
+
const seconds = (now - first.t) / 1000
|
|
605
|
+
// Under half a second of window the number is noise — keep the previous one
|
|
606
|
+
// rather than flashing a wildly wrong rate.
|
|
607
|
+
if (seconds >= 0.5 && downloaded > first.b) setRate((downloaded - first.b) / seconds)
|
|
608
|
+
}, [downloaded, state])
|
|
609
|
+
|
|
610
|
+
return rate
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Milliseconds since `startedAt`, ticking while the pull runs and frozen the
|
|
615
|
+
* moment it reaches a terminal state — that frozen value is what the collapsed
|
|
616
|
+
* "Pullée en 42 s" line reports. Returns null until mounted, so server and
|
|
617
|
+
* client render the same thing.
|
|
618
|
+
*/
|
|
619
|
+
function useElapsed(startedAt: number | undefined, state: ImagePullState): number | null {
|
|
620
|
+
const [now, setNow] = useState<number | null>(null)
|
|
621
|
+
const [endedAt, setEndedAt] = useState<number | null>(null)
|
|
622
|
+
const settled = state === "done" || state === "cached" || state === "error"
|
|
623
|
+
|
|
624
|
+
useEffect(() => {
|
|
625
|
+
if (settled) {
|
|
626
|
+
// Stamped here, not on the last tick, so "Pullée en 42 s" isn't off by
|
|
627
|
+
// up to a second of interval slack.
|
|
628
|
+
setEndedAt(Date.now())
|
|
629
|
+
return
|
|
630
|
+
}
|
|
631
|
+
setEndedAt(null)
|
|
632
|
+
setNow(Date.now())
|
|
633
|
+
const id = setInterval(() => setNow(Date.now()), 1000)
|
|
634
|
+
return () => clearInterval(id)
|
|
635
|
+
}, [settled])
|
|
636
|
+
|
|
637
|
+
const reference = endedAt ?? now
|
|
638
|
+
if (startedAt == null || reference == null) return null
|
|
639
|
+
return Math.max(0, reference - startedAt)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export { ImagePull }
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Who spawned the worker, in 20 px.
|
|
2
|
+
//
|
|
3
|
+
// A plain <img>, deliberately: the design system is consumed by two Next apps
|
|
4
|
+
// *and* by a Vite showcase, so it can't reach for `next/image`. Avatars come
|
|
5
|
+
// from GitHub/Google CDNs already sized for this.
|
|
6
|
+
|
|
7
|
+
import { cn } from "../../utils"
|
|
8
|
+
import type { WorkerCreator } from "./types"
|
|
9
|
+
|
|
10
|
+
export function CreatorAvatar({ creator, className }: { creator: WorkerCreator; className?: string }) {
|
|
11
|
+
if (creator.avatarUrl) {
|
|
12
|
+
return (
|
|
13
|
+
<img
|
|
14
|
+
src={creator.avatarUrl}
|
|
15
|
+
alt={creator.name}
|
|
16
|
+
className={cn("h-5 w-5 shrink-0 rounded-full object-cover ring-1 ring-border", className)}
|
|
17
|
+
/>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const initials = creator.name
|
|
22
|
+
.split(" ")
|
|
23
|
+
.map((n) => n[0])
|
|
24
|
+
.join("")
|
|
25
|
+
.toUpperCase()
|
|
26
|
+
.slice(0, 2)
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<span
|
|
30
|
+
className={cn(
|
|
31
|
+
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-[9px] font-semibold text-muted-foreground ring-1 ring-border",
|
|
32
|
+
className
|
|
33
|
+
)}
|
|
34
|
+
>
|
|
35
|
+
{initials}
|
|
36
|
+
</span>
|
|
37
|
+
)
|
|
38
|
+
}
|