@nwire/studio 0.12.1 → 0.13.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 (130) hide show
  1. package/package.json +5 -3
  2. package/src/App.vue +62 -56
  3. package/src/components/BcCard.stories.ts +47 -0
  4. package/src/components/BcCard.vue +152 -0
  5. package/src/components/DurationBar.stories.ts +55 -0
  6. package/src/components/DurationBar.vue +72 -0
  7. package/src/components/ErrorCard.stories.ts +133 -0
  8. package/src/components/ErrorCard.vue +153 -0
  9. package/src/components/GraphCanvas.stories.ts +48 -0
  10. package/src/components/GraphCanvas.vue +88 -0
  11. package/src/components/KpiTile.stories.ts +32 -0
  12. package/src/components/KpiTile.vue +39 -0
  13. package/src/components/LiveTable.stories.ts +78 -0
  14. package/src/components/LiveTable.vue +186 -0
  15. package/src/components/MetadataInspector.stories.ts +53 -0
  16. package/src/components/MetadataInspector.vue +105 -0
  17. package/src/components/NodeCard.stories.ts +44 -0
  18. package/src/components/NodeCard.vue +150 -0
  19. package/src/components/RcaPanel.stories.ts +95 -0
  20. package/src/components/RcaPanel.vue +223 -0
  21. package/src/components/ServiceNode.vue +134 -0
  22. package/src/components/SourceDrawer.vue +6 -4
  23. package/src/components/SourcePill.vue +10 -3
  24. package/src/components/StatusBadge.stories.ts +33 -0
  25. package/src/components/StatusBadge.vue +54 -0
  26. package/src/components/Waterfall.stories.ts +85 -0
  27. package/src/components/Waterfall.vue +53 -0
  28. package/src/components/WaterfallRow.vue +74 -0
  29. package/src/components/__tests__/BcCard.test.ts +53 -0
  30. package/src/components/__tests__/DurationBar.test.ts +31 -0
  31. package/src/components/__tests__/ErrorCard.test.ts +71 -0
  32. package/src/components/__tests__/KpiTile.test.ts +23 -0
  33. package/src/components/__tests__/LiveTable.test.ts +100 -0
  34. package/src/components/__tests__/MetadataInspector.test.ts +38 -0
  35. package/src/components/__tests__/NodeCard.test.ts +116 -0
  36. package/src/components/__tests__/RcaPanel.test.ts +81 -0
  37. package/src/components/__tests__/StatusBadge.test.ts +23 -0
  38. package/src/components/__tests__/Waterfall.test.ts +54 -0
  39. package/src/components/index.ts +13 -0
  40. package/src/composables/__tests__/composables-context.test.ts +107 -0
  41. package/src/composables/__tests__/useTelemetry.test.ts +104 -0
  42. package/src/composables/__tests__/useTelemetryRuns.test.ts +282 -0
  43. package/src/composables/useDiscovery.ts +73 -0
  44. package/src/composables/useEndpoints.ts +94 -0
  45. package/src/composables/useLogTail.ts +51 -0
  46. package/src/composables/useManifest.ts +43 -0
  47. package/src/composables/useProcesses.ts +114 -0
  48. package/src/composables/useProject.ts +34 -0
  49. package/src/composables/useTelemetry.ts +270 -0
  50. package/src/lib/__tests__/bc-graph.test.ts +218 -0
  51. package/src/lib/__tests__/dispatch-form.test.ts +113 -0
  52. package/src/lib/__tests__/error-friendly.test.ts +198 -0
  53. package/src/lib/__tests__/home.test.ts +231 -0
  54. package/src/lib/__tests__/inspect.test.ts +160 -0
  55. package/src/lib/__tests__/kind-colors.test.ts +59 -0
  56. package/src/lib/__tests__/live-table.test.ts +194 -0
  57. package/src/lib/__tests__/manifest-health.test.ts +120 -0
  58. package/src/lib/__tests__/manifest.test.ts +87 -0
  59. package/src/lib/__tests__/metadata.test.ts +47 -0
  60. package/src/lib/__tests__/node-metrics.test.ts +144 -0
  61. package/src/lib/__tests__/operate.test.ts +97 -0
  62. package/src/lib/__tests__/pipeline-flow.test.ts +79 -0
  63. package/src/lib/__tests__/rca.test.ts +124 -0
  64. package/src/lib/__tests__/telemetry.test.ts +91 -0
  65. package/src/lib/__tests__/topology-graph.test.ts +331 -0
  66. package/src/lib/__tests__/topology-view.test.ts +154 -0
  67. package/src/lib/__tests__/waterfall.test.ts +165 -0
  68. package/src/lib/bc-graph.ts +298 -0
  69. package/src/lib/dispatch-form.ts +160 -0
  70. package/src/lib/error-friendly.ts +288 -0
  71. package/src/lib/home.ts +191 -0
  72. package/src/lib/inspect.ts +226 -0
  73. package/src/lib/kind-colors.ts +132 -0
  74. package/src/lib/live-table.ts +204 -0
  75. package/src/lib/manifest-health.ts +71 -0
  76. package/src/lib/manifest.ts +139 -0
  77. package/src/lib/metadata.ts +52 -0
  78. package/src/lib/node-metrics.ts +242 -0
  79. package/src/lib/operate.ts +114 -0
  80. package/src/lib/pipeline-flow.ts +120 -0
  81. package/src/lib/rca.ts +193 -0
  82. package/src/lib/telemetry.ts +155 -0
  83. package/src/lib/topology-graph.ts +551 -0
  84. package/src/lib/topology-view.ts +185 -0
  85. package/src/lib/waterfall.ts +148 -0
  86. package/src/main.ts +63 -29
  87. package/src/pages/Errors.vue +272 -0
  88. package/src/pages/Home.stories.ts +7 -8
  89. package/src/pages/Home.vue +255 -540
  90. package/src/pages/Hooks.stories.ts +44 -0
  91. package/src/pages/Hooks.vue +165 -164
  92. package/src/pages/Inspect.vue +240 -0
  93. package/src/pages/Map.vue +187 -0
  94. package/src/pages/Operate.vue +74 -0
  95. package/src/pages/Plugins.stories.ts +1 -1
  96. package/src/pages/Plugins.vue +174 -238
  97. package/src/pages/Projects.vue +62 -60
  98. package/src/pages/Streams.vue +344 -0
  99. package/src/pages/Topology.vue +318 -136
  100. package/src/pages/Trace.vue +174 -412
  101. package/src/pages/__tests__/Home.test.ts +109 -54
  102. package/src/pages/__tests__/Hooks.test.ts +5 -5
  103. package/src/pages/__tests__/Inspect.test.ts +111 -0
  104. package/src/pages/__tests__/Plugins.test.ts +85 -35
  105. package/src/pages/__tests__/Trace.test.ts +117 -0
  106. package/src/pages/operate/CommandsPanel.vue +186 -0
  107. package/src/pages/{Dispatch.vue → operate/DispatchPanel.vue} +100 -203
  108. package/src/pages/operate/EndpointPicker.vue +56 -0
  109. package/src/pages/operate/RunPanel.vue +316 -0
  110. package/src/server/__tests__/nwire-read.test.ts +80 -0
  111. package/src/server/nwire-read.ts +63 -0
  112. package/vite.config.ts +220 -2
  113. package/src/lib/__tests__/normalize-cache.test.ts +0 -105
  114. package/src/lib/cache.ts +0 -312
  115. package/src/lib/normalize-cache.ts +0 -92
  116. package/src/pages/Actions.vue +0 -171
  117. package/src/pages/Apps.vue +0 -177
  118. package/src/pages/Commands.vue +0 -262
  119. package/src/pages/Events.vue +0 -210
  120. package/src/pages/Live.vue +0 -249
  121. package/src/pages/Overview.vue +0 -161
  122. package/src/pages/Projections.vue +0 -148
  123. package/src/pages/Queries.vue +0 -148
  124. package/src/pages/Run.vue +0 -618
  125. package/src/pages/Sinks.vue +0 -124
  126. package/src/pages/TraceNode.vue +0 -164
  127. package/src/pages/Workflows.vue +0 -184
  128. package/src/pages/__tests__/Actions.test.ts +0 -98
  129. package/src/pages/__tests__/Projections.test.ts +0 -90
  130. package/src/pages/__tests__/Queries.test.ts +0 -86
