@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.
Files changed (41) hide show
  1. package/README.md +19 -13
  2. package/dist/chunk-TOJR776I.js +2280 -0
  3. package/dist/chunk-TOJR776I.js.map +1 -0
  4. package/dist/index.d.ts +314 -255
  5. package/dist/index.js +5862 -4404
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-CnlMb7E-.d.ts → machines-BpdwuQcD.d.ts} +130 -10
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +23 -1
  10. package/package.json +5 -2
  11. package/src/client.ts +10 -1
  12. package/src/components/chat-composer.tsx +309 -57
  13. package/src/components/machine-card.tsx +81 -15
  14. package/src/components/machine-health-pill.tsx +68 -0
  15. package/src/components/machine-metrics.tsx +10 -24
  16. package/src/components/machines/health.ts +146 -0
  17. package/src/components/machines/machine-detail.tsx +220 -0
  18. package/src/components/machines/metric-history-chart.tsx +298 -0
  19. package/src/components/machines/metric-sparkline.tsx +76 -0
  20. package/src/components/machines/series.ts +113 -0
  21. package/src/components/machines-dashboard.tsx +13 -1
  22. package/src/components/queue-surface.tsx +578 -0
  23. package/src/components/sandbox-files.tsx +94 -9
  24. package/src/components/sandbox-workspace.tsx +186 -52
  25. package/src/components/session-status.tsx +0 -6
  26. package/src/components/workbench-changes.tsx +64 -20
  27. package/src/components/workspace-dock.tsx +146 -55
  28. package/src/hooks/use-composer.ts +369 -39
  29. package/src/hooks/use-session-control.ts +6 -7
  30. package/src/hooks/use-session-events.ts +3 -2
  31. package/src/hooks/use-session-lineage.ts +15 -6
  32. package/src/hooks/use-session.ts +10 -2
  33. package/src/hooks/use-turn-queue.ts +175 -47
  34. package/src/index.ts +13 -7
  35. package/src/machines.ts +16 -0
  36. package/src/provider.tsx +192 -5
  37. package/src/timeline/parsers.ts +43 -6
  38. package/src/timeline/projection.ts +24 -2
  39. package/styles/index.css +22 -0
  40. package/dist/chunk-NFYVQWIB.js +0 -1377
  41. 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>