@spunto/design-system 0.27.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 +1 -1
- package/src/components/workers/index.ts +5 -2
- package/src/components/workers/types.ts +22 -0
- package/src/components/workers/worker-cockpit.tsx +17 -2
- package/src/components/workers/worker-status-history.test.ts +85 -0
- package/src/components/workers/worker-status-history.tsx +166 -0
- package/src/index.ts +1 -1
- package/src/utils.ts +23 -0
package/package.json
CHANGED
|
@@ -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"
|
|
@@ -68,3 +68,25 @@ export interface WorkerCockpitWorker extends WorkerCardWorker {
|
|
|
68
68
|
branch?: string | null
|
|
69
69
|
containerId?: string | null
|
|
70
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
|
+
}
|
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
|
+
}
|