@telorun/debug-ui 0.2.1 → 0.4.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/README.md +21 -6
- package/app-dist/assets/index-BMTuPr57.js +126 -0
- package/app-dist/assets/index-CWG3ZSzH.css +1 -0
- package/app-dist/index.html +2 -2
- package/app-single/index.html +48 -26
- package/package.json +4 -2
- package/src/components/DebugPanel.tsx +12 -3
- package/src/components/EventGraph.tsx +462 -0
- package/src/components/index.ts +1 -0
- package/src/components/styles.css +270 -0
- package/src/filter.ts +6 -2
- package/src/graph.test.ts +222 -0
- package/src/graph.ts +360 -0
- package/src/index.ts +16 -0
- package/app-dist/assets/index-CK8ArLzP.css +0 -1
- package/app-dist/assets/index-CwTwcQ4d.js +0 -104
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/debug-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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,8 +43,10 @@
|
|
|
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
|
-
"@telorun/debug-wire": "0.
|
|
49
|
+
"@telorun/debug-wire": "0.2.0"
|
|
48
50
|
},
|
|
49
51
|
"peerDependencies": {
|
|
50
52
|
"react": ">=18",
|
|
@@ -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 `"
|
|
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 = "
|
|
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,462 @@
|
|
|
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.label ?? 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.label ? `${node.kind} · ${node.name}` : 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
|
+
{inv.context !== undefined && (
|
|
424
|
+
<>
|
|
425
|
+
<div className="tdbg-graph-detail-label">Available context</div>
|
|
426
|
+
<PayloadInspector value={inv.context} resolveUrl={resolveUrl} />
|
|
427
|
+
</>
|
|
428
|
+
)}
|
|
429
|
+
</div>
|
|
430
|
+
))}
|
|
431
|
+
</div>
|
|
432
|
+
</aside>
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function DetailHead({ name, onClose }: { name: string; onClose: () => void }) {
|
|
437
|
+
return (
|
|
438
|
+
<div className="tdbg-graph-detail-head">
|
|
439
|
+
<span className="tdbg-node-name">{name}</span>
|
|
440
|
+
<button className="tdbg-btn tdbg-icon-btn" onClick={onClose} title="Close">
|
|
441
|
+
✕
|
|
442
|
+
</button>
|
|
443
|
+
</div>
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function InOut({
|
|
448
|
+
inv,
|
|
449
|
+
resolveUrl,
|
|
450
|
+
}: {
|
|
451
|
+
inv: Pick<Invocation, "outcome" | "inputs" | "outputs" | "payload">;
|
|
452
|
+
resolveUrl: (rel: string) => string;
|
|
453
|
+
}) {
|
|
454
|
+
return (
|
|
455
|
+
<>
|
|
456
|
+
<div className="tdbg-graph-detail-label">Inputs</div>
|
|
457
|
+
<PayloadInspector value={inv.inputs} resolveUrl={resolveUrl} />
|
|
458
|
+
<div className="tdbg-graph-detail-label">{inv.outcome === "ok" ? "Outputs" : "Error"}</div>
|
|
459
|
+
<PayloadInspector value={inv.outcome === "ok" ? inv.outputs : inv.payload} resolveUrl={resolveUrl} />
|
|
460
|
+
</>
|
|
461
|
+
);
|
|
462
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -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";
|