@telorun/debug-ui 0.2.1 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/debug-ui",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Browser-safe debug event UI for Telo: wire types, filtering, SSE client, and React components for watching a running app's event stream.",
5
5
  "keywords": [
6
6
  "telo",
@@ -43,6 +43,8 @@
43
43
  "src/**"
44
44
  ],
45
45
  "dependencies": {
46
+ "@dagrejs/dagre": "^3.0.0",
47
+ "@xyflow/react": "^12.10.2",
46
48
  "lucide-react": "^1.8.0",
47
49
  "@telorun/debug-wire": "0.1.0"
48
50
  },
@@ -4,6 +4,7 @@ import { distinctSuffixes, type EventFilter, matchesFilter } from "../filter.js"
4
4
  import { type DebugTheme, loadStoredTheme, storeTheme, useResolvedTheme } from "../theme.js";
5
5
  import { type DebugEvent, type DebugFrame, isLogFrame } from "../wire.js";
6
6
  import { EndpointLinks } from "./EndpointLinks.js";
7
+ import { EventGraph } from "./EventGraph.js";
7
8
  import { EventTable } from "./EventTable.js";
8
9
  import { FilterBar } from "./FilterBar.js";
9
10
  import { LogView } from "./LogView.js";
@@ -26,7 +27,7 @@ export interface DebugPanelProps {
26
27
  * {@link LogView} — e.g. the editor injects its interactive terminal here so
27
28
  * the panel is writable. Standalone hosts omit it and get the log line view. */
28
29
  logsSlot?: ReactNode;
29
- /** Which tab is shown first. Default `"events"`; the editor opens on `"logs"`
30
+ /** Which tab is shown first. Default `"graph"`; the editor opens on `"logs"`
30
31
  * so its interactive terminal is front-and-centre. */
31
32
  defaultTab?: Tab;
32
33
  /** Exposed addresses of the running application, rendered as links in the
@@ -38,7 +39,7 @@ export interface DebugPanelProps {
38
39
  theme?: DebugTheme;
39
40
  }
40
41
 
41
- type Tab = "events" | "logs";
42
+ type Tab = "events" | "graph" | "logs";
42
43
 
43
44
  /**
44
45
  * The presentation half of the debug watcher: a Logs / Events tab split over one
@@ -55,7 +56,7 @@ export function DebugPanel({
55
56
  onClear,
56
57
  resolveBlobUrl,
57
58
  logsSlot,
58
- defaultTab = "events",
59
+ defaultTab = "graph",
59
60
  endpoints,
60
61
  theme,
61
62
  }: DebugPanelProps) {
@@ -89,6 +90,12 @@ export function DebugPanel({
89
90
  <div className="tdbg-root" data-theme={resolvedTheme}>
90
91
  <div className="tdbg-tabs">
91
92
  <span className={`tdbg-status tdbg-status-${status}`} title={`stream ${status}`} />
93
+ <button
94
+ className={`tdbg-tab${tab === "graph" ? " tdbg-tab-on" : ""}`}
95
+ onClick={() => setTab("graph")}
96
+ >
97
+ Graph
98
+ </button>
92
99
  <button
93
100
  className={`tdbg-tab${tab === "events" ? " tdbg-tab-on" : ""}`}
94
101
  onClick={() => setTab("events")}
@@ -123,6 +130,8 @@ export function DebugPanel({
123
130
  />
124
131
  <EventTable events={visible} resolveUrl={resolveBlobUrl} />
125
132
  </>
133
+ ) : tab === "graph" ? (
134
+ <EventGraph events={events} resolveUrl={resolveBlobUrl} />
126
135
  ) : logsSlot ? (
127
136
  <div className="tdbg-slot">{logsSlot}</div>
128
137
  ) : (
@@ -0,0 +1,456 @@
1
+ import Dagre from "@dagrejs/dagre";
2
+ import {
3
+ Background,
4
+ Controls,
5
+ Handle,
6
+ type Node,
7
+ type NodeProps,
8
+ Panel,
9
+ Position,
10
+ ReactFlow,
11
+ } from "@xyflow/react";
12
+ import "@xyflow/react/dist/style.css";
13
+ import { useEffect, useMemo, useRef, useState } from "react";
14
+ import {
15
+ deriveGraph,
16
+ deriveInvocations,
17
+ type GraphNode,
18
+ type Invocation,
19
+ type TraceNode,
20
+ traceSubgraph,
21
+ } from "../graph.js";
22
+ import type { DebugEvent } from "../wire.js";
23
+ import { PayloadInspector } from "./PayloadInspector.js";
24
+
25
+ export interface EventGraphProps {
26
+ /** The event stream, in arrival order. Logs are already filtered out. */
27
+ events: readonly DebugEvent[];
28
+ /** Resolve a blob pointer to a fetchable URL (passed through to the inspector). */
29
+ resolveUrl: (rel: string) => string;
30
+ }
31
+
32
+ const NODE_WIDTH = 190;
33
+ const NODE_HEIGHT = 52;
34
+
35
+ const badgeClass = (outcome: Invocation["outcome"]) =>
36
+ `tdbg-badge tdbg-suffix-${outcome === "ok" ? "invoked" : outcome}`;
37
+
38
+ // ── Custom nodes ────────────────────────────────────────────────────────────
39
+
40
+ interface TopologyNodeData extends Record<string, unknown> {
41
+ node: GraphNode;
42
+ selected: boolean;
43
+ }
44
+
45
+ /** Live-topology node: status dot, name + kind, invocation badge; pulses on each
46
+ * new invocation, tinted by outcome. */
47
+ function TopologyNode({ data }: NodeProps<Node<TopologyNodeData>>) {
48
+ const { node, selected } = data;
49
+ const [pulse, setPulse] = useState<"" | "ok" | "bad">("");
50
+ const prevSeq = useRef(node.lastActivitySeq);
51
+
52
+ useEffect(() => {
53
+ if (node.lastActivitySeq === prevSeq.current) return;
54
+ prevSeq.current = node.lastActivitySeq;
55
+ if (!node.lastInvoke) return;
56
+ setPulse(node.lastInvoke.outcome === "ok" ? "ok" : "bad");
57
+ const t = setTimeout(() => setPulse(""), 650);
58
+ return () => clearTimeout(t);
59
+ }, [node.lastActivitySeq, node.lastInvoke]);
60
+
61
+ const cls = [
62
+ "tdbg-node",
63
+ `tdbg-node-${node.status}`,
64
+ selected ? "tdbg-node-sel" : "",
65
+ pulse ? `tdbg-node-pulse-${pulse}` : "",
66
+ ]
67
+ .filter(Boolean)
68
+ .join(" ");
69
+
70
+ return (
71
+ <div className={cls} style={{ width: NODE_WIDTH }}>
72
+ <Handle type="target" position={Position.Left} className="tdbg-node-handle" />
73
+ <div className="tdbg-node-head">
74
+ <span className={`tdbg-node-dot tdbg-node-dot-${node.status}`} />
75
+ <span className="tdbg-node-name">{node.name}</span>
76
+ {node.invokeCount > 0 && (
77
+ <span
78
+ className={`tdbg-node-count${
79
+ node.lastInvoke && node.lastInvoke.outcome !== "ok" ? " tdbg-node-count-bad" : ""
80
+ }`}
81
+ >
82
+ {node.invokeCount}×
83
+ </span>
84
+ )}
85
+ </div>
86
+ <div className="tdbg-node-kind">{node.kind}</div>
87
+ <Handle type="source" position={Position.Right} className="tdbg-node-handle" />
88
+ </div>
89
+ );
90
+ }
91
+
92
+ interface TraceNodeData extends Record<string, unknown> {
93
+ node: TraceNode;
94
+ selected: boolean;
95
+ }
96
+
97
+ /** Scoped-trace node: a resource that took part in the selected invocation. The
98
+ * root is marked; the outcome of its last call in the trace tints the box. */
99
+ function TraceFlowNode({ data }: NodeProps<Node<TraceNodeData>>) {
100
+ const { node, selected } = data;
101
+ const last = node.invocations[node.invocations.length - 1];
102
+ const cls = [
103
+ "tdbg-node",
104
+ "tdbg-node-initialized",
105
+ node.isRoot ? "tdbg-node-root" : "",
106
+ selected ? "tdbg-node-sel" : "",
107
+ last.outcome !== "ok" ? "tdbg-node-bad" : "",
108
+ ]
109
+ .filter(Boolean)
110
+ .join(" ");
111
+
112
+ return (
113
+ <div className={cls} style={{ width: NODE_WIDTH }}>
114
+ <Handle type="target" position={Position.Left} className="tdbg-node-handle" />
115
+ <div className="tdbg-node-head">
116
+ {node.isRoot && <span className="tdbg-node-roottag">root</span>}
117
+ <span className="tdbg-node-name">{node.name}</span>
118
+ <span className={badgeClass(last.outcome)}>
119
+ {node.invocations.length > 1 ? `${node.invocations.length}×` : last.suffix}
120
+ </span>
121
+ </div>
122
+ <div className="tdbg-node-kind">{node.kind}</div>
123
+ <Handle type="source" position={Position.Right} className="tdbg-node-handle" />
124
+ </div>
125
+ );
126
+ }
127
+
128
+ const nodeTypes = { topology: TopologyNode, trace: TraceFlowNode };
129
+
130
+ /** dagre LR layout → id-keyed top-left positions. Works for either graph shape
131
+ * (both expose `nodes[].id` + `edges[].{source,target}`). */
132
+ function layoutPositions(graph: {
133
+ nodes: { id: string }[];
134
+ edges: { source: string; target: string }[];
135
+ }): Map<string, { x: number; y: number }> {
136
+ const present = new Set(graph.nodes.map((n) => n.id));
137
+ const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
138
+ g.setGraph({ rankdir: "LR", nodesep: 24, ranksep: 70 });
139
+ for (const n of graph.nodes) g.setNode(n.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
140
+ for (const e of graph.edges) {
141
+ if (present.has(e.source) && present.has(e.target)) g.setEdge(e.source, e.target);
142
+ }
143
+ Dagre.layout(g);
144
+ const pos = new Map<string, { x: number; y: number }>();
145
+ for (const n of graph.nodes) {
146
+ const p = g.node(n.id);
147
+ pos.set(n.id, { x: (p?.x ?? 0) - NODE_WIDTH / 2, y: (p?.y ?? 0) - NODE_HEIGHT / 2 });
148
+ }
149
+ return pos;
150
+ }
151
+
152
+ // ── The view ────────────────────────────────────────────────────────────────
153
+
154
+ /**
155
+ * The Graph view. The left rail lists every root invocation; selecting one scopes
156
+ * the canvas to just the resources that took part in that call (wired by the real
157
+ * parent→child call edges). With nothing selected, the canvas shows the live
158
+ * resource topology — gray on `Created`, brighter on `Initialized`, pulsing per
159
+ * invocation. Selecting a node opens its inputs → outputs.
160
+ */
161
+ export function EventGraph({ events, resolveUrl }: EventGraphProps) {
162
+ const [traceId, setTraceId] = useState<number | null>(null);
163
+ const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
164
+
165
+ // Default on: standalone services / providers with no dependency wiring are
166
+ // usually noise in the topology view.
167
+ const [hideUnconnected, setHideUnconnected] = useState(true);
168
+
169
+ const topology = useMemo(() => deriveGraph(events), [events]);
170
+ const trace = useMemo(() => deriveInvocations(events), [events]);
171
+
172
+ // A selected trace that's no longer present (buffer trimmed) falls back to live.
173
+ const activeTraceId = traceId !== null && trace.byId.has(traceId) ? traceId : null;
174
+ const scoped = useMemo(
175
+ () => (activeTraceId !== null ? traceSubgraph(trace, activeTraceId) : null),
176
+ [trace, activeTraceId],
177
+ );
178
+
179
+ // Topology with isolated nodes (no incoming/outgoing edge) dropped when the
180
+ // toggle is on. Only meaningful for the live topology — trace subgraphs are all
181
+ // connected by call edges.
182
+ const displayTopology = useMemo(() => {
183
+ if (!hideUnconnected) return topology;
184
+ const connected = new Set<string>();
185
+ for (const e of topology.edges) {
186
+ connected.add(e.source);
187
+ connected.add(e.target);
188
+ }
189
+ return { nodes: topology.nodes.filter((n) => connected.has(n.id)), edges: topology.edges };
190
+ }, [topology, hideUnconnected]);
191
+ const hiddenCount = topology.nodes.length - displayTopology.nodes.length;
192
+
193
+ const graph = scoped ?? displayTopology;
194
+ const topoKey = useMemo(
195
+ () =>
196
+ `${activeTraceId ?? "live"}#${graph.nodes.map((n) => n.id).join(",")}|${graph.edges
197
+ .map((e) => e.id)
198
+ .join(",")}`,
199
+ [graph, activeTraceId],
200
+ );
201
+ const positions = useMemo(() => layoutPositions(graph), [topoKey]);
202
+ const present = useMemo(() => new Set(graph.nodes.map((n) => n.id)), [graph]);
203
+
204
+ const flowNodes: Node[] = useMemo(() => {
205
+ if (scoped) {
206
+ return scoped.nodes.map((n) => ({
207
+ id: n.id,
208
+ type: "trace",
209
+ position: positions.get(n.id) ?? { x: 0, y: 0 },
210
+ data: { node: n, selected: n.id === selectedNodeId } satisfies TraceNodeData,
211
+ }));
212
+ }
213
+ return displayTopology.nodes.map((n) => ({
214
+ id: n.id,
215
+ type: "topology",
216
+ position: positions.get(n.id) ?? { x: 0, y: 0 },
217
+ data: { node: n, selected: n.id === selectedNodeId } satisfies TopologyNodeData,
218
+ }));
219
+ }, [scoped, displayTopology, positions, selectedNodeId]);
220
+
221
+ const flowEdges = useMemo(
222
+ () =>
223
+ graph.edges
224
+ .filter((e) => present.has(e.source) && present.has(e.target))
225
+ .map((e) => ({ id: e.id, source: e.source, target: e.target })),
226
+ [graph, present],
227
+ );
228
+
229
+ const selectTrace = (id: number | null) => {
230
+ setTraceId(id);
231
+ setSelectedNodeId(null);
232
+ };
233
+
234
+ return (
235
+ <div className="tdbg-graph">
236
+ <TraceList
237
+ roots={trace.roots}
238
+ childCount={(id) => countSubtree(trace, id)}
239
+ activeId={activeTraceId}
240
+ onSelect={selectTrace}
241
+ />
242
+ <div className="tdbg-graph-canvas">
243
+ {graph.nodes.length === 0 ? (
244
+ <div className="tdbg-empty">
245
+ {activeTraceId !== null
246
+ ? "No resources in this trace."
247
+ : hideUnconnected && topology.nodes.length > 0
248
+ ? `All ${topology.nodes.length} resource(s) are unconnected — turn off "Hide unconnected" to show them.`
249
+ : "No resources yet — waiting for the stream…"}
250
+ </div>
251
+ ) : (
252
+ <ReactFlow
253
+ // Remount when the scope changes (an invocation is selected/cleared)
254
+ // so `fitView` re-runs against the new node set.
255
+ key={activeTraceId ?? "live"}
256
+ nodes={flowNodes}
257
+ edges={flowEdges}
258
+ nodeTypes={nodeTypes}
259
+ nodesDraggable={false}
260
+ nodesConnectable={false}
261
+ edgesFocusable={false}
262
+ fitView
263
+ minZoom={0.2}
264
+ proOptions={{ hideAttribution: true }}
265
+ onNodeClick={(_e, node) => setSelectedNodeId(node.id)}
266
+ onPaneClick={() => setSelectedNodeId(null)}
267
+ >
268
+ <Background />
269
+ <Controls showInteractive={false} />
270
+ {!scoped && (
271
+ <Panel position="top-right">
272
+ <label className="tdbg-graph-toggle">
273
+ <input
274
+ type="checkbox"
275
+ checked={hideUnconnected}
276
+ onChange={(e) => setHideUnconnected(e.target.checked)}
277
+ />
278
+ Hide unconnected
279
+ {hideUnconnected && hiddenCount > 0 && (
280
+ <span className="tdbg-muted"> ({hiddenCount})</span>
281
+ )}
282
+ </label>
283
+ </Panel>
284
+ )}
285
+ </ReactFlow>
286
+ )}
287
+ </div>
288
+ {selectedNodeId &&
289
+ (scoped ? (
290
+ <TraceNodeDetail
291
+ node={scoped.nodes.find((n) => n.id === selectedNodeId)}
292
+ resolveUrl={resolveUrl}
293
+ onClose={() => setSelectedNodeId(null)}
294
+ />
295
+ ) : (
296
+ <NodeDetail
297
+ node={topology.nodes.find((n) => n.id === selectedNodeId)}
298
+ resolveUrl={resolveUrl}
299
+ onClose={() => setSelectedNodeId(null)}
300
+ />
301
+ ))}
302
+ </div>
303
+ );
304
+ }
305
+
306
+ function countSubtree(trace: ReturnType<typeof deriveInvocations>, rootId: number): number {
307
+ let n = 0;
308
+ const visit = (id: number) => {
309
+ n += 1;
310
+ for (const c of trace.childrenOf.get(id) ?? []) visit(c);
311
+ };
312
+ visit(rootId);
313
+ return n;
314
+ }
315
+
316
+ function TraceList({
317
+ roots,
318
+ childCount,
319
+ activeId,
320
+ onSelect,
321
+ }: {
322
+ roots: Invocation[];
323
+ childCount: (id: number) => number;
324
+ activeId: number | null;
325
+ onSelect: (id: number | null) => void;
326
+ }) {
327
+ return (
328
+ <aside className="tdbg-trace-list">
329
+ <div className="tdbg-trace-list-head">Invocations</div>
330
+ <button
331
+ className={`tdbg-trace-row${activeId === null ? " tdbg-trace-row-on" : ""}`}
332
+ onClick={() => onSelect(null)}
333
+ >
334
+ <span className="tdbg-node-name">Live topology</span>
335
+ </button>
336
+ {roots.length === 0 ? (
337
+ <div className="tdbg-trace-empty">
338
+ No traced invocations yet. Run with <code>--inspect</code> to capture traces.
339
+ </div>
340
+ ) : (
341
+ [...roots].reverse().map((r) => {
342
+ const calls = childCount(r.id);
343
+ return (
344
+ <button
345
+ key={r.id}
346
+ className={`tdbg-trace-row${activeId === r.id ? " tdbg-trace-row-on" : ""}`}
347
+ onClick={() => onSelect(r.id)}
348
+ >
349
+ <span className="tdbg-time">{r.timestamp.slice(11, 23)}</span>
350
+ <span className="tdbg-node-name">{r.name}</span>
351
+ <span className={badgeClass(r.outcome)}>{r.outcome === "ok" ? "ok" : r.outcome}</span>
352
+ {calls > 1 && <span className="tdbg-trace-calls">{calls}</span>}
353
+ </button>
354
+ );
355
+ })
356
+ )}
357
+ </aside>
358
+ );
359
+ }
360
+
361
+ function NodeDetail({
362
+ node,
363
+ resolveUrl,
364
+ onClose,
365
+ }: {
366
+ node: GraphNode | undefined;
367
+ resolveUrl: (rel: string) => string;
368
+ onClose: () => void;
369
+ }) {
370
+ if (!node) return null;
371
+ const i = node.lastInvoke;
372
+ return (
373
+ <aside className="tdbg-graph-detail">
374
+ <DetailHead name={node.name} onClose={onClose} />
375
+ <div className="tdbg-graph-detail-meta">
376
+ <span className="tdbg-muted">{node.kind}</span>
377
+ <span className={`tdbg-badge tdbg-suffix-${node.status}`}>{node.status}</span>
378
+ <span className="tdbg-muted">{node.invokeCount} invocation(s)</span>
379
+ </div>
380
+ {i ? (
381
+ <div className="tdbg-graph-detail-body">
382
+ <div className="tdbg-graph-detail-sub">
383
+ Last call
384
+ <span className={badgeClass(i.outcome)}>{i.suffix}</span>
385
+ <span className="tdbg-muted">{i.timestamp.slice(11, 23)}</span>
386
+ </div>
387
+ <InOut inv={i} resolveUrl={resolveUrl} />
388
+ </div>
389
+ ) : (
390
+ <div className="tdbg-empty">Not invoked yet.</div>
391
+ )}
392
+ </aside>
393
+ );
394
+ }
395
+
396
+ function TraceNodeDetail({
397
+ node,
398
+ resolveUrl,
399
+ onClose,
400
+ }: {
401
+ node: TraceNode | undefined;
402
+ resolveUrl: (rel: string) => string;
403
+ onClose: () => void;
404
+ }) {
405
+ if (!node) return null;
406
+ return (
407
+ <aside className="tdbg-graph-detail">
408
+ <DetailHead name={node.name} onClose={onClose} />
409
+ <div className="tdbg-graph-detail-meta">
410
+ <span className="tdbg-muted">{node.kind}</span>
411
+ {node.isRoot && <span className="tdbg-badge tdbg-suffix-created">root</span>}
412
+ <span className="tdbg-muted">{node.invocations.length} call(s) in trace</span>
413
+ </div>
414
+ <div className="tdbg-graph-detail-body">
415
+ {node.invocations.map((inv) => (
416
+ <div key={inv.id} className="tdbg-graph-detail-body">
417
+ <div className="tdbg-graph-detail-sub">
418
+ <span className={badgeClass(inv.outcome)}>{inv.suffix}</span>
419
+ <span className="tdbg-muted">#{inv.id}</span>
420
+ <span className="tdbg-muted">{inv.timestamp.slice(11, 23)}</span>
421
+ </div>
422
+ <InOut inv={inv} resolveUrl={resolveUrl} />
423
+ </div>
424
+ ))}
425
+ </div>
426
+ </aside>
427
+ );
428
+ }
429
+
430
+ function DetailHead({ name, onClose }: { name: string; onClose: () => void }) {
431
+ return (
432
+ <div className="tdbg-graph-detail-head">
433
+ <span className="tdbg-node-name">{name}</span>
434
+ <button className="tdbg-btn tdbg-icon-btn" onClick={onClose} title="Close">
435
+ ✕
436
+ </button>
437
+ </div>
438
+ );
439
+ }
440
+
441
+ function InOut({
442
+ inv,
443
+ resolveUrl,
444
+ }: {
445
+ inv: Pick<Invocation, "outcome" | "inputs" | "outputs" | "payload">;
446
+ resolveUrl: (rel: string) => string;
447
+ }) {
448
+ return (
449
+ <>
450
+ <div className="tdbg-graph-detail-label">Inputs</div>
451
+ <PayloadInspector value={inv.inputs} resolveUrl={resolveUrl} />
452
+ <div className="tdbg-graph-detail-label">{inv.outcome === "ok" ? "Outputs" : "Error"}</div>
453
+ <PayloadInspector value={inv.outcome === "ok" ? inv.outputs : inv.payload} resolveUrl={resolveUrl} />
454
+ </>
455
+ );
456
+ }
@@ -2,6 +2,7 @@
2
2
  export { DebugWatcher, type DebugWatcherProps } from "./DebugWatcher.js";
3
3
  export { DebugPanel, type DebugPanelProps } from "./DebugPanel.js";
4
4
  export { EventTable } from "./EventTable.js";
5
+ export { EventGraph, type EventGraphProps } from "./EventGraph.js";
5
6
  export { FilterBar, type FilterBarProps } from "./FilterBar.js";
6
7
  export { LogView, type LogViewProps } from "./LogView.js";
7
8
  export { PayloadInspector } from "./PayloadInspector.js";