@nwire/studio 0.9.1

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.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/components.json +19 -0
  4. package/index.html +12 -0
  5. package/package.json +66 -0
  6. package/src/App.vue +305 -0
  7. package/src/components/EmptyState.stories.ts +53 -0
  8. package/src/components/EmptyState.vue +28 -0
  9. package/src/components/ErrorBoundary.vue +60 -0
  10. package/src/components/FilterInput.stories.ts +32 -0
  11. package/src/components/FilterInput.vue +33 -0
  12. package/src/components/JsonView.stories.ts +38 -0
  13. package/src/components/JsonView.vue +34 -0
  14. package/src/components/KindBadge.stories.ts +72 -0
  15. package/src/components/KindBadge.vue +59 -0
  16. package/src/components/ListRow.stories.ts +56 -0
  17. package/src/components/ListRow.vue +48 -0
  18. package/src/components/MasterDetail.stories.ts +74 -0
  19. package/src/components/MasterDetail.vue +35 -0
  20. package/src/components/MonacoViewer.vue +143 -0
  21. package/src/components/PageHeader.stories.ts +45 -0
  22. package/src/components/PageHeader.vue +46 -0
  23. package/src/components/SchemaNode.vue +208 -0
  24. package/src/components/SchemaTree.vue +65 -0
  25. package/src/components/SourceDrawer.vue +136 -0
  26. package/src/components/SourcePill.vue +103 -0
  27. package/src/components/__tests__/EmptyState.test.ts +28 -0
  28. package/src/components/__tests__/ErrorBoundary.test.ts +52 -0
  29. package/src/components/__tests__/FilterInput.test.ts +38 -0
  30. package/src/components/__tests__/JsonView.test.ts +33 -0
  31. package/src/components/__tests__/KindBadge.test.ts +39 -0
  32. package/src/components/__tests__/ListRow.test.ts +39 -0
  33. package/src/components/__tests__/MasterDetail.test.ts +40 -0
  34. package/src/components/__tests__/PageHeader.test.ts +42 -0
  35. package/src/components/index.ts +17 -0
  36. package/src/components/ui/badge/Badge.vue +17 -0
  37. package/src/components/ui/badge/index.ts +25 -0
  38. package/src/components/ui/button/Button.vue +28 -0
  39. package/src/components/ui/button/index.ts +34 -0
  40. package/src/components/ui/card/Card.vue +14 -0
  41. package/src/components/ui/card/CardContent.vue +14 -0
  42. package/src/components/ui/card/CardDescription.vue +14 -0
  43. package/src/components/ui/card/CardFooter.vue +14 -0
  44. package/src/components/ui/card/CardHeader.vue +14 -0
  45. package/src/components/ui/card/CardTitle.vue +14 -0
  46. package/src/components/ui/card/index.ts +6 -0
  47. package/src/components/ui/dialog/Dialog.vue +15 -0
  48. package/src/components/ui/dialog/DialogClose.vue +12 -0
  49. package/src/components/ui/dialog/DialogContent.vue +47 -0
  50. package/src/components/ui/dialog/DialogDescription.vue +22 -0
  51. package/src/components/ui/dialog/DialogFooter.vue +12 -0
  52. package/src/components/ui/dialog/DialogHeader.vue +14 -0
  53. package/src/components/ui/dialog/DialogScrollContent.vue +60 -0
  54. package/src/components/ui/dialog/DialogTitle.vue +22 -0
  55. package/src/components/ui/dialog/DialogTrigger.vue +12 -0
  56. package/src/components/ui/dialog/index.ts +9 -0
  57. package/src/components/ui/input/Input.vue +32 -0
  58. package/src/components/ui/input/index.ts +1 -0
  59. package/src/components/ui/scroll-area/ScrollArea.vue +22 -0
  60. package/src/components/ui/scroll-area/ScrollBar.vue +32 -0
  61. package/src/components/ui/scroll-area/index.ts +2 -0
  62. package/src/components/ui/separator/Separator.vue +27 -0
  63. package/src/components/ui/separator/index.ts +1 -0
  64. package/src/components/ui/skeleton/Skeleton.vue +14 -0
  65. package/src/components/ui/skeleton/index.ts +1 -0
  66. package/src/components/ui/tabs/Tabs.vue +15 -0
  67. package/src/components/ui/tabs/TabsContent.vue +25 -0
  68. package/src/components/ui/tabs/TabsList.vue +25 -0
  69. package/src/components/ui/tabs/TabsTrigger.vue +29 -0
  70. package/src/components/ui/tabs/index.ts +4 -0
  71. package/src/components/ui/tooltip/Tooltip.vue +15 -0
  72. package/src/components/ui/tooltip/TooltipContent.vue +40 -0
  73. package/src/components/ui/tooltip/TooltipProvider.vue +12 -0
  74. package/src/components/ui/tooltip/TooltipTrigger.vue +12 -0
  75. package/src/components/ui/tooltip/index.ts +4 -0
  76. package/src/composables/useCopy.ts +31 -0
  77. package/src/lib/__tests__/normalize-cache.test.ts +104 -0
  78. package/src/lib/cache.ts +334 -0
  79. package/src/lib/normalize-cache.ts +92 -0
  80. package/src/lib/project-catalog.ts +125 -0
  81. package/src/lib/utils.ts +6 -0
  82. package/src/main.ts +112 -0
  83. package/src/pages/Actions.vue +180 -0
  84. package/src/pages/Commands.vue +262 -0
  85. package/src/pages/Dispatch.vue +431 -0
  86. package/src/pages/Events.vue +166 -0
  87. package/src/pages/Home.stories.ts +47 -0
  88. package/src/pages/Home.vue +485 -0
  89. package/src/pages/Hooks.vue +297 -0
  90. package/src/pages/Live.vue +249 -0
  91. package/src/pages/Modules.vue +174 -0
  92. package/src/pages/Overview.vue +159 -0
  93. package/src/pages/Plugins.stories.ts +44 -0
  94. package/src/pages/Plugins.vue +403 -0
  95. package/src/pages/Projects.vue +272 -0
  96. package/src/pages/Run.vue +479 -0
  97. package/src/pages/Topology.vue +164 -0
  98. package/src/pages/Trace.vue +511 -0
  99. package/src/pages/TraceNode.vue +166 -0
  100. package/src/pages/Workflows.vue +191 -0
  101. package/src/pages/__tests__/Actions.test.ts +98 -0
  102. package/src/pages/__tests__/Home.test.ts +98 -0
  103. package/src/pages/__tests__/Hooks.test.ts +119 -0
  104. package/src/pages/__tests__/Plugins.test.ts +80 -0
  105. package/src/style.css +40 -0
  106. package/tsconfig.json +20 -0
  107. package/vite.config.ts +892 -0
