omkit 0.8.10 → 0.8.12
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 +2 -2
- package/dist/core/ActionRun.d.ts +3 -1
- package/dist/core/ActionRun.js +2 -0
- package/dist/core/ExecutionTree.d.ts +7 -0
- package/dist/core/ExecutionTree.js +27 -0
- package/dist/core/action.js +11 -2
- package/dist/core/om.js +3 -0
- package/dist/core/types.d.ts +13 -0
- package/dist/output/writers/ReportWriter.js +134 -25
- package/dist/output/writers/report/report.css +78 -4
- package/dist/output/writers/report/report.js +387 -46
- package/dist/output/writers/views.d.ts +10 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -185,11 +185,11 @@ flakyBackgroundJob().result.catch((e) => console.warn("job failed, carrying on",
|
|
|
185
185
|
|
|
186
186
|
Every action's output, events, asserts, snapshots, and lifecycle land on **one timeline** — streamed live to the terminal (curated milestones) and written to a per-run folder:
|
|
187
187
|
|
|
188
|
-
- `result.json` — the run's tree, per-node status, verdict, and curated artifacts.
|
|
188
|
+
- `result.json` — the run's tree, per-node status, verdict, and curated artifacts. Each node also carries what its definition declared about itself: the `.describe()` summary, the `.mcp()` exposure, and the `.args()` schema as JSON Schema.
|
|
189
189
|
- `raw.jsonl` — every entry, machine-readable.
|
|
190
190
|
- `main.log` + one `.log` per action — the human-readable timelines.
|
|
191
191
|
- `events.log` / `asserts.log` / `snapshots.log` / `artifacts.log` — cross-cutting rollups.
|
|
192
|
-
- `report.html` — the whole run as one self-contained page: a timeline of every action with its events, a flame graph, a flow graph of the tree, per-action details, logs, assertions,
|
|
192
|
+
- `report.html` — the whole run as one self-contained page: a timeline of every action with its events, a flame graph, a flow graph of the tree, per-action details, logs, assertions, the run's snapshots and images, and each action's source (the `<>` button next to a block). Everything is inlined (data, base64 images, d3), so it opens straight from disk and survives being zipped or attached.
|
|
193
193
|
|
|
194
194
|
`ctx` also gives each action `assert(cond, msg)` (tallies into the run's verdict), `snapshot(name, value)` (captures JSON state to the run folder), and `artifact(name, file, opts?)` (labels a file the action wrote — name, optional description, MIME inferred from the extension — and returns its absolute path) — so a run is an inspectable artifact, not just an exit code. Write the files you label under `ctx.artifactsFolder`, the run's own output folder.
|
|
195
195
|
|
package/dist/core/ActionRun.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { ActionRef } from "../foundation/ActionRef.ts";
|
|
|
3
3
|
import type { LogStore } from "../output/log/LogStore.ts";
|
|
4
4
|
import type { SnapshotStore } from "../output/snapshot/SnapshotStore.ts";
|
|
5
5
|
import type { ArtifactStore } from "../output/artifact/ArtifactStore.ts";
|
|
6
|
-
import type { Activity, Exec, InstanceEvents, NodeStatus } from "./types.ts";
|
|
6
|
+
import type { ActionMeta, Activity, Exec, InstanceEvents, NodeStatus } from "./types.ts";
|
|
7
7
|
/** Everything a node needs to exist, minus its behavior. */
|
|
8
8
|
export interface NodeInit {
|
|
9
9
|
readonly store: LogStore;
|
|
@@ -45,6 +45,8 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
|
|
|
45
45
|
readonly path: string;
|
|
46
46
|
readonly parentId: string | null;
|
|
47
47
|
readonly tags: string[];
|
|
48
|
+
/** What the definition said about itself (`.describe()`, `.mcp()`, `.args()`); for the record. */
|
|
49
|
+
meta: ActionMeta | undefined;
|
|
48
50
|
/** The captured args this node ran with (for the log header); set at run start. */
|
|
49
51
|
args: readonly unknown[];
|
|
50
52
|
readonly children: ActionRun[];
|
package/dist/core/ActionRun.js
CHANGED
|
@@ -18,6 +18,8 @@ export class ActionRun {
|
|
|
18
18
|
path;
|
|
19
19
|
parentId;
|
|
20
20
|
tags = [];
|
|
21
|
+
/** What the definition said about itself (`.describe()`, `.mcp()`, `.args()`); for the record. */
|
|
22
|
+
meta;
|
|
21
23
|
/** The captured args this node ran with (for the log header); set at run start. */
|
|
22
24
|
args = [];
|
|
23
25
|
children = [];
|
|
@@ -41,6 +41,7 @@ export declare class ExecutionTree {
|
|
|
41
41
|
private readonly snapshotStore;
|
|
42
42
|
private readonly artifactStore;
|
|
43
43
|
private readonly registry;
|
|
44
|
+
private readonly schemaCache;
|
|
44
45
|
private readonly unsettled;
|
|
45
46
|
private readonly originalLog;
|
|
46
47
|
private streaming;
|
|
@@ -101,6 +102,12 @@ export declare class ExecutionTree {
|
|
|
101
102
|
private printSummary;
|
|
102
103
|
private verdictOk;
|
|
103
104
|
private nodeView;
|
|
105
|
+
/**
|
|
106
|
+
* The node's self-description, projected for the record. The root's comes from the om
|
|
107
|
+
* builder; a child's from its action definition. The args schema is converted here, once per
|
|
108
|
+
* definition, so a step launched a thousand times converts its schema once.
|
|
109
|
+
*/
|
|
110
|
+
private metaView;
|
|
104
111
|
/** Test seam: the projected run view (same object written to result.json). */
|
|
105
112
|
runViewForTest(): RunView;
|
|
106
113
|
/** Test seam: the run's curated artifacts. */
|
|
@@ -61,6 +61,7 @@ export class ExecutionTree {
|
|
|
61
61
|
snapshotStore;
|
|
62
62
|
artifactStore = new ArtifactStore();
|
|
63
63
|
registry = [];
|
|
64
|
+
schemaCache = new Map();
|
|
64
65
|
unsettled = new Set();
|
|
65
66
|
originalLog = console.log.bind(console);
|
|
66
67
|
streaming;
|
|
@@ -137,6 +138,7 @@ export class ExecutionTree {
|
|
|
137
138
|
// into the parent's log, attributed to this child's id.
|
|
138
139
|
bubble: (message) => this.append(parent, "child", id, message),
|
|
139
140
|
});
|
|
141
|
+
node.meta = spec.meta;
|
|
140
142
|
parent.children.push(node);
|
|
141
143
|
this.registry.push(node);
|
|
142
144
|
// Launch reference — the parent's pointer to the child's own (absolute) log, plus where
|
|
@@ -386,9 +388,34 @@ export class ExecutionTree {
|
|
|
386
388
|
endedAt: node.endedAt,
|
|
387
389
|
duration: node.duration,
|
|
388
390
|
logFile: this.logFile(node.path),
|
|
391
|
+
...this.metaView(node),
|
|
389
392
|
children: node.children.map((child) => this.nodeView(child)),
|
|
390
393
|
};
|
|
391
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* The node's self-description, projected for the record. The root's comes from the om
|
|
397
|
+
* builder; a child's from its action definition. The args schema is converted here, once per
|
|
398
|
+
* definition, so a step launched a thousand times converts its schema once.
|
|
399
|
+
*/
|
|
400
|
+
metaView(node) {
|
|
401
|
+
const meta = node.parentId === null
|
|
402
|
+
? { description: this.description, mcp: this.mcp, describeArgs: node.meta?.describeArgs }
|
|
403
|
+
: node.meta;
|
|
404
|
+
if (!meta)
|
|
405
|
+
return {};
|
|
406
|
+
let inputSchema;
|
|
407
|
+
if (meta.describeArgs) {
|
|
408
|
+
if (!this.schemaCache.has(meta.describeArgs)) {
|
|
409
|
+
this.schemaCache.set(meta.describeArgs, meta.describeArgs().inputSchema);
|
|
410
|
+
}
|
|
411
|
+
inputSchema = this.schemaCache.get(meta.describeArgs);
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
...(meta.description === undefined ? {} : { description: { ...meta.description } }),
|
|
415
|
+
...(meta.mcp === undefined ? {} : { mcp: { ...meta.mcp } }),
|
|
416
|
+
...(inputSchema === undefined ? {} : { inputSchema }),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
392
419
|
/** Test seam: the projected run view (same object written to result.json). */
|
|
393
420
|
runViewForTest() {
|
|
394
421
|
return this.runView();
|
package/dist/core/action.js
CHANGED
|
@@ -49,9 +49,13 @@ class Builder {
|
|
|
49
49
|
// No `.args()` on this path, so there is nothing to describe — or to resolve against.
|
|
50
50
|
const describeArgs = () => describeSchema(undefined);
|
|
51
51
|
const parseArgs = (supplied) => supplied;
|
|
52
|
+
const meta = {
|
|
53
|
+
...(description === undefined ? {} : { description }),
|
|
54
|
+
...(mcp === undefined ? {} : { mcp }),
|
|
55
|
+
};
|
|
52
56
|
const exec = body;
|
|
53
57
|
const create = (...args) => {
|
|
54
|
-
const spec = { name, args, tags: [], body: exec, definedAt };
|
|
58
|
+
const spec = { name, args, tags: [], body: exec, definedAt, meta };
|
|
55
59
|
return ExecutionTree.require().launch(spec);
|
|
56
60
|
};
|
|
57
61
|
return Object.assign(create, {
|
|
@@ -102,6 +106,11 @@ class ArgsBuilder {
|
|
|
102
106
|
// that built it — rather than in whichever copy the discovery child is running.
|
|
103
107
|
const schema = this.schema;
|
|
104
108
|
const describeArgs = () => describeSchema(schema);
|
|
109
|
+
const meta = {
|
|
110
|
+
...(description === undefined ? {} : { description }),
|
|
111
|
+
...(mcp === undefined ? {} : { mcp }),
|
|
112
|
+
describeArgs,
|
|
113
|
+
};
|
|
105
114
|
// `safeParse` is what applies `.default()`s, so this is where an advertised default
|
|
106
115
|
// becomes the effective one. A refusal names the action and every failing field.
|
|
107
116
|
const parseArgs = (supplied) => {
|
|
@@ -112,7 +121,7 @@ class ArgsBuilder {
|
|
|
112
121
|
};
|
|
113
122
|
const exec = body;
|
|
114
123
|
const create = (...args) => {
|
|
115
|
-
const spec = { name, args, tags: [], body: exec, definedAt };
|
|
124
|
+
const spec = { name, args, tags: [], body: exec, definedAt, meta };
|
|
116
125
|
return ExecutionTree.require().launch(spec);
|
|
117
126
|
};
|
|
118
127
|
return Object.assign(create, {
|
package/dist/core/om.js
CHANGED
|
@@ -37,6 +37,9 @@ function launch(name, site, description, exposure, body, schema) {
|
|
|
37
37
|
}
|
|
38
38
|
const tree = new ExecutionTree(name, site, description, exposure);
|
|
39
39
|
ExecutionTree.current = tree;
|
|
40
|
+
// The root's description and exposure live on the tree; its schema rides on the node.
|
|
41
|
+
if (schema !== undefined)
|
|
42
|
+
tree.root.meta = { describeArgs: () => describeSchema(schema) };
|
|
40
43
|
const rootBody = (ctx) => body({
|
|
41
44
|
signal: ctx.signal,
|
|
42
45
|
logs: ctx.logs,
|
package/dist/core/types.d.ts
CHANGED
|
@@ -239,4 +239,17 @@ export interface LaunchSpec {
|
|
|
239
239
|
readonly body: Exec<object, unknown, unknown>;
|
|
240
240
|
/** `file:line` where the action was defined; carried onto the node for its log header. */
|
|
241
241
|
readonly definedAt: string | undefined;
|
|
242
|
+
/** What the definition declared about itself — carried onto the node for the run record. */
|
|
243
|
+
readonly meta?: ActionMeta;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* A definition's self-description, as it travels onto each node it launches: what
|
|
247
|
+
* `.describe()` and `.mcp()` said, and the `.args()` schema as JSON Schema. Read only by the
|
|
248
|
+
* run record (`result.json`, `report.html`) — the runtime never branches on it.
|
|
249
|
+
*/
|
|
250
|
+
export interface ActionMeta {
|
|
251
|
+
readonly description?: OmDescription;
|
|
252
|
+
readonly mcp?: ResolvedMcpExposure;
|
|
253
|
+
/** Converts the declared args schema; called once, at finalize. */
|
|
254
|
+
readonly describeArgs?: () => DescribedArgs;
|
|
242
255
|
}
|
|
@@ -33,10 +33,14 @@ export async function writeReport(file, input) {
|
|
|
33
33
|
const assets = [];
|
|
34
34
|
const files = await collectFiles(input, assets);
|
|
35
35
|
const snapshots = await collectSnapshots(input);
|
|
36
|
+
const root = await projectRoot(input.folder);
|
|
36
37
|
const payload = {
|
|
37
38
|
version: 1,
|
|
38
39
|
name: input.name,
|
|
39
40
|
folder: input.folder,
|
|
41
|
+
projectRoot: root,
|
|
42
|
+
sources: definitionSources(input.run.root, root),
|
|
43
|
+
code: await definitionCode(input.run.root),
|
|
40
44
|
generatedAt: Date.now(),
|
|
41
45
|
run: input.run,
|
|
42
46
|
entries: input.entries,
|
|
@@ -55,29 +59,29 @@ function renderPage(input, payload, assets, code) {
|
|
|
55
59
|
const blobs = assets
|
|
56
60
|
.map((a) => `<script type="text/plain" id="${a.id}" data-mime="${a.mime}">${a.data}</script>`)
|
|
57
61
|
.join("\n");
|
|
58
|
-
return `<!doctype html>
|
|
59
|
-
<html lang="en">
|
|
60
|
-
<head>
|
|
61
|
-
<meta charset="utf-8">
|
|
62
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
63
|
-
<title>${escapeHtml(title)}</title>
|
|
64
|
-
<style>${code.css}</style>
|
|
65
|
-
</head>
|
|
66
|
-
<body>
|
|
67
|
-
<div id="loader" role="status" aria-live="polite">
|
|
68
|
-
<div class="loader-card">
|
|
69
|
-
<div class="loader-spin"></div>
|
|
70
|
-
<div class="loader-title">${escapeHtml(title)}</div>
|
|
71
|
-
<div class="loader-step" id="loader-step">Loading the run record…</div>
|
|
72
|
-
</div>
|
|
73
|
-
</div>
|
|
74
|
-
<div id="app" hidden></div>
|
|
75
|
-
<script id="omkit-data" type="application/json">${inertJson(payload)}</script>
|
|
76
|
-
${blobs}
|
|
77
|
-
<script>${inertScript(code.d3)}</script>
|
|
78
|
-
<script>${inertScript(code.client)}</script>
|
|
79
|
-
</body>
|
|
80
|
-
</html>
|
|
62
|
+
return `<!doctype html>
|
|
63
|
+
<html lang="en">
|
|
64
|
+
<head>
|
|
65
|
+
<meta charset="utf-8">
|
|
66
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
67
|
+
<title>${escapeHtml(title)}</title>
|
|
68
|
+
<style>${code.css}</style>
|
|
69
|
+
</head>
|
|
70
|
+
<body>
|
|
71
|
+
<div id="loader" role="status" aria-live="polite">
|
|
72
|
+
<div class="loader-card">
|
|
73
|
+
<div class="loader-spin"></div>
|
|
74
|
+
<div class="loader-title">${escapeHtml(title)}</div>
|
|
75
|
+
<div class="loader-step" id="loader-step">Loading the run record…</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<div id="app" hidden></div>
|
|
79
|
+
<script id="omkit-data" type="application/json">${inertJson(payload)}</script>
|
|
80
|
+
${blobs}
|
|
81
|
+
<script>${inertScript(code.d3)}</script>
|
|
82
|
+
<script>${inertScript(code.client)}</script>
|
|
83
|
+
</body>
|
|
84
|
+
</html>
|
|
81
85
|
`;
|
|
82
86
|
}
|
|
83
87
|
async function collectFiles(input, assets) {
|
|
@@ -117,7 +121,11 @@ async function collectFiles(input, assets) {
|
|
|
117
121
|
out.push({ ...base, size, text: await readFile(abs, "utf8") });
|
|
118
122
|
}
|
|
119
123
|
else {
|
|
120
|
-
out.push({
|
|
124
|
+
out.push({
|
|
125
|
+
...base,
|
|
126
|
+
size,
|
|
127
|
+
omitted: isText(meta.mime) ? `larger than ${mb(TEXT_CAP)}` : "binary",
|
|
128
|
+
});
|
|
121
129
|
}
|
|
122
130
|
};
|
|
123
131
|
for (const a of input.artifacts) {
|
|
@@ -150,7 +158,14 @@ async function collectSnapshots(input) {
|
|
|
150
158
|
continue;
|
|
151
159
|
const name = e.message.slice(0, split);
|
|
152
160
|
const abs = e.message.slice(split + 3);
|
|
153
|
-
const base = {
|
|
161
|
+
const base = {
|
|
162
|
+
seq: ++seq,
|
|
163
|
+
name,
|
|
164
|
+
nodeId: e.nodeId,
|
|
165
|
+
ts: e.ts,
|
|
166
|
+
file: abs,
|
|
167
|
+
rel: relTo(input.folder, abs),
|
|
168
|
+
};
|
|
154
169
|
try {
|
|
155
170
|
const raw = await readFile(abs, "utf8");
|
|
156
171
|
if (raw.length > SNAPSHOT_CAP) {
|
|
@@ -183,6 +198,100 @@ function artifactTimes(entries) {
|
|
|
183
198
|
}
|
|
184
199
|
return out;
|
|
185
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* The project the run belongs to, for project-relative paths: the nearest ancestor of the run
|
|
203
|
+
* folder holding `.git`, else the folder `logs/` sits in (the root omkit was given), else cwd.
|
|
204
|
+
*/
|
|
205
|
+
async function projectRoot(folder) {
|
|
206
|
+
let dir = path.resolve(folder);
|
|
207
|
+
for (;;) {
|
|
208
|
+
try {
|
|
209
|
+
await stat(path.join(dir, ".git"));
|
|
210
|
+
return dir;
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
/* keep climbing */
|
|
214
|
+
}
|
|
215
|
+
const up = path.dirname(dir);
|
|
216
|
+
if (up === dir)
|
|
217
|
+
break;
|
|
218
|
+
dir = up;
|
|
219
|
+
}
|
|
220
|
+
return process.env.OMKIT_ROOT ? path.resolve(process.env.OMKIT_ROOT) : process.cwd();
|
|
221
|
+
}
|
|
222
|
+
/** Each distinct `definedAt` site → its file, relative to the project root, forward-slashed, no line. */
|
|
223
|
+
function definitionSources(root, projectDir) {
|
|
224
|
+
const out = {};
|
|
225
|
+
const visit = (n) => {
|
|
226
|
+
if (n.definedAt && !(n.definedAt in out)) {
|
|
227
|
+
const file = n.definedAt.replace(/:\d+(:\d+)?$/, "");
|
|
228
|
+
out[n.definedAt] = path.relative(projectDir, file).split(path.sep).join("/") || file;
|
|
229
|
+
}
|
|
230
|
+
n.children.forEach(visit);
|
|
231
|
+
};
|
|
232
|
+
visit(root);
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
/** Longest excerpt shown per definition; a body past this is cut, and says so. */
|
|
236
|
+
const CODE_LINES = 300;
|
|
237
|
+
/**
|
|
238
|
+
* Each distinct `definedAt` site → the source of the definition there: the whole top-level
|
|
239
|
+
* statement holding that line (`export const x = action(…)…run(…)`, or the `om(…).run(…)` call),
|
|
240
|
+
* found with the TypeScript parser so it is the real extent, not a guess at one. A site whose
|
|
241
|
+
* file is gone or unparseable gets a window of lines around it instead, or nothing.
|
|
242
|
+
*/
|
|
243
|
+
async function definitionCode(root) {
|
|
244
|
+
const sites = new Set();
|
|
245
|
+
const visit = (n) => {
|
|
246
|
+
if (n.definedAt)
|
|
247
|
+
sites.add(n.definedAt);
|
|
248
|
+
n.children.forEach(visit);
|
|
249
|
+
};
|
|
250
|
+
visit(root);
|
|
251
|
+
if (sites.size === 0)
|
|
252
|
+
return {};
|
|
253
|
+
// Loaded only here: the parser is heavy, and a run that never writes a report never pays for it.
|
|
254
|
+
const ts = (await import("typescript")).default;
|
|
255
|
+
const out = {};
|
|
256
|
+
const texts = new Map();
|
|
257
|
+
for (const site of sites) {
|
|
258
|
+
const m = /^(.*):(\d+)(?::\d+)?$/.exec(site);
|
|
259
|
+
if (!m)
|
|
260
|
+
continue;
|
|
261
|
+
const file = m[1];
|
|
262
|
+
const line = Number(m[2]);
|
|
263
|
+
if (!texts.has(file))
|
|
264
|
+
texts.set(file, await readFile(file, "utf8").catch(() => null));
|
|
265
|
+
const text = texts.get(file);
|
|
266
|
+
if (!text)
|
|
267
|
+
continue;
|
|
268
|
+
const lang = /\.[cm]?tsx?$/i.test(file) ? "ts" : "js";
|
|
269
|
+
const lines = text.split(/\r?\n/);
|
|
270
|
+
let from = Math.max(1, line - 10);
|
|
271
|
+
let to = Math.min(lines.length, line + 20);
|
|
272
|
+
try {
|
|
273
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
274
|
+
const pos = sf.getPositionOfLineAndCharacter(Math.min(line, sf.getLineStarts().length) - 1, 0);
|
|
275
|
+
const stmt = sf.statements.find((s) => s.getStart(sf) <= pos && pos < s.getEnd()) ??
|
|
276
|
+
sf.statements.find((s) => s.getFullStart() <= pos && pos < s.getEnd());
|
|
277
|
+
if (stmt) {
|
|
278
|
+
from = sf.getLineAndCharacterOfPosition(stmt.getStart(sf, true)).line + 1;
|
|
279
|
+
to = sf.getLineAndCharacterOfPosition(stmt.getEnd()).line + 1;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
/* fall back to the window around the line */
|
|
284
|
+
}
|
|
285
|
+
const truncated = to - from + 1 > CODE_LINES;
|
|
286
|
+
out[site] = {
|
|
287
|
+
start: from,
|
|
288
|
+
text: lines.slice(from - 1, truncated ? from - 1 + CODE_LINES : to).join("\n"),
|
|
289
|
+
lang,
|
|
290
|
+
...(truncated ? { truncated } : {}),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
186
295
|
async function walk(dir, depth = 0) {
|
|
187
296
|
if (depth > 4)
|
|
188
297
|
return [];
|
|
@@ -153,10 +153,13 @@ button { font: inherit; color: inherit; }
|
|
|
153
153
|
.guide::before { content: ""; position: absolute; left: 7px; top: 0; bottom: 0; border-left: 1px solid var(--line); }
|
|
154
154
|
|
|
155
155
|
.chart { position: relative; min-width: 0; }
|
|
156
|
-
.chart svg { display: block; }
|
|
157
|
-
.chart .bar-label { font-size: 12px; fill: var(--bar-text); font-weight: 500;
|
|
156
|
+
.chart svg { display: block; overflow: hidden; }
|
|
157
|
+
.chart .bar-label { font-size: 12px; fill: var(--bar-text); font-weight: 500; cursor: pointer; }
|
|
158
|
+
.chart .hit { fill: transparent; cursor: pointer; }
|
|
159
|
+
.chart .hit:hover + .bar { filter: brightness(1.15); }
|
|
158
160
|
.chart .bar-label.outside { fill: var(--text); }
|
|
159
|
-
.chart .mark-label { font-size: 11px; fill: var(--text-2);
|
|
161
|
+
.chart .mark-label { font-size: 11px; fill: var(--text-2); cursor: pointer; }
|
|
162
|
+
.chart .mark-label:hover { fill: var(--text); }
|
|
160
163
|
.chart .grid line { stroke: var(--line-soft); }
|
|
161
164
|
.chart .cursor { stroke: var(--text-2); stroke-dasharray: 3 3; pointer-events: none; }
|
|
162
165
|
.chart .link { fill: none; stroke: var(--text-3); stroke-width: 1.2; }
|
|
@@ -175,7 +178,7 @@ button { font: inherit; color: inherit; }
|
|
|
175
178
|
.flow-link { fill: none; stroke: var(--text-3); stroke-width: 1.3; }
|
|
176
179
|
.flame-rect { cursor: pointer; stroke: var(--panel); stroke-width: 1; }
|
|
177
180
|
.flame-rect.selected { stroke: var(--text); stroke-width: 2; }
|
|
178
|
-
.flame-text { font-size: 11px; fill: var(--bar-text);
|
|
181
|
+
.flame-text { font-size: 11px; fill: var(--bar-text); cursor: pointer; }
|
|
179
182
|
|
|
180
183
|
/* ── details ────────────────────────────────────────────── */
|
|
181
184
|
.details { display: grid; grid-template-rows: auto auto minmax(0, 1fr); overflow: hidden; }
|
|
@@ -252,6 +255,31 @@ table.list tr:hover td { background: var(--raise); }
|
|
|
252
255
|
}
|
|
253
256
|
.tip .tt { font-weight: 600; margin-bottom: 2px; }
|
|
254
257
|
.tip .ts { color: var(--text-2); }
|
|
258
|
+
.tip.wide { max-width: 420px; min-width: 260px; }
|
|
259
|
+
.tip .mono { font-family: var(--mono); font-size: 11px; overflow-wrap: anywhere; }
|
|
260
|
+
.tip .small { color: var(--text-2); }
|
|
261
|
+
.tip .badges { display: flex; flex-wrap: wrap; gap: 5px; margin: 6px 0; }
|
|
262
|
+
.tip .badge { display: inline-flex; align-items: center; gap: 5px; padding: 1px 8px; border-radius: 999px; background: var(--raise); font-size: 11px; color: var(--text-2); }
|
|
263
|
+
.tip .badge.mcp { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
|
264
|
+
.tip .summary { margin: 4px 0 8px; line-height: 1.45; }
|
|
265
|
+
.tip .tkv { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 2px 10px; font-variant-numeric: tabular-nums; }
|
|
266
|
+
.tip .tkv .k, .tip .sec { color: var(--text-3); }
|
|
267
|
+
.tip .sec { margin: 8px 0 3px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
268
|
+
.tip .sec.fail { color: var(--fail); }
|
|
269
|
+
.params { display: grid; gap: 3px; }
|
|
270
|
+
.param { font: 11px/1.4 var(--mono); }
|
|
271
|
+
.param .pn { color: var(--text); }
|
|
272
|
+
.param .pt { color: var(--accent); margin-left: 6px; }
|
|
273
|
+
.param .pd { color: var(--text-3); margin-left: 6px; }
|
|
274
|
+
.param .pdesc { font-family: var(--sans); color: var(--text-2); margin-left: 10px; }
|
|
275
|
+
.kv .v.defined { display: flex; align-items: center; gap: 6px; }
|
|
276
|
+
.kv .v.defined a { min-width: 0; overflow-wrap: anywhere; font-family: var(--mono); font-size: 12px; }
|
|
277
|
+
.icon-btn { flex: none; border: 1px solid var(--line); background: var(--panel-2); color: var(--text-2); border-radius: 6px; width: 24px; height: 22px; display: grid; place-items: center; cursor: pointer; padding: 0; }
|
|
278
|
+
.icon-btn:hover { color: var(--text); border-color: var(--accent); }
|
|
279
|
+
.icon-btn svg { width: 13px; height: 13px; }
|
|
280
|
+
.tip .hint { margin-top: 8px; color: var(--text-3); font-size: 11px; }
|
|
281
|
+
.tip kbd { font: 600 10px var(--mono); border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 4px; padding: 0 4px; color: var(--text-2); }
|
|
282
|
+
.badge-inline { display: inline-block; padding: 0 7px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); font-size: 11px; font-weight: 600; }
|
|
255
283
|
.tip img { display: block; max-width: 240px; max-height: 150px; border-radius: 4px; margin: 6px 0; border: 1px solid var(--line); }
|
|
256
284
|
.modal { position: fixed; inset: 0; background: rgba(5, 8, 18, 0.72); z-index: 60; display: grid; place-items: center; padding: 28px; }
|
|
257
285
|
.modal .box { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); max-width: min(1200px, 100%); max-height: 100%; display: grid; grid-template-rows: auto minmax(0, 1fr); overflow: hidden; }
|
|
@@ -264,6 +292,52 @@ table.list tr:hover td { background: var(--raise); }
|
|
|
264
292
|
.menu button:hover { background: var(--raise); }
|
|
265
293
|
.toast { position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%); background: var(--raise); border: 1px solid var(--line); padding: 7px 14px; border-radius: 8px; z-index: 70; }
|
|
266
294
|
|
|
295
|
+
/* ── title info, locate, defined-at, code popover ───────── */
|
|
296
|
+
.title-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
297
|
+
.title-row h1 { min-width: 0; }
|
|
298
|
+
.info-btn { flex: none; width: 22px; height: 22px; border: 0; border-radius: 50%; background: none; color: var(--text-3); display: grid; place-items: center; cursor: help; padding: 0; }
|
|
299
|
+
.info-btn:hover, .info-btn:focus-visible { color: var(--accent); background: var(--accent-soft); }
|
|
300
|
+
.info-btn svg { width: 17px; height: 17px; }
|
|
301
|
+
.gal-item .cap .locate { margin-left: auto; width: 22px; height: 20px; }
|
|
302
|
+
.kv .v.defined { flex-wrap: wrap; }
|
|
303
|
+
.kv .v.defined .defined-name { color: var(--text-2); font-family: var(--mono); font-size: 12px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
304
|
+
a.icon-btn { text-decoration: none; }
|
|
305
|
+
.chart .code-btn, .flow-card .code-btn { cursor: pointer; opacity: 0; transition: opacity 0.12s; }
|
|
306
|
+
.chart g.r:hover .code-btn, .chart g.r.sel .code-btn, .flow-card:hover .code-btn, .flow-card.selected .code-btn { opacity: 1; }
|
|
307
|
+
.code-btn circle { fill: var(--panel); stroke: var(--text-3); stroke-width: 1.2; }
|
|
308
|
+
.code-btn text { fill: var(--text-2); font: 700 9px var(--mono); pointer-events: none; }
|
|
309
|
+
.code-btn:hover circle { stroke: var(--accent); }
|
|
310
|
+
.code-btn:hover text { fill: var(--accent); }
|
|
311
|
+
.chart line.pin.flash, .flame-view line.pin.flash { animation: pin-flash 0.6s ease-in-out 3; }
|
|
312
|
+
.chart circle.mark.flash { animation: mark-flash 0.6s ease-in-out 3; transform-box: fill-box; transform-origin: center; }
|
|
313
|
+
@keyframes pin-flash { 50% { stroke-width: 5; } }
|
|
314
|
+
@keyframes mark-flash { 50% { transform: scale(1.9); } }
|
|
315
|
+
.code-pop {
|
|
316
|
+
position: fixed; z-index: 58; width: 640px; max-width: 92vw; max-height: 70vh; display: flex; flex-direction: column;
|
|
317
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--shadow); overflow: hidden;
|
|
318
|
+
}
|
|
319
|
+
.code-pop .cp-head { display: flex; align-items: center; gap: 6px; padding: 6px 8px 6px 12px; background: var(--panel-2); border-bottom: 1px solid var(--line); }
|
|
320
|
+
.code-pop .cp-path { font: 12px var(--mono); color: var(--text-2); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
321
|
+
.code-pop .cp-name { flex: 1; text-align: right; color: var(--text-3); font-size: 12px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
322
|
+
.code-pop .cp-body { overflow: auto; min-height: 0; }
|
|
323
|
+
.code-pop .cp-code { font: 11.5px/1.65 var(--mono); padding: 8px 0; min-width: max-content; }
|
|
324
|
+
.code-pop .cl { display: grid; grid-template-columns: 48px auto; white-space: pre; }
|
|
325
|
+
.code-pop .cl.at { background: var(--accent-soft); }
|
|
326
|
+
.code-pop .ln { color: var(--text-3); text-align: right; padding-right: 14px; user-select: none; }
|
|
327
|
+
.code-pop .lc { padding-right: 16px; }
|
|
328
|
+
.code-pop .cp-more { padding: 6px 14px 10px; font-size: 12px; }
|
|
329
|
+
:root { --tk-c: #6f7a99; --tk-s: #7ee0a3; --tk-n: #f0b26b; --tk-k: #c792ea; --tk-f: #82aaff; --tk-t: #ffcb6b; }
|
|
330
|
+
:root[data-theme="light"] { --tk-c: #6a737d; --tk-s: #116329; --tk-n: #b35900; --tk-k: #cf222e; --tk-f: #6639ba; --tk-t: #953800; }
|
|
331
|
+
@media (prefers-color-scheme: light) {
|
|
332
|
+
:root:not([data-theme="dark"]) { --tk-c: #6a737d; --tk-s: #116329; --tk-n: #b35900; --tk-k: #cf222e; --tk-f: #6639ba; --tk-t: #953800; }
|
|
333
|
+
}
|
|
334
|
+
.tk-c { color: var(--tk-c); font-style: italic; }
|
|
335
|
+
.tk-s { color: var(--tk-s); }
|
|
336
|
+
.tk-n { color: var(--tk-n); }
|
|
337
|
+
.tk-k { color: var(--tk-k); }
|
|
338
|
+
.tk-f { color: var(--tk-f); }
|
|
339
|
+
.tk-t { color: var(--tk-t); }
|
|
340
|
+
|
|
267
341
|
@media (max-width: 1100px) {
|
|
268
342
|
.bottom { grid-template-columns: minmax(0, 1fr); }
|
|
269
343
|
.legend, .overview { display: none; }
|
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
download: "M12 4v11M7 10l5 5 5-5M5 20h14",
|
|
41
41
|
copy: "M9 9h11v11H9zM5 15V4h11",
|
|
42
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
|
+
external: "M14 4h6v6M20 4l-9 9M18 14v6H4V6h6",
|
|
43
47
|
};
|
|
44
48
|
const svgIcon = (name, extra) =>
|
|
45
49
|
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"${
|
|
@@ -297,6 +301,11 @@
|
|
|
297
301
|
zoomToDomain(Math.max(0, d0), Math.max(0, d0) + ms);
|
|
298
302
|
});
|
|
299
303
|
app.querySelector("#more").addEventListener("click", (ev) => openMenu(ev.currentTarget));
|
|
304
|
+
const info = app.querySelector("#om-info");
|
|
305
|
+
info.addEventListener("mouseenter", (ev) => showTip(ev, omTip(), true));
|
|
306
|
+
info.addEventListener("mousemove", (ev) => moveTip(ev));
|
|
307
|
+
info.addEventListener("mouseleave", hideTip);
|
|
308
|
+
info.addEventListener("click", () => select(model.root.id));
|
|
300
309
|
|
|
301
310
|
// ── zoom behaviours ──
|
|
302
311
|
const chartZoom = d3
|
|
@@ -393,6 +402,9 @@
|
|
|
393
402
|
select(n.id);
|
|
394
403
|
});
|
|
395
404
|
r.addEventListener("dblclick", () => focusNode(n));
|
|
405
|
+
r.addEventListener("mouseenter", (ev) => showTip(ev, nodeTip(n), true, 280));
|
|
406
|
+
r.addEventListener("mousemove", (ev) => moveTip(ev));
|
|
407
|
+
r.addEventListener("mouseleave", hideTip);
|
|
396
408
|
});
|
|
397
409
|
}
|
|
398
410
|
function treeRowHtml(n) {
|
|
@@ -488,12 +500,18 @@
|
|
|
488
500
|
.data(rowsNow, (n) => n.id)
|
|
489
501
|
.join((enter) => {
|
|
490
502
|
const r = enter.append("g").attr("class", "r");
|
|
503
|
+
// A transparent target spanning the bar *and* its label, so a 2px bar is still hoverable.
|
|
504
|
+
r.append("rect").attr("class", "hit").attr("y", BAR_Y - 4).attr("height", BAR_H + 8);
|
|
491
505
|
r.append("rect").attr("class", "bar").attr("rx", 5).attr("y", BAR_Y).attr("height", BAR_H);
|
|
492
506
|
r.append("text").attr("class", "bar-label").attr("y", BAR_Y + BAR_H / 2 + 4);
|
|
493
507
|
r.append("g").attr("class", "marks");
|
|
508
|
+
const cb = r.append("g").attr("class", "code-btn");
|
|
509
|
+
cb.append("circle").attr("r", 9);
|
|
510
|
+
cb.append("text").attr("text-anchor", "middle").attr("y", 3.5).text("<>");
|
|
494
511
|
return r;
|
|
495
512
|
})
|
|
496
|
-
.attr("transform", (_n, i) => `translate(0,${i * ROW_H})`)
|
|
513
|
+
.attr("transform", (_n, i) => `translate(0,${i * ROW_H})`)
|
|
514
|
+
.classed("sel", (n) => n.id === state.selected);
|
|
497
515
|
|
|
498
516
|
g.select("rect.bar")
|
|
499
517
|
.attr("x", (n) => x(n.start))
|
|
@@ -504,17 +522,7 @@
|
|
|
504
522
|
.attr("stroke-width", (n) => (n.status === "failed" ? 2 : null))
|
|
505
523
|
.attr("stroke-dasharray", (n) => (n.status === "cancelled" ? "4 3" : null))
|
|
506
524
|
.classed("selected", (n) => n.id === state.selected)
|
|
507
|
-
.
|
|
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);
|
|
525
|
+
.call(bindNode);
|
|
518
526
|
|
|
519
527
|
g.select("text.bar-label").each(function (n) {
|
|
520
528
|
const t = d3.select(this);
|
|
@@ -524,10 +532,34 @@
|
|
|
524
532
|
const need = text.length * 6.6 + 14;
|
|
525
533
|
const inside = w >= need;
|
|
526
534
|
const clipX = Math.max(x0, 0);
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
535
|
+
const outside = !inside && w <= 60;
|
|
536
|
+
const shown = inside || outside ? text : truncate(text, (w - 14) / 6.6);
|
|
537
|
+
const tw = shown.length * 6.6;
|
|
538
|
+
// an outside label that would run off the chart hangs left of its bar instead
|
|
539
|
+
const flip = outside && x0 + Math.max(w, 3) + 6 + tw > W;
|
|
540
|
+
const lx = flip ? x0 - 6 : outside ? x0 + Math.max(w, 3) + 6 : Math.min(clipX + 8, x0 + w - 20);
|
|
541
|
+
t.text(shown).classed("outside", outside).attr("x", lx).attr("text-anchor", flip ? "end" : "start");
|
|
542
|
+
// the hit target covers bar + label, padded so a sliver of a bar is still easy to reach
|
|
543
|
+
const left = Math.min(x0, flip ? lx - tw : lx) - 6;
|
|
544
|
+
const right = Math.max(x0 + Math.max(w, 3), flip ? x0 : lx + tw) + 6;
|
|
545
|
+
d3.select(this.parentNode).select("rect.hit").attr("x", left).attr("width", Math.max(16, right - left));
|
|
546
|
+
// the <> sits just past the block (or just before it, when that would leave the chart)
|
|
547
|
+
// …and, for a block spanning the chart, inside it right after its label
|
|
548
|
+
const cx =
|
|
549
|
+
right + 8 <= W - 10 ? right + 8 : left - 8 >= 10 ? left - 8 : Math.min(W - 12, (flip ? x0 : lx + tw) + 14);
|
|
550
|
+
d3.select(this.parentNode)
|
|
551
|
+
.select("g.code-btn")
|
|
552
|
+
.style("display", hasCode(n) ? null : "none")
|
|
553
|
+
.attr("transform", `translate(${cx},${BAR_Y + BAR_H / 2})`);
|
|
530
554
|
});
|
|
555
|
+
g.select("g.code-btn")
|
|
556
|
+
.on("click", (ev, n) => {
|
|
557
|
+
ev.stopPropagation();
|
|
558
|
+
toggleCode(n, ev);
|
|
559
|
+
})
|
|
560
|
+
.on("mouseenter", (ev) => showTip(ev, "Show source"))
|
|
561
|
+
.on("mouseleave", hideTip);
|
|
562
|
+
g.selectAll("rect.hit, text.bar-label").call(bindNode);
|
|
531
563
|
|
|
532
564
|
g.select("g.marks").each(function (n) {
|
|
533
565
|
drawMarks(d3.select(this), n, x);
|
|
@@ -584,7 +616,14 @@
|
|
|
584
616
|
.attr("x", (l) => (flip(l) ? l.d.px + 4 : l.d.px - 4))
|
|
585
617
|
.attr("text-anchor", (l) => (flip(l) ? "end" : "start"))
|
|
586
618
|
.attr("y", BAR_Y + BAR_H + 13)
|
|
587
|
-
.text((l) => l.text)
|
|
619
|
+
.text((l) => l.text)
|
|
620
|
+
.on("click", (ev, l) => {
|
|
621
|
+
ev.stopPropagation();
|
|
622
|
+
openItem(l.d.m);
|
|
623
|
+
})
|
|
624
|
+
.on("mouseenter", (ev, l) => showTip(ev, markTip(l.d)))
|
|
625
|
+
.on("mousemove", (ev) => moveTip(ev))
|
|
626
|
+
.on("mouseleave", hideTip);
|
|
588
627
|
}
|
|
589
628
|
function worst(d) {
|
|
590
629
|
const all = [d.m, ...d.more].map((m) => m.kind);
|
|
@@ -634,12 +673,15 @@
|
|
|
634
673
|
.on("mouseleave", () => moveCursor(null));
|
|
635
674
|
flameSvg.append("g").attr("class", "grid");
|
|
636
675
|
flameSvg.append("g").attr("class", "cells");
|
|
676
|
+
flameSvg.append("line").attr("class", "pin").attr("stroke", css("--accent")).attr("stroke-width", 1.5).attr("y1", 0).attr("y2", 4000);
|
|
637
677
|
flameSvg.append("line").attr("class", "cursor").attr("y1", 0).attr("y2", 4000).attr("visibility", "hidden");
|
|
638
678
|
flameSvg.call(flameZoom).call(flameZoom.transform, state.zoom);
|
|
639
679
|
redrawX();
|
|
640
680
|
}
|
|
641
681
|
function drawFlame(x) {
|
|
642
682
|
if (!flameSvg) return;
|
|
683
|
+
const px = state.pin != null ? x(state.pin) : -10;
|
|
684
|
+
flameSvg.select("line.pin").attr("x1", px).attr("x2", px);
|
|
643
685
|
const H = el.flame.clientHeight;
|
|
644
686
|
const ticks = x.ticks(Math.max(2, Math.floor(W / 90)));
|
|
645
687
|
flameSvg
|
|
@@ -669,25 +711,20 @@
|
|
|
669
711
|
.attr("fill", (n) => colorOf(n))
|
|
670
712
|
.attr("fill-opacity", (n) => (n.status === "cancelled" ? 0.55 : 0.92))
|
|
671
713
|
.classed("selected", (n) => n.id === state.selected)
|
|
672
|
-
.
|
|
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);
|
|
714
|
+
.call(bindNode);
|
|
680
715
|
cells
|
|
681
716
|
.select("text")
|
|
682
717
|
.attr("x", (n) => Math.max(x(n.start), 0) + 5)
|
|
683
718
|
.text((n) => {
|
|
684
719
|
const w = x(n.end) - Math.max(x(n.start), 0);
|
|
685
720
|
return w < 34 ? "" : truncate(`${displayName(n)} ${fmtDur(n.duration)}`, (w - 10) / 6.2);
|
|
686
|
-
})
|
|
721
|
+
})
|
|
722
|
+
.call(bindNode);
|
|
687
723
|
}
|
|
688
724
|
|
|
689
725
|
// ── flow view ──
|
|
690
726
|
let flowG = null;
|
|
727
|
+
let flowPos = new Map();
|
|
691
728
|
let flowBounds = null;
|
|
692
729
|
function renderFlow() {
|
|
693
730
|
d3.select(el.flow).selectAll("svg").remove();
|
|
@@ -747,6 +784,22 @@
|
|
|
747
784
|
.attr("cy", 14)
|
|
748
785
|
.attr("r", 4)
|
|
749
786
|
.attr("fill", (d) => css(statusVar(d.data.status)));
|
|
787
|
+
const fcb = card
|
|
788
|
+
.filter((d) => hasCode(d.data))
|
|
789
|
+
.append("g")
|
|
790
|
+
.attr("class", "code-btn")
|
|
791
|
+
.attr("transform", `translate(${CW / 2},0)`)
|
|
792
|
+
.on("click", (ev, d) => {
|
|
793
|
+
ev.stopPropagation();
|
|
794
|
+
toggleCode(d.data, ev);
|
|
795
|
+
})
|
|
796
|
+
.on("mouseenter", (ev) => {
|
|
797
|
+
ev.stopPropagation();
|
|
798
|
+
showTip(ev, "Show source");
|
|
799
|
+
});
|
|
800
|
+
fcb.append("circle").attr("r", 9);
|
|
801
|
+
fcb.append("text").attr("text-anchor", "middle").attr("y", 3.5).text("<>");
|
|
802
|
+
flowPos = new Map(h.descendants().map((d) => [d.data.id, { x: d.y + CW / 2, y: d.x }]));
|
|
750
803
|
svg.call(flowZoom);
|
|
751
804
|
fitFlow(true);
|
|
752
805
|
}
|
|
@@ -815,7 +868,10 @@
|
|
|
815
868
|
state.details = true;
|
|
816
869
|
el.main.classList.remove("no-details");
|
|
817
870
|
el.tree.querySelectorAll(".row").forEach((r) => r.classList.toggle("selected", r.dataset.id === id));
|
|
818
|
-
if (chartSvg)
|
|
871
|
+
if (chartSvg) {
|
|
872
|
+
chartSvg.selectAll("rect.bar").classed("selected", (n) => n.id === id);
|
|
873
|
+
chartSvg.selectAll("g.r").classed("sel", (n) => n.id === id);
|
|
874
|
+
}
|
|
819
875
|
if (flameSvg) flameSvg.selectAll("rect.flame-rect").classed("selected", (n) => n.id === id);
|
|
820
876
|
d3.select(el.flow).selectAll("g.flow-card").classed("selected", (d) => d.data.id === id);
|
|
821
877
|
renderDetails();
|
|
@@ -824,7 +880,7 @@
|
|
|
824
880
|
const pad = Math.max(n.end - n.start, 50) * 0.08;
|
|
825
881
|
zoomToDomain(n.start - pad, n.end + pad);
|
|
826
882
|
}
|
|
827
|
-
function revealRow(n) {
|
|
883
|
+
function revealRow(n, block) {
|
|
828
884
|
let p = n.parent;
|
|
829
885
|
let changed = false;
|
|
830
886
|
while (p) {
|
|
@@ -833,7 +889,55 @@
|
|
|
833
889
|
}
|
|
834
890
|
if (changed && state.view === "timeline") renderTimeline();
|
|
835
891
|
const row = el.tree.querySelector(`.row[data-id="${CSS.escape(n.id)}"]`);
|
|
836
|
-
if (row) row.scrollIntoView({ block: "nearest" });
|
|
892
|
+
if (row) row.scrollIntoView({ block: block || "nearest" });
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Bring a gallery item into view in whichever graph is showing: select its node, pin its
|
|
897
|
+
* time, pan (keeping the zoom) so that time is on screen, and pulse its mark.
|
|
898
|
+
*/
|
|
899
|
+
function locate(g) {
|
|
900
|
+
if (!g) return;
|
|
901
|
+
if (g.node) select(g.node.id, "snapshots");
|
|
902
|
+
if (state.view === "flow") {
|
|
903
|
+
if (g.node) centerFlowOn(g.node);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
if (g.t != null) {
|
|
907
|
+
state.pin = g.t;
|
|
908
|
+
const [a, b] = xz().domain();
|
|
909
|
+
if (g.t < a || g.t > b) {
|
|
910
|
+
const k = state.zoom.k;
|
|
911
|
+
const t = d3.zoomIdentity.translate(W / 2 - k * baseX(g.t), 0).scale(k);
|
|
912
|
+
const { z, sel } = activeZoom();
|
|
913
|
+
sel.call(z.transform, t);
|
|
914
|
+
} else redrawX();
|
|
915
|
+
}
|
|
916
|
+
if (g.node && state.view === "timeline") revealRow(g.node, "center");
|
|
917
|
+
flash(g);
|
|
918
|
+
}
|
|
919
|
+
function flash(g) {
|
|
920
|
+
const svgs = [chartSvg, flameSvg].filter(Boolean);
|
|
921
|
+
for (const s of svgs) {
|
|
922
|
+
s.selectAll("line.pin").classed("flash", false).node()?.getBoundingClientRect();
|
|
923
|
+
s.selectAll("line.pin").classed("flash", true);
|
|
924
|
+
}
|
|
925
|
+
if (chartSvg) {
|
|
926
|
+
const hit = chartSvg
|
|
927
|
+
.selectAll("circle.mark")
|
|
928
|
+
.filter((d) => d.m.gallery === g || d.more.some((m) => m.gallery === g));
|
|
929
|
+
hit.classed("flash", false).node()?.getBoundingClientRect();
|
|
930
|
+
hit.classed("flash", true);
|
|
931
|
+
}
|
|
932
|
+
setTimeout(() => d3.selectAll(".flash").classed("flash", false), 1800);
|
|
933
|
+
}
|
|
934
|
+
function centerFlowOn(n) {
|
|
935
|
+
const svg = d3.select(el.flow).select("svg");
|
|
936
|
+
const p = flowPos.get(n.id);
|
|
937
|
+
if (svg.empty() || !p) return;
|
|
938
|
+
const k = Math.max(0.8, d3.zoomTransform(svg.node()).k);
|
|
939
|
+
const t = d3.zoomIdentity.translate(el.flow.clientWidth / 2 - k * p.x, el.flow.clientHeight / 2 - k * p.y).scale(k);
|
|
940
|
+
svg.transition().duration(300).call(flowZoom.transform, t);
|
|
837
941
|
}
|
|
838
942
|
|
|
839
943
|
function renderDetails() {
|
|
@@ -858,14 +962,40 @@
|
|
|
858
962
|
<div class="k">Duration</div><div class="v">${fmtDur(n.duration)}</div>
|
|
859
963
|
<div class="k">Start</div><div class="v">${fmtRel(n.start)} (${fmtClock(n.startAbs)})</div>
|
|
860
964
|
<div class="k">End</div><div class="v">${fmtRel(n.end)} (${fmtClock(n.endAbs)})</div>
|
|
965
|
+
${n.v.description ? `<div class="k">Summary</div><div class="v">${esc(n.v.description.summary)}</div>` : ""}
|
|
966
|
+
${n.v.mcp ? `<div class="k">MCP</div><div class="v"><span class="badge-inline">${esc(n.v.mcp.mode)}</span></div>` : ""}
|
|
861
967
|
<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
|
|
968
|
+
${n.v.definedAt ? `<div class="k">Defined at</div><div class="v defined">
|
|
969
|
+
<button class="icon-btn" data-copy-source aria-label="Copy relative path">${svgIcon("copy")}</button>
|
|
970
|
+
${hasCode(n) ? `<button class="icon-btn" data-show-code aria-label="Show source">${svgIcon("code")}</button>` : ""}
|
|
971
|
+
<a class="icon-btn" href="${esc(editorUrl(n.v.definedAt))}" aria-label="Open in VS Code" data-tip-text="Open in VS Code">${svgIcon("external")}</a>
|
|
972
|
+
<span class="defined-name" >${esc(basename(sourceOf(n)))}</span>
|
|
973
|
+
</div>` : ""}
|
|
863
974
|
</div>
|
|
864
975
|
<div class="tabs">${tabs
|
|
865
976
|
.map(([k, l]) => `<button class="tab ${state.detailTab === k ? "active" : ""}" data-tab="${k}">${l}</button>`)
|
|
866
977
|
.join("")}</div>
|
|
867
978
|
<div class="tabbody">${detailTabHtml(n)}</div>
|
|
868
979
|
</div>`;
|
|
980
|
+
const copySrc = el.details.querySelector("[data-copy-source]");
|
|
981
|
+
if (copySrc) {
|
|
982
|
+
copySrc.addEventListener("click", () => copySource(n));
|
|
983
|
+
copySrc.addEventListener("mouseenter", (ev) =>
|
|
984
|
+
showTip(ev, `<div class="mono">${esc(sourceOf(n))}</div><div class="hint">Click (or press <kbd>C</kbd>) to copy</div>`)
|
|
985
|
+
);
|
|
986
|
+
copySrc.addEventListener("mousemove", (ev) => moveTip(ev));
|
|
987
|
+
copySrc.addEventListener("mouseleave", hideTip);
|
|
988
|
+
}
|
|
989
|
+
const showCode = el.details.querySelector("[data-show-code]");
|
|
990
|
+
if (showCode) {
|
|
991
|
+
showCode.addEventListener("click", (ev) => toggleCode(n, ev));
|
|
992
|
+
showCode.addEventListener("mouseenter", (ev) => showTip(ev, "Show source"));
|
|
993
|
+
showCode.addEventListener("mouseleave", hideTip);
|
|
994
|
+
}
|
|
995
|
+
el.details.querySelectorAll("[data-tip-text]").forEach((a) => {
|
|
996
|
+
a.addEventListener("mouseenter", (ev) => showTip(ev, esc(a.dataset.tipText)));
|
|
997
|
+
a.addEventListener("mouseleave", hideTip);
|
|
998
|
+
});
|
|
869
999
|
el.details.querySelector(".x").addEventListener("click", () => {
|
|
870
1000
|
state.details = false;
|
|
871
1001
|
el.main.classList.add("no-details");
|
|
@@ -883,7 +1013,9 @@
|
|
|
883
1013
|
function detailTabHtml(n) {
|
|
884
1014
|
const tab = state.detailTab;
|
|
885
1015
|
if (tab === "args") {
|
|
886
|
-
|
|
1016
|
+
const params = schemaFields(n.v.inputSchema);
|
|
1017
|
+
const schema = params.length ? `<div class="section-title">Schema</div><div class="params">${params.map(paramHtml).join("")}</div><div class="section-title">Values</div>` : "";
|
|
1018
|
+
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>');
|
|
887
1019
|
}
|
|
888
1020
|
if (tab === "events") {
|
|
889
1021
|
const evs = n.entries.filter((i) => i.kind !== "info");
|
|
@@ -991,6 +1123,14 @@
|
|
|
991
1123
|
body.querySelectorAll("tr[data-row]").forEach((tr) =>
|
|
992
1124
|
tr.addEventListener("click", () => openItem(model.items[+tr.dataset.row]))
|
|
993
1125
|
);
|
|
1126
|
+
body.querySelectorAll("[data-locate]").forEach((b) => {
|
|
1127
|
+
b.addEventListener("click", (ev) => {
|
|
1128
|
+
ev.stopPropagation();
|
|
1129
|
+
locate(model.gallery[+b.dataset.locate]);
|
|
1130
|
+
});
|
|
1131
|
+
b.addEventListener("mouseenter", (ev) => showTip(ev, `Show in the ${state.view === "flow" ? "flow" : "graph"}`));
|
|
1132
|
+
b.addEventListener("mouseleave", hideTip);
|
|
1133
|
+
});
|
|
994
1134
|
}
|
|
995
1135
|
function matches(...parts) {
|
|
996
1136
|
const q = state.filter.trim().toLowerCase();
|
|
@@ -1004,7 +1144,8 @@
|
|
|
1004
1144
|
return `<div class="gallery">${g
|
|
1005
1145
|
.map((x) => {
|
|
1006
1146
|
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
|
-
|
|
1147
|
+
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>` : "";
|
|
1148
|
+
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>`;
|
|
1008
1149
|
})
|
|
1009
1150
|
.join("")}</div>`;
|
|
1010
1151
|
}
|
|
@@ -1115,11 +1256,54 @@
|
|
|
1115
1256
|
}
|
|
1116
1257
|
|
|
1117
1258
|
// ── tooltips ──
|
|
1259
|
+
/** Click selects, double-click focuses, hover explains — the same on every node mark. */
|
|
1260
|
+
function bindNode(sel) {
|
|
1261
|
+
sel
|
|
1262
|
+
.on("click", (ev, n) => {
|
|
1263
|
+
ev.stopPropagation();
|
|
1264
|
+
select(n.id);
|
|
1265
|
+
})
|
|
1266
|
+
.on("dblclick", (ev, n) => {
|
|
1267
|
+
ev.stopPropagation();
|
|
1268
|
+
focusNode(n);
|
|
1269
|
+
})
|
|
1270
|
+
.on("mouseenter", (ev, n) => showTip(ev, nodeTip(n), true))
|
|
1271
|
+
.on("mousemove", (ev) => moveTip(ev))
|
|
1272
|
+
.on("mouseleave", hideTip);
|
|
1273
|
+
}
|
|
1118
1274
|
function nodeTip(n) {
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1275
|
+
hovered = n; // the node `C` copies while its tooltip is up
|
|
1276
|
+
const v = n.v;
|
|
1277
|
+
const shown = n.depth === 0 ? model.data.name : displayName(n);
|
|
1278
|
+
const badges = [
|
|
1279
|
+
`<span class="badge"><span class="dot ${n.status}"></span>${esc(n.status)}</span>`,
|
|
1280
|
+
v.mcp ? `<span class="badge mcp" title="Exposed over MCP">MCP · ${esc(v.mcp.mode)}</span>` : "",
|
|
1281
|
+
n.depth === 0 ? '<span class="badge">om</span>' : "",
|
|
1282
|
+
].join("");
|
|
1283
|
+
const rows = [
|
|
1284
|
+
["Duration", fmtDur(n.duration)],
|
|
1285
|
+
["When", `${fmtRel(n.start)} → ${fmtRel(n.end)}`],
|
|
1286
|
+
v.tags.length ? ["Tags", esc(v.tags.join(" · "))] : null,
|
|
1287
|
+
v.definedAt ? ["Defined", `<span class="mono">${esc(sourceOf(n))}</span>`] : null,
|
|
1288
|
+
n.children.length ? ["Children", String(n.children.length)] : null,
|
|
1289
|
+
n.marks.length ? ["Events", String(n.marks.length)] : null,
|
|
1290
|
+
].filter(Boolean);
|
|
1291
|
+
const args = v.args && v.args.length ? truncate(oneLine(v.args.length === 1 ? v.args[0] : v.args), 220) : "";
|
|
1292
|
+
const params = schemaFields(v.inputSchema);
|
|
1293
|
+
const fails = model.run.failures.filter((f) => f.action === n.id || f.action === n.name || f.action === v.path);
|
|
1294
|
+
return `<div class="tt">${esc(shown)}</div>
|
|
1295
|
+
${shown !== n.name ? `<div class="ts mono">${esc(truncate(n.name, 140))}</div>` : ""}
|
|
1296
|
+
<div class="badges">${badges}</div>
|
|
1297
|
+
${v.description ? `<div class="summary">${esc(v.description.summary)}</div>` : '<div class="summary muted">No description.</div>'}
|
|
1298
|
+
<div class="tkv">${rows.map(([k, val]) => `<span class="k">${k}</span><span>${val}</span>`).join("")}</div>
|
|
1299
|
+
${params.length ? `<div class="sec">Args schema</div><div class="params">${params.map(paramHtml).join("")}</div>` : ""}
|
|
1300
|
+
${args ? `<div class="sec">Args</div><div class="mono small">${esc(args)}</div>` : ""}
|
|
1301
|
+
${v.definedAt ? '<div class="hint">Press <kbd>C</kbd> to copy the path</div>' : ""}
|
|
1302
|
+
${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>` : ""}`;
|
|
1303
|
+
}
|
|
1304
|
+
/** The om itself: its node tooltip, plus where the run's record lives. */
|
|
1305
|
+
function omTip() {
|
|
1306
|
+
return `${nodeTip(model.root)}<div class="sec">Run folder</div><div class="mono small">${esc(model.data.folder)}</div>`;
|
|
1123
1307
|
}
|
|
1124
1308
|
function markTip(d) {
|
|
1125
1309
|
const m = d.m;
|
|
@@ -1129,12 +1313,91 @@
|
|
|
1129
1313
|
const extra = m.gallery && m.gallery.image ? `<div class="ts">${esc(m.gallery.f.rel)} · ${fmtSize(m.gallery.f.size)}</div>` : "";
|
|
1130
1314
|
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
1315
|
}
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1316
|
+
// ── source popover (the <> buttons) — click-toggled, like the tskb explorer's ──
|
|
1317
|
+
const codeEl = document.createElement("div");
|
|
1318
|
+
codeEl.className = "code-pop";
|
|
1319
|
+
codeEl.hidden = true;
|
|
1320
|
+
document.body.appendChild(codeEl);
|
|
1321
|
+
let codeFor = null;
|
|
1322
|
+
let codeAway = null;
|
|
1323
|
+
function hasCode(n) {
|
|
1324
|
+
return !!(n.v.definedAt && model.data.code && model.data.code[n.v.definedAt]);
|
|
1325
|
+
}
|
|
1326
|
+
function toggleCode(n, ev) {
|
|
1327
|
+
if (codeFor === n.id) return closeCode();
|
|
1328
|
+
const site = n.v.definedAt;
|
|
1329
|
+
const c = model.data.code && model.data.code[site];
|
|
1330
|
+
if (!c) return;
|
|
1331
|
+
hideTip();
|
|
1332
|
+
codeFor = n.id;
|
|
1333
|
+
const line = Number((/:(\d+)(?::\d+)?$/.exec(site) || [])[1]);
|
|
1334
|
+
const rel = sourceOf(n);
|
|
1335
|
+
const lines = highlight(c.text).map(
|
|
1336
|
+
(html, i) => `<div class="cl${c.start + i === line ? " at" : ""}"><span class="ln">${c.start + i}</span><span class="lc">${html || " "}</span></div>`
|
|
1337
|
+
);
|
|
1338
|
+
codeEl.innerHTML = `<div class="cp-head">
|
|
1339
|
+
<span class="cp-path" title="${esc(site)}">${esc(rel)}<span class="muted">:${line || c.start}</span></span>
|
|
1340
|
+
<button class="icon-btn" data-a="copy" aria-label="Copy path">${svgIcon("copy")}</button>
|
|
1341
|
+
<a class="icon-btn" href="${esc(editorUrl(site))}" aria-label="Open in VS Code">${svgIcon("external")}</a>
|
|
1342
|
+
<span class="cp-name">${esc(displayName(n))}</span>
|
|
1343
|
+
<button class="icon-btn" data-a="close" aria-label="Close">${svgIcon("close")}</button>
|
|
1344
|
+
</div>
|
|
1345
|
+
<div class="cp-body"><div class="cp-code">${lines.join("")}</div>${
|
|
1346
|
+
c.truncated ? '<div class="muted cp-more">Cut short — open the file for the rest.</div>' : ""
|
|
1347
|
+
}</div>`;
|
|
1348
|
+
codeEl.querySelector('[data-a="copy"]').addEventListener("click", () => copy(rel, `Copied ${rel}`));
|
|
1349
|
+
codeEl.querySelector('[data-a="close"]').addEventListener("click", closeCode);
|
|
1350
|
+
codeEl.hidden = false;
|
|
1351
|
+
const x = ev && ev.clientX != null ? ev.clientX : window.innerWidth / 2;
|
|
1352
|
+
const y = ev && ev.clientY != null ? ev.clientY : window.innerHeight / 3;
|
|
1353
|
+
const w = codeEl.offsetWidth;
|
|
1354
|
+
const h = codeEl.offsetHeight;
|
|
1355
|
+
const left = x + 14 + w > window.innerWidth - 8 ? x - w - 14 : x + 14;
|
|
1356
|
+
const top = y + 14 + h > window.innerHeight - 8 ? y - h - 14 : y + 14;
|
|
1357
|
+
codeEl.style.left = `${Math.max(8, left)}px`;
|
|
1358
|
+
codeEl.style.top = `${Math.max(8, top)}px`;
|
|
1359
|
+
const at = codeEl.querySelector(".cl.at");
|
|
1360
|
+
if (at) at.scrollIntoView({ block: "center" });
|
|
1361
|
+
if (codeAway) document.removeEventListener("mousedown", codeAway, true);
|
|
1362
|
+
codeAway = (e) => {
|
|
1363
|
+
if (!codeEl.contains(e.target) && !e.target.closest(".code-btn, [data-show-code]")) closeCode();
|
|
1364
|
+
};
|
|
1365
|
+
setTimeout(() => codeAway && document.addEventListener("mousedown", codeAway, true), 0);
|
|
1366
|
+
}
|
|
1367
|
+
function closeCode() {
|
|
1368
|
+
codeFor = null;
|
|
1369
|
+
codeEl.hidden = true;
|
|
1370
|
+
if (codeAway) document.removeEventListener("mousedown", codeAway, true);
|
|
1371
|
+
codeAway = null;
|
|
1372
|
+
}
|
|
1373
|
+
let tipTimer = 0;
|
|
1374
|
+
let hovered = null;
|
|
1375
|
+
/** The file where `n` is defined, relative to the project root — no line. */
|
|
1376
|
+
function sourceOf(n) {
|
|
1377
|
+
const site = n.v.definedAt;
|
|
1378
|
+
if (!site) return "";
|
|
1379
|
+
return (model.data.sources && model.data.sources[site]) || site.replace(/:\d+(:\d+)?$/, "");
|
|
1380
|
+
}
|
|
1381
|
+
function copySource(n) {
|
|
1382
|
+
const src = n && sourceOf(n);
|
|
1383
|
+
if (src) copy(src, `Copied ${src}`);
|
|
1384
|
+
}
|
|
1385
|
+
let lastEv = null;
|
|
1386
|
+
function showTip(ev, html, wide, delay) {
|
|
1387
|
+
clearTimeout(tipTimer);
|
|
1388
|
+
lastEv = ev;
|
|
1389
|
+
const open = () => {
|
|
1390
|
+
tip.innerHTML = html;
|
|
1391
|
+
tip.classList.toggle("wide", !!wide);
|
|
1392
|
+
tip.hidden = false;
|
|
1393
|
+
moveTip(lastEv);
|
|
1394
|
+
};
|
|
1395
|
+
if (delay && tip.hidden) tipTimer = setTimeout(open, delay);
|
|
1396
|
+
else open();
|
|
1136
1397
|
}
|
|
1137
1398
|
function moveTip(ev) {
|
|
1399
|
+
lastEv = ev;
|
|
1400
|
+
if (tip.hidden) return;
|
|
1138
1401
|
const pad = 14;
|
|
1139
1402
|
const r = tip.getBoundingClientRect();
|
|
1140
1403
|
let x = ev.clientX + pad;
|
|
@@ -1145,6 +1408,8 @@
|
|
|
1145
1408
|
tip.style.top = `${y}px`;
|
|
1146
1409
|
}
|
|
1147
1410
|
function hideTip() {
|
|
1411
|
+
hovered = null;
|
|
1412
|
+
clearTimeout(tipTimer);
|
|
1148
1413
|
tip.hidden = true;
|
|
1149
1414
|
}
|
|
1150
1415
|
|
|
@@ -1168,10 +1433,14 @@
|
|
|
1168
1433
|
resizeTimer = setTimeout(relayout, 120);
|
|
1169
1434
|
});
|
|
1170
1435
|
document.addEventListener("keydown", (e) => {
|
|
1436
|
+
if (e.key === "Escape" && codeFor) return closeCode();
|
|
1171
1437
|
if (e.target.closest && e.target.closest("input")) return;
|
|
1172
1438
|
if (e.key === "+" || e.key === "=") zoomBy(1.6);
|
|
1173
1439
|
else if (e.key === "-") zoomBy(1 / 1.6);
|
|
1174
1440
|
else if (e.key === "0") fit();
|
|
1441
|
+
else if ((e.key === "c" || e.key === "C") && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
1442
|
+
copySource(hovered || model.byId.get(state.selected));
|
|
1443
|
+
}
|
|
1175
1444
|
else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
1176
1445
|
const rows = visible();
|
|
1177
1446
|
const i = rows.findIndex((n) => n.id === state.selected);
|
|
@@ -1201,7 +1470,10 @@
|
|
|
1201
1470
|
return `
|
|
1202
1471
|
<header class="top">
|
|
1203
1472
|
<div class="titles">
|
|
1204
|
-
<
|
|
1473
|
+
<div class="title-row">
|
|
1474
|
+
<h1 title="${esc(model.data.folder)}">Run: ${esc(model.data.name)}</h1>
|
|
1475
|
+
<button class="info-btn" id="om-info" aria-label="About this om">${svgIcon("info")}</button>
|
|
1476
|
+
</div>
|
|
1205
1477
|
<div class="meta"><span class="dot ${ok ? "ok" : "failed"}"></span><span>${ok ? "passed" : "failed"}</span>
|
|
1206
1478
|
<span class="sep">•</span><span>${fmtDate(run.startedAt)}</span>
|
|
1207
1479
|
<span class="sep">•</span><span title="body ${fmtDur(run.duration)} · record ${fmtDur(model.span)}">${fmtDur(run.duration)}</span>
|
|
@@ -1281,6 +1553,75 @@
|
|
|
1281
1553
|
c.opacity = a;
|
|
1282
1554
|
return c.formatRgb();
|
|
1283
1555
|
}
|
|
1556
|
+
const KEYWORDS = new Set(
|
|
1557
|
+
"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(" ")
|
|
1558
|
+
);
|
|
1559
|
+
const TOKEN =
|
|
1560
|
+
/(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|(`(?:\\[\s\S]|[^`\\])*`|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*')|(\b\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?n?\b)|([A-Za-z_$][\w$]*)/g;
|
|
1561
|
+
/** Highlighted HTML per source line. Tokens that span lines are closed and reopened at each break. */
|
|
1562
|
+
function highlight(text) {
|
|
1563
|
+
const parts = [];
|
|
1564
|
+
let last = 0;
|
|
1565
|
+
let m;
|
|
1566
|
+
TOKEN.lastIndex = 0;
|
|
1567
|
+
while ((m = TOKEN.exec(text))) {
|
|
1568
|
+
if (m.index > last) parts.push([null, text.slice(last, m.index)]);
|
|
1569
|
+
let cls = null;
|
|
1570
|
+
if (m[1]) cls = "tk-c";
|
|
1571
|
+
else if (m[2]) cls = "tk-s";
|
|
1572
|
+
else if (m[3]) cls = "tk-n";
|
|
1573
|
+
else if (m[4]) {
|
|
1574
|
+
if (KEYWORDS.has(m[4])) cls = "tk-k";
|
|
1575
|
+
else if (/^\s*\(/.test(text.slice(TOKEN.lastIndex, TOKEN.lastIndex + 40))) cls = "tk-f";
|
|
1576
|
+
else if (/^[A-Z]/.test(m[4])) cls = "tk-t";
|
|
1577
|
+
}
|
|
1578
|
+
parts.push([cls, m[0]]);
|
|
1579
|
+
last = TOKEN.lastIndex;
|
|
1580
|
+
}
|
|
1581
|
+
if (last < text.length) parts.push([null, text.slice(last)]);
|
|
1582
|
+
const lines = [""];
|
|
1583
|
+
for (const [cls, chunk] of parts) {
|
|
1584
|
+
chunk.split("\n").forEach((piece, i) => {
|
|
1585
|
+
if (i > 0) lines.push("");
|
|
1586
|
+
if (piece) lines[lines.length - 1] += cls ? `<span class="${cls}">${esc(piece)}</span>` : esc(piece);
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
return lines;
|
|
1590
|
+
}
|
|
1591
|
+
function oneLine(v) {
|
|
1592
|
+
try {
|
|
1593
|
+
return typeof v === "string" ? v : JSON.stringify(v);
|
|
1594
|
+
} catch {
|
|
1595
|
+
return String(v);
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
/** Top-level fields of a JSON Schema object: name, type, required, default, description. */
|
|
1599
|
+
function schemaFields(schema) {
|
|
1600
|
+
if (!schema || typeof schema !== "object" || !schema.properties) return [];
|
|
1601
|
+
const req = new Set(schema.required || []);
|
|
1602
|
+
return Object.entries(schema.properties).map(([name, p]) => ({
|
|
1603
|
+
name,
|
|
1604
|
+
type: typeOf(p),
|
|
1605
|
+
required: req.has(name) && !(p && "default" in p),
|
|
1606
|
+
def: p && "default" in p ? oneLine(p.default) : undefined,
|
|
1607
|
+
desc: p && p.description,
|
|
1608
|
+
}));
|
|
1609
|
+
}
|
|
1610
|
+
function typeOf(p) {
|
|
1611
|
+
if (!p || typeof p !== "object") return "any";
|
|
1612
|
+
if (p.enum) return p.enum.map((e) => JSON.stringify(e)).join(" | ");
|
|
1613
|
+
if (p.const !== undefined) return JSON.stringify(p.const);
|
|
1614
|
+
if (Array.isArray(p.type)) return p.type.join(" | ");
|
|
1615
|
+
if (p.type === "array") return `${typeOf(p.items)}[]`;
|
|
1616
|
+
if (p.type) return p.type;
|
|
1617
|
+
if (p.anyOf || p.oneOf) return (p.anyOf || p.oneOf).map(typeOf).join(" | ");
|
|
1618
|
+
return "any";
|
|
1619
|
+
}
|
|
1620
|
+
function paramHtml(f) {
|
|
1621
|
+
return `<div class="param"><span class="pn">${esc(f.name)}${f.required ? "" : "?"}</span><span class="pt">${esc(truncate(f.type, 40))}</span>${
|
|
1622
|
+
f.def !== undefined ? `<span class="pd">= ${esc(truncate(f.def, 24))}</span>` : ""
|
|
1623
|
+
}${f.desc ? `<div class="pdesc">${esc(f.desc)}</div>` : ""}</div>`;
|
|
1624
|
+
}
|
|
1284
1625
|
function truncate(s, n) {
|
|
1285
1626
|
n = Math.max(1, Math.floor(n));
|
|
1286
1627
|
return s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s;
|
|
@@ -1306,19 +1647,19 @@
|
|
|
1306
1647
|
if (/build|compile|bundle/.test(s)) return "box";
|
|
1307
1648
|
return "play";
|
|
1308
1649
|
}
|
|
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);
|
|
1650
|
+
function copy(text, message) {
|
|
1651
|
+
const done = () => toast(message || "Copied");
|
|
1652
|
+
if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text).then(done, () => fallbackCopy(text, message));
|
|
1653
|
+
else fallbackCopy(text, message);
|
|
1313
1654
|
}
|
|
1314
|
-
function fallbackCopy(text) {
|
|
1655
|
+
function fallbackCopy(text, message) {
|
|
1315
1656
|
const t = document.createElement("textarea");
|
|
1316
1657
|
t.value = text;
|
|
1317
1658
|
document.body.appendChild(t);
|
|
1318
1659
|
t.select();
|
|
1319
1660
|
try {
|
|
1320
1661
|
document.execCommand("copy");
|
|
1321
|
-
toast("Copied");
|
|
1662
|
+
toast(message || "Copied");
|
|
1322
1663
|
} catch {
|
|
1323
1664
|
toast(text);
|
|
1324
1665
|
}
|
|
@@ -19,6 +19,16 @@ export interface NodeView {
|
|
|
19
19
|
readonly duration: number;
|
|
20
20
|
/** Absolute path to this node's own `.log`. */
|
|
21
21
|
readonly logFile: string;
|
|
22
|
+
/** What the definition's `.describe()` said; absent when it said nothing. */
|
|
23
|
+
readonly description?: {
|
|
24
|
+
readonly summary: string;
|
|
25
|
+
};
|
|
26
|
+
/** The definition's `.mcp()` exposure; absent when it is not exposed. */
|
|
27
|
+
readonly mcp?: {
|
|
28
|
+
readonly mode: string;
|
|
29
|
+
};
|
|
30
|
+
/** The definition's `.args()` schema as JSON Schema; absent without one. */
|
|
31
|
+
readonly inputSchema?: unknown;
|
|
22
32
|
readonly children: readonly NodeView[];
|
|
23
33
|
}
|
|
24
34
|
/** A curated artifact, as projected into `result.json`. Data-only — no core import. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omkit",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.12",
|
|
4
4
|
"description": "A tiny runtime for developer workflows: typed actions that pass runtime capabilities and emit typed events, under one run and one shared log. The operational/runtime layer to tskb's knowledge layer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|