@spunto/design-system 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -1
- package/package.json +6 -1
- package/src/components/command-palette.tsx +702 -0
- package/src/components/kbd.tsx +117 -0
- package/src/components/workers/creator-avatar.tsx +38 -0
- package/src/components/workers/git-branch-chips.tsx +128 -0
- package/src/components/workers/index.ts +35 -0
- package/src/components/workers/resource-bars.tsx +104 -0
- package/src/components/workers/setup-progress.tsx +130 -0
- package/src/components/workers/tag-chips.tsx +139 -0
- package/src/components/workers/tag-color.ts +28 -0
- package/src/components/workers/types.ts +44 -0
- package/src/components/workers/worker-card.tsx +345 -0
- package/src/components/workers/worker-status.tsx +181 -0
- package/src/index.ts +40 -1
- package/src/utils.ts +23 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, type ComponentProps } from "react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "../utils"
|
|
6
|
+
|
|
7
|
+
// One key = one cap. Kept as a constant so `Kbd` and every caller (the command
|
|
8
|
+
// palette footer, an item's `shortcut`) draw the exact same box.
|
|
9
|
+
const capClass =
|
|
10
|
+
"inline-flex h-5 min-w-5 items-center justify-center rounded border border-border bg-muted px-1.5 font-sans text-[0.6875rem] leading-none font-medium text-muted-foreground"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Symbol shown per modifier, per platform, plus the accessible name a screen
|
|
14
|
+
* reader gets instead of the glyph ("⌘" alone reads as nothing useful).
|
|
15
|
+
* `mod` is the portable modifier: ⌘ on Apple, Ctrl everywhere else — the one
|
|
16
|
+
* every "⌘K" shortcut actually means.
|
|
17
|
+
*/
|
|
18
|
+
const KEYS: Record<string, { mac: string; other: string; label: string }> = {
|
|
19
|
+
mod: { mac: "⌘", other: "Ctrl", label: "Ctrl" },
|
|
20
|
+
meta: { mac: "⌘", other: "Win", label: "Meta" },
|
|
21
|
+
cmd: { mac: "⌘", other: "Ctrl", label: "Commande" },
|
|
22
|
+
command: { mac: "⌘", other: "Ctrl", label: "Commande" },
|
|
23
|
+
ctrl: { mac: "⌃", other: "Ctrl", label: "Ctrl" },
|
|
24
|
+
control: { mac: "⌃", other: "Ctrl", label: "Ctrl" },
|
|
25
|
+
alt: { mac: "⌥", other: "Alt", label: "Alt" },
|
|
26
|
+
option: { mac: "⌥", other: "Alt", label: "Alt" },
|
|
27
|
+
shift: { mac: "⇧", other: "Maj", label: "Maj" },
|
|
28
|
+
enter: { mac: "↵", other: "↵", label: "Entrée" },
|
|
29
|
+
return: { mac: "↵", other: "↵", label: "Entrée" },
|
|
30
|
+
esc: { mac: "esc", other: "esc", label: "Échap" },
|
|
31
|
+
escape: { mac: "esc", other: "esc", label: "Échap" },
|
|
32
|
+
backspace: { mac: "⌫", other: "⌫", label: "Retour arrière" },
|
|
33
|
+
delete: { mac: "⌦", other: "Suppr", label: "Suppr" },
|
|
34
|
+
tab: { mac: "⇥", other: "⇥", label: "Tab" },
|
|
35
|
+
space: { mac: "␣", other: "␣", label: "Espace" },
|
|
36
|
+
up: { mac: "↑", other: "↑", label: "Flèche haut" },
|
|
37
|
+
down: { mac: "↓", other: "↓", label: "Flèche bas" },
|
|
38
|
+
left: { mac: "←", other: "←", label: "Flèche gauche" },
|
|
39
|
+
right: { mac: "→", other: "→", label: "Flèche droite" },
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Apple platform detection, deferred to an effect on purpose: the package is
|
|
44
|
+
* consumed from a Next.js App Router, so the first render must be identical on
|
|
45
|
+
* the server and on the client. We render the portable label ("Ctrl") first and
|
|
46
|
+
* swap to "⌘" after mount rather than reading `navigator` during render.
|
|
47
|
+
*/
|
|
48
|
+
function useApplePlatform(): boolean {
|
|
49
|
+
const [isApple, setIsApple] = useState(false)
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const ua = navigator.userAgent
|
|
52
|
+
setIsApple(/Mac|iPhone|iPad|iPod/.test(ua))
|
|
53
|
+
}, [])
|
|
54
|
+
return isApple
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function keyGlyph(part: string, isApple: boolean): string {
|
|
58
|
+
const known = KEYS[part.toLowerCase()]
|
|
59
|
+
if (known) return isApple ? known.mac : known.other
|
|
60
|
+
// Single letters read better upper-cased ("K"), longer names as written ("F5").
|
|
61
|
+
return part.length === 1 ? part.toUpperCase() : part
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function keyLabel(part: string): string {
|
|
65
|
+
return KEYS[part.toLowerCase()]?.label ?? part.toUpperCase()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface KbdProps extends ComponentProps<"kbd"> {
|
|
69
|
+
/**
|
|
70
|
+
* Shortcut expressed as `"mod+shift+p"` — rendered as one cap per key, with
|
|
71
|
+
* `mod` resolved to ⌘ or Ctrl depending on the platform. Omit it and pass
|
|
72
|
+
* children instead to draw a single raw cap (`<Kbd>↵</Kbd>`).
|
|
73
|
+
*/
|
|
74
|
+
keys?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A keyboard key, drawn as a cap. Either a raw one (`<Kbd>↵</Kbd>`) or a whole
|
|
79
|
+
* shortcut (`<Kbd keys="mod+k" />` → ⌘ K), which is the form the command palette
|
|
80
|
+
* uses for item shortcuts and footer hints.
|
|
81
|
+
*
|
|
82
|
+
* The accessible name is spelled out ("Commande + K"), because the glyphs alone
|
|
83
|
+
* announce as gibberish — or as nothing at all.
|
|
84
|
+
*/
|
|
85
|
+
function Kbd({ className, keys, children, ...props }: KbdProps) {
|
|
86
|
+
const isApple = useApplePlatform()
|
|
87
|
+
|
|
88
|
+
if (!keys) {
|
|
89
|
+
return (
|
|
90
|
+
<kbd data-slot="kbd" className={cn(capClass, className)} {...props}>
|
|
91
|
+
{children}
|
|
92
|
+
</kbd>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const parts = keys
|
|
97
|
+
.split("+")
|
|
98
|
+
.map((p) => p.trim())
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<kbd
|
|
103
|
+
data-slot="kbd"
|
|
104
|
+
aria-label={parts.map(keyLabel).join(" + ")}
|
|
105
|
+
className={cn("inline-flex items-center gap-1 border-0 bg-transparent p-0", className)}
|
|
106
|
+
{...props}
|
|
107
|
+
>
|
|
108
|
+
{parts.map((part, i) => (
|
|
109
|
+
<kbd key={`${part}-${i}`} aria-hidden className={capClass}>
|
|
110
|
+
{keyGlyph(part, isApple)}
|
|
111
|
+
</kbd>
|
|
112
|
+
))}
|
|
113
|
+
</kbd>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export { Kbd }
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Who spawned the worker, in 20 px.
|
|
2
|
+
//
|
|
3
|
+
// A plain <img>, deliberately: the design system is consumed by two Next apps
|
|
4
|
+
// *and* by a Vite showcase, so it can't reach for `next/image`. Avatars come
|
|
5
|
+
// from GitHub/Google CDNs already sized for this.
|
|
6
|
+
|
|
7
|
+
import { cn } from "../../utils"
|
|
8
|
+
import type { WorkerCreator } from "./types"
|
|
9
|
+
|
|
10
|
+
export function CreatorAvatar({ creator, className }: { creator: WorkerCreator; className?: string }) {
|
|
11
|
+
if (creator.avatarUrl) {
|
|
12
|
+
return (
|
|
13
|
+
<img
|
|
14
|
+
src={creator.avatarUrl}
|
|
15
|
+
alt={creator.name}
|
|
16
|
+
className={cn("h-5 w-5 shrink-0 rounded-full object-cover ring-1 ring-border", className)}
|
|
17
|
+
/>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const initials = creator.name
|
|
22
|
+
.split(" ")
|
|
23
|
+
.map((n) => n[0])
|
|
24
|
+
.join("")
|
|
25
|
+
.toUpperCase()
|
|
26
|
+
.slice(0, 2)
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<span
|
|
30
|
+
className={cn(
|
|
31
|
+
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-[9px] font-semibold text-muted-foreground ring-1 ring-border",
|
|
32
|
+
className
|
|
33
|
+
)}
|
|
34
|
+
>
|
|
35
|
+
{initials}
|
|
36
|
+
</span>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// "repo · branch +3" chips — what a worker is actually working on.
|
|
2
|
+
//
|
|
3
|
+
// The dashboard's version shows the modified count; Lite's also shows ahead /
|
|
4
|
+
// behind. This is the union: every counter is rendered only when the caller
|
|
5
|
+
// supplies it, so neither app has to send a field it doesn't have.
|
|
6
|
+
|
|
7
|
+
import { ArrowDownIcon, ArrowUpIcon, ExternalLinkIcon, GitBranchIcon } from "lucide-react"
|
|
8
|
+
|
|
9
|
+
import { cn } from "../../utils"
|
|
10
|
+
|
|
11
|
+
export interface GitRepoStatus {
|
|
12
|
+
/** Absolute path of the checkout inside the worker — also the React key. */
|
|
13
|
+
path: string
|
|
14
|
+
branch?: string | null
|
|
15
|
+
/** Display name; defaults to the last segment of `path`. */
|
|
16
|
+
name?: string
|
|
17
|
+
/** Uncommitted files. */
|
|
18
|
+
modified?: number
|
|
19
|
+
/** Commits ahead of / behind the upstream branch. */
|
|
20
|
+
ahead?: number
|
|
21
|
+
behind?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface GitBranchChipsProps {
|
|
25
|
+
repos: GitRepoStatus[]
|
|
26
|
+
/**
|
|
27
|
+
* Chip → URL. A slot, not a link component: the design system knows nothing
|
|
28
|
+
* about code-server URLs, and returning `undefined` renders a plain span.
|
|
29
|
+
*/
|
|
30
|
+
repoHref?: (repo: GitRepoStatus) => string | undefined
|
|
31
|
+
className?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function repoName(repo: GitRepoStatus): string {
|
|
35
|
+
return repo.name ?? repo.path.split("/").filter(Boolean).pop() ?? repo.path
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One chip per checked-out repository, wrapping onto as many lines as needed. */
|
|
39
|
+
export function GitBranchChips({ repos, repoHref, className }: GitBranchChipsProps) {
|
|
40
|
+
if (!repos.length) return null
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
// `@container` so the branch cap below reads *this row's* width: the same
|
|
44
|
+
// chips sit in a 600 px card on a desktop and in a 290 px one on a phone,
|
|
45
|
+
// and a viewport media query can't tell those apart.
|
|
46
|
+
<div data-slot="git-branch-chips" className={cn("@container flex flex-wrap gap-1.5", className)}>
|
|
47
|
+
{repos.map((repo) => {
|
|
48
|
+
const href = repoHref?.(repo)
|
|
49
|
+
const Tag = href ? "a" : "span"
|
|
50
|
+
return (
|
|
51
|
+
<Tag
|
|
52
|
+
key={repo.path}
|
|
53
|
+
{...(href ? { href, target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
54
|
+
title={repo.path}
|
|
55
|
+
// `max-w-full` is what keeps a chip inside the card: without it a
|
|
56
|
+
// long "repo · branch" is a single unbreakable row that runs under
|
|
57
|
+
// the card's rounded edge and gets clipped.
|
|
58
|
+
className={cn(
|
|
59
|
+
"inline-flex min-w-0 max-w-full items-center gap-1 rounded-md border border-border bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground",
|
|
60
|
+
href && "transition-colors hover:border-border/80 hover:bg-muted/70"
|
|
61
|
+
)}
|
|
62
|
+
>
|
|
63
|
+
<span className="shrink-0 font-medium text-foreground/70">{repoName(repo)}</span>
|
|
64
|
+
<span className="mx-0.5 shrink-0 text-muted-foreground/40">·</span>
|
|
65
|
+
<GitBranchIcon className="h-2.5 w-2.5 shrink-0" />
|
|
66
|
+
{/* The branch is the only part allowed to give up room — the repo
|
|
67
|
+
name and the counters are short and carry more meaning. */}
|
|
68
|
+
<span className="min-w-0 max-w-[6rem] truncate @min-[20rem]:max-w-[120px]">{repo.branch || "—"}</span>
|
|
69
|
+
<RepoCounters repo={repo} />
|
|
70
|
+
{href && <ExternalLinkIcon className="ml-0.5 h-2.5 w-2.5 shrink-0 opacity-40" />}
|
|
71
|
+
</Tag>
|
|
72
|
+
)
|
|
73
|
+
})}
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Amber for local work not yet committed/pushed, muted for what's merely behind. */
|
|
79
|
+
function RepoCounters({ repo }: { repo: GitRepoStatus }) {
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
{!!repo.modified && <span className="ml-0.5 shrink-0 text-amber-500 dark:text-amber-400">+{repo.modified}</span>}
|
|
83
|
+
{!!repo.ahead && (
|
|
84
|
+
<span className="ml-0.5 inline-flex shrink-0 items-center text-amber-500 dark:text-amber-400">
|
|
85
|
+
<ArrowUpIcon className="h-2.5 w-2.5" />
|
|
86
|
+
{repo.ahead}
|
|
87
|
+
</span>
|
|
88
|
+
)}
|
|
89
|
+
{!!repo.behind && (
|
|
90
|
+
<span className="ml-0.5 inline-flex shrink-0 items-center text-muted-foreground/70">
|
|
91
|
+
<ArrowDownIcon className="h-2.5 w-2.5" />
|
|
92
|
+
{repo.behind}
|
|
93
|
+
</span>
|
|
94
|
+
)}
|
|
95
|
+
</>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface GitStatusSummaryProps {
|
|
100
|
+
repos: GitRepoStatus[]
|
|
101
|
+
/** Heading above the list — e.g. "Open in VS Code" inside a tooltip. */
|
|
102
|
+
title?: string
|
|
103
|
+
className?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The same information as the chips, stacked one repo per line. Meant for the
|
|
108
|
+
* inside of a tooltip or a popover, where chips would wrap badly — the tooltip
|
|
109
|
+
* itself stays the app's business (the design system doesn't decide what opens
|
|
110
|
+
* it).
|
|
111
|
+
*/
|
|
112
|
+
export function GitStatusSummary({ repos, title, className }: GitStatusSummaryProps) {
|
|
113
|
+
if (!repos.length) return null
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<span data-slot="git-status-summary" className={cn("flex flex-col gap-1", className)}>
|
|
117
|
+
{title && <span className="text-[10px] uppercase tracking-wide opacity-60">{title}</span>}
|
|
118
|
+
{repos.map((repo) => (
|
|
119
|
+
<span key={repo.path} className="flex items-center gap-1.5">
|
|
120
|
+
<span className="font-medium">{repoName(repo)}</span>
|
|
121
|
+
<GitBranchIcon className="h-3 w-3 opacity-60" />
|
|
122
|
+
<span className="font-mono">{repo.branch || "—"}</span>
|
|
123
|
+
<RepoCounters repo={repo} />
|
|
124
|
+
</span>
|
|
125
|
+
))}
|
|
126
|
+
</span>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// @spunto/design-system/workers — Spunto's *domain* components.
|
|
2
|
+
//
|
|
3
|
+
// Deliberately a sub-export rather than part of the main entry: the root of the
|
|
4
|
+
// package stays "primitives with no domain" (a Button knows nothing about a
|
|
5
|
+
// worker), and a third party consuming the design system never has to carry the
|
|
6
|
+
// notion of a worker. New business components go here — see the README's
|
|
7
|
+
// "Domain components" section for the rule.
|
|
8
|
+
|
|
9
|
+
export { WorkerCard } from "./worker-card"
|
|
10
|
+
export type { WorkerCardProps, LinkRender } from "./worker-card"
|
|
11
|
+
|
|
12
|
+
export { WorkerStatusDot, WorkerStatusPill, resolveWorkerStatus, workerStatusConfig } from "./worker-status"
|
|
13
|
+
export type { WorkerStatus, WorkerStatusConfig, WorkerStatusKey } from "./worker-status"
|
|
14
|
+
|
|
15
|
+
export { SetupProgress, StepIndicator, phaseLabel, setupProgress, setupTotalMs } from "./setup-progress"
|
|
16
|
+
export type { SetupProgressProps, StepState } from "./setup-progress"
|
|
17
|
+
|
|
18
|
+
export { ResourceBars } from "./resource-bars"
|
|
19
|
+
export type { ResourceBarsProps, WorkerStats } from "./resource-bars"
|
|
20
|
+
|
|
21
|
+
export { GitBranchChips, GitStatusSummary, repoName } from "./git-branch-chips"
|
|
22
|
+
export type { GitBranchChipsProps, GitRepoStatus, GitStatusSummaryProps } from "./git-branch-chips"
|
|
23
|
+
|
|
24
|
+
export { TagChips } from "./tag-chips"
|
|
25
|
+
export type { TagChipsProps } from "./tag-chips"
|
|
26
|
+
export { tagColor } from "./tag-color"
|
|
27
|
+
export type { TagPalette } from "./tag-color"
|
|
28
|
+
|
|
29
|
+
export { CreatorAvatar } from "./creator-avatar"
|
|
30
|
+
|
|
31
|
+
export type { WorkerCardWorker, WorkerCreator, WorkerSetupStatus } from "./types"
|
|
32
|
+
|
|
33
|
+
// Re-exported here too so a consumer of the worker surface doesn't have to
|
|
34
|
+
// import the root entry just to format the timestamps it feeds these components.
|
|
35
|
+
export { cn, formatDuration, formatRelativeTime } from "../../utils"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// CPU + memory of a running container, as two thin bars.
|
|
2
|
+
//
|
|
3
|
+
// Written identically in the dashboard's card and its table row — the `compact`
|
|
4
|
+
// flag is the only thing that ever differed between the two copies.
|
|
5
|
+
|
|
6
|
+
import type { ReactNode } from "react"
|
|
7
|
+
import { CpuIcon, MemoryStickIcon } from "lucide-react"
|
|
8
|
+
|
|
9
|
+
import { cn } from "../../utils"
|
|
10
|
+
|
|
11
|
+
export interface WorkerStats {
|
|
12
|
+
cpuPercent: number
|
|
13
|
+
memUsageMb: number
|
|
14
|
+
memLimitMb?: number
|
|
15
|
+
memPercent?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ResourceBarsProps {
|
|
19
|
+
stats: WorkerStats
|
|
20
|
+
/** Drops the framed panel — just the two bars, for a table cell or a tooltip. */
|
|
21
|
+
compact?: boolean
|
|
22
|
+
className?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Past this, the bar turns red: the container is about to be the problem. */
|
|
26
|
+
const DANGER = 80
|
|
27
|
+
|
|
28
|
+
export function ResourceBars({ stats, compact = false, className }: ResourceBarsProps) {
|
|
29
|
+
const cpu = stats.cpuPercent ?? 0
|
|
30
|
+
const mem = stats.memPercent ?? 0
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div
|
|
34
|
+
data-slot="resource-bars"
|
|
35
|
+
className={cn(
|
|
36
|
+
// Side by side as soon as there's room for it, stacked below ~240 px —
|
|
37
|
+
// under that the label and its figure end up touching. Container query,
|
|
38
|
+
// not a media query: these bars live in a card, a table cell and a
|
|
39
|
+
// sidebar, and only their own width tells them what to do.
|
|
40
|
+
"@container grid grid-cols-1 gap-3 @min-[15rem]:grid-cols-2",
|
|
41
|
+
!compact && "rounded-lg border border-border/40 bg-muted/20 px-3 py-2.5",
|
|
42
|
+
className
|
|
43
|
+
)}
|
|
44
|
+
>
|
|
45
|
+
<Metric
|
|
46
|
+
icon={<CpuIcon className="h-3 w-3" />}
|
|
47
|
+
label="CPU"
|
|
48
|
+
value={`${cpu.toFixed(1)}%`}
|
|
49
|
+
valueClass={cpu > DANGER ? "text-orange-500 dark:text-orange-400" : "text-foreground/70"}
|
|
50
|
+
percent={cpu}
|
|
51
|
+
// Flame for CPU, Build amber for memory: two bars side by side need to be
|
|
52
|
+
// told apart at a glance, and both hues already mean "compute" here.
|
|
53
|
+
barClass={cpu > DANGER ? "bg-destructive" : "bg-primary"}
|
|
54
|
+
/>
|
|
55
|
+
<Metric
|
|
56
|
+
icon={<MemoryStickIcon className="h-3 w-3" />}
|
|
57
|
+
label="Memory"
|
|
58
|
+
value={
|
|
59
|
+
<>
|
|
60
|
+
{(stats.memUsageMb ?? 0).toFixed(0)}
|
|
61
|
+
<span className="text-muted-foreground/50"> MB</span>
|
|
62
|
+
</>
|
|
63
|
+
}
|
|
64
|
+
valueClass="text-foreground/70"
|
|
65
|
+
percent={mem}
|
|
66
|
+
barClass={mem > DANGER ? "bg-destructive" : "bg-build"}
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function Metric({
|
|
73
|
+
icon,
|
|
74
|
+
label,
|
|
75
|
+
value,
|
|
76
|
+
valueClass,
|
|
77
|
+
percent,
|
|
78
|
+
barClass,
|
|
79
|
+
}: {
|
|
80
|
+
icon: ReactNode
|
|
81
|
+
label: string
|
|
82
|
+
value: ReactNode
|
|
83
|
+
valueClass: string
|
|
84
|
+
percent: number
|
|
85
|
+
barClass: string
|
|
86
|
+
}) {
|
|
87
|
+
return (
|
|
88
|
+
<div>
|
|
89
|
+
<div className="mb-1.5 flex items-center justify-between gap-2">
|
|
90
|
+
<span className="flex min-w-0 items-center gap-1 truncate text-[11px] text-muted-foreground">
|
|
91
|
+
{icon}
|
|
92
|
+
{label}
|
|
93
|
+
</span>
|
|
94
|
+
<span className={cn("shrink-0 font-mono text-[11px] tabular-nums", valueClass)}>{value}</span>
|
|
95
|
+
</div>
|
|
96
|
+
<div className="h-1 overflow-hidden rounded-full bg-muted">
|
|
97
|
+
<div
|
|
98
|
+
className={cn("h-full rounded-full transition-all duration-500 motion-reduce:transition-none", barClass)}
|
|
99
|
+
style={{ width: `${Math.min(Math.max(percent, 0), 100)}%` }}
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
}
|