@opengeni/react 0.13.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 +19 -13
- package/dist/chunk-TOJR776I.js +2280 -0
- package/dist/chunk-TOJR776I.js.map +1 -0
- package/dist/index.d.ts +314 -255
- package/dist/index.js +5862 -4404
- package/dist/index.js.map +1 -1
- package/dist/{machines-CnlMb7E-.d.ts → machines-BpdwuQcD.d.ts} +130 -10
- package/dist/machines.d.ts +1 -1
- package/dist/machines.js +23 -1
- package/package.json +5 -2
- package/src/client.ts +10 -1
- package/src/components/chat-composer.tsx +309 -57
- package/src/components/machine-card.tsx +81 -15
- package/src/components/machine-health-pill.tsx +68 -0
- package/src/components/machine-metrics.tsx +10 -24
- 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 +13 -1
- package/src/components/queue-surface.tsx +578 -0
- package/src/components/sandbox-files.tsx +94 -9
- package/src/components/sandbox-workspace.tsx +186 -52
- package/src/components/session-status.tsx +0 -6
- package/src/components/workbench-changes.tsx +64 -20
- package/src/components/workspace-dock.tsx +146 -55
- package/src/hooks/use-composer.ts +369 -39
- package/src/hooks/use-session-control.ts +6 -7
- package/src/hooks/use-session-events.ts +3 -2
- package/src/hooks/use-session-lineage.ts +15 -6
- package/src/hooks/use-session.ts +10 -2
- package/src/hooks/use-turn-queue.ts +175 -47
- package/src/index.ts +13 -7
- package/src/machines.ts +16 -0
- package/src/provider.tsx +192 -5
- package/src/timeline/parsers.ts +43 -6
- package/src/timeline/projection.ts +24 -2
- package/styles/index.css +22 -0
- package/dist/chunk-NFYVQWIB.js +0 -1377
- package/dist/chunk-NFYVQWIB.js.map +0 -1
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// ----------------------------------------------------------------------------
|
|
2
|
+
// MetricHistoryChart — the per-metric history visual on the machine detail view.
|
|
3
|
+
//
|
|
4
|
+
// A dependency-free SVG line/area chart tuned for the calm dark aesthetic:
|
|
5
|
+
// a soft gradient fill, a smoothed stroke that draws itself in, whisper-quiet
|
|
6
|
+
// gridlines, dashed threshold guides, and a hover crosshair with a mono readout.
|
|
7
|
+
// It degrades honestly — a handful of points render as markers, zero points show
|
|
8
|
+
// a quiet "no samples" note rather than an empty axis.
|
|
9
|
+
//
|
|
10
|
+
// Pure presentational: it takes plotted {t, v} points + a little config and owns
|
|
11
|
+
// no data-fetching. `color` is any CSS color (pass a token var for theme-safety).
|
|
12
|
+
// ----------------------------------------------------------------------------
|
|
13
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
14
|
+
import { cn } from "../../lib/cn";
|
|
15
|
+
|
|
16
|
+
export type SeriesPoint = { t: number; v: number | null };
|
|
17
|
+
|
|
18
|
+
export type MetricHistoryChartProps = {
|
|
19
|
+
points: SeriesPoint[];
|
|
20
|
+
/** Fixed ceiling (100 for %) or "auto" to fit the data with headroom. */
|
|
21
|
+
yMax?: number | "auto";
|
|
22
|
+
yMin?: number;
|
|
23
|
+
/** Format a value for the axis + readout (default: rounded + unit). */
|
|
24
|
+
format?: (v: number) => string;
|
|
25
|
+
unit?: string;
|
|
26
|
+
/** Stroke/fill hue — any CSS color; pass a token var to stay theme-safe. */
|
|
27
|
+
color?: string;
|
|
28
|
+
thresholds?: { warn?: number | undefined; crit?: number | undefined } | undefined;
|
|
29
|
+
height?: number;
|
|
30
|
+
/** Human range label for the empty state ("in the last hour"). */
|
|
31
|
+
rangeLabel?: string | undefined;
|
|
32
|
+
className?: string | undefined;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const PAD = { top: 10, right: 12, bottom: 18, left: 40 };
|
|
36
|
+
|
|
37
|
+
function niceCeil(v: number): number {
|
|
38
|
+
if (v <= 0) return 1;
|
|
39
|
+
const mag = 10 ** Math.floor(Math.log10(v));
|
|
40
|
+
const n = v / mag;
|
|
41
|
+
const step = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10;
|
|
42
|
+
return step * mag;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Catmull-Rom → cubic-bezier path so the line is smooth without overshooting. */
|
|
46
|
+
function smoothPath(pts: Array<{ x: number; y: number }>): string {
|
|
47
|
+
if (pts.length === 0) return "";
|
|
48
|
+
if (pts.length === 1) return `M${pts[0]!.x},${pts[0]!.y}`;
|
|
49
|
+
let d = `M${pts[0]!.x},${pts[0]!.y}`;
|
|
50
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
51
|
+
const p0 = pts[i - 1] ?? pts[i]!;
|
|
52
|
+
const p1 = pts[i]!;
|
|
53
|
+
const p2 = pts[i + 1]!;
|
|
54
|
+
const p3 = pts[i + 2] ?? p2;
|
|
55
|
+
const cp1x = p1.x + (p2.x - p0.x) / 6;
|
|
56
|
+
const cp1y = p1.y + (p2.y - p0.y) / 6;
|
|
57
|
+
const cp2x = p2.x - (p3.x - p1.x) / 6;
|
|
58
|
+
const cp2y = p2.y - (p3.y - p1.y) / 6;
|
|
59
|
+
d += `C${cp1x},${cp1y} ${cp2x},${cp2y} ${p2.x},${p2.y}`;
|
|
60
|
+
}
|
|
61
|
+
return d;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function fmtClock(t: number): string {
|
|
65
|
+
const d = new Date(t);
|
|
66
|
+
return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function MetricHistoryChart({
|
|
70
|
+
points,
|
|
71
|
+
yMax = "auto",
|
|
72
|
+
yMin = 0,
|
|
73
|
+
format,
|
|
74
|
+
unit = "",
|
|
75
|
+
color = "var(--og-color-accent)",
|
|
76
|
+
thresholds,
|
|
77
|
+
height = 132,
|
|
78
|
+
rangeLabel = "in this range",
|
|
79
|
+
className,
|
|
80
|
+
}: MetricHistoryChartProps) {
|
|
81
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
82
|
+
const [w, setW] = useState(560);
|
|
83
|
+
const [hover, setHover] = useState<number | null>(null);
|
|
84
|
+
|
|
85
|
+
useLayoutEffect(() => {
|
|
86
|
+
const el = ref.current;
|
|
87
|
+
if (!el) return;
|
|
88
|
+
const ro = new ResizeObserver((entries) => {
|
|
89
|
+
const cw = entries[0]?.contentRect.width;
|
|
90
|
+
if (cw && cw > 0) setW(cw);
|
|
91
|
+
});
|
|
92
|
+
ro.observe(el);
|
|
93
|
+
return () => ro.disconnect();
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
const valued = points.filter(
|
|
97
|
+
(p): p is { t: number; v: number } => p.v != null && Number.isFinite(p.v),
|
|
98
|
+
);
|
|
99
|
+
const fmt = format ?? ((v: number) => `${Math.round(v)}${unit}`);
|
|
100
|
+
|
|
101
|
+
const H = height;
|
|
102
|
+
const plotW = Math.max(1, w - PAD.left - PAD.right);
|
|
103
|
+
const plotH = Math.max(1, H - PAD.top - PAD.bottom);
|
|
104
|
+
|
|
105
|
+
const dataMax = valued.length ? Math.max(...valued.map((p) => p.v)) : 1;
|
|
106
|
+
const top = yMax === "auto" ? niceCeil(Math.max(dataMax * 1.15, thresholds?.warn ?? 0, 1)) : yMax;
|
|
107
|
+
const span = Math.max(1e-6, top - yMin);
|
|
108
|
+
|
|
109
|
+
const tMin = valued.length ? valued[0]!.t : 0;
|
|
110
|
+
const tMax = valued.length ? valued[valued.length - 1]!.t : 1;
|
|
111
|
+
const tSpan = Math.max(1, tMax - tMin);
|
|
112
|
+
|
|
113
|
+
const xOf = (t: number) => PAD.left + ((t - tMin) / tSpan) * plotW;
|
|
114
|
+
const yOf = (v: number) =>
|
|
115
|
+
PAD.top + (1 - (Math.min(top, Math.max(yMin, v)) - yMin) / span) * plotH;
|
|
116
|
+
|
|
117
|
+
const pxPts = valued.map((p) => ({ x: xOf(p.t), y: yOf(p.v), t: p.t, v: p.v }));
|
|
118
|
+
const sparse = pxPts.length > 0 && pxPts.length <= 4;
|
|
119
|
+
|
|
120
|
+
const line = smoothPath(pxPts);
|
|
121
|
+
const area =
|
|
122
|
+
pxPts.length > 1
|
|
123
|
+
? `${line} L${pxPts[pxPts.length - 1]!.x},${PAD.top + plotH} L${pxPts[0]!.x},${PAD.top + plotH} Z`
|
|
124
|
+
: "";
|
|
125
|
+
|
|
126
|
+
// y gridlines at 0 / mid / top
|
|
127
|
+
const yTicks = [yMin, yMin + span / 2, top];
|
|
128
|
+
const gid = `og-mh-${Math.round(top)}-${Math.round(color.length)}`;
|
|
129
|
+
|
|
130
|
+
const hoverPt = hover != null ? pxPts[hover] : null;
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div
|
|
134
|
+
ref={ref}
|
|
135
|
+
className={cn("relative w-full select-none", className)}
|
|
136
|
+
style={{ height: H }}
|
|
137
|
+
data-metric-chart
|
|
138
|
+
>
|
|
139
|
+
{valued.length === 0 ? (
|
|
140
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
141
|
+
<span className="text-og-xs text-og-fg-subtle">No samples {rangeLabel}</span>
|
|
142
|
+
</div>
|
|
143
|
+
) : (
|
|
144
|
+
<svg
|
|
145
|
+
width={w}
|
|
146
|
+
height={H}
|
|
147
|
+
className="overflow-visible"
|
|
148
|
+
role="img"
|
|
149
|
+
onMouseLeave={() => setHover(null)}
|
|
150
|
+
onMouseMove={(e) => {
|
|
151
|
+
const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
|
|
152
|
+
const mx = e.clientX - rect.left;
|
|
153
|
+
let best = 0;
|
|
154
|
+
let bestD = Infinity;
|
|
155
|
+
for (let i = 0; i < pxPts.length; i++) {
|
|
156
|
+
const d = Math.abs(pxPts[i]!.x - mx);
|
|
157
|
+
if (d < bestD) {
|
|
158
|
+
bestD = d;
|
|
159
|
+
best = i;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
setHover(best);
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
<defs>
|
|
166
|
+
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
|
|
167
|
+
<stop offset="0%" stopColor={color} stopOpacity="0.26" />
|
|
168
|
+
<stop offset="100%" stopColor={color} stopOpacity="0" />
|
|
169
|
+
</linearGradient>
|
|
170
|
+
</defs>
|
|
171
|
+
|
|
172
|
+
{/* gridlines + y labels */}
|
|
173
|
+
{yTicks.map((v, i) => {
|
|
174
|
+
const y = yOf(v);
|
|
175
|
+
return (
|
|
176
|
+
<g key={`yt-${v}`}>
|
|
177
|
+
<line
|
|
178
|
+
x1={PAD.left}
|
|
179
|
+
x2={w - PAD.right}
|
|
180
|
+
y1={y}
|
|
181
|
+
y2={y}
|
|
182
|
+
stroke="var(--og-color-border)"
|
|
183
|
+
strokeOpacity={i === 0 ? 0.9 : 0.4}
|
|
184
|
+
strokeWidth={1}
|
|
185
|
+
/>
|
|
186
|
+
<text
|
|
187
|
+
x={PAD.left - 8}
|
|
188
|
+
y={y + 3}
|
|
189
|
+
textAnchor="end"
|
|
190
|
+
className="font-og-mono"
|
|
191
|
+
fontSize={10}
|
|
192
|
+
fill="var(--og-color-fg-subtle)"
|
|
193
|
+
>
|
|
194
|
+
{fmt(v)}
|
|
195
|
+
</text>
|
|
196
|
+
</g>
|
|
197
|
+
);
|
|
198
|
+
})}
|
|
199
|
+
|
|
200
|
+
{/* threshold guides */}
|
|
201
|
+
{(["warn", "crit"] as const).map((k) => {
|
|
202
|
+
const tv = thresholds?.[k];
|
|
203
|
+
if (tv == null || tv > top) return null;
|
|
204
|
+
const y = yOf(tv);
|
|
205
|
+
const stroke =
|
|
206
|
+
k === "crit" ? "var(--og-color-status-failed)" : "var(--og-color-status-waiting)";
|
|
207
|
+
return (
|
|
208
|
+
<line
|
|
209
|
+
key={k}
|
|
210
|
+
x1={PAD.left}
|
|
211
|
+
x2={w - PAD.right}
|
|
212
|
+
y1={y}
|
|
213
|
+
y2={y}
|
|
214
|
+
stroke={stroke}
|
|
215
|
+
strokeOpacity={0.5}
|
|
216
|
+
strokeWidth={1}
|
|
217
|
+
strokeDasharray="3 4"
|
|
218
|
+
/>
|
|
219
|
+
);
|
|
220
|
+
})}
|
|
221
|
+
|
|
222
|
+
{/* x time ticks (first + last) */}
|
|
223
|
+
{valued.length > 1 &&
|
|
224
|
+
[tMin, tMax].map((t, i) => (
|
|
225
|
+
<text
|
|
226
|
+
key={i === 0 ? "x-start" : "x-end"}
|
|
227
|
+
x={i === 0 ? PAD.left : w - PAD.right}
|
|
228
|
+
y={H - 4}
|
|
229
|
+
textAnchor={i === 0 ? "start" : "end"}
|
|
230
|
+
className="font-og-mono"
|
|
231
|
+
fontSize={10}
|
|
232
|
+
fill="var(--og-color-fg-subtle)"
|
|
233
|
+
>
|
|
234
|
+
{fmtClock(t)}
|
|
235
|
+
</text>
|
|
236
|
+
))}
|
|
237
|
+
|
|
238
|
+
{/* area + line */}
|
|
239
|
+
{area && <path d={area} fill={`url(#${gid})`} />}
|
|
240
|
+
<path
|
|
241
|
+
d={line}
|
|
242
|
+
fill="none"
|
|
243
|
+
stroke={color}
|
|
244
|
+
strokeWidth={1.75}
|
|
245
|
+
strokeLinejoin="round"
|
|
246
|
+
strokeLinecap="round"
|
|
247
|
+
pathLength={1}
|
|
248
|
+
className="og-mh-draw"
|
|
249
|
+
/>
|
|
250
|
+
|
|
251
|
+
{/* sparse markers */}
|
|
252
|
+
{sparse &&
|
|
253
|
+
pxPts.map((p) => <circle key={`m-${p.t}`} cx={p.x} cy={p.y} r={2.5} fill={color} />)}
|
|
254
|
+
|
|
255
|
+
{/* hover crosshair */}
|
|
256
|
+
{hoverPt && (
|
|
257
|
+
<g pointerEvents="none">
|
|
258
|
+
<line
|
|
259
|
+
x1={hoverPt.x}
|
|
260
|
+
x2={hoverPt.x}
|
|
261
|
+
y1={PAD.top}
|
|
262
|
+
y2={PAD.top + plotH}
|
|
263
|
+
stroke="var(--og-color-border-strong)"
|
|
264
|
+
strokeWidth={1}
|
|
265
|
+
/>
|
|
266
|
+
<circle
|
|
267
|
+
cx={hoverPt.x}
|
|
268
|
+
cy={hoverPt.y}
|
|
269
|
+
r={3.5}
|
|
270
|
+
fill={color}
|
|
271
|
+
stroke="var(--og-color-bg)"
|
|
272
|
+
strokeWidth={1.5}
|
|
273
|
+
/>
|
|
274
|
+
</g>
|
|
275
|
+
)}
|
|
276
|
+
</svg>
|
|
277
|
+
)}
|
|
278
|
+
|
|
279
|
+
{/* floating readout */}
|
|
280
|
+
{hoverPt && (
|
|
281
|
+
<div
|
|
282
|
+
className="pointer-events-none absolute z-10 flex flex-col rounded-og-sm border border-og-border-strong bg-og-surface-3/95 px-2 py-1 shadow-og-md backdrop-blur-sm"
|
|
283
|
+
style={{
|
|
284
|
+
left: Math.min(Math.max(hoverPt.x - 30, 0), w - 78),
|
|
285
|
+
top: Math.max(hoverPt.y - 42, 0),
|
|
286
|
+
}}
|
|
287
|
+
>
|
|
288
|
+
<span className="font-og-mono text-og-sm font-medium tabular-nums text-og-fg">
|
|
289
|
+
{fmt(hoverPt.v)}
|
|
290
|
+
</span>
|
|
291
|
+
<span className="font-og-mono text-[10px] tabular-nums text-og-fg-subtle">
|
|
292
|
+
{fmtClock(hoverPt.t)}
|
|
293
|
+
</span>
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// ----------------------------------------------------------------------------
|
|
2
|
+
// MetricSparkline — an axis-less micro-trend for stat tiles and (later) cards.
|
|
3
|
+
// Same smoothing as the full chart, no chrome: a hairline + a faint fill and a
|
|
4
|
+
// dot on the latest point. Fixed viewBox so it scales crisply at any width.
|
|
5
|
+
// ----------------------------------------------------------------------------
|
|
6
|
+
import type { SeriesPoint } from "./metric-history-chart";
|
|
7
|
+
|
|
8
|
+
export type MetricSparklineProps = {
|
|
9
|
+
points: SeriesPoint[];
|
|
10
|
+
color?: string;
|
|
11
|
+
yMax?: number | "auto";
|
|
12
|
+
height?: number;
|
|
13
|
+
className?: string | undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const VW = 120;
|
|
17
|
+
|
|
18
|
+
export function MetricSparkline({
|
|
19
|
+
points,
|
|
20
|
+
color = "var(--og-color-accent)",
|
|
21
|
+
yMax = "auto",
|
|
22
|
+
height = 28,
|
|
23
|
+
className,
|
|
24
|
+
}: MetricSparklineProps) {
|
|
25
|
+
const valued = points.filter(
|
|
26
|
+
(p): p is { t: number; v: number } => p.v != null && Number.isFinite(p.v),
|
|
27
|
+
);
|
|
28
|
+
if (valued.length < 2) {
|
|
29
|
+
return <div className={className} style={{ height }} aria-hidden />;
|
|
30
|
+
}
|
|
31
|
+
const vh = height;
|
|
32
|
+
const top = yMax === "auto" ? Math.max(1, ...valued.map((p) => p.v)) * 1.1 : yMax;
|
|
33
|
+
const tMin = valued[0]!.t;
|
|
34
|
+
const tSpan = Math.max(1, valued[valued.length - 1]!.t - tMin);
|
|
35
|
+
const xy = valued.map((p) => ({
|
|
36
|
+
x: ((p.t - tMin) / tSpan) * VW,
|
|
37
|
+
y: vh - (Math.min(top, Math.max(0, p.v)) / Math.max(1e-6, top)) * (vh - 2) - 1,
|
|
38
|
+
}));
|
|
39
|
+
let d = `M${xy[0]!.x},${xy[0]!.y}`;
|
|
40
|
+
for (let i = 0; i < xy.length - 1; i++) {
|
|
41
|
+
const p0 = xy[i - 1] ?? xy[i]!;
|
|
42
|
+
const p1 = xy[i]!;
|
|
43
|
+
const p2 = xy[i + 1]!;
|
|
44
|
+
const p3 = xy[i + 2] ?? p2;
|
|
45
|
+
d += `C${p1.x + (p2.x - p0.x) / 6},${p1.y + (p2.y - p0.y) / 6} ${p2.x - (p3.x - p1.x) / 6},${p2.y - (p3.y - p1.y) / 6} ${p2.x},${p2.y}`;
|
|
46
|
+
}
|
|
47
|
+
const last = xy[xy.length - 1]!;
|
|
48
|
+
const gid = `spark-${Math.round(top)}-${color.length}`;
|
|
49
|
+
return (
|
|
50
|
+
<svg
|
|
51
|
+
viewBox={`0 0 ${VW} ${vh}`}
|
|
52
|
+
preserveAspectRatio="none"
|
|
53
|
+
className={className}
|
|
54
|
+
style={{ height, width: "100%" }}
|
|
55
|
+
aria-hidden
|
|
56
|
+
>
|
|
57
|
+
<defs>
|
|
58
|
+
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
|
|
59
|
+
<stop offset="0%" stopColor={color} stopOpacity="0.22" />
|
|
60
|
+
<stop offset="100%" stopColor={color} stopOpacity="0" />
|
|
61
|
+
</linearGradient>
|
|
62
|
+
</defs>
|
|
63
|
+
<path d={`${d} L${last.x},${vh} L${xy[0]!.x},${vh} Z`} fill={`url(#${gid})`} />
|
|
64
|
+
<path
|
|
65
|
+
d={d}
|
|
66
|
+
fill="none"
|
|
67
|
+
stroke={color}
|
|
68
|
+
strokeWidth={1.25}
|
|
69
|
+
strokeLinejoin="round"
|
|
70
|
+
strokeLinecap="round"
|
|
71
|
+
vectorEffect="non-scaling-stroke"
|
|
72
|
+
/>
|
|
73
|
+
<circle cx={last.x} cy={last.y} r={1.6} fill={color} vectorEffect="non-scaling-stroke" />
|
|
74
|
+
</svg>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Shared series helpers: project a MetricSample[] into per-metric {t, v} points,
|
|
2
|
+
// and the metric catalog the detail view + tiles iterate over.
|
|
3
|
+
import type { MetricSample } from "../../types/machines";
|
|
4
|
+
import type { SeriesPoint } from "./metric-history-chart";
|
|
5
|
+
|
|
6
|
+
export type MetricWindow = "15m" | "1h" | "6h" | "24h";
|
|
7
|
+
export const METRIC_WINDOWS: MetricWindow[] = ["15m", "1h", "6h", "24h"];
|
|
8
|
+
|
|
9
|
+
export const WINDOW_LABEL: Record<MetricWindow, string> = {
|
|
10
|
+
"15m": "in the last 15 min",
|
|
11
|
+
"1h": "in the last hour",
|
|
12
|
+
"6h": "in the last 6 hours",
|
|
13
|
+
"24h": "in the last 24 hours",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function ptsFrom(samples: MetricSample[], pick: (s: MetricSample) => number | null): SeriesPoint[] {
|
|
17
|
+
return samples.map((s) => ({ t: new Date(s.sampledAt).getTime(), v: pick(s) }));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const memPct = (s: MetricSample) =>
|
|
21
|
+
s.memTotalBytes > 0 ? (s.memUsedBytes / s.memTotalBytes) * 100 : null;
|
|
22
|
+
const diskPct = (s: MetricSample) =>
|
|
23
|
+
s.diskTotalBytes > 0 ? (s.diskUsedBytes / s.diskTotalBytes) * 100 : null;
|
|
24
|
+
|
|
25
|
+
export type MetricKey = "cpu" | "mem" | "disk" | "load" | "gpu";
|
|
26
|
+
|
|
27
|
+
export type MetricDef = {
|
|
28
|
+
key: MetricKey;
|
|
29
|
+
title: string;
|
|
30
|
+
unit: string;
|
|
31
|
+
yMax: number | "auto";
|
|
32
|
+
color: string;
|
|
33
|
+
thresholds?: { warn?: number; crit?: number };
|
|
34
|
+
pick: (s: MetricSample) => number | null;
|
|
35
|
+
/** Big current-value string for tiles/badges. */
|
|
36
|
+
current: (s: MetricSample) => string;
|
|
37
|
+
/** Optional sub-line under the current value. */
|
|
38
|
+
sub?: (s: MetricSample) => string | null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const fmtBytes = (b: number): string => {
|
|
42
|
+
if (b < 1024) return `${b} B`;
|
|
43
|
+
const u = ["KB", "MB", "GB", "TB"];
|
|
44
|
+
let v = b / 1024;
|
|
45
|
+
for (const unit of u) {
|
|
46
|
+
if (v < 1024 || unit === "TB") return `${v.toFixed(v < 10 ? 1 : 0)} ${unit}`;
|
|
47
|
+
v /= 1024;
|
|
48
|
+
}
|
|
49
|
+
return `${b} B`;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// The catalog. GPU is included but the detail view only renders it when the
|
|
53
|
+
// latest sample actually reports a GPU (gpuUtilPct != null).
|
|
54
|
+
export const METRICS: MetricDef[] = [
|
|
55
|
+
{
|
|
56
|
+
key: "cpu",
|
|
57
|
+
title: "CPU",
|
|
58
|
+
unit: "%",
|
|
59
|
+
yMax: 100,
|
|
60
|
+
color: "var(--og-color-accent)",
|
|
61
|
+
thresholds: { warn: 90, crit: 98 },
|
|
62
|
+
pick: (s) => s.cpuPct,
|
|
63
|
+
current: (s) => `${Math.round(s.cpuPct)}%`,
|
|
64
|
+
sub: (s) => `${s.runQueue} in run queue`,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
key: "mem",
|
|
68
|
+
title: "Memory",
|
|
69
|
+
unit: "%",
|
|
70
|
+
yMax: 100,
|
|
71
|
+
color: "var(--og-color-status-idle)",
|
|
72
|
+
thresholds: { warn: 85, crit: 95 },
|
|
73
|
+
pick: memPct,
|
|
74
|
+
current: (s) => `${Math.round(memPct(s) ?? 0)}%`,
|
|
75
|
+
sub: (s) => `${fmtBytes(s.memUsedBytes)} / ${fmtBytes(s.memTotalBytes)}`,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
key: "disk",
|
|
79
|
+
title: "Disk",
|
|
80
|
+
unit: "%",
|
|
81
|
+
yMax: 100,
|
|
82
|
+
color: "var(--og-color-status-waiting)",
|
|
83
|
+
thresholds: { warn: 90, crit: 96 },
|
|
84
|
+
pick: diskPct,
|
|
85
|
+
current: (s) => `${Math.round(diskPct(s) ?? 0)}%`,
|
|
86
|
+
sub: (s) => `${fmtBytes(s.diskUsedBytes)} / ${fmtBytes(s.diskTotalBytes)}`,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
key: "load",
|
|
90
|
+
title: "Load average",
|
|
91
|
+
unit: "",
|
|
92
|
+
yMax: "auto",
|
|
93
|
+
color: "var(--og-color-accent-strong)",
|
|
94
|
+
pick: (s) => s.load1,
|
|
95
|
+
current: (s) => s.load1.toFixed(2),
|
|
96
|
+
sub: (s) => `5m ${s.load5.toFixed(2)} · 15m ${s.load15.toFixed(2)}`,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
key: "gpu",
|
|
100
|
+
title: "GPU",
|
|
101
|
+
unit: "%",
|
|
102
|
+
yMax: 100,
|
|
103
|
+
color: "var(--og-color-status-running)",
|
|
104
|
+
thresholds: { warn: 90, crit: 98 },
|
|
105
|
+
pick: (s) => s.gpuUtilPct,
|
|
106
|
+
current: (s) => (s.gpuUtilPct == null ? "—" : `${Math.round(s.gpuUtilPct)}%`),
|
|
107
|
+
sub: (s) => (s.gpuMemBytes == null ? null : `${fmtBytes(s.gpuMemBytes)} used`),
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
export function pointsFor(def: MetricDef, samples: MetricSample[]): SeriesPoint[] {
|
|
112
|
+
return ptsFrom(samples, def.pick);
|
|
113
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
import { LaptopIcon, PlusIcon, RefreshCwIcon } from "lucide-react";
|
|
3
3
|
import { cn } from "../lib/cn";
|
|
4
|
-
import type { MachineView } from "../types/machines";
|
|
4
|
+
import type { MachineView, MetricSample } from "../types/machines";
|
|
5
5
|
import { MachineCard } from "./machine-card";
|
|
6
6
|
|
|
7
7
|
export type MachinesDashboardProps = {
|
|
@@ -14,6 +14,12 @@ export type MachinesDashboardProps = {
|
|
|
14
14
|
onAttach?: ((machine: MachineView) => void) | undefined;
|
|
15
15
|
/** The sandbox id currently being attached/swapped to (disables that card). */
|
|
16
16
|
attachingSandboxId?: string | null | undefined;
|
|
17
|
+
/** Short recent history per machine (keyed by sandboxId) — drives card sparklines. */
|
|
18
|
+
seriesByMachine?: Record<string, MetricSample[]> | undefined;
|
|
19
|
+
/** Open the per-machine telemetry detail (makes each card actionable). */
|
|
20
|
+
onOpenDetail?: ((machine: MachineView) => void) | undefined;
|
|
21
|
+
/** Shared clock so freshness/relative times render consistently across cards. */
|
|
22
|
+
now?: number | undefined;
|
|
17
23
|
/** Open the enrollment flow (the "Enroll a machine" CTA). */
|
|
18
24
|
onEnroll?: (() => void) | undefined;
|
|
19
25
|
onRefresh?: (() => void) | undefined;
|
|
@@ -109,6 +115,9 @@ export function MachinesDashboard({
|
|
|
109
115
|
error,
|
|
110
116
|
onAttach,
|
|
111
117
|
attachingSandboxId,
|
|
118
|
+
seriesByMachine,
|
|
119
|
+
onOpenDetail,
|
|
120
|
+
now,
|
|
112
121
|
onEnroll,
|
|
113
122
|
onRefresh,
|
|
114
123
|
className,
|
|
@@ -162,6 +171,9 @@ export function MachinesDashboard({
|
|
|
162
171
|
}}
|
|
163
172
|
onAttach={onAttach}
|
|
164
173
|
attaching={attachingSandboxId === machine.sandboxId}
|
|
174
|
+
series={seriesByMachine?.[machine.sandboxId]}
|
|
175
|
+
onOpenDetail={onOpenDetail}
|
|
176
|
+
now={now}
|
|
165
177
|
/>
|
|
166
178
|
))}
|
|
167
179
|
</div>
|