@spunto/design-system 0.26.0 → 0.27.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
|
@@ -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
|
|
@@ -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
|
-
//
|
|
10
|
-
// the
|
|
11
|
-
//
|
|
12
|
-
// `
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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),
|
|
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 })
|