@spunto/design-system 0.26.0 → 0.28.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,6 +27,9 @@ export type { SetupProgressProps, StepState } from "./setup-progress"
27
27
  export { WorkerSetupTimeline } from "./worker-setup-timeline"
28
28
  export { SetupWaterfall } from "./setup-waterfall"
29
29
 
30
+ export { WorkerStatusHistory, statusHistorySpans } from "./worker-status-history"
31
+ export type { WorkerStatusHistoryProps, WorkerStatusSpan } from "./worker-status-history"
32
+
30
33
  export { ResourceBars } from "./resource-bars"
31
34
  export type { ResourceBarsProps, WorkerStats } from "./resource-bars"
32
35
 
@@ -40,8 +43,8 @@ export type { TagPalette } from "./tag-color"
40
43
 
41
44
  export { CreatorAvatar } from "./creator-avatar"
42
45
 
43
- export type { WorkerCardWorker, WorkerCockpitWorker, WorkerCreator, WorkerSetupStatus } from "./types"
46
+ export type { WorkerCardWorker, WorkerCockpitWorker, WorkerCreator, WorkerSetupStatus, WorkerStatusEvent } from "./types"
44
47
 
45
48
  // Re-exported here too so a consumer of the worker surface doesn't have to
46
49
  // import the root entry just to format the timestamps it feeds these components.
