@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,297 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Hooks — the unified extension-point primitive every nwire substrate now
4
+ * collapses onto. Each row in `cache.hooks` is one named hook with its
5
+ * chain + listener counts and the source location of `hook("name")`.
6
+ *
7
+ * Detail pane shows steady-state metadata + the live tap stream (last-N
8
+ * step observations) for the selected hook, fed by the running wire's
9
+ * `/_nwire/events/stream` channel under `kind: "hook.step"`.
10
+ */
11
+ import { computed, onMounted, onUnmounted, ref, watch } from "vue";
12
+ import { useRoute, useRouter } from "vue-router";
13
+ import { useCache, type HookEntry } from "@/lib/cache";
14
+ import { Activity, Boxes, Puzzle, RefreshCw, Search } from "lucide-vue-next";
15
+ import { SourcePill, SourceDrawer } from "@/components";
16
+
17
+ interface StepRecord {
18
+ readonly kind: "hook.step";
19
+ readonly hookName: string;
20
+ readonly hookId: string;
21
+ readonly runId: string;
22
+ readonly parentRunId?: string;
23
+ readonly stepId: number;
24
+ readonly stepKind: "chain" | "listener";
25
+ readonly stepName?: string;
26
+ readonly phase: "start" | "end" | "error";
27
+ readonly durationMs?: number;
28
+ readonly error?: { message?: string };
29
+ readonly ts: string;
30
+ }
31
+
32
+ const route = useRoute();
33
+ const router = useRouter();
34
+ const { cache } = useCache();
35
+ const filter = ref("");
36
+ const selected = ref<string | null>(null);
37
+ const sourcePreview = ref<{ file: string; line: number; column?: number } | null>(null);
38
+
39
+ function applyQueryPreselect(): void {
40
+ const name = route.query.name;
41
+ if (typeof name !== "string" || name.length === 0) return;
42
+ // Hooks select by id; query is by hook name. First name match wins.
43
+ const found = cache.value?.hooks.find((h) => h.name === name);
44
+ if (found) selected.value = found.id;
45
+ }
46
+
47
+ onMounted(applyQueryPreselect);
48
+ watch(() => route.query.name, applyQueryPreselect);
49
+ watch(() => cache.value, applyQueryPreselect);
50
+
51
+ // ── Live tap stream ───────────────────────────────────────────────────
52
+ const liveSteps = ref<StepRecord[]>([]);
53
+ const streamStatus = ref<"connecting" | "open" | "closed" | "error">("connecting");
54
+ let es: EventSource | null = null;
55
+
56
+ onMounted(() => {
57
+ connect();
58
+ });
59
+ onUnmounted(() => {
60
+ es?.close();
61
+ });
62
+
63
+ function connect(): void {
64
+ streamStatus.value = "connecting";
65
+ es?.close();
66
+ es = new EventSource("/_nwire/telemetry/stream");
67
+ es.onopen = () => {
68
+ streamStatus.value = "open";
69
+ };
70
+ es.onerror = () => {
71
+ streamStatus.value = "error";
72
+ };
73
+ es.onmessage = (m) => {
74
+ try {
75
+ const rec = JSON.parse(m.data) as { kind?: string };
76
+ if (rec.kind !== "hook.step") return;
77
+ liveSteps.value.push(rec as StepRecord);
78
+ if (liveSteps.value.length > 500) liveSteps.value.splice(0, 200);
79
+ } catch {
80
+ /* ignore non-JSON frames */
81
+ }
82
+ };
83
+ }
84
+
85
+ const filteredHooks = computed<HookEntry[]>(() => {
86
+ if (!cache.value) return [];
87
+ const q = filter.value.toLowerCase();
88
+ return cache.value.hooks.filter(
89
+ (h) => !q || h.name.toLowerCase().includes(q) || h.id.toLowerCase().includes(q),
90
+ );
91
+ });
92
+
93
+ const detail = computed<HookEntry | null>(
94
+ () => cache.value?.hooks.find((h) => h.id === selected.value) ?? null,
95
+ );
96
+
97
+ const detailSteps = computed<StepRecord[]>(() => {
98
+ if (!detail.value) return [];
99
+ return liveSteps.value
100
+ .filter((s) => s.hookName === detail.value!.name)
101
+ .slice(-50)
102
+ .reverse();
103
+ });
104
+
105
+ /**
106
+ * Parse `plugin.boot:<name>` / `plugin.shutdown:<name>` → `<name>`.
107
+ * Used by the "Registered by" cross-link to /plugins?name=…
108
+ */
109
+ const registeringPlugin = computed<string | null>(() => {
110
+ const n = detail.value?.name;
111
+ if (!n) return null;
112
+ for (const prefix of ["plugin.boot:", "plugin.shutdown:"]) {
113
+ if (n.startsWith(prefix)) return n.slice(prefix.length);
114
+ }
115
+ return null;
116
+ });
117
+
118
+ watch(detail, () => {
119
+ /* allow new selection to redraw */
120
+ });
121
+ </script>
122
+
123
+ <template>
124
+ <div v-if="cache" class="h-full flex flex-col" data-testid="hooks-page">
125
+ <!-- Header -->
126
+ <div class="p-6 pb-3 border-b border-zinc-800 flex items-center justify-between">
127
+ <div>
128
+ <div class="flex items-center gap-2">
129
+ <Boxes class="w-4 h-4 text-orange-400" />
130
+ <h1 class="text-lg font-medium">Hooks</h1>
131
+ <span class="text-[10px] text-zinc-500">
132
+ {{ filteredHooks.length }} / {{ cache.hooks.length }}
133
+ </span>
134
+ <span
135
+ class="ml-2 text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-zinc-900 text-zinc-400"
136
+ data-testid="stream-status"
137
+ >
138
+ stream {{ streamStatus }}
139
+ </span>
140
+ </div>
141
+ <p class="text-xs text-zinc-500 mt-1">
142
+ Every extension point — middleware, lifecycle, listeners — built on one named hook. Live
143
+ taps stream from the running wire.
144
+ </p>
145
+ </div>
146
+ <button
147
+ class="p-1 rounded hover:bg-zinc-800 text-zinc-400"
148
+ title="Reconnect stream"
149
+ @click="connect"
150
+ >
151
+ <RefreshCw class="w-3.5 h-3.5" />
152
+ </button>
153
+ </div>
154
+
155
+ <!-- Master / detail -->
156
+ <div class="flex-1 flex min-h-0">
157
+ <!-- Master -->
158
+ <aside class="w-80 border-r border-zinc-800 flex flex-col shrink-0">
159
+ <div class="px-3 py-2 border-b border-zinc-800">
160
+ <div class="relative">
161
+ <Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500" />
162
+ <input
163
+ v-model="filter"
164
+ placeholder="filter…"
165
+ 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"
166
+ />
167
+ </div>
168
+ </div>
169
+
170
+ <div v-if="filteredHooks.length === 0" class="p-4 text-xs text-zinc-500">
171
+ {{
172
+ cache.hooks.length === 0
173
+ ? "No hooks in cache. Did you rebuild after adding hook() calls?"
174
+ : "No hooks match the filter."
175
+ }}
176
+ </div>
177
+ <div v-else class="flex-1 overflow-auto">
178
+ <button
179
+ v-for="h in filteredHooks"
180
+ :key="h.id"
181
+ class="w-full text-left px-3 py-2 border-b border-zinc-900 hover:bg-zinc-900/50 transition-colors"
182
+ :class="{ 'bg-zinc-900/70': h.id === selected }"
183
+ @click="selected = h.id"
184
+ >
185
+ <div class="font-mono text-xs text-zinc-100 truncate">{{ h.name }}</div>
186
+ <div class="text-[10px] text-zinc-500 tabular-nums mt-0.5 flex gap-2">
187
+ <span>chain {{ h.chain }}</span>
188
+ <span>·</span>
189
+ <span>listeners {{ h.listeners }}</span>
190
+ </div>
191
+ </button>
192
+ </div>
193
+ </aside>
194
+
195
+ <!-- Detail -->
196
+ <main class="flex-1 flex flex-col min-w-0">
197
+ <div v-if="!detail" class="p-8 text-sm text-zinc-500 italic">
198
+ Select a hook to view its surface + live tap stream.
199
+ </div>
200
+ <div v-else class="flex-1 flex flex-col overflow-hidden">
201
+ <div class="px-6 py-5 border-b border-zinc-800">
202
+ <div class="flex items-center gap-3 flex-wrap">
203
+ <h2 class="font-mono text-xl">{{ detail.name }}</h2>
204
+ <span class="text-[10px] text-zinc-500 font-mono">{{ detail.id }}</span>
205
+ <button
206
+ v-if="detail.source"
207
+ type="button"
208
+ class="ml-auto inline-flex items-center"
209
+ @click="sourcePreview = detail.source!"
210
+ >
211
+ <SourcePill :source="detail.source" />
212
+ </button>
213
+ </div>
214
+ <div class="text-[11px] text-zinc-500 mt-2 flex gap-4 tabular-nums">
215
+ <span>{{ detail.chain }} chain step{{ detail.chain === 1 ? "" : "s" }}</span>
216
+ <span>{{ detail.listeners }} listener{{ detail.listeners === 1 ? "" : "s" }}</span>
217
+ </div>
218
+ <div v-if="registeringPlugin" class="mt-2 flex items-center gap-2 text-xs">
219
+ <span class="text-zinc-500">Registered by</span>
220
+ <button
221
+ type="button"
222
+ class="inline-flex items-center gap-1 font-mono text-fuchsia-300 hover:underline"
223
+ :data-testid="`plugin-link-${registeringPlugin}`"
224
+ @click="router.push({ path: '/plugins', query: { name: registeringPlugin } })"
225
+ >
226
+ <Puzzle class="w-3 h-3" />
227
+ {{ registeringPlugin }}
228
+ </button>
229
+ </div>
230
+ </div>
231
+
232
+ <!-- Live tap stream -->
233
+ <div class="flex-1 overflow-auto">
234
+ <div class="px-6 py-3 border-b border-zinc-900 flex items-center gap-2">
235
+ <Activity class="w-3.5 h-3.5 text-emerald-400" />
236
+ <h3 class="text-xs uppercase tracking-wide text-zinc-400">Live taps</h3>
237
+ <span class="text-[10px] text-zinc-500 tabular-nums">
238
+ {{ detailSteps.length }} recent
239
+ </span>
240
+ </div>
241
+ <div
242
+ v-if="detailSteps.length === 0"
243
+ class="p-6 text-xs text-zinc-500 italic"
244
+ data-testid="hooks-empty-tap"
245
+ >
246
+ No tap data yet. Trigger an action through the running wire and observations will
247
+ appear here.
248
+ </div>
249
+ <ul v-else class="divide-y divide-zinc-900" data-testid="hooks-tap-list">
250
+ <li
251
+ v-for="(s, i) in detailSteps"
252
+ :key="`${s.runId}:${s.stepId}:${s.phase}:${i}`"
253
+ class="px-6 py-1.5 flex items-center gap-3 font-mono text-[11px] hover:bg-zinc-900/30"
254
+ >
255
+ <span class="text-zinc-600 tabular-nums w-20 shrink-0">
256
+ {{ new Date(s.ts).toLocaleTimeString(undefined, { hour12: false }) }}
257
+ </span>
258
+ <span
259
+ class="w-12 text-[10px] uppercase tracking-wide shrink-0"
260
+ :class="{
261
+ 'text-zinc-400': s.phase === 'start',
262
+ 'text-emerald-400': s.phase === 'end',
263
+ 'text-red-400': s.phase === 'error',
264
+ }"
265
+ >{{ s.phase }}</span
266
+ >
267
+ <span
268
+ class="w-16 text-[10px] uppercase tracking-wide shrink-0"
269
+ :class="s.stepKind === 'chain' ? 'text-cyan-400' : 'text-amber-400'"
270
+ >{{ s.stepKind }}</span
271
+ >
272
+ <span class="text-zinc-200 truncate flex-1">
273
+ {{ s.stepName ?? `#${s.stepId}` }}
274
+ </span>
275
+ <span v-if="s.durationMs !== undefined" class="text-zinc-500 tabular-nums shrink-0">
276
+ {{ s.durationMs.toFixed(1) }} ms
277
+ </span>
278
+ <span
279
+ v-if="s.error"
280
+ class="text-red-400 truncate max-w-40 shrink-0"
281
+ :title="s.error.message"
282
+ >
283
+ {{ s.error.message }}
284
+ </span>
285
+ <span class="text-zinc-600 text-[10px] tabular-nums shrink-0">
286
+ run {{ s.runId.split("-")[0] }}
287
+ </span>
288
+ </li>
289
+ </ul>
290
+ </div>
291
+ </div>
292
+ </main>
293
+ </div>
294
+
295
+ <SourceDrawer :source="sourcePreview" @close="sourcePreview = null" />
296
+ </div>
297
+ </template>
@@ -0,0 +1,249 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted, onUnmounted } from "vue";
3
+ import {
4
+ Radio,
5
+ PlayCircle,
6
+ PauseCircle,
7
+ Trash2,
8
+ Filter,
9
+ Network,
10
+ Globe,
11
+ Search,
12
+ } from "lucide-vue-next";
13
+
14
+ interface BufferedEvent {
15
+ seq: number;
16
+ eventName: string;
17
+ payload: unknown;
18
+ envelope: {
19
+ messageId: string;
20
+ correlationId: string;
21
+ causationId: string;
22
+ tenant?: string;
23
+ userId?: string;
24
+ timestamp: string;
25
+ version: number;
26
+ };
27
+ source: "in-process" | "external";
28
+ appName: string;
29
+ capturedAt: string;
30
+ }
31
+
32
+ const events = ref<BufferedEvent[]>([]);
33
+ const paused = ref(false);
34
+ const filter = ref("");
35
+ const selectedCorrelation = ref<string | null>(null);
36
+ const status = ref<"connecting" | "open" | "closed" | "error">("connecting");
37
+ let es: EventSource | null = null;
38
+
39
+ onMounted(() => {
40
+ connect();
41
+ });
42
+
43
+ onUnmounted(() => {
44
+ es?.close();
45
+ });
46
+
47
+ function connect() {
48
+ status.value = "connecting";
49
+ es?.close();
50
+ es = new EventSource("/_nwire/events/stream");
51
+ es.onopen = () => {
52
+ status.value = "open";
53
+ };
54
+ es.onerror = () => {
55
+ status.value = "error";
56
+ };
57
+ es.onmessage = (msg) => {
58
+ if (paused.value) return;
59
+ try {
60
+ const evt = JSON.parse(msg.data) as BufferedEvent;
61
+ events.value.unshift(evt);
62
+ if (events.value.length > 1000) events.value.pop();
63
+ } catch (err) {
64
+ console.error("bad event", err);
65
+ }
66
+ };
67
+ }
68
+
69
+ function reconnect() {
70
+ connect();
71
+ }
72
+
73
+ function clear() {
74
+ events.value = [];
75
+ }
76
+
77
+ const filtered = computed(() => {
78
+ const q = filter.value.toLowerCase();
79
+ return events.value.filter((e) => {
80
+ if (selectedCorrelation.value && e.envelope.correlationId !== selectedCorrelation.value) {
81
+ return false;
82
+ }
83
+ if (!q) return true;
84
+ return (
85
+ e.eventName.toLowerCase().includes(q) ||
86
+ e.envelope.messageId.toLowerCase().includes(q) ||
87
+ e.envelope.correlationId.toLowerCase().includes(q) ||
88
+ (e.envelope.tenant?.toLowerCase().includes(q) ?? false) ||
89
+ (e.envelope.userId?.toLowerCase().includes(q) ?? false)
90
+ );
91
+ });
92
+ });
93
+
94
+ const selectedTrace = computed(() => {
95
+ if (!selectedCorrelation.value) return [];
96
+ return events.value
97
+ .filter((e) => e.envelope.correlationId === selectedCorrelation.value)
98
+ .sort((a, b) => a.seq - b.seq);
99
+ });
100
+
101
+ function shortId(id: string): string {
102
+ return id.split("-")[0] ?? id.slice(0, 8);
103
+ }
104
+ </script>
105
+
106
+ <template>
107
+ <div class="h-full flex">
108
+ <!-- Stream column -->
109
+ <div class="flex-1 flex flex-col border-r border-zinc-800 min-w-0">
110
+ <div class="border-b border-zinc-800 px-4 py-3 flex items-center justify-between gap-3">
111
+ <div class="flex items-center gap-3 min-w-0">
112
+ <Radio
113
+ class="w-5 h-5 shrink-0"
114
+ :class="{
115
+ 'text-emerald-400 animate-pulse': status === 'open' && !paused,
116
+ 'text-zinc-400': paused,
117
+ 'text-amber-400': status === 'connecting',
118
+ 'text-rose-400': status === 'error' || status === 'closed',
119
+ }"
120
+ />
121
+ <h1 class="font-semibold text-lg truncate">Live</h1>
122
+ <span
123
+ class="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-zinc-900 text-zinc-400"
124
+ >
125
+ {{ status }}
126
+ </span>
127
+ <span class="text-xs text-zinc-500 tabular-nums"
128
+ >{{ filtered.length }} / {{ events.length }}</span
129
+ >
130
+ </div>
131
+ <div class="flex items-center gap-1 shrink-0">
132
+ <button
133
+ class="p-1.5 rounded hover:bg-zinc-800 text-zinc-400 hover:text-zinc-100"
134
+ :title="paused ? 'Resume' : 'Pause'"
135
+ @click="paused = !paused"
136
+ >
137
+ <PlayCircle v-if="paused" class="w-4 h-4 text-emerald-400" />
138
+ <PauseCircle v-else class="w-4 h-4" />
139
+ </button>
140
+ <button
141
+ class="p-1.5 rounded hover:bg-zinc-800 text-zinc-400 hover:text-zinc-100"
142
+ title="Clear"
143
+ @click="clear"
144
+ >
145
+ <Trash2 class="w-4 h-4" />
146
+ </button>
147
+ <button
148
+ v-if="status === 'error' || status === 'closed'"
149
+ class="px-2 py-1 rounded text-xs bg-zinc-800 hover:bg-zinc-700"
150
+ @click="reconnect"
151
+ >
152
+ Reconnect
153
+ </button>
154
+ </div>
155
+ </div>
156
+
157
+ <div class="px-4 py-2 border-b border-zinc-800 flex items-center gap-2">
158
+ <div class="relative flex-1">
159
+ <Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500" />
160
+ <input
161
+ v-model="filter"
162
+ placeholder="filter event name / id / tenant / user…"
163
+ class="w-full bg-zinc-900 border border-zinc-800 rounded pl-7 pr-2 py-1.5 text-sm placeholder:text-zinc-600 focus:outline-none focus:border-zinc-600"
164
+ />
165
+ </div>
166
+ <button
167
+ v-if="selectedCorrelation"
168
+ class="text-xs px-2 py-1 rounded bg-purple-950/50 border border-purple-900 text-purple-300 hover:bg-purple-900/50"
169
+ @click="selectedCorrelation = null"
170
+ >
171
+ <Filter class="w-3 h-3 inline mr-1" />
172
+ clear trace filter
173
+ </button>
174
+ </div>
175
+
176
+ <div class="flex-1 overflow-auto">
177
+ <div v-if="filtered.length === 0" class="p-6 text-sm text-zinc-500">
178
+ {{ events.length === 0 ? "Waiting for events…" : "No events match the filter." }}
179
+ </div>
180
+ <button
181
+ v-for="evt in filtered"
182
+ :key="evt.seq"
183
+ class="w-full text-left px-4 py-2 border-b border-zinc-900 hover:bg-zinc-900/50 transition-colors"
184
+ :class="{
185
+ 'bg-zinc-900/70':
186
+ selectedCorrelation && evt.envelope.correlationId === selectedCorrelation,
187
+ }"
188
+ @click="selectedCorrelation = evt.envelope.correlationId"
189
+ >
190
+ <div class="flex items-center justify-between gap-3">
191
+ <div class="flex items-center gap-2 min-w-0">
192
+ <component
193
+ :is="evt.source === 'external' ? Network : Globe"
194
+ class="w-3 h-3 shrink-0"
195
+ :class="evt.source === 'external' ? 'text-purple-400' : 'text-emerald-400'"
196
+ />
197
+ <span class="font-mono text-sm truncate">{{ evt.eventName }}</span>
198
+ <span class="text-[10px] text-zinc-500 shrink-0">{{ evt.appName }}</span>
199
+ </div>
200
+ <span class="text-[10px] text-zinc-500 tabular-nums shrink-0">
201
+ {{ new Date(evt.capturedAt).toLocaleTimeString() }}
202
+ </span>
203
+ </div>
204
+ <div class="text-[10px] text-zinc-500 font-mono mt-0.5 truncate">
205
+ msg {{ shortId(evt.envelope.messageId) }} · corr
206
+ {{ shortId(evt.envelope.correlationId) }}
207
+ <span v-if="evt.envelope.tenant">· tenant {{ evt.envelope.tenant }}</span>
208
+ <span v-if="evt.envelope.userId">· user {{ evt.envelope.userId }}</span>
209
+ </div>
210
+ </button>
211
+ </div>
212
+ </div>
213
+
214
+ <!-- Trace column -->
215
+ <div class="w-1/2 flex flex-col">
216
+ <div class="border-b border-zinc-800 px-4 py-3">
217
+ <h2 class="text-sm font-medium tracking-tight">
218
+ {{ selectedCorrelation ? "Trace" : "Details" }}
219
+ </h2>
220
+ <p v-if="selectedCorrelation" class="text-[10px] text-zinc-500 font-mono mt-0.5">
221
+ corr {{ selectedCorrelation }}
222
+ </p>
223
+ <p v-else class="text-xs text-zinc-500 mt-0.5">Click an event to see its causation tree.</p>
224
+ </div>
225
+ <div class="flex-1 overflow-auto p-4 space-y-2">
226
+ <div v-for="(evt, i) in selectedTrace" :key="evt.seq" class="relative pl-4">
227
+ <div
228
+ class="absolute left-1 top-0 bottom-0 w-px bg-zinc-800"
229
+ v-if="i < selectedTrace.length - 1"
230
+ ></div>
231
+ <div class="absolute left-0 top-2.5 w-2 h-2 rounded-full bg-emerald-400"></div>
232
+ <div class="rounded border border-zinc-800 bg-zinc-900/40 p-3">
233
+ <div class="flex items-center justify-between mb-1">
234
+ <span class="font-mono text-sm">{{ evt.eventName }}</span>
235
+ <span class="text-[10px] text-zinc-500">{{ evt.appName }}</span>
236
+ </div>
237
+ <div class="text-[10px] text-zinc-500 font-mono mb-2">
238
+ msg {{ shortId(evt.envelope.messageId) }} ← caused by
239
+ {{ shortId(evt.envelope.causationId) }}
240
+ </div>
241
+ <pre class="text-[11px] bg-zinc-950 border border-zinc-800 rounded p-2 overflow-auto">{{
242
+ JSON.stringify(evt.payload, null, 2)
243
+ }}</pre>
244
+ </div>
245
+ </div>
246
+ </div>
247
+ </div>
248
+ </div>
249
+ </template>