@@ -0,0 +1,511 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Trace — visualize one correlationId's full causal tree.
4
+ *
5
+ * /trace?correlationId=…
6
+ *
7
+ * Pulls events from the running wire's `/_nwire/events/{recent,stream}`
8
+ * SSE surface, groups them by `correlationId`, and renders the causation
9
+ * forest as a vertical tree (each `causationId` is an edge from parent
10
+ * `messageId` → child).
11
+ *
12
+ * - Left: trace picker — recent correlation ids, with the count of
13
+ * events under each. Click → loads.
14
+ * - Middle: the causation tree. Each node is an event card with name,
15
+ * timestamp, payload (collapsed), source location pill.
16
+ * - Right: context explorer — modules touched, apps participating,
17
+ * unique events fired, total span, error count. The "who
18
+ * touched this trace?" answer.
19
+ */
20
+ import { computed, onMounted, onUnmounted, ref, watch } from "vue";
21
+ import { useRoute, useRouter } from "vue-router";
22
+ import {
23
+ Activity,
24
+ Boxes,
25
+ Clock,
26
+ Globe,
27
+ Network,
28
+ RefreshCw,
29
+ Search,
30
+ Workflow,
31
+ Zap,
32
+ } from "lucide-vue-next";
33
+ import { useCache } from "@/lib/cache";
34
+ import { SourceDrawer } from "@/components";
35
+ import TraceNode from "./TraceNode.vue";
36
+
37
+ interface BufferedEvent {
38
+ seq: number;
39
+ eventName?: string;
40
+ payload: unknown;
41
+ envelope: {
42
+ messageId: string;
43
+ correlationId: string;
44
+ causationId: string;
45
+ tenant?: string;
46
+ userId?: string;
47
+ timestamp: string;
48
+ version: number;
49
+ };
50
+ source: "in-process" | "external";
51
+ appName: string;
52
+ capturedAt: string;
53
+ }
54
+
55
+ const route = useRoute();
56
+ const router = useRouter();
57
+ const { cache } = useCache();
58
+
59
+ const events = ref<BufferedEvent[]>([]);
60
+ const status = ref<"connecting" | "open" | "closed" | "error">("connecting");
61
+ const expanded = ref<Record<string, boolean>>({});
62
+ const filter = ref("");
63
+ const sourcePreview = ref<{ file: string; line: number; column?: number } | null>(null);
64
+
65
+ let es: EventSource | null = null;
66
+
67
+ const selectedCorrelation = computed<string | undefined>({
68
+ get: () => (route.query.correlationId as string | undefined) ?? recentCorrelations.value[0],
69
+ set: (id) => {
70
+ // Preserve the action filter when the user picks a trace.
71
+ const q: Record<string, string> = {};
72
+ if (id) q.correlationId = id;
73
+ if (typeof route.query.action === "string" && route.query.action) {
74
+ q.action = route.query.action;
75
+ }
76
+ router.replace({ path: "/trace", query: q });
77
+ },
78
+ });
79
+
80
+ /**
81
+ * `?action=<name>` seeds the search filter on first paint so the operator
82
+ * lands on traces whose events were emitted by (or named after) the action.
83
+ * It is a non-destructive seed — the user can clear it. The existing
84
+ * `?correlationId` deep-link path is untouched and still wins selection.
85
+ */
86
+ function applyActionFilter(): void {
87
+ const action = route.query.action;
88
+ if (typeof action === "string" && action.length > 0) {
89
+ filter.value = action;
90
+ }
91
+ }
92
+
93
+ onMounted(() => {
94
+ applyActionFilter();
95
+ void start();
96
+ });
97
+ watch(() => route.query.action, applyActionFilter);
98
+ onUnmounted(() => es?.close());
99
+
100
+ async function start() {
101
+ await loadRecent();
102
+ connectStream();
103
+ }
104
+
105
+ async function loadRecent() {
106
+ try {
107
+ const r = await fetch("/_nwire/events/recent?limit=500");
108
+ if (!r.ok) return;
109
+ const json = (await r.json()) as BufferedEvent[];
110
+ events.value = json.slice().reverse(); // oldest first
111
+ } catch {
112
+ // Wire not reachable yet — connectStream() will retry.
113
+ }
114
+ }
115
+
116
+ function connectStream() {
117
+ status.value = "connecting";
118
+ es?.close();
119
+ es = new EventSource("/_nwire/events/stream");
120
+ es.onopen = () => {
121
+ status.value = "open";
122
+ };
123
+ es.onerror = () => {
124
+ status.value = "error";
125
+ };
126
+ es.onmessage = (m) => {
127
+ try {
128
+ const evt = JSON.parse(m.data) as BufferedEvent;
129
+ events.value.push(evt);
130
+ if (events.value.length > 2000) events.value.splice(0, 500);
131
+ } catch {
132
+ /* ignore */
133
+ }
134
+ };
135
+ }
136
+
137
+ function reconnect() {
138
+ connectStream();
139
+ }
140
+ function refresh() {
141
+ void loadRecent();
142
+ }
143
+
144
+ // ── Recent correlation ids (newest first), bucketed by activity ──────
145
+ const recentCorrelations = computed<readonly string[]>(() => {
146
+ const seen = new Map<string, number>(); // id → last seq
147
+ for (const e of events.value) {
148
+ seen.set(e.envelope.correlationId, e.seq);
149
+ }
150
+ return Array.from(seen.entries())
151
+ .sort((a, b) => b[1] - a[1])
152
+ .map(([id]) => id);
153
+ });
154
+
155
+ const filteredCorrelations = computed<readonly string[]>(() => {
156
+ const q = filter.value.toLowerCase();
157
+ if (!q) return recentCorrelations.value;
158
+ return recentCorrelations.value.filter((id) => {
159
+ if (id.toLowerCase().includes(q)) return true;
160
+ // Filter on event names + tenant within the trace too.
161
+ const traceEvents = events.value.filter((e) => e.envelope.correlationId === id);
162
+ return traceEvents.some(
163
+ (e) =>
164
+ e.eventName?.toLowerCase().includes(q) ||
165
+ e.envelope.tenant?.toLowerCase().includes(q) ||
166
+ e.envelope.userId?.toLowerCase().includes(q),
167
+ );
168
+ });
169
+ });
170
+
171
+ function correlationLabel(id: string): string {
172
+ return id.split("-")[0] ?? id.slice(0, 8);
173
+ }
174
+ function correlationCount(id: string): number {
175
+ let n = 0;
176
+ for (const e of events.value) if (e.envelope.correlationId === id) n++;
177
+ return n;
178
+ }
179
+
180
+ // ── The causal tree for the selected correlation ─────────────────────
181
+ interface TraceNode {
182
+ evt: BufferedEvent;
183
+ children: TraceNode[];
184
+ }
185
+
186
+ const selectedTraceEvents = computed<readonly BufferedEvent[]>(() => {
187
+ if (!selectedCorrelation.value) return [];
188
+ return events.value
189
+ .filter((e) => e.envelope.correlationId === selectedCorrelation.value)
190
+ .sort((a, b) => a.seq - b.seq);
191
+ });
192
+
193
+ const causalForest = computed<TraceNode[]>(() => {
194
+ const evts = selectedTraceEvents.value;
195
+ if (evts.length === 0) return [];
196
+ const byMsg = new Map<string, TraceNode>();
197
+ for (const evt of evts) byMsg.set(evt.envelope.messageId, { evt, children: [] });
198
+
199
+ const roots: TraceNode[] = [];
200
+ for (const node of byMsg.values()) {
201
+ const cid = node.evt.envelope.causationId;
202
+ if (!cid || cid === node.evt.envelope.messageId) {
203
+ roots.push(node);
204
+ continue;
205
+ }
206
+ const parent = byMsg.get(cid);
207
+ if (parent) parent.children.push(node);
208
+ else roots.push(node);
209
+ }
210
+ return roots;
211
+ });
212
+
213
+ // ── Index by name for cross-linking to /actions, /events, etc. ───────
214
+ const eventIndex = computed(() => {
215
+ const m = new Map<
216
+ string,
217
+ { module: string; app: string; source?: { file: string; line: number; column?: number } }
218
+ >();
219
+ if (cache.value) {
220
+ for (const e of cache.value.events) {
221
+ m.set(e.name, { module: e.module, app: e.app, source: e.source });
222
+ }
223
+ }
224
+ return m;
225
+ });
226
+
227
+ // ── Context explorer ─────────────────────────────────────────────────
228
+ interface ContextDigest {
229
+ apps: Set<string>;
230
+ modules: Set<string>;
231
+ uniqueEvents: Set<string>;
232
+ startedAt: string;
233
+ endedAt: string;
234
+ spanMs: number;
235
+ total: number;
236
+ inProcess: number;
237
+ external: number;
238
+ tenants: Set<string>;
239
+ users: Set<string>;
240
+ }
241
+
242
+ const context = computed<ContextDigest | undefined>(() => {
243
+ const evts = selectedTraceEvents.value;
244
+ if (evts.length === 0) return undefined;
245
+ const apps = new Set<string>();
246
+ const modules = new Set<string>();
247
+ const unique = new Set<string>();
248
+ const tenants = new Set<string>();
249
+ const users = new Set<string>();
250
+ let inProc = 0;
251
+ let external = 0;
252
+
253
+ for (const e of evts) {
254
+ apps.add(e.appName);
255
+ if (e.eventName) {
256
+ unique.add(e.eventName);
257
+ const meta = eventIndex.value.get(e.eventName);
258
+ if (meta) modules.add(`${meta.app}/${meta.module}`);
259
+ }
260
+ if (e.envelope.tenant) tenants.add(e.envelope.tenant);
261
+ if (e.envelope.userId) users.add(e.envelope.userId);
262
+ if (e.source === "external") external++;
263
+ else inProc++;
264
+ }
265
+
266
+ const startedAt = evts[0]!.capturedAt;
267
+ const endedAt = evts[evts.length - 1]!.capturedAt;
268
+ const spanMs = new Date(endedAt).getTime() - new Date(startedAt).getTime();
269
+
270
+ return {
271
+ apps,
272
+ modules,
273
+ uniqueEvents: unique,
274
+ startedAt,
275
+ endedAt,
276
+ spanMs,
277
+ total: evts.length,
278
+ inProcess: inProc,
279
+ external,
280
+ tenants,
281
+ users,
282
+ };
283
+ });
284
+
285
+ function isExpanded(id: string): boolean {
286
+ return expanded.value[id] !== false; // default expanded
287
+ }
288
+ function toggle(id: string): void {
289
+ expanded.value[id] = !isExpanded(id);
290
+ }
291
+ function formatTime(iso: string): string {
292
+ return new Date(iso).toLocaleTimeString(undefined, { hour12: false });
293
+ }
294
+ function payloadPreview(p: unknown): string {
295
+ if (p === undefined || p === null) return "—";
296
+ const s = JSON.stringify(p);
297
+ return s.length > 80 ? s.slice(0, 80) + "…" : s;
298
+ }
299
+
300
+ watch(
301
+ () => selectedCorrelation.value,
302
+ () => {
303
+ expanded.value = {};
304
+ },
305
+ );
306
+ </script>
307
+
308
+ <template>
309
+ <div class="h-full flex">
310
+ <!-- ── Left: trace picker ───────────────────────────────────────── -->
311
+ <aside class="w-72 border-r border-zinc-800 flex flex-col shrink-0">
312
+ <header class="border-b border-zinc-800 px-4 py-3 flex items-center justify-between gap-2">
313
+ <div class="flex items-center gap-2 min-w-0">
314
+ <Workflow class="w-4 h-4 text-orange-400" />
315
+ <h1 class="text-sm font-medium truncate">Traces</h1>
316
+ <span
317
+ class="text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-zinc-900 text-zinc-400"
318
+ >
319
+ {{ status }}
320
+ </span>
321
+ </div>
322
+ <button
323
+ class="p-1 rounded hover:bg-zinc-800 text-zinc-400"
324
+ title="Refresh"
325
+ @click="refresh"
326
+ >
327
+ <RefreshCw class="w-3.5 h-3.5" />
328
+ </button>
329
+ </header>
330
+
331
+ <div class="px-3 py-2 border-b border-zinc-800">
332
+ <div class="relative">
333
+ <Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500" />
334
+ <input
335
+ v-model="filter"
336
+ placeholder="filter…"
337
+ class="w-full bg-zinc-900 border border-zinc-800 rounded pl-7 pr-2 py-1 text-xs placeholder:text-zinc-600 focus:outline-none focus:border-zinc-600"
338
+ />
339
+ </div>
340
+ <div class="text-[10px] text-zinc-500 mt-1">
341
+ {{ filteredCorrelations.length }} traces · {{ events.length }} events
342
+ </div>
343
+ </div>
344
+
345
+ <div class="flex-1 overflow-auto">
346
+ <div v-if="filteredCorrelations.length === 0" class="p-4 text-xs text-zinc-500">
347
+ {{ events.length === 0 ? "Waiting for events…" : "No traces match the filter." }}
348
+ <button
349
+ v-if="status === 'error' || status === 'closed'"
350
+ class="block mt-2 text-orange-400 hover:underline"
351
+ @click="reconnect"
352
+ >
353
+ Reconnect
354
+ </button>
355
+ </div>
356
+ <button
357
+ v-for="id in filteredCorrelations"
358
+ :key="id"
359
+ class="w-full text-left px-3 py-2 border-b border-zinc-900 hover:bg-zinc-900/50 transition-colors"
360
+ :class="{ 'bg-zinc-900/70': id === selectedCorrelation }"
361
+ @click="selectedCorrelation = id"
362
+ >
363
+ <div class="flex items-center justify-between gap-2">
364
+ <span class="font-mono text-xs text-zinc-200 truncate">
365
+ {{ correlationLabel(id) }}
366
+ </span>
367
+ <span class="text-[10px] text-zinc-500 tabular-nums shrink-0">
368
+ {{ correlationCount(id) }} ev
369
+ </span>
370
+ </div>
371
+ </button>
372
+ </div>
373
+ </aside>
374
+
375
+ <!-- ── Middle: causal tree ──────────────────────────────────────── -->
376
+ <main class="flex-1 flex flex-col min-w-0">
377
+ <header class="border-b border-zinc-800 px-4 py-3 flex items-center gap-3">
378
+ <Activity class="w-4 h-4 text-emerald-400" />
379
+ <h2 class="text-sm font-medium">Causal trace</h2>
380
+ <span v-if="selectedCorrelation" class="text-xs font-mono text-zinc-500">
381
+ corr {{ correlationLabel(selectedCorrelation) }}
382
+ </span>
383
+ <span v-else class="text-xs text-zinc-500">No trace selected.</span>
384
+ </header>
385
+
386
+ <div class="flex-1 overflow-auto p-4">
387
+ <div
388
+ v-if="!selectedCorrelation || causalForest.length === 0"
389
+ class="text-xs text-zinc-500 italic"
390
+ >
391
+ Pick a trace from the left to see its causation tree.
392
+ </div>
393
+ <ul v-else class="space-y-3">
394
+ <li v-for="root in causalForest" :key="root.evt.envelope.messageId">
395
+ <TraceNode
396
+ :node="root"
397
+ :depth="0"
398
+ :event-index="eventIndex"
399
+ :is-expanded="isExpanded"
400
+ :toggle="toggle"
401
+ :format-time="formatTime"
402
+ :payload-preview="payloadPreview"
403
+ @open-source="(s) => (sourcePreview = s)"
404
+ />
405
+ </li>
406
+ </ul>
407
+ </div>
408
+ </main>
409
+
410
+ <!-- ── Right: context explorer ──────────────────────────────────── -->
411
+ <aside class="w-80 border-l border-zinc-800 flex flex-col shrink-0">
412
+ <header class="border-b border-zinc-800 px-4 py-3 flex items-center gap-2">
413
+ <Boxes class="w-4 h-4 text-violet-400" />
414
+ <h2 class="text-sm font-medium">Context</h2>
415
+ </header>
416
+ <div v-if="!context" class="p-4 text-xs text-zinc-500 italic">
417
+ Context appears once a trace is selected.
418
+ </div>
419
+ <div v-else class="overflow-auto divide-y divide-zinc-800">
420
+ <section class="px-4 py-3">
421
+ <h3 class="text-[10px] uppercase tracking-wide text-zinc-500 mb-2">Span</h3>
422
+ <div class="flex items-center gap-2 text-xs">
423
+ <Clock class="w-3.5 h-3.5 text-zinc-500" />
424
+ <span class="font-mono">{{ context.spanMs }} ms</span>
425
+ <span class="text-zinc-600">·</span>
426
+ <span class="font-mono text-zinc-400">{{ context.total }} events</span>
427
+ </div>
428
+ <div class="text-[10px] text-zinc-500 font-mono mt-1">
429
+ {{ formatTime(context.startedAt) }} → {{ formatTime(context.endedAt) }}
430
+ </div>
431
+ </section>
432
+
433
+ <section class="px-4 py-3">
434
+ <h3 class="text-[10px] uppercase tracking-wide text-zinc-500 mb-2">Apps</h3>
435
+ <div class="flex flex-wrap gap-1">
436
+ <span
437
+ v-for="a in [...context.apps]"
438
+ :key="a"
439
+ class="inline-flex items-center gap-1 text-xs font-mono bg-zinc-900 border border-zinc-800 rounded px-1.5 py-0.5"
440
+ >
441
+ <Globe class="w-3 h-3 text-emerald-400" />
442
+ {{ a }}
443
+ </span>
444
+ </div>
445
+ </section>
446
+
447
+ <section v-if="context.modules.size" class="px-4 py-3">
448
+ <h3 class="text-[10px] uppercase tracking-wide text-zinc-500 mb-2">Modules touched</h3>
449
+ <ul class="space-y-1">
450
+ <li
451
+ v-for="m in [...context.modules]"
452
+ :key="m"
453
+ class="flex items-center gap-2 text-xs font-mono"
454
+ >
455
+ <Boxes class="w-3 h-3 text-violet-400" />
456
+ {{ m }}
457
+ </li>
458
+ </ul>
459
+ </section>
460
+
461
+ <section v-if="context.uniqueEvents.size" class="px-4 py-3">
462
+ <h3 class="text-[10px] uppercase tracking-wide text-zinc-500 mb-2">Distinct events</h3>
463
+ <ul class="space-y-1">
464
+ <li
465
+ v-for="ev in [...context.uniqueEvents]"
466
+ :key="ev"
467
+ class="flex items-center gap-2 text-xs font-mono"
468
+ >
469
+ <Zap class="w-3 h-3 text-amber-400" />
470
+ {{ ev }}
471
+ </li>
472
+ </ul>
473
+ </section>
474
+
475
+ <section v-if="context.external > 0" class="px-4 py-3">
476
+ <h3 class="text-[10px] uppercase tracking-wide text-zinc-500 mb-2">Origin</h3>
477
+ <div class="text-xs space-y-0.5">
478
+ <div class="flex items-center gap-2">
479
+ <Globe class="w-3 h-3 text-emerald-400" />
480
+ <span class="text-zinc-400">in-process</span>
481
+ <span class="ml-auto font-mono">{{ context.inProcess }}</span>
482
+ </div>
483
+ <div class="flex items-center gap-2">
484
+ <Network class="w-3 h-3 text-violet-400" />
485
+ <span class="text-zinc-400">external (bus)</span>
486
+ <span class="ml-auto font-mono">{{ context.external }}</span>
487
+ </div>
488
+ </div>
489
+ </section>
490
+
491
+ <section v-if="context.tenants.size || context.users.size" class="px-4 py-3">
492
+ <h3 class="text-[10px] uppercase tracking-wide text-zinc-500 mb-2">Actor / scope</h3>
493
+ <div v-if="context.tenants.size" class="text-xs space-y-0.5">
494
+ <div class="text-zinc-500">Tenants</div>
495
+ <div v-for="t in [...context.tenants]" :key="t" class="font-mono">
496
+ {{ t }}
497
+ </div>
498
+ </div>
499
+ <div v-if="context.users.size" class="text-xs space-y-0.5 mt-2">
500
+ <div class="text-zinc-500">Users</div>
501
+ <div v-for="u in [...context.users]" :key="u" class="font-mono truncate">
502
+ {{ u }}
503
+ </div>
504
+ </div>
505
+ </section>
506
+ </div>
507
+ </aside>
508
+
509
+ <SourceDrawer :source="sourcePreview" @close="sourcePreview = null" />
510
+ </div>
511
+ </template>
@@ -0,0 +1,166 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * One node in the trace tree. Renders the event card and (when expanded)
4
+ * recursively renders its causation children.
5
+ *
6
+ * Indentation comes from `depth`. The collapsed/expanded state lives in
7
+ * the parent page (`expanded` ref keyed by messageId) so it survives
8
+ * re-renders.
9
+ */
10
+ import { computed } from "vue";
11
+ import { useRouter } from "vue-router";
12
+ import { ChevronDown, ChevronRight, Globe, Network, Zap } from "lucide-vue-next";
13
+ import { useCache } from "@/lib/cache";
14
+ import SourcePill from "@/components/SourcePill.vue";
15
+
16
+ interface BufferedEvent {
17
+ seq: number;
18
+ eventName?: string;
19
+ payload: unknown;
20
+ envelope: {
21
+ messageId: string;
22
+ correlationId: string;
23
+ causationId: string;
24
+ tenant?: string;
25
+ userId?: string;
26
+ timestamp: string;
27
+ version: number;
28
+ };
29
+ source: "in-process" | "external";
30
+ appName: string;
31
+ capturedAt: string;
32
+ }
33
+
34
+ interface TraceNode {
35
+ evt: BufferedEvent;
36
+ children: TraceNode[];
37
+ }
38
+
39
+ interface SourceLoc {
40
+ file: string;
41
+ line: number;
42
+ column?: number;
43
+ }
44
+
45
+ const props = defineProps<{
46
+ node: TraceNode;
47
+ depth: number;
48
+ eventIndex: Map<string, { module: string; app: string; source?: SourceLoc }>;
49
+ isExpanded: (id: string) => boolean;
50
+ toggle: (id: string) => void;
51
+ formatTime: (iso: string) => string;
52
+ payloadPreview: (p: unknown) => string;
53
+ }>();
54
+
55
+ const emit = defineEmits<{ (e: "openSource", source: SourceLoc): void }>();
56
+
57
+ const router = useRouter();
58
+ const { cache } = useCache();
59
+
60
+ const displayName = props.node.evt.eventName ?? "(framework lifecycle)";
61
+ const meta = props.node.evt.eventName ? props.eventIndex.get(props.node.evt.eventName) : undefined;
62
+ const source = meta?.source;
63
+
64
+ /**
65
+ * The action whose handler emitted this event, if any. We look it up by
66
+ * scanning cache.actions for one whose `emits` list contains this event
67
+ * name. First match wins. Used to render the "Action: <name>" deep link
68
+ * back to /actions?name=<name>.
69
+ */
70
+ const dispatchingAction = computed<string | null>(() => {
71
+ const evtName = props.node.evt.eventName;
72
+ if (!evtName || !cache.value) return null;
73
+ const found = cache.value.actions.find((a) => a.emits.includes(evtName));
74
+ return found?.name ?? null;
75
+ });
76
+
77
+ function openAction(name: string): void {
78
+ void router.push({ path: "/actions", query: { name } });
79
+ }
80
+
81
+ function open(s: SourceLoc | undefined) {
82
+ if (s) emit("openSource", s);
83
+ }
84
+ </script>
85
+
86
+ <template>
87
+ <div :style="{ paddingLeft: `${depth * 16}px` }">
88
+ <div class="flex items-start gap-2">
89
+ <button
90
+ type="button"
91
+ class="mt-1.5 text-zinc-500 hover:text-zinc-300 shrink-0"
92
+ :class="{ invisible: node.children.length === 0 }"
93
+ @click="toggle(node.evt.envelope.messageId)"
94
+ >
95
+ <ChevronDown v-if="isExpanded(node.evt.envelope.messageId)" class="w-3.5 h-3.5" />
96
+ <ChevronRight v-else class="w-3.5 h-3.5" />
97
+ </button>
98
+
99
+ <div class="flex-1 min-w-0">
100
+ <div
101
+ class="rounded border border-zinc-800 bg-zinc-900/30 hover:bg-zinc-900/60 transition-colors p-3"
102
+ >
103
+ <div class="flex items-center gap-2 flex-wrap">
104
+ <component
105
+ :is="node.evt.source === 'external' ? Network : Globe"
106
+ class="w-3.5 h-3.5"
107
+ :class="node.evt.source === 'external' ? 'text-violet-400' : 'text-emerald-400'"
108
+ />
109
+ <span
110
+ class="font-mono text-sm truncate"
111
+ :class="node.evt.eventName ? 'text-zinc-100' : 'text-zinc-500 italic'"
112
+ >
113
+ {{ displayName }}
114
+ </span>
115
+ <span class="text-[10px] text-zinc-500 tabular-nums">
116
+ {{ formatTime(node.evt.capturedAt) }}
117
+ </span>
118
+ <span v-if="meta" class="text-[10px] text-zinc-500 font-mono">
119
+ · {{ meta.app }}/{{ meta.module }}
120
+ </span>
121
+ <button v-if="source" type="button" class="ml-auto" @click="open(source)">
122
+ <SourcePill :source="source" compact />
123
+ </button>
124
+ </div>
125
+ <div class="text-[10px] text-zinc-500 font-mono mt-1 truncate">
126
+ msg {{ node.evt.envelope.messageId.split("-")[0] }} ← caused by
127
+ {{ node.evt.envelope.causationId.split("-")[0] }}
128
+ </div>
129
+ <div v-if="dispatchingAction" class="text-[10px] mt-1">
130
+ <button
131
+ type="button"
132
+ class="inline-flex items-center gap-1 font-mono text-amber-300 hover:underline"
133
+ :data-testid="`action-link-${dispatchingAction}`"
134
+ @click="openAction(dispatchingAction)"
135
+ >
136
+ <Zap class="w-3 h-3 text-amber-400" />
137
+ Action: {{ dispatchingAction }}
138
+ </button>
139
+ </div>
140
+ <div v-if="node.evt.payload" class="text-[11px] font-mono text-zinc-400 mt-1.5 truncate">
141
+ <Zap class="inline w-3 h-3 text-amber-400 mr-1" />
142
+ {{ payloadPreview(node.evt.payload) }}
143
+ </div>
144
+ </div>
145
+
146
+ <div
147
+ v-if="isExpanded(node.evt.envelope.messageId) && node.children.length"
148
+ class="mt-2 space-y-2"
149
+ >
150
+ <TraceNode
151
+ v-for="child in node.children"
152
+ :key="child.evt.envelope.messageId"
153
+ :node="child"
154
+ :depth="depth + 1"
155
+ :event-index="eventIndex"
156
+ :is-expanded="isExpanded"
157
+ :toggle="toggle"
158
+ :format-time="formatTime"
159
+ :payload-preview="payloadPreview"
160
+ @open-source="emit('openSource', $event)"
161
+ />
162
+ </div>
163
+ </div>
164
+ </div>
165
+ </div>
166
+ </template>