omkit 0.8.9 → 0.8.10
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 +411 -410
- package/dist/core/ExecutionTree.d.ts +4 -0
- package/dist/core/ExecutionTree.js +26 -1
- package/dist/output/writers/ReportWriter.d.ts +16 -0
- package/dist/output/writers/ReportWriter.js +238 -0
- package/dist/output/writers/report/report.css +272 -0
- package/dist/output/writers/report/report.js +1385 -0
- package/dist/skill/render.js +2 -1
- package/package.json +4 -3
|
@@ -0,0 +1,1385 @@
|
|
|
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
|
+
};
|
|
44
|
+
const svgIcon = (name, extra) =>
|
|
45
|
+
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"${
|
|
46
|
+
extra || ""
|
|
47
|
+
}><path d="${ICON[name]}"/></svg>`;
|
|
48
|
+
|
|
49
|
+
const PALETTE = ["--c1", "--c2", "--c3", "--c4", "--c5", "--c6", "--c7", "--c8"];
|
|
50
|
+
const css = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
|
|
51
|
+
|
|
52
|
+
const MARK = {
|
|
53
|
+
event: { color: "--c3", icon: "check", label: "Process event" },
|
|
54
|
+
snapshot: { color: "--c2", icon: "camera", label: "Snapshot" },
|
|
55
|
+
artifact: { color: "--c6", icon: "file", label: "Artifact" },
|
|
56
|
+
pass: { color: "--ok", icon: "check", label: "Assertion passed" },
|
|
57
|
+
fail: { color: "--fail", icon: "alert", label: "Assertion failed" },
|
|
58
|
+
error: { color: "--fail", icon: "x", label: "Error" },
|
|
59
|
+
cancel: { color: "--cancel", icon: "stop", label: "Cancelled" },
|
|
60
|
+
lifecycle: { color: "--c1", icon: "play", label: "Lifecycle" },
|
|
61
|
+
run: { color: "--text-3", icon: "root", label: "Run" },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ── formatting ───────────────────────────────────────────
|
|
65
|
+
const fmtDur = (ms) => {
|
|
66
|
+
if (!isFinite(ms)) return "–";
|
|
67
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
68
|
+
if (ms < 10000) return `${(ms / 1000).toFixed(1)}s`;
|
|
69
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
70
|
+
const m = Math.floor(ms / 60000);
|
|
71
|
+
const s = Math.round((ms % 60000) / 1000);
|
|
72
|
+
return `${m}m ${String(s).padStart(2, "0")}s`;
|
|
73
|
+
};
|
|
74
|
+
const fmtRel = (ms) => `+${fmtDur(Math.max(0, ms)).replace(/^0ms$/, "0ms")}`;
|
|
75
|
+
const pad2 = (n) => String(n).padStart(2, "0");
|
|
76
|
+
const fmtClock = (ts) => {
|
|
77
|
+
const d = new Date(ts);
|
|
78
|
+
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, "0")}`;
|
|
79
|
+
};
|
|
80
|
+
const fmtDate = (ts) => {
|
|
81
|
+
const d = new Date(ts);
|
|
82
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
|
|
83
|
+
};
|
|
84
|
+
const fmtSize = (b) =>
|
|
85
|
+
b < 1024 ? `${b} B` : b < 1048576 ? `${Math.round(b / 1024)} KB` : `${(b / 1048576).toFixed(1)} MB`;
|
|
86
|
+
const fmtTick = (ms) => {
|
|
87
|
+
if (ms === 0) return "0s";
|
|
88
|
+
const a = Math.abs(ms);
|
|
89
|
+
if (a < 1000) return `${Math.round(ms)}ms`;
|
|
90
|
+
if (a < 60000) return `${+(ms / 1000).toFixed(a < 10000 ? 2 : 1)}s`;
|
|
91
|
+
return `${Math.floor(ms / 60000)}m${pad2(Math.round((ms % 60000) / 1000))}s`;
|
|
92
|
+
};
|
|
93
|
+
const esc = (s) =>
|
|
94
|
+
String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
95
|
+
const basename = (p) => String(p).split(/[\\/]/).pop();
|
|
96
|
+
const fileUrl = (p) => "file:///" + String(p).replace(/\\/g, "/").replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/").replace(/^([A-Za-z])%3A/, "$1:");
|
|
97
|
+
const editorUrl = (site) => {
|
|
98
|
+
const m = /^(.*):(\d+)$/.exec(site);
|
|
99
|
+
const file = (m ? m[1] : site).replace(/\\/g, "/");
|
|
100
|
+
return `vscode://file/${file.replace(/^\/+/, "")}${m ? ":" + m[2] : ""}`;
|
|
101
|
+
};
|
|
102
|
+
const json = (v) => {
|
|
103
|
+
try {
|
|
104
|
+
return JSON.stringify(v, null, 2);
|
|
105
|
+
} catch {
|
|
106
|
+
return String(v);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// ── model ────────────────────────────────────────────────
|
|
111
|
+
function buildModel(data) {
|
|
112
|
+
const run = data.run;
|
|
113
|
+
const t0 = run.startedAt;
|
|
114
|
+
const nodes = [];
|
|
115
|
+
const byId = new Map();
|
|
116
|
+
let tEnd = run.endedAt || t0;
|
|
117
|
+
let topIndex = 0;
|
|
118
|
+
(function walk(v, parent, depth, branch) {
|
|
119
|
+
const isTop = depth === 1;
|
|
120
|
+
const b = depth === 0 ? 0 : isTop ? topIndex++ % PALETTE.length : branch;
|
|
121
|
+
const n = {
|
|
122
|
+
v,
|
|
123
|
+
id: v.id,
|
|
124
|
+
name: v.name,
|
|
125
|
+
parent,
|
|
126
|
+
depth,
|
|
127
|
+
branch: depth === 0 ? -1 : b,
|
|
128
|
+
children: [],
|
|
129
|
+
status: ["ok", "failed", "cancelled"].includes(v.status) ? v.status : v.endedAt ? v.status : "running",
|
|
130
|
+
startAbs: v.startedAt || t0,
|
|
131
|
+
endAbs: v.endedAt || 0,
|
|
132
|
+
entries: [],
|
|
133
|
+
marks: [],
|
|
134
|
+
};
|
|
135
|
+
nodes.push(n);
|
|
136
|
+
byId.set(n.id, n);
|
|
137
|
+
if (parent) parent.children.push(n);
|
|
138
|
+
if (n.endAbs > tEnd) tEnd = n.endAbs;
|
|
139
|
+
for (const c of v.children || []) walk(c, n, depth + 1, b);
|
|
140
|
+
})(run.root, null, 0, 0);
|
|
141
|
+
|
|
142
|
+
for (const e of data.entries) if (e.ts > tEnd) tEnd = e.ts;
|
|
143
|
+
for (const n of nodes) {
|
|
144
|
+
if (!n.endAbs) n.endAbs = tEnd;
|
|
145
|
+
n.start = n.startAbs - t0;
|
|
146
|
+
n.end = Math.max(n.start, n.endAbs - t0);
|
|
147
|
+
n.duration = n.v.duration || n.end - n.start;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const items = [];
|
|
151
|
+
const runEntries = [];
|
|
152
|
+
for (const e of data.entries) {
|
|
153
|
+
if (e.level === "child" || e.level === "tag") continue;
|
|
154
|
+
const kind = kindOf(e);
|
|
155
|
+
const item = { idx: items.length, e, kind, t: e.ts - t0, label: labelOf(e, kind), node: byId.get(e.nodeId) || null };
|
|
156
|
+
items.push(item);
|
|
157
|
+
if (item.node) {
|
|
158
|
+
item.node.entries.push(item);
|
|
159
|
+
if (kind !== "info" && kind !== "lifecycle") item.node.marks.push(item);
|
|
160
|
+
} else runEntries.push(item);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const assets = new Map();
|
|
164
|
+
const gallery = [];
|
|
165
|
+
for (const f of data.files) {
|
|
166
|
+
gallery.push({
|
|
167
|
+
type: "file",
|
|
168
|
+
f,
|
|
169
|
+
name: f.name,
|
|
170
|
+
t: f.ts ? f.ts - t0 : null,
|
|
171
|
+
node: f.nodeId ? byId.get(f.nodeId) || null : null,
|
|
172
|
+
image: f.mime.startsWith("image/") && !!f.asset,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
for (const s of data.snapshots) {
|
|
176
|
+
gallery.push({ type: "snapshot", s, name: s.name, t: s.ts - t0, node: byId.get(s.nodeId) || null, image: false });
|
|
177
|
+
}
|
|
178
|
+
gallery.sort((a, b) => (a.t ?? Infinity) - (b.t ?? Infinity));
|
|
179
|
+
// Let a mark find its gallery item (snapshot by file, artifact by file).
|
|
180
|
+
const galleryByFile = new Map();
|
|
181
|
+
for (const g of gallery) galleryByFile.set(String(g.type === "file" ? g.f.file : g.s.file).toLowerCase(), g);
|
|
182
|
+
for (const it of items) {
|
|
183
|
+
if (it.kind === "snapshot" || it.kind === "artifact") {
|
|
184
|
+
const i = it.e.message.lastIndexOf(" → ");
|
|
185
|
+
if (i >= 0) it.gallery = galleryByFile.get(it.e.message.slice(i + 3).toLowerCase()) || null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const asserts = items.filter((i) => i.kind === "pass" || i.kind === "fail");
|
|
190
|
+
return {
|
|
191
|
+
data,
|
|
192
|
+
run,
|
|
193
|
+
t0,
|
|
194
|
+
span: Math.max(1, tEnd - t0),
|
|
195
|
+
nodes,
|
|
196
|
+
byId,
|
|
197
|
+
root: nodes[0],
|
|
198
|
+
items,
|
|
199
|
+
runEntries,
|
|
200
|
+
gallery,
|
|
201
|
+
asserts,
|
|
202
|
+
assets,
|
|
203
|
+
asset(id) {
|
|
204
|
+
if (!assets.has(id)) {
|
|
205
|
+
const el = document.getElementById(id);
|
|
206
|
+
assets.set(id, el ? `data:${el.dataset.mime};base64,${el.textContent.trim()}` : "");
|
|
207
|
+
}
|
|
208
|
+
return assets.get(id);
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function kindOf(e) {
|
|
214
|
+
if (e.level === "assert") return e.message.startsWith("⊭") ? "fail" : "pass";
|
|
215
|
+
if (e.level === "snapshot") return "snapshot";
|
|
216
|
+
if (e.level === "artifact") return "artifact";
|
|
217
|
+
if (e.level === "error") return "error";
|
|
218
|
+
if (e.level === "run") return "run";
|
|
219
|
+
if (e.level === "event" && e.source === "cancel") return "cancel";
|
|
220
|
+
if (e.level === "event" && e.source === "lifecycle") return "lifecycle";
|
|
221
|
+
if (e.level === "event") return "event";
|
|
222
|
+
return "info";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function labelOf(e, kind) {
|
|
226
|
+
const m = e.message;
|
|
227
|
+
if (kind === "snapshot" || kind === "artifact") {
|
|
228
|
+
const i = m.lastIndexOf(" → ");
|
|
229
|
+
return i >= 0 ? m.slice(0, i) : m;
|
|
230
|
+
}
|
|
231
|
+
if (kind === "pass" || kind === "fail") return m.replace(/^[⊨⊭]\s*(pass|FAIL)\s*·\s*/, "");
|
|
232
|
+
if (kind === "error") return m.split("\n")[0];
|
|
233
|
+
if (kind === "event") {
|
|
234
|
+
// Command events repeat their source as a prefix — drop it.
|
|
235
|
+
let s = m.startsWith(e.source + " · ") ? m.slice(e.source.length + 3) : m;
|
|
236
|
+
s = s.split(" · ")[0];
|
|
237
|
+
return s;
|
|
238
|
+
}
|
|
239
|
+
return m;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── app ──────────────────────────────────────────────────
|
|
243
|
+
function App(model) {
|
|
244
|
+
const app = document.getElementById("app");
|
|
245
|
+
const state = {
|
|
246
|
+
view: "timeline",
|
|
247
|
+
selected: model.root.id,
|
|
248
|
+
collapsed: new Set(),
|
|
249
|
+
zoom: d3.zoomIdentity,
|
|
250
|
+
detailTab: "overview",
|
|
251
|
+
lowerTab: "snapshots",
|
|
252
|
+
filter: "",
|
|
253
|
+
pin: null,
|
|
254
|
+
details: true,
|
|
255
|
+
};
|
|
256
|
+
// Deep, wide runs start collapsed below the second level so the first screen stays readable.
|
|
257
|
+
if (model.nodes.length > 60) for (const n of model.nodes) if (n.depth >= 2 && n.children.length) state.collapsed.add(n.id);
|
|
258
|
+
|
|
259
|
+
app.innerHTML = shellHtml(model);
|
|
260
|
+
const el = {
|
|
261
|
+
main: app.querySelector(".main"),
|
|
262
|
+
timeline: app.querySelector(".timeline"),
|
|
263
|
+
head: app.querySelector(".tl-head"),
|
|
264
|
+
axis: app.querySelector(".tl-head svg"),
|
|
265
|
+
body: app.querySelector(".tl-body"),
|
|
266
|
+
tree: app.querySelector(".tree"),
|
|
267
|
+
chart: app.querySelector(".chart"),
|
|
268
|
+
flame: app.querySelector(".flame-view"),
|
|
269
|
+
flow: app.querySelector(".flow-view"),
|
|
270
|
+
details: app.querySelector(".details"),
|
|
271
|
+
lower: app.querySelector(".lower"),
|
|
272
|
+
overview: app.querySelector(".overview svg"),
|
|
273
|
+
range: app.querySelector(".overview .range"),
|
|
274
|
+
windowSel: app.querySelector("#window"),
|
|
275
|
+
};
|
|
276
|
+
const tip = document.createElement("div");
|
|
277
|
+
tip.className = "tip";
|
|
278
|
+
tip.hidden = true;
|
|
279
|
+
document.body.appendChild(tip);
|
|
280
|
+
|
|
281
|
+
let W = 600; // chart pixel width (timeline + flame share the x mapping)
|
|
282
|
+
let baseX = d3.scaleLinear();
|
|
283
|
+
const xz = () => state.zoom.rescaleX(baseX);
|
|
284
|
+
let syncing = false;
|
|
285
|
+
|
|
286
|
+
// ── header controls ──
|
|
287
|
+
app.querySelectorAll("[data-view]").forEach((b) =>
|
|
288
|
+
b.addEventListener("click", () => setView(b.dataset.view))
|
|
289
|
+
);
|
|
290
|
+
app.querySelector("#zin").addEventListener("click", () => zoomBy(1.6));
|
|
291
|
+
app.querySelector("#zout").addEventListener("click", () => zoomBy(1 / 1.6));
|
|
292
|
+
app.querySelector("#fit").addEventListener("click", fit);
|
|
293
|
+
el.windowSel.addEventListener("change", () => {
|
|
294
|
+
const ms = +el.windowSel.value;
|
|
295
|
+
if (!ms) return fit();
|
|
296
|
+
const [d0] = xz().domain();
|
|
297
|
+
zoomToDomain(Math.max(0, d0), Math.max(0, d0) + ms);
|
|
298
|
+
});
|
|
299
|
+
app.querySelector("#more").addEventListener("click", (ev) => openMenu(ev.currentTarget));
|
|
300
|
+
|
|
301
|
+
// ── zoom behaviours ──
|
|
302
|
+
const chartZoom = d3
|
|
303
|
+
.zoom()
|
|
304
|
+
.scaleExtent([1, 5000])
|
|
305
|
+
.filter((e) => (e.type === "wheel" ? e.ctrlKey || e.metaKey : !e.button && e.type !== "dblclick"))
|
|
306
|
+
.on("zoom", (e) => onZoom(e.transform));
|
|
307
|
+
const flameZoom = d3
|
|
308
|
+
.zoom()
|
|
309
|
+
.scaleExtent([1, 5000])
|
|
310
|
+
.filter((e) => !e.button && e.type !== "dblclick")
|
|
311
|
+
.on("zoom", (e) => onZoom(e.transform));
|
|
312
|
+
const flowZoom = d3.zoom().scaleExtent([0.1, 4]).on("zoom", (e) => flowG && flowG.attr("transform", e.transform));
|
|
313
|
+
|
|
314
|
+
function activeZoom() {
|
|
315
|
+
return state.view === "flame"
|
|
316
|
+
? { z: flameZoom, sel: d3.select(el.flame).select("svg") }
|
|
317
|
+
: { z: chartZoom, sel: d3.select(el.chart).select("svg") };
|
|
318
|
+
}
|
|
319
|
+
function onZoom(t) {
|
|
320
|
+
state.zoom = t;
|
|
321
|
+
if (!syncing) {
|
|
322
|
+
syncing = true;
|
|
323
|
+
// keep the other x-view's internal transform in step
|
|
324
|
+
const other = state.view === "flame" ? d3.select(el.chart).select("svg") : d3.select(el.flame).select("svg");
|
|
325
|
+
const oz = state.view === "flame" ? chartZoom : flameZoom;
|
|
326
|
+
if (!other.empty()) other.call(oz.transform, t);
|
|
327
|
+
syncing = false;
|
|
328
|
+
}
|
|
329
|
+
redrawX();
|
|
330
|
+
}
|
|
331
|
+
function zoomBy(k) {
|
|
332
|
+
if (state.view === "flow") return d3.select(el.flow).select("svg").transition().duration(200).call(flowZoom.scaleBy, k);
|
|
333
|
+
const { z, sel } = activeZoom();
|
|
334
|
+
const [cx] = state.pin != null ? [xz()(state.pin)] : [W / 2];
|
|
335
|
+
sel.transition().duration(200).call(z.scaleBy, k, [cx, 0]);
|
|
336
|
+
}
|
|
337
|
+
function fit() {
|
|
338
|
+
if (state.view === "flow") return fitFlow();
|
|
339
|
+
const { z, sel } = activeZoom();
|
|
340
|
+
sel.transition().duration(250).call(z.transform, d3.zoomIdentity);
|
|
341
|
+
}
|
|
342
|
+
function zoomToDomain(a, b) {
|
|
343
|
+
const x0 = baseX(a);
|
|
344
|
+
const x1 = baseX(b);
|
|
345
|
+
if (!(x1 > x0)) return;
|
|
346
|
+
const k = Math.max(1, W / (x1 - x0));
|
|
347
|
+
const t = d3.zoomIdentity.scale(k).translate(-x0, 0);
|
|
348
|
+
const { z, sel } = activeZoom();
|
|
349
|
+
sel.call(z.transform, t);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── layout ──
|
|
353
|
+
function measure() {
|
|
354
|
+
const sb = el.body.offsetWidth - el.body.clientWidth;
|
|
355
|
+
el.head.style.paddingRight = `${sb}px`;
|
|
356
|
+
W = Math.max(200, state.view === "flame" ? el.flame.clientWidth : el.chart.clientWidth);
|
|
357
|
+
const span = model.span;
|
|
358
|
+
baseX = d3.scaleLinear().domain([-span * 0.01, span * 1.015]).range([0, W]);
|
|
359
|
+
for (const z of [chartZoom, flameZoom]) z.translateExtent([[0, -1e6], [W, 1e6]]).extent([[0, 0], [W, 1]]);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function setView(v) {
|
|
363
|
+
state.view = v;
|
|
364
|
+
app.querySelectorAll("[data-view]").forEach((b) => b.classList.toggle("active", b.dataset.view === v));
|
|
365
|
+
el.timeline.classList.toggle("no-tree", v === "flame");
|
|
366
|
+
el.body.hidden = v !== "timeline";
|
|
367
|
+
el.head.hidden = v === "flow";
|
|
368
|
+
el.flame.hidden = v !== "flame";
|
|
369
|
+
el.flow.hidden = v !== "flow";
|
|
370
|
+
el.windowSel.disabled = v === "flow";
|
|
371
|
+
renderAll();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── tree + timeline ──
|
|
375
|
+
function visible() {
|
|
376
|
+
const out = [];
|
|
377
|
+
(function walk(n) {
|
|
378
|
+
out.push(n);
|
|
379
|
+
if (!state.collapsed.has(n.id)) n.children.forEach(walk);
|
|
380
|
+
})(model.root);
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function renderTree(rows) {
|
|
385
|
+
el.tree.innerHTML = rows.map((n) => treeRowHtml(n)).join("");
|
|
386
|
+
el.tree.querySelectorAll(".row").forEach((r) => {
|
|
387
|
+
const n = model.byId.get(r.dataset.id);
|
|
388
|
+
r.addEventListener("click", (ev) => {
|
|
389
|
+
if (ev.target.closest(".twist")) {
|
|
390
|
+
toggle(n);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
select(n.id);
|
|
394
|
+
});
|
|
395
|
+
r.addEventListener("dblclick", () => focusNode(n));
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
function treeRowHtml(n) {
|
|
399
|
+
const guides = Array.from({ length: n.depth }, () => '<span class="guide"></span>').join("");
|
|
400
|
+
const open = !state.collapsed.has(n.id);
|
|
401
|
+
const twist = n.children.length
|
|
402
|
+
? `<button class="twist ${open ? "open" : ""}" title="${open ? "Collapse" : "Expand"}">${svgIcon("chevron")}</button>`
|
|
403
|
+
: '<span class="twist"></span>';
|
|
404
|
+
const color = colorOf(n);
|
|
405
|
+
const rest = (n.v.tags || []).filter((t) => t !== displayName(n));
|
|
406
|
+
const tags = rest.length ? `<span class="tagchip">${esc(rest.join(" · "))}</span>` : "";
|
|
407
|
+
return `<div class="row ${state.selected === n.id ? "selected" : ""}" data-id="${esc(n.id)}" title="${esc(n.v.path)}">
|
|
408
|
+
<div class="name" style="padding-left:${n.depth ? 4 : 10}px">${guides}${twist}
|
|
409
|
+
<span class="icon" style="color:${color};background:${alpha(color, 0.16)}">${svgIcon(iconOf(n))}</span>
|
|
410
|
+
<span class="label">${esc(displayName(n))}</span>${tags}
|
|
411
|
+
</div>
|
|
412
|
+
<div class="status"><span class="dot ${n.status}"></span>${esc(n.status)}</div>
|
|
413
|
+
<div class="dur">${fmtDur(n.duration)}</div>
|
|
414
|
+
</div>`;
|
|
415
|
+
}
|
|
416
|
+
function toggle(n) {
|
|
417
|
+
if (state.collapsed.has(n.id)) state.collapsed.delete(n.id);
|
|
418
|
+
else state.collapsed.add(n.id);
|
|
419
|
+
renderTimeline();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
let chartSvg = null;
|
|
423
|
+
let rowsNow = [];
|
|
424
|
+
function renderTimeline() {
|
|
425
|
+
rowsNow = visible();
|
|
426
|
+
renderTree(rowsNow);
|
|
427
|
+
const H = rowsNow.length * ROW_H + 8;
|
|
428
|
+
d3.select(el.chart).selectAll("svg").remove();
|
|
429
|
+
chartSvg = d3
|
|
430
|
+
.select(el.chart)
|
|
431
|
+
.append("svg")
|
|
432
|
+
.attr("width", W)
|
|
433
|
+
.attr("height", H)
|
|
434
|
+
.on("mousemove", (ev) => moveCursor(d3.pointer(ev)[0]))
|
|
435
|
+
.on("mouseleave", () => moveCursor(null));
|
|
436
|
+
chartSvg.append("g").attr("class", "grid");
|
|
437
|
+
chartSvg.append("g").attr("class", "links");
|
|
438
|
+
chartSvg.append("g").attr("class", "rows");
|
|
439
|
+
chartSvg.append("line").attr("class", "pin").attr("stroke", css("--accent")).attr("stroke-width", 1.5).attr("y1", 0).attr("y2", H);
|
|
440
|
+
chartSvg.append("line").attr("class", "cursor").attr("y1", 0).attr("y2", H).attr("visibility", "hidden");
|
|
441
|
+
chartSvg.call(chartZoom).call(chartZoom.transform, state.zoom);
|
|
442
|
+
redrawX();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function redrawX() {
|
|
446
|
+
const x = xz();
|
|
447
|
+
updateAxis(x);
|
|
448
|
+
updateWindowLabel(x);
|
|
449
|
+
updateOverviewBrush(x);
|
|
450
|
+
if (state.view === "timeline" && chartSvg) drawRows(x);
|
|
451
|
+
if (state.view === "flame") drawFlame(x);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function drawRows(x) {
|
|
455
|
+
const H = rowsNow.length * ROW_H + 8;
|
|
456
|
+
const ticks = x.ticks(Math.max(2, Math.floor(W / 90)));
|
|
457
|
+
chartSvg
|
|
458
|
+
.select(".grid")
|
|
459
|
+
.selectAll("line")
|
|
460
|
+
.data(ticks)
|
|
461
|
+
.join("line")
|
|
462
|
+
.attr("x1", (d) => x(d))
|
|
463
|
+
.attr("x2", (d) => x(d))
|
|
464
|
+
.attr("y1", 0)
|
|
465
|
+
.attr("y2", H);
|
|
466
|
+
const index = new Map(rowsNow.map((n, i) => [n.id, i]));
|
|
467
|
+
// parent → child elbows
|
|
468
|
+
const links = rowsNow.filter((n) => n.parent && index.has(n.parent.id));
|
|
469
|
+
chartSvg
|
|
470
|
+
.select(".links")
|
|
471
|
+
.selectAll("path")
|
|
472
|
+
.data(links, (n) => n.id)
|
|
473
|
+
.join("path")
|
|
474
|
+
.attr("class", "link")
|
|
475
|
+
.attr("d", (n) => {
|
|
476
|
+
const pi = index.get(n.parent.id);
|
|
477
|
+
const ci = index.get(n.id);
|
|
478
|
+
const cx = x(n.start);
|
|
479
|
+
const px = Math.max(x(n.parent.start) + 6, cx - 12);
|
|
480
|
+
const py = pi * ROW_H + BAR_Y + BAR_H;
|
|
481
|
+
const cy = ci * ROW_H + BAR_Y + BAR_H / 2;
|
|
482
|
+
return `M${px},${py}V${cy - 4}Q${px},${cy} ${px + 4},${cy}H${cx}`;
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const g = chartSvg
|
|
486
|
+
.select(".rows")
|
|
487
|
+
.selectAll("g.r")
|
|
488
|
+
.data(rowsNow, (n) => n.id)
|
|
489
|
+
.join((enter) => {
|
|
490
|
+
const r = enter.append("g").attr("class", "r");
|
|
491
|
+
r.append("rect").attr("class", "bar").attr("rx", 5).attr("y", BAR_Y).attr("height", BAR_H);
|
|
492
|
+
r.append("text").attr("class", "bar-label").attr("y", BAR_Y + BAR_H / 2 + 4);
|
|
493
|
+
r.append("g").attr("class", "marks");
|
|
494
|
+
return r;
|
|
495
|
+
})
|
|
496
|
+
.attr("transform", (_n, i) => `translate(0,${i * ROW_H})`);
|
|
497
|
+
|
|
498
|
+
g.select("rect.bar")
|
|
499
|
+
.attr("x", (n) => x(n.start))
|
|
500
|
+
.attr("width", (n) => Math.max(3, x(n.end) - x(n.start)))
|
|
501
|
+
.attr("fill", (n) => colorOf(n))
|
|
502
|
+
.attr("fill-opacity", (n) => (n.status === "cancelled" ? 0.55 : 0.92))
|
|
503
|
+
.attr("stroke", (n) => (n.status === "failed" ? css("--fail") : null))
|
|
504
|
+
.attr("stroke-width", (n) => (n.status === "failed" ? 2 : null))
|
|
505
|
+
.attr("stroke-dasharray", (n) => (n.status === "cancelled" ? "4 3" : null))
|
|
506
|
+
.classed("selected", (n) => n.id === state.selected)
|
|
507
|
+
.on("click", (ev, n) => {
|
|
508
|
+
ev.stopPropagation();
|
|
509
|
+
select(n.id);
|
|
510
|
+
})
|
|
511
|
+
.on("dblclick", (ev, n) => {
|
|
512
|
+
ev.stopPropagation();
|
|
513
|
+
focusNode(n);
|
|
514
|
+
})
|
|
515
|
+
.on("mouseenter", (ev, n) => showTip(ev, nodeTip(n)))
|
|
516
|
+
.on("mousemove", (ev) => moveTip(ev))
|
|
517
|
+
.on("mouseleave", hideTip);
|
|
518
|
+
|
|
519
|
+
g.select("text.bar-label").each(function (n) {
|
|
520
|
+
const t = d3.select(this);
|
|
521
|
+
const x0 = x(n.start);
|
|
522
|
+
const w = x(n.end) - x0;
|
|
523
|
+
const text = `${displayName(n)} (${fmtDur(n.duration)})`;
|
|
524
|
+
const need = text.length * 6.6 + 14;
|
|
525
|
+
const inside = w >= need;
|
|
526
|
+
const clipX = Math.max(x0, 0);
|
|
527
|
+
t.text(inside ? text : w > 60 ? truncate(text, (w - 14) / 6.6) : text)
|
|
528
|
+
.classed("outside", !inside && w <= 60)
|
|
529
|
+
.attr("x", !inside && w <= 60 ? x0 + Math.max(w, 3) + 6 : Math.min(clipX + 8, x0 + w - 20));
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
g.select("g.marks").each(function (n) {
|
|
533
|
+
drawMarks(d3.select(this), n, x);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
chartSvg.select("line.pin").attr("x1", state.pin != null ? x(state.pin) : -10).attr("x2", state.pin != null ? x(state.pin) : -10);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function drawMarks(g, n, x) {
|
|
540
|
+
const cy = BAR_Y + BAR_H / 2;
|
|
541
|
+
// cluster marks closer than 4px so a chatty node draws a readable line of dots
|
|
542
|
+
const marks = [];
|
|
543
|
+
let last = -Infinity;
|
|
544
|
+
for (const m of n.marks) {
|
|
545
|
+
const px = x(m.t);
|
|
546
|
+
if (px - last < 4 && marks.length) {
|
|
547
|
+
marks[marks.length - 1].more.push(m);
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
marks.push({ m, px, more: [] });
|
|
551
|
+
last = px;
|
|
552
|
+
}
|
|
553
|
+
g.selectAll("circle.mark")
|
|
554
|
+
.data(marks)
|
|
555
|
+
.join("circle")
|
|
556
|
+
.attr("class", "mark")
|
|
557
|
+
.attr("cx", (d) => d.px)
|
|
558
|
+
.attr("cy", cy)
|
|
559
|
+
.attr("r", (d) => (d.more.length ? 6 : 5))
|
|
560
|
+
.attr("fill", (d) => css(MARK[worst(d)].color))
|
|
561
|
+
.on("click", (ev, d) => {
|
|
562
|
+
ev.stopPropagation();
|
|
563
|
+
openItem(d.m);
|
|
564
|
+
})
|
|
565
|
+
.on("mouseenter", (ev, d) => showTip(ev, markTip(d)))
|
|
566
|
+
.on("mousemove", (ev) => moveTip(ev))
|
|
567
|
+
.on("mouseleave", hideTip);
|
|
568
|
+
// labels under the bar, skipped where they would collide
|
|
569
|
+
const labels = [];
|
|
570
|
+
let edge = -Infinity;
|
|
571
|
+
for (const d of marks) {
|
|
572
|
+
const text = truncate(d.more.length ? `${d.m.label} +${d.more.length}` : d.m.label, 22);
|
|
573
|
+
const w = text.length * 6 + 8;
|
|
574
|
+
if (d.px - 6 < edge) continue;
|
|
575
|
+
labels.push({ d, text });
|
|
576
|
+
edge = d.px + w;
|
|
577
|
+
}
|
|
578
|
+
// a label that would run off the right edge hangs left of its mark instead
|
|
579
|
+
const flip = (l) => l.d.px - 4 + l.text.length * 6 > W;
|
|
580
|
+
g.selectAll("text.mark-label")
|
|
581
|
+
.data(labels)
|
|
582
|
+
.join("text")
|
|
583
|
+
.attr("class", "mark-label")
|
|
584
|
+
.attr("x", (l) => (flip(l) ? l.d.px + 4 : l.d.px - 4))
|
|
585
|
+
.attr("text-anchor", (l) => (flip(l) ? "end" : "start"))
|
|
586
|
+
.attr("y", BAR_Y + BAR_H + 13)
|
|
587
|
+
.text((l) => l.text);
|
|
588
|
+
}
|
|
589
|
+
function worst(d) {
|
|
590
|
+
const all = [d.m, ...d.more].map((m) => m.kind);
|
|
591
|
+
for (const k of ["fail", "error", "cancel", "snapshot", "artifact", "pass", "event"]) if (all.includes(k)) return k;
|
|
592
|
+
return all[0];
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ── axis + cursor ──
|
|
596
|
+
function updateAxis(x) {
|
|
597
|
+
const svg = d3.select(el.axis);
|
|
598
|
+
if (state.view === "flow") return;
|
|
599
|
+
const w = state.view === "flame" ? W : el.chart.clientWidth || W;
|
|
600
|
+
svg.attr("viewBox", null);
|
|
601
|
+
const g = svg.selectAll("g.axis").data([0]).join("g").attr("class", "axis").attr("transform", "translate(0,33)");
|
|
602
|
+
g.call(d3.axisTop(x).ticks(Math.max(2, Math.floor(w / 90))).tickFormat(fmtTick).tickSizeOuter(0));
|
|
603
|
+
g.selectAll(".tick text").attr("dy", "-0.5em");
|
|
604
|
+
svg.selectAll("text.cursor-t").data([0]).join("text").attr("class", "cursor-t").attr("y", 12).attr("fill", css("--accent")).attr("font-size", 11);
|
|
605
|
+
}
|
|
606
|
+
function moveCursor(px) {
|
|
607
|
+
const svgs = [chartSvg, flameSvg].filter(Boolean);
|
|
608
|
+
for (const s of svgs) {
|
|
609
|
+
s.select("line.cursor").attr("visibility", px == null ? "hidden" : "visible").attr("x1", px).attr("x2", px);
|
|
610
|
+
}
|
|
611
|
+
const label = d3.select(el.axis).select("text.cursor-t");
|
|
612
|
+
if (px == null) label.text("");
|
|
613
|
+
else {
|
|
614
|
+
const t = xz().invert(px);
|
|
615
|
+
label.attr("x", px + 4).text(fmtTick(Math.max(0, t)));
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
function updateWindowLabel(x) {
|
|
619
|
+
const [a, b] = x.domain();
|
|
620
|
+
const span = b - a;
|
|
621
|
+
const opt = el.windowSel.querySelector("option[value='0']");
|
|
622
|
+
opt.textContent = `${fmtTick(span)} window`;
|
|
623
|
+
el.windowSel.value = "0";
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ── flame view ──
|
|
627
|
+
let flameSvg = null;
|
|
628
|
+
function renderFlame() {
|
|
629
|
+
d3.select(el.flame).selectAll("svg").remove();
|
|
630
|
+
flameSvg = d3
|
|
631
|
+
.select(el.flame)
|
|
632
|
+
.append("svg")
|
|
633
|
+
.on("mousemove", (ev) => moveCursor(d3.pointer(ev)[0]))
|
|
634
|
+
.on("mouseleave", () => moveCursor(null));
|
|
635
|
+
flameSvg.append("g").attr("class", "grid");
|
|
636
|
+
flameSvg.append("g").attr("class", "cells");
|
|
637
|
+
flameSvg.append("line").attr("class", "cursor").attr("y1", 0).attr("y2", 4000).attr("visibility", "hidden");
|
|
638
|
+
flameSvg.call(flameZoom).call(flameZoom.transform, state.zoom);
|
|
639
|
+
redrawX();
|
|
640
|
+
}
|
|
641
|
+
function drawFlame(x) {
|
|
642
|
+
if (!flameSvg) return;
|
|
643
|
+
const H = el.flame.clientHeight;
|
|
644
|
+
const ticks = x.ticks(Math.max(2, Math.floor(W / 90)));
|
|
645
|
+
flameSvg
|
|
646
|
+
.select(".grid")
|
|
647
|
+
.selectAll("line")
|
|
648
|
+
.data(ticks)
|
|
649
|
+
.join("line")
|
|
650
|
+
.attr("x1", (d) => x(d))
|
|
651
|
+
.attr("x2", (d) => x(d))
|
|
652
|
+
.attr("y1", 0)
|
|
653
|
+
.attr("y2", H);
|
|
654
|
+
const cells = flameSvg
|
|
655
|
+
.select(".cells")
|
|
656
|
+
.selectAll("g.cell")
|
|
657
|
+
.data(model.nodes, (n) => n.id)
|
|
658
|
+
.join((enter) => {
|
|
659
|
+
const g = enter.append("g").attr("class", "cell");
|
|
660
|
+
g.append("rect").attr("class", "flame-rect").attr("rx", 3).attr("height", FLAME_ROW - 2);
|
|
661
|
+
g.append("text").attr("class", "flame-text").attr("y", FLAME_ROW / 2 + 3);
|
|
662
|
+
return g;
|
|
663
|
+
})
|
|
664
|
+
.attr("transform", (n) => `translate(0,${8 + n.depth * FLAME_ROW})`);
|
|
665
|
+
cells
|
|
666
|
+
.select("rect")
|
|
667
|
+
.attr("x", (n) => x(n.start))
|
|
668
|
+
.attr("width", (n) => Math.max(1, x(n.end) - x(n.start)))
|
|
669
|
+
.attr("fill", (n) => colorOf(n))
|
|
670
|
+
.attr("fill-opacity", (n) => (n.status === "cancelled" ? 0.55 : 0.92))
|
|
671
|
+
.classed("selected", (n) => n.id === state.selected)
|
|
672
|
+
.on("click", (ev, n) => select(n.id))
|
|
673
|
+
.on("dblclick", (ev, n) => {
|
|
674
|
+
ev.stopPropagation();
|
|
675
|
+
focusNode(n);
|
|
676
|
+
})
|
|
677
|
+
.on("mouseenter", (ev, n) => showTip(ev, nodeTip(n)))
|
|
678
|
+
.on("mousemove", (ev) => moveTip(ev))
|
|
679
|
+
.on("mouseleave", hideTip);
|
|
680
|
+
cells
|
|
681
|
+
.select("text")
|
|
682
|
+
.attr("x", (n) => Math.max(x(n.start), 0) + 5)
|
|
683
|
+
.text((n) => {
|
|
684
|
+
const w = x(n.end) - Math.max(x(n.start), 0);
|
|
685
|
+
return w < 34 ? "" : truncate(`${displayName(n)} ${fmtDur(n.duration)}`, (w - 10) / 6.2);
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ── flow view ──
|
|
690
|
+
let flowG = null;
|
|
691
|
+
let flowBounds = null;
|
|
692
|
+
function renderFlow() {
|
|
693
|
+
d3.select(el.flow).selectAll("svg").remove();
|
|
694
|
+
const svg = d3.select(el.flow).append("svg");
|
|
695
|
+
flowG = svg.append("g");
|
|
696
|
+
const h = d3.hierarchy(model.root, (n) => n.children);
|
|
697
|
+
const CW = 210;
|
|
698
|
+
const CH = 46;
|
|
699
|
+
d3.tree().nodeSize([CH + 18, CW + 70])(h);
|
|
700
|
+
let minX = Infinity;
|
|
701
|
+
let maxX = -Infinity;
|
|
702
|
+
let maxY = 0;
|
|
703
|
+
h.each((d) => {
|
|
704
|
+
minX = Math.min(minX, d.x);
|
|
705
|
+
maxX = Math.max(maxX, d.x);
|
|
706
|
+
maxY = Math.max(maxY, d.y);
|
|
707
|
+
});
|
|
708
|
+
flowBounds = { x0: -20, y0: minX - CH, x1: maxY + CW + 20, y1: maxX + CH };
|
|
709
|
+
flowG
|
|
710
|
+
.append("g")
|
|
711
|
+
.selectAll("path")
|
|
712
|
+
.data(h.links())
|
|
713
|
+
.join("path")
|
|
714
|
+
.attr("class", "flow-link")
|
|
715
|
+
.attr("d", (l) => {
|
|
716
|
+
const sx = l.source.y + CW;
|
|
717
|
+
const sy = l.source.x;
|
|
718
|
+
const tx = l.target.y;
|
|
719
|
+
const ty = l.target.x;
|
|
720
|
+
const mx = (sx + tx) / 2;
|
|
721
|
+
return `M${sx},${sy}C${mx},${sy} ${mx},${ty} ${tx},${ty}`;
|
|
722
|
+
});
|
|
723
|
+
const card = flowG
|
|
724
|
+
.append("g")
|
|
725
|
+
.selectAll("g")
|
|
726
|
+
.data(h.descendants())
|
|
727
|
+
.join("g")
|
|
728
|
+
.attr("class", (d) => `flow-card ${d.data.id === state.selected ? "selected" : ""}`)
|
|
729
|
+
.attr("transform", (d) => `translate(${d.y},${d.x - CH / 2})`)
|
|
730
|
+
.style("cursor", "pointer")
|
|
731
|
+
.on("click", (ev, d) => select(d.data.id))
|
|
732
|
+
.on("mouseenter", (ev, d) => showTip(ev, nodeTip(d.data)))
|
|
733
|
+
.on("mousemove", (ev) => moveTip(ev))
|
|
734
|
+
.on("mouseleave", hideTip);
|
|
735
|
+
card.append("rect").attr("class", "box").attr("width", CW).attr("height", CH).attr("rx", 8);
|
|
736
|
+
card.append("rect").attr("width", 5).attr("height", CH - 12).attr("x", 6).attr("y", 6).attr("rx", 2).attr("fill", (d) => colorOf(d.data));
|
|
737
|
+
card.append("text").attr("x", 20).attr("y", 19).text((d) => truncate(displayName(d.data), 27));
|
|
738
|
+
card
|
|
739
|
+
.append("text")
|
|
740
|
+
.attr("class", "sub")
|
|
741
|
+
.attr("x", 20)
|
|
742
|
+
.attr("y", 35)
|
|
743
|
+
.text((d) => `${d.data.status} · ${fmtDur(d.data.duration)} · +${fmtTick(d.data.start)}`);
|
|
744
|
+
card
|
|
745
|
+
.append("circle")
|
|
746
|
+
.attr("cx", CW - 14)
|
|
747
|
+
.attr("cy", 14)
|
|
748
|
+
.attr("r", 4)
|
|
749
|
+
.attr("fill", (d) => css(statusVar(d.data.status)));
|
|
750
|
+
svg.call(flowZoom);
|
|
751
|
+
fitFlow(true);
|
|
752
|
+
}
|
|
753
|
+
function fitFlow(instant) {
|
|
754
|
+
const svg = d3.select(el.flow).select("svg");
|
|
755
|
+
if (svg.empty() || !flowBounds) return;
|
|
756
|
+
const w = el.flow.clientWidth;
|
|
757
|
+
const h = el.flow.clientHeight;
|
|
758
|
+
const b = flowBounds;
|
|
759
|
+
const k = Math.min(1.2, Math.min(w / (b.x1 - b.x0), h / (b.y1 - b.y0)) * 0.95);
|
|
760
|
+
const t = d3.zoomIdentity
|
|
761
|
+
.translate(w / 2 - (k * (b.x0 + b.x1)) / 2, h / 2 - (k * (b.y0 + b.y1)) / 2)
|
|
762
|
+
.scale(k);
|
|
763
|
+
(instant ? svg : svg.transition().duration(250)).call(flowZoom.transform, t);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// ── overview minimap ──
|
|
767
|
+
let ovBrush = null;
|
|
768
|
+
let ovX = null;
|
|
769
|
+
let brushing = false;
|
|
770
|
+
function renderOverview() {
|
|
771
|
+
const svg = d3.select(el.overview);
|
|
772
|
+
svg.selectAll("*").remove();
|
|
773
|
+
const w = el.overview.clientWidth || 220;
|
|
774
|
+
const h = el.overview.clientHeight || 120;
|
|
775
|
+
ovX = d3.scaleLinear().domain(baseX.domain()).range([0, w]);
|
|
776
|
+
const rowH = Math.max(1.5, Math.min(7, (h - 6) / model.nodes.length));
|
|
777
|
+
svg
|
|
778
|
+
.append("g")
|
|
779
|
+
.selectAll("rect")
|
|
780
|
+
.data(model.nodes)
|
|
781
|
+
.join("rect")
|
|
782
|
+
.attr("x", (n) => ovX(n.start))
|
|
783
|
+
.attr("width", (n) => Math.max(1, ovX(n.end) - ovX(n.start)))
|
|
784
|
+
.attr("y", (_n, i) => 3 + i * rowH)
|
|
785
|
+
.attr("height", Math.max(1, rowH - 1))
|
|
786
|
+
.attr("rx", Math.min(2, rowH / 2))
|
|
787
|
+
.attr("fill", (n) => colorOf(n));
|
|
788
|
+
ovBrush = d3
|
|
789
|
+
.brushX()
|
|
790
|
+
.extent([[0, 0], [w, h]])
|
|
791
|
+
.on("start", (ev) => {
|
|
792
|
+
if (ev.sourceEvent) brushing = true;
|
|
793
|
+
})
|
|
794
|
+
.on("brush end", (ev) => {
|
|
795
|
+
if (ev.type === "end") brushing = false;
|
|
796
|
+
if (!ev.sourceEvent || !ev.selection) return;
|
|
797
|
+
const [a, b] = ev.selection.map(ovX.invert);
|
|
798
|
+
zoomToDomain(a, b);
|
|
799
|
+
});
|
|
800
|
+
svg.append("g").attr("class", "brush").call(ovBrush);
|
|
801
|
+
el.range.textContent = `0s – ${fmtTick(model.span)} (${fmtDur(model.root.duration)} body)`;
|
|
802
|
+
updateOverviewBrush(xz());
|
|
803
|
+
}
|
|
804
|
+
function updateOverviewBrush(x) {
|
|
805
|
+
if (!ovBrush || !ovX || brushing) return;
|
|
806
|
+
const [a, b] = x.domain();
|
|
807
|
+
const s = [Math.max(0, ovX(a)), Math.min(ovX.range()[1], ovX(b))];
|
|
808
|
+
d3.select(el.overview).select("g.brush").call(ovBrush.move, s);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// ── selection + details ──
|
|
812
|
+
function select(id, tab) {
|
|
813
|
+
state.selected = id;
|
|
814
|
+
if (tab) state.detailTab = tab;
|
|
815
|
+
state.details = true;
|
|
816
|
+
el.main.classList.remove("no-details");
|
|
817
|
+
el.tree.querySelectorAll(".row").forEach((r) => r.classList.toggle("selected", r.dataset.id === id));
|
|
818
|
+
if (chartSvg) chartSvg.selectAll("rect.bar").classed("selected", (n) => n.id === id);
|
|
819
|
+
if (flameSvg) flameSvg.selectAll("rect.flame-rect").classed("selected", (n) => n.id === id);
|
|
820
|
+
d3.select(el.flow).selectAll("g.flow-card").classed("selected", (d) => d.data.id === id);
|
|
821
|
+
renderDetails();
|
|
822
|
+
}
|
|
823
|
+
function focusNode(n) {
|
|
824
|
+
const pad = Math.max(n.end - n.start, 50) * 0.08;
|
|
825
|
+
zoomToDomain(n.start - pad, n.end + pad);
|
|
826
|
+
}
|
|
827
|
+
function revealRow(n) {
|
|
828
|
+
let p = n.parent;
|
|
829
|
+
let changed = false;
|
|
830
|
+
while (p) {
|
|
831
|
+
if (state.collapsed.delete(p.id)) changed = true;
|
|
832
|
+
p = p.parent;
|
|
833
|
+
}
|
|
834
|
+
if (changed && state.view === "timeline") renderTimeline();
|
|
835
|
+
const row = el.tree.querySelector(`.row[data-id="${CSS.escape(n.id)}"]`);
|
|
836
|
+
if (row) row.scrollIntoView({ block: "nearest" });
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function renderDetails() {
|
|
840
|
+
const n = model.byId.get(state.selected) || model.root;
|
|
841
|
+
const color = colorOf(n);
|
|
842
|
+
const tabs = [
|
|
843
|
+
["overview", "Overview"],
|
|
844
|
+
["args", "Args"],
|
|
845
|
+
["events", `Events`],
|
|
846
|
+
["logs", "Logs"],
|
|
847
|
+
["snapshots", "Snapshots"],
|
|
848
|
+
];
|
|
849
|
+
el.details.innerHTML = `
|
|
850
|
+
<div class="dh">Action details <button class="x" title="Close">${svgIcon("close", ' width="16" height="16"')}</button></div>
|
|
851
|
+
<div class="who">
|
|
852
|
+
<span class="icon" style="color:${color};background:${alpha(color, 0.16)}">${svgIcon(iconOf(n))}</span>
|
|
853
|
+
<span class="nm" title="${esc(n.name)}">${esc(displayName(n))}</span>
|
|
854
|
+
<span class="pill"><span class="dot ${n.status}"></span>${esc(n.status)}</span>
|
|
855
|
+
</div>
|
|
856
|
+
<div class="scroll">
|
|
857
|
+
<div class="kv">
|
|
858
|
+
<div class="k">Duration</div><div class="v">${fmtDur(n.duration)}</div>
|
|
859
|
+
<div class="k">Start</div><div class="v">${fmtRel(n.start)} (${fmtClock(n.startAbs)})</div>
|
|
860
|
+
<div class="k">End</div><div class="v">${fmtRel(n.end)} (${fmtClock(n.endAbs)})</div>
|
|
861
|
+
<div class="k">Path</div><div class="v">${esc(n.v.path)}</div>
|
|
862
|
+
${n.v.definedAt ? `<div class="k">Defined at</div><div class="v"><a href="${esc(editorUrl(n.v.definedAt))}" title="${esc(n.v.definedAt)}">${esc(basename(n.v.definedAt))}</a></div>` : ""}
|
|
863
|
+
</div>
|
|
864
|
+
<div class="tabs">${tabs
|
|
865
|
+
.map(([k, l]) => `<button class="tab ${state.detailTab === k ? "active" : ""}" data-tab="${k}">${l}</button>`)
|
|
866
|
+
.join("")}</div>
|
|
867
|
+
<div class="tabbody">${detailTabHtml(n)}</div>
|
|
868
|
+
</div>`;
|
|
869
|
+
el.details.querySelector(".x").addEventListener("click", () => {
|
|
870
|
+
state.details = false;
|
|
871
|
+
el.main.classList.add("no-details");
|
|
872
|
+
relayout();
|
|
873
|
+
});
|
|
874
|
+
el.details.querySelectorAll(".tab").forEach((b) =>
|
|
875
|
+
b.addEventListener("click", () => {
|
|
876
|
+
state.detailTab = b.dataset.tab;
|
|
877
|
+
renderDetails();
|
|
878
|
+
})
|
|
879
|
+
);
|
|
880
|
+
wireItems(el.details);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function detailTabHtml(n) {
|
|
884
|
+
const tab = state.detailTab;
|
|
885
|
+
if (tab === "args") {
|
|
886
|
+
return 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>';
|
|
887
|
+
}
|
|
888
|
+
if (tab === "events") {
|
|
889
|
+
const evs = n.entries.filter((i) => i.kind !== "info");
|
|
890
|
+
return evs.length ? `<ul class="ev-list">${evs.map((i) => evLi(i, n)).join("")}</ul>` : '<div class="muted">No events.</div>';
|
|
891
|
+
}
|
|
892
|
+
if (tab === "logs") {
|
|
893
|
+
const logs = n.entries.filter((i) => i.kind === "info" || i.kind === "error");
|
|
894
|
+
return logs.length
|
|
895
|
+
? `<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>`
|
|
896
|
+
: '<div class="muted">No log output.</div>';
|
|
897
|
+
}
|
|
898
|
+
if (tab === "snapshots") {
|
|
899
|
+
const g = model.gallery.filter((x) => x.node === n);
|
|
900
|
+
return g.length ? `<div class="thumbs">${g.map(thumbHtml).join("")}</div>` : '<div class="muted">No snapshots or files.</div>';
|
|
901
|
+
}
|
|
902
|
+
// overview
|
|
903
|
+
const fails = model.run.failures.filter((f) => f.action === n.id || f.action === n.name || f.action === n.v.path);
|
|
904
|
+
const asserts = n.entries.filter((i) => i.kind === "pass" || i.kind === "fail");
|
|
905
|
+
const g = model.gallery.filter((x) => x.node === n);
|
|
906
|
+
return `
|
|
907
|
+
<div class="kv">
|
|
908
|
+
<div class="k">Id</div><div class="v">${esc(n.id)}</div>
|
|
909
|
+
${n.name !== displayName(n) ? `<div class="k">Name</div><div class="v">${esc(n.name)}</div>` : ""}
|
|
910
|
+
<div class="k">Tags</div><div class="v">${n.v.tags.length ? esc(n.v.tags.join(", ")) : '<span class="muted">none</span>'}</div>
|
|
911
|
+
<div class="k">Children</div><div class="v">${n.children.length}</div>
|
|
912
|
+
<div class="k">Events</div><div class="v">${n.marks.length}</div>
|
|
913
|
+
</div>
|
|
914
|
+
${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("")}` : ""}
|
|
915
|
+
${g.length ? `<div class="section-title">Snapshots (${g.length})</div><div class="thumbs">${g.slice(0, 6).map(thumbHtml).join("")}</div>` : ""}
|
|
916
|
+
<div class="section-title">Related logs</div>
|
|
917
|
+
<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>
|
|
918
|
+
<div class="section-title">Assertions (${asserts.length})</div>
|
|
919
|
+
${asserts.length ? `<ul class="ev-list">${asserts.map((i) => evLi(i, n)).join("")}</ul>` : '<div class="muted">No assertions in this action.</div>'}`;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function evLi(i, n) {
|
|
923
|
+
const mk = MARK[i.kind] || MARK.event;
|
|
924
|
+
const color = css(mk.color);
|
|
925
|
+
const text = i.kind === "lifecycle" ? i.e.message : i.label;
|
|
926
|
+
return `<li data-item="${i.idx}" style="cursor:pointer">
|
|
927
|
+
<span class="t">${fmtRel(i.t - (n ? n.start : 0))}</span>
|
|
928
|
+
<span class="ev-ico" style="background:${alpha(color, 0.2)};color:${color}">${svgIcon(mk.icon)}</span>
|
|
929
|
+
<span class="m" title="${esc(i.e.message)}">${esc(truncate(text, 140))}</span></li>`;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function thumbHtml(g) {
|
|
933
|
+
const i = model.gallery.indexOf(g);
|
|
934
|
+
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>`;
|
|
935
|
+
const label = g.type === "snapshot" ? "{ }" : g.f.mime.split("/")[1] || "file";
|
|
936
|
+
return `<div class="thumb" data-gallery="${i}" title="${esc(g.name)}"><span class="json-ico">${esc(label)}</span></div>`;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function wireItems(root) {
|
|
940
|
+
root.querySelectorAll("img[data-asset]").forEach((img) => {
|
|
941
|
+
if (!img.src) img.src = model.asset(img.dataset.asset);
|
|
942
|
+
});
|
|
943
|
+
root.querySelectorAll("[data-gallery]").forEach((t) =>
|
|
944
|
+
t.addEventListener("click", () => openGallery(model.gallery[+t.dataset.gallery]))
|
|
945
|
+
);
|
|
946
|
+
root.querySelectorAll("[data-item]").forEach((t) =>
|
|
947
|
+
t.addEventListener("click", () => openItem(model.items[+t.dataset.item]))
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function openItem(item) {
|
|
952
|
+
if (!item) return;
|
|
953
|
+
setPin(item.t);
|
|
954
|
+
if (item.node && item.node.id !== state.selected) select(item.node.id, "events");
|
|
955
|
+
if (item.gallery) openGallery(item.gallery);
|
|
956
|
+
}
|
|
957
|
+
function setPin(t) {
|
|
958
|
+
state.pin = t;
|
|
959
|
+
redrawX();
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// ── lower panel ──
|
|
963
|
+
function renderLower() {
|
|
964
|
+
const tabs = [
|
|
965
|
+
["snapshots", `Snapshots (${model.gallery.length})`],
|
|
966
|
+
["logs", "Logs"],
|
|
967
|
+
["asserts", `Assertions (${model.asserts.length})`],
|
|
968
|
+
["events", "Timeline events"],
|
|
969
|
+
];
|
|
970
|
+
el.lower.innerHTML = `<div class="tabs">${tabs
|
|
971
|
+
.map(([k, l]) => `<button class="tab ${state.lowerTab === k ? "active" : ""}" data-tab="${k}">${l}</button>`)
|
|
972
|
+
.join("")}<span class="spacer"></span><input class="filter" placeholder="Filter…" value="${esc(state.filter)}"></div>
|
|
973
|
+
<div class="body">${lowerBodyHtml()}</div>`;
|
|
974
|
+
el.lower.querySelectorAll(".tab").forEach((b) =>
|
|
975
|
+
b.addEventListener("click", () => {
|
|
976
|
+
state.lowerTab = b.dataset.tab;
|
|
977
|
+
renderLower();
|
|
978
|
+
})
|
|
979
|
+
);
|
|
980
|
+
const input = el.lower.querySelector(".filter");
|
|
981
|
+
input.addEventListener("input", () => {
|
|
982
|
+
state.filter = input.value;
|
|
983
|
+
el.lower.querySelector(".body").innerHTML = lowerBodyHtml();
|
|
984
|
+
wireLower();
|
|
985
|
+
});
|
|
986
|
+
wireLower();
|
|
987
|
+
}
|
|
988
|
+
function wireLower() {
|
|
989
|
+
const body = el.lower.querySelector(".body");
|
|
990
|
+
wireItems(body);
|
|
991
|
+
body.querySelectorAll("tr[data-row]").forEach((tr) =>
|
|
992
|
+
tr.addEventListener("click", () => openItem(model.items[+tr.dataset.row]))
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
function matches(...parts) {
|
|
996
|
+
const q = state.filter.trim().toLowerCase();
|
|
997
|
+
return !q || parts.some((p) => String(p || "").toLowerCase().includes(q));
|
|
998
|
+
}
|
|
999
|
+
function lowerBodyHtml() {
|
|
1000
|
+
const tab = state.lowerTab;
|
|
1001
|
+
if (tab === "snapshots") {
|
|
1002
|
+
const g = model.gallery.filter((x) => matches(x.name, x.node && x.node.v.path));
|
|
1003
|
+
if (!g.length) return '<div class="muted">No snapshots, artifacts, or images in this run.</div>';
|
|
1004
|
+
return `<div class="gallery">${g
|
|
1005
|
+
.map((x) => {
|
|
1006
|
+
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" : ""}`;
|
|
1007
|
+
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></div><div class="sz">${sub}</div></div>`;
|
|
1008
|
+
})
|
|
1009
|
+
.join("")}</div>`;
|
|
1010
|
+
}
|
|
1011
|
+
const pick =
|
|
1012
|
+
tab === "logs"
|
|
1013
|
+
? (i) => i.kind === "info" || i.kind === "error"
|
|
1014
|
+
: tab === "asserts"
|
|
1015
|
+
? (i) => i.kind === "pass" || i.kind === "fail"
|
|
1016
|
+
: (i) => i.kind !== "info";
|
|
1017
|
+
const rows = [];
|
|
1018
|
+
for (let idx = 0; idx < model.items.length; idx++) {
|
|
1019
|
+
const i = model.items[idx];
|
|
1020
|
+
if (!pick(i)) continue;
|
|
1021
|
+
if (!matches(i.e.message, i.e.path, i.e.source)) continue;
|
|
1022
|
+
rows.push({ i, idx });
|
|
1023
|
+
if (rows.length >= 3000) break;
|
|
1024
|
+
}
|
|
1025
|
+
if (!rows.length) return `<div class="muted">${tab === "asserts" ? "No assertions in this run." : "Nothing matches."}</div>`;
|
|
1026
|
+
return `<table class="list"><tbody>${rows
|
|
1027
|
+
.map(({ i, idx }) => {
|
|
1028
|
+
const mk = MARK[i.kind];
|
|
1029
|
+
const ico = mk
|
|
1030
|
+
? `<span class="ev-ico" style="background:${alpha(css(mk.color), 0.2)};color:${css(mk.color)}">${svgIcon(mk.icon)}</span>`
|
|
1031
|
+
: "";
|
|
1032
|
+
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>`;
|
|
1033
|
+
})
|
|
1034
|
+
.join("")}</tbody></table>${rows.length >= 3000 ? '<div class="muted">First 3000 rows — narrow with the filter.</div>' : ""}`;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// ── modal ──
|
|
1038
|
+
function openGallery(g) {
|
|
1039
|
+
if (!g) return;
|
|
1040
|
+
if (g.t != null) setPin(g.t);
|
|
1041
|
+
let body;
|
|
1042
|
+
let actions = "";
|
|
1043
|
+
const pathOf = g.type === "file" ? g.f.file : g.s.file;
|
|
1044
|
+
if (g.type === "file" && g.image) {
|
|
1045
|
+
const src = model.asset(g.f.asset);
|
|
1046
|
+
body = `<img alt="${esc(g.name)}" src="${src}">`;
|
|
1047
|
+
actions = `<a class="btn solo" download="${esc(basename(g.f.file))}" href="${src}">${svgIcon("download")}Download</a>`;
|
|
1048
|
+
} else if (g.type === "file") {
|
|
1049
|
+
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>`;
|
|
1050
|
+
} else {
|
|
1051
|
+
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>' : ""}`;
|
|
1052
|
+
}
|
|
1053
|
+
const sub = [g.t != null ? fmtRel(g.t) : null, g.node ? g.node.v.path : null, g.type === "file" ? fmtSize(g.f.size) : "snapshot"]
|
|
1054
|
+
.filter(Boolean)
|
|
1055
|
+
.join(" · ");
|
|
1056
|
+
const m = document.createElement("div");
|
|
1057
|
+
m.className = "modal";
|
|
1058
|
+
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>
|
|
1059
|
+
${actions}
|
|
1060
|
+
<a class="btn solo" href="${esc(fileUrl(pathOf))}">${svgIcon("file")}Open</a>
|
|
1061
|
+
<button class="btn solo" data-copy>${svgIcon("copy")}Copy path</button>
|
|
1062
|
+
<button class="btn solo" data-close title="Close">${svgIcon("close")}</button></div>
|
|
1063
|
+
<div class="mb">${body}</div></div>`;
|
|
1064
|
+
const close = () => {
|
|
1065
|
+
m.remove();
|
|
1066
|
+
document.removeEventListener("keydown", onKey);
|
|
1067
|
+
};
|
|
1068
|
+
const onKey = (e) => e.key === "Escape" && close();
|
|
1069
|
+
m.addEventListener("click", (e) => e.target === m && close());
|
|
1070
|
+
m.querySelector("[data-close]").addEventListener("click", close);
|
|
1071
|
+
m.querySelector("[data-copy]").addEventListener("click", () => copy(pathOf));
|
|
1072
|
+
document.addEventListener("keydown", onKey);
|
|
1073
|
+
document.body.appendChild(m);
|
|
1074
|
+
if (g.node && g.node.id !== state.selected) select(g.node.id);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function openMenu(anchor) {
|
|
1078
|
+
const r = anchor.getBoundingClientRect();
|
|
1079
|
+
const m = document.createElement("div");
|
|
1080
|
+
m.className = "menu";
|
|
1081
|
+
m.style.top = `${r.bottom + 6}px`;
|
|
1082
|
+
m.style.right = `${window.innerWidth - r.right}px`;
|
|
1083
|
+
m.innerHTML = `<button data-a="theme">${svgIcon("sun", ' width="14" height="14"')} Toggle light / dark</button>
|
|
1084
|
+
<button data-a="data">${svgIcon("download", ' width="14" height="14"')} Download run data (JSON)</button>
|
|
1085
|
+
<button data-a="folder">${svgIcon("copy", ' width="14" height="14"')} Copy run folder path</button>
|
|
1086
|
+
<button data-a="expand">Expand all</button>
|
|
1087
|
+
<button data-a="collapse">Collapse all</button>`;
|
|
1088
|
+
const close = () => {
|
|
1089
|
+
m.remove();
|
|
1090
|
+
document.removeEventListener("mousedown", away, true);
|
|
1091
|
+
};
|
|
1092
|
+
const away = (e) => !m.contains(e.target) && close();
|
|
1093
|
+
m.addEventListener("click", (e) => {
|
|
1094
|
+
const a = e.target.closest("button")?.dataset.a;
|
|
1095
|
+
close();
|
|
1096
|
+
if (a === "theme") {
|
|
1097
|
+
toggleTheme();
|
|
1098
|
+
renderAll();
|
|
1099
|
+
renderDetails();
|
|
1100
|
+
renderLower();
|
|
1101
|
+
}
|
|
1102
|
+
if (a === "data") download(`${model.data.name}-run.json`, JSON.stringify(model.data, null, 2), "application/json");
|
|
1103
|
+
if (a === "folder") copy(model.data.folder);
|
|
1104
|
+
if (a === "expand") {
|
|
1105
|
+
state.collapsed.clear();
|
|
1106
|
+
renderAll();
|
|
1107
|
+
}
|
|
1108
|
+
if (a === "collapse") {
|
|
1109
|
+
for (const n of model.nodes) if (n.depth >= 1 && n.children.length) state.collapsed.add(n.id);
|
|
1110
|
+
renderAll();
|
|
1111
|
+
}
|
|
1112
|
+
});
|
|
1113
|
+
document.addEventListener("mousedown", away, true);
|
|
1114
|
+
document.body.appendChild(m);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// ── tooltips ──
|
|
1118
|
+
function nodeTip(n) {
|
|
1119
|
+
return `<div class="tt">${esc(n.name)}</div>
|
|
1120
|
+
<div class="ts"><span class="dot ${n.status}"></span> ${esc(n.status)} · ${fmtDur(n.duration)}</div>
|
|
1121
|
+
<div class="ts">${fmtRel(n.start)} → ${fmtRel(n.end)}</div>
|
|
1122
|
+
${n.v.tags.length ? `<div class="ts">${esc(n.v.tags.join(" · "))}</div>` : ""}`;
|
|
1123
|
+
}
|
|
1124
|
+
function markTip(d) {
|
|
1125
|
+
const m = d.m;
|
|
1126
|
+
const mk = MARK[m.kind] || MARK.event;
|
|
1127
|
+
const img = m.gallery && m.gallery.image ? `<img src="${model.asset(m.gallery.f.asset)}" alt="">` : "";
|
|
1128
|
+
const more = d.more.length ? `<div class="ts">+${d.more.length} more within a few ms</div>` : "";
|
|
1129
|
+
const extra = m.gallery && m.gallery.image ? `<div class="ts">${esc(m.gallery.f.rel)} · ${fmtSize(m.gallery.f.size)}</div>` : "";
|
|
1130
|
+
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}`;
|
|
1131
|
+
}
|
|
1132
|
+
function showTip(ev, html) {
|
|
1133
|
+
tip.innerHTML = html;
|
|
1134
|
+
tip.hidden = false;
|
|
1135
|
+
moveTip(ev);
|
|
1136
|
+
}
|
|
1137
|
+
function moveTip(ev) {
|
|
1138
|
+
const pad = 14;
|
|
1139
|
+
const r = tip.getBoundingClientRect();
|
|
1140
|
+
let x = ev.clientX + pad;
|
|
1141
|
+
let y = ev.clientY + pad;
|
|
1142
|
+
if (x + r.width > window.innerWidth - 8) x = ev.clientX - r.width - pad;
|
|
1143
|
+
if (y + r.height > window.innerHeight - 8) y = ev.clientY - r.height - pad;
|
|
1144
|
+
tip.style.left = `${x}px`;
|
|
1145
|
+
tip.style.top = `${y}px`;
|
|
1146
|
+
}
|
|
1147
|
+
function hideTip() {
|
|
1148
|
+
tip.hidden = true;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// ── render orchestration ──
|
|
1152
|
+
function renderAll() {
|
|
1153
|
+
measure();
|
|
1154
|
+
if (state.view === "timeline") renderTimeline();
|
|
1155
|
+
if (state.view === "flame") renderFlame();
|
|
1156
|
+
if (state.view === "flow") renderFlow();
|
|
1157
|
+
renderOverview();
|
|
1158
|
+
}
|
|
1159
|
+
function relayout() {
|
|
1160
|
+
const keep = state.zoom;
|
|
1161
|
+
measure();
|
|
1162
|
+
state.zoom = keep;
|
|
1163
|
+
renderAll();
|
|
1164
|
+
}
|
|
1165
|
+
let resizeTimer = 0;
|
|
1166
|
+
window.addEventListener("resize", () => {
|
|
1167
|
+
clearTimeout(resizeTimer);
|
|
1168
|
+
resizeTimer = setTimeout(relayout, 120);
|
|
1169
|
+
});
|
|
1170
|
+
document.addEventListener("keydown", (e) => {
|
|
1171
|
+
if (e.target.closest && e.target.closest("input")) return;
|
|
1172
|
+
if (e.key === "+" || e.key === "=") zoomBy(1.6);
|
|
1173
|
+
else if (e.key === "-") zoomBy(1 / 1.6);
|
|
1174
|
+
else if (e.key === "0") fit();
|
|
1175
|
+
else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
1176
|
+
const rows = visible();
|
|
1177
|
+
const i = rows.findIndex((n) => n.id === state.selected);
|
|
1178
|
+
const next = rows[Math.max(0, Math.min(rows.length - 1, i + (e.key === "ArrowDown" ? 1 : -1)))];
|
|
1179
|
+
if (next) {
|
|
1180
|
+
e.preventDefault();
|
|
1181
|
+
select(next.id);
|
|
1182
|
+
revealRow(next);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
setView("timeline");
|
|
1188
|
+
renderDetails();
|
|
1189
|
+
renderLower();
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// ── shell markup ─────────────────────────────────────────
|
|
1193
|
+
function shellHtml(model) {
|
|
1194
|
+
const run = model.run;
|
|
1195
|
+
const ok = run.ok;
|
|
1196
|
+
const a = run.assertions;
|
|
1197
|
+
const total = a.passed + a.failed;
|
|
1198
|
+
const assertText = total ? `${a.passed} / ${total} assertions passed` : "no assertions";
|
|
1199
|
+
const fails = run.failures.length ? `<span class="sep">•</span><span style="color:var(--fail)">${run.failures.length} failure${run.failures.length > 1 ? "s" : ""}</span>` : "";
|
|
1200
|
+
const windows = [0, 100, 500, 1000, 5000, 10000, 30000, 60000, 300000].filter((w) => w === 0 || w < model.span);
|
|
1201
|
+
return `
|
|
1202
|
+
<header class="top">
|
|
1203
|
+
<div class="titles">
|
|
1204
|
+
<h1 title="${esc(model.data.folder)}">Run: ${esc(model.data.name)}</h1>
|
|
1205
|
+
<div class="meta"><span class="dot ${ok ? "ok" : "failed"}"></span><span>${ok ? "passed" : "failed"}</span>
|
|
1206
|
+
<span class="sep">•</span><span>${fmtDate(run.startedAt)}</span>
|
|
1207
|
+
<span class="sep">•</span><span title="body ${fmtDur(run.duration)} · record ${fmtDur(model.span)}">${fmtDur(run.duration)}</span>
|
|
1208
|
+
<span class="sep">•</span><span>${model.nodes.length} action${model.nodes.length > 1 ? "s" : ""}</span>
|
|
1209
|
+
<span class="sep">•</span><span>${assertText}</span>${fails}
|
|
1210
|
+
</div>
|
|
1211
|
+
</div>
|
|
1212
|
+
<div class="btn-group">
|
|
1213
|
+
<button class="btn active" data-view="timeline">${svgIcon("timeline")}Timeline + Graph</button>
|
|
1214
|
+
<button class="btn" data-view="flame">${svgIcon("flame")}Flame Graph</button>
|
|
1215
|
+
<button class="btn" data-view="flow">${svgIcon("flow")}Flow</button>
|
|
1216
|
+
</div>
|
|
1217
|
+
<div class="btn-group">
|
|
1218
|
+
<button class="btn" id="zout" title="Zoom out (-)">${svgIcon("zoomOut")}</button>
|
|
1219
|
+
<button class="btn" id="zin" title="Zoom in (+)">${svgIcon("zoomIn")}</button>
|
|
1220
|
+
<button class="btn" id="fit" title="Fit (0)">Fit</button>
|
|
1221
|
+
</div>
|
|
1222
|
+
<select id="window" class="btn solo" title="Visible window">
|
|
1223
|
+
${windows.map((w) => `<option value="${w}">${w ? fmtTick(w) : "window"}</option>`).join("")}
|
|
1224
|
+
</select>
|
|
1225
|
+
<button class="btn solo" id="more" title="More">${svgIcon("more")}</button>
|
|
1226
|
+
</header>
|
|
1227
|
+
<div class="main">
|
|
1228
|
+
<section class="card timeline" style="--tree-w:420px;--row-h:${ROW_H}px;position:relative">
|
|
1229
|
+
<div class="tl-head">
|
|
1230
|
+
<div class="cols"><div>Action / Event</div><div>Status</div><div>Duration</div></div>
|
|
1231
|
+
<svg></svg>
|
|
1232
|
+
</div>
|
|
1233
|
+
<div class="tl-body"><div class="tree"></div><div class="chart"></div></div>
|
|
1234
|
+
<div class="flame-view" hidden style="top:34px"></div>
|
|
1235
|
+
<div class="flow-view" hidden></div>
|
|
1236
|
+
</section>
|
|
1237
|
+
<aside class="card details"></aside>
|
|
1238
|
+
</div>
|
|
1239
|
+
<div class="bottom">
|
|
1240
|
+
<section class="card lower"></section>
|
|
1241
|
+
<section class="card legend">
|
|
1242
|
+
<h3>Legend</h3>
|
|
1243
|
+
<div class="grid2">
|
|
1244
|
+
${legendItem("--c1", "Action run", "bar")}
|
|
1245
|
+
${legendItem("--c2", "Snapshot", "dot")}
|
|
1246
|
+
${legendItem("--c3", "Process event", "dot")}
|
|
1247
|
+
${legendItem("--c6", "Artifact", "dot")}
|
|
1248
|
+
${legendItem("--ok", "Assert passed", "dot")}
|
|
1249
|
+
${legendItem("--fail", "Failure / error", "dot")}
|
|
1250
|
+
${legendItem("--cancel", "Cancelled", "dash")}
|
|
1251
|
+
${legendItem("--accent", "Pinned time", "line")}
|
|
1252
|
+
</div>
|
|
1253
|
+
<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>
|
|
1254
|
+
</section>
|
|
1255
|
+
<section class="card overview"><h3>Overview</h3><div class="range"></div><svg></svg></section>
|
|
1256
|
+
</div>`;
|
|
1257
|
+
}
|
|
1258
|
+
function legendItem(color, label, shape) {
|
|
1259
|
+
const c = `var(${color})`;
|
|
1260
|
+
const sw =
|
|
1261
|
+
shape === "bar"
|
|
1262
|
+
? `<span style="width:16px;height:9px;border-radius:3px;background:${c}"></span>`
|
|
1263
|
+
: shape === "dash"
|
|
1264
|
+
? `<span style="width:16px;height:0;border-top:2px dashed ${c}"></span>`
|
|
1265
|
+
: shape === "line"
|
|
1266
|
+
? `<span style="width:2px;height:14px;background:${c};margin:0 7px"></span>`
|
|
1267
|
+
: `<span class="dot" style="background:${c};width:11px;height:11px"></span>`;
|
|
1268
|
+
return `<div class="li">${sw}${esc(label)}</div>`;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// ── helpers ──────────────────────────────────────────────
|
|
1272
|
+
function colorOf(n) {
|
|
1273
|
+
return n.branch < 0 ? css("--c1") : css(PALETTE[n.branch]);
|
|
1274
|
+
}
|
|
1275
|
+
function statusVar(s) {
|
|
1276
|
+
return s === "ok" ? "--ok" : s === "failed" ? "--fail" : s === "cancelled" ? "--cancel" : "--running";
|
|
1277
|
+
}
|
|
1278
|
+
function alpha(color, a) {
|
|
1279
|
+
const c = d3.color(color);
|
|
1280
|
+
if (!c) return color;
|
|
1281
|
+
c.opacity = a;
|
|
1282
|
+
return c.formatRgb();
|
|
1283
|
+
}
|
|
1284
|
+
function truncate(s, n) {
|
|
1285
|
+
n = Math.max(1, Math.floor(n));
|
|
1286
|
+
return s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s;
|
|
1287
|
+
}
|
|
1288
|
+
function displayName(n) {
|
|
1289
|
+
if (n.depth === 0) return "main";
|
|
1290
|
+
const tag = n.v.tags && n.v.tags[0];
|
|
1291
|
+
// Battery steps are named after their kind (`command`, `chromePage`); their first tag
|
|
1292
|
+
// is what the om called them, which is the more useful label.
|
|
1293
|
+
return tag && tag !== n.name && n.name.length > 28 ? tag : n.name;
|
|
1294
|
+
}
|
|
1295
|
+
function iconOf(n) {
|
|
1296
|
+
if (n.depth === 0) return "root";
|
|
1297
|
+
const s = `${n.name} ${(n.v.tags || []).join(" ")}`.toLowerCase();
|
|
1298
|
+
if (/^(npm|npx|node|pnpm|yarn|tsc|git|bun)\b/.test(n.name) || /command|shell|exec/.test(s)) return "terminal";
|
|
1299
|
+
if (/chromepage|page:|tab/.test(s)) return "window";
|
|
1300
|
+
if (/browser|chrome/.test(s)) return "globe";
|
|
1301
|
+
if (/health|ready|gate|wait|port/.test(s)) return "pulse";
|
|
1302
|
+
if (/drive|inspect|click/.test(s)) return "cursor";
|
|
1303
|
+
if (/test|assert|validate/.test(s)) return "flask";
|
|
1304
|
+
if (/snapshot|screenshot/.test(s)) return "camera";
|
|
1305
|
+
if (/prompt|ask/.test(s)) return "chat";
|
|
1306
|
+
if (/build|compile|bundle/.test(s)) return "box";
|
|
1307
|
+
return "play";
|
|
1308
|
+
}
|
|
1309
|
+
function copy(text) {
|
|
1310
|
+
const done = () => toast("Copied");
|
|
1311
|
+
if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text).then(done, () => fallbackCopy(text));
|
|
1312
|
+
else fallbackCopy(text);
|
|
1313
|
+
}
|
|
1314
|
+
function fallbackCopy(text) {
|
|
1315
|
+
const t = document.createElement("textarea");
|
|
1316
|
+
t.value = text;
|
|
1317
|
+
document.body.appendChild(t);
|
|
1318
|
+
t.select();
|
|
1319
|
+
try {
|
|
1320
|
+
document.execCommand("copy");
|
|
1321
|
+
toast("Copied");
|
|
1322
|
+
} catch {
|
|
1323
|
+
toast(text);
|
|
1324
|
+
}
|
|
1325
|
+
t.remove();
|
|
1326
|
+
}
|
|
1327
|
+
function toast(text) {
|
|
1328
|
+
const t = document.createElement("div");
|
|
1329
|
+
t.className = "toast";
|
|
1330
|
+
t.textContent = text;
|
|
1331
|
+
document.body.appendChild(t);
|
|
1332
|
+
setTimeout(() => t.remove(), 1400);
|
|
1333
|
+
}
|
|
1334
|
+
function download(name, text, mime) {
|
|
1335
|
+
const a = document.createElement("a");
|
|
1336
|
+
a.href = URL.createObjectURL(new Blob([text], { type: mime }));
|
|
1337
|
+
a.download = name;
|
|
1338
|
+
a.click();
|
|
1339
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
1340
|
+
}
|
|
1341
|
+
function toggleTheme() {
|
|
1342
|
+
const root = document.documentElement;
|
|
1343
|
+
const dark = root.dataset.theme ? root.dataset.theme === "dark" : !matchMedia("(prefers-color-scheme: light)").matches;
|
|
1344
|
+
root.dataset.theme = dark ? "light" : "dark";
|
|
1345
|
+
try {
|
|
1346
|
+
localStorage.setItem("omkit-report-theme", root.dataset.theme);
|
|
1347
|
+
} catch {
|
|
1348
|
+
/* storage may be unavailable on file:// */
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// ── boot ─────────────────────────────────────────────────
|
|
1353
|
+
const frame = () => new Promise((r) => requestAnimationFrame(() => setTimeout(r, 0)));
|
|
1354
|
+
const step = (t) => {
|
|
1355
|
+
const s = document.getElementById("loader-step");
|
|
1356
|
+
if (s) s.textContent = t;
|
|
1357
|
+
};
|
|
1358
|
+
async function boot() {
|
|
1359
|
+
try {
|
|
1360
|
+
const saved = localStorage.getItem("omkit-report-theme");
|
|
1361
|
+
if (saved) document.documentElement.dataset.theme = saved;
|
|
1362
|
+
} catch {
|
|
1363
|
+
/* ignore */
|
|
1364
|
+
}
|
|
1365
|
+
await frame();
|
|
1366
|
+
step("Parsing the run record…");
|
|
1367
|
+
await frame();
|
|
1368
|
+
const data = JSON.parse(document.getElementById("omkit-data").textContent);
|
|
1369
|
+
step(`Building ${data.run ? "the timeline" : "the view"}…`);
|
|
1370
|
+
await frame();
|
|
1371
|
+
const model = buildModel(data);
|
|
1372
|
+
const app = document.getElementById("app");
|
|
1373
|
+
app.hidden = false;
|
|
1374
|
+
App(model);
|
|
1375
|
+
await frame();
|
|
1376
|
+
const loader = document.getElementById("loader");
|
|
1377
|
+
loader.style.transition = "opacity 0.2s";
|
|
1378
|
+
loader.style.opacity = "0";
|
|
1379
|
+
setTimeout(() => loader.remove(), 220);
|
|
1380
|
+
}
|
|
1381
|
+
boot().catch((err) => {
|
|
1382
|
+
step(`Could not render this run: ${err && err.message ? err.message : err}`);
|
|
1383
|
+
console.error(err);
|
|
1384
|
+
});
|
|
1385
|
+
})();
|