@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/debug-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"@dagrejs/dagre": "^3.0.0",
|
|
47
47
|
"@xyflow/react": "^12.10.2",
|
|
48
48
|
"lucide-react": "^1.8.0",
|
|
49
|
-
"@telorun/debug-wire": "0.
|
|
49
|
+
"@telorun/debug-wire": "0.3.0"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"react": ">=18",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ReactNode, useMemo, useState } from "react";
|
|
2
|
-
import type { AppEndpoint } from "../endpoints.js";
|
|
2
|
+
import type { AppEndpoint, EndpointReachability } from "../endpoints.js";
|
|
3
3
|
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";
|
|
@@ -34,6 +34,9 @@ export interface DebugPanelProps {
|
|
|
34
34
|
* header. The standalone {@link DebugWatcher} sources these from the producer's
|
|
35
35
|
* handshake; the editor passes its resolved run endpoints. */
|
|
36
36
|
endpoints?: readonly AppEndpoint[];
|
|
37
|
+
/** Per-port reachability for the endpoint badges (spinner / ok / error). The
|
|
38
|
+
* editor sources it from the runner's `reachability` events. */
|
|
39
|
+
endpointReachability?: ReadonlyMap<number, EndpointReachability>;
|
|
37
40
|
/** Color theme. Defaults to `"system"` (follows the OS); an embedding host
|
|
38
41
|
* (e.g. the editor) passes its own resolved `"light"` / `"dark"`. */
|
|
39
42
|
theme?: DebugTheme;
|
|
@@ -58,6 +61,7 @@ export function DebugPanel({
|
|
|
58
61
|
logsSlot,
|
|
59
62
|
defaultTab = "graph",
|
|
60
63
|
endpoints,
|
|
64
|
+
endpointReachability,
|
|
61
65
|
theme,
|
|
62
66
|
}: DebugPanelProps) {
|
|
63
67
|
const [tab, setTab] = useState<Tab>(defaultTab);
|
|
@@ -109,7 +113,9 @@ export function DebugPanel({
|
|
|
109
113
|
Logs{logsSlot ? null : <span className="tdbg-tabcount">{logs.length}</span>}
|
|
110
114
|
</button>
|
|
111
115
|
<span className="tdbg-tabs-spacer" />
|
|
112
|
-
{endpoints && endpoints.length > 0 &&
|
|
116
|
+
{endpoints && endpoints.length > 0 && (
|
|
117
|
+
<EndpointLinks endpoints={endpoints} reachability={endpointReachability} />
|
|
118
|
+
)}
|
|
113
119
|
<button className="tdbg-btn" onClick={onTogglePause}>
|
|
114
120
|
{paused ? "Resume" : "Pause"}
|
|
115
121
|
</button>
|
|
@@ -1,14 +1,23 @@
|
|
|
1
|
-
import { ExternalLink } from "lucide-react";
|
|
2
|
-
import {
|
|
1
|
+
import { CheckCircle2, ExternalLink, Loader2, XCircle } from "lucide-react";
|
|
2
|
+
import {
|
|
3
|
+
type AppEndpoint,
|
|
4
|
+
type EndpointReachability,
|
|
5
|
+
endpointHref,
|
|
6
|
+
endpointLabel,
|
|
7
|
+
} from "../endpoints.js";
|
|
3
8
|
|
|
4
9
|
export interface EndpointLinksProps {
|
|
5
10
|
endpoints: readonly AppEndpoint[];
|
|
11
|
+
/** Per-port reachability, rendered as a status icon on each tcp link. A tcp
|
|
12
|
+
* endpoint with no entry yet shows the `checking` spinner. */
|
|
13
|
+
reachability?: ReadonlyMap<number, EndpointReachability>;
|
|
6
14
|
}
|
|
7
15
|
|
|
8
16
|
/** The running application's exposed addresses, rendered in the panel header.
|
|
9
|
-
* tcp endpoints are clickable links to the app
|
|
10
|
-
*
|
|
11
|
-
|
|
17
|
+
* tcp endpoints are clickable links to the app, each carrying a reachability
|
|
18
|
+
* status icon (spinner while checking, ok/error once known); udp endpoints show
|
|
19
|
+
* as a plain label. Renders nothing when there are no endpoints. */
|
|
20
|
+
export function EndpointLinks({ endpoints, reachability }: EndpointLinksProps) {
|
|
12
21
|
if (endpoints.length === 0) return null;
|
|
13
22
|
return (
|
|
14
23
|
<span className="tdbg-endpoints">
|
|
@@ -32,11 +41,42 @@ export function EndpointLinks({ endpoints }: EndpointLinksProps) {
|
|
|
32
41
|
rel="noreferrer"
|
|
33
42
|
title={`Open ${href}`}
|
|
34
43
|
>
|
|
35
|
-
|
|
44
|
+
{reachability && (
|
|
45
|
+
<ReachabilityIcon state={reachability.get(endpoint.port) ?? "checking"} />
|
|
46
|
+
)}
|
|
36
47
|
{label}
|
|
48
|
+
<ExternalLink size={11} aria-hidden />
|
|
37
49
|
</a>
|
|
38
50
|
);
|
|
39
51
|
})}
|
|
40
52
|
</span>
|
|
41
53
|
);
|
|
42
54
|
}
|
|
55
|
+
|
|
56
|
+
function ReachabilityIcon({ state }: { state: EndpointReachability }) {
|
|
57
|
+
if (state === "reachable") {
|
|
58
|
+
return (
|
|
59
|
+
<CheckCircle2
|
|
60
|
+
size={11}
|
|
61
|
+
className="tdbg-endpoint-status tdbg-endpoint-status--reachable"
|
|
62
|
+
aria-label="reachable"
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (state === "unreachable") {
|
|
67
|
+
return (
|
|
68
|
+
<XCircle
|
|
69
|
+
size={11}
|
|
70
|
+
className="tdbg-endpoint-status tdbg-endpoint-status--unreachable"
|
|
71
|
+
aria-label="not reachable"
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return (
|
|
76
|
+
<Loader2
|
|
77
|
+
size={11}
|
|
78
|
+
className="tdbg-endpoint-status tdbg-endpoint-status--checking tdbg-spin"
|
|
79
|
+
aria-label="checking reachability"
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -2,6 +2,7 @@ import Dagre from "@dagrejs/dagre";
|
|
|
2
2
|
import {
|
|
3
3
|
Background,
|
|
4
4
|
Controls,
|
|
5
|
+
type Edge,
|
|
5
6
|
Handle,
|
|
6
7
|
type Node,
|
|
7
8
|
type NodeProps,
|
|
@@ -10,12 +11,17 @@ import {
|
|
|
10
11
|
ReactFlow,
|
|
11
12
|
} from "@xyflow/react";
|
|
12
13
|
import "@xyflow/react/dist/style.css";
|
|
13
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
14
|
+
import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react";
|
|
14
15
|
import {
|
|
16
|
+
collapseTopology,
|
|
15
17
|
deriveGraph,
|
|
16
18
|
deriveInvocations,
|
|
19
|
+
type GraphEdge,
|
|
17
20
|
type GraphNode,
|
|
21
|
+
type GraphState,
|
|
22
|
+
type GroupedGraphNode,
|
|
18
23
|
type Invocation,
|
|
24
|
+
subtreeGraph,
|
|
19
25
|
type TraceNode,
|
|
20
26
|
traceSubgraph,
|
|
21
27
|
} from "../graph.js";
|
|
@@ -38,12 +44,14 @@ const badgeClass = (outcome: Invocation["outcome"]) =>
|
|
|
38
44
|
// ── Custom nodes ────────────────────────────────────────────────────────────
|
|
39
45
|
|
|
40
46
|
interface TopologyNodeData extends Record<string, unknown> {
|
|
41
|
-
node:
|
|
47
|
+
node: GroupedGraphNode;
|
|
42
48
|
selected: boolean;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
/** Live-topology node: status dot, name + kind, invocation badge; pulses on each
|
|
46
|
-
* new invocation, tinted by outcome.
|
|
52
|
+
* new invocation, tinted by outcome. A node that owns spawned children (a
|
|
53
|
+
* templated kind) shows an "n internal" badge — clicking it opens the drill-down
|
|
54
|
+
* pane with that resource and the children it spawned. */
|
|
47
55
|
function TopologyNode({ data }: NodeProps<Node<TopologyNodeData>>) {
|
|
48
56
|
const { node, selected } = data;
|
|
49
57
|
const [pulse, setPulse] = useState<"" | "ok" | "bad">("");
|
|
@@ -61,6 +69,7 @@ function TopologyNode({ data }: NodeProps<Node<TopologyNodeData>>) {
|
|
|
61
69
|
const cls = [
|
|
62
70
|
"tdbg-node",
|
|
63
71
|
`tdbg-node-${node.status}`,
|
|
72
|
+
node.childCount > 0 ? "tdbg-node-owner" : "",
|
|
64
73
|
selected ? "tdbg-node-sel" : "",
|
|
65
74
|
pulse ? `tdbg-node-pulse-${pulse}` : "",
|
|
66
75
|
]
|
|
@@ -84,6 +93,9 @@ function TopologyNode({ data }: NodeProps<Node<TopologyNodeData>>) {
|
|
|
84
93
|
)}
|
|
85
94
|
</div>
|
|
86
95
|
<div className="tdbg-node-kind">{node.kind}</div>
|
|
96
|
+
{node.childCount > 0 && (
|
|
97
|
+
<span className="tdbg-node-internal">{node.childCount} internal ›</span>
|
|
98
|
+
)}
|
|
87
99
|
<Handle type="source" position={Position.Right} className="tdbg-node-handle" />
|
|
88
100
|
</div>
|
|
89
101
|
);
|
|
@@ -149,6 +161,29 @@ function layoutPositions(graph: {
|
|
|
149
161
|
return pos;
|
|
150
162
|
}
|
|
151
163
|
|
|
164
|
+
/** Map graph edges to ReactFlow edges, dropping any whose endpoints aren't shown.
|
|
165
|
+
* Ownership edges (owner → child) render dashed + muted so they read as
|
|
166
|
+
* containment, not data flow. */
|
|
167
|
+
function buildFlowEdges(edges: readonly GraphEdge[], present: ReadonlySet<string>): Edge[] {
|
|
168
|
+
return edges
|
|
169
|
+
.filter((e) => present.has(e.source) && present.has(e.target))
|
|
170
|
+
.map((e) => ({
|
|
171
|
+
id: e.id,
|
|
172
|
+
source: e.source,
|
|
173
|
+
target: e.target,
|
|
174
|
+
...(e.ownership
|
|
175
|
+
? {
|
|
176
|
+
className: "tdbg-edge-owner",
|
|
177
|
+
style: { stroke: "var(--tdbg-muted)", strokeDasharray: "5 4", opacity: 0.65 },
|
|
178
|
+
}
|
|
179
|
+
: {}),
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The main canvas shows the topology fully collapsed — templated resources are
|
|
184
|
+
* one node each; their children are reached through the drill-down pane. */
|
|
185
|
+
const NO_EXPANSION: ReadonlySet<string> = new Set();
|
|
186
|
+
|
|
152
187
|
// ── The view ────────────────────────────────────────────────────────────────
|
|
153
188
|
|
|
154
189
|
/**
|
|
@@ -162,6 +197,10 @@ export function EventGraph({ events, resolveUrl }: EventGraphProps) {
|
|
|
162
197
|
const [traceId, setTraceId] = useState<number | null>(null);
|
|
163
198
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
|
164
199
|
|
|
200
|
+
// Drill-down stack of owner ids: clicking a templated resource pushes it and
|
|
201
|
+
// opens the nested pane; the breadcrumb pops back. Empty = pane closed.
|
|
202
|
+
const [drilldown, setDrilldown] = useState<string[]>([]);
|
|
203
|
+
|
|
165
204
|
// Default on: standalone services / providers with no dependency wiring are
|
|
166
205
|
// usually noise in the topology view.
|
|
167
206
|
const [hideUnconnected, setHideUnconnected] = useState(true);
|
|
@@ -176,19 +215,23 @@ export function EventGraph({ events, resolveUrl }: EventGraphProps) {
|
|
|
176
215
|
[trace, activeTraceId],
|
|
177
216
|
);
|
|
178
217
|
|
|
218
|
+
// The main canvas is the fully-collapsed topology: a templated resource is one
|
|
219
|
+
// node (absorbing its children's edges); the children are reached by clicking it.
|
|
220
|
+
const collapsed = useMemo(() => collapseTopology(topology, NO_EXPANSION), [topology]);
|
|
221
|
+
|
|
179
222
|
// Topology with isolated nodes (no incoming/outgoing edge) dropped when the
|
|
180
223
|
// toggle is on. Only meaningful for the live topology — trace subgraphs are all
|
|
181
224
|
// connected by call edges.
|
|
182
225
|
const displayTopology = useMemo(() => {
|
|
183
|
-
if (!hideUnconnected) return
|
|
226
|
+
if (!hideUnconnected) return collapsed;
|
|
184
227
|
const connected = new Set<string>();
|
|
185
|
-
for (const e of
|
|
228
|
+
for (const e of collapsed.edges) {
|
|
186
229
|
connected.add(e.source);
|
|
187
230
|
connected.add(e.target);
|
|
188
231
|
}
|
|
189
|
-
return { nodes:
|
|
190
|
-
}, [
|
|
191
|
-
const hiddenCount =
|
|
232
|
+
return { nodes: collapsed.nodes.filter((n) => connected.has(n.id)), edges: collapsed.edges };
|
|
233
|
+
}, [collapsed, hideUnconnected]);
|
|
234
|
+
const hiddenCount = collapsed.nodes.length - displayTopology.nodes.length;
|
|
192
235
|
|
|
193
236
|
const graph = scoped ?? displayTopology;
|
|
194
237
|
const topoKey = useMemo(
|
|
@@ -219,16 +262,26 @@ export function EventGraph({ events, resolveUrl }: EventGraphProps) {
|
|
|
219
262
|
}, [scoped, displayTopology, positions, selectedNodeId]);
|
|
220
263
|
|
|
221
264
|
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 })),
|
|
265
|
+
() => buildFlowEdges(graph.edges as readonly GraphEdge[], present),
|
|
226
266
|
[graph, present],
|
|
227
267
|
);
|
|
228
268
|
|
|
229
269
|
const selectTrace = (id: number | null) => {
|
|
230
270
|
setTraceId(id);
|
|
231
271
|
setSelectedNodeId(null);
|
|
272
|
+
setDrilldown([]);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// Clicking a templated resource (one that owns children) opens the drill-down
|
|
276
|
+
// pane; any other node opens its inputs/outputs detail.
|
|
277
|
+
const onTopologyNodeClick = (id: string) => {
|
|
278
|
+
const node = displayTopology.nodes.find((n) => n.id === id);
|
|
279
|
+
if (node && node.childCount > 0) {
|
|
280
|
+
setDrilldown([id]);
|
|
281
|
+
setSelectedNodeId(null);
|
|
282
|
+
} else {
|
|
283
|
+
setSelectedNodeId(id);
|
|
284
|
+
}
|
|
232
285
|
};
|
|
233
286
|
|
|
234
287
|
return (
|
|
@@ -244,8 +297,8 @@ export function EventGraph({ events, resolveUrl }: EventGraphProps) {
|
|
|
244
297
|
<div className="tdbg-empty">
|
|
245
298
|
{activeTraceId !== null
|
|
246
299
|
? "No resources in this trace."
|
|
247
|
-
: hideUnconnected &&
|
|
248
|
-
? `All ${
|
|
300
|
+
: hideUnconnected && collapsed.nodes.length > 0
|
|
301
|
+
? `All ${collapsed.nodes.length} resource(s) are unconnected — turn off "Hide unconnected" to show them.`
|
|
249
302
|
: "No resources yet — waiting for the stream…"}
|
|
250
303
|
</div>
|
|
251
304
|
) : (
|
|
@@ -262,7 +315,9 @@ export function EventGraph({ events, resolveUrl }: EventGraphProps) {
|
|
|
262
315
|
fitView
|
|
263
316
|
minZoom={0.2}
|
|
264
317
|
proOptions={{ hideAttribution: true }}
|
|
265
|
-
onNodeClick={(_e, node) =>
|
|
318
|
+
onNodeClick={(_e, node) =>
|
|
319
|
+
scoped ? setSelectedNodeId(node.id) : onTopologyNodeClick(node.id)
|
|
320
|
+
}
|
|
266
321
|
onPaneClick={() => setSelectedNodeId(null)}
|
|
267
322
|
>
|
|
268
323
|
<Background />
|
|
@@ -299,10 +354,146 @@ export function EventGraph({ events, resolveUrl }: EventGraphProps) {
|
|
|
299
354
|
onClose={() => setSelectedNodeId(null)}
|
|
300
355
|
/>
|
|
301
356
|
))}
|
|
357
|
+
{!scoped && drilldown.length > 0 && (
|
|
358
|
+
<>
|
|
359
|
+
<div className="tdbg-nested-backdrop" onClick={() => setDrilldown([])} />
|
|
360
|
+
{drilldown.map((ownerId, i) => (
|
|
361
|
+
<NestedGraphPane
|
|
362
|
+
key={`${ownerId}@${i}`}
|
|
363
|
+
topology={topology}
|
|
364
|
+
ownerId={ownerId}
|
|
365
|
+
depth={i}
|
|
366
|
+
isTop={i === drilldown.length - 1}
|
|
367
|
+
onDrill={(childId) => setDrilldown((d) => [...d.slice(0, i + 1), childId])}
|
|
368
|
+
onReveal={() => setDrilldown((d) => d.slice(0, i + 1))}
|
|
369
|
+
onClose={() => setDrilldown((d) => d.slice(0, i))}
|
|
370
|
+
resolveUrl={resolveUrl}
|
|
371
|
+
/>
|
|
372
|
+
))}
|
|
373
|
+
</>
|
|
374
|
+
)}
|
|
302
375
|
</div>
|
|
303
376
|
);
|
|
304
377
|
}
|
|
305
378
|
|
|
379
|
+
/**
|
|
380
|
+
* One panel in the drill-down stack — a templated resource and the children it
|
|
381
|
+
* spawned (`subtreeGraph`), wired by dashed ownership edges plus the dependency
|
|
382
|
+
* edges among them. Each drill-in pushes a *new* panel (cascaded to the right);
|
|
383
|
+
* clicking a child that owns its own children opens the next panel, recursive to
|
|
384
|
+
* any depth. Only the top panel is interactive; clicking a panel beneath pops the
|
|
385
|
+
* stack back to it, and clicking a leaf (or the parent) opens its detail.
|
|
386
|
+
*/
|
|
387
|
+
function NestedGraphPane({
|
|
388
|
+
topology,
|
|
389
|
+
ownerId,
|
|
390
|
+
depth,
|
|
391
|
+
isTop,
|
|
392
|
+
onDrill,
|
|
393
|
+
onReveal,
|
|
394
|
+
onClose,
|
|
395
|
+
resolveUrl,
|
|
396
|
+
}: {
|
|
397
|
+
topology: GraphState;
|
|
398
|
+
ownerId: string;
|
|
399
|
+
depth: number;
|
|
400
|
+
isTop: boolean;
|
|
401
|
+
onDrill: (childId: string) => void;
|
|
402
|
+
onReveal: () => void;
|
|
403
|
+
onClose: () => void;
|
|
404
|
+
resolveUrl: (rel: string) => string;
|
|
405
|
+
}) {
|
|
406
|
+
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
|
407
|
+
|
|
408
|
+
const nodeById = useMemo(() => new Map(topology.nodes.map((n) => [n.id, n])), [topology]);
|
|
409
|
+
const sub = useMemo(() => subtreeGraph(topology, ownerId), [topology, ownerId]);
|
|
410
|
+
const positions = useMemo(() => layoutPositions(sub), [sub]);
|
|
411
|
+
const present = useMemo(() => new Set(sub.nodes.map((n) => n.id)), [sub]);
|
|
412
|
+
|
|
413
|
+
const flowNodes: Node[] = useMemo(
|
|
414
|
+
() =>
|
|
415
|
+
sub.nodes.map((n) => ({
|
|
416
|
+
id: n.id,
|
|
417
|
+
type: "topology",
|
|
418
|
+
position: positions.get(n.id) ?? { x: 0, y: 0 },
|
|
419
|
+
data: { node: n, selected: n.id === selectedNodeId } satisfies TopologyNodeData,
|
|
420
|
+
})),
|
|
421
|
+
[sub, positions, selectedNodeId],
|
|
422
|
+
);
|
|
423
|
+
const flowEdges = useMemo(() => buildFlowEdges(sub.edges, present), [sub, present]);
|
|
424
|
+
|
|
425
|
+
const onNodeClick = (id: string) => {
|
|
426
|
+
const node = sub.nodes.find((n) => n.id === id);
|
|
427
|
+
// A child that owns its own children opens the next panel; the parent itself
|
|
428
|
+
// or a leaf opens its detail in this panel.
|
|
429
|
+
if (id !== ownerId && node && node.childCount > 0) onDrill(id);
|
|
430
|
+
else setSelectedNodeId(id);
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const owner = nodeById.get(ownerId);
|
|
434
|
+
const detailNode = selectedNodeId ? nodeById.get(selectedNodeId) : undefined;
|
|
435
|
+
|
|
436
|
+
return (
|
|
437
|
+
<aside
|
|
438
|
+
className={`tdbg-nested-pane${isTop ? "" : " tdbg-nested-pane-under"}`}
|
|
439
|
+
// `--tdbg-depth` drives the cascade offset (`left`) in CSS, so a media query
|
|
440
|
+
// can tighten it on a narrow screen; z-index stacks the panels.
|
|
441
|
+
style={{ zIndex: 20 + depth, ["--tdbg-depth"]: depth } as CSSProperties}
|
|
442
|
+
// A panel beneath the top acts as a single "go back here" surface.
|
|
443
|
+
onClick={isTop ? undefined : onReveal}
|
|
444
|
+
>
|
|
445
|
+
<div className="tdbg-nested-head">
|
|
446
|
+
<span className="tdbg-nested-title">
|
|
447
|
+
<span className="tdbg-node-name">{owner?.name ?? ownerId}</span>
|
|
448
|
+
{owner && <span className="tdbg-muted">{owner.kind}</span>}
|
|
449
|
+
</span>
|
|
450
|
+
<button
|
|
451
|
+
className="tdbg-btn tdbg-icon-btn"
|
|
452
|
+
onClick={(e) => {
|
|
453
|
+
e.stopPropagation();
|
|
454
|
+
onClose();
|
|
455
|
+
}}
|
|
456
|
+
title="Close"
|
|
457
|
+
>
|
|
458
|
+
✕
|
|
459
|
+
</button>
|
|
460
|
+
</div>
|
|
461
|
+
<div className="tdbg-nested-body" style={{ pointerEvents: isTop ? "auto" : "none" }}>
|
|
462
|
+
<div className="tdbg-nested-canvas">
|
|
463
|
+
{sub.nodes.length === 0 ? (
|
|
464
|
+
<div className="tdbg-empty">This resource spawned no children.</div>
|
|
465
|
+
) : (
|
|
466
|
+
<ReactFlow
|
|
467
|
+
key={ownerId}
|
|
468
|
+
nodes={flowNodes}
|
|
469
|
+
edges={flowEdges}
|
|
470
|
+
nodeTypes={nodeTypes}
|
|
471
|
+
nodesDraggable={false}
|
|
472
|
+
nodesConnectable={false}
|
|
473
|
+
edgesFocusable={false}
|
|
474
|
+
fitView
|
|
475
|
+
minZoom={0.2}
|
|
476
|
+
proOptions={{ hideAttribution: true }}
|
|
477
|
+
onNodeClick={(_e, node) => onNodeClick(node.id)}
|
|
478
|
+
onPaneClick={() => setSelectedNodeId(null)}
|
|
479
|
+
>
|
|
480
|
+
<Background />
|
|
481
|
+
<Controls showInteractive={false} />
|
|
482
|
+
</ReactFlow>
|
|
483
|
+
)}
|
|
484
|
+
</div>
|
|
485
|
+
{detailNode && (
|
|
486
|
+
<NodeDetail
|
|
487
|
+
node={detailNode}
|
|
488
|
+
resolveUrl={resolveUrl}
|
|
489
|
+
onClose={() => setSelectedNodeId(null)}
|
|
490
|
+
/>
|
|
491
|
+
)}
|
|
492
|
+
</div>
|
|
493
|
+
</aside>
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
306
497
|
function countSubtree(trace: ReturnType<typeof deriveInvocations>, rootId: number): number {
|
|
307
498
|
let n = 0;
|
|
308
499
|
const visit = (id: number) => {
|
|
@@ -369,6 +560,9 @@ function NodeDetail({
|
|
|
369
560
|
}) {
|
|
370
561
|
if (!node) return null;
|
|
371
562
|
const i = node.lastInvoke;
|
|
563
|
+
const props = node.properties;
|
|
564
|
+
const hasProps =
|
|
565
|
+
!!props && typeof props === "object" && Object.keys(props as object).length > 0;
|
|
372
566
|
return (
|
|
373
567
|
<aside className="tdbg-graph-detail">
|
|
374
568
|
<DetailHead name={node.name} onClose={onClose} />
|
|
@@ -377,6 +571,12 @@ function NodeDetail({
|
|
|
377
571
|
<span className={`tdbg-badge tdbg-suffix-${node.status}`}>{node.status}</span>
|
|
378
572
|
<span className="tdbg-muted">{node.invokeCount} invocation(s)</span>
|
|
379
573
|
</div>
|
|
574
|
+
{hasProps && (
|
|
575
|
+
<div className="tdbg-graph-detail-body">
|
|
576
|
+
<div className="tdbg-graph-detail-label">Properties</div>
|
|
577
|
+
<PayloadInspector value={props} resolveUrl={resolveUrl} />
|
|
578
|
+
</div>
|
|
579
|
+
)}
|
|
380
580
|
{i ? (
|
|
381
581
|
<div className="tdbg-graph-detail-body">
|
|
382
582
|
<div className="tdbg-graph-detail-sub">
|
|
@@ -88,6 +88,26 @@
|
|
|
88
88
|
.tdbg-endpoint-link:hover {
|
|
89
89
|
border-color: var(--tdbg-accent);
|
|
90
90
|
}
|
|
91
|
+
.tdbg-endpoint-status {
|
|
92
|
+
flex: none;
|
|
93
|
+
}
|
|
94
|
+
.tdbg-endpoint-status--checking {
|
|
95
|
+
color: var(--tdbg-muted);
|
|
96
|
+
}
|
|
97
|
+
.tdbg-endpoint-status--reachable {
|
|
98
|
+
color: var(--tdbg-good);
|
|
99
|
+
}
|
|
100
|
+
.tdbg-endpoint-status--unreachable {
|
|
101
|
+
color: var(--tdbg-bad);
|
|
102
|
+
}
|
|
103
|
+
.tdbg-spin {
|
|
104
|
+
animation: tdbg-spin 0.8s linear infinite;
|
|
105
|
+
}
|
|
106
|
+
@keyframes tdbg-spin {
|
|
107
|
+
to {
|
|
108
|
+
transform: rotate(360deg);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
91
111
|
|
|
92
112
|
.tdbg-bar {
|
|
93
113
|
position: sticky;
|
|
@@ -333,6 +353,7 @@
|
|
|
333
353
|
display: flex;
|
|
334
354
|
flex: 1;
|
|
335
355
|
min-height: 0;
|
|
356
|
+
position: relative;
|
|
336
357
|
}
|
|
337
358
|
.tdbg-graph-canvas {
|
|
338
359
|
flex: 1;
|
|
@@ -349,6 +370,77 @@
|
|
|
349
370
|
--xy-controls-button-color-hover-default: var(--tdbg-accent);
|
|
350
371
|
--xy-controls-button-border-color-default: var(--tdbg-line);
|
|
351
372
|
background: var(--tdbg-bg);
|
|
373
|
+
/* xyflow ships no `touch-action`, so on touch the browser claims one-finger
|
|
374
|
+
* drags and d3-zoom never pans/zooms. Hand the gestures to the canvas. */
|
|
375
|
+
touch-action: none;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/* ── Drill-down stack ───────────────────────────────────────────────────── */
|
|
379
|
+
/* One backdrop behind the whole stack; clicking it closes every panel. */
|
|
380
|
+
.tdbg-nested-backdrop {
|
|
381
|
+
position: absolute;
|
|
382
|
+
inset: 0;
|
|
383
|
+
z-index: 19;
|
|
384
|
+
background: rgba(0, 0, 0, 0.45);
|
|
385
|
+
}
|
|
386
|
+
/* Each drill level is its own panel, docked right and cascaded left (via inline
|
|
387
|
+
* `left` + `z-index`) so the panels beneath peek out as a navigable stack. */
|
|
388
|
+
.tdbg-nested-pane {
|
|
389
|
+
position: absolute;
|
|
390
|
+
top: 0;
|
|
391
|
+
right: 0;
|
|
392
|
+
bottom: 0;
|
|
393
|
+
/* Cascade offset: each deeper panel starts further right (`--tdbg-depth` set by
|
|
394
|
+
* the component), leaving the panels beneath peeking on the left. */
|
|
395
|
+
left: calc(10% + var(--tdbg-depth, 0) * 46px);
|
|
396
|
+
display: flex;
|
|
397
|
+
flex-direction: column;
|
|
398
|
+
min-width: 0;
|
|
399
|
+
background: var(--tdbg-bg);
|
|
400
|
+
border-left: 1px solid var(--tdbg-line);
|
|
401
|
+
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.3);
|
|
402
|
+
}
|
|
403
|
+
/* A panel beneath the top is a single "go back here" surface. */
|
|
404
|
+
.tdbg-nested-pane-under {
|
|
405
|
+
cursor: pointer;
|
|
406
|
+
}
|
|
407
|
+
.tdbg-nested-pane-under::after {
|
|
408
|
+
content: "";
|
|
409
|
+
position: absolute;
|
|
410
|
+
inset: 0;
|
|
411
|
+
background: rgba(0, 0, 0, 0.2);
|
|
412
|
+
pointer-events: none;
|
|
413
|
+
}
|
|
414
|
+
.tdbg-nested-head {
|
|
415
|
+
display: flex;
|
|
416
|
+
align-items: center;
|
|
417
|
+
gap: 8px;
|
|
418
|
+
padding: 6px 10px;
|
|
419
|
+
border-bottom: 1px solid var(--tdbg-line);
|
|
420
|
+
background: var(--tdbg-panel);
|
|
421
|
+
}
|
|
422
|
+
.tdbg-nested-title {
|
|
423
|
+
display: flex;
|
|
424
|
+
align-items: baseline;
|
|
425
|
+
gap: 8px;
|
|
426
|
+
flex: 1;
|
|
427
|
+
min-width: 0;
|
|
428
|
+
overflow: hidden;
|
|
429
|
+
}
|
|
430
|
+
.tdbg-nested-title .tdbg-node-name {
|
|
431
|
+
overflow: hidden;
|
|
432
|
+
text-overflow: ellipsis;
|
|
433
|
+
}
|
|
434
|
+
.tdbg-nested-body {
|
|
435
|
+
display: flex;
|
|
436
|
+
flex: 1;
|
|
437
|
+
min-height: 0;
|
|
438
|
+
}
|
|
439
|
+
.tdbg-nested-canvas {
|
|
440
|
+
flex: 1;
|
|
441
|
+
min-width: 0;
|
|
442
|
+
min-height: 0;
|
|
443
|
+
position: relative;
|
|
352
444
|
}
|
|
353
445
|
|
|
354
446
|
.tdbg-node {
|
|
@@ -429,6 +521,19 @@
|
|
|
429
521
|
overflow: hidden;
|
|
430
522
|
text-overflow: ellipsis;
|
|
431
523
|
}
|
|
524
|
+
.tdbg-node-owner {
|
|
525
|
+
border-left: 3px solid var(--tdbg-accent);
|
|
526
|
+
}
|
|
527
|
+
.tdbg-node-internal {
|
|
528
|
+
display: inline-block;
|
|
529
|
+
margin-top: 3px;
|
|
530
|
+
padding: 0 6px;
|
|
531
|
+
border-radius: 999px;
|
|
532
|
+
background: var(--tdbg-accent-bg, var(--tdbg-panel));
|
|
533
|
+
color: var(--tdbg-accent);
|
|
534
|
+
font-size: 10px;
|
|
535
|
+
font-weight: 600;
|
|
536
|
+
}
|
|
432
537
|
.tdbg-node-pulse-ok {
|
|
433
538
|
animation: tdbg-pulse-ok 0.65s ease-out;
|
|
434
539
|
}
|
|
@@ -491,7 +596,10 @@
|
|
|
491
596
|
.tdbg-graph-detail-body {
|
|
492
597
|
display: flex;
|
|
493
598
|
flex-direction: column;
|
|
494
|
-
|
|
599
|
+
/* Don't let flex shrink the body below its content — otherwise each payload
|
|
600
|
+
* (overflow: auto) collapses into a tiny nested scrollbar. The whole detail
|
|
601
|
+
* aside (overflow: auto) is the single scroll container. */
|
|
602
|
+
flex-shrink: 0;
|
|
495
603
|
}
|
|
496
604
|
.tdbg-graph-detail-sub {
|
|
497
605
|
display: flex;
|
|
@@ -597,3 +705,79 @@
|
|
|
597
705
|
.tdbg-graph-toggle input {
|
|
598
706
|
cursor: pointer;
|
|
599
707
|
}
|
|
708
|
+
|
|
709
|
+
/* ── Mobile (≤640px) ────────────────────────────────────────────────────────
|
|
710
|
+
* The desktop layout leans on fixed-width side rails (220px trace list, 340px
|
|
711
|
+
* detail) and a single-row header. On a phone we wrap the header, stack the graph
|
|
712
|
+
* vertically (trace list → top scroll strip, detail → bottom sheet), and let the
|
|
713
|
+
* drill-down panels go near-full-width. */
|
|
714
|
+
@media (max-width: 640px) {
|
|
715
|
+
.tdbg-tabs {
|
|
716
|
+
flex-wrap: wrap;
|
|
717
|
+
row-gap: 6px;
|
|
718
|
+
}
|
|
719
|
+
.tdbg-tab,
|
|
720
|
+
.tdbg-btn {
|
|
721
|
+
min-height: 34px;
|
|
722
|
+
}
|
|
723
|
+
.tdbg-bar-row {
|
|
724
|
+
flex-wrap: wrap;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/* Graph: stack the rails above/below the canvas instead of beside it. */
|
|
728
|
+
.tdbg-graph {
|
|
729
|
+
flex-direction: column;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/* Trace list → a horizontal scroll strip of invocation chips. */
|
|
733
|
+
.tdbg-trace-list {
|
|
734
|
+
width: 100%;
|
|
735
|
+
flex-direction: row;
|
|
736
|
+
align-items: stretch;
|
|
737
|
+
max-height: 30vh;
|
|
738
|
+
overflow-x: auto;
|
|
739
|
+
overflow-y: hidden;
|
|
740
|
+
border-right: none;
|
|
741
|
+
border-bottom: 1px solid var(--tdbg-line);
|
|
742
|
+
}
|
|
743
|
+
.tdbg-trace-list-head {
|
|
744
|
+
position: static;
|
|
745
|
+
flex: 0 0 auto;
|
|
746
|
+
display: flex;
|
|
747
|
+
align-items: center;
|
|
748
|
+
border-bottom: none;
|
|
749
|
+
border-right: 1px solid var(--tdbg-line);
|
|
750
|
+
}
|
|
751
|
+
.tdbg-trace-row {
|
|
752
|
+
width: auto;
|
|
753
|
+
flex: 0 0 auto;
|
|
754
|
+
border-bottom: none;
|
|
755
|
+
border-right: 1px solid var(--tdbg-line);
|
|
756
|
+
white-space: nowrap;
|
|
757
|
+
}
|
|
758
|
+
.tdbg-trace-row .tdbg-node-name {
|
|
759
|
+
flex: 0 0 auto;
|
|
760
|
+
}
|
|
761
|
+
.tdbg-trace-row-on {
|
|
762
|
+
box-shadow: inset 0 -2px 0 var(--tdbg-accent);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/* Detail → a bottom sheet overlaying the canvas, not a 340px side column. */
|
|
766
|
+
.tdbg-graph-detail {
|
|
767
|
+
position: absolute;
|
|
768
|
+
left: 0;
|
|
769
|
+
right: 0;
|
|
770
|
+
bottom: 0;
|
|
771
|
+
width: auto;
|
|
772
|
+
max-height: 55%;
|
|
773
|
+
z-index: 10;
|
|
774
|
+
border-left: none;
|
|
775
|
+
border-top: 1px solid var(--tdbg-line);
|
|
776
|
+
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.3);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/* Drill-down panels go near-full-width with a tight cascade. */
|
|
780
|
+
.tdbg-nested-pane {
|
|
781
|
+
left: calc(var(--tdbg-depth, 0) * 14px);
|
|
782
|
+
}
|
|
783
|
+
}
|