@opengeni/react 0.12.0 → 0.15.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 +20 -14
- package/dist/chunk-TOJR776I.js +2280 -0
- package/dist/chunk-TOJR776I.js.map +1 -0
- package/dist/index.d.ts +1040 -363
- package/dist/index.js +10938 -6281
- package/dist/index.js.map +1 -1
- package/dist/{machines-BeZ3bD3t.d.ts → machines-BpdwuQcD.d.ts} +134 -14
- package/dist/machines.d.ts +1 -1
- package/dist/machines.js +23 -1
- package/package.json +20 -12
- package/src/approvals.ts +16 -4
- package/src/client.ts +41 -5
- package/src/commands/index.ts +1 -7
- package/src/commands/registry.ts +46 -12
- package/src/components/chat-composer.tsx +417 -69
- package/src/components/code-editor.tsx +28 -36
- package/src/components/command-palette.tsx +26 -5
- package/src/components/desktop-viewer.tsx +29 -16
- package/src/components/diff-view.tsx +27 -189
- package/src/components/enrollment-consent.tsx +28 -10
- package/src/components/enrollment-device-flow.tsx +8 -5
- package/src/components/file-browser.tsx +190 -56
- package/src/components/fleet-tile.tsx +13 -4
- package/src/components/machine-card.tsx +93 -15
- package/src/components/machine-dock-bar.tsx +20 -8
- package/src/components/machine-health-pill.tsx +68 -0
- package/src/components/machine-metrics.tsx +28 -19
- package/src/components/machine-status-pill.tsx +26 -5
- package/src/components/machines/health.ts +146 -0
- package/src/components/machines/machine-detail.tsx +220 -0
- package/src/components/machines/metric-history-chart.tsx +298 -0
- package/src/components/machines/metric-sparkline.tsx +76 -0
- package/src/components/machines/series.ts +113 -0
- package/src/components/machines-dashboard.tsx +21 -3
- package/src/components/markdown.tsx +39 -9
- package/src/components/message-timeline.tsx +633 -109
- package/src/components/pierre-diff.tsx +83 -15
- package/src/components/pierre-file.tsx +10 -9
- package/src/components/queue-surface.tsx +578 -0
- package/src/components/sandbox-files.tsx +193 -225
- package/src/components/sandbox-terminal.tsx +256 -88
- package/src/components/sandbox-workspace.tsx +957 -0
- package/src/components/session-status.tsx +22 -2
- package/src/components/workbench-changes.tsx +585 -0
- package/src/components/workspace-dock.tsx +213 -59
- package/src/hooks/internal.ts +5 -2
- package/src/hooks/use-available-models.ts +7 -2
- package/src/hooks/use-billing-usage.ts +4 -1
- package/src/hooks/use-codex-accounts.ts +74 -44
- package/src/hooks/use-composer.ts +414 -63
- package/src/hooks/use-environments.ts +92 -47
- package/src/hooks/use-file-attachments.ts +101 -55
- package/src/hooks/use-goal.ts +52 -16
- package/src/hooks/use-machine-chip.ts +134 -0
- package/src/hooks/use-machines.ts +34 -16
- package/src/hooks/use-packs.ts +24 -19
- package/src/hooks/use-relay-frame-stream.ts +5 -2
- package/src/hooks/use-rigs.ts +335 -0
- package/src/hooks/use-sandbox-files.ts +213 -39
- package/src/hooks/use-sandbox-git.ts +188 -11
- package/src/hooks/use-sandbox-terminal.ts +18 -14
- package/src/hooks/use-scheduled-tasks.ts +10 -2
- package/src/hooks/use-session-capabilities.ts +77 -20
- package/src/hooks/use-session-control.ts +50 -23
- package/src/hooks/use-session-events.ts +50 -21
- package/src/hooks/use-session-lineage.ts +128 -0
- package/src/hooks/use-session.ts +53 -30
- package/src/hooks/use-slash-commands.ts +6 -4
- package/src/hooks/use-turn-queue.ts +202 -147
- package/src/hooks/use-windowed-sections.ts +204 -0
- package/src/hooks/use-workspace-capture.ts +233 -0
- package/src/hooks/use-workspace-edit.ts +231 -0
- package/src/hooks/use-workspace-sessions.ts +48 -5
- package/src/hooks/use-workspaces.ts +18 -15
- package/src/index.ts +133 -23
- package/src/lib/format.ts +3 -3
- package/src/lib/xterm-renderer.ts +65 -0
- package/src/lib/xterm-theme.ts +224 -18
- package/src/machines.ts +29 -3
- package/src/provider.tsx +195 -6
- package/src/timeline/activity-rail.tsx +213 -54
- package/src/timeline/disclosure-context.tsx +12 -2
- package/src/timeline/index.ts +21 -2
- package/src/timeline/parsers.ts +87 -16
- package/src/timeline/projection.ts +412 -87
- package/src/timeline/shared.tsx +111 -24
- package/src/timeline/tool-diff.tsx +24 -20
- package/src/timeline/tool-renderers.tsx +98 -21
- package/src/timeline/turn-summary.tsx +76 -23
- package/src/timeline/types.ts +96 -12
- package/styles/index.css +22 -0
- package/styles/tokens.css +2 -2
- package/dist/chunk-5TIXRCSI.js +0 -1291
- package/dist/chunk-5TIXRCSI.js.map +0 -1
|
@@ -22,10 +22,9 @@ export type MachineDockBarProps = {
|
|
|
22
22
|
* render IDENTICALLY regardless of backend — this bar is the only backend-aware
|
|
23
23
|
* chrome (the dock-parity contract: selfhosted == Modal for the surfaces).
|
|
24
24
|
*/
|
|
25
|
-
export function MachineDockBar({ name, kind, state,
|
|
25
|
+
export function MachineDockBar({ name, kind, state, className }: MachineDockBarProps) {
|
|
26
26
|
const Icon = kind === "selfhosted" ? LaptopIcon : CpuIcon;
|
|
27
27
|
const stateBadge = MACHINE_STATE_BADGE_META[state];
|
|
28
|
-
const shared = (sharedSessionCount ?? 0) > 1;
|
|
29
28
|
return (
|
|
30
29
|
<div
|
|
31
30
|
data-machine-dock-bar
|
|
@@ -40,13 +39,20 @@ export function MachineDockBar({ name, kind, state, sharedSessionCount, classNam
|
|
|
40
39
|
{stateBadge ? (
|
|
41
40
|
<span
|
|
42
41
|
data-state-badge={state}
|
|
43
|
-
className={cn(
|
|
42
|
+
className={cn(
|
|
43
|
+
"shrink-0 rounded-full border px-1.5 py-px text-[10px] font-medium",
|
|
44
|
+
stateBadge.badgeClassName,
|
|
45
|
+
)}
|
|
44
46
|
>
|
|
45
47
|
{stateBadge.label}
|
|
46
48
|
</span>
|
|
47
49
|
) : null}
|
|
48
50
|
</div>
|
|
49
|
-
<ConnectionStatusPill
|
|
51
|
+
<ConnectionStatusPill
|
|
52
|
+
status={connectionStatusForState(state)}
|
|
53
|
+
size="sm"
|
|
54
|
+
className="shrink-0"
|
|
55
|
+
/>
|
|
50
56
|
</div>
|
|
51
57
|
);
|
|
52
58
|
}
|
|
@@ -63,21 +69,27 @@ export type SharedMachineDisclosureProps = {
|
|
|
63
69
|
* dock (and reused on the desktop take-control gate) so the user knows others are
|
|
64
70
|
* driving the same whole-machine lease. Render when `sharedSessionCount > 1`.
|
|
65
71
|
*/
|
|
66
|
-
export function SharedMachineDisclosure({
|
|
72
|
+
export function SharedMachineDisclosure({
|
|
73
|
+
sharedSessionCount,
|
|
74
|
+
density = "compact",
|
|
75
|
+
className,
|
|
76
|
+
}: SharedMachineDisclosureProps) {
|
|
67
77
|
const others = Math.max(0, sharedSessionCount - 1);
|
|
68
78
|
return (
|
|
69
79
|
<div
|
|
70
80
|
data-shared-disclosure
|
|
71
81
|
className={cn(
|
|
72
82
|
"flex items-center gap-1.5 border-og-accent/25 bg-og-accent-soft text-og-fg-muted",
|
|
73
|
-
density === "full"
|
|
83
|
+
density === "full"
|
|
84
|
+
? "rounded-og-md border px-3 py-2 text-[12px]"
|
|
85
|
+
: "border-b px-2.5 py-1 text-[11px]",
|
|
74
86
|
className,
|
|
75
87
|
)}
|
|
76
88
|
>
|
|
77
89
|
<UsersIcon className="size-3.5 shrink-0 text-og-accent" aria-hidden />
|
|
78
90
|
<span>
|
|
79
|
-
Shared — {others === 1 ? "another session is" : `${others} other sessions are`} on this
|
|
80
|
-
terminal, files, and desktop.
|
|
91
|
+
Shared — {others === 1 ? "another session is" : `${others} other sessions are`} on this
|
|
92
|
+
machine. They see the same terminal, files, and desktop.
|
|
81
93
|
</span>
|
|
82
94
|
</div>
|
|
83
95
|
);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The card's primary status: the fused HEALTH verdict (dot + label), which
|
|
2
|
+
// subsumes plain connection state (offline/reconnecting fall out of it) and adds
|
|
3
|
+
// resource-pressure + staleness. Capability limitations (consent / no display /
|
|
4
|
+
// enrolling) are orthogonal to reachability, so they ride alongside as their own
|
|
5
|
+
// quiet chip — reusing the existing badge meta so the two surfaces never drift.
|
|
6
|
+
import { cn } from "../lib/cn";
|
|
7
|
+
import type { MachineState, MetricSample } from "../types/machines";
|
|
8
|
+
import { deriveHealth, HEALTH_TOKEN, healthPulses } from "./machines/health";
|
|
9
|
+
import { MACHINE_STATE_BADGE_META } from "./machine-status-pill";
|
|
10
|
+
|
|
11
|
+
export type MachineHealthPillProps = {
|
|
12
|
+
state: MachineState;
|
|
13
|
+
metrics: MetricSample | null;
|
|
14
|
+
now?: number | undefined;
|
|
15
|
+
size?: "sm" | "md" | undefined;
|
|
16
|
+
className?: string | undefined;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function MachineHealthPill({
|
|
20
|
+
state,
|
|
21
|
+
metrics,
|
|
22
|
+
now,
|
|
23
|
+
size = "sm",
|
|
24
|
+
className,
|
|
25
|
+
}: MachineHealthPillProps) {
|
|
26
|
+
const health = deriveHealth(state, metrics, now ?? Date.now());
|
|
27
|
+
const tone = HEALTH_TOKEN[health.level];
|
|
28
|
+
const badge = MACHINE_STATE_BADGE_META[state];
|
|
29
|
+
const pad = size === "md" ? "px-2.5 py-1 text-og-sm" : "px-2 py-0.5 text-og-xs";
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className={cn("flex shrink-0 items-center gap-1.5", className)}>
|
|
33
|
+
{badge ? (
|
|
34
|
+
<span
|
|
35
|
+
className={cn(
|
|
36
|
+
"rounded-full border px-2 py-0.5 text-og-xs font-medium",
|
|
37
|
+
badge.badgeClassName,
|
|
38
|
+
)}
|
|
39
|
+
>
|
|
40
|
+
{badge.label}
|
|
41
|
+
</span>
|
|
42
|
+
) : null}
|
|
43
|
+
<span
|
|
44
|
+
className={cn(
|
|
45
|
+
"inline-flex items-center gap-1.5 rounded-full border font-medium",
|
|
46
|
+
pad,
|
|
47
|
+
tone.soft,
|
|
48
|
+
tone.text,
|
|
49
|
+
)}
|
|
50
|
+
data-health={health.level}
|
|
51
|
+
title={health.reason}
|
|
52
|
+
>
|
|
53
|
+
<span className="relative flex size-2">
|
|
54
|
+
{healthPulses(health.level) && (
|
|
55
|
+
<span
|
|
56
|
+
className={cn(
|
|
57
|
+
"absolute inline-flex size-full animate-og-pulse rounded-full opacity-60",
|
|
58
|
+
tone.dot,
|
|
59
|
+
)}
|
|
60
|
+
/>
|
|
61
|
+
)}
|
|
62
|
+
<span className={cn("relative inline-flex size-2 rounded-full", tone.dot)} />
|
|
63
|
+
</span>
|
|
64
|
+
{health.label}
|
|
65
|
+
</span>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -28,12 +28,20 @@ function Meter({
|
|
|
28
28
|
fillPct: number;
|
|
29
29
|
tone: "ok" | "warn" | "hot";
|
|
30
30
|
}) {
|
|
31
|
+
// Traffic-light ramp, aligned with the fused health verdict: calm resources
|
|
32
|
+
// read green (idle), pressure ambers (running), near-the-wall reds (failed).
|
|
31
33
|
const fillClass =
|
|
32
|
-
tone === "hot"
|
|
34
|
+
tone === "hot"
|
|
35
|
+
? "bg-og-status-failed"
|
|
36
|
+
: tone === "warn"
|
|
37
|
+
? "bg-og-status-running"
|
|
38
|
+
: "bg-og-status-idle";
|
|
33
39
|
return (
|
|
34
40
|
<div className="flex min-w-0 flex-col gap-1" data-metric={label.toLowerCase()}>
|
|
35
41
|
<div className="flex items-baseline justify-between gap-2">
|
|
36
|
-
<span className="text-[10px] font-medium uppercase tracking-wide text-og-fg-subtle">
|
|
42
|
+
<span className="text-[10px] font-medium uppercase tracking-wide text-og-fg-subtle">
|
|
43
|
+
{label}
|
|
44
|
+
</span>
|
|
37
45
|
<span className="font-og-mono text-[11px] tabular-nums text-og-fg-muted">{value}</span>
|
|
38
46
|
</div>
|
|
39
47
|
<div className="h-1 w-full overflow-hidden rounded-full bg-og-surface-2">
|
|
@@ -53,16 +61,12 @@ function toneFor(p: number): "ok" | "warn" | "hot" {
|
|
|
53
61
|
return "ok";
|
|
54
62
|
}
|
|
55
63
|
|
|
56
|
-
/** The token text color for a tone (matches the `Meter` fill ramp). */
|
|
57
|
-
function toneTextClass(tone: "ok" | "warn" | "hot"): string {
|
|
58
|
-
return tone === "hot" ? "text-og-status-failed" : tone === "warn" ? "text-og-status-waiting" : "text-og-status-running";
|
|
59
|
-
}
|
|
60
|
-
|
|
61
64
|
/**
|
|
62
65
|
* Load average as three labeled mini-stats (1m / 5m / 15m) — an honest triple of
|
|
63
|
-
* numbers, NOT a fake gauge.
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
+
* numbers, NOT a fake gauge. Load is not a 0..100 ratio and we don't know the
|
|
67
|
+
* core count, so a raw load of 2 or 8 is not inherently "bad" — we render it
|
|
68
|
+
* NEUTRAL rather than falsely alarming. The real saturation signal is the run
|
|
69
|
+
* queue, which rides alongside as a quiet chip when there's pending work.
|
|
66
70
|
*/
|
|
67
71
|
function StatTriple({
|
|
68
72
|
load1,
|
|
@@ -75,9 +79,6 @@ function StatTriple({
|
|
|
75
79
|
load15: number;
|
|
76
80
|
runQueue: number;
|
|
77
81
|
}) {
|
|
78
|
-
// Load isn't a percent — tone by load1 against a nominal single-core ceiling
|
|
79
|
-
// (≥0.9/core ≈ saturated). Used only to tint the labels/values, never a bar.
|
|
80
|
-
const tone = toneFor(load1 * 100);
|
|
81
82
|
const stats: Array<{ label: string; value: number }> = [
|
|
82
83
|
{ label: "1m", value: load1 },
|
|
83
84
|
{ label: "5m", value: load5 },
|
|
@@ -86,14 +87,16 @@ function StatTriple({
|
|
|
86
87
|
return (
|
|
87
88
|
<div className="flex items-center justify-between gap-3" data-metric="load">
|
|
88
89
|
<div className="flex min-w-0 flex-col gap-1">
|
|
89
|
-
<span className="text-[10px] font-medium uppercase tracking-wide text-og-fg-subtle">
|
|
90
|
+
<span className="text-[10px] font-medium uppercase tracking-wide text-og-fg-subtle">
|
|
91
|
+
Load
|
|
92
|
+
</span>
|
|
90
93
|
<div className="flex items-baseline gap-3">
|
|
91
94
|
{stats.map((s) => (
|
|
92
95
|
<div key={s.label} className="flex items-baseline gap-1">
|
|
93
|
-
<span className=
|
|
96
|
+
<span className="text-[10px] font-medium uppercase tracking-wide text-og-fg-subtle">
|
|
94
97
|
{s.label}
|
|
95
98
|
</span>
|
|
96
|
-
<span className=
|
|
99
|
+
<span className="font-og-mono text-[12px] tabular-nums text-og-fg">
|
|
97
100
|
{s.value.toFixed(2)}
|
|
98
101
|
</span>
|
|
99
102
|
</div>
|
|
@@ -128,8 +131,10 @@ export function MachineMetrics({ metrics, density = "compact", className }: Mach
|
|
|
128
131
|
);
|
|
129
132
|
}
|
|
130
133
|
|
|
131
|
-
const memPct =
|
|
132
|
-
|
|
134
|
+
const memPct =
|
|
135
|
+
metrics.memTotalBytes > 0 ? (metrics.memUsedBytes / metrics.memTotalBytes) * 100 : 0;
|
|
136
|
+
const diskPct =
|
|
137
|
+
metrics.diskTotalBytes > 0 ? (metrics.diskUsedBytes / metrics.diskTotalBytes) * 100 : 0;
|
|
133
138
|
const hasGpu = metrics.gpuUtilPct !== null;
|
|
134
139
|
const gpuMemLabel = metrics.gpuMemBytes !== null ? formatBytes(metrics.gpuMemBytes) : null;
|
|
135
140
|
// Memory & disk are honest used/total ratios → full-width meters; stack them in
|
|
@@ -165,7 +170,11 @@ export function MachineMetrics({ metrics, density = "compact", className }: Mach
|
|
|
165
170
|
{hasGpu ? (
|
|
166
171
|
<Meter
|
|
167
172
|
label="GPU"
|
|
168
|
-
value={
|
|
173
|
+
value={
|
|
174
|
+
gpuMemLabel
|
|
175
|
+
? `${metrics.gpuUtilPct!.toFixed(0)}% · ${gpuMemLabel}`
|
|
176
|
+
: `${metrics.gpuUtilPct!.toFixed(0)}%`
|
|
177
|
+
}
|
|
169
178
|
fillPct={metrics.gpuUtilPct!}
|
|
170
179
|
tone={toneFor(metrics.gpuUtilPct!)}
|
|
171
180
|
/>
|
|
@@ -73,7 +73,12 @@ export type ConnectionStatusPillProps = {
|
|
|
73
73
|
* the Machines dashboard, the dock header, and the session timeline. Reconnecting
|
|
74
74
|
* breathes (the resiliency-as-headline blip), online/offline hold still.
|
|
75
75
|
*/
|
|
76
|
-
export function ConnectionStatusPill({
|
|
76
|
+
export function ConnectionStatusPill({
|
|
77
|
+
status,
|
|
78
|
+
label,
|
|
79
|
+
size = "md",
|
|
80
|
+
className,
|
|
81
|
+
}: ConnectionStatusPillProps) {
|
|
77
82
|
const meta = CONNECTION_STATUS_META[status];
|
|
78
83
|
return (
|
|
79
84
|
<span
|
|
@@ -100,8 +105,16 @@ export type ConnectionDotProps = {
|
|
|
100
105
|
export function ConnectionDot({ status, className }: ConnectionDotProps) {
|
|
101
106
|
const meta = CONNECTION_STATUS_META[status];
|
|
102
107
|
return (
|
|
103
|
-
<span
|
|
104
|
-
|
|
108
|
+
<span
|
|
109
|
+
className={cn(
|
|
110
|
+
"relative inline-flex size-1.5 shrink-0 rounded-full",
|
|
111
|
+
meta.dotClassName,
|
|
112
|
+
className,
|
|
113
|
+
)}
|
|
114
|
+
>
|
|
115
|
+
{meta.pulse ? (
|
|
116
|
+
<span className={cn("absolute inset-0 animate-og-pulse rounded-full", meta.dotClassName)} />
|
|
117
|
+
) : null}
|
|
105
118
|
</span>
|
|
106
119
|
);
|
|
107
120
|
}
|
|
@@ -121,11 +134,19 @@ export type MachineStatusPillProps = {
|
|
|
121
134
|
* dashboard row, the dock header, and the timeline all reuse so the status
|
|
122
135
|
* language is identical everywhere.
|
|
123
136
|
*/
|
|
124
|
-
export function MachineStatusPill({
|
|
137
|
+
export function MachineStatusPill({
|
|
138
|
+
state,
|
|
139
|
+
sharedSessionCount,
|
|
140
|
+
size = "md",
|
|
141
|
+
className,
|
|
142
|
+
}: MachineStatusPillProps) {
|
|
125
143
|
const stateBadge = MACHINE_STATE_BADGE_META[state];
|
|
126
144
|
const shared = (sharedSessionCount ?? 0) > 1;
|
|
127
145
|
return (
|
|
128
|
-
<span
|
|
146
|
+
<span
|
|
147
|
+
className={cn("og-root inline-flex flex-wrap items-center gap-1", className)}
|
|
148
|
+
data-machine-state={state}
|
|
149
|
+
>
|
|
129
150
|
<ConnectionStatusPill status={connectionStatusForState(state)} size={size} />
|
|
130
151
|
{stateBadge ? (
|
|
131
152
|
<span
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// ----------------------------------------------------------------------------
|
|
2
|
+
// Machine health — the single derived signal.
|
|
3
|
+
//
|
|
4
|
+
// The dashboard already shows connection state (a pill) and per-resource meters
|
|
5
|
+
// (independently tinted). What it lacks is ONE verdict that fuses the three
|
|
6
|
+
// things that actually make a machine "in trouble":
|
|
7
|
+
//
|
|
8
|
+
// 1. reachability — is the control plane still hearing from it?
|
|
9
|
+
// 2. resource load — is a resource (mem/disk/cpu) near the wall?
|
|
10
|
+
// 3. freshness — is the latest sample recent, or has telemetry gone quiet?
|
|
11
|
+
//
|
|
12
|
+
// This module is a PURE function from a machine's state + latest sample + clock
|
|
13
|
+
// to a `HealthVerdict`. No React, no tokens — the UI maps `level` to a color.
|
|
14
|
+
// Keeping it pure means it is trivially testable and reused by cards, the detail
|
|
15
|
+
// hero, and any future fleet-summary count.
|
|
16
|
+
// ----------------------------------------------------------------------------
|
|
17
|
+
import type { MachineState, MetricSample } from "../../types/machines";
|
|
18
|
+
|
|
19
|
+
export type HealthLevel = "healthy" | "degraded" | "critical" | "offline" | "unknown";
|
|
20
|
+
|
|
21
|
+
export type HealthVerdict = {
|
|
22
|
+
level: HealthLevel;
|
|
23
|
+
/** Title-case label for the pill ("Healthy", "Under load", "Critical", …). */
|
|
24
|
+
label: string;
|
|
25
|
+
/** One short human clause naming the dominant cause ("Memory 94%", "No display"…). */
|
|
26
|
+
reason: string;
|
|
27
|
+
/** The latest sample is older than we'd expect from a live agent (~5s cadence). */
|
|
28
|
+
stale: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Resource pressure thresholds. `warn` mirrors the existing meter ramp (>=70
|
|
32
|
+
// tints), `crit` is the "near the wall" line. Disk is stricter than CPU/mem
|
|
33
|
+
// because a full disk is a hard failure, whereas transient CPU/mem spikes are
|
|
34
|
+
// normal on a working machine.
|
|
35
|
+
const PRESSURE = {
|
|
36
|
+
cpu: { warn: 90, crit: 98 },
|
|
37
|
+
mem: { warn: 85, crit: 95 },
|
|
38
|
+
disk: { warn: 90, crit: 96 },
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
// A live agent samples ~every 5s and the series downsamples to ~1/min. We treat
|
|
42
|
+
// a latest sample older than 45s as "telemetry has gone quiet" — enough slack to
|
|
43
|
+
// not flap on a single missed beat, tight enough to notice a wedged sampler.
|
|
44
|
+
const STALE_AFTER_MS = 45_000;
|
|
45
|
+
|
|
46
|
+
function pct(used: number, total: number): number {
|
|
47
|
+
if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) return 0;
|
|
48
|
+
return (used / total) * 100;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Fold a machine's reachability + latest sample + wall-clock into one verdict. */
|
|
52
|
+
export function deriveHealth(
|
|
53
|
+
state: MachineState,
|
|
54
|
+
metrics: MetricSample | null,
|
|
55
|
+
now: number = Date.now(),
|
|
56
|
+
): HealthVerdict {
|
|
57
|
+
// Reachability dominates: an offline machine has no meaningful resource story.
|
|
58
|
+
if (state === "offline") {
|
|
59
|
+
return { level: "offline", label: "Offline", reason: "Not reachable", stale: true };
|
|
60
|
+
}
|
|
61
|
+
if (state === "enrolling") {
|
|
62
|
+
return { level: "unknown", label: "Enrolling", reason: "Completing enrollment", stale: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const stale = metrics ? now - new Date(metrics.sampledAt).getTime() > STALE_AFTER_MS : true;
|
|
66
|
+
|
|
67
|
+
// Reconnecting, or online-but-quiet: reachable yet we can't trust the numbers.
|
|
68
|
+
if (state === "reconnecting") {
|
|
69
|
+
return { level: "degraded", label: "Reconnecting", reason: "Re-establishing link", stale };
|
|
70
|
+
}
|
|
71
|
+
if (!metrics) {
|
|
72
|
+
return { level: "unknown", label: "Online", reason: "Awaiting first sample", stale };
|
|
73
|
+
}
|
|
74
|
+
if (stale) {
|
|
75
|
+
return { level: "degraded", label: "Telemetry stale", reason: "No recent sample", stale: true };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Reachable + fresh: the verdict is now the worst resource pressure. Evaluate
|
|
79
|
+
// each resource, keep the most severe, and name it in the reason.
|
|
80
|
+
const memPct = pct(metrics.memUsedBytes, metrics.memTotalBytes);
|
|
81
|
+
const diskPct = pct(metrics.diskUsedBytes, metrics.diskTotalBytes);
|
|
82
|
+
const cpu = metrics.cpuPct;
|
|
83
|
+
|
|
84
|
+
const pressures: Array<{ level: HealthLevel; reason: string; sev: number }> = [
|
|
85
|
+
resolve("Disk", diskPct, PRESSURE.disk),
|
|
86
|
+
resolve("Memory", memPct, PRESSURE.mem),
|
|
87
|
+
resolve("CPU", cpu, PRESSURE.cpu),
|
|
88
|
+
];
|
|
89
|
+
const worst = pressures.reduce((a, b) => (b.sev > a.sev ? b : a));
|
|
90
|
+
|
|
91
|
+
if (worst.sev === 2) return { level: "critical", label: "Critical", reason: worst.reason, stale };
|
|
92
|
+
if (worst.sev === 1)
|
|
93
|
+
return { level: "degraded", label: "Under load", reason: worst.reason, stale };
|
|
94
|
+
return { level: "healthy", label: "Healthy", reason: "All systems nominal", stale };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function resolve(
|
|
98
|
+
name: string,
|
|
99
|
+
value: number,
|
|
100
|
+
t: { warn: number; crit: number },
|
|
101
|
+
): { level: HealthLevel; reason: string; sev: number } {
|
|
102
|
+
const rounded = Math.round(value);
|
|
103
|
+
if (value >= t.crit) return { level: "critical", reason: `${name} ${rounded}%`, sev: 2 };
|
|
104
|
+
if (value >= t.warn) return { level: "degraded", reason: `${name} ${rounded}%`, sev: 1 };
|
|
105
|
+
return { level: "healthy", reason: `${name} ${rounded}%`, sev: 0 };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- UI mapping helpers (color/token names live here so every surface agrees) --
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The token color ramp for a health level. Green/amber/red/grey is the
|
|
112
|
+
* universally-legible traffic model; we reserve it for the HEALTH verdict
|
|
113
|
+
* specifically (per-resource meters keep their own ramp in `machine-metrics`).
|
|
114
|
+
*/
|
|
115
|
+
export const HEALTH_TOKEN: Record<HealthLevel, { text: string; dot: string; soft: string }> = {
|
|
116
|
+
healthy: {
|
|
117
|
+
text: "text-og-status-idle",
|
|
118
|
+
dot: "bg-og-status-idle",
|
|
119
|
+
soft: "bg-og-status-idle/10 border-og-status-idle/25",
|
|
120
|
+
},
|
|
121
|
+
degraded: {
|
|
122
|
+
text: "text-og-status-running",
|
|
123
|
+
dot: "bg-og-status-running",
|
|
124
|
+
soft: "bg-og-status-running/10 border-og-status-running/25",
|
|
125
|
+
},
|
|
126
|
+
critical: {
|
|
127
|
+
text: "text-og-status-failed",
|
|
128
|
+
dot: "bg-og-status-failed",
|
|
129
|
+
soft: "bg-og-status-failed/10 border-og-status-failed/30",
|
|
130
|
+
},
|
|
131
|
+
offline: {
|
|
132
|
+
text: "text-og-fg-subtle",
|
|
133
|
+
dot: "bg-og-fg-subtle",
|
|
134
|
+
soft: "bg-og-surface-2 border-og-border",
|
|
135
|
+
},
|
|
136
|
+
unknown: {
|
|
137
|
+
text: "text-og-fg-muted",
|
|
138
|
+
dot: "bg-og-fg-muted",
|
|
139
|
+
soft: "bg-og-surface-2 border-og-border",
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** Live/transient levels breathe; settled levels hold still. */
|
|
144
|
+
export function healthPulses(level: HealthLevel): boolean {
|
|
145
|
+
return level === "degraded" || level === "critical";
|
|
146
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// ----------------------------------------------------------------------------
|
|
2
|
+
// MachineDetail — the marquee per-machine telemetry view.
|
|
3
|
+
//
|
|
4
|
+
// Opens from a machine card. Answers, at a glance and then in depth: is this
|
|
5
|
+
// machine healthy right now (one fused verdict), what are its live numbers, and
|
|
6
|
+
// how have they moved over the chosen window. Built entirely on the existing
|
|
7
|
+
// `og-*` token system — no new visual language, just the missing depth.
|
|
8
|
+
// ----------------------------------------------------------------------------
|
|
9
|
+
import { ArrowLeftIcon, CpuIcon, LaptopIcon, RadioIcon, ServerIcon } from "lucide-react";
|
|
10
|
+
import { cn } from "../../lib/cn";
|
|
11
|
+
import { formatRelativeTime } from "../../lib/format";
|
|
12
|
+
import type { MachineView, MetricSample } from "../../types/machines";
|
|
13
|
+
import { deriveHealth, HEALTH_TOKEN, healthPulses } from "./health";
|
|
14
|
+
import { MetricHistoryChart } from "./metric-history-chart";
|
|
15
|
+
import { MetricSparkline } from "./metric-sparkline";
|
|
16
|
+
import { METRICS, METRIC_WINDOWS, pointsFor, WINDOW_LABEL, type MetricWindow } from "./series";
|
|
17
|
+
|
|
18
|
+
export type MachineDetailProps = {
|
|
19
|
+
machine: MachineView;
|
|
20
|
+
/** Downsampled history for the active window (from `fetchSeries`). */
|
|
21
|
+
series: MetricSample[];
|
|
22
|
+
window: MetricWindow;
|
|
23
|
+
onWindowChange: (w: MetricWindow) => void;
|
|
24
|
+
loadingSeries?: boolean | undefined;
|
|
25
|
+
onBack?: (() => void) | undefined;
|
|
26
|
+
now?: number | undefined;
|
|
27
|
+
className?: string | undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function KindIcon({ kind, session }: { kind: string; session: boolean }) {
|
|
31
|
+
if (session) return <ServerIcon className="size-4 text-og-fg-subtle" aria-hidden />;
|
|
32
|
+
if (kind === "modal") return <CpuIcon className="size-4 text-og-fg-subtle" aria-hidden />;
|
|
33
|
+
return <LaptopIcon className="size-4 text-og-fg-subtle" aria-hidden />;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function MachineDetail({
|
|
37
|
+
machine,
|
|
38
|
+
series,
|
|
39
|
+
window,
|
|
40
|
+
onWindowChange,
|
|
41
|
+
loadingSeries,
|
|
42
|
+
onBack,
|
|
43
|
+
now = Date.now(),
|
|
44
|
+
className,
|
|
45
|
+
}: MachineDetailProps) {
|
|
46
|
+
const health = deriveHealth(machine.state, machine.metrics, now);
|
|
47
|
+
const tone = HEALTH_TOKEN[health.level];
|
|
48
|
+
const latest = machine.metrics;
|
|
49
|
+
const sampledAgo = latest ? formatRelativeTime(latest.sampledAt, new Date(now)) : null;
|
|
50
|
+
|
|
51
|
+
// Only render metrics that have a value (GPU is dropped on headless boxes).
|
|
52
|
+
const visible = METRICS.filter((m) => !(m.key === "gpu" && latest?.gpuUtilPct == null));
|
|
53
|
+
// The tile grid tracks the metric count exactly so it never leaves a dead cell.
|
|
54
|
+
const tileCols =
|
|
55
|
+
visible.length >= 5
|
|
56
|
+
? "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5"
|
|
57
|
+
: "grid-cols-2 sm:grid-cols-4";
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className={cn("og-root mx-auto flex w-full max-w-5xl flex-col gap-5", className)}>
|
|
61
|
+
{/* ── top bar ─────────────────────────────────────────────────────── */}
|
|
62
|
+
<div className="flex items-center justify-between gap-3">
|
|
63
|
+
<div className="flex min-w-0 items-center gap-2.5">
|
|
64
|
+
{onBack && (
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
onClick={onBack}
|
|
68
|
+
className="flex size-7 shrink-0 items-center justify-center rounded-og-sm border border-og-border bg-og-surface-1 text-og-fg-muted transition-colors hover:bg-og-surface-2 hover:text-og-fg"
|
|
69
|
+
aria-label="Back to machines"
|
|
70
|
+
>
|
|
71
|
+
<ArrowLeftIcon className="size-3.5" aria-hidden />
|
|
72
|
+
</button>
|
|
73
|
+
)}
|
|
74
|
+
<KindIcon kind={machine.kind} session={machine.isSessionGroup} />
|
|
75
|
+
<h1 className="truncate text-og-md font-semibold text-og-fg">{machine.name}</h1>
|
|
76
|
+
<span className="hidden shrink-0 rounded-og-sm border border-og-border bg-og-surface-1 px-1.5 py-0.5 font-og-mono text-og-xs text-og-fg-subtle sm:inline">
|
|
77
|
+
{machine.os} · {machine.arch}
|
|
78
|
+
</span>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{/* range selector */}
|
|
82
|
+
<div className="flex shrink-0 items-center gap-0.5 rounded-og-md border border-og-border bg-og-surface-1 p-0.5">
|
|
83
|
+
{METRIC_WINDOWS.map((w) => (
|
|
84
|
+
<button
|
|
85
|
+
key={w}
|
|
86
|
+
type="button"
|
|
87
|
+
onClick={() => onWindowChange(w)}
|
|
88
|
+
data-active={w === window}
|
|
89
|
+
className={cn(
|
|
90
|
+
"rounded-og-sm px-2 py-1 font-og-mono text-og-xs transition-colors",
|
|
91
|
+
w === window
|
|
92
|
+
? "bg-og-surface-3 text-og-fg shadow-og-sm"
|
|
93
|
+
: "text-og-fg-subtle hover:text-og-fg-muted",
|
|
94
|
+
)}
|
|
95
|
+
>
|
|
96
|
+
{w}
|
|
97
|
+
</button>
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{/* ── health hero ─────────────────────────────────────────────────── */}
|
|
103
|
+
<div
|
|
104
|
+
className={cn(
|
|
105
|
+
"relative flex flex-col gap-4 overflow-hidden rounded-og-lg border p-5 shadow-og-sm",
|
|
106
|
+
tone.soft,
|
|
107
|
+
)}
|
|
108
|
+
>
|
|
109
|
+
<span aria-hidden className={cn("absolute inset-y-0 left-0 w-1", tone.dot)} />
|
|
110
|
+
<div className="flex flex-wrap items-center justify-between gap-3 pl-1">
|
|
111
|
+
<div className="flex items-center gap-3">
|
|
112
|
+
<span className="relative flex size-3">
|
|
113
|
+
{healthPulses(health.level) && (
|
|
114
|
+
<span
|
|
115
|
+
className={cn(
|
|
116
|
+
"absolute inline-flex size-full animate-og-pulse rounded-full opacity-60",
|
|
117
|
+
tone.dot,
|
|
118
|
+
)}
|
|
119
|
+
/>
|
|
120
|
+
)}
|
|
121
|
+
<span className={cn("relative inline-flex size-3 rounded-full", tone.dot)} />
|
|
122
|
+
</span>
|
|
123
|
+
<div className="flex flex-col">
|
|
124
|
+
<span className={cn("text-og-md font-semibold", tone.text)}>{health.label}</span>
|
|
125
|
+
<span className="text-og-sm text-og-fg-muted">{health.reason}</span>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
<div className="flex items-start gap-5 pr-1">
|
|
129
|
+
{sampledAgo && (
|
|
130
|
+
<div className="flex flex-col items-end gap-0.5">
|
|
131
|
+
<span className="text-[10px] font-medium uppercase tracking-wide text-og-fg-subtle">
|
|
132
|
+
Sampled
|
|
133
|
+
</span>
|
|
134
|
+
<span className="flex items-center gap-1 font-og-mono text-og-sm tabular-nums text-og-fg">
|
|
135
|
+
<RadioIcon
|
|
136
|
+
className={cn("size-3", health.stale ? "text-og-fg-subtle" : tone.text)}
|
|
137
|
+
aria-hidden
|
|
138
|
+
/>
|
|
139
|
+
{sampledAgo === "now" ? "live" : `${sampledAgo} ago`}
|
|
140
|
+
</span>
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
{machine.lastSeenAt && (
|
|
144
|
+
<div className="hidden flex-col items-end gap-0.5 sm:flex">
|
|
145
|
+
<span className="text-[10px] font-medium uppercase tracking-wide text-og-fg-subtle">
|
|
146
|
+
Last seen
|
|
147
|
+
</span>
|
|
148
|
+
<span className="font-og-mono text-og-sm tabular-nums text-og-fg-muted">
|
|
149
|
+
{formatRelativeTime(machine.lastSeenAt, new Date(now))}
|
|
150
|
+
</span>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
{/* current-stat tiles */}
|
|
157
|
+
{latest && (
|
|
158
|
+
<div
|
|
159
|
+
className={cn(
|
|
160
|
+
"grid gap-px overflow-hidden rounded-og-md border border-og-border bg-og-border",
|
|
161
|
+
tileCols,
|
|
162
|
+
)}
|
|
163
|
+
>
|
|
164
|
+
{visible.map((m) => {
|
|
165
|
+
const pts = pointsFor(m, series);
|
|
166
|
+
return (
|
|
167
|
+
<div key={m.key} className="flex flex-col gap-1.5 bg-og-surface-1 p-3">
|
|
168
|
+
<span className="text-[10px] font-medium uppercase tracking-wide text-og-fg-subtle">
|
|
169
|
+
{m.title}
|
|
170
|
+
</span>
|
|
171
|
+
<span className="font-og-mono text-og-md font-medium tabular-nums text-og-fg">
|
|
172
|
+
{m.current(latest)}
|
|
173
|
+
</span>
|
|
174
|
+
<MetricSparkline points={pts} color={m.color} yMax={m.yMax} height={22} />
|
|
175
|
+
{m.sub && (
|
|
176
|
+
<span className="truncate font-og-mono text-[10px] text-og-fg-subtle">
|
|
177
|
+
{m.sub(latest)}
|
|
178
|
+
</span>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
})}
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
{/* ── history charts ──────────────────────────────────────────────── */}
|
|
188
|
+
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
|
189
|
+
{visible.map((m) => {
|
|
190
|
+
const pts = pointsFor(m, series);
|
|
191
|
+
return (
|
|
192
|
+
<div
|
|
193
|
+
key={m.key}
|
|
194
|
+
className="flex flex-col gap-3 rounded-og-lg border border-og-border bg-og-surface-1 p-4 shadow-og-sm"
|
|
195
|
+
>
|
|
196
|
+
<div className="flex items-baseline justify-between">
|
|
197
|
+
<span className="text-og-sm font-medium text-og-fg">{m.title}</span>
|
|
198
|
+
{latest && (
|
|
199
|
+
<span className="font-og-mono text-og-sm tabular-nums text-og-fg-muted">
|
|
200
|
+
{m.current(latest)}
|
|
201
|
+
</span>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
<MetricHistoryChart
|
|
205
|
+
points={pts}
|
|
206
|
+
yMax={m.yMax}
|
|
207
|
+
unit={m.unit}
|
|
208
|
+
color={m.color}
|
|
209
|
+
thresholds={m.thresholds}
|
|
210
|
+
rangeLabel={WINDOW_LABEL[window]}
|
|
211
|
+
height={128}
|
|
212
|
+
className={cn(loadingSeries && "opacity-50 transition-opacity")}
|
|
213
|
+
/>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
})}
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|