omkit 0.8.14 → 0.8.15
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 +1 -1
- package/dist/core/ExecutionTree.js +1 -0
- package/dist/core/step.js +1 -0
- package/dist/core/types.d.ts +2 -0
- package/dist/output/writers/report/report.css +57 -0
- package/dist/output/writers/report/report.js +2387 -1981
- package/dist/output/writers/views.d.ts +2 -0
- package/package.json +1 -1
|
@@ -1,1981 +1,2387 @@
|
|
|
1
|
-
/* omkit run report — the viewer. Runs in the browser against the JSON block the writer
|
|
2
|
-
* inlined (`#omkit-data`) and the global `d3`. Plain script, no build step: the writer
|
|
3
|
-
* reads this file and pastes it into report.html as-is. */
|
|
4
|
-
(function () {
|
|
5
|
-
"use strict";
|
|
6
|
-
|
|
7
|
-
const ROW_H = 42;
|
|
8
|
-
const BAR_Y = 6;
|
|
9
|
-
const BAR_H = 20;
|
|
10
|
-
const FLAME_ROW = 26;
|
|
11
|
-
|
|
12
|
-
// ── icons (24×24, stroked) ───────────────────────────────
|
|
13
|
-
const ICON = {
|
|
14
|
-
timeline: "M3 17l5-5 4 4 8-8M3 21h18",
|
|
15
|
-
flame: "M12 3c1 4 6 6 6 11a6 6 0 0 1-12 0c0-3 2-4 2-7 2 1 3 3 3 5 1-2 1-6 1-9z",
|
|
16
|
-
flow: "M4 4h7v6H4zM13 14h7v6h-7zM7.5 10v4h9v0M16.5 14v-1",
|
|
17
|
-
zoomIn: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4M11 8v6M8 11h6",
|
|
18
|
-
zoomOut: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4M8 11h6",
|
|
19
|
-
more: "M5 12h.01M12 12h.01M19 12h.01",
|
|
20
|
-
close: "M6 6l12 12M18 6L6 18",
|
|
21
|
-
chevron: "M9 6l6 6-6 6",
|
|
22
|
-
play: "M8 5v14l11-7z",
|
|
23
|
-
terminal: "M4 5h16v14H4zM7 9l3 3-3 3M12 15h5",
|
|
24
|
-
globe: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18",
|
|
25
|
-
window: "M3 5h18v14H3zM3 9h18M6 7h.01M9 7h.01",
|
|
26
|
-
pulse: "M3 12h4l2-5 4 10 2-5h6",
|
|
27
|
-
cursor: "M5 3l14 7-6 2-2 6z",
|
|
28
|
-
box: "M12 3l8 4.5v9L12 21l-8-4.5v-9zM4 7.5l8 4.5 8-4.5M12 12v9",
|
|
29
|
-
camera: "M4 8h3l2-3h6l2 3h3v11H4zM12 10a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7z",
|
|
30
|
-
file: "M6 3h8l4 4v14H6zM14 3v4h4",
|
|
31
|
-
check: "M5 12l4 4 10-10",
|
|
32
|
-
x: "M6 6l12 12M18 6L6 18",
|
|
33
|
-
alert: "M12 4l9 16H3zM12 10v4M12 17h.01",
|
|
34
|
-
root: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM12 2v3M12 19v3M2 12h3M19 12h3",
|
|
35
|
-
chat: "M4 5h16v11H9l-5 4z",
|
|
36
|
-
flask: "M9 3h6M10 3v6l-5 9a2 2 0 0 0 2 3h10a2 2 0 0 0 2-3l-5-9V3",
|
|
37
|
-
braces: "M8 4c-2 0-3 1-3 3v2c0 1-1 2-2 3 1 1 2 2 2 3v2c0 2 1 3 3 3M16 4c2 0 3 1 3 3v2c0 1 1 2 2 3-1 1-2 2-2 3v2c0 2-1 3-3 3",
|
|
38
|
-
image: "M4 5h16v14H4zM4 16l5-5 4 4 2-2 5 5M15 9h.01",
|
|
39
|
-
sun: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4",
|
|
40
|
-
download: "M12 4v11M7 10l5 5 5-5M5 20h14",
|
|
41
|
-
copy: "M9 9h11v11H9zM5 15V4h11",
|
|
42
|
-
stop: "M6 6h12v12H6z",
|
|
43
|
-
info: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 11v5M12 8h.01",
|
|
44
|
-
locate: "M12 5a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM12 2v3M12 19v3M2 12h3M19 12h3M12 10.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z",
|
|
45
|
-
code: "M8 7l-5 5 5 5M16 7l5 5-5 5",
|
|
46
|
-
ret: "M9 10l-4 4 4 4M5 14h11a4 4 0 0 0 4-4V5",
|
|
47
|
-
external: "M14 4h6v6M20 4l-9 9M18 14v6H4V6h6",
|
|
48
|
-
};
|
|
49
|
-
const svgIcon = (name, extra) =>
|
|
50
|
-
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"${
|
|
51
|
-
extra || ""
|
|
52
|
-
}><path d="${ICON[name]}"/></svg>`;
|
|
53
|
-
|
|
54
|
-
const PALETTE = ["--c1", "--c2", "--c3", "--c4", "--c5", "--c6", "--c7", "--c8"];
|
|
55
|
-
const css = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
|
|
56
|
-
|
|
57
|
-
const MARK = {
|
|
58
|
-
event: { color: "--c3", icon: "check", label: "Process event" },
|
|
59
|
-
snapshot: { color: "--c2", icon: "camera", label: "Snapshot" },
|
|
60
|
-
artifact: { color: "--c6", icon: "file", label: "Artifact" },
|
|
61
|
-
pass: { color: "--ok", icon: "check", label: "Assertion passed" },
|
|
62
|
-
fail: { color: "--fail", icon: "alert", label: "Assertion failed" },
|
|
63
|
-
error: { color: "--fail", icon: "x", label: "Error" },
|
|
64
|
-
cancel: { color: "--cancel", icon: "stop", label: "Cancelled" },
|
|
65
|
-
lifecycle: { color: "--c1", icon: "play", label: "Lifecycle" },
|
|
66
|
-
run: { color: "--text-3", icon: "root", label: "Run" },
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
// ── formatting ───────────────────────────────────────────
|
|
70
|
-
const fmtDur = (ms) => {
|
|
71
|
-
if (!isFinite(ms)) return "–";
|
|
72
|
-
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
73
|
-
if (ms < 10000) return `${(ms / 1000).toFixed(1)}s`;
|
|
74
|
-
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
75
|
-
const m = Math.floor(ms / 60000);
|
|
76
|
-
const s = Math.round((ms % 60000) / 1000);
|
|
77
|
-
return `${m}m ${String(s).padStart(2, "0")}s`;
|
|
78
|
-
};
|
|
79
|
-
const fmtRel = (ms) => `+${fmtDur(Math.max(0, ms)).replace(/^0ms$/, "0ms")}`;
|
|
80
|
-
const pad2 = (n) => String(n).padStart(2, "0");
|
|
81
|
-
const fmtClock = (ts) => {
|
|
82
|
-
const d = new Date(ts);
|
|
83
|
-
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, "0")}`;
|
|
84
|
-
};
|
|
85
|
-
const fmtDate = (ts) => {
|
|
86
|
-
const d = new Date(ts);
|
|
87
|
-
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
|
|
88
|
-
};
|
|
89
|
-
const fmtSize = (b) =>
|
|
90
|
-
b < 1024 ? `${b} B` : b < 1048576 ? `${Math.round(b / 1024)} KB` : `${(b / 1048576).toFixed(1)} MB`;
|
|
91
|
-
const fmtTick = (ms) => {
|
|
92
|
-
if (ms === 0) return "0s";
|
|
93
|
-
const a = Math.abs(ms);
|
|
94
|
-
if (a < 1000) return `${Math.round(ms)}ms`;
|
|
95
|
-
if (a < 60000) return `${+(ms / 1000).toFixed(a < 10000 ? 2 : 1)}s`;
|
|
96
|
-
return `${Math.floor(ms / 60000)}m${pad2(Math.round((ms % 60000) / 1000))}s`;
|
|
97
|
-
};
|
|
98
|
-
const esc = (s) =>
|
|
99
|
-
String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
100
|
-
const basename = (p) => String(p).split(/[\\/]/).pop();
|
|
101
|
-
const fileUrl = (p) => "file:///" + String(p).replace(/\\/g, "/").replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/").replace(/^([A-Za-z])%3A/, "$1:");
|
|
102
|
-
const editorUrl = (site) => {
|
|
103
|
-
const m = /^(.*):(\d+)$/.exec(site);
|
|
104
|
-
const file = (m ? m[1] : site).replace(/\\/g, "/");
|
|
105
|
-
return `vscode://file/${file.replace(/^\/+/, "")}${m ? ":" + m[2] : ""}`;
|
|
106
|
-
};
|
|
107
|
-
const json = (v) => {
|
|
108
|
-
try {
|
|
109
|
-
return JSON.stringify(v, null, 2);
|
|
110
|
-
} catch {
|
|
111
|
-
return String(v);
|
|
112
|
-
}
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
// ── model ────────────────────────────────────────────────
|
|
116
|
-
function buildModel(data) {
|
|
117
|
-
const run = data.run;
|
|
118
|
-
const t0 = run.startedAt;
|
|
119
|
-
const nodes = [];
|
|
120
|
-
const byId = new Map();
|
|
121
|
-
let tEnd = run.endedAt || t0;
|
|
122
|
-
let topIndex = 0;
|
|
123
|
-
(function walk(v, parent, depth, branch) {
|
|
124
|
-
const isTop = depth === 1;
|
|
125
|
-
const b = depth === 0 ? 0 : isTop ? topIndex++ % PALETTE.length : branch;
|
|
126
|
-
const n = {
|
|
127
|
-
v,
|
|
128
|
-
id: v.id,
|
|
129
|
-
name: v.name,
|
|
130
|
-
parent,
|
|
131
|
-
depth,
|
|
132
|
-
branch: depth === 0 ? -1 : b,
|
|
133
|
-
children: [],
|
|
134
|
-
status: ["ok", "failed", "cancelled"].includes(v.status) ? v.status : v.endedAt ? v.status : "running",
|
|
135
|
-
startAbs: v.startedAt || t0,
|
|
136
|
-
endAbs: v.endedAt || 0,
|
|
137
|
-
entries: [],
|
|
138
|
-
marks: [],
|
|
139
|
-
};
|
|
140
|
-
nodes.push(n);
|
|
141
|
-
byId.set(n.id, n);
|
|
142
|
-
if (parent) parent.children.push(n);
|
|
143
|
-
if (n.endAbs > tEnd) tEnd = n.endAbs;
|
|
144
|
-
for (const c of v.children || []) walk(c, n, depth + 1, b);
|
|
145
|
-
})(run.root, null, 0, 0);
|
|
146
|
-
|
|
147
|
-
for (const e of data.entries) if (e.ts > tEnd) tEnd = e.ts;
|
|
148
|
-
for (const n of nodes) {
|
|
149
|
-
if (!n.endAbs) n.endAbs = tEnd;
|
|
150
|
-
n.start = n.startAbs - t0;
|
|
151
|
-
n.end = Math.max(n.start, n.endAbs - t0);
|
|
152
|
-
n.duration = n.v.duration || n.end - n.start;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const items = [];
|
|
156
|
-
const runEntries = [];
|
|
157
|
-
for (const e of data.entries) {
|
|
158
|
-
if (e.level === "child" || e.level === "tag") continue;
|
|
159
|
-
const kind = kindOf(e);
|
|
160
|
-
const item = { idx: items.length, e, kind, t: e.ts - t0, label: labelOf(e, kind), node: byId.get(e.nodeId) || null };
|
|
161
|
-
items.push(item);
|
|
162
|
-
if (item.node) {
|
|
163
|
-
item.node.entries.push(item);
|
|
164
|
-
if (kind !== "info" && kind !== "lifecycle") item.node.marks.push(item);
|
|
165
|
-
} else runEntries.push(item);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const assets = new Map();
|
|
169
|
-
const gallery = [];
|
|
170
|
-
for (const f of data.files) {
|
|
171
|
-
gallery.push({
|
|
172
|
-
type: "file",
|
|
173
|
-
f,
|
|
174
|
-
name: f.name,
|
|
175
|
-
t: f.ts ? f.ts - t0 : null,
|
|
176
|
-
node: f.nodeId ? byId.get(f.nodeId) || null : null,
|
|
177
|
-
image: f.mime.startsWith("image/") && !!f.asset,
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
for (const s of data.snapshots) {
|
|
181
|
-
gallery.push({ type: "snapshot", s, name: s.name, t: s.ts - t0, node: byId.get(s.nodeId) || null, image: false });
|
|
182
|
-
}
|
|
183
|
-
gallery.sort((a, b) => (a.t ?? Infinity) - (b.t ?? Infinity));
|
|
184
|
-
// Let a mark find its gallery item (snapshot by file, artifact by file).
|
|
185
|
-
const galleryByFile = new Map();
|
|
186
|
-
for (const g of gallery) galleryByFile.set(String(g.type === "file" ? g.f.file : g.s.file).toLowerCase(), g);
|
|
187
|
-
for (const it of items) {
|
|
188
|
-
if (it.kind === "snapshot" || it.kind === "artifact") {
|
|
189
|
-
const i = it.e.message.lastIndexOf(" → ");
|
|
190
|
-
if (i >= 0) it.gallery = galleryByFile.get(it.e.message.slice(i + 3).toLowerCase()) || null;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const asserts = items.filter((i) => i.kind === "pass" || i.kind === "fail");
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
.
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
}
|
|
411
|
-
function
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
function
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
.
|
|
472
|
-
.
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
.
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
.
|
|
608
|
-
|
|
609
|
-
.
|
|
610
|
-
.
|
|
611
|
-
.attr("
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
.
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
.
|
|
639
|
-
.on("
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
.on("
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
.
|
|
691
|
-
.
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
.
|
|
711
|
-
.
|
|
712
|
-
.
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
const
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
.
|
|
770
|
-
.
|
|
771
|
-
.
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
})
|
|
782
|
-
|
|
783
|
-
.
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
.
|
|
800
|
-
.
|
|
801
|
-
.
|
|
802
|
-
.
|
|
803
|
-
|
|
804
|
-
.
|
|
805
|
-
.attr("
|
|
806
|
-
.attr("
|
|
807
|
-
|
|
808
|
-
.
|
|
809
|
-
|
|
810
|
-
.
|
|
811
|
-
.
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
})
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
.
|
|
826
|
-
|
|
827
|
-
.
|
|
828
|
-
.attr("
|
|
829
|
-
.
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
})
|
|
833
|
-
.
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
const
|
|
919
|
-
const
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
.
|
|
926
|
-
.
|
|
927
|
-
.
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
.attr("
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
.
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
const
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
);
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
if (
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
if (
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
function
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
)
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
const
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
const
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
};
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
const
|
|
1382
|
-
const
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
<
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
/**
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
function
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
const
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
const
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
function
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
<div class="
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
</
|
|
1713
|
-
<
|
|
1714
|
-
|
|
1715
|
-
</
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
}
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
const
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
n
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1
|
+
/* omkit run report — the viewer. Runs in the browser against the JSON block the writer
|
|
2
|
+
* inlined (`#omkit-data`) and the global `d3`. Plain script, no build step: the writer
|
|
3
|
+
* reads this file and pastes it into report.html as-is. */
|
|
4
|
+
(function () {
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
const ROW_H = 42;
|
|
8
|
+
const BAR_Y = 6;
|
|
9
|
+
const BAR_H = 20;
|
|
10
|
+
const FLAME_ROW = 26;
|
|
11
|
+
|
|
12
|
+
// ── icons (24×24, stroked) ───────────────────────────────
|
|
13
|
+
const ICON = {
|
|
14
|
+
timeline: "M3 17l5-5 4 4 8-8M3 21h18",
|
|
15
|
+
flame: "M12 3c1 4 6 6 6 11a6 6 0 0 1-12 0c0-3 2-4 2-7 2 1 3 3 3 5 1-2 1-6 1-9z",
|
|
16
|
+
flow: "M4 4h7v6H4zM13 14h7v6h-7zM7.5 10v4h9v0M16.5 14v-1",
|
|
17
|
+
zoomIn: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4M11 8v6M8 11h6",
|
|
18
|
+
zoomOut: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4M8 11h6",
|
|
19
|
+
more: "M5 12h.01M12 12h.01M19 12h.01",
|
|
20
|
+
close: "M6 6l12 12M18 6L6 18",
|
|
21
|
+
chevron: "M9 6l6 6-6 6",
|
|
22
|
+
play: "M8 5v14l11-7z",
|
|
23
|
+
terminal: "M4 5h16v14H4zM7 9l3 3-3 3M12 15h5",
|
|
24
|
+
globe: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18",
|
|
25
|
+
window: "M3 5h18v14H3zM3 9h18M6 7h.01M9 7h.01",
|
|
26
|
+
pulse: "M3 12h4l2-5 4 10 2-5h6",
|
|
27
|
+
cursor: "M5 3l14 7-6 2-2 6z",
|
|
28
|
+
box: "M12 3l8 4.5v9L12 21l-8-4.5v-9zM4 7.5l8 4.5 8-4.5M12 12v9",
|
|
29
|
+
camera: "M4 8h3l2-3h6l2 3h3v11H4zM12 10a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7z",
|
|
30
|
+
file: "M6 3h8l4 4v14H6zM14 3v4h4",
|
|
31
|
+
check: "M5 12l4 4 10-10",
|
|
32
|
+
x: "M6 6l12 12M18 6L6 18",
|
|
33
|
+
alert: "M12 4l9 16H3zM12 10v4M12 17h.01",
|
|
34
|
+
root: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM12 2v3M12 19v3M2 12h3M19 12h3",
|
|
35
|
+
chat: "M4 5h16v11H9l-5 4z",
|
|
36
|
+
flask: "M9 3h6M10 3v6l-5 9a2 2 0 0 0 2 3h10a2 2 0 0 0 2-3l-5-9V3",
|
|
37
|
+
braces: "M8 4c-2 0-3 1-3 3v2c0 1-1 2-2 3 1 1 2 2 2 3v2c0 2 1 3 3 3M16 4c2 0 3 1 3 3v2c0 1 1 2 2 3-1 1-2 2-2 3v2c0 2-1 3-3 3",
|
|
38
|
+
image: "M4 5h16v14H4zM4 16l5-5 4 4 2-2 5 5M15 9h.01",
|
|
39
|
+
sun: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4",
|
|
40
|
+
download: "M12 4v11M7 10l5 5 5-5M5 20h14",
|
|
41
|
+
copy: "M9 9h11v11H9zM5 15V4h11",
|
|
42
|
+
stop: "M6 6h12v12H6z",
|
|
43
|
+
info: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 11v5M12 8h.01",
|
|
44
|
+
locate: "M12 5a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM12 2v3M12 19v3M2 12h3M19 12h3M12 10.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z",
|
|
45
|
+
code: "M8 7l-5 5 5 5M16 7l5 5-5 5",
|
|
46
|
+
ret: "M9 10l-4 4 4 4M5 14h11a4 4 0 0 0 4-4V5",
|
|
47
|
+
external: "M14 4h6v6M20 4l-9 9M18 14v6H4V6h6",
|
|
48
|
+
};
|
|
49
|
+
const svgIcon = (name, extra) =>
|
|
50
|
+
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"${
|
|
51
|
+
extra || ""
|
|
52
|
+
}><path d="${ICON[name]}"/></svg>`;
|
|
53
|
+
|
|
54
|
+
const PALETTE = ["--c1", "--c2", "--c3", "--c4", "--c5", "--c6", "--c7", "--c8"];
|
|
55
|
+
const css = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
|
|
56
|
+
|
|
57
|
+
const MARK = {
|
|
58
|
+
event: { color: "--c3", icon: "check", label: "Process event" },
|
|
59
|
+
snapshot: { color: "--c2", icon: "camera", label: "Snapshot" },
|
|
60
|
+
artifact: { color: "--c6", icon: "file", label: "Artifact" },
|
|
61
|
+
pass: { color: "--ok", icon: "check", label: "Assertion passed" },
|
|
62
|
+
fail: { color: "--fail", icon: "alert", label: "Assertion failed" },
|
|
63
|
+
error: { color: "--fail", icon: "x", label: "Error" },
|
|
64
|
+
cancel: { color: "--cancel", icon: "stop", label: "Cancelled" },
|
|
65
|
+
lifecycle: { color: "--c1", icon: "play", label: "Lifecycle" },
|
|
66
|
+
run: { color: "--text-3", icon: "root", label: "Run" },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ── formatting ───────────────────────────────────────────
|
|
70
|
+
const fmtDur = (ms) => {
|
|
71
|
+
if (!isFinite(ms)) return "–";
|
|
72
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
73
|
+
if (ms < 10000) return `${(ms / 1000).toFixed(1)}s`;
|
|
74
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
75
|
+
const m = Math.floor(ms / 60000);
|
|
76
|
+
const s = Math.round((ms % 60000) / 1000);
|
|
77
|
+
return `${m}m ${String(s).padStart(2, "0")}s`;
|
|
78
|
+
};
|
|
79
|
+
const fmtRel = (ms) => `+${fmtDur(Math.max(0, ms)).replace(/^0ms$/, "0ms")}`;
|
|
80
|
+
const pad2 = (n) => String(n).padStart(2, "0");
|
|
81
|
+
const fmtClock = (ts) => {
|
|
82
|
+
const d = new Date(ts);
|
|
83
|
+
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, "0")}`;
|
|
84
|
+
};
|
|
85
|
+
const fmtDate = (ts) => {
|
|
86
|
+
const d = new Date(ts);
|
|
87
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
|
|
88
|
+
};
|
|
89
|
+
const fmtSize = (b) =>
|
|
90
|
+
b < 1024 ? `${b} B` : b < 1048576 ? `${Math.round(b / 1024)} KB` : `${(b / 1048576).toFixed(1)} MB`;
|
|
91
|
+
const fmtTick = (ms) => {
|
|
92
|
+
if (ms === 0) return "0s";
|
|
93
|
+
const a = Math.abs(ms);
|
|
94
|
+
if (a < 1000) return `${Math.round(ms)}ms`;
|
|
95
|
+
if (a < 60000) return `${+(ms / 1000).toFixed(a < 10000 ? 2 : 1)}s`;
|
|
96
|
+
return `${Math.floor(ms / 60000)}m${pad2(Math.round((ms % 60000) / 1000))}s`;
|
|
97
|
+
};
|
|
98
|
+
const esc = (s) =>
|
|
99
|
+
String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
100
|
+
const basename = (p) => String(p).split(/[\\/]/).pop();
|
|
101
|
+
const fileUrl = (p) => "file:///" + String(p).replace(/\\/g, "/").replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/").replace(/^([A-Za-z])%3A/, "$1:");
|
|
102
|
+
const editorUrl = (site) => {
|
|
103
|
+
const m = /^(.*):(\d+)$/.exec(site);
|
|
104
|
+
const file = (m ? m[1] : site).replace(/\\/g, "/");
|
|
105
|
+
return `vscode://file/${file.replace(/^\/+/, "")}${m ? ":" + m[2] : ""}`;
|
|
106
|
+
};
|
|
107
|
+
const json = (v) => {
|
|
108
|
+
try {
|
|
109
|
+
return JSON.stringify(v, null, 2);
|
|
110
|
+
} catch {
|
|
111
|
+
return String(v);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// ── model ────────────────────────────────────────────────
|
|
116
|
+
function buildModel(data) {
|
|
117
|
+
const run = data.run;
|
|
118
|
+
const t0 = run.startedAt;
|
|
119
|
+
const nodes = [];
|
|
120
|
+
const byId = new Map();
|
|
121
|
+
let tEnd = run.endedAt || t0;
|
|
122
|
+
let topIndex = 0;
|
|
123
|
+
(function walk(v, parent, depth, branch) {
|
|
124
|
+
const isTop = depth === 1;
|
|
125
|
+
const b = depth === 0 ? 0 : isTop ? topIndex++ % PALETTE.length : branch;
|
|
126
|
+
const n = {
|
|
127
|
+
v,
|
|
128
|
+
id: v.id,
|
|
129
|
+
name: v.name,
|
|
130
|
+
parent,
|
|
131
|
+
depth,
|
|
132
|
+
branch: depth === 0 ? -1 : b,
|
|
133
|
+
children: [],
|
|
134
|
+
status: ["ok", "failed", "cancelled"].includes(v.status) ? v.status : v.endedAt ? v.status : "running",
|
|
135
|
+
startAbs: v.startedAt || t0,
|
|
136
|
+
endAbs: v.endedAt || 0,
|
|
137
|
+
entries: [],
|
|
138
|
+
marks: [],
|
|
139
|
+
};
|
|
140
|
+
nodes.push(n);
|
|
141
|
+
byId.set(n.id, n);
|
|
142
|
+
if (parent) parent.children.push(n);
|
|
143
|
+
if (n.endAbs > tEnd) tEnd = n.endAbs;
|
|
144
|
+
for (const c of v.children || []) walk(c, n, depth + 1, b);
|
|
145
|
+
})(run.root, null, 0, 0);
|
|
146
|
+
|
|
147
|
+
for (const e of data.entries) if (e.ts > tEnd) tEnd = e.ts;
|
|
148
|
+
for (const n of nodes) {
|
|
149
|
+
if (!n.endAbs) n.endAbs = tEnd;
|
|
150
|
+
n.start = n.startAbs - t0;
|
|
151
|
+
n.end = Math.max(n.start, n.endAbs - t0);
|
|
152
|
+
n.duration = n.v.duration || n.end - n.start;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const items = [];
|
|
156
|
+
const runEntries = [];
|
|
157
|
+
for (const e of data.entries) {
|
|
158
|
+
if (e.level === "child" || e.level === "tag") continue;
|
|
159
|
+
const kind = kindOf(e);
|
|
160
|
+
const item = { idx: items.length, e, kind, t: e.ts - t0, label: labelOf(e, kind), node: byId.get(e.nodeId) || null };
|
|
161
|
+
items.push(item);
|
|
162
|
+
if (item.node) {
|
|
163
|
+
item.node.entries.push(item);
|
|
164
|
+
if (kind !== "info" && kind !== "lifecycle") item.node.marks.push(item);
|
|
165
|
+
} else runEntries.push(item);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const assets = new Map();
|
|
169
|
+
const gallery = [];
|
|
170
|
+
for (const f of data.files) {
|
|
171
|
+
gallery.push({
|
|
172
|
+
type: "file",
|
|
173
|
+
f,
|
|
174
|
+
name: f.name,
|
|
175
|
+
t: f.ts ? f.ts - t0 : null,
|
|
176
|
+
node: f.nodeId ? byId.get(f.nodeId) || null : null,
|
|
177
|
+
image: f.mime.startsWith("image/") && !!f.asset,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
for (const s of data.snapshots) {
|
|
181
|
+
gallery.push({ type: "snapshot", s, name: s.name, t: s.ts - t0, node: byId.get(s.nodeId) || null, image: false });
|
|
182
|
+
}
|
|
183
|
+
gallery.sort((a, b) => (a.t ?? Infinity) - (b.t ?? Infinity));
|
|
184
|
+
// Let a mark find its gallery item (snapshot by file, artifact by file).
|
|
185
|
+
const galleryByFile = new Map();
|
|
186
|
+
for (const g of gallery) galleryByFile.set(String(g.type === "file" ? g.f.file : g.s.file).toLowerCase(), g);
|
|
187
|
+
for (const it of items) {
|
|
188
|
+
if (it.kind === "snapshot" || it.kind === "artifact") {
|
|
189
|
+
const i = it.e.message.lastIndexOf(" → ");
|
|
190
|
+
if (i >= 0) it.gallery = galleryByFile.get(it.e.message.slice(i + 3).toLowerCase()) || null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const asserts = items.filter((i) => i.kind === "pass" || i.kind === "fail");
|
|
195
|
+
const errors = collectErrors(items, nodes, run, tEnd - t0);
|
|
196
|
+
const errorsBy = new Map();
|
|
197
|
+
const errPath = new Set();
|
|
198
|
+
for (const e of errors) {
|
|
199
|
+
if (!e.node) continue;
|
|
200
|
+
errorsBy.set(e.node, (errorsBy.get(e.node) || 0) + 1);
|
|
201
|
+
for (let p = e.node.parent; p; p = p.parent) errPath.add(p.id);
|
|
202
|
+
}
|
|
203
|
+
const errNodes = new Set([...errorsBy.keys()].map((n) => n.id));
|
|
204
|
+
return {
|
|
205
|
+
errors,
|
|
206
|
+
errorsBy,
|
|
207
|
+
errNodes,
|
|
208
|
+
errPath,
|
|
209
|
+
data,
|
|
210
|
+
run,
|
|
211
|
+
t0,
|
|
212
|
+
span: Math.max(1, tEnd - t0),
|
|
213
|
+
nodes,
|
|
214
|
+
byId,
|
|
215
|
+
root: nodes[0],
|
|
216
|
+
items,
|
|
217
|
+
runEntries,
|
|
218
|
+
gallery,
|
|
219
|
+
asserts,
|
|
220
|
+
assets,
|
|
221
|
+
asset(id) {
|
|
222
|
+
if (!assets.has(id)) {
|
|
223
|
+
const el = document.getElementById(id);
|
|
224
|
+
assets.set(id, el ? `data:${el.dataset.mime};base64,${el.textContent.trim()}` : "");
|
|
225
|
+
}
|
|
226
|
+
return assets.get(id);
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Everything that went wrong, in time order, as three kinds:
|
|
233
|
+
* - `failure` — an action threw: its error (with stack), one per failed node;
|
|
234
|
+
* - `assert` — a failed `assert(…)`;
|
|
235
|
+
* - `output` — error output (stderr, console.error). A process's consecutive lines arrive as
|
|
236
|
+
* separate entries; lines from one source within a short gap are one block, so a stack
|
|
237
|
+
* trace reads as one error rather than thirty.
|
|
238
|
+
*/
|
|
239
|
+
function collectErrors(items, nodes, run, span) {
|
|
240
|
+
const out = [];
|
|
241
|
+
const open = new Map(); // `${nodeId}\0${source}` → the output block still accepting lines
|
|
242
|
+
for (const it of items) {
|
|
243
|
+
if (it.kind === "fail") {
|
|
244
|
+
out.push({ type: "assert", node: it.node, t: it.t, title: it.label, text: it.e.message, items: [it] });
|
|
245
|
+
} else if (it.kind === "error" && it.e.source === "error") {
|
|
246
|
+
out.push({ type: "failure", node: it.node, t: it.t, title: firstLine(it.e.message), text: it.e.message, items: [it] });
|
|
247
|
+
} else if (it.kind === "error") {
|
|
248
|
+
const key = `${it.e.nodeId}\0${it.e.source}`;
|
|
249
|
+
const block = open.get(key);
|
|
250
|
+
if (block && it.t - block.tEnd <= 300) {
|
|
251
|
+
block.lines.push(it.e.message);
|
|
252
|
+
block.items.push(it);
|
|
253
|
+
block.tEnd = it.t;
|
|
254
|
+
block.text = block.lines.join("\n");
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const fresh = { type: "output", node: it.node, t: it.t, tEnd: it.t, source: it.e.source, title: firstLine(it.e.message), lines: [it.e.message], text: it.e.message, items: [it] };
|
|
258
|
+
open.set(key, fresh);
|
|
259
|
+
out.push(fresh);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// a failure the verdict holds but no node logged (e.g. the body threw outside any action)
|
|
263
|
+
for (const f of run.failures || []) {
|
|
264
|
+
const n = nodes.find((x) => x.v.path === f.action || x.id === f.action || x.name === f.action) || null;
|
|
265
|
+
if (out.some((e) => e.type === "failure" && e.node === n)) continue;
|
|
266
|
+
// a failed assert is already its own entry; the verdict just lists it again as a fault
|
|
267
|
+
if (out.some((e) => e.type === "assert" && e.node === n && String(f.error).includes(e.title))) continue;
|
|
268
|
+
out.push({ type: "failure", node: n, t: n ? n.end : span, title: firstLine(f.error), text: f.error, items: [] });
|
|
269
|
+
}
|
|
270
|
+
out.sort((a, b) => a.t - b.t);
|
|
271
|
+
out.forEach((e, i) => (e.idx = i));
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
function firstLine(s) {
|
|
275
|
+
return String(s).split("\n")[0];
|
|
276
|
+
}
|
|
277
|
+
function kindOf(e) {
|
|
278
|
+
if (e.level === "assert") return e.message.startsWith("⊭") ? "fail" : "pass";
|
|
279
|
+
if (e.level === "snapshot") return "snapshot";
|
|
280
|
+
if (e.level === "artifact") return "artifact";
|
|
281
|
+
if (e.level === "error") return "error";
|
|
282
|
+
if (e.level === "run") return "run";
|
|
283
|
+
if (e.level === "event" && e.source === "cancel") return "cancel";
|
|
284
|
+
if (e.level === "event" && e.source === "lifecycle") return "lifecycle";
|
|
285
|
+
if (e.level === "event") return "event";
|
|
286
|
+
return "info";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function labelOf(e, kind) {
|
|
290
|
+
const m = e.message;
|
|
291
|
+
if (kind === "snapshot" || kind === "artifact") {
|
|
292
|
+
const i = m.lastIndexOf(" → ");
|
|
293
|
+
return i >= 0 ? m.slice(0, i) : m;
|
|
294
|
+
}
|
|
295
|
+
if (kind === "pass" || kind === "fail") return m.replace(/^[⊨⊭]\s*(pass|FAIL)\s*·\s*/, "");
|
|
296
|
+
if (kind === "error") return m.split("\n")[0];
|
|
297
|
+
if (kind === "event") {
|
|
298
|
+
// Command events repeat their source as a prefix — drop it.
|
|
299
|
+
let s = m.startsWith(e.source + " · ") ? m.slice(e.source.length + 3) : m;
|
|
300
|
+
s = s.split(" · ")[0];
|
|
301
|
+
return s;
|
|
302
|
+
}
|
|
303
|
+
return m;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── app ──────────────────────────────────────────────────
|
|
307
|
+
function App(model) {
|
|
308
|
+
const app = document.getElementById("app");
|
|
309
|
+
const state = {
|
|
310
|
+
view: "timeline",
|
|
311
|
+
selected: model.root.id,
|
|
312
|
+
collapsed: new Set(),
|
|
313
|
+
zoom: d3.zoomIdentity,
|
|
314
|
+
detailTab: "overview",
|
|
315
|
+
errFilter: "all",
|
|
316
|
+
errHl: false,
|
|
317
|
+
errOpen: new Set(),
|
|
318
|
+
lowerTab: "snapshots",
|
|
319
|
+
filter: "",
|
|
320
|
+
pin: null,
|
|
321
|
+
details: true,
|
|
322
|
+
};
|
|
323
|
+
// Deep, wide runs start collapsed below the second level so the first screen stays readable.
|
|
324
|
+
if (model.nodes.length > 60) for (const n of model.nodes) if (n.depth >= 2 && n.children.length) state.collapsed.add(n.id);
|
|
325
|
+
|
|
326
|
+
app.innerHTML = shellHtml(model);
|
|
327
|
+
const el = {
|
|
328
|
+
main: app.querySelector(".main"),
|
|
329
|
+
timeline: app.querySelector(".timeline"),
|
|
330
|
+
head: app.querySelector(".tl-head"),
|
|
331
|
+
axis: app.querySelector(".tl-head svg"),
|
|
332
|
+
body: app.querySelector(".tl-body"),
|
|
333
|
+
tree: app.querySelector(".tree"),
|
|
334
|
+
chart: app.querySelector(".chart"),
|
|
335
|
+
flame: app.querySelector(".flame-view"),
|
|
336
|
+
flow: app.querySelector(".flow-view"),
|
|
337
|
+
details: app.querySelector(".details"),
|
|
338
|
+
lower: app.querySelector(".lower"),
|
|
339
|
+
overview: app.querySelector(".overview svg"),
|
|
340
|
+
range: app.querySelector(".overview .range"),
|
|
341
|
+
windowSel: app.querySelector("#window"),
|
|
342
|
+
};
|
|
343
|
+
const tip = document.createElement("div");
|
|
344
|
+
tip.className = "tip";
|
|
345
|
+
tip.hidden = true;
|
|
346
|
+
document.body.appendChild(tip);
|
|
347
|
+
|
|
348
|
+
let W = 600; // chart pixel width (timeline + flame share the x mapping)
|
|
349
|
+
let baseX = d3.scaleLinear();
|
|
350
|
+
const xz = () => state.zoom.rescaleX(baseX);
|
|
351
|
+
let syncing = false;
|
|
352
|
+
|
|
353
|
+
// ── header controls ──
|
|
354
|
+
app.querySelectorAll("[data-view]").forEach((b) =>
|
|
355
|
+
b.addEventListener("click", () => setView(b.dataset.view))
|
|
356
|
+
);
|
|
357
|
+
app.querySelector("#zin").addEventListener("click", () => zoomBy(1.6));
|
|
358
|
+
app.querySelector("#zout").addEventListener("click", () => zoomBy(1 / 1.6));
|
|
359
|
+
app.querySelector("#fit").addEventListener("click", fit);
|
|
360
|
+
el.windowSel.addEventListener("change", () => {
|
|
361
|
+
const ms = +el.windowSel.value;
|
|
362
|
+
if (!ms) return fit();
|
|
363
|
+
const [d0] = xz().domain();
|
|
364
|
+
zoomToDomain(Math.max(0, d0), Math.max(0, d0) + ms);
|
|
365
|
+
});
|
|
366
|
+
app.querySelector("#more").addEventListener("click", (ev) => openMenu(ev.currentTarget));
|
|
367
|
+
const goErrors = app.querySelector("#go-errors");
|
|
368
|
+
if (goErrors) goErrors.addEventListener("click", () => openErrors(true));
|
|
369
|
+
const info = app.querySelector("#om-info");
|
|
370
|
+
info.addEventListener("mouseenter", (ev) => showTip(ev, omTip(), true));
|
|
371
|
+
info.addEventListener("mousemove", (ev) => moveTip(ev));
|
|
372
|
+
info.addEventListener("mouseleave", hideTip);
|
|
373
|
+
info.addEventListener("click", () => select(model.root.id));
|
|
374
|
+
|
|
375
|
+
// ── zoom behaviours ──
|
|
376
|
+
const chartZoom = d3
|
|
377
|
+
.zoom()
|
|
378
|
+
.scaleExtent([1, 5000])
|
|
379
|
+
.filter((e) => (e.type === "wheel" ? e.ctrlKey || e.metaKey : !e.button && e.type !== "dblclick"))
|
|
380
|
+
.on("zoom", (e) => onZoom(e.transform));
|
|
381
|
+
const flameZoom = d3
|
|
382
|
+
.zoom()
|
|
383
|
+
.scaleExtent([1, 5000])
|
|
384
|
+
.filter((e) => (e.type === "wheel" ? e.ctrlKey || e.metaKey : !e.button && e.type !== "dblclick"))
|
|
385
|
+
.on("zoom", (e) => onZoom(e.transform));
|
|
386
|
+
const flowZoom = d3.zoom().scaleExtent([0.1, 4]).on("zoom", (e) => flowG && flowG.attr("transform", e.transform));
|
|
387
|
+
|
|
388
|
+
function activeZoom() {
|
|
389
|
+
return state.view === "flame"
|
|
390
|
+
? { z: flameZoom, sel: d3.select(el.flame).select("svg") }
|
|
391
|
+
: { z: chartZoom, sel: d3.select(el.chart).select("svg") };
|
|
392
|
+
}
|
|
393
|
+
function onZoom(t) {
|
|
394
|
+
state.zoom = t;
|
|
395
|
+
if (!syncing) {
|
|
396
|
+
syncing = true;
|
|
397
|
+
// keep the other x-view's internal transform in step
|
|
398
|
+
const other = state.view === "flame" ? d3.select(el.chart).select("svg") : d3.select(el.flame).select("svg");
|
|
399
|
+
const oz = state.view === "flame" ? chartZoom : flameZoom;
|
|
400
|
+
if (!other.empty()) other.call(oz.transform, t);
|
|
401
|
+
syncing = false;
|
|
402
|
+
}
|
|
403
|
+
redrawX();
|
|
404
|
+
}
|
|
405
|
+
function zoomBy(k) {
|
|
406
|
+
if (state.view === "flow") return d3.select(el.flow).select("svg").transition().duration(200).call(flowZoom.scaleBy, k);
|
|
407
|
+
const { z, sel } = activeZoom();
|
|
408
|
+
const [cx] = state.pin != null ? [xz()(state.pin)] : [W / 2];
|
|
409
|
+
sel.transition().duration(200).call(z.scaleBy, k, [cx, 0]);
|
|
410
|
+
}
|
|
411
|
+
function fit() {
|
|
412
|
+
if (state.view === "flow") return fitFlow();
|
|
413
|
+
const { z, sel } = activeZoom();
|
|
414
|
+
sel.transition().duration(250).call(z.transform, d3.zoomIdentity);
|
|
415
|
+
}
|
|
416
|
+
function zoomToDomain(a, b) {
|
|
417
|
+
const x0 = baseX(a);
|
|
418
|
+
const x1 = baseX(b);
|
|
419
|
+
if (!(x1 > x0)) return;
|
|
420
|
+
const k = Math.max(1, W / (x1 - x0));
|
|
421
|
+
const t = d3.zoomIdentity.scale(k).translate(-x0, 0);
|
|
422
|
+
const { z, sel } = activeZoom();
|
|
423
|
+
sel.call(z.transform, t);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ── layout ──
|
|
427
|
+
function measure() {
|
|
428
|
+
const sb = el.body.offsetWidth - el.body.clientWidth;
|
|
429
|
+
el.head.style.paddingRight = `${sb}px`;
|
|
430
|
+
W = Math.max(200, state.view === "flame" ? el.flame.clientWidth : el.chart.clientWidth);
|
|
431
|
+
const span = model.span;
|
|
432
|
+
baseX = d3.scaleLinear().domain([-span * 0.01, span * 1.015]).range([0, W]);
|
|
433
|
+
for (const z of [chartZoom, flameZoom]) z.translateExtent([[0, -1e6], [W, 1e6]]).extent([[0, 0], [W, 1]]);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function setView(v) {
|
|
437
|
+
state.view = v;
|
|
438
|
+
app.querySelectorAll("[data-view]").forEach((b) => b.classList.toggle("active", b.dataset.view === v));
|
|
439
|
+
el.timeline.classList.toggle("no-tree", v === "flame");
|
|
440
|
+
el.body.hidden = v !== "timeline";
|
|
441
|
+
el.head.hidden = v === "flow";
|
|
442
|
+
el.flame.hidden = v !== "flame";
|
|
443
|
+
el.flow.hidden = v !== "flow";
|
|
444
|
+
el.windowSel.disabled = v === "flow";
|
|
445
|
+
renderAll();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ── tree + timeline ──
|
|
449
|
+
function visible() {
|
|
450
|
+
const out = [];
|
|
451
|
+
(function walk(n) {
|
|
452
|
+
out.push(n);
|
|
453
|
+
if (!state.collapsed.has(n.id)) n.children.forEach(walk);
|
|
454
|
+
})(model.root);
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function renderTree(rows) {
|
|
459
|
+
el.tree.innerHTML = rows.map((n) => treeRowHtml(n)).join("");
|
|
460
|
+
el.tree.querySelectorAll(".row").forEach((r) => {
|
|
461
|
+
const n = model.byId.get(r.dataset.id);
|
|
462
|
+
r.addEventListener("click", (ev) => {
|
|
463
|
+
if (ev.target.closest(".twist")) {
|
|
464
|
+
toggle(n);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
select(n.id);
|
|
468
|
+
});
|
|
469
|
+
r.addEventListener("dblclick", () => focusNode(n));
|
|
470
|
+
r.addEventListener("mouseenter", (ev) => showTip(ev, nodeTip(n), true, 280));
|
|
471
|
+
r.addEventListener("mousemove", (ev) => moveTip(ev));
|
|
472
|
+
r.addEventListener("mouseleave", hideTip);
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
function treeRowHtml(n) {
|
|
476
|
+
const guides = Array.from({ length: n.depth }, () => '<span class="guide"></span>').join("");
|
|
477
|
+
const open = !state.collapsed.has(n.id);
|
|
478
|
+
const twist = n.children.length
|
|
479
|
+
? `<button class="twist ${open ? "open" : ""}" title="${open ? "Collapse" : "Expand"}">${svgIcon("chevron")}</button>`
|
|
480
|
+
: '<span class="twist"></span>';
|
|
481
|
+
const color = colorOf(n);
|
|
482
|
+
const rest = (n.v.tags || []).filter((t) => t !== displayName(n));
|
|
483
|
+
const tags = rest.length ? `<span class="tagchip">${esc(rest.join(" · "))}</span>` : "";
|
|
484
|
+
return `<div class="row ${state.selected === n.id ? "selected" : ""}" data-id="${esc(n.id)}" title="${esc(n.v.path)}">
|
|
485
|
+
<div class="name" style="padding-left:${n.depth ? 4 : 10}px">${guides}${twist}
|
|
486
|
+
<span class="icon" style="color:${color};background:${alpha(color, 0.16)}">${svgIcon(iconOf(n))}</span>
|
|
487
|
+
<span class="label">${esc(displayName(n))}</span>${tags}${
|
|
488
|
+
model.errorsBy.get(n) ? `<span class="errbadge" title="${model.errorsBy.get(n)} error${model.errorsBy.get(n) > 1 ? "s" : ""}">${model.errorsBy.get(n)}</span>` : ""
|
|
489
|
+
}
|
|
490
|
+
</div>
|
|
491
|
+
<div class="status"><span class="dot ${n.status}"></span>${esc(n.status)}</div>
|
|
492
|
+
<div class="dur">${fmtDur(n.duration)}</div>
|
|
493
|
+
</div>`;
|
|
494
|
+
}
|
|
495
|
+
function toggle(n) {
|
|
496
|
+
if (state.collapsed.has(n.id)) state.collapsed.delete(n.id);
|
|
497
|
+
else state.collapsed.add(n.id);
|
|
498
|
+
renderTimeline();
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
let chartSvg = null;
|
|
502
|
+
let rowsNow = [];
|
|
503
|
+
function renderTimeline() {
|
|
504
|
+
rowsNow = visible();
|
|
505
|
+
renderTree(rowsNow);
|
|
506
|
+
const H = rowsNow.length * ROW_H + 8;
|
|
507
|
+
d3.select(el.chart).selectAll("svg").remove();
|
|
508
|
+
chartSvg = d3
|
|
509
|
+
.select(el.chart)
|
|
510
|
+
.append("svg")
|
|
511
|
+
.attr("width", W)
|
|
512
|
+
.attr("height", H)
|
|
513
|
+
.on("mousemove", (ev) => moveCursor(d3.pointer(ev)[0]))
|
|
514
|
+
.on("mouseleave", () => moveCursor(null));
|
|
515
|
+
chartSvg.append("g").attr("class", "grid");
|
|
516
|
+
chartSvg.append("g").attr("class", "links");
|
|
517
|
+
chartSvg.append("g").attr("class", "rows");
|
|
518
|
+
chartSvg.append("line").attr("class", "pin").attr("stroke", css("--accent")).attr("stroke-width", 1.5).attr("y1", 0).attr("y2", H);
|
|
519
|
+
chartSvg.append("line").attr("class", "cursor").attr("y1", 0).attr("y2", H).attr("visibility", "hidden");
|
|
520
|
+
chartSvg.call(chartZoom).call(chartZoom.transform, state.zoom);
|
|
521
|
+
redrawX();
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function redrawX() {
|
|
525
|
+
const x = xz();
|
|
526
|
+
updateAxis(x);
|
|
527
|
+
updateWindowLabel(x);
|
|
528
|
+
updateOverviewBrush(x);
|
|
529
|
+
if (state.view === "timeline" && chartSvg) drawRows(x);
|
|
530
|
+
if (state.view === "flame") drawFlame(x);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function drawRows(x) {
|
|
534
|
+
const H = rowsNow.length * ROW_H + 8;
|
|
535
|
+
const ticks = x.ticks(Math.max(2, Math.floor(W / 90)));
|
|
536
|
+
chartSvg
|
|
537
|
+
.select(".grid")
|
|
538
|
+
.selectAll("line")
|
|
539
|
+
.data(ticks)
|
|
540
|
+
.join("line")
|
|
541
|
+
.attr("x1", (d) => x(d))
|
|
542
|
+
.attr("x2", (d) => x(d))
|
|
543
|
+
.attr("y1", 0)
|
|
544
|
+
.attr("y2", H);
|
|
545
|
+
const index = new Map(rowsNow.map((n, i) => [n.id, i]));
|
|
546
|
+
// parent → child elbows
|
|
547
|
+
const links = rowsNow.filter((n) => n.parent && index.has(n.parent.id));
|
|
548
|
+
chartSvg
|
|
549
|
+
.select(".links")
|
|
550
|
+
.selectAll("path")
|
|
551
|
+
.data(links, (n) => n.id)
|
|
552
|
+
.join("path")
|
|
553
|
+
.attr("class", "link")
|
|
554
|
+
.attr("d", (n) => {
|
|
555
|
+
const pi = index.get(n.parent.id);
|
|
556
|
+
const ci = index.get(n.id);
|
|
557
|
+
const cx = x(n.start);
|
|
558
|
+
const px = Math.max(x(n.parent.start) + 6, cx - 12);
|
|
559
|
+
const py = pi * ROW_H + BAR_Y + BAR_H;
|
|
560
|
+
const cy = ci * ROW_H + BAR_Y + BAR_H / 2;
|
|
561
|
+
return `M${px},${py}V${cy - 4}Q${px},${cy} ${px + 4},${cy}H${cx}`;
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const g = chartSvg
|
|
565
|
+
.select(".rows")
|
|
566
|
+
.selectAll("g.r")
|
|
567
|
+
.data(rowsNow, (n) => n.id)
|
|
568
|
+
.join((enter) => {
|
|
569
|
+
const r = enter.append("g").attr("class", "r");
|
|
570
|
+
// A transparent target spanning the bar *and* its label, so a 2px bar is still hoverable.
|
|
571
|
+
r.append("rect").attr("class", "hit").attr("y", BAR_Y - 4).attr("height", BAR_H + 8);
|
|
572
|
+
r.append("rect").attr("class", "bar").attr("rx", 5).attr("y", BAR_Y).attr("height", BAR_H);
|
|
573
|
+
r.append("text").attr("class", "bar-label").attr("y", BAR_Y + BAR_H / 2 + 4);
|
|
574
|
+
r.append("g").attr("class", "marks");
|
|
575
|
+
const cb = r.append("g").attr("class", "code-btn src");
|
|
576
|
+
cb.append("circle").attr("r", 9);
|
|
577
|
+
cb.append("text").attr("text-anchor", "middle").attr("y", 3.5).text("<>");
|
|
578
|
+
const rb = r.append("g").attr("class", "code-btn ret");
|
|
579
|
+
rb.append("circle").attr("r", 9);
|
|
580
|
+
rb.append("path").attr("d", ICON.ret).attr("transform", "translate(-6,-6) scale(0.5)");
|
|
581
|
+
return r;
|
|
582
|
+
})
|
|
583
|
+
.attr("transform", (_n, i) => `translate(0,${i * ROW_H})`)
|
|
584
|
+
.classed("sel", (n) => n.id === state.selected);
|
|
585
|
+
|
|
586
|
+
g.select("rect.bar")
|
|
587
|
+
.attr("x", (n) => x(n.start))
|
|
588
|
+
.attr("width", (n) => Math.max(3, x(n.end) - x(n.start)))
|
|
589
|
+
.attr("fill", (n) => colorOf(n))
|
|
590
|
+
.attr("fill-opacity", (n) => (n.status === "cancelled" ? 0.55 : 0.92))
|
|
591
|
+
.attr("stroke", (n) => (n.status === "failed" ? css("--fail") : null))
|
|
592
|
+
.attr("stroke-width", (n) => (n.status === "failed" ? 2 : null))
|
|
593
|
+
.attr("stroke-dasharray", (n) => (n.status === "cancelled" ? "4 3" : null))
|
|
594
|
+
.classed("selected", (n) => n.id === state.selected)
|
|
595
|
+
.call(bindNode);
|
|
596
|
+
|
|
597
|
+
g.select("text.bar-label").each(function (n) {
|
|
598
|
+
const t = d3.select(this);
|
|
599
|
+
const x0 = x(n.start);
|
|
600
|
+
const w = x(n.end) - x0;
|
|
601
|
+
const text = `${displayName(n)} (${fmtDur(n.duration)})`;
|
|
602
|
+
const need = text.length * 6.6 + 14;
|
|
603
|
+
const inside = w >= need;
|
|
604
|
+
const clipX = Math.max(x0, 0);
|
|
605
|
+
const outside = !inside && w <= 60;
|
|
606
|
+
const shown = inside || outside ? text : truncate(text, (w - 14) / 6.6);
|
|
607
|
+
const tw = shown.length * 6.6;
|
|
608
|
+
// an outside label that would run off the chart hangs left of its bar instead
|
|
609
|
+
const flip = outside && x0 + Math.max(w, 3) + 6 + tw > W;
|
|
610
|
+
const lx = flip ? x0 - 6 : outside ? x0 + Math.max(w, 3) + 6 : Math.min(clipX + 8, x0 + w - 20);
|
|
611
|
+
t.text(shown).classed("outside", outside).attr("x", lx).attr("text-anchor", flip ? "end" : "start");
|
|
612
|
+
// the hit target covers bar + label, padded so a sliver of a bar is still easy to reach
|
|
613
|
+
const left = Math.min(x0, flip ? lx - tw : lx) - 6;
|
|
614
|
+
const right = Math.max(x0 + Math.max(w, 3), flip ? x0 : lx + tw) + 6;
|
|
615
|
+
d3.select(this.parentNode).select("rect.hit").attr("x", left).attr("width", Math.max(16, right - left));
|
|
616
|
+
// the <> sits just past the block (or just before it, when that would leave the chart)
|
|
617
|
+
// …and, for a block spanning the chart, inside it right after its label
|
|
618
|
+
const cx =
|
|
619
|
+
right + 8 <= W - 10 ? right + 8 : left - 8 >= 10 ? left - 8 : Math.min(W - 12, (flip ? x0 : lx + tw) + 14);
|
|
620
|
+
// buttons stack away from the block: rightward when they sit after it, leftward before it
|
|
621
|
+
const dir = right + 8 <= W - 10 || left - 8 < 10 ? 1 : -1;
|
|
622
|
+
const src = hasCode(n);
|
|
623
|
+
const row = d3.select(this.parentNode);
|
|
624
|
+
row
|
|
625
|
+
.select("g.code-btn.src")
|
|
626
|
+
.style("display", src ? null : "none")
|
|
627
|
+
.attr("transform", `translate(${cx},${BAR_Y + BAR_H / 2})`);
|
|
628
|
+
row
|
|
629
|
+
.select("g.code-btn.ret")
|
|
630
|
+
.style("display", hasValue(n) ? null : "none")
|
|
631
|
+
.attr("transform", `translate(${src ? cx + dir * 21 : cx},${BAR_Y + BAR_H / 2})`);
|
|
632
|
+
});
|
|
633
|
+
g.select("g.code-btn.src")
|
|
634
|
+
.on("click", (ev, n) => {
|
|
635
|
+
ev.stopPropagation();
|
|
636
|
+
toggleCode(n, ev);
|
|
637
|
+
})
|
|
638
|
+
.on("mouseenter", (ev) => showTip(ev, "Show source"))
|
|
639
|
+
.on("mouseleave", hideTip);
|
|
640
|
+
g.select("g.code-btn.ret")
|
|
641
|
+
.on("click", (ev, n) => {
|
|
642
|
+
ev.stopPropagation();
|
|
643
|
+
toggleCode(n, ev, "value", true);
|
|
644
|
+
})
|
|
645
|
+
.on("mouseenter", (ev, n) => showTip(ev, `Returned: <span class="mono">${esc(valuePreview(n, 80))}</span>`))
|
|
646
|
+
.on("mouseleave", hideTip);
|
|
647
|
+
g.selectAll("rect.hit, text.bar-label").call(bindNode);
|
|
648
|
+
|
|
649
|
+
g.select("g.marks").each(function (n) {
|
|
650
|
+
drawMarks(d3.select(this), n, x);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
chartSvg.select("line.pin").attr("x1", state.pin != null ? x(state.pin) : -10).attr("x2", state.pin != null ? x(state.pin) : -10);
|
|
654
|
+
if (app.classList.contains("hl-on") || state.errHl) applyHl();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function drawMarks(g, n, x) {
|
|
658
|
+
const cy = BAR_Y + BAR_H / 2;
|
|
659
|
+
// cluster marks closer than 4px so a chatty node draws a readable line of dots
|
|
660
|
+
const marks = [];
|
|
661
|
+
let last = -Infinity;
|
|
662
|
+
for (const m of n.marks) {
|
|
663
|
+
const px = x(m.t);
|
|
664
|
+
if (px - last < 4 && marks.length) {
|
|
665
|
+
marks[marks.length - 1].more.push(m);
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
marks.push({ m, px, more: [] });
|
|
669
|
+
last = px;
|
|
670
|
+
}
|
|
671
|
+
g.selectAll("circle.mark")
|
|
672
|
+
.data(marks)
|
|
673
|
+
.join("circle")
|
|
674
|
+
.attr("class", "mark")
|
|
675
|
+
.attr("cx", (d) => d.px)
|
|
676
|
+
.attr("cy", cy)
|
|
677
|
+
.attr("r", (d) => (d.more.length ? 6 : 5))
|
|
678
|
+
.attr("fill", (d) => css(MARK[worst(d)].color))
|
|
679
|
+
.on("click", (ev, d) => {
|
|
680
|
+
ev.stopPropagation();
|
|
681
|
+
openItem(d.m);
|
|
682
|
+
})
|
|
683
|
+
.on("mouseenter", (ev, d) => showTip(ev, markTip(d)))
|
|
684
|
+
.on("mousemove", (ev) => moveTip(ev))
|
|
685
|
+
.on("mouseleave", hideTip);
|
|
686
|
+
// labels under the bar, skipped where they would collide
|
|
687
|
+
const labels = [];
|
|
688
|
+
let edge = -Infinity;
|
|
689
|
+
for (const d of marks) {
|
|
690
|
+
const text = truncate(d.more.length ? `${d.m.label} +${d.more.length}` : d.m.label, 22);
|
|
691
|
+
const w = text.length * 6 + 8;
|
|
692
|
+
if (d.px - 6 < edge) continue;
|
|
693
|
+
labels.push({ d, text });
|
|
694
|
+
edge = d.px + w;
|
|
695
|
+
}
|
|
696
|
+
// a label that would run off the right edge hangs left of its mark instead
|
|
697
|
+
const flip = (l) => l.d.px - 4 + l.text.length * 6 > W;
|
|
698
|
+
g.selectAll("text.mark-label")
|
|
699
|
+
.data(labels)
|
|
700
|
+
.join("text")
|
|
701
|
+
.attr("class", "mark-label")
|
|
702
|
+
.attr("x", (l) => (flip(l) ? l.d.px + 4 : l.d.px - 4))
|
|
703
|
+
.attr("text-anchor", (l) => (flip(l) ? "end" : "start"))
|
|
704
|
+
.attr("y", BAR_Y + BAR_H + 13)
|
|
705
|
+
.text((l) => l.text)
|
|
706
|
+
.on("click", (ev, l) => {
|
|
707
|
+
ev.stopPropagation();
|
|
708
|
+
openItem(l.d.m);
|
|
709
|
+
})
|
|
710
|
+
.on("mouseenter", (ev, l) => showTip(ev, markTip(l.d)))
|
|
711
|
+
.on("mousemove", (ev) => moveTip(ev))
|
|
712
|
+
.on("mouseleave", hideTip);
|
|
713
|
+
}
|
|
714
|
+
function worst(d) {
|
|
715
|
+
const all = [d.m, ...d.more].map((m) => m.kind);
|
|
716
|
+
for (const k of ["fail", "error", "cancel", "snapshot", "artifact", "pass", "event"]) if (all.includes(k)) return k;
|
|
717
|
+
return all[0];
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ── axis + cursor ──
|
|
721
|
+
function updateAxis(x) {
|
|
722
|
+
const svg = d3.select(el.axis);
|
|
723
|
+
if (state.view === "flow") return;
|
|
724
|
+
const w = state.view === "flame" ? W : el.chart.clientWidth || W;
|
|
725
|
+
svg.attr("viewBox", null);
|
|
726
|
+
const g = svg.selectAll("g.axis").data([0]).join("g").attr("class", "axis").attr("transform", "translate(0,33)");
|
|
727
|
+
g.call(d3.axisTop(x).ticks(Math.max(2, Math.floor(w / 90))).tickFormat(fmtTick).tickSizeOuter(0));
|
|
728
|
+
g.selectAll(".tick text").attr("dy", "-0.5em");
|
|
729
|
+
svg.selectAll("text.cursor-t").data([0]).join("text").attr("class", "cursor-t").attr("y", 12).attr("fill", css("--accent")).attr("font-size", 11);
|
|
730
|
+
}
|
|
731
|
+
function moveCursor(px) {
|
|
732
|
+
const svgs = [chartSvg, flameSvg].filter(Boolean);
|
|
733
|
+
for (const s of svgs) {
|
|
734
|
+
s.select("line.cursor").attr("visibility", px == null ? "hidden" : "visible").attr("x1", px).attr("x2", px);
|
|
735
|
+
}
|
|
736
|
+
const label = d3.select(el.axis).select("text.cursor-t");
|
|
737
|
+
if (px == null) label.text("");
|
|
738
|
+
else {
|
|
739
|
+
const t = xz().invert(px);
|
|
740
|
+
label.attr("x", px + 4).text(fmtTick(Math.max(0, t)));
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
function updateWindowLabel(x) {
|
|
744
|
+
const [a, b] = x.domain();
|
|
745
|
+
const span = b - a;
|
|
746
|
+
const opt = el.windowSel.querySelector("option[value='0']");
|
|
747
|
+
opt.textContent = `${fmtTick(span)} window`;
|
|
748
|
+
el.windowSel.value = "0";
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ── flame view ──
|
|
752
|
+
let flameSvg = null;
|
|
753
|
+
/**
|
|
754
|
+
* A row per node, packed: a node goes on the first row below its parent's that nothing
|
|
755
|
+
* already placed there overlaps in time. Plain depth rows hid every step that ran beside a
|
|
756
|
+
* long-lived sibling (a daemon, a server) — they shared a row and were drawn over.
|
|
757
|
+
*/
|
|
758
|
+
let flameRows = null;
|
|
759
|
+
function packFlame() {
|
|
760
|
+
const rows = []; // rows[r] = [[start, end], …] already taken
|
|
761
|
+
const out = new Map();
|
|
762
|
+
const free = (r, a, b) => !(rows[r] || []).some(([s, e]) => a < e && s < b);
|
|
763
|
+
(function place(n, parentRow) {
|
|
764
|
+
let r = parentRow + 1;
|
|
765
|
+
// a zero-length node still needs a sliver of time to claim, or it would stack on anything
|
|
766
|
+
const a = n.start;
|
|
767
|
+
const b = Math.max(n.end, n.start + model.span / 2000);
|
|
768
|
+
while (!free(r, a, b)) r++;
|
|
769
|
+
(rows[r] = rows[r] || []).push([a, b]);
|
|
770
|
+
out.set(n.id, r);
|
|
771
|
+
[...n.children].sort((p, q) => p.start - q.start).forEach((c) => place(c, r));
|
|
772
|
+
})(model.root, -1);
|
|
773
|
+
return { rowOf: out, count: rows.length };
|
|
774
|
+
}
|
|
775
|
+
function renderFlame() {
|
|
776
|
+
d3.select(el.flame).selectAll("svg").remove();
|
|
777
|
+
flameRows = flameRows || packFlame();
|
|
778
|
+
flameSvg = d3
|
|
779
|
+
.select(el.flame)
|
|
780
|
+
.append("svg")
|
|
781
|
+
.style("height", `${Math.max(el.flame.clientHeight, 16 + flameRows.count * FLAME_ROW)}px`)
|
|
782
|
+
.on("mousemove", (ev) => moveCursor(d3.pointer(ev)[0]))
|
|
783
|
+
.on("mouseleave", () => moveCursor(null));
|
|
784
|
+
flameSvg.append("g").attr("class", "grid");
|
|
785
|
+
flameSvg.append("g").attr("class", "cells");
|
|
786
|
+
flameSvg.append("line").attr("class", "pin").attr("stroke", css("--accent")).attr("stroke-width", 1.5).attr("y1", 0).attr("y2", 4000);
|
|
787
|
+
flameSvg.append("line").attr("class", "cursor").attr("y1", 0).attr("y2", 4000).attr("visibility", "hidden");
|
|
788
|
+
flameSvg.call(flameZoom).call(flameZoom.transform, state.zoom);
|
|
789
|
+
redrawX();
|
|
790
|
+
}
|
|
791
|
+
function drawFlame(x) {
|
|
792
|
+
if (!flameSvg) return;
|
|
793
|
+
const px = state.pin != null ? x(state.pin) : -10;
|
|
794
|
+
queueMicrotask(() => (app.classList.contains("hl-on") || state.errHl) && applyHl());
|
|
795
|
+
flameSvg.select("line.pin").attr("x1", px).attr("x2", px);
|
|
796
|
+
const H = el.flame.clientHeight;
|
|
797
|
+
const ticks = x.ticks(Math.max(2, Math.floor(W / 90)));
|
|
798
|
+
flameSvg
|
|
799
|
+
.select(".grid")
|
|
800
|
+
.selectAll("line")
|
|
801
|
+
.data(ticks)
|
|
802
|
+
.join("line")
|
|
803
|
+
.attr("x1", (d) => x(d))
|
|
804
|
+
.attr("x2", (d) => x(d))
|
|
805
|
+
.attr("y1", 0)
|
|
806
|
+
.attr("y2", H);
|
|
807
|
+
const cells = flameSvg
|
|
808
|
+
.select(".cells")
|
|
809
|
+
.selectAll("g.cell")
|
|
810
|
+
.data(model.nodes, (n) => n.id)
|
|
811
|
+
.join((enter) => {
|
|
812
|
+
const g = enter.append("g").attr("class", "cell");
|
|
813
|
+
g.append("rect").attr("class", "flame-rect").attr("rx", 3).attr("height", FLAME_ROW - 2);
|
|
814
|
+
g.append("text").attr("class", "flame-text").attr("y", FLAME_ROW / 2 + 3);
|
|
815
|
+
return g;
|
|
816
|
+
})
|
|
817
|
+
.attr("transform", (n) => `translate(0,${8 + flameRows.rowOf.get(n.id) * FLAME_ROW})`);
|
|
818
|
+
cells
|
|
819
|
+
.select("rect")
|
|
820
|
+
.attr("x", (n) => x(n.start))
|
|
821
|
+
.attr("width", (n) => Math.max(1, x(n.end) - x(n.start)))
|
|
822
|
+
.attr("fill", (n) => colorOf(n))
|
|
823
|
+
.attr("fill-opacity", (n) => (n.status === "cancelled" ? 0.55 : 0.92))
|
|
824
|
+
.classed("selected", (n) => n.id === state.selected)
|
|
825
|
+
.call(bindNode);
|
|
826
|
+
cells
|
|
827
|
+
.select("text")
|
|
828
|
+
.attr("x", (n) => Math.max(x(n.start), 0) + 5)
|
|
829
|
+
.text((n) => {
|
|
830
|
+
const w = x(n.end) - Math.max(x(n.start), 0);
|
|
831
|
+
return w < 34 ? "" : truncate(`${displayName(n)} ${fmtDur(n.duration)}`, (w - 10) / 6.2);
|
|
832
|
+
})
|
|
833
|
+
.call(bindNode);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// ── flow view ──
|
|
837
|
+
//
|
|
838
|
+
// Each node's children form one *lane*: a column to its right, in start order, joined by
|
|
839
|
+
// dashed down-arrows — the direction the run went. The node's own snapshots sit on those
|
|
840
|
+
// arrows, between the children they happened between, so "when, relative to what" reads
|
|
841
|
+
// off the picture. A leaf has no lane; its snapshots sit in a strip under its card.
|
|
842
|
+
let flowG = null;
|
|
843
|
+
let flowPos = new Map();
|
|
844
|
+
let flowBounds = null;
|
|
845
|
+
const FLOW = { CW: 250, CH: 58, GX: 64, GY: 26, LANE_DX: 22, TW: 46, TH: 28 };
|
|
846
|
+
function renderFlow() {
|
|
847
|
+
d3.select(el.flow).selectAll("svg").remove();
|
|
848
|
+
const svg = d3.select(el.flow).append("svg");
|
|
849
|
+
svg
|
|
850
|
+
.append("defs")
|
|
851
|
+
.append("marker")
|
|
852
|
+
.attr("id", "flow-arrow")
|
|
853
|
+
.attr("viewBox", "0 0 10 10")
|
|
854
|
+
.attr("refX", 9)
|
|
855
|
+
.attr("refY", 5)
|
|
856
|
+
.attr("markerWidth", 7)
|
|
857
|
+
.attr("markerHeight", 7)
|
|
858
|
+
.attr("orient", "auto")
|
|
859
|
+
.append("path")
|
|
860
|
+
.attr("class", "flow-arrowhead")
|
|
861
|
+
.attr("d", "M0,1 L10,5 L0,9 z");
|
|
862
|
+
flowG = svg.append("g");
|
|
863
|
+
const { CW, CH, GX, GY, LANE_DX, TW, TH } = FLOW;
|
|
864
|
+
|
|
865
|
+
const shotsBy = new Map();
|
|
866
|
+
for (const g of model.gallery) {
|
|
867
|
+
if (!g.node) continue;
|
|
868
|
+
if (!shotsBy.has(g.node)) shotsBy.set(g.node, []);
|
|
869
|
+
shotsBy.get(g.node).push(g);
|
|
870
|
+
}
|
|
871
|
+
const byTime = (a, b) => (a.t ?? Infinity) - (b.t ?? Infinity);
|
|
872
|
+
const pos = new Map();
|
|
873
|
+
const lanes = [];
|
|
874
|
+
const chipRows = [];
|
|
875
|
+
const strips = [];
|
|
876
|
+
const STRIP_H = 6 + TH;
|
|
877
|
+
const cardBottom = (n, y) => y + CH + (!n.children.length && shotsBy.has(n) ? STRIP_H : 0);
|
|
878
|
+
|
|
879
|
+
// Lays `n` out with its card's top-left at (x, y); returns the bottom of its whole subtree.
|
|
880
|
+
const lay = (n, x, y) => {
|
|
881
|
+
pos.set(n.id, { x, y });
|
|
882
|
+
const shots = [...(shotsBy.get(n) || [])].sort(byTime);
|
|
883
|
+
if (!n.children.length) {
|
|
884
|
+
if (shots.length) strips.push({ n, x: x + 6, y: y + CH + 6, shots });
|
|
885
|
+
return cardBottom(n, y);
|
|
886
|
+
}
|
|
887
|
+
const cx = x + CW + GX;
|
|
888
|
+
const kids = [...n.children].sort((a, b) => a.start - b.start);
|
|
889
|
+
const seq = [];
|
|
890
|
+
let si = 0;
|
|
891
|
+
for (const k of kids) {
|
|
892
|
+
const before = [];
|
|
893
|
+
while (si < shots.length && (shots[si].t ?? Infinity) < k.start) before.push(shots[si++]);
|
|
894
|
+
if (before.length) seq.push({ shots: before });
|
|
895
|
+
seq.push({ node: k });
|
|
896
|
+
}
|
|
897
|
+
if (si < shots.length) seq.push({ shots: shots.slice(si) });
|
|
898
|
+
const items = [];
|
|
899
|
+
let cy = y;
|
|
900
|
+
for (const it of seq) {
|
|
901
|
+
if (it.shots) {
|
|
902
|
+
const row = { x: cx + LANE_DX - TW / 2, y: cy, shots: it.shots };
|
|
903
|
+
chipRows.push(row);
|
|
904
|
+
items.push({ top: cy, bottom: cy + TH, row });
|
|
905
|
+
cy += TH + GY;
|
|
906
|
+
} else {
|
|
907
|
+
const bottom = lay(it.node, cx, cy);
|
|
908
|
+
items.push({ top: cy, bottom: cardBottom(it.node, cy), node: it.node });
|
|
909
|
+
cy = bottom + GY;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
lanes.push({ parent: n, x: cx + LANE_DX, cardX: cx, items });
|
|
913
|
+
return Math.max(cardBottom(n, y), cy - GY);
|
|
914
|
+
};
|
|
915
|
+
const total = lay(model.root, 0, 0);
|
|
916
|
+
|
|
917
|
+
let maxX = CW;
|
|
918
|
+
for (const p of pos.values()) maxX = Math.max(maxX, p.x + CW);
|
|
919
|
+
for (const r of chipRows) maxX = Math.max(maxX, r.x + r.shots.length * (TW + 4) + 60);
|
|
920
|
+
flowBounds = { x0: -24, y0: -24, x1: maxX + 24, y1: total + 24 };
|
|
921
|
+
|
|
922
|
+
// parent → its lane: across from the card, then down into the first item
|
|
923
|
+
const links = flowG.append("g").attr("class", "flow-links");
|
|
924
|
+
for (const lane of lanes) {
|
|
925
|
+
const p = pos.get(lane.parent.id);
|
|
926
|
+
const first = lane.items[0];
|
|
927
|
+
const mid = p.y + CH / 2;
|
|
928
|
+
const d =
|
|
929
|
+
first.node && first.top === p.y
|
|
930
|
+
? `M${p.x + CW},${mid}H${lane.cardX}`
|
|
931
|
+
: `M${p.x + CW},${mid}H${lane.x - 8}Q${lane.x},${mid} ${lane.x},${mid + 8}V${first.top - 2}`;
|
|
932
|
+
links.append("path").attr("class", "flow-link").attr("d", d);
|
|
933
|
+
// sibling → next sibling: the flow direction. Overlapping siblings ran in parallel.
|
|
934
|
+
let prevNode = null;
|
|
935
|
+
for (let i = 0; i + 1 < lane.items.length; i++) {
|
|
936
|
+
const a = lane.items[i];
|
|
937
|
+
const b = lane.items[i + 1];
|
|
938
|
+
if (a.node) prevNode = a.node;
|
|
939
|
+
const parallel = !!(b.node && prevNode && b.node.start < prevNode.end - 1);
|
|
940
|
+
links
|
|
941
|
+
.append("path")
|
|
942
|
+
.attr("class", `flow-seq${parallel ? " parallel" : ""}`)
|
|
943
|
+
.attr("d", `M${lane.x},${a.bottom + 2}V${b.top - 3}`)
|
|
944
|
+
.attr("marker-end", parallel ? null : "url(#flow-arrow)");
|
|
945
|
+
if (parallel) {
|
|
946
|
+
links
|
|
947
|
+
.append("text")
|
|
948
|
+
.attr("class", "flow-par")
|
|
949
|
+
.attr("x", lane.x + 6)
|
|
950
|
+
.attr("y", (a.bottom + b.top) / 2 + 3)
|
|
951
|
+
.text("∥ parallel");
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const nodes = [...pos.keys()].map((id) => model.byId.get(id));
|
|
957
|
+
const card = flowG
|
|
958
|
+
.append("g")
|
|
959
|
+
.selectAll("g.flow-card")
|
|
960
|
+
.data(nodes, (n) => n.id)
|
|
961
|
+
.join("g")
|
|
962
|
+
.attr("class", (n) => `flow-card ${n.id === state.selected ? "selected" : ""}`)
|
|
963
|
+
.attr("transform", (n) => `translate(${pos.get(n.id).x},${pos.get(n.id).y})`)
|
|
964
|
+
.style("cursor", "pointer")
|
|
965
|
+
.on("click", (ev, n) => select(n.id))
|
|
966
|
+
.on("dblclick", (ev, n) => {
|
|
967
|
+
ev.stopPropagation();
|
|
968
|
+
setView("timeline");
|
|
969
|
+
focusNode(n);
|
|
970
|
+
})
|
|
971
|
+
.on("mouseenter", (ev, n) => showTip(ev, nodeTip(n), true))
|
|
972
|
+
.on("mousemove", (ev) => moveTip(ev))
|
|
973
|
+
.on("mouseleave", hideTip);
|
|
974
|
+
card.append("rect").attr("class", "box").attr("width", CW).attr("height", CH).attr("rx", 9);
|
|
975
|
+
card
|
|
976
|
+
.append("rect")
|
|
977
|
+
.attr("class", "icon-bg")
|
|
978
|
+
.attr("x", 10)
|
|
979
|
+
.attr("y", 10)
|
|
980
|
+
.attr("width", 24)
|
|
981
|
+
.attr("height", 24)
|
|
982
|
+
.attr("rx", 6)
|
|
983
|
+
.attr("fill", (n) => alpha(colorOf(n), 0.16));
|
|
984
|
+
card
|
|
985
|
+
.append("g")
|
|
986
|
+
.attr("class", "flow-icon")
|
|
987
|
+
.attr("transform", "translate(15,15) scale(0.58)")
|
|
988
|
+
.attr("color", (n) => colorOf(n))
|
|
989
|
+
.html((n) => `<path d="${ICON[iconOf(n)]}" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`);
|
|
990
|
+
card.each(function (n) {
|
|
991
|
+
const lines = wrapName(displayName(n), 31);
|
|
992
|
+
const t = d3.select(this);
|
|
993
|
+
lines.forEach((line, i) =>
|
|
994
|
+
t
|
|
995
|
+
.append("text")
|
|
996
|
+
.attr("class", "nm")
|
|
997
|
+
.attr("x", 42)
|
|
998
|
+
.attr("y", (lines.length > 1 ? 17 : 22) + i * 14)
|
|
999
|
+
.text(line)
|
|
1000
|
+
);
|
|
1001
|
+
t.append("text")
|
|
1002
|
+
.attr("class", "sub")
|
|
1003
|
+
.attr("x", 42)
|
|
1004
|
+
.attr("y", lines.length > 1 ? 49 : 41)
|
|
1005
|
+
.text(`${nodeKind(n)} · ${n.status} · ${fmtDur(n.duration)} · +${fmtTick(n.start)}`);
|
|
1006
|
+
});
|
|
1007
|
+
card
|
|
1008
|
+
.append("circle")
|
|
1009
|
+
.attr("cx", CW - 12)
|
|
1010
|
+
.attr("cy", 12)
|
|
1011
|
+
.attr("r", 4)
|
|
1012
|
+
.attr("fill", (n) => css(statusVar(n.status)));
|
|
1013
|
+
const fcb = card
|
|
1014
|
+
.filter((n) => hasCode(n))
|
|
1015
|
+
.append("g")
|
|
1016
|
+
.attr("class", "code-btn")
|
|
1017
|
+
.attr("transform", `translate(${CW / 2},0)`)
|
|
1018
|
+
.on("click", (ev, n) => {
|
|
1019
|
+
ev.stopPropagation();
|
|
1020
|
+
toggleCode(n, ev);
|
|
1021
|
+
})
|
|
1022
|
+
.on("mouseenter", (ev) => {
|
|
1023
|
+
ev.stopPropagation();
|
|
1024
|
+
showTip(ev, "Show source");
|
|
1025
|
+
});
|
|
1026
|
+
fcb.append("circle").attr("r", 9);
|
|
1027
|
+
fcb.append("text").attr("text-anchor", "middle").attr("y", 3.5).text("<>");
|
|
1028
|
+
const frb = card
|
|
1029
|
+
.filter((n) => hasValue(n))
|
|
1030
|
+
.append("g")
|
|
1031
|
+
.attr("class", "code-btn ret")
|
|
1032
|
+
.attr("transform", (n) => `translate(${CW / 2 + (hasCode(n) ? 22 : 0)},0)`)
|
|
1033
|
+
.on("click", (ev, n) => {
|
|
1034
|
+
ev.stopPropagation();
|
|
1035
|
+
toggleCode(n, ev, "value", true);
|
|
1036
|
+
})
|
|
1037
|
+
.on("mouseenter", (ev, n) => {
|
|
1038
|
+
ev.stopPropagation();
|
|
1039
|
+
showTip(ev, `Returned: <span class="mono">${esc(valuePreview(n, 80))}</span>`);
|
|
1040
|
+
});
|
|
1041
|
+
frb.append("circle").attr("r", 9);
|
|
1042
|
+
frb.append("path").attr("d", ICON.ret).attr("transform", "translate(-6,-6) scale(0.5)");
|
|
1043
|
+
|
|
1044
|
+
// snapshot chips — on lane arrows, and in strips under leaf cards
|
|
1045
|
+
const chipLayer = flowG.append("g").attr("class", "flow-shots");
|
|
1046
|
+
const drawChips = (x, y, shots, max, owner) => {
|
|
1047
|
+
const shown = shots.length > max ? shots.slice(0, max - 1) : shots;
|
|
1048
|
+
const g = chipLayer.append("g").attr("transform", `translate(${x},${y})`);
|
|
1049
|
+
const cell = g
|
|
1050
|
+
.selectAll("g.shot")
|
|
1051
|
+
.data(shown)
|
|
1052
|
+
.join("g")
|
|
1053
|
+
.attr("class", "shot")
|
|
1054
|
+
.attr("transform", (_x, i) => `translate(${i * (TW + 4)},0)`)
|
|
1055
|
+
.on("click", (ev, x2) => {
|
|
1056
|
+
ev.stopPropagation();
|
|
1057
|
+
openGallery(x2);
|
|
1058
|
+
})
|
|
1059
|
+
.on("mouseenter", (ev, x2) => showTip(ev, galleryTip(x2)))
|
|
1060
|
+
.on("mousemove", (ev) => moveTip(ev))
|
|
1061
|
+
.on("mouseleave", hideTip);
|
|
1062
|
+
cell.append("rect").attr("width", TW).attr("height", TH).attr("rx", 5);
|
|
1063
|
+
cell
|
|
1064
|
+
.filter((x2) => x2.image)
|
|
1065
|
+
.append("image")
|
|
1066
|
+
.attr("href", (x2) => model.asset(x2.f.asset))
|
|
1067
|
+
.attr("x", 1)
|
|
1068
|
+
.attr("y", 1)
|
|
1069
|
+
.attr("width", TW - 2)
|
|
1070
|
+
.attr("height", TH - 2)
|
|
1071
|
+
.attr("preserveAspectRatio", "xMidYMid slice");
|
|
1072
|
+
cell
|
|
1073
|
+
.filter((x2) => !x2.image)
|
|
1074
|
+
.append("text")
|
|
1075
|
+
.attr("x", TW / 2)
|
|
1076
|
+
.attr("y", TH / 2 + 3.5)
|
|
1077
|
+
.attr("text-anchor", "middle")
|
|
1078
|
+
.text((x2) => (x2.type === "snapshot" ? "{ }" : "file"));
|
|
1079
|
+
let end = shown.length * (TW + 4);
|
|
1080
|
+
if (shots.length > shown.length) {
|
|
1081
|
+
const more = g
|
|
1082
|
+
.append("g")
|
|
1083
|
+
.attr("class", "shot more")
|
|
1084
|
+
.attr("transform", `translate(${end},0)`)
|
|
1085
|
+
.on("click", (ev) => {
|
|
1086
|
+
ev.stopPropagation();
|
|
1087
|
+
select(owner.id, "snapshots");
|
|
1088
|
+
});
|
|
1089
|
+
more.append("rect").attr("width", TW).attr("height", TH).attr("rx", 5);
|
|
1090
|
+
more
|
|
1091
|
+
.append("text")
|
|
1092
|
+
.attr("x", TW / 2)
|
|
1093
|
+
.attr("y", TH / 2 + 3.5)
|
|
1094
|
+
.attr("text-anchor", "middle")
|
|
1095
|
+
.text(`+${shots.length - shown.length}`);
|
|
1096
|
+
end += TW + 4;
|
|
1097
|
+
}
|
|
1098
|
+
const t = shots[0].t;
|
|
1099
|
+
if (t != null) {
|
|
1100
|
+
g.append("text")
|
|
1101
|
+
.attr("class", "flow-shot-t")
|
|
1102
|
+
.attr("x", end + 2)
|
|
1103
|
+
.attr("y", TH / 2 + 4)
|
|
1104
|
+
.text(`${fmtRel(t)} · ${shots.length > 1 ? `${shots.length} snapshots` : esc(truncate(shots[0].name, 22))}`);
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
for (const r of chipRows) {
|
|
1108
|
+
const owner = lanes.find((l) => l.items.some((i) => i.row === r)).parent;
|
|
1109
|
+
drawChips(r.x, r.y, r.shots, 6, owner);
|
|
1110
|
+
}
|
|
1111
|
+
for (const s of strips) drawChips(s.x, s.y, s.shots, Math.floor((CW - 12) / (TW + 4)), s.n);
|
|
1112
|
+
|
|
1113
|
+
flowPos = new Map([...pos].map(([id, p]) => [id, { x: p.x + CW / 2, y: p.y + CH / 2 }]));
|
|
1114
|
+
svg.call(flowZoom).on("dblclick.zoom", null);
|
|
1115
|
+
fitFlow(true, "start");
|
|
1116
|
+
if (state.errHl) applyHl();
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* `"start"` (the first view): a readable zoom, anchored at the run's beginning — a large run
|
|
1120
|
+
* fit whole is a field of unreadable dots. Otherwise (the Fit button): everything on screen.
|
|
1121
|
+
*/
|
|
1122
|
+
function fitFlow(instant, mode) {
|
|
1123
|
+
const svg = d3.select(el.flow).select("svg");
|
|
1124
|
+
if (svg.empty() || !flowBounds) return;
|
|
1125
|
+
const w = el.flow.clientWidth;
|
|
1126
|
+
const h = el.flow.clientHeight;
|
|
1127
|
+
const b = flowBounds;
|
|
1128
|
+
const fitK = Math.min(w / (b.x1 - b.x0), h / (b.y1 - b.y0)) * 0.95;
|
|
1129
|
+
const k = mode === "start" ? Math.max(0.62, Math.min(1, fitK)) : Math.min(1.2, fitK);
|
|
1130
|
+
const fits = (b.x1 - b.x0) * k <= w && (b.y1 - b.y0) * k <= h;
|
|
1131
|
+
const t = fits
|
|
1132
|
+
? d3.zoomIdentity.translate(w / 2 - (k * (b.x0 + b.x1)) / 2, h / 2 - (k * (b.y0 + b.y1)) / 2).scale(k)
|
|
1133
|
+
: d3.zoomIdentity.translate(-k * b.x0, -k * b.y0).scale(k);
|
|
1134
|
+
(instant ? svg : svg.transition().duration(250)).call(flowZoom.transform, t);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// ── overview minimap ──
|
|
1138
|
+
let ovBrush = null;
|
|
1139
|
+
let ovX = null;
|
|
1140
|
+
let brushing = false;
|
|
1141
|
+
function renderOverview() {
|
|
1142
|
+
const svg = d3.select(el.overview);
|
|
1143
|
+
svg.selectAll("*").remove();
|
|
1144
|
+
const w = el.overview.clientWidth || 220;
|
|
1145
|
+
const h = el.overview.clientHeight || 120;
|
|
1146
|
+
ovX = d3.scaleLinear().domain(baseX.domain()).range([0, w]);
|
|
1147
|
+
const rowH = Math.max(1.5, Math.min(7, (h - 6) / model.nodes.length));
|
|
1148
|
+
svg
|
|
1149
|
+
.append("g")
|
|
1150
|
+
.selectAll("rect")
|
|
1151
|
+
.data(model.nodes)
|
|
1152
|
+
.join("rect")
|
|
1153
|
+
.attr("x", (n) => ovX(n.start))
|
|
1154
|
+
.attr("width", (n) => Math.max(1, ovX(n.end) - ovX(n.start)))
|
|
1155
|
+
.attr("y", (_n, i) => 3 + i * rowH)
|
|
1156
|
+
.attr("height", Math.max(1, rowH - 1))
|
|
1157
|
+
.attr("rx", Math.min(2, rowH / 2))
|
|
1158
|
+
.attr("fill", (n) => colorOf(n));
|
|
1159
|
+
ovBrush = d3
|
|
1160
|
+
.brushX()
|
|
1161
|
+
.extent([[0, 0], [w, h]])
|
|
1162
|
+
.on("start", (ev) => {
|
|
1163
|
+
if (ev.sourceEvent) brushing = true;
|
|
1164
|
+
})
|
|
1165
|
+
.on("brush end", (ev) => {
|
|
1166
|
+
if (ev.type === "end") brushing = false;
|
|
1167
|
+
if (!ev.sourceEvent || !ev.selection) return;
|
|
1168
|
+
const [a, b] = ev.selection.map(ovX.invert);
|
|
1169
|
+
zoomToDomain(a, b);
|
|
1170
|
+
});
|
|
1171
|
+
svg.append("g").attr("class", "brush").call(ovBrush);
|
|
1172
|
+
el.range.textContent = `0s – ${fmtTick(model.span)} (${fmtDur(model.root.duration)} body)`;
|
|
1173
|
+
updateOverviewBrush(xz());
|
|
1174
|
+
}
|
|
1175
|
+
function updateOverviewBrush(x) {
|
|
1176
|
+
if (!ovBrush || !ovX || brushing) return;
|
|
1177
|
+
const [a, b] = x.domain();
|
|
1178
|
+
const s = [Math.max(0, ovX(a)), Math.min(ovX.range()[1], ovX(b))];
|
|
1179
|
+
d3.select(el.overview).select("g.brush").call(ovBrush.move, s);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// ── selection + details ──
|
|
1183
|
+
function select(id, tab) {
|
|
1184
|
+
state.selected = id;
|
|
1185
|
+
if (tab) state.detailTab = tab;
|
|
1186
|
+
state.details = true;
|
|
1187
|
+
el.main.classList.remove("no-details");
|
|
1188
|
+
el.tree.querySelectorAll(".row").forEach((r) => r.classList.toggle("selected", r.dataset.id === id));
|
|
1189
|
+
if (chartSvg) {
|
|
1190
|
+
chartSvg.selectAll("rect.bar").classed("selected", (n) => n.id === id);
|
|
1191
|
+
chartSvg.selectAll("g.r").classed("sel", (n) => n.id === id);
|
|
1192
|
+
}
|
|
1193
|
+
if (flameSvg) flameSvg.selectAll("rect.flame-rect").classed("selected", (n) => n.id === id);
|
|
1194
|
+
d3.select(el.flow).selectAll("g.flow-card").classed("selected", (n) => n.id === id);
|
|
1195
|
+
renderDetails();
|
|
1196
|
+
}
|
|
1197
|
+
function focusNode(n) {
|
|
1198
|
+
const pad = Math.max(n.end - n.start, 50) * 0.08;
|
|
1199
|
+
zoomToDomain(n.start - pad, n.end + pad);
|
|
1200
|
+
}
|
|
1201
|
+
function revealRow(n, block) {
|
|
1202
|
+
let p = n.parent;
|
|
1203
|
+
let changed = false;
|
|
1204
|
+
while (p) {
|
|
1205
|
+
if (state.collapsed.delete(p.id)) changed = true;
|
|
1206
|
+
p = p.parent;
|
|
1207
|
+
}
|
|
1208
|
+
if (changed && state.view === "timeline") renderTimeline();
|
|
1209
|
+
const row = el.tree.querySelector(`.row[data-id="${CSS.escape(n.id)}"]`);
|
|
1210
|
+
if (row) row.scrollIntoView({ block: block || "nearest" });
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Bring a gallery item into view in whichever graph is showing: select its node, pin its
|
|
1215
|
+
* time, pan (keeping the zoom) so that time is on screen, and pulse its mark.
|
|
1216
|
+
*/
|
|
1217
|
+
function locate(g) {
|
|
1218
|
+
if (!g) return;
|
|
1219
|
+
locateAt(g.node, g.t, (m) => m.gallery === g, "snapshots");
|
|
1220
|
+
}
|
|
1221
|
+
/** Select `node` (on `tab`), pin `t` and pan to it, then pulse the marks `matchMark` accepts. */
|
|
1222
|
+
function locateAt(node, t0, matchMark, tab) {
|
|
1223
|
+
const g = { node, t: t0 };
|
|
1224
|
+
if (g.node) select(g.node.id, tab);
|
|
1225
|
+
if (state.view === "flow") {
|
|
1226
|
+
if (g.node) centerFlowOn(g.node);
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
if (g.t != null) {
|
|
1230
|
+
state.pin = g.t;
|
|
1231
|
+
const [a, b] = xz().domain();
|
|
1232
|
+
if (g.t < a || g.t > b) {
|
|
1233
|
+
const k = state.zoom.k;
|
|
1234
|
+
const t = d3.zoomIdentity.translate(W / 2 - k * baseX(g.t), 0).scale(k);
|
|
1235
|
+
const { z, sel } = activeZoom();
|
|
1236
|
+
sel.call(z.transform, t);
|
|
1237
|
+
} else redrawX();
|
|
1238
|
+
}
|
|
1239
|
+
if (g.node && state.view === "timeline") revealRow(g.node, "center");
|
|
1240
|
+
flash(matchMark);
|
|
1241
|
+
}
|
|
1242
|
+
function flash(matchMark) {
|
|
1243
|
+
const svgs = [chartSvg, flameSvg].filter(Boolean);
|
|
1244
|
+
for (const s of svgs) {
|
|
1245
|
+
s.selectAll("line.pin").classed("flash", false).node()?.getBoundingClientRect();
|
|
1246
|
+
s.selectAll("line.pin").classed("flash", true);
|
|
1247
|
+
}
|
|
1248
|
+
if (chartSvg) {
|
|
1249
|
+
const hit = chartSvg
|
|
1250
|
+
.selectAll("circle.mark")
|
|
1251
|
+
.filter((d) => matchMark(d.m) || d.more.some(matchMark));
|
|
1252
|
+
hit.classed("flash", false).node()?.getBoundingClientRect();
|
|
1253
|
+
hit.classed("flash", true);
|
|
1254
|
+
}
|
|
1255
|
+
setTimeout(() => d3.selectAll(".flash").classed("flash", false), 1800);
|
|
1256
|
+
}
|
|
1257
|
+
function centerFlowOn(n) {
|
|
1258
|
+
const svg = d3.select(el.flow).select("svg");
|
|
1259
|
+
const p = flowPos.get(n.id);
|
|
1260
|
+
if (svg.empty() || !p) return;
|
|
1261
|
+
const k = Math.max(0.8, d3.zoomTransform(svg.node()).k);
|
|
1262
|
+
const t = d3.zoomIdentity.translate(el.flow.clientWidth / 2 - k * p.x, el.flow.clientHeight / 2 - k * p.y).scale(k);
|
|
1263
|
+
svg.transition().duration(300).call(flowZoom.transform, t);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function renderDetails() {
|
|
1267
|
+
const n = model.byId.get(state.selected) || model.root;
|
|
1268
|
+
const color = colorOf(n);
|
|
1269
|
+
const tabs = [
|
|
1270
|
+
["overview", "Overview"],
|
|
1271
|
+
["args", "Args"],
|
|
1272
|
+
...(hasValue(n) ? [["value", "Return"]] : []),
|
|
1273
|
+
...(model.errorsBy.get(n) ? [["errors", `Errors (${model.errorsBy.get(n)})`]] : []),
|
|
1274
|
+
["events", `Events`],
|
|
1275
|
+
["logs", "Logs"],
|
|
1276
|
+
["snapshots", "Snapshots"],
|
|
1277
|
+
];
|
|
1278
|
+
el.details.innerHTML = `
|
|
1279
|
+
<div class="dh">Action details <button class="x" title="Close">${svgIcon("close", ' width="16" height="16"')}</button></div>
|
|
1280
|
+
<div class="who">
|
|
1281
|
+
<span class="icon" style="color:${color};background:${alpha(color, 0.16)}">${svgIcon(iconOf(n))}</span>
|
|
1282
|
+
<span class="nm" title="${esc(n.name)}">${esc(displayName(n))}</span>
|
|
1283
|
+
<span class="pill"><span class="dot ${n.status}"></span>${esc(n.status)}</span>
|
|
1284
|
+
</div>
|
|
1285
|
+
<div class="scroll">
|
|
1286
|
+
<div class="kv">
|
|
1287
|
+
<div class="k">Duration</div><div class="v">${fmtDur(n.duration)}</div>
|
|
1288
|
+
<div class="k">Start</div><div class="v">${fmtRel(n.start)} (${fmtClock(n.startAbs)})</div>
|
|
1289
|
+
<div class="k">End</div><div class="v">${fmtRel(n.end)} (${fmtClock(n.endAbs)})</div>
|
|
1290
|
+
${n.v.description ? `<div class="k">Summary</div><div class="v">${esc(n.v.description.summary)}</div>` : ""}
|
|
1291
|
+
${n.v.mcp ? `<div class="k">MCP</div><div class="v"><span class="badge-inline">${esc(n.v.mcp.mode)}</span></div>` : ""}
|
|
1292
|
+
<div class="k">Path</div><div class="v">${esc(n.v.path)}</div>
|
|
1293
|
+
${userSite(n) ? `<div class="k">${siteLabel(n)}</div><div class="v defined">
|
|
1294
|
+
<button class="icon-btn" data-copy-source aria-label="Copy relative path">${svgIcon("copy")}</button>
|
|
1295
|
+
${hasCode(n) ? `<button class="icon-btn" data-show-code aria-label="Show source">${svgIcon("code")}</button>` : ""}
|
|
1296
|
+
${hasValue(n) ? `<button class="icon-btn" data-show-value aria-label="Show returned value">${svgIcon("ret")}</button>` : ""}
|
|
1297
|
+
<a class="icon-btn" href="${esc(editorUrl(userSite(n)))}" aria-label="Open in VS Code" data-tip-text="Open in VS Code">${svgIcon("external")}</a>
|
|
1298
|
+
<span class="defined-name" >${esc(basename(sourceOf(n)))}</span>
|
|
1299
|
+
</div>` : ""}
|
|
1300
|
+
</div>
|
|
1301
|
+
<div class="tabs">${tabs
|
|
1302
|
+
.map(([k, l]) => `<button class="tab ${state.detailTab === k ? "active" : ""}" data-tab="${k}">${l}</button>`)
|
|
1303
|
+
.join("")}</div>
|
|
1304
|
+
<div class="tabbody">${detailTabHtml(n)}</div>
|
|
1305
|
+
</div>`;
|
|
1306
|
+
const copySrc = el.details.querySelector("[data-copy-source]");
|
|
1307
|
+
if (copySrc) {
|
|
1308
|
+
copySrc.addEventListener("click", () => copySource(n));
|
|
1309
|
+
copySrc.addEventListener("mouseenter", (ev) =>
|
|
1310
|
+
showTip(ev, `<div class="mono">${esc(sourceOf(n))}</div><div class="hint">Click (or press <kbd>C</kbd>) to copy</div>`)
|
|
1311
|
+
);
|
|
1312
|
+
copySrc.addEventListener("mousemove", (ev) => moveTip(ev));
|
|
1313
|
+
copySrc.addEventListener("mouseleave", hideTip);
|
|
1314
|
+
}
|
|
1315
|
+
const showValue = el.details.querySelector("[data-show-value]");
|
|
1316
|
+
if (showValue) {
|
|
1317
|
+
showValue.addEventListener("click", (ev) => toggleCode(n, ev, "value", true));
|
|
1318
|
+
showValue.addEventListener("mouseenter", (ev) => showTip(ev, `Returned: <span class="mono">${esc(valuePreview(n, 80))}</span>`));
|
|
1319
|
+
showValue.addEventListener("mouseleave", hideTip);
|
|
1320
|
+
}
|
|
1321
|
+
wireValue(el.details, n);
|
|
1322
|
+
const showCode = el.details.querySelector("[data-show-code]");
|
|
1323
|
+
if (showCode) {
|
|
1324
|
+
showCode.addEventListener("click", (ev) => toggleCode(n, ev));
|
|
1325
|
+
showCode.addEventListener("mouseenter", (ev) => showTip(ev, "Show source"));
|
|
1326
|
+
showCode.addEventListener("mouseleave", hideTip);
|
|
1327
|
+
}
|
|
1328
|
+
el.details.querySelectorAll("[data-tip-text]").forEach((a) => {
|
|
1329
|
+
a.addEventListener("mouseenter", (ev) => showTip(ev, esc(a.dataset.tipText)));
|
|
1330
|
+
a.addEventListener("mouseleave", hideTip);
|
|
1331
|
+
});
|
|
1332
|
+
el.details.querySelector(".x").addEventListener("click", () => {
|
|
1333
|
+
state.details = false;
|
|
1334
|
+
el.main.classList.add("no-details");
|
|
1335
|
+
relayout();
|
|
1336
|
+
});
|
|
1337
|
+
el.details.querySelectorAll(".tab").forEach((b) =>
|
|
1338
|
+
b.addEventListener("click", () => {
|
|
1339
|
+
state.detailTab = b.dataset.tab;
|
|
1340
|
+
renderDetails();
|
|
1341
|
+
})
|
|
1342
|
+
);
|
|
1343
|
+
wireItems(el.details);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function detailTabHtml(n) {
|
|
1347
|
+
const tab = state.detailTab;
|
|
1348
|
+
if (tab === "errors") {
|
|
1349
|
+
const list = model.errors.filter((e) => e.node === n);
|
|
1350
|
+
if (!list.length) return '<div class="muted">No errors.</div>';
|
|
1351
|
+
return list
|
|
1352
|
+
.map((e) => {
|
|
1353
|
+
const kind = ERR_TYPES.find(([k]) => k === e.type)[1];
|
|
1354
|
+
return `<div class="section-title err-kind ${e.type}">${esc(kind)} <span class="muted">${fmtRel(e.t - n.start)}</span></div><pre class="code err-text">${esc(e.text)}</pre>`;
|
|
1355
|
+
})
|
|
1356
|
+
.join("");
|
|
1357
|
+
}
|
|
1358
|
+
if (tab === "value") {
|
|
1359
|
+
return hasValue(n) ? `<div class="code-box">${valueHtml(n)}</div>` : '<div class="muted">Returned nothing.</div>';
|
|
1360
|
+
}
|
|
1361
|
+
if (tab === "args") {
|
|
1362
|
+
const params = schemaFields(n.v.inputSchema);
|
|
1363
|
+
const schema = params.length ? `<div class="section-title">Schema</div><div class="params">${params.map(paramHtml).join("")}</div><div class="section-title">Values</div>` : "";
|
|
1364
|
+
return schema + (n.v.args && n.v.args.length ? `<pre class="code">${esc(json(n.v.args.length === 1 ? n.v.args[0] : n.v.args))}</pre>` : '<div class="muted">No arguments.</div>');
|
|
1365
|
+
}
|
|
1366
|
+
if (tab === "events") {
|
|
1367
|
+
const evs = n.entries.filter((i) => i.kind !== "info");
|
|
1368
|
+
return evs.length ? `<ul class="ev-list">${evs.map((i) => evLi(i, n)).join("")}</ul>` : '<div class="muted">No events.</div>';
|
|
1369
|
+
}
|
|
1370
|
+
if (tab === "logs") {
|
|
1371
|
+
const logs = n.entries.filter((i) => i.kind === "info" || i.kind === "error");
|
|
1372
|
+
return logs.length
|
|
1373
|
+
? `<pre class="code">${logs.map((i) => `<span class="muted">${esc(fmtRel(i.t - n.start).padEnd(8))}</span>${esc(i.e.message)}`).join("\n")}</pre>`
|
|
1374
|
+
: '<div class="muted">No log output.</div>';
|
|
1375
|
+
}
|
|
1376
|
+
if (tab === "snapshots") {
|
|
1377
|
+
const g = model.gallery.filter((x) => x.node === n);
|
|
1378
|
+
return g.length ? `<div class="thumbs">${g.map(thumbHtml).join("")}</div>` : '<div class="muted">No snapshots or files.</div>';
|
|
1379
|
+
}
|
|
1380
|
+
// overview
|
|
1381
|
+
const fails = model.run.failures.filter((f) => f.action === n.id || f.action === n.name || f.action === n.v.path);
|
|
1382
|
+
const asserts = n.entries.filter((i) => i.kind === "pass" || i.kind === "fail");
|
|
1383
|
+
const g = model.gallery.filter((x) => x.node === n);
|
|
1384
|
+
return `
|
|
1385
|
+
<div class="kv">
|
|
1386
|
+
<div class="k">Id</div><div class="v">${esc(n.id)}</div>
|
|
1387
|
+
${n.name !== displayName(n) ? `<div class="k">Name</div><div class="v">${esc(n.name)}</div>` : ""}
|
|
1388
|
+
<div class="k">Tags</div><div class="v">${n.v.tags.length ? esc(n.v.tags.join(", ")) : '<span class="muted">none</span>'}</div>
|
|
1389
|
+
<div class="k">Children</div><div class="v">${n.children.length}</div>
|
|
1390
|
+
<div class="k">Events</div><div class="v">${n.marks.length}</div>
|
|
1391
|
+
</div>
|
|
1392
|
+
${fails.length ? `<div class="section-title" style="color:var(--fail)">Failures (${fails.length})</div>${fails.map((f) => `<pre class="code">${esc(f.error)}</pre>`).join("")}` : ""}
|
|
1393
|
+
${g.length ? `<div class="section-title">Snapshots (${g.length})</div><div class="thumbs">${g.slice(0, 6).map(thumbHtml).join("")}</div>` : ""}
|
|
1394
|
+
<div class="section-title">Related logs</div>
|
|
1395
|
+
<div class="kv"><div class="k">${svgIcon("file", ' width="14" height="14"')}</div><div class="v"><a href="${esc(fileUrl(n.v.logFile))}" title="${esc(n.v.logFile)}">${esc(basename(n.v.logFile))}</a></div></div>
|
|
1396
|
+
<div class="section-title">Assertions (${asserts.length})</div>
|
|
1397
|
+
${asserts.length ? `<ul class="ev-list">${asserts.map((i) => evLi(i, n)).join("")}</ul>` : '<div class="muted">No assertions in this action.</div>'}`;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function evLi(i, n) {
|
|
1401
|
+
const mk = MARK[i.kind] || MARK.event;
|
|
1402
|
+
const color = css(mk.color);
|
|
1403
|
+
const text = i.kind === "lifecycle" ? i.e.message : i.label;
|
|
1404
|
+
return `<li data-item="${i.idx}" style="cursor:pointer">
|
|
1405
|
+
<span class="t">${fmtRel(i.t - (n ? n.start : 0))}</span>
|
|
1406
|
+
<span class="ev-ico" style="background:${alpha(color, 0.2)};color:${color}">${svgIcon(mk.icon)}</span>
|
|
1407
|
+
<span class="m" title="${esc(i.e.message)}">${esc(truncate(text, 140))}</span></li>`;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function thumbHtml(g) {
|
|
1411
|
+
const i = model.gallery.indexOf(g);
|
|
1412
|
+
if (g.image) return `<div class="thumb" data-gallery="${i}" title="${esc(g.name)}"><img alt="${esc(g.name)}" data-asset="${esc(g.f.asset)}"></div>`;
|
|
1413
|
+
const label = g.type === "snapshot" ? "{ }" : g.f.mime.split("/")[1] || "file";
|
|
1414
|
+
return `<div class="thumb" data-gallery="${i}" title="${esc(g.name)}"><span class="json-ico">${esc(label)}</span></div>`;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function wireItems(root) {
|
|
1418
|
+
root.querySelectorAll("img[data-asset]").forEach((img) => {
|
|
1419
|
+
if (!img.src) img.src = model.asset(img.dataset.asset);
|
|
1420
|
+
});
|
|
1421
|
+
root.querySelectorAll("[data-gallery]").forEach((t) =>
|
|
1422
|
+
t.addEventListener("click", () => openGallery(model.gallery[+t.dataset.gallery]))
|
|
1423
|
+
);
|
|
1424
|
+
root.querySelectorAll("[data-item]").forEach((t) =>
|
|
1425
|
+
t.addEventListener("click", () => openItem(model.items[+t.dataset.item]))
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
function openItem(item) {
|
|
1430
|
+
if (!item) return;
|
|
1431
|
+
setPin(item.t);
|
|
1432
|
+
if (item.node && item.node.id !== state.selected) select(item.node.id, "events");
|
|
1433
|
+
if (item.gallery) openGallery(item.gallery);
|
|
1434
|
+
}
|
|
1435
|
+
function setPin(t) {
|
|
1436
|
+
state.pin = t;
|
|
1437
|
+
redrawX();
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
// ── errors: the tab, and highlighting them in the graphs ──
|
|
1442
|
+
const ERR_TYPES = [
|
|
1443
|
+
["failure", "Failures", "x"],
|
|
1444
|
+
["assert", "Failed assertions", "alert"],
|
|
1445
|
+
["output", "Error output", "terminal"],
|
|
1446
|
+
];
|
|
1447
|
+
/** Failures and failed assertions — what makes a run wrong. Error output alone may be noise. */
|
|
1448
|
+
function seriousCount() {
|
|
1449
|
+
return model.errors.filter((e) => e.type !== "output").length;
|
|
1450
|
+
}
|
|
1451
|
+
function errorsShown() {
|
|
1452
|
+
return model.errors.filter(
|
|
1453
|
+
(e) =>
|
|
1454
|
+
(state.errFilter === "all" || e.type === state.errFilter) &&
|
|
1455
|
+
matches(e.text, e.node && e.node.v.path, e.source)
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
function errorsHtml() {
|
|
1459
|
+
if (!model.errors.length) return '<div class="muted">No errors: nothing failed, no assertion failed, and nothing wrote error output.</div>';
|
|
1460
|
+
const count = (t) => model.errors.filter((e) => t === "all" || e.type === t).length;
|
|
1461
|
+
const seg = [["all", "All"], ...ERR_TYPES.map(([k, l]) => [k, l])]
|
|
1462
|
+
.filter(([k]) => k === "all" || count(k))
|
|
1463
|
+
.map(([k, l]) => `<button class="seg ${state.errFilter === k ? "active" : ""}" data-err-filter="${k}">${l} <span class="muted">${count(k)}</span></button>`)
|
|
1464
|
+
.join("");
|
|
1465
|
+
const shown = errorsShown();
|
|
1466
|
+
const sections = ERR_TYPES.map(([type, label, icon]) => {
|
|
1467
|
+
const list = shown.filter((e) => e.type === type);
|
|
1468
|
+
if (!list.length) return "";
|
|
1469
|
+
return `<div class="err-sec"><div class="err-sec-h">${svgIcon(icon)}${label} <span class="muted">${list.length}</span></div>${list.map(errEntryHtml).join("")}</div>`;
|
|
1470
|
+
}).join("");
|
|
1471
|
+
return `<div class="err-bar"><div class="segs">${seg}</div>
|
|
1472
|
+
<button class="btn solo small ${state.errHl ? "active" : ""}" data-err-hl>${svgIcon("locate")}Highlight in graphs</button></div>
|
|
1473
|
+
${sections || '<div class="muted">Nothing matches.</div>'}`;
|
|
1474
|
+
}
|
|
1475
|
+
function errEntryHtml(e) {
|
|
1476
|
+
const open = state.errOpen.has(e.idx);
|
|
1477
|
+
const who = e.node ? displayName(e.node) : "run";
|
|
1478
|
+
const extra = e.type === "output" && e.lines.length > 1 ? `<span class="err-n">${e.lines.length} lines</span>` : "";
|
|
1479
|
+
const src = e.type === "output" && e.source ? `<span class="err-src">${esc(truncate(e.source, 40))}</span>` : "";
|
|
1480
|
+
return `<div class="err-entry ${e.type}${open ? " open" : ""}" data-err="${e.idx}">
|
|
1481
|
+
<div class="err-row">
|
|
1482
|
+
<span class="twist ${open ? "open" : ""}">${svgIcon("chevron")}</span>
|
|
1483
|
+
<span class="t">${fmtTick(e.t)}</span>
|
|
1484
|
+
<span class="who" title="${esc(e.node ? e.node.v.path : "run")}">${esc(truncate(who, 40))}</span>
|
|
1485
|
+
${src}
|
|
1486
|
+
<span class="msg" title="${esc(e.title)}">${esc(e.title)}</span>
|
|
1487
|
+
${extra}
|
|
1488
|
+
<button class="icon-btn" data-err-locate="${e.idx}" aria-label="Show in graph">${svgIcon("locate")}</button>
|
|
1489
|
+
<button class="icon-btn" data-err-copy="${e.idx}" aria-label="Copy">${svgIcon("copy")}</button>
|
|
1490
|
+
</div>
|
|
1491
|
+
${open ? `<pre class="code err-text">${esc(e.text)}</pre>` : ""}
|
|
1492
|
+
</div>`;
|
|
1493
|
+
}
|
|
1494
|
+
function wireErrors(body) {
|
|
1495
|
+
body.querySelectorAll("[data-err-filter]").forEach((b) =>
|
|
1496
|
+
b.addEventListener("click", () => {
|
|
1497
|
+
state.errFilter = b.dataset.errFilter;
|
|
1498
|
+
refreshErrors();
|
|
1499
|
+
})
|
|
1500
|
+
);
|
|
1501
|
+
const hl = body.querySelector("[data-err-hl]");
|
|
1502
|
+
if (hl) hl.addEventListener("click", () => setErrHl(!state.errHl));
|
|
1503
|
+
body.querySelectorAll(".err-entry").forEach((row) => {
|
|
1504
|
+
const e = model.errors[+row.dataset.err];
|
|
1505
|
+
row.querySelector(".err-row").addEventListener("click", (ev) => {
|
|
1506
|
+
if (ev.target.closest("button")) return;
|
|
1507
|
+
if (state.errOpen.has(e.idx)) state.errOpen.delete(e.idx);
|
|
1508
|
+
else state.errOpen.add(e.idx);
|
|
1509
|
+
refreshErrors();
|
|
1510
|
+
});
|
|
1511
|
+
row.querySelector(".err-row").addEventListener("dblclick", () => locateError(e));
|
|
1512
|
+
// hovering an entry previews just its action in the graphs
|
|
1513
|
+
row.addEventListener("mouseenter", () => e.node && applyHl(new Set([e.node.id])));
|
|
1514
|
+
row.addEventListener("mouseleave", () => applyHl());
|
|
1515
|
+
});
|
|
1516
|
+
body.querySelectorAll("[data-err-locate]").forEach((b) => {
|
|
1517
|
+
b.addEventListener("click", () => locateError(model.errors[+b.dataset.errLocate]));
|
|
1518
|
+
b.addEventListener("mouseenter", (ev) => showTip(ev, "Show in graph"));
|
|
1519
|
+
b.addEventListener("mouseleave", hideTip);
|
|
1520
|
+
});
|
|
1521
|
+
body.querySelectorAll("[data-err-copy]").forEach((b) =>
|
|
1522
|
+
b.addEventListener("click", () => copy(model.errors[+b.dataset.errCopy].text, "Copied the error"))
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
function refreshErrors() {
|
|
1526
|
+
if (state.lowerTab !== "errors") return;
|
|
1527
|
+
const body = el.lower.querySelector(".body");
|
|
1528
|
+
const top = body.scrollTop;
|
|
1529
|
+
body.innerHTML = errorsHtml();
|
|
1530
|
+
wireLower();
|
|
1531
|
+
body.scrollTop = top;
|
|
1532
|
+
}
|
|
1533
|
+
function setErrHl(on) {
|
|
1534
|
+
state.errHl = on;
|
|
1535
|
+
refreshErrors();
|
|
1536
|
+
applyHl();
|
|
1537
|
+
}
|
|
1538
|
+
function openErrors(highlight) {
|
|
1539
|
+
state.lowerTab = "errors";
|
|
1540
|
+
renderLower();
|
|
1541
|
+
if (highlight) setErrHl(true);
|
|
1542
|
+
}
|
|
1543
|
+
function locateError(e) {
|
|
1544
|
+
if (!e) return;
|
|
1545
|
+
const items = new Set(e.items);
|
|
1546
|
+
locateAt(e.node, e.t, (m) => items.has(m), "errors");
|
|
1547
|
+
if (e.node) applyHl(new Set([e.node.id]), 1600);
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* Mark error nodes in every graph: red on the nodes that errored, a lighter mark on their
|
|
1551
|
+
* ancestors (the path to them), everything else dimmed. With no `only`, the highlight
|
|
1552
|
+
* follows the toggle; `only` previews a set (an entry being hovered), and `ms` makes that
|
|
1553
|
+
* preview lapse back to the toggle's state.
|
|
1554
|
+
*/
|
|
1555
|
+
let hlTimer = 0;
|
|
1556
|
+
function applyHl(only, ms) {
|
|
1557
|
+
clearTimeout(hlTimer);
|
|
1558
|
+
const set = only || (state.errHl ? model.errNodes : null);
|
|
1559
|
+
const path = only ? pathOf(only) : model.errPath;
|
|
1560
|
+
app.classList.toggle("hl-on", !!set);
|
|
1561
|
+
const mark = (sel, idOf) =>
|
|
1562
|
+
sel.classed("err", (d) => !!set && set.has(idOf(d))).classed("err-path", (d) => !!set && path.has(idOf(d)));
|
|
1563
|
+
if (chartSvg) mark(chartSvg.selectAll("g.r"), (n) => n.id);
|
|
1564
|
+
if (flameSvg) mark(flameSvg.selectAll("g.cell"), (n) => n.id);
|
|
1565
|
+
mark(d3.select(el.flow).selectAll("g.flow-card"), (n) => n.id);
|
|
1566
|
+
el.tree.querySelectorAll(".row").forEach((r) => {
|
|
1567
|
+
r.classList.toggle("err", !!set && set.has(r.dataset.id));
|
|
1568
|
+
r.classList.toggle("err-path", !!set && path.has(r.dataset.id));
|
|
1569
|
+
});
|
|
1570
|
+
if (ms) hlTimer = setTimeout(() => applyHl(), ms);
|
|
1571
|
+
}
|
|
1572
|
+
function pathOf(ids) {
|
|
1573
|
+
const out = new Set();
|
|
1574
|
+
for (const id of ids) for (let p = model.byId.get(id)?.parent; p; p = p.parent) out.add(p.id);
|
|
1575
|
+
return out;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// ── lower panel ──
|
|
1579
|
+
function renderLower() {
|
|
1580
|
+
const tabs = [
|
|
1581
|
+
["snapshots", `Snapshots (${model.gallery.length})`],
|
|
1582
|
+
["errors", `Errors <span class="tab-count${seriousCount() ? " bad" : model.errors.length ? " warn" : ""}">${model.errors.length}</span>`],
|
|
1583
|
+
["logs", "Logs"],
|
|
1584
|
+
["asserts", `Assertions (${model.asserts.length})`],
|
|
1585
|
+
["events", "Timeline events"],
|
|
1586
|
+
];
|
|
1587
|
+
el.lower.innerHTML = `<div class="tabs">${tabs
|
|
1588
|
+
.map(([k, l]) => `<button class="tab ${state.lowerTab === k ? "active" : ""}" data-tab="${k}">${l}</button>`)
|
|
1589
|
+
.join("")}<span class="spacer"></span><input class="filter" placeholder="Filter…" value="${esc(state.filter)}"></div>
|
|
1590
|
+
<div class="body">${lowerBodyHtml()}</div>`;
|
|
1591
|
+
el.lower.querySelectorAll(".tab").forEach((b) =>
|
|
1592
|
+
b.addEventListener("click", () => {
|
|
1593
|
+
state.lowerTab = b.dataset.tab;
|
|
1594
|
+
renderLower();
|
|
1595
|
+
})
|
|
1596
|
+
);
|
|
1597
|
+
const input = el.lower.querySelector(".filter");
|
|
1598
|
+
input.addEventListener("input", () => {
|
|
1599
|
+
state.filter = input.value;
|
|
1600
|
+
el.lower.querySelector(".body").innerHTML = lowerBodyHtml();
|
|
1601
|
+
wireLower();
|
|
1602
|
+
});
|
|
1603
|
+
wireLower();
|
|
1604
|
+
}
|
|
1605
|
+
function wireLower() {
|
|
1606
|
+
const body = el.lower.querySelector(".body");
|
|
1607
|
+
if (state.lowerTab === "errors") wireErrors(body);
|
|
1608
|
+
wireItems(body);
|
|
1609
|
+
body.querySelectorAll("tr[data-row]").forEach((tr) =>
|
|
1610
|
+
tr.addEventListener("click", () => openItem(model.items[+tr.dataset.row]))
|
|
1611
|
+
);
|
|
1612
|
+
body.querySelectorAll("[data-locate]").forEach((b) => {
|
|
1613
|
+
b.addEventListener("click", (ev) => {
|
|
1614
|
+
ev.stopPropagation();
|
|
1615
|
+
locate(model.gallery[+b.dataset.locate]);
|
|
1616
|
+
});
|
|
1617
|
+
b.addEventListener("mouseenter", (ev) => showTip(ev, `Show in the ${state.view === "flow" ? "flow" : "graph"}`));
|
|
1618
|
+
b.addEventListener("mouseleave", hideTip);
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
function matches(...parts) {
|
|
1622
|
+
const q = state.filter.trim().toLowerCase();
|
|
1623
|
+
return !q || parts.some((p) => String(p || "").toLowerCase().includes(q));
|
|
1624
|
+
}
|
|
1625
|
+
function lowerBodyHtml() {
|
|
1626
|
+
const tab = state.lowerTab;
|
|
1627
|
+
if (tab === "errors") return errorsHtml();
|
|
1628
|
+
if (tab === "snapshots") {
|
|
1629
|
+
const g = model.gallery.filter((x) => matches(x.name, x.node && x.node.v.path));
|
|
1630
|
+
if (!g.length) return '<div class="muted">No snapshots, artifacts, or images in this run.</div>';
|
|
1631
|
+
return `<div class="gallery">${g
|
|
1632
|
+
.map((x) => {
|
|
1633
|
+
const sub = x.type === "snapshot" ? `snapshot · ${x.node ? esc(displayName(x.node)) : ""}` : `${esc(x.f.mime)} · ${fmtSize(x.f.size)}${x.f.omitted ? " · not embedded" : ""}`;
|
|
1634
|
+
const at = x.t != null || x.node ? `<button class="icon-btn locate" data-locate="${model.gallery.indexOf(x)}" aria-label="Show in graph">${svgIcon("locate")}</button>` : "";
|
|
1635
|
+
return `<div class="gal-item">${thumbHtml(x)}<div class="cap"><span class="t">${x.t == null ? "–" : fmtTick(x.t)}</span><span class="n" title="${esc(x.name)}">${esc(x.name)}</span>${at}</div><div class="sz">${sub}</div></div>`;
|
|
1636
|
+
})
|
|
1637
|
+
.join("")}</div>`;
|
|
1638
|
+
}
|
|
1639
|
+
const pick =
|
|
1640
|
+
tab === "logs"
|
|
1641
|
+
? (i) => i.kind === "info" || i.kind === "error"
|
|
1642
|
+
: tab === "asserts"
|
|
1643
|
+
? (i) => i.kind === "pass" || i.kind === "fail"
|
|
1644
|
+
: (i) => i.kind !== "info";
|
|
1645
|
+
const rows = [];
|
|
1646
|
+
for (let idx = 0; idx < model.items.length; idx++) {
|
|
1647
|
+
const i = model.items[idx];
|
|
1648
|
+
if (!pick(i)) continue;
|
|
1649
|
+
if (!matches(i.e.message, i.e.path, i.e.source)) continue;
|
|
1650
|
+
rows.push({ i, idx });
|
|
1651
|
+
if (rows.length >= 3000) break;
|
|
1652
|
+
}
|
|
1653
|
+
if (!rows.length) return `<div class="muted">${tab === "asserts" ? "No assertions in this run." : "Nothing matches."}</div>`;
|
|
1654
|
+
return `<table class="list"><tbody>${rows
|
|
1655
|
+
.map(({ i, idx }) => {
|
|
1656
|
+
const mk = MARK[i.kind];
|
|
1657
|
+
const ico = mk
|
|
1658
|
+
? `<span class="ev-ico" style="background:${alpha(css(mk.color), 0.2)};color:${css(mk.color)}">${svgIcon(mk.icon)}</span>`
|
|
1659
|
+
: "";
|
|
1660
|
+
return `<tr data-row="${idx}" class="${i.kind === "error" || i.kind === "fail" ? "err" : ""}"><td class="t">${fmtTick(i.t)}</td><td>${ico}</td><td class="p" title="${esc(i.e.path)}">${esc(i.node ? displayName(i.node) : i.e.path)}</td><td class="m">${esc(i.e.message)}</td></tr>`;
|
|
1661
|
+
})
|
|
1662
|
+
.join("")}</tbody></table>${rows.length >= 3000 ? '<div class="muted">First 3000 rows — narrow with the filter.</div>' : ""}`;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// ── modal ──
|
|
1666
|
+
function openGallery(g) {
|
|
1667
|
+
if (!g) return;
|
|
1668
|
+
if (g.t != null) setPin(g.t);
|
|
1669
|
+
let body;
|
|
1670
|
+
let actions = "";
|
|
1671
|
+
const pathOf = g.type === "file" ? g.f.file : g.s.file;
|
|
1672
|
+
if (g.type === "file" && g.image) {
|
|
1673
|
+
const src = model.asset(g.f.asset);
|
|
1674
|
+
body = `<img alt="${esc(g.name)}" src="${src}">`;
|
|
1675
|
+
actions = `<a class="btn solo" download="${esc(basename(g.f.file))}" href="${src}">${svgIcon("download")}Download</a>`;
|
|
1676
|
+
} else if (g.type === "file") {
|
|
1677
|
+
body = g.f.text != null ? `<pre class="code">${esc(g.f.text)}</pre>` : `<div class="muted">Not embedded (${esc(g.f.omitted || "binary")}). Open it from the run folder.</div>`;
|
|
1678
|
+
} else {
|
|
1679
|
+
body = `<pre class="code">${esc(g.s.value !== undefined ? json(g.s.value) : g.s.text)}</pre>${g.s.truncated ? '<div class="muted">Truncated — the snapshot file holds the rest.</div>' : ""}`;
|
|
1680
|
+
}
|
|
1681
|
+
const sub = [g.t != null ? fmtRel(g.t) : null, g.node ? g.node.v.path : null, g.type === "file" ? fmtSize(g.f.size) : "snapshot"]
|
|
1682
|
+
.filter(Boolean)
|
|
1683
|
+
.join(" · ");
|
|
1684
|
+
const m = document.createElement("div");
|
|
1685
|
+
m.className = "modal";
|
|
1686
|
+
m.innerHTML = `<div class="box"><div class="mh"><span class="nm">${esc(g.name)} <span class="muted" style="font-weight:400">${esc(sub)}</span></span>
|
|
1687
|
+
${actions}
|
|
1688
|
+
<a class="btn solo" href="${esc(fileUrl(pathOf))}">${svgIcon("file")}Open</a>
|
|
1689
|
+
<button class="btn solo" data-copy>${svgIcon("copy")}Copy path</button>
|
|
1690
|
+
<button class="btn solo" data-close title="Close">${svgIcon("close")}</button></div>
|
|
1691
|
+
<div class="mb">${body}</div></div>`;
|
|
1692
|
+
const close = () => {
|
|
1693
|
+
m.remove();
|
|
1694
|
+
document.removeEventListener("keydown", onKey);
|
|
1695
|
+
};
|
|
1696
|
+
const onKey = (e) => e.key === "Escape" && close();
|
|
1697
|
+
m.addEventListener("click", (e) => e.target === m && close());
|
|
1698
|
+
m.querySelector("[data-close]").addEventListener("click", close);
|
|
1699
|
+
m.querySelector("[data-copy]").addEventListener("click", () => copy(pathOf));
|
|
1700
|
+
document.addEventListener("keydown", onKey);
|
|
1701
|
+
document.body.appendChild(m);
|
|
1702
|
+
if (g.node && g.node.id !== state.selected) select(g.node.id);
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
function openMenu(anchor) {
|
|
1706
|
+
const r = anchor.getBoundingClientRect();
|
|
1707
|
+
const m = document.createElement("div");
|
|
1708
|
+
m.className = "menu";
|
|
1709
|
+
m.style.top = `${r.bottom + 6}px`;
|
|
1710
|
+
m.style.right = `${window.innerWidth - r.right}px`;
|
|
1711
|
+
m.innerHTML = `<button data-a="theme">${svgIcon("sun", ' width="14" height="14"')} Toggle light / dark</button>
|
|
1712
|
+
<button data-a="data">${svgIcon("download", ' width="14" height="14"')} Download run data (JSON)</button>
|
|
1713
|
+
<button data-a="folder">${svgIcon("copy", ' width="14" height="14"')} Copy run folder path</button>
|
|
1714
|
+
<button data-a="expand">Expand all</button>
|
|
1715
|
+
<button data-a="collapse">Collapse all</button>`;
|
|
1716
|
+
const close = () => {
|
|
1717
|
+
m.remove();
|
|
1718
|
+
document.removeEventListener("mousedown", away, true);
|
|
1719
|
+
};
|
|
1720
|
+
const away = (e) => !m.contains(e.target) && close();
|
|
1721
|
+
m.addEventListener("click", (e) => {
|
|
1722
|
+
const a = e.target.closest("button")?.dataset.a;
|
|
1723
|
+
close();
|
|
1724
|
+
if (a === "theme") {
|
|
1725
|
+
toggleTheme();
|
|
1726
|
+
renderAll();
|
|
1727
|
+
renderDetails();
|
|
1728
|
+
renderLower();
|
|
1729
|
+
}
|
|
1730
|
+
if (a === "data") download(`${model.data.name}-run.json`, JSON.stringify(model.data, null, 2), "application/json");
|
|
1731
|
+
if (a === "folder") copy(model.data.folder);
|
|
1732
|
+
if (a === "expand") {
|
|
1733
|
+
state.collapsed.clear();
|
|
1734
|
+
renderAll();
|
|
1735
|
+
}
|
|
1736
|
+
if (a === "collapse") {
|
|
1737
|
+
for (const n of model.nodes) if (n.depth >= 1 && n.children.length) state.collapsed.add(n.id);
|
|
1738
|
+
renderAll();
|
|
1739
|
+
}
|
|
1740
|
+
});
|
|
1741
|
+
document.addEventListener("mousedown", away, true);
|
|
1742
|
+
document.body.appendChild(m);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// ── tooltips ──
|
|
1746
|
+
/** Click selects, double-click focuses, hover explains — the same on every node mark. */
|
|
1747
|
+
function bindNode(sel) {
|
|
1748
|
+
sel
|
|
1749
|
+
.on("click", (ev, n) => {
|
|
1750
|
+
ev.stopPropagation();
|
|
1751
|
+
select(n.id);
|
|
1752
|
+
})
|
|
1753
|
+
.on("dblclick", (ev, n) => {
|
|
1754
|
+
ev.stopPropagation();
|
|
1755
|
+
focusNode(n);
|
|
1756
|
+
})
|
|
1757
|
+
.on("mouseenter", (ev, n) => showTip(ev, nodeTip(n), true))
|
|
1758
|
+
.on("mousemove", (ev) => moveTip(ev))
|
|
1759
|
+
.on("mouseleave", hideTip);
|
|
1760
|
+
}
|
|
1761
|
+
function nodeTip(n) {
|
|
1762
|
+
hovered = n; // the node `C` copies while its tooltip is up
|
|
1763
|
+
const v = n.v;
|
|
1764
|
+
const shown = n.depth === 0 ? model.data.name : displayName(n);
|
|
1765
|
+
const badges = [
|
|
1766
|
+
`<span class="badge"><span class="dot ${n.status}"></span>${esc(n.status)}</span>`,
|
|
1767
|
+
v.mcp ? `<span class="badge mcp" title="Exposed over MCP">MCP · ${esc(v.mcp.mode)}</span>` : "",
|
|
1768
|
+
n.depth === 0 ? '<span class="badge">om</span>' : "",
|
|
1769
|
+
n.v.kind === "step" ? '<span class="badge" title="An inline step(…), not a reusable action">step</span>' : "",
|
|
1770
|
+
].join("");
|
|
1771
|
+
const rows = [
|
|
1772
|
+
["Duration", fmtDur(n.duration)],
|
|
1773
|
+
["When", `${fmtRel(n.start)} → ${fmtRel(n.end)}`],
|
|
1774
|
+
v.tags.length ? ["Tags", esc(v.tags.join(" · "))] : null,
|
|
1775
|
+
userSite(n) ? [siteLabel(n) === "Called at" ? "Called" : "Defined", `<span class="mono">${esc(sourceOf(n))}</span>`] : null,
|
|
1776
|
+
hasValue(n) ? ["Returns", `<span class="mono">${esc(valuePreview(n, 90))}</span>`] : null,
|
|
1777
|
+
n.children.length ? ["Children", String(n.children.length)] : null,
|
|
1778
|
+
n.marks.length ? ["Events", String(n.marks.length)] : null,
|
|
1779
|
+
].filter(Boolean);
|
|
1780
|
+
const args = !promptOf(n) && v.args && v.args.length ? truncate(oneLine(v.args.length === 1 ? v.args[0] : v.args), 220) : "";
|
|
1781
|
+
const params = schemaFields(v.inputSchema);
|
|
1782
|
+
const pr = promptOf(n);
|
|
1783
|
+
const promptRows = pr
|
|
1784
|
+
? `<div class="sec">Prompt</div><div class="tprompt">${esc(truncate(pr.message || "", 200))}</div>${
|
|
1785
|
+
pr.answer ? `<div class="tkv"><span class="k">Answer</span><span class="mono">${esc(truncate(typeof pr.answer.value === "string" ? pr.answer.value : oneLine(pr.answer.value), 120))}</span>${pr.answer.via ? `<span class="k">Via</span><span>${esc(pr.answer.via)}</span>` : ""}</div>` : ""
|
|
1786
|
+
}`
|
|
1787
|
+
: "";
|
|
1788
|
+
const fails = model.run.failures.filter((f) => f.action === n.id || f.action === n.name || f.action === v.path);
|
|
1789
|
+
return `<div class="tt">${esc(shown)}</div>
|
|
1790
|
+
${shown !== n.name ? `<div class="ts mono">${esc(truncate(n.name, 140))}</div>` : ""}
|
|
1791
|
+
<div class="badges">${badges}</div>
|
|
1792
|
+
${v.description ? `<div class="summary">${esc(v.description.summary)}</div>` : '<div class="summary muted">No description.</div>'}
|
|
1793
|
+
<div class="tkv">${rows.map(([k, val]) => `<span class="k">${k}</span><span>${val}</span>`).join("")}</div>
|
|
1794
|
+
${promptRows}
|
|
1795
|
+
${params.length ? `<div class="sec">Args schema</div><div class="params">${params.map(paramHtml).join("")}</div>` : ""}
|
|
1796
|
+
${args ? `<div class="sec">Args</div><div class="mono small">${esc(args)}</div>` : ""}
|
|
1797
|
+
${v.definedAt ? '<div class="hint">Press <kbd>C</kbd> to copy the path</div>' : ""}
|
|
1798
|
+
${fails.length ? `<div class="sec fail">Failed</div><div class="mono small">${esc(truncate(String(fails[0].error).split(String.fromCharCode(10))[0], 200))}</div>` : ""}`;
|
|
1799
|
+
}
|
|
1800
|
+
function galleryTip(x) {
|
|
1801
|
+
const img = x.image ? `<img src="${model.asset(x.f.asset)}" alt="">` : "";
|
|
1802
|
+
const body = x.type === "snapshot"
|
|
1803
|
+
? `<div class="mono small">${esc(truncate(x.s.value !== undefined ? oneLine(x.s.value) : x.s.text || "", 200))}</div>`
|
|
1804
|
+
: `<div class="ts">${esc(x.f.mime)} · ${fmtSize(x.f.size)}</div>`;
|
|
1805
|
+
return `<div class="tt">${esc(x.name)}${x.t != null ? ` · ${fmtRel(x.t)}` : ""}</div>${img}${body}`;
|
|
1806
|
+
}
|
|
1807
|
+
/** The om itself: its node tooltip, plus where the run's record lives. */
|
|
1808
|
+
function omTip() {
|
|
1809
|
+
return `${nodeTip(model.root)}<div class="sec">Run folder</div><div class="mono small">${esc(model.data.folder)}</div>`;
|
|
1810
|
+
}
|
|
1811
|
+
function markTip(d) {
|
|
1812
|
+
const m = d.m;
|
|
1813
|
+
const mk = MARK[m.kind] || MARK.event;
|
|
1814
|
+
const img = m.gallery && m.gallery.image ? `<img src="${model.asset(m.gallery.f.asset)}" alt="">` : "";
|
|
1815
|
+
const more = d.more.length ? `<div class="ts">+${d.more.length} more within a few ms</div>` : "";
|
|
1816
|
+
const extra = m.gallery && m.gallery.image ? `<div class="ts">${esc(m.gallery.f.rel)} · ${fmtSize(m.gallery.f.size)}</div>` : "";
|
|
1817
|
+
return `<div class="tt">${esc(mk.label)} · ${fmtRel(m.t)}</div>${img}<div>${esc(truncate(m.kind === "snapshot" || m.kind === "artifact" ? m.label : m.e.message, 260))}</div>${extra}${more}`;
|
|
1818
|
+
}
|
|
1819
|
+
// ── source popover (the <> buttons) — click-toggled, like the tskb explorer's ──
|
|
1820
|
+
const codeEl = document.createElement("div");
|
|
1821
|
+
codeEl.className = "code-pop";
|
|
1822
|
+
codeEl.hidden = true;
|
|
1823
|
+
document.body.appendChild(codeEl);
|
|
1824
|
+
let codeFor = null;
|
|
1825
|
+
let codeTab = null;
|
|
1826
|
+
let codeAway = null;
|
|
1827
|
+
/** A site inside omkit or an installed package — library source, not the user's. */
|
|
1828
|
+
function isLib(site) {
|
|
1829
|
+
return !!(site && model.data.library && model.data.library.includes(site));
|
|
1830
|
+
}
|
|
1831
|
+
/**
|
|
1832
|
+
* The site worth showing for `n`: where it is defined, unless that is library source (a
|
|
1833
|
+
* battery, a prompt), in which case the user's call that launched it.
|
|
1834
|
+
*/
|
|
1835
|
+
function userSite(n) {
|
|
1836
|
+
const { definedAt, calledAt } = n.v;
|
|
1837
|
+
if (definedAt && !isLib(definedAt)) return definedAt;
|
|
1838
|
+
if (calledAt && !isLib(calledAt)) return calledAt;
|
|
1839
|
+
return definedAt || calledAt;
|
|
1840
|
+
}
|
|
1841
|
+
function siteLabel(n) {
|
|
1842
|
+
return userSite(n) === n.v.definedAt ? "Defined at" : "Called at";
|
|
1843
|
+
}
|
|
1844
|
+
function codeOf(site) {
|
|
1845
|
+
return site && model.data.code ? model.data.code[site] : undefined;
|
|
1846
|
+
}
|
|
1847
|
+
/** The popover's tabs for `n`, most useful first. */
|
|
1848
|
+
function codeTabs(n) {
|
|
1849
|
+
const tabs = [];
|
|
1850
|
+
if (promptOf(n)) tabs.push({ key: "prompt", label: "Prompt" });
|
|
1851
|
+
if (hasValue(n)) tabs.push({ key: "value", label: "Returned" });
|
|
1852
|
+
const mine = userSite(n);
|
|
1853
|
+
if (codeOf(mine)) tabs.push({ key: "user", label: siteLabel(n) === "Called at" ? "Call site" : "Source", site: mine });
|
|
1854
|
+
const def = n.v.definedAt;
|
|
1855
|
+
if (def && def !== mine && codeOf(def)) tabs.push({ key: "def", label: isLib(def) ? "Definition (omkit)" : "Definition", site: def });
|
|
1856
|
+
return tabs;
|
|
1857
|
+
}
|
|
1858
|
+
function hasCode(n) {
|
|
1859
|
+
return codeTabs(n).some((t) => t.key !== "value");
|
|
1860
|
+
}
|
|
1861
|
+
function relOf(site) {
|
|
1862
|
+
return (model.data.sources && model.data.sources[site]) || String(site).replace(/:\d+(:\d+)?$/, "");
|
|
1863
|
+
}
|
|
1864
|
+
function hasValue(n) {
|
|
1865
|
+
return n.v.value !== undefined || !!n.v.valueNote;
|
|
1866
|
+
}
|
|
1867
|
+
function valuePreview(n, max) {
|
|
1868
|
+
if (n.v.value === undefined) return n.v.valueNote || "";
|
|
1869
|
+
return truncate(oneLine(n.v.value), max);
|
|
1870
|
+
}
|
|
1871
|
+
/** The returned value in the code view: pretty JSON, highlighted, numbered, copyable. */
|
|
1872
|
+
function valueHtml(n) {
|
|
1873
|
+
if (n.v.value === undefined) {
|
|
1874
|
+
return `<div class="pp"><div class="muted">${esc(n.v.valueNote || "Returned nothing.")}</div></div>`;
|
|
1875
|
+
}
|
|
1876
|
+
const text = json(n.v.value);
|
|
1877
|
+
const lines = highlight(text);
|
|
1878
|
+
const kind = Array.isArray(n.v.value) ? `array · ${n.v.value.length} items` : n.v.value === null ? "null" : typeof n.v.value === "object" ? `object · ${Object.keys(n.v.value).length} keys` : typeof n.v.value;
|
|
1879
|
+
return `<div class="cp-tools"><span class="muted">${esc(kind)} · ${lines.length} line${lines.length > 1 ? "s" : ""} · ${fmtSize(text.length)}</span>
|
|
1880
|
+
<button class="btn solo small" data-a="copy-json">${svgIcon("copy")}Copy JSON</button></div>
|
|
1881
|
+
<div class="cp-code">${lines
|
|
1882
|
+
.map((html, i) => `<div class="cl"><span class="ln">${i + 1}</span><span class="lc">${html || " "}</span></div>`)
|
|
1883
|
+
.join("")}</div>`;
|
|
1884
|
+
}
|
|
1885
|
+
function wireValue(root, n) {
|
|
1886
|
+
const b = root.querySelector('[data-a="copy-json"]');
|
|
1887
|
+
if (b) b.addEventListener("click", () => copy(json(n.v.value), "Copied the returned value"));
|
|
1888
|
+
}
|
|
1889
|
+
function codeHtml(site) {
|
|
1890
|
+
const c = codeOf(site);
|
|
1891
|
+
const line = Number((/:(\d+)(?::\d+)?$/.exec(site) || [])[1]);
|
|
1892
|
+
const lines = highlight(c.text).map(
|
|
1893
|
+
(html, i) => `<div class="cl${c.start + i === line ? " at" : ""}"><span class="ln">${c.start + i}</span><span class="lc">${html || " "}</span></div>`
|
|
1894
|
+
);
|
|
1895
|
+
return `<div class="cp-code">${lines.join("")}</div>${c.truncated ? '<div class="muted cp-more">Cut short — open the file for the rest.</div>' : ""}`;
|
|
1896
|
+
}
|
|
1897
|
+
function promptHtml(p) {
|
|
1898
|
+
const choices = p.choices && p.choices.length
|
|
1899
|
+
? `<div class="pp-k">Choices</div><div class="pp-v"><ol class="pp-choices">${p.choices
|
|
1900
|
+
.map((c) => {
|
|
1901
|
+
const value = typeof c === "string" ? c : c.value;
|
|
1902
|
+
const label = typeof c === "string" ? c : c.label;
|
|
1903
|
+
const picked = p.answer && p.answer.value === value;
|
|
1904
|
+
return `<li class="${picked ? "picked" : ""}">${esc(label)}${label !== value ? ` <span class="muted">(${esc(value)})</span>` : ""}</li>`;
|
|
1905
|
+
})
|
|
1906
|
+
.join("")}</ol></div>`
|
|
1907
|
+
: "";
|
|
1908
|
+
const answer = p.answer
|
|
1909
|
+
? `<div class="pp-answer"><span class="pp-k">Answer</span><pre class="code">${esc(typeof p.answer.value === "string" ? p.answer.value : json(p.answer.value))}</pre>${
|
|
1910
|
+
p.answer.via ? `<span class="badge-inline">via ${esc(p.answer.via)}</span>` : ""
|
|
1911
|
+
}</div>`
|
|
1912
|
+
: '<div class="muted">No answer recorded.</div>';
|
|
1913
|
+
return `<div class="pp">
|
|
1914
|
+
<div class="pp-q">${esc(p.message || "(no message)")}</div>
|
|
1915
|
+
${p.hint ? `<div class="mono small">${esc(p.hint)}</div>` : ""}
|
|
1916
|
+
<div class="pp-grid">
|
|
1917
|
+
<div class="pp-k">Kind</div><div class="pp-v">${esc(p.kind)}</div>
|
|
1918
|
+
${p.def !== undefined ? `<div class="pp-k">Default</div><div class="pp-v mono">${esc(oneLine(p.def))}</div>` : ""}
|
|
1919
|
+
${p.timeoutMs !== undefined ? `<div class="pp-k">Timeout</div><div class="pp-v">${p.timeoutMs > 0 && isFinite(p.timeoutMs) ? fmtDur(p.timeoutMs) : "none"}</div>` : ""}
|
|
1920
|
+
${choices}
|
|
1921
|
+
</div>
|
|
1922
|
+
${answer}
|
|
1923
|
+
</div>`;
|
|
1924
|
+
}
|
|
1925
|
+
function toggleCode(n, ev, want, toggle) {
|
|
1926
|
+
// the <> closes whatever is open on its node; the return button closes only its own tab
|
|
1927
|
+
if (codeFor === n.id && (!want || (toggle && codeTab === want))) return closeCode();
|
|
1928
|
+
const tabs = codeTabs(n);
|
|
1929
|
+
if (!tabs.length) return;
|
|
1930
|
+
const tab = tabs.find((t) => t.key === want) || tabs.find((t) => t.key !== "value") || tabs[0];
|
|
1931
|
+
codeTab = tab.key;
|
|
1932
|
+
hideTip();
|
|
1933
|
+
const reopen = codeFor !== n.id;
|
|
1934
|
+
codeFor = n.id;
|
|
1935
|
+
const site = tab.site || userSite(n);
|
|
1936
|
+
const rel = site ? relOf(site) : "";
|
|
1937
|
+
const line = site ? (/:(\d+)(?::\d+)?$/.exec(site) || [])[1] : "";
|
|
1938
|
+
codeEl.innerHTML = `<div class="cp-head">
|
|
1939
|
+
${site ? `<span class="cp-path" title="${esc(site)}">${esc(rel)}<span class="muted">${line ? ":" + line : ""}</span></span>
|
|
1940
|
+
<button class="icon-btn" data-a="copy" aria-label="Copy path">${svgIcon("copy")}</button>
|
|
1941
|
+
<a class="icon-btn" href="${esc(editorUrl(site))}" aria-label="Open in VS Code">${svgIcon("external")}</a>` : ""}
|
|
1942
|
+
<span class="cp-name">${esc(displayName(n))}</span>
|
|
1943
|
+
<button class="icon-btn" data-a="close" aria-label="Close">${svgIcon("close")}</button>
|
|
1944
|
+
</div>
|
|
1945
|
+
${tabs.length > 1 ? `<div class="cp-tabs">${tabs.map((t) => `<button class="tab ${t === tab ? "active" : ""}" data-tab="${t.key}">${esc(t.label)}</button>`).join("")}</div>` : ""}
|
|
1946
|
+
<div class="cp-body">${tab.key === "prompt" ? promptHtml(promptOf(n)) : tab.key === "value" ? valueHtml(n) : codeHtml(site)}</div>`;
|
|
1947
|
+
wireValue(codeEl, n);
|
|
1948
|
+
const cp = codeEl.querySelector('[data-a="copy"]');
|
|
1949
|
+
if (cp) cp.addEventListener("click", () => copy(rel, `Copied ${rel}`));
|
|
1950
|
+
codeEl.querySelector('[data-a="close"]').addEventListener("click", closeCode);
|
|
1951
|
+
codeEl.querySelectorAll(".cp-tabs .tab").forEach((b) => b.addEventListener("click", () => toggleCode(n, null, b.dataset.tab)));
|
|
1952
|
+
codeEl.hidden = false;
|
|
1953
|
+
if (reopen) {
|
|
1954
|
+
const x = ev && ev.clientX != null ? ev.clientX : window.innerWidth / 2;
|
|
1955
|
+
const y = ev && ev.clientY != null ? ev.clientY : window.innerHeight / 3;
|
|
1956
|
+
const w = codeEl.offsetWidth;
|
|
1957
|
+
const h = Math.min(codeEl.offsetHeight, window.innerHeight * 0.7);
|
|
1958
|
+
const left = x + 14 + w > window.innerWidth - 8 ? x - w - 14 : x + 14;
|
|
1959
|
+
const top = y + 14 + h > window.innerHeight - 8 ? y - h - 14 : y + 14;
|
|
1960
|
+
codeEl.style.left = `${Math.max(8, left)}px`;
|
|
1961
|
+
codeEl.style.top = `${Math.max(8, top)}px`;
|
|
1962
|
+
}
|
|
1963
|
+
const at = codeEl.querySelector(".cl.at");
|
|
1964
|
+
if (at) at.scrollIntoView({ block: "center" });
|
|
1965
|
+
if (codeAway) document.removeEventListener("mousedown", codeAway, true);
|
|
1966
|
+
codeAway = (e) => {
|
|
1967
|
+
if (!codeEl.contains(e.target) && !e.target.closest(".code-btn, [data-show-code], [data-show-value]")) closeCode();
|
|
1968
|
+
};
|
|
1969
|
+
setTimeout(() => codeAway && document.addEventListener("mousedown", codeAway, true), 0);
|
|
1970
|
+
}
|
|
1971
|
+
function closeCode() {
|
|
1972
|
+
codeFor = null;
|
|
1973
|
+
codeEl.hidden = true;
|
|
1974
|
+
if (codeAway) document.removeEventListener("mousedown", codeAway, true);
|
|
1975
|
+
codeAway = null;
|
|
1976
|
+
}
|
|
1977
|
+
let tipTimer = 0;
|
|
1978
|
+
let hovered = null;
|
|
1979
|
+
/** The file worth opening for `n` (see {@link userSite}), relative to the project root — no line. */
|
|
1980
|
+
function sourceOf(n) {
|
|
1981
|
+
const site = userSite(n);
|
|
1982
|
+
return site ? relOf(site) : "";
|
|
1983
|
+
}
|
|
1984
|
+
function copySource(n) {
|
|
1985
|
+
const src = n && sourceOf(n);
|
|
1986
|
+
if (src) copy(src, `Copied ${src}`);
|
|
1987
|
+
}
|
|
1988
|
+
let lastEv = null;
|
|
1989
|
+
function showTip(ev, html, wide, delay) {
|
|
1990
|
+
clearTimeout(tipTimer);
|
|
1991
|
+
lastEv = ev;
|
|
1992
|
+
const open = () => {
|
|
1993
|
+
tip.innerHTML = html;
|
|
1994
|
+
tip.classList.toggle("wide", !!wide);
|
|
1995
|
+
tip.hidden = false;
|
|
1996
|
+
moveTip(lastEv);
|
|
1997
|
+
};
|
|
1998
|
+
if (delay && tip.hidden) tipTimer = setTimeout(open, delay);
|
|
1999
|
+
else open();
|
|
2000
|
+
}
|
|
2001
|
+
function moveTip(ev) {
|
|
2002
|
+
lastEv = ev;
|
|
2003
|
+
if (tip.hidden) return;
|
|
2004
|
+
const pad = 14;
|
|
2005
|
+
const r = tip.getBoundingClientRect();
|
|
2006
|
+
let x = ev.clientX + pad;
|
|
2007
|
+
let y = ev.clientY + pad;
|
|
2008
|
+
if (x + r.width > window.innerWidth - 8) x = ev.clientX - r.width - pad;
|
|
2009
|
+
if (y + r.height > window.innerHeight - 8) y = ev.clientY - r.height - pad;
|
|
2010
|
+
tip.style.left = `${x}px`;
|
|
2011
|
+
tip.style.top = `${y}px`;
|
|
2012
|
+
}
|
|
2013
|
+
function hideTip() {
|
|
2014
|
+
hovered = null;
|
|
2015
|
+
clearTimeout(tipTimer);
|
|
2016
|
+
tip.hidden = true;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
// ── render orchestration ──
|
|
2020
|
+
function renderAll() {
|
|
2021
|
+
measure();
|
|
2022
|
+
if (state.view === "timeline") renderTimeline();
|
|
2023
|
+
if (state.view === "flame") renderFlame();
|
|
2024
|
+
if (state.view === "flow") renderFlow();
|
|
2025
|
+
renderOverview();
|
|
2026
|
+
}
|
|
2027
|
+
function relayout() {
|
|
2028
|
+
const keep = state.zoom;
|
|
2029
|
+
measure();
|
|
2030
|
+
state.zoom = keep;
|
|
2031
|
+
renderAll();
|
|
2032
|
+
}
|
|
2033
|
+
let resizeTimer = 0;
|
|
2034
|
+
window.addEventListener("resize", () => {
|
|
2035
|
+
clearTimeout(resizeTimer);
|
|
2036
|
+
resizeTimer = setTimeout(relayout, 120);
|
|
2037
|
+
});
|
|
2038
|
+
document.addEventListener("keydown", (e) => {
|
|
2039
|
+
if (e.key === "Escape" && codeFor) return closeCode();
|
|
2040
|
+
if (e.target.closest && e.target.closest("input")) return;
|
|
2041
|
+
if (e.key === "+" || e.key === "=") zoomBy(1.6);
|
|
2042
|
+
else if (e.key === "-") zoomBy(1 / 1.6);
|
|
2043
|
+
else if (e.key === "0") fit();
|
|
2044
|
+
else if ((e.key === "c" || e.key === "C") && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
2045
|
+
copySource(hovered || model.byId.get(state.selected));
|
|
2046
|
+
}
|
|
2047
|
+
else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
2048
|
+
const rows = visible();
|
|
2049
|
+
const i = rows.findIndex((n) => n.id === state.selected);
|
|
2050
|
+
const next = rows[Math.max(0, Math.min(rows.length - 1, i + (e.key === "ArrowDown" ? 1 : -1)))];
|
|
2051
|
+
if (next) {
|
|
2052
|
+
e.preventDefault();
|
|
2053
|
+
select(next.id);
|
|
2054
|
+
revealRow(next);
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
});
|
|
2058
|
+
|
|
2059
|
+
setView("timeline");
|
|
2060
|
+
renderDetails();
|
|
2061
|
+
renderLower();
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
// ── shell markup ─────────────────────────────────────────
|
|
2065
|
+
function shellHtml(model) {
|
|
2066
|
+
const run = model.run;
|
|
2067
|
+
const ok = run.ok;
|
|
2068
|
+
const a = run.assertions;
|
|
2069
|
+
const total = a.passed + a.failed;
|
|
2070
|
+
const assertText = total ? `${a.passed} / ${total} assertions passed` : "no assertions";
|
|
2071
|
+
const serious = model.errors.filter((e) => e.type !== "output").length;
|
|
2072
|
+
const output = model.errors.length - serious;
|
|
2073
|
+
const parts = [
|
|
2074
|
+
serious ? `${serious} error${serious > 1 ? "s" : ""}` : "",
|
|
2075
|
+
output ? `${output} error output` : "",
|
|
2076
|
+
].filter(Boolean);
|
|
2077
|
+
const fails = parts.length
|
|
2078
|
+
? `<span class="sep">•</span><button class="linkish ${serious ? "bad" : "warn"}" id="go-errors" title="Open the Errors tab and highlight them in the graphs">${parts.join(" · ")}</button>`
|
|
2079
|
+
: "";
|
|
2080
|
+
const windows = [0, 100, 500, 1000, 5000, 10000, 30000, 60000, 300000].filter((w) => w === 0 || w < model.span);
|
|
2081
|
+
return `
|
|
2082
|
+
<header class="top">
|
|
2083
|
+
<div class="titles">
|
|
2084
|
+
<div class="title-row">
|
|
2085
|
+
<h1 title="${esc(model.data.folder)}">Run: ${esc(model.data.name)}</h1>
|
|
2086
|
+
<button class="info-btn" id="om-info" aria-label="About this om">${svgIcon("info")}</button>
|
|
2087
|
+
</div>
|
|
2088
|
+
<div class="meta"><span class="dot ${ok ? "ok" : "failed"}"></span><span>${ok ? "passed" : "failed"}</span>
|
|
2089
|
+
<span class="sep">•</span><span>${fmtDate(run.startedAt)}</span>
|
|
2090
|
+
<span class="sep">•</span><span title="body ${fmtDur(run.duration)} · record ${fmtDur(model.span)}">${fmtDur(run.duration)}</span>
|
|
2091
|
+
<span class="sep">•</span><span>${model.nodes.length} action${model.nodes.length > 1 ? "s" : ""}</span>
|
|
2092
|
+
<span class="sep">•</span><span>${assertText}</span>${fails}
|
|
2093
|
+
</div>
|
|
2094
|
+
</div>
|
|
2095
|
+
<div class="btn-group">
|
|
2096
|
+
<button class="btn active" data-view="timeline">${svgIcon("timeline")}Timeline + Graph</button>
|
|
2097
|
+
<button class="btn" data-view="flame">${svgIcon("flame")}Flame Graph</button>
|
|
2098
|
+
<button class="btn" data-view="flow">${svgIcon("flow")}Flow</button>
|
|
2099
|
+
</div>
|
|
2100
|
+
<div class="btn-group">
|
|
2101
|
+
<button class="btn" id="zout" title="Zoom out (-)">${svgIcon("zoomOut")}</button>
|
|
2102
|
+
<button class="btn" id="zin" title="Zoom in (+)">${svgIcon("zoomIn")}</button>
|
|
2103
|
+
<button class="btn" id="fit" title="Fit (0)">Fit</button>
|
|
2104
|
+
</div>
|
|
2105
|
+
<select id="window" class="btn solo" title="Visible window">
|
|
2106
|
+
${windows.map((w) => `<option value="${w}">${w ? fmtTick(w) : "window"}</option>`).join("")}
|
|
2107
|
+
</select>
|
|
2108
|
+
<button class="btn solo" id="more" title="More">${svgIcon("more")}</button>
|
|
2109
|
+
</header>
|
|
2110
|
+
<div class="main">
|
|
2111
|
+
<section class="card timeline" style="--tree-w:420px;--row-h:${ROW_H}px;position:relative">
|
|
2112
|
+
<div class="tl-head">
|
|
2113
|
+
<div class="cols"><div>Action / Event</div><div>Status</div><div>Duration</div></div>
|
|
2114
|
+
<svg></svg>
|
|
2115
|
+
</div>
|
|
2116
|
+
<div class="tl-body"><div class="tree"></div><div class="chart"></div></div>
|
|
2117
|
+
<div class="flame-view" hidden style="top:34px"></div>
|
|
2118
|
+
<div class="flow-view" hidden></div>
|
|
2119
|
+
</section>
|
|
2120
|
+
<aside class="card details"></aside>
|
|
2121
|
+
</div>
|
|
2122
|
+
<div class="bottom">
|
|
2123
|
+
<section class="card lower"></section>
|
|
2124
|
+
<section class="card legend">
|
|
2125
|
+
<h3>Legend</h3>
|
|
2126
|
+
<div class="grid2">
|
|
2127
|
+
${legendItem("--c1", "Action run", "bar")}
|
|
2128
|
+
${legendItem("--c2", "Snapshot", "dot")}
|
|
2129
|
+
${legendItem("--c3", "Process event", "dot")}
|
|
2130
|
+
${legendItem("--c6", "Artifact", "dot")}
|
|
2131
|
+
${legendItem("--ok", "Assert passed", "dot")}
|
|
2132
|
+
${legendItem("--fail", "Failure / error", "dot")}
|
|
2133
|
+
${legendItem("--cancel", "Cancelled", "dash")}
|
|
2134
|
+
${legendItem("--accent", "Pinned time", "line")}
|
|
2135
|
+
</div>
|
|
2136
|
+
<div class="muted" style="margin-top:12px;font-size:11px">Ctrl + wheel to zoom · drag to pan · double-click a bar to focus · ↑/↓ to step</div>
|
|
2137
|
+
</section>
|
|
2138
|
+
<section class="card overview"><h3>Overview</h3><div class="range"></div><svg></svg></section>
|
|
2139
|
+
</div>`;
|
|
2140
|
+
}
|
|
2141
|
+
function legendItem(color, label, shape) {
|
|
2142
|
+
const c = `var(${color})`;
|
|
2143
|
+
const sw =
|
|
2144
|
+
shape === "bar"
|
|
2145
|
+
? `<span style="width:16px;height:9px;border-radius:3px;background:${c}"></span>`
|
|
2146
|
+
: shape === "dash"
|
|
2147
|
+
? `<span style="width:16px;height:0;border-top:2px dashed ${c}"></span>`
|
|
2148
|
+
: shape === "line"
|
|
2149
|
+
? `<span style="width:2px;height:14px;background:${c};margin:0 7px"></span>`
|
|
2150
|
+
: `<span class="dot" style="background:${c};width:11px;height:11px"></span>`;
|
|
2151
|
+
return `<div class="li">${sw}${esc(label)}</div>`;
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
// ── helpers ──────────────────────────────────────────────
|
|
2155
|
+
function colorOf(n) {
|
|
2156
|
+
return n.branch < 0 ? css("--c1") : css(PALETTE[n.branch]);
|
|
2157
|
+
}
|
|
2158
|
+
function statusVar(s) {
|
|
2159
|
+
return s === "ok" ? "--ok" : s === "failed" ? "--fail" : s === "cancelled" ? "--cancel" : "--running";
|
|
2160
|
+
}
|
|
2161
|
+
function alpha(color, a) {
|
|
2162
|
+
const c = d3.color(color);
|
|
2163
|
+
if (!c) return color;
|
|
2164
|
+
c.opacity = a;
|
|
2165
|
+
return c.formatRgb();
|
|
2166
|
+
}
|
|
2167
|
+
const KEYWORDS = new Set(
|
|
2168
|
+
"abstract as async await break case catch class const continue default delete do else enum export extends false finally for from function get if implements import in instanceof interface keyof let new null of private protected public readonly return satisfies set static super switch this throw true try type typeof undefined var void while yield".split(" ")
|
|
2169
|
+
);
|
|
2170
|
+
const TOKEN =
|
|
2171
|
+
/(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|(`(?:\\[\s\S]|[^`\\])*`|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*')|(\b\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?n?\b)|([A-Za-z_$][\w$]*)/g;
|
|
2172
|
+
/** Highlighted HTML per source line. Tokens that span lines are closed and reopened at each break. */
|
|
2173
|
+
function highlight(text) {
|
|
2174
|
+
const parts = [];
|
|
2175
|
+
let last = 0;
|
|
2176
|
+
let m;
|
|
2177
|
+
TOKEN.lastIndex = 0;
|
|
2178
|
+
while ((m = TOKEN.exec(text))) {
|
|
2179
|
+
if (m.index > last) parts.push([null, text.slice(last, m.index)]);
|
|
2180
|
+
let cls = null;
|
|
2181
|
+
if (m[1]) cls = "tk-c";
|
|
2182
|
+
else if (m[2]) cls = /^\s*:/.test(text.slice(TOKEN.lastIndex, TOKEN.lastIndex + 8)) ? "tk-p" : "tk-s";
|
|
2183
|
+
else if (m[3]) cls = "tk-n";
|
|
2184
|
+
else if (m[4]) {
|
|
2185
|
+
if (KEYWORDS.has(m[4])) cls = "tk-k";
|
|
2186
|
+
else if (/^\s*\(/.test(text.slice(TOKEN.lastIndex, TOKEN.lastIndex + 40))) cls = "tk-f";
|
|
2187
|
+
else if (/^[A-Z]/.test(m[4])) cls = "tk-t";
|
|
2188
|
+
}
|
|
2189
|
+
parts.push([cls, m[0]]);
|
|
2190
|
+
last = TOKEN.lastIndex;
|
|
2191
|
+
}
|
|
2192
|
+
if (last < text.length) parts.push([null, text.slice(last)]);
|
|
2193
|
+
const lines = [""];
|
|
2194
|
+
for (const [cls, chunk] of parts) {
|
|
2195
|
+
chunk.split("\n").forEach((piece, i) => {
|
|
2196
|
+
if (i > 0) lines.push("");
|
|
2197
|
+
if (piece) lines[lines.length - 1] += cls ? `<span class="${cls}">${esc(piece)}</span>` : esc(piece);
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
return lines;
|
|
2201
|
+
}
|
|
2202
|
+
function oneLine(v) {
|
|
2203
|
+
try {
|
|
2204
|
+
return typeof v === "string" ? v : JSON.stringify(v);
|
|
2205
|
+
} catch {
|
|
2206
|
+
return String(v);
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
/** Top-level fields of a JSON Schema object: name, type, required, default, description. */
|
|
2210
|
+
function schemaFields(schema) {
|
|
2211
|
+
if (!schema || typeof schema !== "object" || !schema.properties) return [];
|
|
2212
|
+
const req = new Set(schema.required || []);
|
|
2213
|
+
return Object.entries(schema.properties).map(([name, p]) => ({
|
|
2214
|
+
name,
|
|
2215
|
+
type: typeOf(p),
|
|
2216
|
+
required: req.has(name) && !(p && "default" in p),
|
|
2217
|
+
def: p && "default" in p ? oneLine(p.default) : undefined,
|
|
2218
|
+
desc: p && p.description,
|
|
2219
|
+
}));
|
|
2220
|
+
}
|
|
2221
|
+
function typeOf(p) {
|
|
2222
|
+
if (!p || typeof p !== "object") return "any";
|
|
2223
|
+
if (p.enum) return p.enum.map((e) => JSON.stringify(e)).join(" | ");
|
|
2224
|
+
if (p.const !== undefined) return JSON.stringify(p.const);
|
|
2225
|
+
if (Array.isArray(p.type)) return p.type.join(" | ");
|
|
2226
|
+
if (p.type === "array") return `${typeOf(p.items)}[]`;
|
|
2227
|
+
if (p.type) return p.type;
|
|
2228
|
+
if (p.anyOf || p.oneOf) return (p.anyOf || p.oneOf).map(typeOf).join(" | ");
|
|
2229
|
+
return "any";
|
|
2230
|
+
}
|
|
2231
|
+
function paramHtml(f) {
|
|
2232
|
+
return `<div class="param"><span class="pn">${esc(f.name)}${f.required ? "" : "?"}</span><span class="pt">${esc(truncate(f.type, 40))}</span>${
|
|
2233
|
+
f.def !== undefined ? `<span class="pd">= ${esc(truncate(f.def, 24))}</span>` : ""
|
|
2234
|
+
}${f.desc ? `<div class="pdesc">${esc(f.desc)}</div>` : ""}</div>`;
|
|
2235
|
+
}
|
|
2236
|
+
/**
|
|
2237
|
+
* What a `prompt` battery node asked and got back: its options (the node's one argument) plus
|
|
2238
|
+
* the `prompt` / `answer` events it emitted. `null` for anything that is not a prompt.
|
|
2239
|
+
*/
|
|
2240
|
+
function promptOf(n) {
|
|
2241
|
+
if (n._prompt !== undefined) return n._prompt;
|
|
2242
|
+
n._prompt = null;
|
|
2243
|
+
if (n.name !== "prompt" || n.depth === 0) return null;
|
|
2244
|
+
const opts = (n.v.args && n.v.args[0] && typeof n.v.args[0] === "object" ? n.v.args[0] : {}) || {};
|
|
2245
|
+
let message = opts.message;
|
|
2246
|
+
let answer = null;
|
|
2247
|
+
for (const it of n.entries) {
|
|
2248
|
+
if (it.e.level !== "event") continue;
|
|
2249
|
+
if (it.e.message.startsWith("prompt · ")) message = it.e.message.slice("prompt · ".length);
|
|
2250
|
+
else if (it.e.message.startsWith("answer · ")) {
|
|
2251
|
+
try {
|
|
2252
|
+
answer = JSON.parse(it.e.message.slice("answer · ".length));
|
|
2253
|
+
} catch {
|
|
2254
|
+
answer = { value: it.e.message.slice("answer · ".length) };
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
if (message === undefined && !answer) return null;
|
|
2259
|
+
n._prompt = {
|
|
2260
|
+
message,
|
|
2261
|
+
kind: opts.kind || "input",
|
|
2262
|
+
hint: opts.hint,
|
|
2263
|
+
choices: opts.choices,
|
|
2264
|
+
def: opts.default,
|
|
2265
|
+
timeoutMs: opts.timeoutMs,
|
|
2266
|
+
answer,
|
|
2267
|
+
};
|
|
2268
|
+
return n._prompt;
|
|
2269
|
+
}
|
|
2270
|
+
/** What a node is, for a card's subtitle: the om, an inline step, a prompt, or an action. */
|
|
2271
|
+
function nodeKind(n) {
|
|
2272
|
+
if (n.depth === 0) return "om";
|
|
2273
|
+
if (promptOf(n)) return "prompt";
|
|
2274
|
+
return n.v.kind === "step" ? "step" : "action";
|
|
2275
|
+
}
|
|
2276
|
+
/** A name broken into at most two lines of `max` characters, at word boundaries when it can. */
|
|
2277
|
+
function wrapName(name, max) {
|
|
2278
|
+
if (name.length <= max) return [name];
|
|
2279
|
+
let cut = name.lastIndexOf(" ", max);
|
|
2280
|
+
if (cut < max * 0.5) cut = max;
|
|
2281
|
+
const first = name.slice(0, cut).trimEnd();
|
|
2282
|
+
return [first, truncate(name.slice(cut).trimStart(), max)];
|
|
2283
|
+
}
|
|
2284
|
+
function truncate(s, n) {
|
|
2285
|
+
n = Math.max(1, Math.floor(n));
|
|
2286
|
+
return s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s;
|
|
2287
|
+
}
|
|
2288
|
+
function displayName(n) {
|
|
2289
|
+
if (n.depth === 0) return "main";
|
|
2290
|
+
const p = promptOf(n);
|
|
2291
|
+
if (p && p.message) return `prompt: ${truncate(p.message, 48)}`;
|
|
2292
|
+
const tag = n.v.tags && n.v.tags[0];
|
|
2293
|
+
// Battery steps are named after their kind (`command`, `chromePage`); their first tag
|
|
2294
|
+
// is what the om called them, which is the more useful label.
|
|
2295
|
+
return tag && tag !== n.name && n.name.length > 28 ? tag : n.name;
|
|
2296
|
+
}
|
|
2297
|
+
function iconOf(n) {
|
|
2298
|
+
if (n.depth === 0) return "root";
|
|
2299
|
+
const s = `${n.name} ${(n.v.tags || []).join(" ")}`.toLowerCase();
|
|
2300
|
+
if (/^(npm|npx|node|pnpm|yarn|tsc|git|bun)\b/.test(n.name) || /command|shell|exec/.test(s)) return "terminal";
|
|
2301
|
+
if (/chromepage|page:|tab/.test(s)) return "window";
|
|
2302
|
+
if (/browser|chrome/.test(s)) return "globe";
|
|
2303
|
+
if (/health|ready|gate|wait|port/.test(s)) return "pulse";
|
|
2304
|
+
if (/drive|inspect|click/.test(s)) return "cursor";
|
|
2305
|
+
if (/test|assert|validate/.test(s)) return "flask";
|
|
2306
|
+
if (/snapshot|screenshot/.test(s)) return "camera";
|
|
2307
|
+
if (/prompt|ask/.test(s)) return "chat";
|
|
2308
|
+
if (/build|compile|bundle/.test(s)) return "box";
|
|
2309
|
+
return "play";
|
|
2310
|
+
}
|
|
2311
|
+
function copy(text, message) {
|
|
2312
|
+
const done = () => toast(message || "Copied");
|
|
2313
|
+
if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text).then(done, () => fallbackCopy(text, message));
|
|
2314
|
+
else fallbackCopy(text, message);
|
|
2315
|
+
}
|
|
2316
|
+
function fallbackCopy(text, message) {
|
|
2317
|
+
const t = document.createElement("textarea");
|
|
2318
|
+
t.value = text;
|
|
2319
|
+
document.body.appendChild(t);
|
|
2320
|
+
t.select();
|
|
2321
|
+
try {
|
|
2322
|
+
document.execCommand("copy");
|
|
2323
|
+
toast(message || "Copied");
|
|
2324
|
+
} catch {
|
|
2325
|
+
toast(text);
|
|
2326
|
+
}
|
|
2327
|
+
t.remove();
|
|
2328
|
+
}
|
|
2329
|
+
function toast(text) {
|
|
2330
|
+
const t = document.createElement("div");
|
|
2331
|
+
t.className = "toast";
|
|
2332
|
+
t.textContent = text;
|
|
2333
|
+
document.body.appendChild(t);
|
|
2334
|
+
setTimeout(() => t.remove(), 1400);
|
|
2335
|
+
}
|
|
2336
|
+
function download(name, text, mime) {
|
|
2337
|
+
const a = document.createElement("a");
|
|
2338
|
+
a.href = URL.createObjectURL(new Blob([text], { type: mime }));
|
|
2339
|
+
a.download = name;
|
|
2340
|
+
a.click();
|
|
2341
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
2342
|
+
}
|
|
2343
|
+
function toggleTheme() {
|
|
2344
|
+
const root = document.documentElement;
|
|
2345
|
+
const dark = root.dataset.theme ? root.dataset.theme === "dark" : !matchMedia("(prefers-color-scheme: light)").matches;
|
|
2346
|
+
root.dataset.theme = dark ? "light" : "dark";
|
|
2347
|
+
try {
|
|
2348
|
+
localStorage.setItem("omkit-report-theme", root.dataset.theme);
|
|
2349
|
+
} catch {
|
|
2350
|
+
/* storage may be unavailable on file:// */
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
// ── boot ─────────────────────────────────────────────────
|
|
2355
|
+
const frame = () => new Promise((r) => requestAnimationFrame(() => setTimeout(r, 0)));
|
|
2356
|
+
const step = (t) => {
|
|
2357
|
+
const s = document.getElementById("loader-step");
|
|
2358
|
+
if (s) s.textContent = t;
|
|
2359
|
+
};
|
|
2360
|
+
async function boot() {
|
|
2361
|
+
try {
|
|
2362
|
+
const saved = localStorage.getItem("omkit-report-theme");
|
|
2363
|
+
if (saved) document.documentElement.dataset.theme = saved;
|
|
2364
|
+
} catch {
|
|
2365
|
+
/* ignore */
|
|
2366
|
+
}
|
|
2367
|
+
await frame();
|
|
2368
|
+
step("Parsing the run record…");
|
|
2369
|
+
await frame();
|
|
2370
|
+
const data = JSON.parse(document.getElementById("omkit-data").textContent);
|
|
2371
|
+
step(`Building ${data.run ? "the timeline" : "the view"}…`);
|
|
2372
|
+
await frame();
|
|
2373
|
+
const model = buildModel(data);
|
|
2374
|
+
const app = document.getElementById("app");
|
|
2375
|
+
app.hidden = false;
|
|
2376
|
+
App(model);
|
|
2377
|
+
await frame();
|
|
2378
|
+
const loader = document.getElementById("loader");
|
|
2379
|
+
loader.style.transition = "opacity 0.2s";
|
|
2380
|
+
loader.style.opacity = "0";
|
|
2381
|
+
setTimeout(() => loader.remove(), 220);
|
|
2382
|
+
}
|
|
2383
|
+
boot().catch((err) => {
|
|
2384
|
+
step(`Could not render this run: ${err && err.message ? err.message : err}`);
|
|
2385
|
+
console.error(err);
|
|
2386
|
+
});
|
|
2387
|
+
})();
|