causal-inspector 0.1.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/dist/CausalInspector.css +124 -0
- package/dist/CausalInspector.d.ts +13 -0
- package/dist/CausalInspector.js +257 -0
- package/dist/components/CopyablePayload.d.ts +4 -0
- package/dist/components/CopyablePayload.js +44 -0
- package/dist/components/FilterBar.d.ts +1 -0
- package/dist/components/FilterBar.js +23 -0
- package/dist/components/GlobalScrubber.d.ts +1 -0
- package/dist/components/GlobalScrubber.js +148 -0
- package/dist/components/JsonSyntax.d.ts +3 -0
- package/dist/components/JsonSyntax.js +40 -0
- package/dist/context.d.ts +26 -0
- package/dist/context.js +28 -0
- package/dist/engines/index.d.ts +22 -0
- package/dist/engines/index.js +29 -0
- package/dist/engines/query.d.ts +14 -0
- package/dist/engines/query.js +241 -0
- package/dist/engines/scrubber.d.ts +12 -0
- package/dist/engines/scrubber.js +69 -0
- package/dist/engines/storage.d.ts +15 -0
- package/dist/engines/storage.js +16 -0
- package/dist/engines/subscription.d.ts +17 -0
- package/dist/engines/subscription.js +44 -0
- package/dist/engines/url.d.ts +13 -0
- package/dist/engines/url.js +64 -0
- package/dist/events.d.ts +77 -0
- package/dist/events.js +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +29 -0
- package/dist/machine/core.d.ts +22 -0
- package/dist/machine/core.js +34 -0
- package/dist/machine/engine.d.ts +17 -0
- package/dist/machine/engine.js +21 -0
- package/dist/machine/events.d.ts +18 -0
- package/dist/machine/events.js +1 -0
- package/dist/machine/hooks.d.ts +23 -0
- package/dist/machine/hooks.js +52 -0
- package/dist/machine/index.d.ts +5 -0
- package/dist/machine/index.js +4 -0
- package/dist/machine/store.d.ts +27 -0
- package/dist/machine/store.js +42 -0
- package/dist/panes/AggregateTimelinePane.d.ts +2 -0
- package/dist/panes/AggregateTimelinePane.js +224 -0
- package/dist/panes/CausalFlowPane.d.ts +7 -0
- package/dist/panes/CausalFlowPane.js +596 -0
- package/dist/panes/CausalTreePane.d.ts +5 -0
- package/dist/panes/CausalTreePane.js +158 -0
- package/dist/panes/CorrelationExplorerPane.d.ts +2 -0
- package/dist/panes/CorrelationExplorerPane.js +46 -0
- package/dist/panes/LogsPane.d.ts +6 -0
- package/dist/panes/LogsPane.js +65 -0
- package/dist/panes/TimelinePane.d.ts +6 -0
- package/dist/panes/TimelinePane.js +121 -0
- package/dist/panes/WaterfallPane.d.ts +2 -0
- package/dist/panes/WaterfallPane.js +202 -0
- package/dist/queries.d.ts +15 -0
- package/dist/queries.js +175 -0
- package/dist/reducer.d.ts +4 -0
- package/dist/reducer.js +177 -0
- package/dist/state.d.ts +34 -0
- package/dist/state.js +39 -0
- package/dist/theme.d.ts +7 -0
- package/dist/theme.js +34 -0
- package/dist/types.d.ts +140 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +14 -0
- package/dist/utils.js +91 -0
- package/package.json +43 -0
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useCallback, useEffect, useRef, useState, memo } from "react";
|
|
3
|
+
import { ReactFlow, Background, Controls, useReactFlow, Handle, MarkerType, Position, } from "@xyflow/react";
|
|
4
|
+
import dagre from "@dagrejs/dagre";
|
|
5
|
+
import { useSelector, useDispatch } from "../machine";
|
|
6
|
+
import { eventBg, eventBorder, eventTextColor } from "../theme";
|
|
7
|
+
import { Filter } from "lucide-react";
|
|
8
|
+
import { inScrubberRange } from "../utils";
|
|
9
|
+
/* eslint-disable-next-line @typescript-eslint/no-redeclare -- shadowing the tree-pane ReactorNode on purpose */
|
|
10
|
+
const NODE_WIDTH = 180;
|
|
11
|
+
const NODE_HEIGHT = 36;
|
|
12
|
+
const REACTOR_WIDTH = 180;
|
|
13
|
+
const REACTOR_HEIGHT = 36;
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Block renderers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
function BlockRenderer({ block }) {
|
|
18
|
+
switch (block.type) {
|
|
19
|
+
case "checklist":
|
|
20
|
+
return (_jsxs("div", { style: { marginTop: 4 }, children: [_jsx("div", { style: { fontSize: 9, color: "#71717a", marginBottom: 2 }, children: block.label }), block.items.map((item, i) => (_jsxs("div", { style: { fontSize: 9, color: item.done ? "#22c55e" : "#52525b", display: "flex", gap: 3, alignItems: "center" }, children: [_jsx("span", { children: item.done ? "\u2713" : "\u25cb" }), _jsx("span", { children: item.text })] }, i)))] }));
|
|
21
|
+
case "counter":
|
|
22
|
+
return (_jsxs("div", { style: { fontSize: 9, color: "#a1a1aa", marginTop: 2 }, children: [block.label, ": ", block.value, "/", block.total] }));
|
|
23
|
+
case "progress": {
|
|
24
|
+
const pct = Math.round(block.fraction * 100);
|
|
25
|
+
return (_jsxs("div", { style: { marginTop: 2 }, children: [_jsxs("div", { style: { fontSize: 9, color: "#a1a1aa" }, children: [block.label, ": ", pct, "%"] }), _jsx("div", { style: { height: 3, background: "#3f3f46", borderRadius: 2, marginTop: 1 }, children: _jsx("div", { style: { height: "100%", width: `${pct}%`, background: "#22c55e", borderRadius: 2 } }) })] }));
|
|
26
|
+
}
|
|
27
|
+
case "label":
|
|
28
|
+
return _jsx("div", { style: { fontSize: 9, color: "#a1a1aa", marginTop: 2 }, children: block.text });
|
|
29
|
+
case "key_value":
|
|
30
|
+
return (_jsxs("div", { style: { fontSize: 9, color: "#a1a1aa", marginTop: 2 }, children: [_jsxs("span", { style: { color: "#71717a" }, children: [block.key, ":"] }), " ", block.value] }));
|
|
31
|
+
case "status": {
|
|
32
|
+
const colors = { waiting: "#71717a", running: "#eab308", done: "#22c55e", error: "#ef4444" };
|
|
33
|
+
return (_jsxs("div", { style: { fontSize: 9, color: colors[block.state] ?? "#a1a1aa", marginTop: 2 }, children: [block.label, ": ", block.state] }));
|
|
34
|
+
}
|
|
35
|
+
default:
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Custom nodes
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
function formatDuration(startedAt, completedAt) {
|
|
43
|
+
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime();
|
|
44
|
+
if (ms < 1000)
|
|
45
|
+
return `${ms}ms`;
|
|
46
|
+
const secs = ms / 1000;
|
|
47
|
+
if (secs < 60)
|
|
48
|
+
return `${secs.toFixed(1)}s`;
|
|
49
|
+
const mins = Math.floor(secs / 60);
|
|
50
|
+
const remainSecs = Math.round(secs % 60);
|
|
51
|
+
return remainSecs > 0 ? `${mins}m ${remainSecs}s` : `${mins}m`;
|
|
52
|
+
}
|
|
53
|
+
const STATUS_BORDER = {
|
|
54
|
+
pending: "#52525b",
|
|
55
|
+
running: "#eab308",
|
|
56
|
+
completed: "#22c55e",
|
|
57
|
+
error: "#ef4444",
|
|
58
|
+
};
|
|
59
|
+
const ReactorNode = memo(({ data }) => {
|
|
60
|
+
const d = data;
|
|
61
|
+
const blocks = d.blocks;
|
|
62
|
+
const outcome = d.outcome;
|
|
63
|
+
const hasBlocks = blocks && blocks.length > 0;
|
|
64
|
+
const borderColor = STATUS_BORDER[outcome?.status ?? "pending"] ?? "#2a2a35";
|
|
65
|
+
const isRunning = outcome?.status === "running";
|
|
66
|
+
const duration = outcome?.status === "completed" && outcome.startedAt && outcome.completedAt
|
|
67
|
+
? formatDuration(outcome.startedAt, outcome.completedAt)
|
|
68
|
+
: null;
|
|
69
|
+
return (_jsxs("div", { style: {
|
|
70
|
+
background: "linear-gradient(135deg, #1a1a22, #15151d)",
|
|
71
|
+
border: `1px solid ${borderColor}`,
|
|
72
|
+
borderRadius: hasBlocks ? 10 : 20,
|
|
73
|
+
fontSize: 10,
|
|
74
|
+
padding: hasBlocks ? "8px 12px" : "6px 14px",
|
|
75
|
+
width: REACTOR_WIDTH,
|
|
76
|
+
color: "#9090a0",
|
|
77
|
+
fontStyle: "italic",
|
|
78
|
+
animation: isRunning ? "pulse 2s ease-in-out infinite" : undefined,
|
|
79
|
+
boxShadow: isRunning
|
|
80
|
+
? `0 0 12px ${borderColor}40`
|
|
81
|
+
: "0 2px 8px rgba(0, 0, 0, 0.3)",
|
|
82
|
+
}, children: [_jsx(Handle, { type: "target", position: Position.Left, style: { visibility: "hidden" } }), _jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8 }, children: [_jsx("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", direction: "rtl", textAlign: "left" }, children: d.label }), duration && _jsx("span", { style: { fontSize: 9, color: "#60608a", fontStyle: "normal", whiteSpace: "nowrap", flexShrink: 0 }, children: duration })] }), hasBlocks && blocks.map((block, i) => _jsx(BlockRenderer, { block: block }, i)), outcome?.status === "error" && outcome.error && (_jsx("div", { style: { fontSize: 9, color: "#ef4444", marginTop: 4 }, children: outcome.error })), _jsx(Handle, { type: "source", position: Position.Right, style: { visibility: "hidden" } })] }));
|
|
83
|
+
});
|
|
84
|
+
const EventNode = memo(({ data }) => {
|
|
85
|
+
const d = data;
|
|
86
|
+
return (_jsxs("div", { style: {
|
|
87
|
+
background: eventBg(d.eventName),
|
|
88
|
+
border: `1px solid ${eventBorder(d.eventName)}`,
|
|
89
|
+
borderRadius: 8,
|
|
90
|
+
fontSize: 11,
|
|
91
|
+
padding: "7px 12px",
|
|
92
|
+
width: NODE_WIDTH,
|
|
93
|
+
color: eventTextColor(d.eventName),
|
|
94
|
+
overflow: "hidden",
|
|
95
|
+
textOverflow: "ellipsis",
|
|
96
|
+
whiteSpace: "nowrap",
|
|
97
|
+
direction: "rtl",
|
|
98
|
+
textAlign: "left",
|
|
99
|
+
boxShadow: `0 2px 8px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.04)`,
|
|
100
|
+
fontWeight: 500,
|
|
101
|
+
letterSpacing: "0.01em",
|
|
102
|
+
}, children: [_jsx(Handle, { type: "target", position: Position.Left, style: { visibility: "hidden" } }), d.label, _jsx(Handle, { type: "source", position: Position.Right, style: { visibility: "hidden" } })] }));
|
|
103
|
+
});
|
|
104
|
+
const nodeTypes = { reactor: ReactorNode, event: EventNode };
|
|
105
|
+
function buildFlowGraph(events, descriptions, outcomes, hiddenReactors) {
|
|
106
|
+
// Group events by type name only (merge across reactors)
|
|
107
|
+
const eventGroups = new Map();
|
|
108
|
+
const reactorIds = new Set();
|
|
109
|
+
const parentToReactor = new Map();
|
|
110
|
+
const reactorToChildTypes = new Map();
|
|
111
|
+
for (const evt of events) {
|
|
112
|
+
const groupKey = evt.name;
|
|
113
|
+
const group = eventGroups.get(groupKey);
|
|
114
|
+
if (group) {
|
|
115
|
+
group.count++;
|
|
116
|
+
group.events.push(evt);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
eventGroups.set(groupKey, { name: evt.name, count: 1, events: [evt] });
|
|
120
|
+
}
|
|
121
|
+
if (evt.reactorId) {
|
|
122
|
+
reactorIds.add(evt.reactorId);
|
|
123
|
+
const children = reactorToChildTypes.get(evt.reactorId) ?? new Set();
|
|
124
|
+
children.add(groupKey);
|
|
125
|
+
reactorToChildTypes.set(evt.reactorId, children);
|
|
126
|
+
}
|
|
127
|
+
if (evt.parentId && evt.reactorId) {
|
|
128
|
+
const reactors = parentToReactor.get(evt.parentId) ?? new Set();
|
|
129
|
+
reactors.add(evt.reactorId);
|
|
130
|
+
parentToReactor.set(evt.parentId, reactors);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const eventIdToGroup = new Map();
|
|
134
|
+
for (const [groupKey, group] of eventGroups) {
|
|
135
|
+
for (const evt of group.events) {
|
|
136
|
+
if (evt.id)
|
|
137
|
+
eventIdToGroup.set(evt.id, groupKey);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const nodes = [];
|
|
141
|
+
const edges = [];
|
|
142
|
+
const edgeSet = new Set();
|
|
143
|
+
// Event-type nodes (one per event name)
|
|
144
|
+
for (const [groupKey, group] of eventGroups) {
|
|
145
|
+
// Skip if ALL emitting reactors are hidden
|
|
146
|
+
const emittingReactors = new Set(group.events.map((e) => e.reactorId).filter(Boolean));
|
|
147
|
+
if (emittingReactors.size > 0 && hiddenReactors && [...emittingReactors].every((r) => hiddenReactors.has(r)))
|
|
148
|
+
continue;
|
|
149
|
+
nodes.push({
|
|
150
|
+
id: `evt:${groupKey}`,
|
|
151
|
+
type: "event",
|
|
152
|
+
position: { x: 0, y: 0 },
|
|
153
|
+
data: {
|
|
154
|
+
label: group.count > 1 ? `${group.name} (${group.count})` : group.name,
|
|
155
|
+
nodeKind: "event-type",
|
|
156
|
+
eventName: group.name,
|
|
157
|
+
},
|
|
158
|
+
sourcePosition: Position.Right,
|
|
159
|
+
targetPosition: Position.Left,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// Reactor nodes
|
|
163
|
+
for (const reactorId of reactorIds) {
|
|
164
|
+
if (hiddenReactors?.has(reactorId))
|
|
165
|
+
continue;
|
|
166
|
+
const blocks = descriptions?.get(reactorId);
|
|
167
|
+
const outcome = outcomes?.get(reactorId);
|
|
168
|
+
nodes.push({
|
|
169
|
+
id: `hdl:${reactorId}`,
|
|
170
|
+
type: "reactor",
|
|
171
|
+
position: { x: 0, y: 0 },
|
|
172
|
+
data: { label: reactorId, nodeKind: "reactor", reactorId, blocks, outcome },
|
|
173
|
+
sourcePosition: Position.Right,
|
|
174
|
+
targetPosition: Position.Left,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
const arrowMarker = { type: MarkerType.ArrowClosed, color: "#3a3a4a", width: 14, height: 14 };
|
|
178
|
+
// Edges: event type -> reactor (event triggers reactor)
|
|
179
|
+
for (const [parentId, reactors] of parentToReactor) {
|
|
180
|
+
const sourceGroupKey = eventIdToGroup.get(parentId);
|
|
181
|
+
if (!sourceGroupKey)
|
|
182
|
+
continue;
|
|
183
|
+
for (const reactorId of reactors) {
|
|
184
|
+
if (hiddenReactors?.has(reactorId))
|
|
185
|
+
continue;
|
|
186
|
+
const edgeKey = `evt:${sourceGroupKey}->hdl:${reactorId}`;
|
|
187
|
+
if (!edgeSet.has(edgeKey)) {
|
|
188
|
+
edgeSet.add(edgeKey);
|
|
189
|
+
edges.push({
|
|
190
|
+
id: edgeKey,
|
|
191
|
+
source: `evt:${sourceGroupKey}`,
|
|
192
|
+
target: `hdl:${reactorId}`,
|
|
193
|
+
style: { stroke: "#3a3a4a", strokeWidth: 1 },
|
|
194
|
+
markerEnd: arrowMarker,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// Edges: reactor -> child event types (reactor produces events)
|
|
200
|
+
for (const [reactorId, childTypes] of reactorToChildTypes) {
|
|
201
|
+
if (hiddenReactors?.has(reactorId))
|
|
202
|
+
continue;
|
|
203
|
+
for (const typeName of childTypes) {
|
|
204
|
+
const edgeKey = `hdl:${reactorId}->evt:${typeName}`;
|
|
205
|
+
if (!edgeSet.has(edgeKey)) {
|
|
206
|
+
edgeSet.add(edgeKey);
|
|
207
|
+
edges.push({
|
|
208
|
+
id: edgeKey,
|
|
209
|
+
source: `hdl:${reactorId}`,
|
|
210
|
+
target: `evt:${typeName}`,
|
|
211
|
+
style: { stroke: "#3a3a4a", strokeWidth: 1 },
|
|
212
|
+
markerEnd: arrowMarker,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Reactors known from outcomes but not from event stream
|
|
218
|
+
if (outcomes) {
|
|
219
|
+
for (const [reactorId, outcome] of outcomes) {
|
|
220
|
+
if (reactorIds.has(reactorId))
|
|
221
|
+
continue;
|
|
222
|
+
if (hiddenReactors?.has(reactorId))
|
|
223
|
+
continue;
|
|
224
|
+
const blocks = descriptions?.get(reactorId);
|
|
225
|
+
nodes.push({
|
|
226
|
+
id: `hdl:${reactorId}`,
|
|
227
|
+
type: "reactor",
|
|
228
|
+
position: { x: 0, y: 0 },
|
|
229
|
+
data: { label: reactorId, nodeKind: "reactor", reactorId, blocks, outcome },
|
|
230
|
+
sourcePosition: Position.Bottom,
|
|
231
|
+
targetPosition: Position.Top,
|
|
232
|
+
});
|
|
233
|
+
const isPending = outcome.status === "pending" || outcome.status === "running";
|
|
234
|
+
for (const eventId of outcome.triggeringEventIds ?? []) {
|
|
235
|
+
const groupKey = eventIdToGroup.get(eventId);
|
|
236
|
+
if (!groupKey)
|
|
237
|
+
continue;
|
|
238
|
+
const edgeKey = `evt:${groupKey}->hdl:${reactorId}`;
|
|
239
|
+
if (!edgeSet.has(edgeKey)) {
|
|
240
|
+
edgeSet.add(edgeKey);
|
|
241
|
+
edges.push({
|
|
242
|
+
id: edgeKey,
|
|
243
|
+
source: `evt:${groupKey}`,
|
|
244
|
+
target: `hdl:${reactorId}`,
|
|
245
|
+
style: { stroke: "#3a3a4a", strokeWidth: 1 },
|
|
246
|
+
markerEnd: arrowMarker,
|
|
247
|
+
animated: isPending,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return layoutGraph(nodes, edges);
|
|
254
|
+
}
|
|
255
|
+
function estimateReactorHeight(data) {
|
|
256
|
+
if (data.nodeKind !== "reactor")
|
|
257
|
+
return REACTOR_HEIGHT;
|
|
258
|
+
const hasBlocks = data.blocks && data.blocks.length > 0;
|
|
259
|
+
const outcome = data.outcome;
|
|
260
|
+
if (!hasBlocks && !outcome)
|
|
261
|
+
return REACTOR_HEIGHT;
|
|
262
|
+
let h = 24;
|
|
263
|
+
if (data.blocks) {
|
|
264
|
+
for (const block of data.blocks) {
|
|
265
|
+
if (block.type === "checklist") {
|
|
266
|
+
h += 14 + block.items.length * 12;
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
h += 14;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (outcome?.status === "error" && outcome.error)
|
|
274
|
+
h += 14;
|
|
275
|
+
return h;
|
|
276
|
+
}
|
|
277
|
+
function layoutGraph(nodes, edges) {
|
|
278
|
+
const g = new dagre.graphlib.Graph();
|
|
279
|
+
g.setDefaultEdgeLabel(() => ({}));
|
|
280
|
+
g.setGraph({ rankdir: "LR", nodesep: 40, ranksep: 80 });
|
|
281
|
+
const heights = new Map();
|
|
282
|
+
for (const node of nodes) {
|
|
283
|
+
const isReactor = node.id.startsWith("hdl:");
|
|
284
|
+
const h = isReactor ? estimateReactorHeight(node.data) : NODE_HEIGHT;
|
|
285
|
+
heights.set(node.id, h);
|
|
286
|
+
g.setNode(node.id, {
|
|
287
|
+
width: isReactor ? REACTOR_WIDTH : NODE_WIDTH,
|
|
288
|
+
height: h,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
for (const edge of edges) {
|
|
292
|
+
g.setEdge(edge.source, edge.target);
|
|
293
|
+
}
|
|
294
|
+
dagre.layout(g);
|
|
295
|
+
const laidOut = nodes.map((node) => {
|
|
296
|
+
const pos = g.node(node.id);
|
|
297
|
+
const isReactor = node.id.startsWith("hdl:");
|
|
298
|
+
const w = isReactor ? REACTOR_WIDTH : NODE_WIDTH;
|
|
299
|
+
const h = heights.get(node.id) ?? NODE_HEIGHT;
|
|
300
|
+
return {
|
|
301
|
+
...node,
|
|
302
|
+
position: { x: pos.x - w / 2, y: pos.y - h / 2 },
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
return { nodes: laidOut, edges };
|
|
306
|
+
}
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// Scrubber visibility — compute which nodes/edges are visible at a given seq
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
function computeVisibleIds(allEvents, start, end) {
|
|
311
|
+
const visible = allEvents.filter((e) => inScrubberRange(e.seq, start, end));
|
|
312
|
+
const nodeIds = new Set();
|
|
313
|
+
const edgeIds = new Set();
|
|
314
|
+
const eventIdToGroup = new Map();
|
|
315
|
+
const seenTypes = new Set();
|
|
316
|
+
for (const evt of visible) {
|
|
317
|
+
const groupKey = evt.name;
|
|
318
|
+
seenTypes.add(groupKey);
|
|
319
|
+
if (evt.id)
|
|
320
|
+
eventIdToGroup.set(evt.id, groupKey);
|
|
321
|
+
}
|
|
322
|
+
// Event-type nodes
|
|
323
|
+
for (const typeName of seenTypes) {
|
|
324
|
+
nodeIds.add(`evt:${typeName}`);
|
|
325
|
+
}
|
|
326
|
+
// Reactor nodes + edges
|
|
327
|
+
for (const evt of visible) {
|
|
328
|
+
if (evt.reactorId) {
|
|
329
|
+
nodeIds.add(`hdl:${evt.reactorId}`);
|
|
330
|
+
// Reactor -> child event type edge
|
|
331
|
+
edgeIds.add(`hdl:${evt.reactorId}->evt:${evt.name}`);
|
|
332
|
+
}
|
|
333
|
+
// Parent event -> reactor edge
|
|
334
|
+
if (evt.parentId && evt.reactorId) {
|
|
335
|
+
const parentGroup = eventIdToGroup.get(evt.parentId);
|
|
336
|
+
if (parentGroup) {
|
|
337
|
+
edgeIds.add(`evt:${parentGroup}->hdl:${evt.reactorId}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// Root event -> reactor edges
|
|
341
|
+
if (!evt.reactorId && evt.id) {
|
|
342
|
+
for (const child of visible) {
|
|
343
|
+
if (child.parentId === evt.id && child.reactorId) {
|
|
344
|
+
const rootGroup = eventIdToGroup.get(evt.id);
|
|
345
|
+
if (rootGroup) {
|
|
346
|
+
edgeIds.add(`evt:${rootGroup}->hdl:${child.reactorId}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return { nodeIds, edgeIds };
|
|
353
|
+
}
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Auto-center on selection
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
function FitOnLoad() {
|
|
358
|
+
const { fitView } = useReactFlow();
|
|
359
|
+
const fitted = useRef(false);
|
|
360
|
+
const flowData = useSelector((s) => s.flowData);
|
|
361
|
+
useEffect(() => {
|
|
362
|
+
if (!fitted.current && flowData.length > 0) {
|
|
363
|
+
fitted.current = true;
|
|
364
|
+
// Delay slightly to let ReactFlow measure nodes
|
|
365
|
+
requestAnimationFrame(() => fitView({ duration: 300 }));
|
|
366
|
+
}
|
|
367
|
+
}, [flowData, fitView]);
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
function FocusOnSelection({ nodes, flowData }) {
|
|
371
|
+
const selectedSeq = useSelector((s) => s.selectedSeq);
|
|
372
|
+
const scrubberEnd = useSelector((s) => s.scrubberEnd);
|
|
373
|
+
const { setCenter, getZoom } = useReactFlow();
|
|
374
|
+
const nodesRef = useRef(nodes);
|
|
375
|
+
nodesRef.current = nodes;
|
|
376
|
+
useEffect(() => {
|
|
377
|
+
// Don't recenter while scrubber is active
|
|
378
|
+
if (scrubberEnd != null)
|
|
379
|
+
return;
|
|
380
|
+
if (selectedSeq == null || !flowData.length)
|
|
381
|
+
return;
|
|
382
|
+
const evt = flowData.find(e => e.seq === selectedSeq);
|
|
383
|
+
if (!evt)
|
|
384
|
+
return;
|
|
385
|
+
const nodeId = `evt:${evt.name}`;
|
|
386
|
+
const node = nodesRef.current.find(n => n.id === nodeId);
|
|
387
|
+
if (!node)
|
|
388
|
+
return;
|
|
389
|
+
const isReactor = node.id.startsWith("hdl:");
|
|
390
|
+
const w = isReactor ? REACTOR_WIDTH : NODE_WIDTH;
|
|
391
|
+
const h = isReactor ? estimateReactorHeight(node.data) : NODE_HEIGHT;
|
|
392
|
+
setCenter(node.position.x + w / 2, node.position.y + h / 2, { zoom: getZoom(), duration: 400 });
|
|
393
|
+
}, [selectedSeq, scrubberEnd, flowData, setCenter, getZoom]);
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
// Reactor filter dropdown
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
function ReactorFilter({ allReactorIds, hiddenReactors, setHiddenReactors }) {
|
|
400
|
+
const [open, setOpen] = useState(false);
|
|
401
|
+
const [filter, setFilter] = useState("");
|
|
402
|
+
const containerRef = useRef(null);
|
|
403
|
+
// Close on click outside
|
|
404
|
+
useEffect(() => {
|
|
405
|
+
if (!open)
|
|
406
|
+
return;
|
|
407
|
+
const handleClick = (e) => {
|
|
408
|
+
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
409
|
+
setOpen(false);
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
document.addEventListener("mousedown", handleClick);
|
|
413
|
+
return () => document.removeEventListener("mousedown", handleClick);
|
|
414
|
+
}, [open]);
|
|
415
|
+
const toggle = (id) => {
|
|
416
|
+
const next = new Set(hiddenReactors);
|
|
417
|
+
if (next.has(id))
|
|
418
|
+
next.delete(id);
|
|
419
|
+
else
|
|
420
|
+
next.add(id);
|
|
421
|
+
setHiddenReactors(next);
|
|
422
|
+
};
|
|
423
|
+
const filtered = filter
|
|
424
|
+
? allReactorIds.filter(id => id.toLowerCase().includes(filter.toLowerCase()))
|
|
425
|
+
: allReactorIds;
|
|
426
|
+
const hiddenCount = hiddenReactors.size;
|
|
427
|
+
return (_jsxs("div", { ref: containerRef, className: "relative", children: [_jsxs("button", { onClick: () => setOpen(v => !v), className: "text-[10px] text-muted-foreground/60 hover:text-foreground px-2 py-1 rounded-md border border-border hover:border-indigo-500/30 transition-all duration-150", children: [_jsx(Filter, { size: 11, className: "inline mr-1 -mt-px" }), hiddenCount > 0 ? `${hiddenCount} hidden` : "Filter"] }), open && (_jsxs("div", { className: "absolute top-full right-0 mt-1 z-50 border border-border rounded-lg min-w-[240px]", style: { background: "rgba(17, 17, 22, 0.95)", backdropFilter: "blur(12px)", boxShadow: "0 8px 32px rgba(0, 0, 0, 0.5)" }, children: [_jsx("div", { className: "px-3 py-2 border-b border-border", children: _jsx("input", { autoFocus: true, type: "text", value: filter, onChange: e => setFilter(e.target.value), placeholder: "Search reactors...", className: "w-full text-xs bg-transparent border-none outline-none text-foreground placeholder:text-muted-foreground/50" }) }), _jsxs("div", { className: "max-h-64 overflow-y-auto py-1", children: [filtered.map(id => (_jsxs("label", { className: "flex items-center gap-2 px-3 py-1.5 hover:bg-white/[0.03] cursor-pointer transition-colors", children: [_jsx("input", { type: "checkbox", checked: !hiddenReactors.has(id), onChange: () => toggle(id), className: "rounded border-border accent-indigo-500" }), _jsx("span", { className: "text-[11px] font-mono text-foreground/80 truncate", children: id })] }, id))), filtered.length === 0 && (_jsx("div", { className: "text-xs text-muted-foreground/50 px-3 py-2", children: "No matches" }))] })] }))] }));
|
|
428
|
+
}
|
|
429
|
+
export function CausalFlowPane({ defaultHiddenReactors, headerExtra } = {}) {
|
|
430
|
+
const flowCorrelationId = useSelector((s) => s.flowCorrelationId);
|
|
431
|
+
const flowData = useSelector((s) => s.flowData);
|
|
432
|
+
const flowSelection = useSelector((s) => s.flowSelection);
|
|
433
|
+
const descriptionsMap = useSelector((s) => s.descriptions);
|
|
434
|
+
const outcomesMap = useSelector((s) => s.outcomes);
|
|
435
|
+
const scrubberStart = useSelector((s) => s.scrubberStart);
|
|
436
|
+
const scrubberEnd = useSelector((s) => s.scrubberEnd);
|
|
437
|
+
const dispatch = useDispatch();
|
|
438
|
+
const flowLoading = flowCorrelationId != null && flowData.length === 0;
|
|
439
|
+
// Build typed maps from state
|
|
440
|
+
const descriptions = useMemo(() => {
|
|
441
|
+
if (!flowCorrelationId)
|
|
442
|
+
return undefined;
|
|
443
|
+
const raw = descriptionsMap[flowCorrelationId];
|
|
444
|
+
if (!raw)
|
|
445
|
+
return undefined;
|
|
446
|
+
const map = new Map();
|
|
447
|
+
for (const d of raw)
|
|
448
|
+
map.set(d.reactorId, d.blocks);
|
|
449
|
+
return map;
|
|
450
|
+
}, [descriptionsMap, flowCorrelationId]);
|
|
451
|
+
const outcomes = useMemo(() => {
|
|
452
|
+
if (!flowCorrelationId)
|
|
453
|
+
return undefined;
|
|
454
|
+
const raw = outcomesMap[flowCorrelationId];
|
|
455
|
+
if (!raw)
|
|
456
|
+
return undefined;
|
|
457
|
+
const map = new Map();
|
|
458
|
+
for (const o of raw)
|
|
459
|
+
map.set(o.reactorId, o);
|
|
460
|
+
return map;
|
|
461
|
+
}, [outcomesMap, flowCorrelationId]);
|
|
462
|
+
const [hiddenReactors, setHiddenReactors] = useState(() => defaultHiddenReactors ?? new Set());
|
|
463
|
+
const allReactorIds = useMemo(() => {
|
|
464
|
+
const ids = new Set();
|
|
465
|
+
for (const evt of flowData) {
|
|
466
|
+
if (evt.reactorId)
|
|
467
|
+
ids.add(evt.reactorId);
|
|
468
|
+
}
|
|
469
|
+
if (outcomes)
|
|
470
|
+
for (const id of outcomes.keys())
|
|
471
|
+
ids.add(id);
|
|
472
|
+
return [...ids].sort();
|
|
473
|
+
}, [flowData, outcomes]);
|
|
474
|
+
// Full graph layout — stable positions computed from ALL events
|
|
475
|
+
const { nodes: fullNodes, edges: fullEdges } = useMemo(() => {
|
|
476
|
+
if (!flowData || flowData.length === 0)
|
|
477
|
+
return { nodes: [], edges: [] };
|
|
478
|
+
return buildFlowGraph(flowData, descriptions, outcomes, hiddenReactors);
|
|
479
|
+
}, [flowData, descriptions, outcomes, hiddenReactors]);
|
|
480
|
+
// Compute visible IDs when scrubber range is active
|
|
481
|
+
const visibleIds = useMemo(() => {
|
|
482
|
+
if (scrubberStart == null && scrubberEnd == null)
|
|
483
|
+
return null; // show everything
|
|
484
|
+
return computeVisibleIds(flowData, scrubberStart, scrubberEnd);
|
|
485
|
+
}, [flowData, scrubberStart, scrubberEnd]);
|
|
486
|
+
// Apply visibility: hidden nodes get opacity 0, hidden edges are filtered out
|
|
487
|
+
const rawNodes = useMemo(() => {
|
|
488
|
+
if (!visibleIds)
|
|
489
|
+
return fullNodes;
|
|
490
|
+
return fullNodes.map((n) => ({
|
|
491
|
+
...n,
|
|
492
|
+
hidden: !visibleIds.nodeIds.has(n.id),
|
|
493
|
+
}));
|
|
494
|
+
}, [fullNodes, visibleIds]);
|
|
495
|
+
const rawEdges = useMemo(() => {
|
|
496
|
+
if (!visibleIds)
|
|
497
|
+
return fullEdges;
|
|
498
|
+
return fullEdges.map((e) => ({
|
|
499
|
+
...e,
|
|
500
|
+
hidden: !visibleIds.edgeIds.has(e.id),
|
|
501
|
+
}));
|
|
502
|
+
}, [fullEdges, visibleIds]);
|
|
503
|
+
// Derive selected node ID from flowSelection
|
|
504
|
+
const selectedNodeId = useMemo(() => {
|
|
505
|
+
if (!flowSelection)
|
|
506
|
+
return null;
|
|
507
|
+
if (flowSelection.kind === "reactor")
|
|
508
|
+
return `hdl:${flowSelection.reactorId}`;
|
|
509
|
+
return `evt:${flowSelection.name}`;
|
|
510
|
+
}, [flowSelection]);
|
|
511
|
+
// Walk causal chain for highlighting
|
|
512
|
+
const causalNodeIds = useMemo(() => {
|
|
513
|
+
if (!selectedNodeId)
|
|
514
|
+
return null;
|
|
515
|
+
const forward = new Map();
|
|
516
|
+
const backward = new Map();
|
|
517
|
+
for (const e of rawEdges) {
|
|
518
|
+
forward.set(e.source, [...(forward.get(e.source) ?? []), e.target]);
|
|
519
|
+
backward.set(e.target, [...(backward.get(e.target) ?? []), e.source]);
|
|
520
|
+
}
|
|
521
|
+
const visited = new Set();
|
|
522
|
+
const walk = (id, adj) => {
|
|
523
|
+
if (visited.has(id))
|
|
524
|
+
return;
|
|
525
|
+
visited.add(id);
|
|
526
|
+
for (const next of adj.get(id) ?? [])
|
|
527
|
+
walk(next, adj);
|
|
528
|
+
};
|
|
529
|
+
walk(selectedNodeId, forward);
|
|
530
|
+
walk(selectedNodeId, backward);
|
|
531
|
+
return visited;
|
|
532
|
+
}, [selectedNodeId, rawEdges]);
|
|
533
|
+
const nodes = useMemo(() => rawNodes.map(n => ({
|
|
534
|
+
...n,
|
|
535
|
+
selected: n.id === selectedNodeId,
|
|
536
|
+
style: {
|
|
537
|
+
...n.style,
|
|
538
|
+
...(causalNodeIds != null && !causalNodeIds.has(n.id) ? { opacity: 0.5 } : {}),
|
|
539
|
+
},
|
|
540
|
+
})), [rawNodes, selectedNodeId, causalNodeIds]);
|
|
541
|
+
const edges = useMemo(() => rawEdges.map(e => {
|
|
542
|
+
const base = { ...e, zIndex: -1 };
|
|
543
|
+
if (!causalNodeIds)
|
|
544
|
+
return base;
|
|
545
|
+
const onPath = causalNodeIds.has(e.source) && causalNodeIds.has(e.target);
|
|
546
|
+
return {
|
|
547
|
+
...base,
|
|
548
|
+
style: {
|
|
549
|
+
...e.style,
|
|
550
|
+
stroke: onPath ? "#818cf8" : "#3a3a4a",
|
|
551
|
+
strokeWidth: onPath ? 2 : 1,
|
|
552
|
+
opacity: onPath ? 1 : 0.15,
|
|
553
|
+
},
|
|
554
|
+
markerEnd: onPath
|
|
555
|
+
? { type: MarkerType.ArrowClosed, color: "#818cf8", width: 14, height: 14 }
|
|
556
|
+
: e.markerEnd,
|
|
557
|
+
};
|
|
558
|
+
}), [rawEdges, causalNodeIds]);
|
|
559
|
+
const onNodeClick = useCallback((_event, node) => {
|
|
560
|
+
const d = node.data;
|
|
561
|
+
if (d.nodeKind === "event-type") {
|
|
562
|
+
if (flowSelection?.kind === "event-type" && flowSelection.name === d.eventName) {
|
|
563
|
+
dispatch({ type: "ui/flow_node_selected", payload: null });
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
dispatch({
|
|
567
|
+
type: "ui/flow_node_selected",
|
|
568
|
+
payload: { kind: "event-type", name: d.eventName },
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
else if (d.nodeKind === "reactor") {
|
|
573
|
+
if (flowSelection?.kind === "reactor" && flowSelection.reactorId === d.reactorId) {
|
|
574
|
+
dispatch({ type: "ui/flow_node_selected", payload: null });
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
dispatch({
|
|
578
|
+
type: "ui/flow_node_selected",
|
|
579
|
+
payload: { kind: "reactor", reactorId: d.reactorId },
|
|
580
|
+
});
|
|
581
|
+
dispatch({ type: "ui/handler_selected", payload: { reactorId: d.reactorId } });
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}, [flowSelection, dispatch]);
|
|
585
|
+
const onPaneClick = useCallback(() => {
|
|
586
|
+
dispatch({ type: "ui/flow_node_selected", payload: null });
|
|
587
|
+
}, [dispatch]);
|
|
588
|
+
const onNodesChange = useCallback((_changes) => { }, []);
|
|
589
|
+
if (!flowCorrelationId) {
|
|
590
|
+
return (_jsx("div", { className: "flex items-center justify-center h-full text-xs text-muted-foreground/50 tracking-wide", children: "Select an event to visualize its causal flow" }));
|
|
591
|
+
}
|
|
592
|
+
if (flowLoading) {
|
|
593
|
+
return (_jsxs("div", { className: "h-full flex flex-col", children: [_jsxs("div", { className: "flex items-center gap-2 px-3 py-1.5 border-b border-border shrink-0", children: [_jsx("div", { className: "h-3 w-10 bg-muted rounded animate-pulse" }), _jsx("div", { className: "h-3 w-48 bg-muted rounded animate-pulse" })] }), _jsx("div", { className: "flex-1 flex items-center justify-center", children: _jsxs("div", { className: "animate-pulse flex flex-col items-center gap-3", children: [_jsx("div", { className: "h-8 w-40 bg-muted rounded-md" }), _jsx("div", { className: "h-6 w-px bg-muted" }), _jsx("div", { className: "h-6 w-28 bg-muted rounded-full" }), _jsxs("div", { className: "flex items-start gap-8", children: [_jsxs("div", { className: "flex flex-col items-center gap-3", children: [_jsx("div", { className: "h-6 w-px bg-muted" }), _jsx("div", { className: "h-8 w-36 bg-muted rounded-md" })] }), _jsxs("div", { className: "flex flex-col items-center gap-3", children: [_jsx("div", { className: "h-6 w-px bg-muted" }), _jsx("div", { className: "h-8 w-36 bg-muted rounded-md" })] })] })] }) })] }));
|
|
594
|
+
}
|
|
595
|
+
return (_jsxs("div", { className: "h-full flex flex-col", children: [_jsxs("div", { className: "flex items-center gap-2.5 px-3 py-2 border-b border-border shrink-0", style: { background: "rgba(15, 15, 20, 0.6)", backdropFilter: "blur(8px)" }, children: [_jsx("h3", { className: "text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-widest", children: "Flow" }), _jsx("span", { className: "text-[10px] font-mono text-foreground/80 truncate px-1.5 py-0.5 rounded bg-white/[0.03] border border-border", children: flowCorrelationId }), _jsxs("span", { className: "text-[10px] text-muted-foreground/50 tabular-nums", children: [flowData.length, " events \u00B7 ", nodes.length, " nodes"] }), headerExtra, _jsx("div", { className: "ml-auto", children: _jsx(ReactorFilter, { allReactorIds: allReactorIds, hiddenReactors: hiddenReactors, setHiddenReactors: setHiddenReactors }) })] }), _jsx("div", { className: "flex-1 relative", children: _jsxs(ReactFlow, { nodes: nodes, edges: edges, nodeTypes: nodeTypes, defaultEdgeOptions: { type: "smoothstep" }, onNodesChange: onNodesChange, onNodeClick: onNodeClick, onPaneClick: onPaneClick, minZoom: 0.25, proOptions: { hideAttribution: true }, nodesDraggable: false, nodesConnectable: false, elevateNodesOnSelect: false, colorMode: "dark", children: [_jsx(FitOnLoad, {}), _jsx(FocusOnSelection, { nodes: nodes, flowData: flowData }), _jsx(Background, { color: "rgba(255,255,255,0.03)", gap: 24, size: 1 }), _jsx(Controls, { showInteractive: false })] }) })] }));
|
|
596
|
+
}
|