@@ -1,303 +1,138 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
- * Trace — visualize one correlationId's full causal tree.
3
+ * Flow / Trace — the live causation waterfall.
4
4
  *
5
5
  * /trace?correlationId=…
6
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).
7
+ * Subscribes to the telemetry stream (`useTelemetry`), groups records by
8
+ * correlation, and renders the selected chain as a time-positioned waterfall
9
+ * (tree depth + critical path + kind colours). The right rail is a flat,
10
+ * copyable metadata inspector for the selected span. A replay control scrubs
11
+ * the chain so you can watch a trace unfold span by span.
11
12
  *
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.
13
+ * - Left: trace picker — recent correlations, newest first, with span +
14
+ * failure counts. Connection state lives in the header.
15
+ * - Middle: the waterfall + replay (play / pause / restart / scrub).
16
+ * - Right: metadata inspector for the selected span.
19
17
  */
20
- import { computed, onMounted, onUnmounted, ref, watch } from "vue";
18
+ import { computed, ref, watch, onUnmounted } from "vue";
21
19
  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
- }
20
+ import { Activity, Play, Pause, RotateCcw, Workflow } from "lucide-vue-next";
21
+ import { useTelemetry } from "@/composables/useTelemetry";
22
+ import { buildCorrelationTree, isFailure, type TelemetryRecord } from "@/lib/telemetry";
23
+ import { buildWaterfall, type WaterfallRow } from "@/lib/waterfall";
24
+ import { StatusBadge, Waterfall, MetadataInspector, EmptyState } from "@/components";
54
25
 
