@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.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/components.json +19 -0
- package/index.html +12 -0
- package/package.json +66 -0
- package/src/App.vue +305 -0
- package/src/components/EmptyState.stories.ts +53 -0
- package/src/components/EmptyState.vue +28 -0
- package/src/components/ErrorBoundary.vue +60 -0
- package/src/components/FilterInput.stories.ts +32 -0
- package/src/components/FilterInput.vue +33 -0
- package/src/components/JsonView.stories.ts +38 -0
- package/src/components/JsonView.vue +34 -0
- package/src/components/KindBadge.stories.ts +72 -0
- package/src/components/KindBadge.vue +59 -0
- package/src/components/ListRow.stories.ts +56 -0
- package/src/components/ListRow.vue +48 -0
- package/src/components/MasterDetail.stories.ts +74 -0
- package/src/components/MasterDetail.vue +35 -0
- package/src/components/MonacoViewer.vue +143 -0
- package/src/components/PageHeader.stories.ts +45 -0
- package/src/components/PageHeader.vue +46 -0
- package/src/components/SchemaNode.vue +208 -0
- package/src/components/SchemaTree.vue +65 -0
- package/src/components/SourceDrawer.vue +136 -0
- package/src/components/SourcePill.vue +103 -0
- package/src/components/__tests__/EmptyState.test.ts +28 -0
- package/src/components/__tests__/ErrorBoundary.test.ts +52 -0
- package/src/components/__tests__/FilterInput.test.ts +38 -0
- package/src/components/__tests__/JsonView.test.ts +33 -0
- package/src/components/__tests__/KindBadge.test.ts +39 -0
- package/src/components/__tests__/ListRow.test.ts +39 -0
- package/src/components/__tests__/MasterDetail.test.ts +40 -0
- package/src/components/__tests__/PageHeader.test.ts +42 -0
- package/src/components/index.ts +17 -0
- package/src/components/ui/badge/Badge.vue +17 -0
- package/src/components/ui/badge/index.ts +25 -0
- package/src/components/ui/button/Button.vue +28 -0
- package/src/components/ui/button/index.ts +34 -0
- package/src/components/ui/card/Card.vue +14 -0
- package/src/components/ui/card/CardContent.vue +14 -0
- package/src/components/ui/card/CardDescription.vue +14 -0
- package/src/components/ui/card/CardFooter.vue +14 -0
- package/src/components/ui/card/CardHeader.vue +14 -0
- package/src/components/ui/card/CardTitle.vue +14 -0
- package/src/components/ui/card/index.ts +6 -0
- package/src/components/ui/dialog/Dialog.vue +15 -0
- package/src/components/ui/dialog/DialogClose.vue +12 -0
- package/src/components/ui/dialog/DialogContent.vue +47 -0
- package/src/components/ui/dialog/DialogDescription.vue +22 -0
- package/src/components/ui/dialog/DialogFooter.vue +12 -0
- package/src/components/ui/dialog/DialogHeader.vue +14 -0
- package/src/components/ui/dialog/DialogScrollContent.vue +60 -0
- package/src/components/ui/dialog/DialogTitle.vue +22 -0
- package/src/components/ui/dialog/DialogTrigger.vue +12 -0
- package/src/components/ui/dialog/index.ts +9 -0
- package/src/components/ui/input/Input.vue +32 -0
- package/src/components/ui/input/index.ts +1 -0
- package/src/components/ui/scroll-area/ScrollArea.vue +22 -0
- package/src/components/ui/scroll-area/ScrollBar.vue +32 -0
- package/src/components/ui/scroll-area/index.ts +2 -0
- package/src/components/ui/separator/Separator.vue +27 -0
- package/src/components/ui/separator/index.ts +1 -0
- package/src/components/ui/skeleton/Skeleton.vue +14 -0
- package/src/components/ui/skeleton/index.ts +1 -0
- package/src/components/ui/tabs/Tabs.vue +15 -0
- package/src/components/ui/tabs/TabsContent.vue +25 -0
- package/src/components/ui/tabs/TabsList.vue +25 -0
- package/src/components/ui/tabs/TabsTrigger.vue +29 -0
- package/src/components/ui/tabs/index.ts +4 -0
- package/src/components/ui/tooltip/Tooltip.vue +15 -0
- package/src/components/ui/tooltip/TooltipContent.vue +40 -0
- package/src/components/ui/tooltip/TooltipProvider.vue +12 -0
- package/src/components/ui/tooltip/TooltipTrigger.vue +12 -0
- package/src/components/ui/tooltip/index.ts +4 -0
- package/src/composables/useCopy.ts +31 -0
- package/src/lib/__tests__/normalize-cache.test.ts +104 -0
- package/src/lib/cache.ts +334 -0
- package/src/lib/normalize-cache.ts +92 -0
- package/src/lib/project-catalog.ts +125 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.ts +112 -0
- package/src/pages/Actions.vue +180 -0
- package/src/pages/Commands.vue +262 -0
- package/src/pages/Dispatch.vue +431 -0
- package/src/pages/Events.vue +166 -0
- package/src/pages/Home.stories.ts +47 -0
- package/src/pages/Home.vue +485 -0
- package/src/pages/Hooks.vue +297 -0
- package/src/pages/Live.vue +249 -0
- package/src/pages/Modules.vue +174 -0
- package/src/pages/Overview.vue +159 -0
- package/src/pages/Plugins.stories.ts +44 -0
- package/src/pages/Plugins.vue +403 -0
- package/src/pages/Projects.vue +272 -0
- package/src/pages/Run.vue +479 -0
- package/src/pages/Topology.vue +164 -0
- package/src/pages/Trace.vue +511 -0
- package/src/pages/TraceNode.vue +166 -0
- package/src/pages/Workflows.vue +191 -0
- package/src/pages/__tests__/Actions.test.ts +98 -0
- package/src/pages/__tests__/Home.test.ts +98 -0
- package/src/pages/__tests__/Hooks.test.ts +119 -0
- package/src/pages/__tests__/Plugins.test.ts +80 -0
- package/src/style.css +40 -0
- package/tsconfig.json +20 -0
- 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>
|