@telorun/debug-ui 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app-dist/assets/{index-CWG3ZSzH.css → index-Bx9-Zdjf.css} +1 -1
- package/app-dist/assets/index-D_01vIS0.js +141 -0
- package/app-dist/index.html +2 -2
- package/app-single/index.html +43 -28
- package/package.json +2 -2
- package/src/components/DebugPanel.tsx +8 -2
- package/src/components/EndpointLinks.tsx +46 -6
- package/src/components/EventGraph.tsx +215 -15
- package/src/components/styles.css +185 -1
- package/src/endpoints.ts +6 -0
- package/src/graph.test.ts +204 -1
- package/src/graph.ts +211 -16
- package/app-dist/assets/index-BMTuPr57.js +0 -126
package/src/endpoints.ts
CHANGED
|
@@ -13,6 +13,12 @@ export interface AppEndpoint {
|
|
|
13
13
|
url?: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** Per-port reachability of a running app's endpoint, surfaced as a status icon
|
|
17
|
+
* beside the link: a spinner while `checking`, ok when `reachable`, error when
|
|
18
|
+
* `unreachable`. Mirrors the runner contract's `ReachabilityState`, kept local so
|
|
19
|
+
* debug-ui stays browser-safe with no dependency on the runner package. */
|
|
20
|
+
export type EndpointReachability = "checking" | "reachable" | "unreachable";
|
|
21
|
+
|
|
16
22
|
/** The link a tcp endpoint resolves to, or `null` for a non-linkable (udp)
|
|
17
23
|
* endpoint. Prefers an explicit `url`; otherwise derives `http://host:port`. */
|
|
18
24
|
export function endpointHref(endpoint: AppEndpoint): string | null {
|
package/src/graph.test.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
collapseTopology,
|
|
4
|
+
deriveGraph,
|
|
5
|
+
deriveInvocations,
|
|
6
|
+
subtreeGraph,
|
|
7
|
+
traceSubgraph,
|
|
8
|
+
} from "./graph.js";
|
|
3
9
|
import type { DebugFrame } from "./wire.js";
|
|
4
10
|
|
|
5
11
|
let seq = 0;
|
|
@@ -12,6 +18,35 @@ const ev = (event: string, payload?: unknown): DebugFrame => ({
|
|
|
12
18
|
const created = (kind: string, name: string, deps: { kind: string; name: string }[] = []) =>
|
|
13
19
|
ev(`${kind}.${name}.Created`, { resource: { kind, name }, dependencies: deps });
|
|
14
20
|
|
|
21
|
+
type Owner = { kind: string; name: string; id: string };
|
|
22
|
+
const idOf = (kind: string, name: string, ownerId?: string) =>
|
|
23
|
+
ownerId ? `${ownerId}/${kind}.${name}` : `${kind}.${name}`;
|
|
24
|
+
|
|
25
|
+
/** A `Created` carrying the hierarchical `id` (and `owner`, for a spawned child)
|
|
26
|
+
* the current kernel emits — the shape that disambiguates two instances of the
|
|
27
|
+
* same templated kind. */
|
|
28
|
+
const createdR = (
|
|
29
|
+
kind: string,
|
|
30
|
+
name: string,
|
|
31
|
+
opts: { owner?: Owner; deps?: { kind: string; name: string; id?: string }[] } = {},
|
|
32
|
+
) =>
|
|
33
|
+
ev(`${kind}.${name}.Created`, {
|
|
34
|
+
resource: { kind, name, id: idOf(kind, name, opts.owner?.id) },
|
|
35
|
+
...(opts.owner ? { owner: opts.owner } : {}),
|
|
36
|
+
dependencies: opts.deps ?? [],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** A terminal dispatch event whose `ref` carries the hierarchical `id`. */
|
|
40
|
+
const dispId = (
|
|
41
|
+
name: string,
|
|
42
|
+
kind: string,
|
|
43
|
+
id: string,
|
|
44
|
+
outcome: Outcome,
|
|
45
|
+
suffix: string,
|
|
46
|
+
detail: Record<string, unknown> = {},
|
|
47
|
+
): DebugFrame =>
|
|
48
|
+
ev(`${name}.${suffix}`, { capability: "invoke", phase: "end", outcome, ref: { kind, name, id }, ...detail });
|
|
49
|
+
|
|
15
50
|
type Outcome = "ok" | "failed" | "rejected" | "cancelled";
|
|
16
51
|
|
|
17
52
|
/** A terminal dispatch (trace) event: the name drops the kind, the payload carries
|
|
@@ -141,6 +176,174 @@ describe("deriveGraph", () => {
|
|
|
141
176
|
});
|
|
142
177
|
});
|
|
143
178
|
|
|
179
|
+
describe("owner grouping", () => {
|
|
180
|
+
const todos: Owner = { kind: "Crud.Resource", name: "todos", id: "Crud.Resource.todos" };
|
|
181
|
+
const users: Owner = { kind: "Crud.Resource", name: "users", id: "Crud.Resource.users" };
|
|
182
|
+
|
|
183
|
+
it("keeps same-named children of two templated instances from colliding", () => {
|
|
184
|
+
const { nodes } = deriveGraph([
|
|
185
|
+
createdR("Crud.Resource", "todos"),
|
|
186
|
+
createdR("Crud.Resource", "users"),
|
|
187
|
+
createdR("SqlRepo.Read", "reader", { owner: todos }),
|
|
188
|
+
createdR("SqlRepo.Read", "reader", { owner: users }),
|
|
189
|
+
]);
|
|
190
|
+
const readers = nodes.filter((n) => n.name === "reader");
|
|
191
|
+
expect(readers.map((n) => n.id).sort()).toEqual([
|
|
192
|
+
"Crud.Resource.todos/SqlRepo.Read.reader",
|
|
193
|
+
"Crud.Resource.users/SqlRepo.Read.reader",
|
|
194
|
+
]);
|
|
195
|
+
expect(readers.map((n) => n.ownerId).sort()).toEqual([
|
|
196
|
+
"Crud.Resource.todos",
|
|
197
|
+
"Crud.Resource.users",
|
|
198
|
+
]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("attributes an invocation to the right instance via ref.id", () => {
|
|
202
|
+
const { nodes } = deriveGraph([
|
|
203
|
+
createdR("Crud.Resource", "todos"),
|
|
204
|
+
createdR("Crud.Resource", "users"),
|
|
205
|
+
createdR("SqlRepo.Read", "reader", { owner: todos }),
|
|
206
|
+
createdR("SqlRepo.Read", "reader", { owner: users }),
|
|
207
|
+
dispId("reader", "SqlRepo.Read", "Crud.Resource.todos/SqlRepo.Read.reader", "ok", "Invoked", {
|
|
208
|
+
inputs: {},
|
|
209
|
+
outputs: 1,
|
|
210
|
+
}),
|
|
211
|
+
]);
|
|
212
|
+
expect(nodes.find((n) => n.id === "Crud.Resource.todos/SqlRepo.Read.reader")?.invokeCount).toBe(1);
|
|
213
|
+
expect(nodes.find((n) => n.id === "Crud.Resource.users/SqlRepo.Read.reader")?.invokeCount).toBe(0);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("collapseTopology hides children until their owner is expanded", () => {
|
|
217
|
+
const graph = deriveGraph([
|
|
218
|
+
createdR("Crud.Resource", "todos"),
|
|
219
|
+
createdR("SqlRepo.Read", "reader", { owner: todos }),
|
|
220
|
+
createdR("SqlRepo.Create", "creator", { owner: todos }),
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
const collapsedDefault = collapseTopology(graph, new Set());
|
|
224
|
+
expect(collapsedDefault.nodes.map((n) => n.id)).toEqual(["Crud.Resource.todos"]);
|
|
225
|
+
expect(collapsedDefault.nodes[0]).toMatchObject({ childCount: 2, expanded: false });
|
|
226
|
+
|
|
227
|
+
const expanded = collapseTopology(graph, new Set(["Crud.Resource.todos"]));
|
|
228
|
+
expect(expanded.nodes.map((n) => n.id).sort()).toEqual([
|
|
229
|
+
"Crud.Resource.todos",
|
|
230
|
+
"Crud.Resource.todos/SqlRepo.Create.creator",
|
|
231
|
+
"Crud.Resource.todos/SqlRepo.Read.reader",
|
|
232
|
+
]);
|
|
233
|
+
expect(expanded.nodes.find((n) => n.id === "Crud.Resource.todos")).toMatchObject({ expanded: true });
|
|
234
|
+
|
|
235
|
+
// Revealed children stay attached to their owner via ownership edges.
|
|
236
|
+
const owns = expanded.edges.filter((e) => e.ownership);
|
|
237
|
+
expect(owns.map((e) => e.target).sort()).toEqual([
|
|
238
|
+
"Crud.Resource.todos/SqlRepo.Create.creator",
|
|
239
|
+
"Crud.Resource.todos/SqlRepo.Read.reader",
|
|
240
|
+
]);
|
|
241
|
+
expect(owns.every((e) => e.source === "Crud.Resource.todos")).toBe(true);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("adds no ownership edges while the owner is collapsed", () => {
|
|
245
|
+
const graph = deriveGraph([
|
|
246
|
+
createdR("Crud.Resource", "todos"),
|
|
247
|
+
createdR("SqlRepo.Read", "reader", { owner: todos }),
|
|
248
|
+
]);
|
|
249
|
+
expect(collapseTopology(graph, new Set()).edges.some((e) => e.ownership)).toBe(false);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("collapseTopology folds a hidden child's edge onto its owner", () => {
|
|
253
|
+
const graph = deriveGraph([
|
|
254
|
+
createdR("Sql.Connection", "db"),
|
|
255
|
+
createdR("Crud.Resource", "todos"),
|
|
256
|
+
createdR("SqlRepo.Read", "reader", {
|
|
257
|
+
owner: todos,
|
|
258
|
+
deps: [{ kind: "Sql.Connection", name: "db", id: "Sql.Connection.db" }],
|
|
259
|
+
}),
|
|
260
|
+
]);
|
|
261
|
+
const collapsed = collapseTopology(graph, new Set());
|
|
262
|
+
expect(collapsed.edges).toContainEqual({
|
|
263
|
+
id: "Crud.Resource.todos->Sql.Connection.db",
|
|
264
|
+
source: "Crud.Resource.todos",
|
|
265
|
+
target: "Sql.Connection.db",
|
|
266
|
+
});
|
|
267
|
+
// The child node itself is hidden while collapsed.
|
|
268
|
+
expect(collapsed.nodes.some((n) => n.id.endsWith("SqlRepo.Read.reader"))).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("subtreeGraph (drill-down pane)", () => {
|
|
273
|
+
const todos: Owner = { kind: "Crud.Resource", name: "todos", id: "Crud.Resource.todos" };
|
|
274
|
+
|
|
275
|
+
it("links the parent only to children not already reached by a sibling", () => {
|
|
276
|
+
const topology = deriveGraph([
|
|
277
|
+
createdR("Crud.Resource", "todos"),
|
|
278
|
+
createdR("Http.Api", "api", {
|
|
279
|
+
owner: todos,
|
|
280
|
+
deps: [{ kind: "SqlRepo.Read", name: "reader", id: "Crud.Resource.todos/SqlRepo.Read.reader" }],
|
|
281
|
+
}),
|
|
282
|
+
createdR("SqlRepo.Read", "reader", { owner: todos }),
|
|
283
|
+
]);
|
|
284
|
+
const sub = subtreeGraph(topology, "Crud.Resource.todos");
|
|
285
|
+
|
|
286
|
+
expect(sub.nodes.map((n) => n.id).sort()).toEqual([
|
|
287
|
+
"Crud.Resource.todos",
|
|
288
|
+
"Crud.Resource.todos/Http.Api.api",
|
|
289
|
+
"Crud.Resource.todos/SqlRepo.Read.reader",
|
|
290
|
+
]);
|
|
291
|
+
expect(sub.nodes.find((n) => n.id === "Crud.Resource.todos")?.expanded).toBe(true);
|
|
292
|
+
|
|
293
|
+
// The parent owns `api` directly; `reader` is reached through `api`, so the
|
|
294
|
+
// redundant parent→reader ownership edge is dropped.
|
|
295
|
+
const owns = sub.edges.filter((e) => e.ownership);
|
|
296
|
+
expect(owns.map((e) => e.target)).toEqual(["Crud.Resource.todos/Http.Api.api"]);
|
|
297
|
+
expect(owns.some((e) => e.target.endsWith("SqlRepo.Read.reader"))).toBe(false);
|
|
298
|
+
|
|
299
|
+
// The dependency edge among children (api → reader) is kept.
|
|
300
|
+
expect(
|
|
301
|
+
sub.edges.some(
|
|
302
|
+
(e) =>
|
|
303
|
+
!e.ownership &&
|
|
304
|
+
e.source === "Crud.Resource.todos/Http.Api.api" &&
|
|
305
|
+
e.target === "Crud.Resource.todos/SqlRepo.Read.reader",
|
|
306
|
+
),
|
|
307
|
+
).toBe(true);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("reports each child's own childCount and drills recursively", () => {
|
|
311
|
+
const reader: Owner = {
|
|
312
|
+
kind: "SqlRepo.Read",
|
|
313
|
+
name: "reader",
|
|
314
|
+
id: "Crud.Resource.todos/SqlRepo.Read.reader",
|
|
315
|
+
};
|
|
316
|
+
const topology = deriveGraph([
|
|
317
|
+
createdR("Crud.Resource", "todos"),
|
|
318
|
+
createdR("SqlRepo.Read", "reader", { owner: todos }),
|
|
319
|
+
createdR("Sql.Query", "reader-query", { owner: reader }),
|
|
320
|
+
]);
|
|
321
|
+
|
|
322
|
+
const lvl1 = subtreeGraph(topology, "Crud.Resource.todos");
|
|
323
|
+
expect(lvl1.nodes.find((n) => n.id === reader.id)?.childCount).toBe(1);
|
|
324
|
+
|
|
325
|
+
const lvl2 = subtreeGraph(topology, reader.id);
|
|
326
|
+
expect(lvl2.nodes.map((n) => n.id).sort()).toEqual([
|
|
327
|
+
"Crud.Resource.todos/SqlRepo.Read.reader",
|
|
328
|
+
"Crud.Resource.todos/SqlRepo.Read.reader/Sql.Query.reader-query",
|
|
329
|
+
]);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("drops dependency edges that leave the subtree", () => {
|
|
333
|
+
const topology = deriveGraph([
|
|
334
|
+
createdR("Sql.Connection", "db"),
|
|
335
|
+
createdR("Crud.Resource", "todos"),
|
|
336
|
+
createdR("SqlRepo.Read", "reader", {
|
|
337
|
+
owner: todos,
|
|
338
|
+
deps: [{ kind: "Sql.Connection", name: "db", id: "Sql.Connection.db" }],
|
|
339
|
+
}),
|
|
340
|
+
]);
|
|
341
|
+
const sub = subtreeGraph(topology, "Crud.Resource.todos");
|
|
342
|
+
expect(sub.nodes.some((n) => n.id === "Sql.Connection.db")).toBe(false);
|
|
343
|
+
expect(sub.edges.some((e) => e.target === "Sql.Connection.db")).toBe(false);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
144
347
|
/** A terminal dispatch event carrying trace ids — `spanId` / `parentSpanId`. */
|
|
145
348
|
const inv = (
|
|
146
349
|
name: string,
|
package/src/graph.ts
CHANGED
|
@@ -41,11 +41,20 @@ export interface InvokeRecord {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export interface GraphNode {
|
|
44
|
-
/** Stable id — the resource
|
|
44
|
+
/** Stable id — the resource's full hierarchical id (`<owner.id>/<kind>.<name>`),
|
|
45
|
+
* unique across instances of the same templated kind. Falls back to the bare
|
|
46
|
+
* name on a legacy stream that carries no `id`. */
|
|
45
47
|
id: string;
|
|
46
48
|
kind: string;
|
|
47
49
|
name: string;
|
|
48
50
|
module?: string;
|
|
51
|
+
/** The owning resource's id, when this resource was spawned by another (a
|
|
52
|
+
* templated kind's child). Drives the collapsible parent/child grouping. */
|
|
53
|
+
ownerId?: string;
|
|
54
|
+
/** The resource's resolved config "after templating" (from the `Created`
|
|
55
|
+
* payload) — concrete values for compile-time CEL, `{kind,name}` for refs,
|
|
56
|
+
* `${{ … }}` for deferred runtime expressions, secrets scrubbed. */
|
|
57
|
+
properties?: unknown;
|
|
49
58
|
status: NodeStatus;
|
|
50
59
|
/** Total invocations seen across the stream. */
|
|
51
60
|
invokeCount: number;
|
|
@@ -61,6 +70,10 @@ export interface GraphEdge {
|
|
|
61
70
|
source: string;
|
|
62
71
|
/** Target resource name (the dependency). */
|
|
63
72
|
target: string;
|
|
73
|
+
/** An ownership edge (owner → spawned child), not a dependency. Added when a
|
|
74
|
+
* templated resource is expanded so its revealed children stay attached to it;
|
|
75
|
+
* the renderer draws it distinctly (dashed/muted). */
|
|
76
|
+
ownership?: boolean;
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
export interface GraphState {
|
|
@@ -72,9 +85,23 @@ interface WireResourceRef {
|
|
|
72
85
|
kind: string;
|
|
73
86
|
name: string;
|
|
74
87
|
module?: string;
|
|
88
|
+
/** Full hierarchical id; absent on a legacy stream. */
|
|
89
|
+
id?: string;
|
|
75
90
|
alias?: string;
|
|
76
91
|
}
|
|
77
92
|
|
|
93
|
+
interface WireOwner {
|
|
94
|
+
kind: string;
|
|
95
|
+
name: string;
|
|
96
|
+
id: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The id a node is keyed by: the producer's hierarchical `id`, or the bare name
|
|
100
|
+
* on a legacy stream. Globally unique when `id` is present. */
|
|
101
|
+
function nodeKey(ref: { id?: string; name: string }): string {
|
|
102
|
+
return ref.id ?? ref.name;
|
|
103
|
+
}
|
|
104
|
+
|
|
78
105
|
function asResource(payload: unknown): WireResourceRef | undefined {
|
|
79
106
|
const res = (payload as { resource?: unknown } | undefined)?.resource;
|
|
80
107
|
if (res && typeof res === "object") {
|
|
@@ -86,12 +113,30 @@ function asResource(payload: unknown): WireResourceRef | undefined {
|
|
|
86
113
|
return undefined;
|
|
87
114
|
}
|
|
88
115
|
|
|
89
|
-
/** The
|
|
90
|
-
|
|
116
|
+
/** The owning resource a lifecycle/dispatch payload carries under `owner`, when
|
|
117
|
+
* the resource was spawned by another (a templated kind's child). */
|
|
118
|
+
function readOwner(payload: unknown): WireOwner | undefined {
|
|
119
|
+
const owner = (payload as { owner?: unknown } | undefined)?.owner;
|
|
120
|
+
if (owner && typeof owner === "object") {
|
|
121
|
+
const { kind, name, id } = owner as Record<string, unknown>;
|
|
122
|
+
if (typeof kind === "string" && typeof name === "string" && typeof id === "string") {
|
|
123
|
+
return { kind, name, id };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The resolved ref a dispatch event's payload carries under `ref` — `id` keys
|
|
130
|
+
* the node (a templated child's calls land on the right instance). */
|
|
131
|
+
function readRef(
|
|
132
|
+
payload: Record<string, unknown> | undefined,
|
|
133
|
+
): { kind: string; name: string; id?: string } | undefined {
|
|
91
134
|
const ref = payload?.ref;
|
|
92
135
|
if (ref && typeof ref === "object") {
|
|
93
|
-
const { kind, name } = ref as Record<string, unknown>;
|
|
94
|
-
if (typeof kind === "string" && typeof name === "string")
|
|
136
|
+
const { kind, name, id } = ref as Record<string, unknown>;
|
|
137
|
+
if (typeof kind === "string" && typeof name === "string") {
|
|
138
|
+
return { kind, name, id: typeof id === "string" ? id : undefined };
|
|
139
|
+
}
|
|
95
140
|
}
|
|
96
141
|
return undefined;
|
|
97
142
|
}
|
|
@@ -133,15 +178,18 @@ export function deriveGraph(frames: readonly DebugFrame[]): GraphState {
|
|
|
133
178
|
if (suffix === "Created") {
|
|
134
179
|
const res = asResource(event.payload);
|
|
135
180
|
if (!res) return;
|
|
136
|
-
const
|
|
181
|
+
const key = nodeKey(res);
|
|
182
|
+
const existing = nodes.get(key);
|
|
137
183
|
if (existing) {
|
|
138
184
|
existing.lastActivitySeq = seq;
|
|
139
185
|
} else {
|
|
140
|
-
nodes.set(
|
|
141
|
-
id:
|
|
186
|
+
nodes.set(key, {
|
|
187
|
+
id: key,
|
|
142
188
|
kind: res.kind,
|
|
143
189
|
name: res.name,
|
|
144
190
|
module: res.module,
|
|
191
|
+
ownerId: readOwner(event.payload)?.id,
|
|
192
|
+
properties: (event.payload as { properties?: unknown }).properties,
|
|
145
193
|
status: "created",
|
|
146
194
|
invokeCount: 0,
|
|
147
195
|
lastActivitySeq: seq,
|
|
@@ -149,16 +197,17 @@ export function deriveGraph(frames: readonly DebugFrame[]): GraphState {
|
|
|
149
197
|
}
|
|
150
198
|
const deps = (event.payload as { dependencies?: WireResourceRef[] }).dependencies ?? [];
|
|
151
199
|
for (const dep of deps) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
200
|
+
const depKey = dep?.name ? nodeKey(dep) : undefined;
|
|
201
|
+
if (!depKey || depKey === key) continue;
|
|
202
|
+
const id = `${key}->${depKey}`;
|
|
203
|
+
if (!edges.has(id)) edges.set(id, { id, source: key, target: depKey });
|
|
155
204
|
}
|
|
156
205
|
return;
|
|
157
206
|
}
|
|
158
207
|
|
|
159
208
|
if (suffix === "Initialized" || suffix === "Teardown") {
|
|
160
209
|
const res = asResource(event.payload);
|
|
161
|
-
const node = res ? nodes.get(res
|
|
210
|
+
const node = res ? nodes.get(nodeKey(res)) : undefined;
|
|
162
211
|
if (node) {
|
|
163
212
|
node.status = suffix === "Teardown" ? "torndown" : "initialized";
|
|
164
213
|
node.lastActivitySeq = seq;
|
|
@@ -166,13 +215,13 @@ export function deriveGraph(frames: readonly DebugFrame[]): GraphState {
|
|
|
166
215
|
return;
|
|
167
216
|
}
|
|
168
217
|
|
|
169
|
-
// Dispatch (trace) events: the payload's `ref.
|
|
170
|
-
// directly (nodes are
|
|
218
|
+
// Dispatch (trace) events: the payload's `ref.id` resolves the target node
|
|
219
|
+
// directly (nodes are id-keyed). `phase:"start"` events carry no `outcome`
|
|
171
220
|
// and are skipped — only terminal events record a result.
|
|
172
221
|
const p = (event.payload ?? undefined) as Record<string, unknown> | undefined;
|
|
173
222
|
const ref = readRef(p);
|
|
174
223
|
const outcome = asOutcome(p?.outcome);
|
|
175
|
-
const node = ref ? nodes.get(ref
|
|
224
|
+
const node = ref ? nodes.get(nodeKey(ref)) : undefined;
|
|
176
225
|
if (!node || !outcome) return;
|
|
177
226
|
node.invokeCount += 1;
|
|
178
227
|
node.lastActivitySeq = seq;
|
|
@@ -189,6 +238,148 @@ export function deriveGraph(frames: readonly DebugFrame[]): GraphState {
|
|
|
189
238
|
return { nodes: [...nodes.values()], edges: [...edges.values()] };
|
|
190
239
|
}
|
|
191
240
|
|
|
241
|
+
// ── Owner grouping (collapsible parents) ────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/** A topology node augmented with its grouping state — how many direct children
|
|
244
|
+
* it owns and whether they are currently revealed. */
|
|
245
|
+
export interface GroupedGraphNode extends GraphNode {
|
|
246
|
+
/** Number of resources directly owned by this node (spawned children). */
|
|
247
|
+
childCount: number;
|
|
248
|
+
/** Whether this node's children are currently shown (only meaningful when
|
|
249
|
+
* `childCount > 0`). */
|
|
250
|
+
expanded: boolean;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface GroupedGraphState {
|
|
254
|
+
nodes: GroupedGraphNode[];
|
|
255
|
+
edges: GraphEdge[];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Count direct children per owner id — how many resources each node spawned. */
|
|
259
|
+
function countChildren(nodes: readonly GraphNode[]): Map<string, number> {
|
|
260
|
+
const counts = new Map<string, number>();
|
|
261
|
+
for (const n of nodes) {
|
|
262
|
+
if (n.ownerId) counts.set(n.ownerId, (counts.get(n.ownerId) ?? 0) + 1);
|
|
263
|
+
}
|
|
264
|
+
return counts;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Collapse a topology by owner: a resource spawned by another (a templated kind's
|
|
269
|
+
* child) is hidden unless every ancestor in its owner chain is in `expanded`. A
|
|
270
|
+
* collapsed parent absorbs its hidden descendants' edges — each edge endpoint is
|
|
271
|
+
* remapped to its nearest visible ancestor — so the parent stays wired to the
|
|
272
|
+
* rest of the graph as one node. Pure: drives the Graph view's collapse toggles
|
|
273
|
+
* and is independently testable. With an empty `expanded` set, every owner is
|
|
274
|
+
* collapsed (the default), so the top-level topology stays readable.
|
|
275
|
+
*/
|
|
276
|
+
export function collapseTopology(
|
|
277
|
+
graph: GraphState,
|
|
278
|
+
expanded: ReadonlySet<string>,
|
|
279
|
+
): GroupedGraphState {
|
|
280
|
+
const byId = new Map<string, GraphNode>(graph.nodes.map((n) => [n.id, n] as [string, GraphNode]));
|
|
281
|
+
const childCount = countChildren(graph.nodes);
|
|
282
|
+
|
|
283
|
+
// A node is visible when no present ancestor in its owner chain is collapsed.
|
|
284
|
+
const visible = (node: GraphNode): boolean => {
|
|
285
|
+
let cur: GraphNode | undefined = node;
|
|
286
|
+
while (cur?.ownerId) {
|
|
287
|
+
const parent = byId.get(cur.ownerId);
|
|
288
|
+
if (!parent) return true; // owner not in this stream — don't hide under an unknown
|
|
289
|
+
if (!expanded.has(parent.id)) return false;
|
|
290
|
+
cur = parent;
|
|
291
|
+
}
|
|
292
|
+
return true;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// The nearest visible ancestor an edge endpoint folds into.
|
|
296
|
+
const resolveVisible = (id: string): string => {
|
|
297
|
+
const start = byId.get(id);
|
|
298
|
+
if (!start) return id;
|
|
299
|
+
let node: GraphNode = start;
|
|
300
|
+
while (!visible(node)) {
|
|
301
|
+
const parent = node.ownerId ? byId.get(node.ownerId) : undefined;
|
|
302
|
+
if (!parent) return node.id;
|
|
303
|
+
node = parent;
|
|
304
|
+
}
|
|
305
|
+
return node.id;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const nodes = graph.nodes
|
|
309
|
+
.filter(visible)
|
|
310
|
+
.map((n) => ({ ...n, childCount: childCount.get(n.id) ?? 0, expanded: expanded.has(n.id) }));
|
|
311
|
+
|
|
312
|
+
const edges = new Map<string, GraphEdge>();
|
|
313
|
+
for (const e of graph.edges) {
|
|
314
|
+
const source = resolveVisible(e.source);
|
|
315
|
+
const target = resolveVisible(e.target);
|
|
316
|
+
if (source === target) continue;
|
|
317
|
+
const id = `${source}->${target}`;
|
|
318
|
+
if (!edges.has(id)) edges.set(id, { id, source, target });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Attach each revealed child to its owner: when a parent is expanded both it
|
|
322
|
+
// and its children are visible, but no dependency edge ties them together, so
|
|
323
|
+
// they would float as a detached cluster. An ownership edge keeps them grouped
|
|
324
|
+
// (and pulls the children next to the parent in the layout).
|
|
325
|
+
const visibleIds = new Set(nodes.map((n) => n.id));
|
|
326
|
+
for (const n of nodes) {
|
|
327
|
+
if (n.ownerId && visibleIds.has(n.ownerId)) {
|
|
328
|
+
const id = `owner:${n.ownerId}->${n.id}`;
|
|
329
|
+
edges.set(id, { id, source: n.ownerId, target: n.id, ownership: true });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return { nodes, edges: [...edges.values()] };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The drill-down view of one templated resource: the parent node plus the direct
|
|
338
|
+
* children it spawned. Children are wired by the dependency edges that run *among*
|
|
339
|
+
* them (e.g. an Http.Api → its SQL handlers); anything pointing outside the
|
|
340
|
+
* subtree is dropped (that wiring is visible at the level above). The parent
|
|
341
|
+
* connects by an ownership edge only to children that aren't already reached by a
|
|
342
|
+
* sibling's dependency edge — so a handler reached through the Http.Api isn't also
|
|
343
|
+
* tied directly to the parent, leaving a clean tree instead of a redundant fan.
|
|
344
|
+
* Each child that itself owns children reports its `childCount` for a further
|
|
345
|
+
* drill-in. Pure and depth-independent: drilling into a child calls this again.
|
|
346
|
+
*/
|
|
347
|
+
export function subtreeGraph(topology: GraphState, ownerId: string): GroupedGraphState {
|
|
348
|
+
const childCount = countChildren(topology.nodes);
|
|
349
|
+
|
|
350
|
+
const parent = topology.nodes.find((n) => n.id === ownerId);
|
|
351
|
+
const children = topology.nodes.filter((n) => n.ownerId === ownerId);
|
|
352
|
+
|
|
353
|
+
const nodes: GroupedGraphNode[] = [];
|
|
354
|
+
if (parent) nodes.push({ ...parent, childCount: childCount.get(parent.id) ?? 0, expanded: true });
|
|
355
|
+
for (const c of children) {
|
|
356
|
+
nodes.push({ ...c, childCount: childCount.get(c.id) ?? 0, expanded: false });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const present = new Set(nodes.map((n) => n.id));
|
|
360
|
+
const edges = new Map<string, GraphEdge>();
|
|
361
|
+
|
|
362
|
+
// Dependency edges among the shown nodes; track which are reached by one so the
|
|
363
|
+
// parent doesn't also tie to a child already connected through a sibling.
|
|
364
|
+
const reachedByDep = new Set<string>();
|
|
365
|
+
for (const e of topology.edges) {
|
|
366
|
+
if (e.source !== e.target && present.has(e.source) && present.has(e.target) && !edges.has(e.id)) {
|
|
367
|
+
edges.set(e.id, e);
|
|
368
|
+
reachedByDep.add(e.target);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Ownership edge parent → child, only for children not already wired in by a
|
|
373
|
+
// sibling's dependency edge.
|
|
374
|
+
for (const c of children) {
|
|
375
|
+
if (reachedByDep.has(c.id)) continue;
|
|
376
|
+
const id = `owner:${ownerId}->${c.id}`;
|
|
377
|
+
edges.set(id, { id, source: ownerId, target: c.id, ownership: true });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return { nodes, edges: [...edges.values()] };
|
|
381
|
+
}
|
|
382
|
+
|
|
192
383
|
// ── Invocation traces ──────────────────────────────────────────────────────
|
|
193
384
|
|
|
194
385
|
/** Which capability the span represents. `"request"` is an inbound-boundary span. */
|
|
@@ -202,6 +393,9 @@ export interface Invocation {
|
|
|
202
393
|
traceId?: string;
|
|
203
394
|
kind: string;
|
|
204
395
|
name: string;
|
|
396
|
+
/** The dispatched resource's full hierarchical id; absent on a legacy stream.
|
|
397
|
+
* Keys the scoped trace graph so a templated child collapses per instance. */
|
|
398
|
+
resourceId?: string;
|
|
205
399
|
capability?: SpanCapability;
|
|
206
400
|
outcome: InvokeOutcome;
|
|
207
401
|
/** The terminal event's suffix (`Invoked`, `InvokeFailed`, …) — for display only. */
|
|
@@ -259,6 +453,7 @@ export function deriveInvocations(frames: readonly DebugFrame[]): TraceState {
|
|
|
259
453
|
traceId: typeof p?.traceId === "string" ? p.traceId : undefined,
|
|
260
454
|
kind: ref.kind,
|
|
261
455
|
name: ref.name,
|
|
456
|
+
resourceId: ref.id,
|
|
262
457
|
capability: asCapability(p?.capability),
|
|
263
458
|
outcome,
|
|
264
459
|
suffix: eventSuffix(event.event),
|
|
@@ -323,7 +518,7 @@ export function traceSubgraph(trace: TraceState, rootId: number): TraceSubgraph
|
|
|
323
518
|
const root = trace.byId.get(rootId);
|
|
324
519
|
if (!root) return { nodes: [], edges: [] };
|
|
325
520
|
|
|
326
|
-
const resourceKey = (inv: Invocation) => `${inv.kind}.${inv.name}`;
|
|
521
|
+
const resourceKey = (inv: Invocation) => inv.resourceId ?? `${inv.kind}.${inv.name}`;
|
|
327
522
|
const nodes = new Map<string, TraceNode>();
|
|
328
523
|
const edges = new Map<string, TraceEdge>();
|
|
329
524
|
|