omkit 0.8.16 → 0.8.18
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 +3 -1
- package/dist/core/ActionRun.d.ts +4 -0
- package/dist/core/ActionRun.js +74 -7
- package/dist/core/refTrace.d.ts +22 -0
- package/dist/core/refTrace.js +71 -0
- package/dist/foundation/LogEntry.d.ts +21 -1
- package/dist/output/writers/report/report.css +19 -10
- package/dist/output/writers/report/report.js +239 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -132,6 +132,8 @@ Resolution happens **inside** the run, so a prompt and its answer land on the ru
|
|
|
132
132
|
|
|
133
133
|
An action can `attach` a value — a port, a client, a page — that downstream actions receive by awaiting `activity.ref`. Not a file descriptor scraped from a log: a typed runtime handoff, so steps chain without globals or string-parsing. The first `attach` wins; later ones are ignored. Unlike `.result`, `.ref` **rejects** on failure or cancellation — a consumer waiting on a capability that never arrives fails instead of hanging — and an action that publishes nothing still resolves its `.ref` with `undefined` on success, so awaiting it never wedges.
|
|
134
134
|
|
|
135
|
+
The handle `.ref` resolves is a thin, shallow wrapper over the attached value: every reader gets the same one, methods run bound to the real object (private fields work), and each public method call another action makes on it is recorded in the run — who called, from which line, how long it took — so the report can show it. Return values are not wrapped, and recording stops after 500 calls per handle.
|
|
136
|
+
|
|
135
137
|
```ts
|
|
136
138
|
const server = action("server")
|
|
137
139
|
.ref<number>() // this action publishes a port
|
|
@@ -189,7 +191,7 @@ Every action's output, events, asserts, snapshots, and lifecycle land on **one t
|
|
|
189
191
|
- `raw.jsonl` — every entry, machine-readable.
|
|
190
192
|
- `main.log` + one `.log` per action — the human-readable timelines.
|
|
191
193
|
- `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 flow graph of the tree, per-action details, logs, assertions, the run's snapshots and images, each action's source (the `<>` button next to a block),
|
|
194
|
+
- `report.html` — the whole run as one self-contained page: a timeline of every action with its events, a flow graph of the tree, per-action details, logs, assertions, the run's snapshots and images, each action's source (the `<>` button next to a block), an Errors tab that collects failures, failed assertions and error output, and can highlight the actions they came from in every graph, and the links between actions: who waited on whose event (`once`), who cancelled whom, and the calls made on another action's `.ref`. Everything is inlined (data, base64 images, d3), so it opens straight from disk and survives being zipped or attached.
|
|
193
195
|
|
|
194
196
|
`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
197
|
|
package/dist/core/ActionRun.d.ts
CHANGED
|
@@ -72,6 +72,8 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
|
|
|
72
72
|
private attached;
|
|
73
73
|
private observed;
|
|
74
74
|
private refObserved;
|
|
75
|
+
/** The traced handle every `.ref` reader shares (see {@link traceHandle}); made on first read. */
|
|
76
|
+
private tracedRef;
|
|
75
77
|
private cancelLogged;
|
|
76
78
|
private armedExec;
|
|
77
79
|
private armedArgs;
|
|
@@ -123,4 +125,6 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
|
|
|
123
125
|
*/
|
|
124
126
|
private lifecycle;
|
|
125
127
|
private log;
|
|
128
|
+
/** A cross-node interaction, on this (the targeted) node's log; `source` is the link's op. */
|
|
129
|
+
private link;
|
|
126
130
|
}
|
package/dist/core/ActionRun.js
CHANGED
|
@@ -2,6 +2,9 @@ import { defer } from "../foundation/Deferred.js";
|
|
|
2
2
|
import { events } from "../foundation/events.js";
|
|
3
3
|
import { CancelledError } from "../foundation/CancelledError.js";
|
|
4
4
|
import { renderValue } from "../foundation/format.js";
|
|
5
|
+
import { callerSite } from "../foundation/callsite.js";
|
|
6
|
+
import { currentNode } from "./context.js";
|
|
7
|
+
import { traceHandle, REF_CALL_CAP } from "./refTrace.js";
|
|
5
8
|
import { createProc } from "../system/proc.js";
|
|
6
9
|
import { FolderCache } from "../system/fs/FolderCache.js";
|
|
7
10
|
/**
|
|
@@ -45,6 +48,8 @@ export class ActionRun {
|
|
|
45
48
|
attached = false;
|
|
46
49
|
observed = false;
|
|
47
50
|
refObserved = false;
|
|
51
|
+
/** The traced handle every `.ref` reader shares (see {@link traceHandle}); made on first read. */
|
|
52
|
+
tracedRef;
|
|
48
53
|
cancelLogged = false;
|
|
49
54
|
// The armed body + args, set at launch and run once by `commit()` on a microtask — the
|
|
50
55
|
// window during which `withCache` may still wrap the body. `committed` closes that window.
|
|
@@ -108,7 +113,23 @@ export class ActionRun {
|
|
|
108
113
|
/** The attached handle; rejects on failure/cancel. */
|
|
109
114
|
get ref() {
|
|
110
115
|
this.refObserved = true;
|
|
111
|
-
|
|
116
|
+
// One traced handle for every reader — identity holds across reads, and each method call
|
|
117
|
+
// another node makes on it is recorded here as a `ref` link.
|
|
118
|
+
if (!this.tracedRef) {
|
|
119
|
+
this.tracedRef = this.handle.promise.then((h) => traceHandle(h, {
|
|
120
|
+
call: (c) => this.link(`${c.key}() ← ${c.from} · ${c.ms}ms${c.outcome === "failed" ? " · failed" : ""}`, {
|
|
121
|
+
op: "ref",
|
|
122
|
+
from: c.from,
|
|
123
|
+
...(c.site ? { site: c.site } : {}),
|
|
124
|
+
key: c.key,
|
|
125
|
+
ms: c.ms,
|
|
126
|
+
outcome: c.outcome,
|
|
127
|
+
}),
|
|
128
|
+
capped: () => this.log("info", "ref", `ref calls past ${REF_CALL_CAP} are not recorded`),
|
|
129
|
+
}));
|
|
130
|
+
void this.tracedRef.catch(() => { });
|
|
131
|
+
}
|
|
132
|
+
return this.tracedRef;
|
|
112
133
|
}
|
|
113
134
|
on(key, handler) {
|
|
114
135
|
// A late `on("error")` (e.g. attached after an `await`, once the body has already
|
|
@@ -119,9 +140,29 @@ export class ActionRun {
|
|
|
119
140
|
this.emitter.listen(key, handler);
|
|
120
141
|
}
|
|
121
142
|
once(key) {
|
|
143
|
+
// Who waits, from where — recorded on this node as a `once` link when the wait ends.
|
|
144
|
+
const from = currentNode.getStore();
|
|
145
|
+
const site = callerSite();
|
|
146
|
+
const t0 = Date.now();
|
|
147
|
+
const note = (outcome) => {
|
|
148
|
+
if (!from || from.id === this.id)
|
|
149
|
+
return;
|
|
150
|
+
const ms = Date.now() - t0;
|
|
151
|
+
this.link(`${String(key)} ← ${from.id} · ${outcome} after ${ms}ms`, {
|
|
152
|
+
op: "once",
|
|
153
|
+
from: from.id,
|
|
154
|
+
...(site ? { site } : {}),
|
|
155
|
+
key: String(key),
|
|
156
|
+
ms,
|
|
157
|
+
outcome,
|
|
158
|
+
});
|
|
159
|
+
};
|
|
122
160
|
// `done` is the outcome itself — identical to `.result` (resolve the value, reject on failure).
|
|
123
|
-
if (key === "done")
|
|
124
|
-
|
|
161
|
+
if (key === "done") {
|
|
162
|
+
const outcome = this.result;
|
|
163
|
+
outcome.then(() => note("fired"), () => note("failed"));
|
|
164
|
+
return outcome;
|
|
165
|
+
}
|
|
125
166
|
// Awaiting an event observes the activity: resolve the payload when it fires, but if the
|
|
126
167
|
// activity settles first, resolve `undefined` on success and **reject** on failure/cancel —
|
|
127
168
|
// so an event gate surfaces a failure instead of hanging (or, for `error`, hand back the error).
|
|
@@ -130,16 +171,21 @@ export class ActionRun {
|
|
|
130
171
|
let done = false;
|
|
131
172
|
const win = () => (done ? false : (done = true));
|
|
132
173
|
this.emitter.listenOnce(key, (p) => {
|
|
133
|
-
if (win())
|
|
134
|
-
|
|
174
|
+
if (!win())
|
|
175
|
+
return;
|
|
176
|
+
note("fired");
|
|
177
|
+
resolve(p);
|
|
135
178
|
});
|
|
136
179
|
this.settled.promise.then(() => {
|
|
137
|
-
if (win())
|
|
138
|
-
|
|
180
|
+
if (!win())
|
|
181
|
+
return;
|
|
182
|
+
note("ended");
|
|
183
|
+
resolve(undefined);
|
|
139
184
|
}, (error) => {
|
|
140
185
|
if (!win())
|
|
141
186
|
return;
|
|
142
187
|
// Awaiting the `error` event hands back the error; any other gate rejects on failure.
|
|
188
|
+
note(key === "error" ? "fired" : "failed");
|
|
143
189
|
if (key === "error")
|
|
144
190
|
resolve(error);
|
|
145
191
|
else
|
|
@@ -165,6 +211,16 @@ export class ActionRun {
|
|
|
165
211
|
return this;
|
|
166
212
|
}
|
|
167
213
|
cancel() {
|
|
214
|
+
// A cancel from another node is a link: who stopped this one, and from where.
|
|
215
|
+
const from = currentNode.getStore();
|
|
216
|
+
if (from && from.id !== this.id && this.parentId !== null && this.status === "running") {
|
|
217
|
+
const site = callerSite();
|
|
218
|
+
this.link(`by ${from.id}${site ? ` @ ${site}` : ""}`, {
|
|
219
|
+
op: "cancel",
|
|
220
|
+
from: from.id,
|
|
221
|
+
...(site ? { site } : {}),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
168
224
|
this.noteCancelled(); // a direct cancel of this activity
|
|
169
225
|
this.controller.abort();
|
|
170
226
|
}
|
|
@@ -335,6 +391,17 @@ export class ActionRun {
|
|
|
335
391
|
log(level, source, message) {
|
|
336
392
|
this.store.append({ nodeId: this.id, path: this.path, level, source, message });
|
|
337
393
|
}
|
|
394
|
+
/** A cross-node interaction, on this (the targeted) node's log; `source` is the link's op. */
|
|
395
|
+
link(message, link) {
|
|
396
|
+
this.store.append({
|
|
397
|
+
nodeId: this.id,
|
|
398
|
+
path: this.path,
|
|
399
|
+
level: "link",
|
|
400
|
+
source: link.op,
|
|
401
|
+
message,
|
|
402
|
+
link,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
338
405
|
}
|
|
339
406
|
const messageOf = (error) => error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
340
407
|
const firstLine = (message) => message.split("\n", 1)[0] ?? message;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Calls recorded per handle; past this the trace goes quiet, so a polling loop can't flood the log. */
|
|
2
|
+
export declare const REF_CALL_CAP = 500;
|
|
3
|
+
/** One method call on a traced handle, as {@link traceHandle} reports it. */
|
|
4
|
+
export interface RefCall {
|
|
5
|
+
/** The calling node's id. */
|
|
6
|
+
readonly from: string;
|
|
7
|
+
readonly site: string | undefined;
|
|
8
|
+
readonly key: string;
|
|
9
|
+
readonly ms: number;
|
|
10
|
+
readonly outcome: "ok" | "failed";
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Wrap an attached handle so each public method call on it is reported — who called (the
|
|
14
|
+
* ambient node), from where, how long it took, and whether it threw. Shallow by design: what
|
|
15
|
+
* a method returns is handed back untraced. Methods run bound to the real object, so private
|
|
16
|
+
* fields keep working; symbol, `_`-prefixed and {@link QUIET} members pass through unrecorded.
|
|
17
|
+
* A primitive or nullish handle is returned as is.
|
|
18
|
+
*/
|
|
19
|
+
export declare const traceHandle: <H>(handle: H, report: {
|
|
20
|
+
call: (call: RefCall) => void;
|
|
21
|
+
capped: () => void;
|
|
22
|
+
}) => H;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { callerSite } from "../foundation/callsite.js";
|
|
2
|
+
import { currentNode } from "./context.js";
|
|
3
|
+
/** Calls recorded per handle; past this the trace goes quiet, so a polling loop can't flood the log. */
|
|
4
|
+
export const REF_CALL_CAP = 500;
|
|
5
|
+
/** Members never recorded: promise/serialization/inspection plumbing, not the caller's intent. */
|
|
6
|
+
const QUIET = new Set([
|
|
7
|
+
"then",
|
|
8
|
+
"catch",
|
|
9
|
+
"finally",
|
|
10
|
+
"toJSON",
|
|
11
|
+
"toString",
|
|
12
|
+
"valueOf",
|
|
13
|
+
"inspect",
|
|
14
|
+
"constructor",
|
|
15
|
+
]);
|
|
16
|
+
/**
|
|
17
|
+
* Wrap an attached handle so each public method call on it is reported — who called (the
|
|
18
|
+
* ambient node), from where, how long it took, and whether it threw. Shallow by design: what
|
|
19
|
+
* a method returns is handed back untraced. Methods run bound to the real object, so private
|
|
20
|
+
* fields keep working; symbol, `_`-prefixed and {@link QUIET} members pass through unrecorded.
|
|
21
|
+
* A primitive or nullish handle is returned as is.
|
|
22
|
+
*/
|
|
23
|
+
export const traceHandle = (handle, report) => {
|
|
24
|
+
if (handle === null || (typeof handle !== "object" && typeof handle !== "function"))
|
|
25
|
+
return handle;
|
|
26
|
+
const cache = new Map();
|
|
27
|
+
let count = 0;
|
|
28
|
+
return new Proxy(handle, {
|
|
29
|
+
get(target, prop) {
|
|
30
|
+
const value = Reflect.get(target, prop, target);
|
|
31
|
+
if (typeof value !== "function")
|
|
32
|
+
return value;
|
|
33
|
+
// a non-configurable, read-only property must come back as itself (a Proxy invariant)
|
|
34
|
+
const own = Reflect.getOwnPropertyDescriptor(target, prop);
|
|
35
|
+
if (own && !own.configurable && !own.writable)
|
|
36
|
+
return value;
|
|
37
|
+
const hit = cache.get(prop);
|
|
38
|
+
if (hit && hit.orig === value)
|
|
39
|
+
return hit.fn;
|
|
40
|
+
const name = String(prop);
|
|
41
|
+
const quiet = typeof prop === "symbol" || name.startsWith("_") || QUIET.has(name);
|
|
42
|
+
const method = value;
|
|
43
|
+
const fn = (...args) => {
|
|
44
|
+
const from = currentNode.getStore()?.id;
|
|
45
|
+
if (quiet || !from || count >= REF_CALL_CAP)
|
|
46
|
+
return method.apply(target, args);
|
|
47
|
+
if (++count === REF_CALL_CAP)
|
|
48
|
+
report.capped();
|
|
49
|
+
const site = callerSite();
|
|
50
|
+
const t0 = Date.now();
|
|
51
|
+
const done = (outcome) => report.call({ from, site, key: name, ms: Date.now() - t0, outcome });
|
|
52
|
+
let out;
|
|
53
|
+
try {
|
|
54
|
+
out = method.apply(target, args);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
done("failed");
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
if (out !== null && typeof out?.then === "function") {
|
|
61
|
+
out.then(() => done("ok"), () => done("failed"));
|
|
62
|
+
}
|
|
63
|
+
else
|
|
64
|
+
done("ok");
|
|
65
|
+
return out;
|
|
66
|
+
};
|
|
67
|
+
cache.set(prop, { orig: value, fn });
|
|
68
|
+
return fn;
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
};
|
|
@@ -13,11 +13,31 @@ export interface LogEntry {
|
|
|
13
13
|
readonly nodeId: string;
|
|
14
14
|
/** The owning node's execution path (`main/chromePage_9f3c`). */
|
|
15
15
|
readonly path: string;
|
|
16
|
-
/** `info` | `error` | `event` | `assert` | `snapshot` | `artifact` | `tag` | `child` | `run`. */
|
|
16
|
+
/** `info` | `error` | `event` | `assert` | `snapshot` | `artifact` | `tag` | `child` | `run` | `link`. */
|
|
17
17
|
readonly level: string;
|
|
18
18
|
/** Finer origin within the node (proc name, `event`, `run`, …). */
|
|
19
19
|
readonly source: string;
|
|
20
20
|
readonly message: string;
|
|
21
|
+
/** Set on `level: "link"` entries — the other side of a cross-node interaction. */
|
|
22
|
+
readonly link?: LogLink;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A cross-node interaction, recorded on the node it targets: another node waited on one of
|
|
26
|
+
* its events (`once`), cancelled it (`cancel`), or called a method on its attached handle
|
|
27
|
+
* (`ref`). The entry's `ts` is when the interaction ended, so a timed one spans `ts - ms … ts`.
|
|
28
|
+
*/
|
|
29
|
+
export interface LogLink {
|
|
30
|
+
readonly op: "once" | "cancel" | "ref";
|
|
31
|
+
/** The calling node's id. */
|
|
32
|
+
readonly from: string;
|
|
33
|
+
/** `file:line` of the call, when the stack had one. */
|
|
34
|
+
readonly site?: string;
|
|
35
|
+
/** The awaited event key (`once`) or the called method (`ref`). */
|
|
36
|
+
readonly key?: string;
|
|
37
|
+
/** How long the wait or call lasted (`once`, `ref`). */
|
|
38
|
+
readonly ms?: number;
|
|
39
|
+
/** How it ended — `fired` | `ended` | `failed` for a once, `ok` | `failed` for a ref call. */
|
|
40
|
+
readonly outcome?: string;
|
|
21
41
|
}
|
|
22
42
|
/** What an appender supplies; the store stamps `sequence` and `ts`. */
|
|
23
43
|
export type LogInput = Omit<LogEntry, "sequence" | "ts">;
|
|
@@ -147,10 +147,11 @@ button { font: inherit; color: inherit; }
|
|
|
147
147
|
.row .label .tcall { font-size: 11.5px; }
|
|
148
148
|
.row .label .sep, .row .l2 .sep { color: var(--text-3); }
|
|
149
149
|
.row .l2 { font-size: 11px; color: var(--text-2); font-variant-numeric: tabular-nums; }
|
|
150
|
-
.row .l2 .
|
|
150
|
+
.row .l2 .meta { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-left: auto; padding-right: 4px; }
|
|
151
|
+
.row .l2 .st { display: inline-flex; align-items: center; gap: 4px; }
|
|
151
152
|
.row .l2 .dot { width: 7px; height: 7px; }
|
|
152
153
|
.row .l2 .du { flex: none; }
|
|
153
|
-
.row .l2 .chips { flex: 0 1 auto; flex-wrap: nowrap; overflow: hidden;
|
|
154
|
+
.row .l2 .chips { flex: 0 1 auto; flex-wrap: nowrap; overflow: hidden; }
|
|
154
155
|
.row .l2 .chip { flex: 0 1 auto; min-width: 24px; font-size: 10px; line-height: 14px; }
|
|
155
156
|
.row .l2 .chip.more { flex: none; }
|
|
156
157
|
.row .twist { width: 16px; height: 16px; flex: none; display: grid; place-items: center; color: var(--text-3); border: 0; background: none; padding: 0; cursor: pointer; }
|
|
@@ -163,7 +164,8 @@ button { font: inherit; color: inherit; }
|
|
|
163
164
|
|
|
164
165
|
.chart { position: relative; min-width: 0; }
|
|
165
166
|
.chart svg { display: block; overflow: hidden; }
|
|
166
|
-
|
|
167
|
+
/* the row's hit target already spans the label — letting it through keeps the marks under it hoverable */
|
|
168
|
+
.chart .bar-label { font-size: 12px; fill: var(--bar-text); font-weight: 500; pointer-events: none; }
|
|
167
169
|
.chart .hit { fill: transparent; cursor: pointer; }
|
|
168
170
|
.chart .hit:hover + .bar { filter: brightness(1.15); }
|
|
169
171
|
.chart .bar-label.outside { fill: var(--text); }
|
|
@@ -189,7 +191,7 @@ button { font: inherit; color: inherit; }
|
|
|
189
191
|
.flow-seq.parallel { stroke-dasharray: 1 4; stroke-linecap: round; stroke-width: 2; opacity: 0.8; }
|
|
190
192
|
.flow-arrowhead { fill: var(--text-3); }
|
|
191
193
|
.flow-par { fill: var(--text-3); font-size: 10px; font-style: italic; }
|
|
192
|
-
.flow-shot-t { fill: var(--text-3); font-size: 10.5px; pointer-events: none; }
|
|
194
|
+
.flow-shot-t { fill: var(--text-3); font-size: 10.5px; pointer-events: none; stroke: var(--panel); stroke-width: 4px; paint-order: stroke; }
|
|
193
195
|
.flow-card text.sub { fill: var(--text-3); font-size: 11px; }
|
|
194
196
|
.flow-card .tchip text { fill: var(--text); font-size: 10.5px; }
|
|
195
197
|
.flow-card .tchip.more text { fill: var(--text-3); }
|
|
@@ -223,12 +225,12 @@ button { font: inherit; color: inherit; }
|
|
|
223
225
|
.acc-b > .params { margin-bottom: 8px; }
|
|
224
226
|
.acc-b .section-title:first-child { margin-top: 0; }
|
|
225
227
|
.acc-file { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; color: var(--text-2); }
|
|
226
|
-
.
|
|
227
|
-
.
|
|
228
|
-
.
|
|
229
|
-
.
|
|
230
|
-
.
|
|
231
|
-
.
|
|
228
|
+
.dshots { display: grid; gap: 6px; }
|
|
229
|
+
.dshot { display: grid; grid-template-columns: 64px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 4px; border: 1px solid transparent; border-radius: 8px; cursor: pointer; }
|
|
230
|
+
.dshot:hover { border-color: var(--accent); background: var(--accent-soft); }
|
|
231
|
+
.dshot .thumb { width: 64px; }
|
|
232
|
+
.dshot .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
233
|
+
.dshot .t { color: var(--text-3); font-size: 11px; font-variant-numeric: tabular-nums; }
|
|
232
234
|
.kv { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 6px 10px; margin: 2px 0 12px; }
|
|
233
235
|
.kv .k { color: var(--text-2); }
|
|
234
236
|
.kv .v { min-width: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
|
@@ -471,6 +473,13 @@ a.icon-btn { text-decoration: none; }
|
|
|
471
473
|
#app.hl-on .flow-card.err-path rect.box { stroke: var(--fail); stroke-width: 1.2; stroke-dasharray: 4 3; }
|
|
472
474
|
#app.hl-on .tree .row.err { background: rgba(239, 68, 68, 0.1); }
|
|
473
475
|
.chart g.r, .flow-card, .tree .row { transition: opacity 0.15s; }
|
|
476
|
+
/* cross-node links: hollow points on the caller's row, chips in the flow */
|
|
477
|
+
.chart circle.mark.link { stroke-width: 2; }
|
|
478
|
+
.xwire { stroke-width: 1.2; stroke-dasharray: 3 3; opacity: 0.55; pointer-events: none; }
|
|
479
|
+
.xwire.hot { stroke-width: 2; opacity: 1; }
|
|
480
|
+
.fwire { fill: none; stroke-width: 1.2; stroke-dasharray: 3 3; opacity: 0.5; pointer-events: none; }
|
|
481
|
+
.fwire.hot { stroke-width: 2; opacity: 1; }
|
|
482
|
+
.flow-shots .shot rect[stroke] { stroke-width: 1.5; }
|
|
474
483
|
|
|
475
484
|
@media (max-width: 1100px) {
|
|
476
485
|
.bottom { grid-template-columns: minmax(0, 1fr); }
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
cancel: { color: "--cancel", icon: "stop", label: "Cancelled" },
|
|
64
64
|
lifecycle: { color: "--c1", icon: "play", label: "Lifecycle" },
|
|
65
65
|
run: { color: "--text-3", icon: "root", label: "Run" },
|
|
66
|
+
link: { color: "--text-3", icon: "flow", label: "Link" },
|
|
66
67
|
};
|
|
67
68
|
|
|
68
69
|
// ── formatting ───────────────────────────────────────────
|
|
@@ -156,7 +157,7 @@
|
|
|
156
157
|
items.push(item);
|
|
157
158
|
if (item.node) {
|
|
158
159
|
item.node.entries.push(item);
|
|
159
|
-
if (kind !== "info" && kind !== "lifecycle") item.node.marks.push(item);
|
|
160
|
+
if (kind !== "info" && kind !== "lifecycle" && kind !== "link") item.node.marks.push(item);
|
|
160
161
|
} else runEntries.push(item);
|
|
161
162
|
}
|
|
162
163
|
|
|
@@ -186,6 +187,21 @@
|
|
|
186
187
|
}
|
|
187
188
|
}
|
|
188
189
|
|
|
190
|
+
// cross-node interactions, recorded on their target: who waited on, cancelled, or called into whom
|
|
191
|
+
const links = [];
|
|
192
|
+
for (const it of items) {
|
|
193
|
+
const l = it.kind === "link" && it.node && it.e.link;
|
|
194
|
+
const from = l && byId.get(l.from);
|
|
195
|
+
if (!from) continue;
|
|
196
|
+
links.push({ item: it, op: l.op, from, to: it.node, t: it.t, t0: it.t - (l.ms || 0), key: l.key || "", ms: l.ms, outcome: l.outcome || "", site: l.site || "" });
|
|
197
|
+
}
|
|
198
|
+
// …drawn as a point on the row of the action that made it: what it did, when it did it
|
|
199
|
+
for (const l of links) {
|
|
200
|
+
l.label = linkLabel(l);
|
|
201
|
+
l.from.marks.push({ idx: l.item.idx, e: l.item.e, kind: "link", t: l.t, label: l.label, node: l.from, link: l });
|
|
202
|
+
}
|
|
203
|
+
for (const l of links) l.from.marks.sort((a, b) => a.t - b.t);
|
|
204
|
+
|
|
189
205
|
const asserts = items.filter((i) => i.kind === "pass" || i.kind === "fail");
|
|
190
206
|
const errors = collectErrors(items, nodes, run, tEnd - t0);
|
|
191
207
|
const errorsBy = new Map();
|
|
@@ -209,6 +225,7 @@
|
|
|
209
225
|
byId,
|
|
210
226
|
root: nodes[0],
|
|
211
227
|
items,
|
|
228
|
+
links,
|
|
212
229
|
runEntries,
|
|
213
230
|
gallery,
|
|
214
231
|
asserts,
|
|
@@ -266,10 +283,17 @@
|
|
|
266
283
|
out.forEach((e, i) => (e.idx = i));
|
|
267
284
|
return out;
|
|
268
285
|
}
|
|
286
|
+
/** A link's short label, as its point or chip shows it. */
|
|
287
|
+
function linkLabel(l) {
|
|
288
|
+
if (l.op === "once") return `waited ${l.key}`;
|
|
289
|
+
if (l.op === "cancel") return `cancel ${displayName(l.to)}`;
|
|
290
|
+
return `${displayName(l.to)}.${l.key}()`;
|
|
291
|
+
}
|
|
269
292
|
function firstLine(s) {
|
|
270
293
|
return String(s).split("\n")[0];
|
|
271
294
|
}
|
|
272
295
|
function kindOf(e) {
|
|
296
|
+
if (e.level === "link") return "link";
|
|
273
297
|
if (e.level === "assert") return e.message.startsWith("⊭") ? "fail" : "pass";
|
|
274
298
|
if (e.level === "snapshot") return "snapshot";
|
|
275
299
|
if (e.level === "artifact") return "artifact";
|
|
@@ -476,7 +500,7 @@
|
|
|
476
500
|
<span class="l1"><span class="label">${labelHtml(n)}</span>${
|
|
477
501
|
model.errorsBy.get(n) ? `<span class="errbadge" title="${model.errorsBy.get(n)} error${model.errorsBy.get(n) > 1 ? "s" : ""}">${model.errorsBy.get(n)}</span>` : ""
|
|
478
502
|
}</span>
|
|
479
|
-
<span class="l2"><span class="st"><span class="dot ${n.status}"></span>${esc(n.status)}</span><span class="sep">·</span><span class="du">${fmtDur(n.duration)}</span
|
|
503
|
+
<span class="l2">${tags}<span class="meta"><span class="st"><span class="dot ${n.status}"></span>${esc(n.status)}</span><span class="sep">·</span><span class="du">${fmtDur(n.duration)}</span></span></span>
|
|
480
504
|
</span>
|
|
481
505
|
</div>
|
|
482
506
|
</div>`;
|
|
@@ -503,6 +527,7 @@
|
|
|
503
527
|
.on("mouseleave", () => moveCursor(null));
|
|
504
528
|
chartSvg.append("g").attr("class", "grid");
|
|
505
529
|
chartSvg.append("g").attr("class", "links");
|
|
530
|
+
chartSvg.append("g").attr("class", "xwires");
|
|
506
531
|
chartSvg.append("g").attr("class", "rows");
|
|
507
532
|
chartSvg.append("line").attr("class", "pin").attr("stroke", css("--accent")).attr("stroke-width", 1.5).attr("y1", 0).attr("y2", H);
|
|
508
533
|
chartSvg.append("line").attr("class", "cursor").attr("y1", 0).attr("y2", H).attr("visibility", "hidden");
|
|
@@ -619,11 +644,73 @@
|
|
|
619
644
|
g.select("g.marks").each(function (n) {
|
|
620
645
|
drawMarks(d3.select(this), n, x);
|
|
621
646
|
});
|
|
647
|
+
rowIndex = index;
|
|
648
|
+
drawWires(x);
|
|
622
649
|
|
|
623
650
|
chartSvg.select("line.pin").attr("x1", state.pin != null ? x(state.pin) : -10).attr("x2", state.pin != null ? x(state.pin) : -10);
|
|
624
651
|
if (app.classList.contains("hl-on") || state.errHl) applyHl();
|
|
625
652
|
}
|
|
626
653
|
|
|
654
|
+
function linkColor(l) {
|
|
655
|
+
return l.op === "cancel" ? css("--cancel") : colorOf(l.to);
|
|
656
|
+
}
|
|
657
|
+
/** A link in words: who did what to whom, how it ended, and the line that did it. */
|
|
658
|
+
function linkTip(l, count) {
|
|
659
|
+
const from = esc(displayName(l.from));
|
|
660
|
+
const to = esc(displayName(l.to));
|
|
661
|
+
const title = l.op === "once" ? `once · ${esc(l.key)}` : l.op === "cancel" ? "cancel" : `ref · ${esc(l.key)}()`;
|
|
662
|
+
const what =
|
|
663
|
+
l.op === "once"
|
|
664
|
+
? `<b>${from}</b> waited for <b>${esc(l.key)}</b> on <b>${to}</b>`
|
|
665
|
+
: l.op === "cancel"
|
|
666
|
+
? `<b>${from}</b> cancelled <b>${to}</b>`
|
|
667
|
+
: `<b>${from}</b> called <b>${esc(l.key)}()</b> on <b>${to}</b>'s ref`;
|
|
668
|
+
const meta = [
|
|
669
|
+
count > 1 ? `×${count}` : "",
|
|
670
|
+
l.outcome ? esc(l.outcome) : "",
|
|
671
|
+
l.ms != null ? (l.op === "once" ? `after ${fmtDur(l.ms)}` : fmtDur(l.ms)) : "",
|
|
672
|
+
fmtRel(l.t),
|
|
673
|
+
].filter(Boolean);
|
|
674
|
+
const line = (/:(\d+)$/.exec(l.site) || [])[1];
|
|
675
|
+
return `<div class="tt">${title}</div><div class="summary">${what}</div>
|
|
676
|
+
<div class="tmeta">${meta.join('<span class="sep">·</span>')}</div>
|
|
677
|
+
${l.site ? `<div class="mono small" style="margin-top:6px">${esc(relOf(l.site))}${line ? ":" + line : ""}</div>` : ""}`;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
let rowIndex = new Map();
|
|
681
|
+
/** The visible row standing in for `n`: its own, or its nearest expanded ancestor's. */
|
|
682
|
+
function rowOf(n) {
|
|
683
|
+
for (let p = n; p; p = p.parent) if (rowIndex.has(p.id)) return rowIndex.get(p.id);
|
|
684
|
+
return -1;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* A link's wire: straight down (or up) the timeline at the moment it happened, from the
|
|
688
|
+
* caller's point to the target's row — both ends share the instant, so the line reads as
|
|
689
|
+
* "this, then, to that". Waits and cancels always have one; a ref call's shows on hover only,
|
|
690
|
+
* since a chatty handle would otherwise hatch the chart.
|
|
691
|
+
*/
|
|
692
|
+
function wireOf(l, x) {
|
|
693
|
+
const a = rowOf(l.from);
|
|
694
|
+
const b = rowOf(l.to);
|
|
695
|
+
if (a < 0 || b < 0 || a === b) return null;
|
|
696
|
+
const cy = BAR_Y + BAR_H / 2;
|
|
697
|
+
return { l, x: x(l.t), y1: a * ROW_H + cy, y2: b * ROW_H + cy };
|
|
698
|
+
}
|
|
699
|
+
function drawWires(x, extra) {
|
|
700
|
+
const shown = model.links.filter((l) => l.op !== "ref" || l === extra);
|
|
701
|
+
chartSvg
|
|
702
|
+
.select(".xwires")
|
|
703
|
+
.selectAll("line")
|
|
704
|
+
.data(shown.map((l) => wireOf(l, x)).filter(Boolean))
|
|
705
|
+
.join("line")
|
|
706
|
+
.attr("class", (w) => `xwire ${w.l.op}${w.l === extra ? " hot" : ""}`)
|
|
707
|
+
.attr("x1", (w) => w.x)
|
|
708
|
+
.attr("x2", (w) => w.x)
|
|
709
|
+
.attr("y1", (w) => w.y1)
|
|
710
|
+
.attr("y2", (w) => w.y2)
|
|
711
|
+
.attr("stroke", (w) => linkColor(w.l));
|
|
712
|
+
}
|
|
713
|
+
|
|
627
714
|
function drawMarks(g, n, x) {
|
|
628
715
|
const cy = BAR_Y + BAR_H / 2;
|
|
629
716
|
// cluster marks closer than 4px so a chatty node draws a readable line of dots
|
|
@@ -641,18 +728,27 @@
|
|
|
641
728
|
g.selectAll("circle.mark")
|
|
642
729
|
.data(marks)
|
|
643
730
|
.join("circle")
|
|
644
|
-
.attr("class",
|
|
731
|
+
.attr("class", (d) => `mark ${worst(d)}`)
|
|
645
732
|
.attr("cx", (d) => d.px)
|
|
646
733
|
.attr("cy", cy)
|
|
647
734
|
.attr("r", (d) => (d.more.length ? 6 : 5))
|
|
648
735
|
.attr("fill", (d) => css(MARK[worst(d)].color))
|
|
736
|
+
// a link is a ring in its target's colour — set as styles, over the `.mark` stroke rule
|
|
737
|
+
.style("fill", (d) => (worst(d) === "link" ? "var(--panel)" : null))
|
|
738
|
+
.style("stroke", (d) => (worst(d) === "link" ? linkColor(d.m.link) : null))
|
|
649
739
|
.on("click", (ev, d) => {
|
|
650
740
|
ev.stopPropagation();
|
|
651
741
|
openItem(d.m);
|
|
652
742
|
})
|
|
653
|
-
.on("mouseenter", (ev, d) =>
|
|
743
|
+
.on("mouseenter", (ev, d) => {
|
|
744
|
+
showTip(ev, markTip(d), d.m.kind === "link");
|
|
745
|
+
if (d.m.kind === "link") drawWires(xz(), d.m.link);
|
|
746
|
+
})
|
|
654
747
|
.on("mousemove", (ev) => moveTip(ev))
|
|
655
|
-
.on("mouseleave",
|
|
748
|
+
.on("mouseleave", (ev, d) => {
|
|
749
|
+
hideTip();
|
|
750
|
+
if (d.m.kind === "link") drawWires(xz());
|
|
751
|
+
});
|
|
656
752
|
// labels under the bar, skipped where they would collide
|
|
657
753
|
const labels = [];
|
|
658
754
|
let edge = -Infinity;
|
|
@@ -739,6 +835,18 @@
|
|
|
739
835
|
.attr("stroke", color ? alpha(color, 0.45) : css("--line"));
|
|
740
836
|
return { g, w };
|
|
741
837
|
}
|
|
838
|
+
/** A link chip's text: what kind of interaction, at a glance. */
|
|
839
|
+
function linkGlyph(x2) {
|
|
840
|
+
if (x2.l.op === "once") return "wait";
|
|
841
|
+
if (x2.l.op === "cancel") return "stop";
|
|
842
|
+
return x2.n > 1 ? `()×${x2.n}` : "()";
|
|
843
|
+
}
|
|
844
|
+
/** "3 snapshots", "2 links", or "4 items" for a mix. */
|
|
845
|
+
function chipCount(shots) {
|
|
846
|
+
const links = shots.filter((x2) => x2.type === "link").length;
|
|
847
|
+
if (!links) return `${shots.length} snapshots`;
|
|
848
|
+
return links === shots.length ? `${links} links` : `${shots.length} items`;
|
|
849
|
+
}
|
|
742
850
|
function renderFlow() {
|
|
743
851
|
d3.select(el.flow).selectAll("svg").remove();
|
|
744
852
|
const svg = d3.select(el.flow).append("svg");
|
|
@@ -766,6 +874,16 @@
|
|
|
766
874
|
if (!shotsBy.has(g.node)) shotsBy.set(g.node, []);
|
|
767
875
|
shotsBy.get(g.node).push(g);
|
|
768
876
|
}
|
|
877
|
+
// a node's links sit with its snapshots, in time order; a run of identical ref calls is one chip
|
|
878
|
+
for (const l of model.links) {
|
|
879
|
+
const list = shotsBy.get(l.from) || shotsBy.set(l.from, []).get(l.from);
|
|
880
|
+
const prev = list[list.length - 1];
|
|
881
|
+
if (l.op === "ref" && prev && prev.type === "link" && prev.l.op === "ref" && prev.l.to === l.to && prev.l.key === l.key) {
|
|
882
|
+
prev.n++;
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
list.push({ type: "link", l, n: 1, t: l.t, name: l.label });
|
|
886
|
+
}
|
|
769
887
|
const byTime = (a, b) => (a.t ?? Infinity) - (b.t ?? Infinity);
|
|
770
888
|
const pos = new Map();
|
|
771
889
|
const lanes = [];
|
|
@@ -941,6 +1059,31 @@
|
|
|
941
1059
|
|
|
942
1060
|
// snapshot chips — on lane arrows, and in strips under leaf cards
|
|
943
1061
|
const chipLayer = flowG.append("g").attr("class", "flow-shots");
|
|
1062
|
+
// A link chip's wire, drawn under everything else: out of the chip to a gutter right of the
|
|
1063
|
+
// column, along it, and into the target card's right edge — so it never runs behind the cards
|
|
1064
|
+
// between. Waits and cancels always have one; a ref call's shows while hovered.
|
|
1065
|
+
const wireLayer = flowG.insert("g", ":first-child").attr("class", "flow-wires");
|
|
1066
|
+
const linkChips = [];
|
|
1067
|
+
const drawFlowWires = (hot) => {
|
|
1068
|
+
const wires = [];
|
|
1069
|
+
for (const c of linkChips) {
|
|
1070
|
+
const p = pos.get(c.x2.l.to.id);
|
|
1071
|
+
if (!p || (c.x2.l.op === "ref" && c.x2 !== hot)) continue;
|
|
1072
|
+
wires.push({ c, p });
|
|
1073
|
+
}
|
|
1074
|
+
wireLayer
|
|
1075
|
+
.selectAll("path")
|
|
1076
|
+
.data(wires)
|
|
1077
|
+
.join("path")
|
|
1078
|
+
.attr("class", (w) => `fwire ${w.c.x2.l.op}${w.c.x2 === hot ? " hot" : ""}`)
|
|
1079
|
+
.attr("d", (w, i) => {
|
|
1080
|
+
const tx = w.p.x + CW;
|
|
1081
|
+
const ty = w.p.y + CH / 2;
|
|
1082
|
+
const gx = Math.max(tx, w.c.cx + TW / 2) + 12 + (i % 6) * 6; // staggered within the column gap
|
|
1083
|
+
return `M${w.c.cx + TW / 2},${w.c.cy} H${gx} V${ty} H${tx}`;
|
|
1084
|
+
})
|
|
1085
|
+
.attr("stroke", (w) => linkColor(w.c.x2.l));
|
|
1086
|
+
};
|
|
944
1087
|
const drawChips = (x, y, shots, max, owner) => {
|
|
945
1088
|
const shown = shots.length > max ? shots.slice(0, max - 1) : shots;
|
|
946
1089
|
const g = chipLayer.append("g").attr("transform", `translate(${x},${y})`);
|
|
@@ -952,12 +1095,28 @@
|
|
|
952
1095
|
.attr("transform", (_x, i) => `translate(${i * (TW + 4)},0)`)
|
|
953
1096
|
.on("click", (ev, x2) => {
|
|
954
1097
|
ev.stopPropagation();
|
|
955
|
-
openGallery(x2);
|
|
1098
|
+
if (x2.type !== "link") return openGallery(x2);
|
|
1099
|
+
setPin(x2.t);
|
|
1100
|
+
select(x2.l.from.id, "links");
|
|
1101
|
+
})
|
|
1102
|
+
.on("mouseenter", (ev, x2) => {
|
|
1103
|
+
showTip(ev, x2.type === "link" ? linkTip(x2.l, x2.n) : galleryTip(x2), x2.type === "link");
|
|
1104
|
+
if (x2.type === "link") drawFlowWires(x2);
|
|
956
1105
|
})
|
|
957
|
-
.on("mouseenter", (ev, x2) => showTip(ev, galleryTip(x2)))
|
|
958
1106
|
.on("mousemove", (ev) => moveTip(ev))
|
|
959
|
-
.on("mouseleave",
|
|
960
|
-
|
|
1107
|
+
.on("mouseleave", (ev, x2) => {
|
|
1108
|
+
hideTip();
|
|
1109
|
+
if (x2.type === "link") drawFlowWires(null);
|
|
1110
|
+
});
|
|
1111
|
+
shown.forEach((x2, i) => {
|
|
1112
|
+
if (x2.type === "link") linkChips.push({ x2, cx: x + i * (TW + 4) + TW / 2, cy: y + TH / 2 });
|
|
1113
|
+
});
|
|
1114
|
+
cell
|
|
1115
|
+
.append("rect")
|
|
1116
|
+
.attr("width", TW)
|
|
1117
|
+
.attr("height", TH)
|
|
1118
|
+
.attr("rx", 5)
|
|
1119
|
+
.attr("stroke", (x2) => (x2.type === "link" ? linkColor(x2.l) : null));
|
|
961
1120
|
cell
|
|
962
1121
|
.filter((x2) => x2.image)
|
|
963
1122
|
.append("image")
|
|
@@ -973,7 +1132,7 @@
|
|
|
973
1132
|
.attr("x", TW / 2)
|
|
974
1133
|
.attr("y", TH / 2 + 3.5)
|
|
975
1134
|
.attr("text-anchor", "middle")
|
|
976
|
-
.text((x2) => (x2.type === "snapshot" ? "{ }" : "file"));
|
|
1135
|
+
.text((x2) => (x2.type === "link" ? linkGlyph(x2) : x2.type === "snapshot" ? "{ }" : "file"));
|
|
977
1136
|
let end = shown.length * (TW + 4);
|
|
978
1137
|
if (shots.length > shown.length) {
|
|
979
1138
|
const more = g
|
|
@@ -999,7 +1158,7 @@
|
|
|
999
1158
|
.attr("class", "flow-shot-t")
|
|
1000
1159
|
.attr("x", end + 2)
|
|
1001
1160
|
.attr("y", TH / 2 + 4)
|
|
1002
|
-
.text(`${fmtRel(t)} · ${shots.length > 1 ?
|
|
1161
|
+
.text(`${fmtRel(t)} · ${shots.length > 1 ? chipCount(shots) : truncate(shots[0].name, 22)}`);
|
|
1003
1162
|
}
|
|
1004
1163
|
};
|
|
1005
1164
|
for (const r of chipRows) {
|
|
@@ -1007,6 +1166,7 @@
|
|
|
1007
1166
|
drawChips(r.x, r.y, r.shots, 6, owner);
|
|
1008
1167
|
}
|
|
1009
1168
|
for (const s of strips) drawChips(s.x, s.y, s.shots, Math.floor((CW - 12) / (TW + 4)), s.n);
|
|
1169
|
+
drawFlowWires(null);
|
|
1010
1170
|
|
|
1011
1171
|
flowPos = new Map([...pos].map(([id, p]) => [id, { x: p.x + CW / 2, y: p.y + CH / 2 }]));
|
|
1012
1172
|
svg.call(flowZoom).on("dblclick.zoom", null);
|
|
@@ -1235,8 +1395,9 @@
|
|
|
1235
1395
|
function detailSections(n) {
|
|
1236
1396
|
const errs = model.errors.filter((e) => e.node === n);
|
|
1237
1397
|
const params = schemaFields(n.v.inputSchema);
|
|
1238
|
-
const evs = n.entries.filter((i) => i.kind !== "info");
|
|
1398
|
+
const evs = n.entries.filter((i) => i.kind !== "info" && i.kind !== "link");
|
|
1239
1399
|
const logs = n.entries.filter((i) => i.kind === "info" || i.kind === "error");
|
|
1400
|
+
const lks = model.links.filter((l) => l.from === n || l.to === n);
|
|
1240
1401
|
const shots = model.gallery.filter((x) => x.node === n);
|
|
1241
1402
|
const asserts = n.entries.filter((i) => i.kind === "pass" || i.kind === "fail");
|
|
1242
1403
|
const logLink = `<div class="acc-file">${svgIcon("file", ' width="14" height="14"')}<a href="${esc(fileUrl(n.v.logFile))}" title="${esc(n.v.logFile)}">${esc(basename(n.v.logFile))}</a></div>`;
|
|
@@ -1268,7 +1429,7 @@
|
|
|
1268
1429
|
title: "Snapshots & files",
|
|
1269
1430
|
count: shots.length,
|
|
1270
1431
|
empty: !shots.length,
|
|
1271
|
-
body: () => `<div class="
|
|
1432
|
+
body: () => `<div class="dshots">${shots.map((g) => shotHtml(g, n)).join("")}</div>`,
|
|
1272
1433
|
},
|
|
1273
1434
|
{
|
|
1274
1435
|
key: "events",
|
|
@@ -1277,6 +1438,13 @@
|
|
|
1277
1438
|
empty: !evs.length,
|
|
1278
1439
|
body: () => `<ul class="ev-list">${evs.map((i) => evLi(i, n)).join("")}</ul>`,
|
|
1279
1440
|
},
|
|
1441
|
+
{
|
|
1442
|
+
key: "links",
|
|
1443
|
+
title: "Links",
|
|
1444
|
+
count: lks.length,
|
|
1445
|
+
empty: !lks.length,
|
|
1446
|
+
body: () => linksHtml(n, lks),
|
|
1447
|
+
},
|
|
1280
1448
|
{
|
|
1281
1449
|
key: "logs",
|
|
1282
1450
|
title: "Logs",
|
|
@@ -1295,10 +1463,54 @@
|
|
|
1295
1463
|
];
|
|
1296
1464
|
}
|
|
1297
1465
|
|
|
1466
|
+
/**
|
|
1467
|
+
* The details panel's links: what `n` did to other actions and what they did to it. Ref
|
|
1468
|
+
* calls fold into one row per (other action, method), with a count and their total time.
|
|
1469
|
+
*/
|
|
1470
|
+
function linksHtml(n, lks) {
|
|
1471
|
+
const rows = [];
|
|
1472
|
+
const refs = new Map();
|
|
1473
|
+
for (const l of lks) {
|
|
1474
|
+
if (l.op === "ref") {
|
|
1475
|
+
const k = `${l.from.id}\0${l.to.id}\0${l.key}`;
|
|
1476
|
+
if (!refs.has(k)) {
|
|
1477
|
+
refs.set(k, { l, n: 0, ms: 0 });
|
|
1478
|
+
rows.push(refs.get(k));
|
|
1479
|
+
}
|
|
1480
|
+
const r = refs.get(k);
|
|
1481
|
+
r.n++;
|
|
1482
|
+
r.ms += l.ms || 0;
|
|
1483
|
+
} else rows.push({ l, n: 1, ms: l.ms || 0 });
|
|
1484
|
+
}
|
|
1485
|
+
const name = (x) => `<b>${esc(displayName(x))}</b>`;
|
|
1486
|
+
return `<ul class="ev-list">${rows
|
|
1487
|
+
.map(({ l, n: count, ms }) => {
|
|
1488
|
+
const out = l.from === n;
|
|
1489
|
+
const text =
|
|
1490
|
+
l.op === "once"
|
|
1491
|
+
? out
|
|
1492
|
+
? `waited for ${esc(l.key)} on ${name(l.to)} · ${esc(l.outcome)} after ${fmtDur(ms)}`
|
|
1493
|
+
: `${name(l.from)} waited for ${esc(l.key)} · ${esc(l.outcome)} after ${fmtDur(ms)}`
|
|
1494
|
+
: l.op === "cancel"
|
|
1495
|
+
? out
|
|
1496
|
+
? `cancelled ${name(l.to)}`
|
|
1497
|
+
: `cancelled by ${name(l.from)}`
|
|
1498
|
+
: out
|
|
1499
|
+
? `${name(l.to)}.${esc(l.key)}() ×${count} · ${fmtDur(ms)}`
|
|
1500
|
+
: `${name(l.from)} called ${esc(l.key)}() ×${count} · ${fmtDur(ms)}`;
|
|
1501
|
+
const color = l.op === "cancel" ? css("--cancel") : colorOf(out ? l.to : l.from);
|
|
1502
|
+
return `<li data-link="${model.links.indexOf(l)}" style="cursor:pointer">
|
|
1503
|
+
<span class="t">${fmtRel(l.t - n.start)}</span>
|
|
1504
|
+
<span class="ev-ico" style="background:${alpha(color, 0.2)};color:${color}">${svgIcon(l.op === "cancel" ? "stop" : l.op === "once" ? "pulse" : "code")}</span>
|
|
1505
|
+
<span class="m">${out ? "→ " : "← "}${text}</span></li>`;
|
|
1506
|
+
})
|
|
1507
|
+
.join("")}</ul>`;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1298
1510
|
/** A snapshot or file in the details panel: a small thumb with its name and when it landed. */
|
|
1299
1511
|
function shotHtml(g, n) {
|
|
1300
1512
|
const i = model.gallery.indexOf(g);
|
|
1301
|
-
return `<div class="
|
|
1513
|
+
return `<div class="dshot" data-gallery="${i}" title="${esc(g.name)}">
|
|
1302
1514
|
${thumbHtml(g)}
|
|
1303
1515
|
<span class="nm">${esc(g.name)}</span>
|
|
1304
1516
|
${g.t != null ? `<span class="t">${fmtRel(g.t - n.start)}</span>` : ""}
|
|
@@ -1329,6 +1541,9 @@
|
|
|
1329
1541
|
root.querySelectorAll("[data-gallery]").forEach((t) =>
|
|
1330
1542
|
t.addEventListener("click", () => openGallery(model.gallery[+t.dataset.gallery]))
|
|
1331
1543
|
);
|
|
1544
|
+
root.querySelectorAll("[data-link]").forEach((t) =>
|
|
1545
|
+
t.addEventListener("click", () => setPin(model.links[+t.dataset.link].t))
|
|
1546
|
+
);
|
|
1332
1547
|
root.querySelectorAll("[data-item]").forEach((t) =>
|
|
1333
1548
|
t.addEventListener("click", () => openItem(model.items[+t.dataset.item]))
|
|
1334
1549
|
);
|
|
@@ -1337,7 +1552,8 @@
|
|
|
1337
1552
|
function openItem(item) {
|
|
1338
1553
|
if (!item) return;
|
|
1339
1554
|
setPin(item.t);
|
|
1340
|
-
if (item.
|
|
1555
|
+
if (item.kind === "link") select(item.node.id, "links");
|
|
1556
|
+
else if (item.node && item.node.id !== state.selected) select(item.node.id, "events");
|
|
1341
1557
|
if (item.gallery) openGallery(item.gallery);
|
|
1342
1558
|
}
|
|
1343
1559
|
function setPin(t) {
|
|
@@ -1548,7 +1764,7 @@
|
|
|
1548
1764
|
? (i) => i.kind === "info" || i.kind === "error"
|
|
1549
1765
|
: tab === "asserts"
|
|
1550
1766
|
? (i) => i.kind === "pass" || i.kind === "fail"
|
|
1551
|
-
: (i) => i.kind !== "info";
|
|
1767
|
+
: (i) => i.kind !== "info" && !(i.kind === "link" && i.e.link && i.e.link.op === "ref");
|
|
1552
1768
|
const rows = [];
|
|
1553
1769
|
for (let idx = 0; idx < model.items.length; idx++) {
|
|
1554
1770
|
const i = model.items[idx];
|
|
@@ -1697,6 +1913,9 @@
|
|
|
1697
1913
|
}
|
|
1698
1914
|
function markTip(d) {
|
|
1699
1915
|
const m = d.m;
|
|
1916
|
+
if (m.kind === "link") {
|
|
1917
|
+
return linkTip(m.link) + (d.more.length ? `<div class="ts" style="margin-top:4px">+${d.more.length} more within a few ms</div>` : "");
|
|
1918
|
+
}
|
|
1700
1919
|
const mk = MARK[m.kind] || MARK.event;
|
|
1701
1920
|
const img = m.gallery && m.gallery.image ? `<img src="${model.asset(m.gallery.f.asset)}" alt="">` : "";
|
|
1702
1921
|
const more = d.more.length ? `<div class="ts">+${d.more.length} more within a few ms</div>` : "";
|
|
@@ -2089,6 +2308,7 @@
|
|
|
2089
2308
|
${legendItem("--fail", "Failure / error", "dot")}
|
|
2090
2309
|
${legendItem("--cancel", "Cancelled", "dash")}
|
|
2091
2310
|
${legendItem("--accent", "Pinned time", "line")}
|
|
2311
|
+
${legendItem("--text-2", "Wait · cancel · ref call", "ring")}
|
|
2092
2312
|
</div>
|
|
2093
2313
|
<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>
|
|
2094
2314
|
</div>
|
|
@@ -2105,7 +2325,9 @@
|
|
|
2105
2325
|
const sw =
|
|
2106
2326
|
shape === "bar"
|
|
2107
2327
|
? `<span style="width:16px;height:9px;border-radius:3px;background:${c}"></span>`
|
|
2108
|
-
: shape === "
|
|
2328
|
+
: shape === "ring"
|
|
2329
|
+
? `<span class="dot" style="width:11px;height:11px;background:var(--panel);box-shadow:inset 0 0 0 2px ${c}"></span>`
|
|
2330
|
+
: shape === "dash"
|
|
2109
2331
|
? `<span style="width:16px;height:0;border-top:2px dashed ${c}"></span>`
|
|
2110
2332
|
: shape === "line"
|
|
2111
2333
|
? `<span style="width:2px;height:14px;background:${c};margin:0 7px"></span>`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omkit",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.18",
|
|
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",
|