47
- export { cn, formatDuration, formatRelativeTime } from "../../utils"
50
+ export { cn, formatDuration, formatElapsed, formatRelativeTime } from "../../utils"
@@ -26,8 +26,23 @@ export interface WorkerCardWorker {
26
26
  name?: string | null
27
27
  /** Fallback identity when `name` is empty — renders as "Workspace #3". */
28
28
  index?: number | null
29
- /** Lifecycle state: `provisioning` | `building` | `pulling` | `starting` | `setup` | `ready` | `stopping` | `stopped` | `deleting` | `error`. */
29
+ /** Lifecycle state: `provisioning` | `building` | `pulling` | `starting` | `setup` | `ready` | `stopping` | `stopped` | `deleting` | `exited` | `unknown` | `error`. */
30
30
  state?: string | null
31
+ /**
32
+ * Why the worker is in that state, when there is a why: an exit code, a reason, and the status
33
+ * it came from. Read laxly on purpose — every field optional, an unrecognised `reason` yields no
34
+ * detail instead of an invented one. The strict version of this shape lives in
35
+ * `@spunto/build` (`WorkerStatusMeta`), which is where a control plane *writing* it should look.
36
+ */
37
+ statusMeta?: {
38
+ reason?: string | null
39
+ message?: string | null
40
+ exitCode?: number | null
41
+ oomKilled?: boolean | null
42
+ /** What the worker was before it became `unknown` — the buttons to offer depend on it. */
43
+ previousStatus?: string | null
44
+ at?: string | null
45
+ } | null
31
46
  /** Docker-level state: `running` | `stopped` | `exited` | `created` | `not_found` | `error`. */
32
47
  dockerState?: string | null
33
48
  createdAt?: string | null
@@ -53,3 +68,25 @@ export interface WorkerCockpitWorker extends WorkerCardWorker {
53
68
  branch?: string | null
54
69
  containerId?: string | null
55
70
  }
71
+
72
+ /**
73
+ * One row of a worker's status history — a transition, as the control plane journaled it.
74
+ *
75
+ * Mirrors a `worker_status_events` row of the dashboard (`from`, `to`, `source`, `meta`, `at`),
76
+ * laxly: `to` and `at` are the only fields the history can't draw without. `source` is a free
77
+ * string on purpose — the package phrases the ones it knows and shows the others as they come.
78
+ */
79
+ export interface WorkerStatusEvent {
80
+ /** Order within the worker. Falls back to `at` when absent. */
81
+ seq?: number | null
82
+ /** ISO 8601 — when the worker *entered* `to`. */
83
+ at: string
84
+ /** `null` for the very first transition (the worker's creation). */
85
+ from?: string | null
86
+ /** Lifecycle state entered — same vocabulary as `WorkerCardWorker.state`. */
87
+ to: string
88
+ /** Who wrote it: `spawn` | `agent-event` | `reconciliation` | `command` | `setup-monitor` | `boot-recovery` | `node-disconnect`. */
89
+ source?: string | null
90
+ /** The why, same shape as `WorkerCardWorker.statusMeta`. */
91
+ meta?: WorkerCardWorker["statusMeta"]
92
+ }
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  // The full worker page, as one component. Top bar, logs/terminal tabs, and a
4
- // control panel (setup, resources, repos, ports, tags, actions, info) that
4
+ // control panel (setup, resources, repos, ports, tags, actions, history, info) that
5
5
  // lives in a sidebar on desktop and a bottom sheet on mobile — same content,
6
6
  // because a phone doesn't need less of it, just a different place to put it.
7
7
  //
@@ -44,10 +44,11 @@ import { type GitRepoStatus, repoName } from "./git-branch-chips"
44
44
  import { ResourceBars, type WorkerStats } from "./resource-bars"
45
45
  import { SetupWaterfall } from "./setup-waterfall"
46
46
  import { TagChips } from "./tag-chips"
47
- import type { WorkerCockpitWorker, WorkerCreator } from "./types"
47
+ import type { WorkerCockpitWorker, WorkerCreator, WorkerStatusEvent } from "./types"
48
48
  import { type LinkRender } from "./worker-card"
49
49
  import { WorkerSetupTimeline } from "./worker-setup-timeline"
50
50
  import { WorkerStatusDot, WorkerStatusPill, resolveWorkerStatus } from "./worker-status"
51
+ import { WorkerStatusHistory } from "./worker-status-history"
51
52
 
52
53
  type CockpitTab = "logs" | "terminal"
53
54
 
@@ -104,6 +105,12 @@ export interface WorkerCockpitProps {
104
105
  /** Fetches the one-shot SSH command; the cockpit copies it to the clipboard and owns the button's own idle/loading/copied state. */
105
106
  onCopySsh?: () => Promise<string>
106
107
 
108
+ /**
109
+ * The worker's status transitions, as journaled by the control plane. Omitted or empty → no
110
+ * History section. The current stay is measured up to `now` at render time.
111
+ */
112
+ statusHistory?: WorkerStatusEvent[]
113
+
107
114
  /** A failed mutation, typically. */
108
115
  error?: string | null
109
116
  onDismissError?: () => void
@@ -180,6 +187,7 @@ export function WorkerCockpit({
180
187
  onDelete,
181
188
  deleting = false,
182
189
  onCopySsh,
190
+ statusHistory,
183
191
  error,
184
192
  onDismissError,
185
193
  activeTab,
@@ -394,6 +402,13 @@ export function WorkerCockpit({
394
402
  </div>
395
403
  )}
396
404
 
405
+ {!!statusHistory?.length && (
406
+ <div>
407
+ <SectionLabel>History</SectionLabel>
408
+ <WorkerStatusHistory events={statusHistory} />
409
+ </div>
410
+ )}
411
+
397
412
  <div>
398
413
  <SectionLabel>Info</SectionLabel>
399
414
  <div className="divide-y divide-border/40 rounded-lg border border-border/50 bg-muted/20">
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { formatElapsed } from "../../utils"
3
+ import { statusHistorySpans } from "./worker-status-history"
4
+
5
+ const T0 = Date.parse("2026-09-22T10:00:00Z")
6
+ const at = (ms: number) => new Date(T0 + ms).toISOString()
7
+
8
+ describe("statusHistorySpans", () => {
9
+ it("un séjour dure jusqu'à la transition suivante, le dernier jusqu'à now", () => {
10
+ const spans = statusHistorySpans(
11
+ [
12
+ { seq: 1, at: at(0), from: null, to: "provisioning", source: "spawn" },
13
+ { seq: 2, at: at(800), from: "provisioning", to: "building", source: "spawn" },
14
+ { seq: 3, at: at(660_800), from: "building", to: "ready", source: "agent-event" },
15
+ ],
16
+ T0 + 3_600_000
17
+ )
18
+ expect(spans.map((s) => s.duration)).toEqual([800, 660_000, 3_600_000 - 660_800])
19
+ expect(spans.map((s) => s.current)).toEqual([false, false, true])
20
+ })
21
+
22
+ it("remet dans l'ordre un journal qui arrive dans le désordre", () => {
23
+ const spans = statusHistorySpans(
24
+ [
25
+ { seq: 2, at: at(1000), to: "ready" },
26
+ { seq: 1, at: at(0), to: "starting" },
27
+ ],
28
+ T0 + 2000
29
+ )
30
+ expect(spans.map((s) => s.event.to)).toEqual(["starting", "ready"])
31
+ })
32
+
33
+ it("ignore une date illisible et ne produit jamais de durée négative", () => {
34
+ const spans = statusHistorySpans(
35
+ [
36
+ { seq: 1, at: "pas une date", to: "starting" },
37
+ { seq: 2, at: at(5000), to: "ready" },
38
+ ],
39
+ T0 // horloge en retard sur le journal
40
+ )
41
+ expect(spans).toHaveLength(1)
42
+ expect(spans[0].duration).toBe(0)
43
+ })
44
+
45
+ it("ne fait battre que le séjour en cours", () => {
46
+ const spans = statusHistorySpans(
47
+ [
48
+ { seq: 1, at: at(0), to: "ready" },
49
+ { seq: 2, at: at(1000), to: "stopping" },
50
+ { seq: 3, at: at(2000), to: "stopped" },
51
+ { seq: 4, at: at(3000), to: "ready" },
52
+ ],
53
+ T0 + 4000
54
+ )
55
+ expect(spans.map((s) => s.status.running)).toEqual([false, false, false, true])
56
+ })
57
+
58
+ it("dit pourquoi à partir du meta, et retire les points de suspension d'un séjour fini", () => {
59
+ const spans = statusHistorySpans(
60
+ [
61
+ { seq: 1, at: at(0), to: "provisioning" },
62
+ { seq: 2, at: at(1000), to: "starting" },
63
+ { seq: 3, at: at(2000), to: "exited", meta: { reason: "oom-killed", exitCode: 137, oomKilled: true } },
64
+ ],
65
+ T0 + 3000
66
+ )
67
+ expect(spans.map((s) => s.label)).toEqual(["Provisioning", "Setting up", "Exited"])
68
+ expect(spans[2].status.detail).toBe("Ran out of memory (exit 137)")
69
+ })
70
+ })
71
+
72
+ describe("formatElapsed", () => {
73
+ it.each([
74
+ [800, "800ms"],
75
+ [40_000, "40s"],
76
+ [252_000, "4m 12s"],
77
+ [660_000, "11m"],
78
+ [11_520_000, "3h 12m"],
79
+ [7_200_000, "2h"],
80
+ [59_800, "1m"],
81
+ [273_600_000, "3d 4h"],
82
+ ])("%d ms → %s", (ms, out) => {
83
+ expect(formatElapsed(ms)).toBe(out)
84
+ })
85
+ })
@@ -0,0 +1,166 @@
1
+ "use client"
2
+
3
+ // The life of a worker, as its status history tells it (RFC 0027, step 8).
4
+ //
5
+ // Two readings of the same journal, stacked:
6
+ //
7
+ // - a **strip** — one segment per stay, width proportional to how long it lasted. It answers
8
+ // "what did this worker mostly do" at a glance: a strip that is all green is a healthy worker, a
9
+ // strip with a grey tail is one that died, a strip striped yellow/grey is a node that flaps.
10
+ // - a **list**, newest first — what each stay was, how long it lasted, who wrote it, and why when
11
+ // the control plane said why ("Ran out of memory (exit 137)"). This is the "why did it die on
12
+ // Tuesday" answer, without the logs of a container that no longer exists.
13
+ //
14
+ // Durations are *derived*, never sent: a stay lasts until the next transition, and the current one
15
+ // lasts until `now`. Same rule as `SetupWaterfall` ("the next milestone minus this one"), for the
16
+ // whole lifecycle instead of the setup.
17
+ //
18
+ // Colours and labels come from `workerStatusConfig`/`resolveWorkerStatus` — the history does not
19
+ // get its own palette, so a stay in the strip is the colour the pill was while it lasted.
20
+
21
+ import { useState } from "react"
22
+ import { ChevronDownIcon } from "lucide-react"
23
+
24
+ import { cn, formatElapsed, formatShortDate } from "../../utils"
25
+ import type { WorkerStatusEvent } from "./types"
26
+ import { WorkerStatusDot, resolveWorkerStatus, type WorkerStatus } from "./worker-status"
27
+
28
+ /** Who wrote a transition, phrased. An unknown source is shown as sent rather than dropped. */
29
+ const SOURCE_LABELS: Record<string, string> = {
30
+ spawn: "spawn",
31
+ "agent-event": "agent",
32
+ reconciliation: "reconciliation",
33
+ command: "user action",
34
+ "setup-monitor": "setup monitor",
35
+ "boot-recovery": "API restart",
36
+ "node-disconnect": "node disconnect",
37
+ }
38
+
39
+ /**
40
+ * Labels the status table can't give. In a pill, `provisioning` and `starting` both read
41
+ * "Setting up…" because a pill only says *that* something is in flight; in a history they are two
42
+ * consecutive rows, and two identical labels in a row read like a duplicate.
43
+ */
44
+ const HISTORY_LABELS: Record<string, string> = {
45
+ provisioning: "Provisioning",
46
+ }
47
+
48
+ export interface WorkerStatusSpan {
49
+ event: WorkerStatusEvent
50
+ status: WorkerStatus
51
+ /** Label for a stay — the pill's, minus the trailing "…" once the stay is over. */
52
+ label: string
53
+ start: number
54
+ end: number
55
+ duration: number
56
+ /** The last span: the worker is still in it, and `end` is `now`. */
57
+ current: boolean
58
+ }
59
+
60
+ /**
61
+ * Journal → stays, oldest first. Pure, so the dashboard can derive the same numbers without
62
+ * drawing anything (a "time to ready" metric, a test).
63
+ *
64
+ * Tolerant like the rest of the package: rows are ordered by `seq` then `at` whatever order they
65
+ * arrive in, a row with an unparsable `at` is skipped rather than producing `NaN` widths, and a
66
+ * clock that went backwards yields a zero-length stay, not a negative one.
67
+ */
68
+ export function statusHistorySpans(events: WorkerStatusEvent[], now: number = Date.now()): WorkerStatusSpan[] {
69
+ const rows = events
70
+ .map((event) => ({ event, start: Date.parse(event.at) }))
71
+ .filter((r) => Number.isFinite(r.start))
72
+ .sort((a, b) => (a.event.seq ?? 0) - (b.event.seq ?? 0) || a.start - b.start)
73
+
74
+ return rows.map(({ event, start }, i) => {
75
+ const current = i === rows.length - 1
76
+ const end = current ? Math.max(start, now) : rows[i + 1].start
77
+ const resolved = resolveWorkerStatus({ id: "", state: event.to, statusMeta: event.meta })
78
+ // Only the stay the worker is *still* in may pulse — a green dot that pings on a row from last
79
+ // Tuesday would claim a liveness nobody observed.
80
+ const status = current ? resolved : { ...resolved, running: false }
81
+ const label = HISTORY_LABELS[event.to] ?? (current ? status.label : status.label.replace(/…$/, ""))
82
+ return { event, status, label, start, end, duration: Math.max(0, end - start), current }
83
+ })
84
+ }
85
+
86
+ export interface WorkerStatusHistoryProps {
87
+ /** The journal, in any order. Empty → nothing is drawn. */
88
+ events: WorkerStatusEvent[]
89
+ /** Where the current stay ends. The component doesn't tick: the app re-renders on its poll. */
90
+ now?: number | Date
91
+ /** Rows shown before "Show N earlier". The strip always covers everything. */
92
+ limit?: number
93
+ className?: string
94
+ }
95
+
96
+ export function WorkerStatusHistory({ events, now, limit = 5, className }: WorkerStatusHistoryProps) {
97
+ const [expanded, setExpanded] = useState(false)
98
+ const nowMs = now instanceof Date ? now.getTime() : (now ?? Date.now())
99
+ const spans = statusHistorySpans(events, nowMs)
100
+ if (!spans.length) return null
101
+
102
+ const total = spans.reduce((sum, s) => sum + s.duration, 0)
103
+ const newestFirst = [...spans].reverse()
104
+ const hidden = expanded ? 0 : Math.max(0, newestFirst.length - limit)
105
+ const rows = hidden ? newestFirst.slice(0, limit) : newestFirst
106
+
107
+ return (
108
+ <div data-slot="worker-status-history" className={cn("rounded-lg border border-border/40 bg-muted/20 text-xs", className)}>
109
+ <div className="space-y-1.5 border-b border-border/40 px-3 py-2.5">
110
+ <div className="flex items-center justify-between">
111
+ <span className="font-mono text-[11px] font-medium text-muted-foreground">
112
+ {spans.length} {spans.length === 1 ? "state" : "states"}
113
+ </span>
114
+ <span className="font-mono text-[11px] tabular-nums text-foreground/80">{formatElapsed(total)}</span>
115
+ </div>
116
+ {/* flex-grow by duration, with a floor: a 0.8 s provisioning next to three days of Running
117
+ must still be a visible notch, or the strip would lie about what happened. */}
118
+ <div className="flex h-2.5 gap-px overflow-hidden rounded-sm" role="img" aria-label="Time spent in each state">
119
+ {spans.map((s, i) => (
120
+ <span
121
+ key={i}
122
+ className={cn("min-w-[3px] basis-0", s.status.dotClass)}
123
+ style={{ flexGrow: total > 0 ? s.duration : 1 }}
124
+ title={`${s.label}: ${formatElapsed(s.duration)}`}
125
+ />
126
+ ))}
127
+ </div>
128
+ </div>
129
+
130
+ <ol className="px-3 py-2.5">
131
+ {rows.map((s, i) => {
132
+ const source = s.event.source ? (SOURCE_LABELS[s.event.source] ?? s.event.source) : null
133
+ const last = i === rows.length - 1 && !hidden
134
+ return (
135
+ <li key={`${s.event.seq ?? ""}-${s.event.at}`} className="relative pb-2.5 pl-4 last:pb-0">
136
+ {/* The thread between two dots. Absent under the oldest row shown: nothing before it. */}
137
+ {!last && <span aria-hidden className="absolute top-3 bottom-0 left-[3px] w-px bg-border" />}
138
+ <WorkerStatusDot status={s.status} className="absolute top-[5px] left-0" />
139
+ <div className="flex items-baseline justify-between gap-2">
140
+ <span className={cn("truncate font-medium", s.current ? "text-foreground" : "text-foreground/80")}>{s.label}</span>
141
+ <span className="shrink-0 font-mono text-[11px] tabular-nums text-muted-foreground/80">{formatElapsed(s.duration)}</span>
142
+ </div>
143
+ {s.status.detail && <p className="mt-0.5 text-[11px] leading-snug text-foreground/70">{s.status.detail}</p>}
144
+ <p className="mt-0.5 truncate text-[10px] text-muted-foreground/60">
145
+ {s.current ? "since " : ""}
146
+ {formatShortDate(s.start, { time: true })}
147
+ {source && <> · {source}</>}
148
+ </p>
149
+ </li>
150
+ )
151
+ })}
152
+ </ol>
153
+
154
+ {hidden > 0 && (
155
+ <button
156
+ type="button"
157
+ onClick={() => setExpanded(true)}
158
+ className="flex w-full cursor-pointer items-center justify-center gap-1 border-t border-border/40 py-1.5 text-[11px] text-muted-foreground transition-colors hover:text-foreground"
159
+ >
160
+ <ChevronDownIcon className="h-3 w-3" />
161
+ Show {hidden} earlier
162
+ </button>
163
+ )}
164
+ </div>
165
+ )
166
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { resolveWorkerStatus, workerStatusConfig } from "./worker-status"
3
+
4
+ describe("resolveWorkerStatus — le node injoignable", () => {
5
+ it("ne prétend rien, et ne prétend surtout pas qu'un setup est en cours", () => {
6
+ const status = resolveWorkerStatus({ id: "w", state: "unknown" })
7
+ expect(status.key).toBe("unknown")
8
+ expect(status.label).toBe("Unknown")
9
+ expect(status.settingUp).toBe(false)
10
+ expect(status.running).toBe(false)
11
+ })
12
+
13
+ it("prime sur un dockerState périmé", () => {
14
+ // Le cas réel : le dernier `inspect` réussi disait « running », puis le node est parti. La
15
+ // pastille doit dire qu'on ne sait pas, pas répéter une observation qu'on ne peut plus refaire.
16
+ const status = resolveWorkerStatus({ id: "w", state: "unknown", dockerState: "running" })
17
+ expect(status.key).toBe("unknown")
18
+ })
19
+
20
+ it("dit pourquoi, quand le plan de contrôle l'a dit", () => {
21
+ const status = resolveWorkerStatus({
22
+ id: "w",
23
+ state: "unknown",
24
+ statusMeta: { reason: "node-disconnected", previousStatus: "ready" },
25
+ })
26
+ expect(status.detail).toBe("The node is unreachable")
27
+ })
28
+ })
29
+
30
+ describe("resolveWorkerStatus — le conteneur mort tout seul", () => {
31
+ it("se résout sans dockerState, pour un produit qui n'a qu'un champ", () => {
32
+ // Spunto Lite n'a pas de second axe : avant, `exited` n'était atteignable que par la branche
33
+ // docker et un worker mort retombait sur le repli `pending`.
34
+ const status = resolveWorkerStatus({ id: "w", state: "exited" })
35
+ expect(status.key).toBe("exited")
36
+ expect(status.label).toBe("Exited")
37
+ expect(status.running).toBe(false)
38
+ })
39
+
40
+ it("porte le code de sortie quand il y en a un", () => {
41
+ const status = resolveWorkerStatus({
42
+ id: "w",
43
+ state: "exited",
44
+ statusMeta: { reason: "oom-killed", exitCode: 137 },
45
+ })
46
+ expect(status.detail).toBe("Ran out of memory (exit 137)")
47
+ })
48
+
49
+ it("garde un code de sortie nul, qui est une valeur et pas une absence", () => {
50
+ const status = resolveWorkerStatus({
51
+ id: "w",
52
+ state: "exited",
53
+ statusMeta: { reason: "completed", exitCode: 0 },
54
+ })
55
+ expect(status.detail).toBe("The container finished and exited cleanly (exit 0)")
56
+ })
57
+
58
+ it("préfère toujours le message du plan de contrôle à sa propre formulation", () => {
59
+ const status = resolveWorkerStatus({
60
+ id: "w",
61
+ state: "exited",
62
+ statusMeta: { reason: "oom-killed", message: "Killed while compiling the kernel" },
63
+ })
64
+ expect(status.detail).toBe("Killed while compiling the kernel")
65
+ })
66
+ })
67
+
68
+ describe("resolveWorkerStatus — la tolérance qui fait vivre la table", () => {
69
+ it("n'invente pas de détail pour une raison qu'elle ne connaît pas", () => {
70
+ const status = resolveWorkerStatus({ id: "w", state: "ready", statusMeta: { reason: "sonar-storm" } })
71
+ expect(status.detail).toBeUndefined()
72
+ expect(status.key).toBe("running")
73
+ })
74
+
75
+ it("laisse les statuts connus intacts", () => {
76
+ expect(resolveWorkerStatus({ id: "w", state: "building" }).settingUp).toBe(true)
77
+ expect(resolveWorkerStatus({ id: "w", state: "ready", dockerState: "running" }).key).toBe("running")
78
+ expect(resolveWorkerStatus({ id: "w", state: "wat" }).key).toBe("pending")
79
+ })
80
+
81
+ it("donne une pastille à chaque clé, sans exception", () => {
82
+ for (const [key, config] of Object.entries(workerStatusConfig)) {
83
+ expect(config.label, `"${key}" has no label`).toBeTruthy()
84
+ expect(config.dotClass, `"${key}" has no dot`).toBeTruthy()
85
+ }
86
+ })
87
+ })
@@ -6,13 +6,18 @@
6
6
  // This module is the union of both, plus the transient states (`pulling`,
7
7
  // `stopping`, `deleting`) that only ever existed as ad-hoc `if`s above the table.
8
8
  //
9
- // `building` is the odd one out: it is the state a *control plane* reports, and
10
- // the only one here that Spunto Lite names and the dashboard does not. Both wait
11
- // for a project image before a container exists — Cloud does it inside
12
- // `provisioning` and rebuilds the fact client-side by joining the project's image
13
- // builds, Lite says it in the field. It is deliberately not folded into
14
- // `pulling`: building an image and pulling one are different waits, and only one
15
- // of them has a log worth opening.
9
+ // Three keys here are *control plane* states rather than docker ones, and they
10
+ // are the reason this table is not just a mapping of `docker inspect`:
11
+ //
12
+ // - `building` — waiting for the project image, before any container exists. Not
13
+ // folded into `pulling`: building an image and pulling one are different waits,
14
+ // and only one of them has a log worth opening.
15
+ // - `exited` — the container died without being asked to. It already existed as a
16
+ // *docker* state; it is now also a lifecycle one, so a product with a single
17
+ // `state` field resolves it without falling through to the fallback.
18
+ // - `unknown` — the node is unreachable, so we do not know. It claims nothing on
19
+ // purpose: showing the last known status as though it were current is the lie
20
+ // this key exists to avoid. `statusMeta.previousStatus` keeps what it was.
16
21
  //
17
22
  // No "use client": pure functions and hookless components, so a React Server
18
23
  // Component can call them.
@@ -30,6 +35,7 @@ export type WorkerStatusKey =
30
35
  | "pending"
31
36
  | "setup"
32
37
  | "building"
38
+ | "unknown"
33
39
  | "pulling"
34
40
  | "stopping"
35
41
  | "deleting"
@@ -89,6 +95,9 @@ export const workerStatusConfig: Record<WorkerStatusKey, WorkerStatusConfig> = {
89
95
  pending: BUSY,
90
96
  setup: { ...BUSY, label: "Setting up…" },
91
97
  building: { ...BUSY, label: "Building image…" },
98
+ // Volontairement neutre : ni jaune (rien n'est en cours, on ne sait pas), ni rouge
99
+ // (rien n'a échoué). C'est l'absence d'information, et elle a le droit de se voir.
100
+ unknown: { ...IDLE, label: "Unknown" },
92
101
  pulling: { ...BUSY, label: "Pulling image…" },
93
102
  stopping: { ...BUSY, label: "Stopping…" },
94
103
  deleting: { ...BUSY, label: "Deleting…" },
@@ -100,16 +109,51 @@ export interface WorkerStatus extends WorkerStatusConfig {
100
109
  settingUp: boolean
101
110
  /** Container up and past its setup — stats, git chips and the pulsing dot apply. */
102
111
  running: boolean
112
+ /**
113
+ * One sentence for what `statusMeta` carries — "Container ran out of memory", "was ready" —
114
+ * or `undefined` when there is nothing to add. The pill says *what*; this says *why*, and a
115
+ * consumer renders it beside the pill or not at all.
116
+ */
117
+ detail?: string
103
118
  }
104
119
 
105
120
  /** Lifecycle states that mean "not usable yet, a setup is in flight". */
106
121
  const SETUP_STATES = new Set(["provisioning", "starting", "setup"])
107
122
 
123
+ /**
124
+ * Fallback phrasing per reason, used only when the control plane sent no `message`.
125
+ *
126
+ * Deliberately short and deliberately incomplete: a reason this table has never heard of yields
127
+ * no detail rather than an invented one — same rule as the status table itself.
128
+ */
129
+ const REASON_PHRASES: Record<string, string> = {
130
+ "oom-killed": "Ran out of memory",
131
+ exited: "The container exited on its own",
132
+ completed: "The container finished and exited cleanly",
133
+ "removed-externally": "The container is gone from the node",
134
+ "setup-failed": "Setup failed inside the container",
135
+ "build-failed": "The project image could not be built",
136
+ "spawn-orphaned": "Startup was interrupted and cannot resume",
137
+ "node-lost": "The node disconnected before the container existed",
138
+ "node-disconnected": "The node is unreachable",
139
+ }
140
+
141
+ /** What `statusMeta` has to say, if anything: the message it carried, else its reason. */
142
+ function detailOf(meta: WorkerCardWorker["statusMeta"]): string | undefined {
143
+ if (!meta) return undefined
144
+ if (meta.message) return meta.message
145
+ const phrase = meta.reason ? REASON_PHRASES[meta.reason] : undefined
146
+ if (!phrase) return undefined
147
+ // `exitCode` is worth showing when it is there — `0` included, which is why this is not `||`.
148
+ return meta.exitCode != null ? `${phrase} (exit ${meta.exitCode})` : phrase
149
+ }
150
+
108
151
  /**
109
152
  * Worker snapshot → one status, tolerant of shapes it has never seen.
110
153
  *
111
154
  * Order matters: the *lifecycle* state wins whenever it describes an action in
112
- * flight (deleting, stopping, building, pulling, setting up), because docker still reports
155
+ * flight or already settled (deleting, stopping, unknown, exited, building, pulling, setting up),
156
+ * because docker still reports
113
157
  * the container as `running` throughout — showing "Running" while a worker is
114
158
  * being deleted is the bug this ordering exists to prevent. Past that, the
115
159
  * docker state is the truth. Apps with a single `state` field (Spunto Lite) fall
@@ -120,16 +164,24 @@ export function resolveWorkerStatus(worker: WorkerCardWorker): WorkerStatus {
120
164
  const state = worker.state ?? null
121
165
  const docker = worker.dockerState ?? null
122
166
 
167
+ const detail = detailOf(worker.statusMeta)
123
168
  const of = (key: WorkerStatusKey, flags?: Partial<Pick<WorkerStatus, "settingUp" | "running">>): WorkerStatus => ({
124
169
  key,
125
170
  ...workerStatusConfig[key],
126
171
  settingUp: false,
127
172
  running: key === "running",
173
+ ...(detail ? { detail } : {}),
128
174
  ...flags,
129
175
  })
130
176
 
131
177
  if (state === "deleting") return of("deleting")
132
178
  if (state === "stopping") return of("stopping")
179
+ // Le node est injoignable : on ne sait pas. Surtout pas `settingUp` — annoncer une progression
180
+ // pour une machine qu'on ne voit plus est exactement ce que ce statut existe pour éviter.
181
+ if (state === "unknown") return of("unknown")
182
+ // Mort tout seul. Le statut de cycle de vie prime sur `dockerState`, qui dirait la même chose
183
+ // quand il est là — et ne dirait rien du tout chez un produit qui n'a qu'un champ.
184
+ if (state === "exited") return of("exited")
133
185
  if (state === "building") return of("building", { settingUp: true })
134
186
  if (state === "pulling") return of("pulling", { settingUp: true })
135
187
  if (state && SETUP_STATES.has(state)) return of("setup", { settingUp: true })
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
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, formatDuration, formatRelativeTime } from "./utils"
4
+ export { cn, formatDuration, formatElapsed, formatRelativeTime } from "./utils"
5
5
  export * from "./colors"
6
6
 
7
7
  // Domain (Spunto-specific) components live behind their own entry point —
package/src/utils.ts CHANGED
@@ -50,3 +50,26 @@ export function formatDuration(ms: number): string {
50
50
  if (ms >= 1000) return `${(ms / 1000).toFixed(ms >= 10_000 ? 0 : 1)}s`
51
51
  return `${Math.max(0, Math.round(ms))}ms`
52
52
  }
53
+
54
+ /**
55
+ * A span that may last days: "40s", "4m 12s", "11m", "3h 12m", "3d 4h".
56
+ *
57
+ * `formatDuration` is for a setup phase and stops at seconds ("11520s"); this one is for how long
58
+ * a worker *stayed* somewhere, where the range is a second to a week. Two units at most, and the
59
+ * second is dropped once it stops carrying information (the seconds of an 11-minute build).
60
+ */
61
+ export function formatElapsed(ms: number): string {
62
+ if (ms < 10_000) return formatDuration(ms)
63
+ // Rounded once, to the second, before splitting: 59.8 s is "1m", not "0m 59s" nor "60s".
64
+ const totalSeconds = Math.round(ms / 1000)
65
+ if (totalSeconds < 60) return `${totalSeconds}s`
66
+ const minutes = Math.floor(totalSeconds / 60)
67
+ if (minutes < 60) {
68
+ const seconds = totalSeconds % 60
69
+ return minutes < 10 && seconds ? `${minutes}m ${seconds}s` : `${minutes}m`
70
+ }
71
+ const hours = Math.floor(minutes / 60)
72
+ if (hours < 24) return minutes % 60 ? `${hours}h ${minutes % 60}m` : `${hours}h`
73
+ const days = Math.floor(hours / 24)
74
+ return hours % 24 ? `${days}d ${hours % 24}h` : `${days}d`
75
+ }