@spunto/design-system 0.9.0 → 0.9.2
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 +9 -35
- package/package.json +2 -6
- package/src/components/terminal-panel.tsx +695 -0
- package/src/components/terminal.tsx +152 -1
- package/src/index.ts +13 -6
- package/src/utils.ts +0 -23
- package/src/components/workers/creator-avatar.tsx +0 -38
- package/src/components/workers/git-branch-chips.tsx +0 -120
- package/src/components/workers/index.ts +0 -35
- package/src/components/workers/resource-bars.tsx +0 -100
- package/src/components/workers/setup-progress.tsx +0 -130
- package/src/components/workers/tag-chips.tsx +0 -132
- package/src/components/workers/tag-color.ts +0 -28
- package/src/components/workers/types.ts +0 -44
- package/src/components/workers/worker-card.tsx +0 -305
- package/src/components/workers/worker-status.tsx +0 -181
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
// Worker setup, as a percentage and a sentence.
|
|
2
|
-
//
|
|
3
|
-
// The phase → percentage switch and the phase → label switch were written twice,
|
|
4
|
-
// by hand, in two apps — and are the kind of thing that drifts silently (a phase
|
|
5
|
-
// added on the agent side lands in one app's switch and not the other's). One
|
|
6
|
-
// copy, here, plus the bar itself.
|
|
7
|
-
//
|
|
8
|
-
// Hookless, so it stays callable from a React Server Component.
|
|
9
|
-
|
|
10
|
-
import { CheckIcon, CircleIcon, LoaderIcon } from "lucide-react"
|
|
11
|
-
|
|
12
|
-
import { cn } from "../../utils"
|
|
13
|
-
import type { WorkerSetupStatus } from "./types"
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Setup completion, 0-100.
|
|
17
|
-
*
|
|
18
|
-
* The numbers are deliberately uneven: they're calibrated on how long each phase
|
|
19
|
-
* actually takes, not on how many phases there are. Cloning gets a 30-point
|
|
20
|
-
* span sub-divided by repository, because it's the phase where a user watches
|
|
21
|
-
* the longest and the only one with real sub-progress to report.
|
|
22
|
-
*/
|
|
23
|
-
export function setupProgress(status: WorkerSetupStatus | null | undefined): number {
|
|
24
|
-
if (!status) return 5
|
|
25
|
-
switch (status.phase) {
|
|
26
|
-
case "initializing": return 10
|
|
27
|
-
case "credentials": return 25
|
|
28
|
-
case "dotfiles": return 35
|
|
29
|
-
case "cloning": {
|
|
30
|
-
const repos = status.repos ?? []
|
|
31
|
-
const done = repos.filter((r) => r.state === "done").length
|
|
32
|
-
const total = repos.length || 1
|
|
33
|
-
return 40 + Math.round((done / total) * 30)
|
|
34
|
-
}
|
|
35
|
-
case "lifecycle": {
|
|
36
|
-
if (status.postCreate === "running") return 75
|
|
37
|
-
if (status.postCreate === "done" && status.postStart === "running") return 88
|
|
38
|
-
return 80
|
|
39
|
-
}
|
|
40
|
-
case "ready": return 100
|
|
41
|
-
case "error": return 100
|
|
42
|
-
default: return 5
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Human phase label — the line printed above the bar. */
|
|
47
|
-
export function phaseLabel(status: WorkerSetupStatus | null | undefined): string {
|
|
48
|
-
if (!status) return "Starting up…"
|
|
49
|
-
switch (status.phase) {
|
|
50
|
-
case "initializing": return "Starting up…"
|
|
51
|
-
case "credentials": return "Configuring credentials…"
|
|
52
|
-
case "dotfiles": return "Installing dotfiles…"
|
|
53
|
-
case "cloning": return "Cloning repositories…"
|
|
54
|
-
case "lifecycle":
|
|
55
|
-
if (status.postCreate === "running") return "Running post-create command…"
|
|
56
|
-
if (status.postStart === "running") return "Running post-start command…"
|
|
57
|
-
return "Running setup commands…"
|
|
58
|
-
case "ready": return "Ready"
|
|
59
|
-
case "error": return "Setup failed"
|
|
60
|
-
default: return "Setting up…"
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Total startup duration (ms) from the container-side phase timings, or null
|
|
66
|
-
* when we can't bracket it — old or crashed workers have no `ready` stamp, and
|
|
67
|
-
* an invented duration would be worse than no duration.
|
|
68
|
-
*/
|
|
69
|
-
export function setupTotalMs(status: WorkerSetupStatus | null | undefined): number | null {
|
|
70
|
-
const t = status?.timings
|
|
71
|
-
if (!t || t.initializing == null || t.ready == null) return null
|
|
72
|
-
const d = t.ready - t.initializing
|
|
73
|
-
return d > 0 ? d : null
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export interface SetupProgressProps {
|
|
77
|
-
status?: WorkerSetupStatus | null
|
|
78
|
-
/** Hides the phase line + percentage, keeping only the bar (table rows). */
|
|
79
|
-
barOnly?: boolean
|
|
80
|
-
className?: string
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Phase label + percentage + the bar itself. Red as soon as the setup failed. */
|
|
84
|
-
export function SetupProgress({ status, barOnly = false, className }: SetupProgressProps) {
|
|
85
|
-
const pct = setupProgress(status)
|
|
86
|
-
const failed = status?.phase === "error"
|
|
87
|
-
|
|
88
|
-
return (
|
|
89
|
-
<div className={cn("w-full", className)}>
|
|
90
|
-
{!barOnly && (
|
|
91
|
-
<div className="mb-1.5 flex items-center justify-between gap-2">
|
|
92
|
-
<span
|
|
93
|
-
className={cn(
|
|
94
|
-
"min-w-0 truncate font-mono text-[11px]",
|
|
95
|
-
failed ? "text-destructive" : "text-yellow-500 dark:text-yellow-400"
|
|
96
|
-
)}
|
|
97
|
-
>
|
|
98
|
-
{phaseLabel(status)}
|
|
99
|
-
</span>
|
|
100
|
-
<span className="shrink-0 font-mono text-[11px] tabular-nums text-muted-foreground">{pct}%</span>
|
|
101
|
-
</div>
|
|
102
|
-
)}
|
|
103
|
-
<div className="h-1 overflow-hidden rounded-full bg-muted">
|
|
104
|
-
<div
|
|
105
|
-
className={cn(
|
|
106
|
-
"h-full rounded-full transition-all duration-700 motion-reduce:transition-none",
|
|
107
|
-
failed ? "bg-destructive" : "bg-yellow-400"
|
|
108
|
-
)}
|
|
109
|
-
style={{ width: `${pct}%` }}
|
|
110
|
-
/>
|
|
111
|
-
</div>
|
|
112
|
-
</div>
|
|
113
|
-
)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export type StepState = "pending" | "running" | "done" | "error"
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* The per-step bullet of a detailed setup view (one repository being cloned, a
|
|
120
|
-
* lifecycle command running). Same four states, same four colours, everywhere.
|
|
121
|
-
*/
|
|
122
|
-
export function StepIndicator({ state, className }: { state: StepState; className?: string }) {
|
|
123
|
-
const common = cn("h-3 w-3 shrink-0", className)
|
|
124
|
-
|
|
125
|
-
if (state === "running")
|
|
126
|
-
return <LoaderIcon className={cn(common, "animate-spin text-yellow-400 motion-reduce:animate-none")} />
|
|
127
|
-
if (state === "done") return <CheckIcon className={cn(common, "text-green-500")} />
|
|
128
|
-
if (state === "error") return <CircleIcon className={cn(common, "fill-destructive text-destructive")} />
|
|
129
|
-
return <CircleIcon className={cn(common, "text-muted-foreground/30")} />
|
|
130
|
-
}
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
// Free-form worker tags, coloured by hash.
|
|
4
|
-
//
|
|
5
|
-
// Read-only by default (Spunto Lite's case). Pass `onAdd`/`onRemove` and the
|
|
6
|
-
// chips grow a cross and a "+" button — the *persistence* stays with the caller,
|
|
7
|
-
// this component only reports intent.
|
|
8
|
-
|
|
9
|
-
import { useEffect, useRef, useState } from "react"
|
|
10
|
-
import { PlusIcon, XIcon } from "lucide-react"
|
|
11
|
-
|
|
12
|
-
import { cn } from "../../utils"
|
|
13
|
-
import { tagColor } from "./tag-color"
|
|
14
|
-
|
|
15
|
-
export interface TagChipsProps {
|
|
16
|
-
tags: string[]
|
|
17
|
-
/** Adding stays enabled while the caller persists — the chip is already there. */
|
|
18
|
-
onAdd?: (tag: string) => void
|
|
19
|
-
onRemove?: (tag: string) => void
|
|
20
|
-
/** Longest accepted tag; anything past it is truncated on commit. */
|
|
21
|
-
maxLength?: number
|
|
22
|
-
className?: string
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function TagChips({ tags, onAdd, onRemove, maxLength = 32, className }: TagChipsProps) {
|
|
26
|
-
const editable = !!(onAdd || onRemove)
|
|
27
|
-
// Local echo so a freshly typed tag appears instantly, resynced whenever the
|
|
28
|
-
// caller's list changes — the server's answer always wins in the end.
|
|
29
|
-
const [local, setLocal] = useState<string[]>(tags)
|
|
30
|
-
const [input, setInput] = useState("")
|
|
31
|
-
const [adding, setAdding] = useState(false)
|
|
32
|
-
const inputRef = useRef<HTMLInputElement>(null)
|
|
33
|
-
|
|
34
|
-
// Compared by content, not by reference: callers routinely pass
|
|
35
|
-
// `worker.tags ?? []`, a fresh array on every poll, and swapping state for an
|
|
36
|
-
// identical list would re-render the whole card every few seconds.
|
|
37
|
-
useEffect(() => {
|
|
38
|
-
setLocal((prev) => (prev.length === tags.length && prev.every((t, i) => t === tags[i]) ? prev : tags))
|
|
39
|
-
}, [tags])
|
|
40
|
-
|
|
41
|
-
function commit() {
|
|
42
|
-
// Spaces and commas are the separators a user reflexively types; swallowing
|
|
43
|
-
// them here keeps "one chip = one word" true without an error message.
|
|
44
|
-
const raw = input.trim().replace(/,/g, "").slice(0, maxLength)
|
|
45
|
-
if (raw && !local.includes(raw)) {
|
|
46
|
-
setLocal([...local, raw])
|
|
47
|
-
onAdd?.(raw)
|
|
48
|
-
}
|
|
49
|
-
setInput("")
|
|
50
|
-
setAdding(false)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function remove(tag: string) {
|
|
54
|
-
setLocal(local.filter((t) => t !== tag))
|
|
55
|
-
onRemove?.(tag)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const shown = editable ? local : tags
|
|
59
|
-
if (!shown.length && !editable) return null
|
|
60
|
-
|
|
61
|
-
return (
|
|
62
|
-
<>
|
|
63
|
-
{shown.map((tag) => {
|
|
64
|
-
const c = tagColor(tag)
|
|
65
|
-
return (
|
|
66
|
-
<span
|
|
67
|
-
key={tag}
|
|
68
|
-
className={cn(
|
|
69
|
-
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium leading-none",
|
|
70
|
-
c.bg,
|
|
71
|
-
c.text,
|
|
72
|
-
c.border,
|
|
73
|
-
className
|
|
74
|
-
)}
|
|
75
|
-
>
|
|
76
|
-
{tag}
|
|
77
|
-
{onRemove && (
|
|
78
|
-
<button
|
|
79
|
-
type="button"
|
|
80
|
-
onClick={(e) => {
|
|
81
|
-
e.stopPropagation()
|
|
82
|
-
remove(tag)
|
|
83
|
-
}}
|
|
84
|
-
className="ml-0.5 cursor-pointer opacity-50 transition-opacity hover:opacity-100"
|
|
85
|
-
aria-label={`Remove tag ${tag}`}
|
|
86
|
-
>
|
|
87
|
-
<XIcon className="h-2.5 w-2.5" />
|
|
88
|
-
</button>
|
|
89
|
-
)}
|
|
90
|
-
</span>
|
|
91
|
-
)
|
|
92
|
-
})}
|
|
93
|
-
|
|
94
|
-
{onAdd &&
|
|
95
|
-
(adding ? (
|
|
96
|
-
<input
|
|
97
|
-
ref={inputRef}
|
|
98
|
-
autoFocus
|
|
99
|
-
value={input}
|
|
100
|
-
onChange={(e) => setInput(e.target.value.replace(/[\s,]/g, ""))}
|
|
101
|
-
onKeyDown={(e) => {
|
|
102
|
-
if (e.key === "Enter") {
|
|
103
|
-
e.preventDefault()
|
|
104
|
-
commit()
|
|
105
|
-
}
|
|
106
|
-
if (e.key === "Escape") {
|
|
107
|
-
setInput("")
|
|
108
|
-
setAdding(false)
|
|
109
|
-
}
|
|
110
|
-
}}
|
|
111
|
-
// Blur-commits rather than discards: clicking away after typing a
|
|
112
|
-
// tag reads as "done", never as "cancel".
|
|
113
|
-
onBlur={commit}
|
|
114
|
-
placeholder="tag…"
|
|
115
|
-
className="h-4 w-20 rounded-full border border-primary/40 bg-primary/5 px-2 text-[10px] text-primary outline-none focus:border-primary/60"
|
|
116
|
-
/>
|
|
117
|
-
) : (
|
|
118
|
-
<button
|
|
119
|
-
type="button"
|
|
120
|
-
onClick={(e) => {
|
|
121
|
-
e.stopPropagation()
|
|
122
|
-
setAdding(true)
|
|
123
|
-
}}
|
|
124
|
-
className="inline-flex cursor-pointer items-center rounded-full border border-dashed border-border px-1.5 py-0.5 text-[10px] text-muted-foreground/50 transition-colors hover:border-border/80 hover:text-muted-foreground"
|
|
125
|
-
aria-label="Add tag"
|
|
126
|
-
>
|
|
127
|
-
<PlusIcon className="h-2.5 w-2.5" />
|
|
128
|
-
</button>
|
|
129
|
-
))}
|
|
130
|
-
</>
|
|
131
|
-
)
|
|
132
|
-
}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
// Deterministic tag → pastel palette, split out of `tag-chips.tsx` (a "use client"
|
|
2
|
-
// module) so it stays callable from a React Server Component and from plain
|
|
3
|
-
// helpers — same reason `buttonVariants` has its own file.
|
|
4
|
-
|
|
5
|
-
const TAG_PALETTES = [
|
|
6
|
-
{ bg: "bg-orange-500/12", text: "text-orange-700 dark:text-orange-300", border: "border-orange-400/30" },
|
|
7
|
-
{ bg: "bg-amber-500/12", text: "text-amber-700 dark:text-amber-300", border: "border-amber-400/30" },
|
|
8
|
-
{ bg: "bg-lime-500/12", text: "text-lime-700 dark:text-lime-300", border: "border-lime-400/30" },
|
|
9
|
-
{ bg: "bg-green-500/12", text: "text-green-700 dark:text-green-300", border: "border-green-400/30" },
|
|
10
|
-
{ bg: "bg-teal-500/12", text: "text-teal-700 dark:text-teal-300", border: "border-teal-400/30" },
|
|
11
|
-
{ bg: "bg-sky-500/12", text: "text-sky-700 dark:text-sky-300", border: "border-sky-400/30" },
|
|
12
|
-
{ bg: "bg-violet-500/12", text: "text-violet-700 dark:text-violet-300", border: "border-violet-400/30" },
|
|
13
|
-
{ bg: "bg-pink-500/12", text: "text-pink-700 dark:text-pink-300", border: "border-pink-400/30" },
|
|
14
|
-
] as const
|
|
15
|
-
|
|
16
|
-
export type TagPalette = (typeof TAG_PALETTES)[number]
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Notion-style pastel picked from the tag's own characters, so the same tag is
|
|
20
|
-
* the same colour on every card, in every app, forever — no palette state, no
|
|
21
|
-
* assignment to persist. The classes are written out in full (never built by
|
|
22
|
-
* string concatenation) so Tailwind's scanner can see them.
|
|
23
|
-
*/
|
|
24
|
-
export function tagColor(tag: string): TagPalette {
|
|
25
|
-
let h = 0
|
|
26
|
-
for (let i = 0; i < tag.length; i++) h = (Math.imul(31, h) + tag.charCodeAt(i)) | 0
|
|
27
|
-
return TAG_PALETTES[Math.abs(h) % TAG_PALETTES.length]
|
|
28
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
// Structural, deliberately laxist shapes for everything worker-related.
|
|
2
|
-
//
|
|
3
|
-
// The design system must NOT import the OpenAPI-generated types of the Spunto
|
|
4
|
-
// dashboard (nor Spunto Lite's): the two apps model a worker differently — the
|
|
5
|
-
// dashboard has both a lifecycle `state` and a docker-level `dockerState`, Lite
|
|
6
|
-
// has a single `state`; the dashboard's `name` is nullable and backed by an
|
|
7
|
-
// `index`, Lite's is guaranteed. So the types below describe the *minimum* these
|
|
8
|
-
// components read, every field optional, and the state resolution tolerates an
|
|
9
|
-
// unknown value instead of crashing on it (see `resolveWorkerStatus`).
|
|
10
|
-
|
|
11
|
-
/** Container-side setup progress, as reported by the worker agent. */
|
|
12
|
-
export interface WorkerSetupStatus {
|
|
13
|
-
/** `initializing` | `credentials` | `dotfiles` | `cloning` | `lifecycle` | `ready` | `error`. */
|
|
14
|
-
phase?: string | null
|
|
15
|
-
repos?: { name?: string; state?: string | null }[] | null
|
|
16
|
-
postCreate?: string | null
|
|
17
|
-
postStart?: string | null
|
|
18
|
-
error?: string | null
|
|
19
|
-
/** Per-milestone start timestamps (epoch ms) — `initializing` → `ready` brackets the startup. */
|
|
20
|
-
timings?: Record<string, number> | null
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** The snapshot a `WorkerCard` draws. Everything but `id` is optional. */
|
|
24
|
-
export interface WorkerCardWorker {
|
|
25
|
-
id: string
|
|
26
|
-
name?: string | null
|
|
27
|
-
/** Fallback identity when `name` is empty — renders as "Workspace #3". */
|
|
28
|
-
index?: number | null
|
|
29
|
-
/** Lifecycle state: `provisioning` | `pulling` | `starting` | `setup` | `ready` | `stopping` | `stopped` | `deleting` | `error`. */
|
|
30
|
-
state?: string | null
|
|
31
|
-
/** Docker-level state: `running` | `stopped` | `exited` | `created` | `not_found` | `error`. */
|
|
32
|
-
dockerState?: string | null
|
|
33
|
-
createdAt?: string | null
|
|
34
|
-
projectVersion?: number | null
|
|
35
|
-
tags?: string[] | null
|
|
36
|
-
setupStatus?: WorkerSetupStatus | null
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Author of a worker — avatar + name, nothing else. */
|
|
40
|
-
export interface WorkerCreator {
|
|
41
|
-
name: string
|
|
42
|
-
login?: string
|
|
43
|
-
avatarUrl?: string | null
|
|
44
|
-
}
|
|
@@ -1,305 +0,0 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import type { ReactNode } from "react"
|
|
4
|
-
import {
|
|
5
|
-
AlertTriangleIcon,
|
|
6
|
-
ChevronRightIcon,
|
|
7
|
-
ClockIcon,
|
|
8
|
-
ServerIcon,
|
|
9
|
-
TimerIcon,
|
|
10
|
-
WifiOffIcon,
|
|
11
|
-
XIcon,
|
|
12
|
-
} from "lucide-react"
|
|
13
|
-
|
|
14
|
-
import { cn, formatDuration, formatRelativeTime } from "../../utils"
|
|
15
|
-
import { buttonVariants } from "../button-variants"
|
|
16
|
-
import { CreatorAvatar } from "./creator-avatar"
|
|
17
|
-
import { GitBranchChips, type GitRepoStatus } from "./git-branch-chips"
|
|
18
|
-
import { ResourceBars, type WorkerStats } from "./resource-bars"
|
|
19
|
-
import { SetupProgress, setupTotalMs } from "./setup-progress"
|
|
20
|
-
import { TagChips } from "./tag-chips"
|
|
21
|
-
import type { WorkerCardWorker, WorkerCreator } from "./types"
|
|
22
|
-
import { WorkerStatusDot, WorkerStatusPill, resolveWorkerStatus } from "./worker-status"
|
|
23
|
-
|
|
24
|
-
/** Renders the "View" affordance. A slot, so the package never imports `next/link`. */
|
|
25
|
-
export type LinkRender = (props: { href: string; className?: string; children: ReactNode }) => ReactNode
|
|
26
|
-
|
|
27
|
-
export interface WorkerCardProps {
|
|
28
|
-
worker: WorkerCardWorker
|
|
29
|
-
|
|
30
|
-
/** Target of the title + footer button. Omitted → the card isn't a link. */
|
|
31
|
-
href?: string
|
|
32
|
-
/**
|
|
33
|
-
* Escape hatches for app-specific rendering. `link` receives `{ href, className,
|
|
34
|
-
* children }` and must return an anchor-ish node (`next/link`, a router link…);
|
|
35
|
-
* without it the card falls back to a plain `<a>`.
|
|
36
|
-
*/
|
|
37
|
-
render?: { link?: LinkRender }
|
|
38
|
-
|
|
39
|
-
creator?: WorkerCreator
|
|
40
|
-
nodeName?: string
|
|
41
|
-
/** `false` draws the "Node offline" banner. `undefined` = unknown, no banner. */
|
|
42
|
-
nodeOnline?: boolean
|
|
43
|
-
/** Live container stats. Absent → no resource bars (the usual case for a stopped worker). */
|
|
44
|
-
stats?: WorkerStats | null
|
|
45
|
-
/** Checked-out repositories. Absent/empty → no chips. */
|
|
46
|
-
gitStatus?: GitRepoStatus[] | null
|
|
47
|
-
/** Chip → URL (e.g. code-server at that folder). Returning undefined renders a static chip. */
|
|
48
|
-
repoHref?: (repo: GitRepoStatus) => string | undefined
|
|
49
|
-
|
|
50
|
-
/** Tags are read-only unless at least one of these is given. */
|
|
51
|
-
onAddTag?: (tag: string) => void
|
|
52
|
-
onRemoveTag?: (tag: string) => void
|
|
53
|
-
|
|
54
|
-
/** Project's current config version — a worker below it gets the "Rebuild" banner. */
|
|
55
|
-
currentProjectVersion?: number
|
|
56
|
-
onRebuild?: () => void
|
|
57
|
-
rebuilding?: boolean
|
|
58
|
-
|
|
59
|
-
/** Error banner inside the card (a failed mutation, typically). */
|
|
60
|
-
error?: string | null
|
|
61
|
-
onDismissError?: () => void
|
|
62
|
-
|
|
63
|
-
/** The `⋯` menu, top-right. A slot: every entry in it calls the API. */
|
|
64
|
-
actions?: ReactNode
|
|
65
|
-
/** Replaces the default "View" footer entirely (extra buttons, a VS Code link…). */
|
|
66
|
-
footer?: ReactNode
|
|
67
|
-
/** Label of the default footer button. */
|
|
68
|
-
viewLabel?: string
|
|
69
|
-
|
|
70
|
-
className?: string
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* One worker, as a card: state, who spawned it, where it runs, what it's working
|
|
75
|
-
* on, and what you can do with it.
|
|
76
|
-
*
|
|
77
|
-
* **Purely presentational.** It receives a snapshot and draws it — no query, no
|
|
78
|
-
* mutation, no router. Everything that would talk to an API is a slot
|
|
79
|
-
* (`actions`, `footer`) or a callback (`onAddTag`, `onRebuild`…). That rule is
|
|
80
|
-
* what makes the same card renderable by the dashboard, by Spunto Lite, and by
|
|
81
|
-
* the showcase with hand-written fixtures. The card is the assembly; each brick
|
|
82
|
-
* (`WorkerStatusDot`, `SetupProgress`, `ResourceBars`, `GitBranchChips`,
|
|
83
|
-
* `TagChips`) is exported on its own because the table view needs them without
|
|
84
|
-
* the card.
|
|
85
|
-
*
|
|
86
|
-
* The layout is a fixed sequence of optional bands — header, setup bar, git
|
|
87
|
-
* chips, resource bars, banners, footer — and each band disappears when its data
|
|
88
|
-
* is absent, so a stopped worker collapses to a two-line card without any
|
|
89
|
-
* caller-side conditional.
|
|
90
|
-
*/
|
|
91
|
-
export function WorkerCard({
|
|
92
|
-
worker,
|
|
93
|
-
href,
|
|
94
|
-
render,
|
|
95
|
-
creator,
|
|
96
|
-
nodeName,
|
|
97
|
-
nodeOnline,
|
|
98
|
-
stats,
|
|
99
|
-
gitStatus,
|
|
100
|
-
repoHref,
|
|
101
|
-
onAddTag,
|
|
102
|
-
onRemoveTag,
|
|
103
|
-
currentProjectVersion,
|
|
104
|
-
onRebuild,
|
|
105
|
-
rebuilding = false,
|
|
106
|
-
error,
|
|
107
|
-
onDismissError,
|
|
108
|
-
actions,
|
|
109
|
-
footer,
|
|
110
|
-
viewLabel = "View",
|
|
111
|
-
className,
|
|
112
|
-
}: WorkerCardProps) {
|
|
113
|
-
const status = resolveWorkerStatus(worker)
|
|
114
|
-
const setup = worker.setupStatus ?? null
|
|
115
|
-
const startupMs = setupTotalMs(setup)
|
|
116
|
-
const title = worker.name || (worker.index != null ? `Workspace #${worker.index}` : worker.id)
|
|
117
|
-
|
|
118
|
-
// Live detail is only meaningful once the container is up *and* done setting
|
|
119
|
-
// up: mid-setup a stale "running" from docker would show stats for a worker
|
|
120
|
-
// that isn't usable yet.
|
|
121
|
-
const settled = status.running && !status.settingUp
|
|
122
|
-
const repos = settled ? (gitStatus ?? []) : []
|
|
123
|
-
const outdated =
|
|
124
|
-
!status.settingUp &&
|
|
125
|
-
worker.projectVersion != null &&
|
|
126
|
-
currentProjectVersion != null &&
|
|
127
|
-
worker.projectVersion < currentProjectVersion
|
|
128
|
-
|
|
129
|
-
const Link: LinkRender =
|
|
130
|
-
render?.link ?? (({ href: to, className: cls, children }) => <a href={to} className={cls}>{children}</a>)
|
|
131
|
-
|
|
132
|
-
return (
|
|
133
|
-
<div
|
|
134
|
-
data-slot="worker-card"
|
|
135
|
-
className={cn("overflow-hidden rounded-xl border border-border bg-card transition-colors hover:shadow-sm", className)}
|
|
136
|
-
>
|
|
137
|
-
{/* ── Header : état, nom, pills, tags, méta ── */}
|
|
138
|
-
<div className="flex items-start justify-between gap-3 px-4 pt-3.5 pb-3">
|
|
139
|
-
<div className="flex min-w-0 items-start gap-3">
|
|
140
|
-
<WorkerStatusDot status={status} className="mt-1" />
|
|
141
|
-
|
|
142
|
-
<div className="min-w-0 flex-1">
|
|
143
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
144
|
-
<span className="text-sm leading-none font-semibold">{title}</span>
|
|
145
|
-
<WorkerStatusPill status={status} />
|
|
146
|
-
{worker.projectVersion != null && (
|
|
147
|
-
<span className="inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5 font-mono text-[10px] leading-none text-muted-foreground">
|
|
148
|
-
v{worker.projectVersion}
|
|
149
|
-
</span>
|
|
150
|
-
)}
|
|
151
|
-
<TagChips tags={worker.tags ?? []} onAdd={onAddTag} onRemove={onRemoveTag} />
|
|
152
|
-
</div>
|
|
153
|
-
|
|
154
|
-
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
|
155
|
-
{creator && (
|
|
156
|
-
<span className="flex items-center gap-1.5">
|
|
157
|
-
<CreatorAvatar creator={creator} />
|
|
158
|
-
<span className="text-[11px] text-muted-foreground">{creator.name}</span>
|
|
159
|
-
</span>
|
|
160
|
-
)}
|
|
161
|
-
{nodeName && (
|
|
162
|
-
<>
|
|
163
|
-
<Dot />
|
|
164
|
-
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/60">
|
|
165
|
-
<ServerIcon className="h-3 w-3" />
|
|
166
|
-
{nodeName}
|
|
167
|
-
</span>
|
|
168
|
-
</>
|
|
169
|
-
)}
|
|
170
|
-
{worker.createdAt && (
|
|
171
|
-
<>
|
|
172
|
-
{creator || nodeName ? <Dot /> : null}
|
|
173
|
-
<span
|
|
174
|
-
className="flex items-center gap-1 text-[11px] text-muted-foreground/60"
|
|
175
|
-
title={new Date(worker.createdAt).toLocaleString()}
|
|
176
|
-
>
|
|
177
|
-
<ClockIcon className="h-3 w-3" />
|
|
178
|
-
{formatRelativeTime(worker.createdAt)}
|
|
179
|
-
</span>
|
|
180
|
-
</>
|
|
181
|
-
)}
|
|
182
|
-
{/* Only worth showing once it's over — during setup the live bar says it better. */}
|
|
183
|
-
{!status.settingUp && startupMs != null && (
|
|
184
|
-
<>
|
|
185
|
-
<Dot />
|
|
186
|
-
<span
|
|
187
|
-
className="flex items-center gap-1 text-[11px] text-muted-foreground/60"
|
|
188
|
-
title="Worker startup time (clone, features, postCreate…)"
|
|
189
|
-
>
|
|
190
|
-
<TimerIcon className="h-3 w-3" />
|
|
191
|
-
started in {formatDuration(startupMs)}
|
|
192
|
-
</span>
|
|
193
|
-
</>
|
|
194
|
-
)}
|
|
195
|
-
</div>
|
|
196
|
-
</div>
|
|
197
|
-
</div>
|
|
198
|
-
|
|
199
|
-
{actions}
|
|
200
|
-
</div>
|
|
201
|
-
|
|
202
|
-
{status.settingUp && (
|
|
203
|
-
<div className="px-4 pb-3">
|
|
204
|
-
<SetupProgress status={setup} />
|
|
205
|
-
</div>
|
|
206
|
-
)}
|
|
207
|
-
|
|
208
|
-
{repos.length > 0 && (
|
|
209
|
-
<div className="px-4 pb-3">
|
|
210
|
-
<GitBranchChips repos={repos} repoHref={repoHref} />
|
|
211
|
-
</div>
|
|
212
|
-
)}
|
|
213
|
-
|
|
214
|
-
{settled && stats && (
|
|
215
|
-
<div className="px-4 pb-3">
|
|
216
|
-
<ResourceBars stats={stats} />
|
|
217
|
-
</div>
|
|
218
|
-
)}
|
|
219
|
-
|
|
220
|
-
{nodeOnline === false && (
|
|
221
|
-
<Banner tone="offline">
|
|
222
|
-
<WifiOffIcon className="h-3.5 w-3.5 shrink-0" />
|
|
223
|
-
Node offline
|
|
224
|
-
</Banner>
|
|
225
|
-
)}
|
|
226
|
-
|
|
227
|
-
{outdated && (
|
|
228
|
-
<Banner tone="warn">
|
|
229
|
-
<span className="flex min-w-0 items-center gap-1.5">
|
|
230
|
-
<AlertTriangleIcon className="h-3.5 w-3.5 shrink-0" />
|
|
231
|
-
Config <span className="font-mono font-semibold">v{worker.projectVersion}</span> → project at{" "}
|
|
232
|
-
<span className="font-mono font-semibold">v{currentProjectVersion}</span>
|
|
233
|
-
</span>
|
|
234
|
-
{onRebuild && (
|
|
235
|
-
<button
|
|
236
|
-
type="button"
|
|
237
|
-
disabled={rebuilding}
|
|
238
|
-
onClick={onRebuild}
|
|
239
|
-
className="shrink-0 cursor-pointer text-[11px] font-medium hover:underline disabled:opacity-50"
|
|
240
|
-
>
|
|
241
|
-
{rebuilding ? "Rebuilding…" : "Rebuild"}
|
|
242
|
-
</button>
|
|
243
|
-
)}
|
|
244
|
-
</Banner>
|
|
245
|
-
)}
|
|
246
|
-
|
|
247
|
-
{error && (
|
|
248
|
-
<Banner tone="error">
|
|
249
|
-
<span className="truncate font-mono">{error}</span>
|
|
250
|
-
{onDismissError && (
|
|
251
|
-
<button
|
|
252
|
-
type="button"
|
|
253
|
-
onClick={onDismissError}
|
|
254
|
-
aria-label="Dismiss error"
|
|
255
|
-
className="shrink-0 cursor-pointer opacity-60 transition-opacity hover:opacity-100"
|
|
256
|
-
>
|
|
257
|
-
<XIcon className="h-3.5 w-3.5" />
|
|
258
|
-
</button>
|
|
259
|
-
)}
|
|
260
|
-
</Banner>
|
|
261
|
-
)}
|
|
262
|
-
|
|
263
|
-
{(footer ?? href) && (
|
|
264
|
-
<div className="border-t border-border/50 bg-muted/20 px-3 py-2">
|
|
265
|
-
{footer ?? (
|
|
266
|
-
<Link href={href!} className={cn(buttonVariants({ variant: "default", size: "sm" }), "h-7 w-full gap-1.5 text-xs")}>
|
|
267
|
-
{viewLabel}
|
|
268
|
-
<ChevronRightIcon className="h-3.5 w-3.5" />
|
|
269
|
-
</Link>
|
|
270
|
-
)}
|
|
271
|
-
</div>
|
|
272
|
-
)}
|
|
273
|
-
</div>
|
|
274
|
-
)
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/** The faint separator between two meta items. */
|
|
278
|
-
function Dot() {
|
|
279
|
-
return <span className="text-[11px] text-muted-foreground/30">·</span>
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* The card's inline bands. Warm tones = something to know (an unreachable node,
|
|
284
|
-
* a stale config), red = something went wrong. They keep the card's horizontal
|
|
285
|
-
* margin so they read as *inside* the card, not as a strip glued across it.
|
|
286
|
-
*/
|
|
287
|
-
const BANNER_TONES = {
|
|
288
|
-
/** Infrastructure problem — orange, one notch louder than a stale config. */
|
|
289
|
-
offline: "border-orange-500/25 bg-orange-500/5 text-orange-600 dark:text-orange-400",
|
|
290
|
-
warn: "border-amber-500/25 bg-amber-500/5 text-amber-600 dark:text-amber-400",
|
|
291
|
-
error: "border-destructive/25 bg-destructive/5 text-destructive",
|
|
292
|
-
} as const
|
|
293
|
-
|
|
294
|
-
function Banner({ tone, children }: { tone: keyof typeof BANNER_TONES; children: ReactNode }) {
|
|
295
|
-
return (
|
|
296
|
-
<div
|
|
297
|
-
className={cn(
|
|
298
|
-
"mx-4 mb-3 flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-xs",
|
|
299
|
-
BANNER_TONES[tone]
|
|
300
|
-
)}
|
|
301
|
-
>
|
|
302
|
-
{children}
|
|
303
|
-
</div>
|
|
304
|
-
)
|
|
305
|
-
}
|