55
26
  const route = useRoute();
56
27
  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
28
 
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;
29
+ const { records, status, byCorrelation, connect } = useTelemetry();
30
+
31
+ // ── Connection state StatusBadge ───────────────────────────────────
32
+ const conn = computed(() => {
33
+ switch (status.value) {
34
+ case "open":
35
+ return { status: "live" as const, label: "live", pulse: true };
36
+ case "connecting":
37
+ case "reconnecting":
38
+ return { status: "warn" as const, label: status.value, pulse: false };
39
+ case "closed":
40
+ return { status: "error" as const, label: "closed", pulse: false };
41
+ default:
42
+ return { status: "idle" as const, label: "idle", pulse: false };
90
43
  }
91
- }
92
-
93
- onMounted(() => {
94
- applyActionFilter();
95
- void start();
96
44
  });
97
- watch(() => route.query.action, applyActionFilter);
98
- onUnmounted(() => es?.close());
99
45
 
100
- async function start() {
101
- await loadRecent();
102
- connectStream();
46
+ // ── Correlations, newest first ───────────────────────────────────────
47
+ interface TraceSummary {
48
+ readonly id: string;
49
+ readonly count: number;
50
+ readonly failures: number;
51
+ readonly lastIndex: number;
103
52
  }
104
53
 
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.
54
+ const traces = computed<TraceSummary[]>(() => {
55
+ const lastIndex = new Map<string, number>();
56
+ records.value.forEach((r, i) => {
57
+ const cid = r.envelope?.correlationId;
58
+ if (cid) lastIndex.set(cid, i);
59
+ });
60
+ const out: TraceSummary[] = [];
61
+ for (const [id, recs] of byCorrelation.value) {
62
+ if (!id) continue; // skip the envelope-less bucket
63
+ out.push({
64
+ id,
65
+ count: recs.length,
66
+ failures: recs.filter(isFailure).length,
67
+ lastIndex: lastIndex.get(id) ?? -1,
68
+ });
113
69
  }
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
- }
70
+ return out.sort((a, b) => b.lastIndex - a.lastIndex);
71
+ });
143
72
 
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);
73
+ const selectedCorrelation = computed<string | undefined>({
74
+ get: () => (route.query.correlationId as string | undefined) ?? traces.value[0]?.id,
75
+ set: (id) => router.replace({ path: "/trace", query: id ? { correlationId: id } : {} }),
153
76
  });
154
77
 
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
- });
78
+ const forest = computed(() => {
79
+ const id = selectedCorrelation.value;
80
+ if (!id) return [];
81
+ return buildCorrelationTree(byCorrelation.value.get(id) ?? []);
169
82
  });
170
83
 
171
- function correlationLabel(id: string): string {
84
+ function shortId(id: string): string {
172
85
  return id.split("-")[0] ?? id.slice(0, 8);
173
86
  }
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
87
 
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;
88
+ // ── Selected span inspector ────────────────────────────────────────
89
+ const selectedKey = ref<string | undefined>(undefined);
90
+ const rows = computed(() => buildWaterfall(forest.value).rows);
91
+ const selectedRecord = computed<TelemetryRecord | undefined>(() => {
92
+ const rs = rows.value;
93
+ return (rs.find((r) => r.key === selectedKey.value) ?? rs[0])?.record;
211
94
  });
212
-
213
- // ── Index by name for cross-linking to /actions, /events, etc. ───────
214
- const eventIndex = computed(() => {
215
- const m = new Map<
216
- string,
217
- { 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, { app: e.app, source: e.source });
222
- }
223
- }
224
- return m;
225
- });
226
-
227
- // ── Context explorer ─────────────────────────────────────────────────
228
- interface ContextDigest {
229
- apps: Set<string>;
230
- uniqueEvents: Set<string>;
231
- startedAt: string;
232
- endedAt: string;
233
- spanMs: number;
234
- total: number;
235
- inProcess: number;
236
- external: number;
237
- tenants: Set<string>;
238
- users: Set<string>;
95
+ function onSelect(row: WaterfallRow): void {
96
+ selectedKey.value = row.key;
239
97
  }
240
98
 
241
- const context = computed<ContextDigest | undefined>(() => {
242
- const evts = selectedTraceEvents.value;
243
- if (evts.length === 0) return undefined;
244
- const apps = new Set<string>();
245
- const unique = new Set<string>();
246
- const tenants = new Set<string>();
247
- const users = new Set<string>();
248
- let inProc = 0;
249
- let external = 0;
99
+ // ── Replay scrub ─────────────────────────────────────────────────────
100
+ const reveal = ref<number | undefined>(undefined); // undefined = show all
101
+ let timer: ReturnType<typeof setInterval> | null = null;
102
+ const playing = ref(false);
250
103
 
251
- for (const e of evts) {
252
- apps.add(e.appName);
253
- if (e.eventName) {
254
- unique.add(e.eventName);
255
- }
256
- if (e.envelope.tenant) tenants.add(e.envelope.tenant);
257
- if (e.envelope.userId) users.add(e.envelope.userId);
258
- if (e.source === "external") external++;
259
- else inProc++;
260
- }
261
-
262
- const startedAt = evts[0]!.capturedAt;
263
- const endedAt = evts[evts.length - 1]!.capturedAt;
264
- const spanMs = new Date(endedAt).getTime() - new Date(startedAt).getTime();
265
-
266
- return {
267
- apps,
268
- uniqueEvents: unique,
269
- startedAt,
270
- endedAt,
271
- spanMs,
272
- total: evts.length,
273
- inProcess: inProc,
274
- external,
275
- tenants,
276
- users,
277
- };
278
- });
279
-
280
- function isExpanded(id: string): boolean {
281
- return expanded.value[id] !== false; // default expanded
282
- }
283
- function toggle(id: string): void {
284
- expanded.value[id] = !isExpanded(id);
104
+ function stop(): void {
105
+ if (timer) clearInterval(timer);
106
+ timer = null;
107
+ playing.value = false;
285
108
  }
286
- function formatTime(iso: string): string {
287
- return new Date(iso).toLocaleTimeString(undefined, { hour12: false });
109
+ function play(): void {
110
+ stop();
111
+ const total = rows.value.length;
112
+ if (total === 0) return;
113
+ reveal.value = reveal.value && reveal.value < total ? reveal.value : 0;
114
+ playing.value = true;
115
+ timer = setInterval(() => {
116
+ reveal.value = (reveal.value ?? 0) + 1;
117
+ if ((reveal.value ?? 0) >= total) {
118
+ reveal.value = undefined; // settle to "all"
119
+ stop();
120
+ }
121
+ }, 350);
288
122
  }
289
- function payloadPreview(p: unknown): string {
290
- if (p === undefined || p === null) return "—";
291
- const s = JSON.stringify(p);
292
- return s.length > 80 ? s.slice(0, 80) + "…" : s;
123
+ function restart(): void {
124
+ stop();
125
+ reveal.value = undefined;
293
126
  }
294
127
 
295
- watch(
296
- () => selectedCorrelation.value,
297
- () => {
298
- expanded.value = {};
299
- },
300
- );
128
+ // Reset selection + replay when the trace changes.
129
+ watch(selectedCorrelation, () => {
130
+ selectedKey.value = undefined;
131
+ restart();
132
+ });
133
+ onUnmounted(stop);
134
+
135
+ const loading = computed(() => status.value === "connecting" && records.value.length === 0);
301
136
  </script>
302
137
 
303
138
  <template>
@@ -308,185 +143,112 @@ watch(
308
143
  <div class="flex items-center gap-2 min-w-0">
309
144
  <Workflow class="w-4 h-4 text-orange-400" />
310
145
  <h1 class="text-sm font-medium truncate">Traces</h1>
311
- <span
312
- class="text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-zinc-900 text-zinc-400"
313
- >
314
- {{ status }}
315
- </span>
316
146
  </div>
317
- <button
318
- class="p-1 rounded hover:bg-zinc-800 text-zinc-400"
319
- title="Refresh"
320
- @click="refresh"
321
- >
322
- <RefreshCw class="w-3.5 h-3.5" />
323
- </button>
147
+ <StatusBadge :status="conn.status" :label="conn.label" :pulse="conn.pulse" />
324
148
  </header>
325
149
 
326
- <div class="px-3 py-2 border-b border-zinc-800">
327
- <div class="relative">
328
- <Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500" />
329
- <input
330
- v-model="filter"
331
- placeholder="filter…"
332
- 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"
333
- />
334
- </div>
335
- <div class="text-[10px] text-zinc-500 mt-1">
336
- {{ filteredCorrelations.length }} traces · {{ events.length }} events
337
- </div>
150
+ <div class="px-3 py-2 border-b border-zinc-800 text-[10px] text-zinc-500">
151
+ {{ traces.length }} traces · {{ records.length }} records
338
152
  </div>
339
153
 
340
154
  <div class="flex-1 overflow-auto">
341
- <div v-if="filteredCorrelations.length === 0" class="p-4 text-xs text-zinc-500">
342
- {{ events.length === 0 ? "Waiting for events…" : "No traces match the filter." }}
155
+ <div v-if="traces.length === 0" class="p-4 text-xs text-zinc-500">
156
+ {{ loading ? "Connecting…" : "Waiting for behavior…" }}
343
157
  <button
344
- v-if="status === 'error' || status === 'closed'"
158
+ v-if="status === 'closed'"
345
159
  class="block mt-2 text-orange-400 hover:underline"
346
- @click="reconnect"
160
+ @click="connect()"
347
161
  >
348
162
  Reconnect
349
163
  </button>
350
164
  </div>
351
165
  <button
352
- v-for="id in filteredCorrelations"
353
- :key="id"
166
+ v-for="t in traces"
167
+ :key="t.id"
354
168
  class="w-full text-left px-3 py-2 border-b border-zinc-900 hover:bg-zinc-900/50 transition-colors"
355
- :class="{ 'bg-zinc-900/70': id === selectedCorrelation }"
356
- @click="selectedCorrelation = id"
169
+ :class="{ 'bg-zinc-900/70': t.id === selectedCorrelation }"
170
+ data-testid="trace-row"
171
+ @click="selectedCorrelation = t.id"
357
172
  >
358
173
  <div class="flex items-center justify-between gap-2">
359
- <span class="font-mono text-xs text-zinc-200 truncate">
360
- {{ correlationLabel(id) }}
361
- </span>
362
- <span class="text-[10px] text-zinc-500 tabular-nums shrink-0">
363
- {{ correlationCount(id) }} ev
174
+ <span class="font-mono text-xs text-zinc-200 truncate">{{ shortId(t.id) }}</span>
175
+ <span class="flex items-center gap-2 shrink-0">
176
+ <span
177
+ v-if="t.failures"
178
+ class="text-[10px] text-rose-400 tabular-nums"
179
+ data-testid="trace-failures"
180
+ >{{ t.failures }} ✕</span
181
+ >
182
+ <span class="text-[10px] text-zinc-500 tabular-nums">{{ t.count }}</span>
364
183
  </span>
365
184
  </div>
366
185
  </button>
367
186
  </div>
368
187
  </aside>
369
188
 
370
- <!-- ── Middle: causal tree ──────────────────────────────────────── -->
189
+ <!-- ── Middle: waterfall + replay ───────────────────────────────── -->
371
190
  <main class="flex-1 flex flex-col min-w-0">
372
191
  <header class="border-b border-zinc-800 px-4 py-3 flex items-center gap-3">
373
192
  <Activity class="w-4 h-4 text-emerald-400" />
374
- <h2 class="text-sm font-medium">Causal trace</h2>
193
+ <h2 class="text-sm font-medium">Causal waterfall</h2>
375
194
  <span v-if="selectedCorrelation" class="text-xs font-mono text-zinc-500">
376
- corr {{ correlationLabel(selectedCorrelation) }}
195
+ corr {{ shortId(selectedCorrelation) }}
377
196
  </span>
378
- <span v-else class="text-xs text-zinc-500">No trace selected.</span>
197
+ <div v-if="forest.length" class="ml-auto flex items-center gap-1.5">
198
+ <button
199
+ class="p-1 rounded hover:bg-zinc-800 text-zinc-400"
200
+ :title="playing ? 'Pause' : 'Play trace'"
201
+ data-testid="replay-toggle"
202
+ @click="playing ? stop() : play()"
203
+ >
204
+ <component :is="playing ? Pause : Play" class="w-3.5 h-3.5" />
205
+ </button>
206
+ <button
207
+ class="p-1 rounded hover:bg-zinc-800 text-zinc-400"
208
+ title="Restart"
209
+ data-testid="replay-restart"
210
+ @click="restart"
211
+ >
212
+ <RotateCcw class="w-3.5 h-3.5" />
213
+ </button>
214
+ <input
215
+ type="range"
216
+ min="0"
217
+ :max="rows.length"
218
+ :value="reveal ?? rows.length"
219
+ class="w-28 accent-orange-400"
220
+ data-testid="replay-scrub"
221
+ @input="reveal = Number(($event.target as HTMLInputElement).value)"
222
+ />
223
+ </div>
379
224
  </header>
380
225
 
381
- <div class="flex-1 overflow-auto p-4">
382
- <div
383
- v-if="!selectedCorrelation || causalForest.length === 0"
384
- class="text-xs text-zinc-500 italic"
385
- >
386
- Pick a trace from the left to see its causation tree.
387
- </div>
388
- <ul v-else class="space-y-3">
389
- <li v-for="root in causalForest" :key="root.evt.envelope.messageId">
390
- <TraceNode
391
- :node="root"
392
- :depth="0"
393
- :event-index="eventIndex"
394
- :is-expanded="isExpanded"
395
- :toggle="toggle"
396
- :format-time="formatTime"
397
- :payload-preview="payloadPreview"
398
- @open-source="(s) => (sourcePreview = s)"
399
- />
400
- </li>
401
- </ul>
226
+ <div class="flex-1 overflow-auto">
227
+ <EmptyState
228
+ v-if="!selectedCorrelation || forest.length === 0"
229
+ :icon="Activity"
230
+ title="No trace selected"
231
+ hint="Pick a trace on the left to see its causation waterfall, or trigger behavior to populate the stream."
232
+ />
233
+ <Waterfall
234
+ v-else
235
+ :forest="forest"
236
+ :selected-key="selectedRecord ? (selectedKey ?? rows[0]?.key) : undefined"
237
+ :reveal="reveal"
238
+ @select="onSelect"
239
+ />
402
240
  </div>
403
241
  </main>
404
242
 
405
- <!-- ── Right: context explorer ──────────────────────────────────── -->
243
+ <!-- ── Right: metadata inspector ────────────────────────────────── -->
406
244
  <aside class="w-80 border-l border-zinc-800 flex flex-col shrink-0">
407
- <header class="border-b border-zinc-800 px-4 py-3 flex items-center gap-2">
408
- <Boxes class="w-4 h-4 text-violet-400" />
409
- <h2 class="text-sm font-medium">Context</h2>
410
- </header>
411
- <div v-if="!context" class="p-4 text-xs text-zinc-500 italic">
412
- Context appears once a trace is selected.
413
- </div>
414
- <div v-else class="overflow-auto divide-y divide-zinc-800">
415
- <section class="px-4 py-3">
416
- <h3 class="text-[10px] uppercase tracking-wide text-zinc-500 mb-2">Span</h3>
417
- <div class="flex items-center gap-2 text-xs">
418
- <Clock class="w-3.5 h-3.5 text-zinc-500" />
419
- <span class="font-mono">{{ context.spanMs }} ms</span>
420
- <span class="text-zinc-600">·</span>
421
- <span class="font-mono text-zinc-400">{{ context.total }} events</span>
422
- </div>
423
- <div class="text-[10px] text-zinc-500 font-mono mt-1">
424
- {{ formatTime(context.startedAt) }} → {{ formatTime(context.endedAt) }}
425
- </div>
426
- </section>
427
-
428
- <section class="px-4 py-3">
429
- <h3 class="text-[10px] uppercase tracking-wide text-zinc-500 mb-2">Apps</h3>
430
- <div class="flex flex-wrap gap-1">
431
- <span
432
- v-for="a in [...context.apps]"
433
- :key="a"
434
- 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"
435
- >
436
- <Globe class="w-3 h-3 text-emerald-400" />
437
- {{ a }}
438
- </span>
439
- </div>
440
- </section>
441
-
442
- <section v-if="context.uniqueEvents.size" class="px-4 py-3">
443
- <h3 class="text-[10px] uppercase tracking-wide text-zinc-500 mb-2">Distinct events</h3>
444
- <ul class="space-y-1">
445
- <li
446
- v-for="ev in [...context.uniqueEvents]"
447
- :key="ev"
448
- class="flex items-center gap-2 text-xs font-mono"
449
- >
450
- <Zap class="w-3 h-3 text-amber-400" />
451
- {{ ev }}
452
- </li>
453
- </ul>
454
- </section>
455
-
456
- <section v-if="context.external > 0" class="px-4 py-3">
457
- <h3 class="text-[10px] uppercase tracking-wide text-zinc-500 mb-2">Origin</h3>
458
- <div class="text-xs space-y-0.5">
459
- <div class="flex items-center gap-2">
460
- <Globe class="w-3 h-3 text-emerald-400" />
461
- <span class="text-zinc-400">in-process</span>
462
- <span class="ml-auto font-mono">{{ context.inProcess }}</span>
463
- </div>
464
- <div class="flex items-center gap-2">
465
- <Network class="w-3 h-3 text-violet-400" />
466
- <span class="text-zinc-400">external (bus)</span>
467
- <span class="ml-auto font-mono">{{ context.external }}</span>
468
- </div>
469
- </div>
470
- </section>
471
-
472
- <section v-if="context.tenants.size || context.users.size" class="px-4 py-3">
473
- <h3 class="text-[10px] uppercase tracking-wide text-zinc-500 mb-2">Actor / scope</h3>
474
- <div v-if="context.tenants.size" class="text-xs space-y-0.5">
475
- <div class="text-zinc-500">Tenants</div>
476
- <div v-for="t in [...context.tenants]" :key="t" class="font-mono">
477
- {{ t }}
478
- </div>
479
- </div>
480
- <div v-if="context.users.size" class="text-xs space-y-0.5 mt-2">
481
- <div class="text-zinc-500">Users</div>
482
- <div v-for="u in [...context.users]" :key="u" class="font-mono truncate">
483
- {{ u }}
484
- </div>
485
- </div>
486
- </section>
487
- </div>
245
+ <MetadataInspector
246
+ v-if="selectedRecord"
247
+ :data="selectedRecord"
248
+ :label="`Span · ${selectedRecord.kind}`"
249
+ class="h-full"
250
+ />
251
+ <div v-else class="p-4 text-xs text-zinc-500 italic">Select a span to inspect it.</div>
488
252
  </aside>
489
-
490
- <SourceDrawer :source="sourcePreview" @close="sourcePreview = null" />
491
253
  </div>
492
254
  </template>