@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,48 +1,48 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
- * Projects catalog — every Nwire project this browser has opened
4
- * Studio for. Each card shows composition + live running status; click
5
- * a card to navigate to its Home if it's the current Studio's project,
6
- * or copy a `cd && nwire studio` snippet for the others.
3
+ * Projects catalog — every Nwire project this browser has opened Studio for.
4
+ * Each card shows composition + live running status.
5
+ *
6
+ * Discovery data (catalog + live status) comes from `useDiscovery` the same
7
+ * composable Home uses — so both pages see the same live picture without
8
+ * duplicating the poll. This page adds the management actions (forget, rescan,
9
+ * switch, copy start-command) that Home's compact strip omits.
7
10
  *
8
11
  * Snapshots live in localStorage under `nwire.projects` (one per cwd).
9
- * Live status comes from `/__nwire/projects/status` which walks each
10
- * project's `.nwire/processes/` registry — no per-project Studio needs
11
- * to be open for the dots to be accurate.
12
+ * Live status is polled from `/__nwire/projects/status`.
12
13
  */
13
14
  import { computed, onMounted, ref } from "vue";
14
15
  import { FolderOpen, Folder, Copy, Trash2, Activity, Circle, RefreshCw } from "lucide-vue-next";
15
16
  import { useRouter } from "vue-router";
16
- import {
17
- loadCatalog,
18
- forgetProject,
19
- setActiveProjectCwd,
20
- type ProjectSnapshot,
21
- } from "@/lib/project-catalog";
22
-
23
- interface ProcessRec {
24
- id: string;
25
- port?: number;
26
- pid: number;
27
- status: string;
28
- startedAt: string;
29
- }
30
- type StatusMap = Record<string, { hasManifest: boolean; processes: ProcessRec[] }>;
17
+ import { forgetProject, setActiveProjectCwd, getActiveProjectCwd } from "@/lib/project-catalog";
18
+ import { useDiscovery } from "@/composables/useDiscovery";
31
19
 
32
20
  const router = useRouter();
33
- const catalog = ref<Record<string, ProjectSnapshot>>({});
34
- const status = ref<StatusMap>({});
35
- const currentCwd = ref<string | null>(null);
21
+
22
+ // Single source of truth — catalog + live status poll shared with Home.
23
+ const { projects: discovered, refetch } = useDiscovery();
24
+
25
+ const currentCwd = ref<string | null>(getActiveProjectCwd());
36
26
  const copiedCwd = ref<string | null>(null);
37
- /** cwd → human label while a per-project `nwire cache` is in flight. */
27
+ /** cwd → label while a per-project `nwire cache` is in flight. */
38
28
  const rescanState = ref<Record<string, "running" | "done" | "error">>({});
39
29
 
40
- const projects = computed(() => {
41
- return Object.values(catalog.value).sort((a, b) => b.lastVisited.localeCompare(a.lastVisited));
42
- });
30
+ /** Stable sort: most-recently-visited first. */
31
+ const projects = computed(() =>
32
+ [...discovered.value].sort((a, b) =>
33
+ b.snapshot.lastVisited.localeCompare(a.snapshot.lastVisited),
34
+ ),
35
+ );
36
+
37
+ /** Resolve the process list for a cwd from the discovery status. */
38
+ function processList(cwd: string): Array<{ id?: string; port?: number; pid?: number }> {
39
+ const p = discovered.value.find((d) => d.cwd === cwd);
40
+ const procs = (p?.status as { processes?: Array<{ port?: number }> } | undefined)?.processes;
41
+ return Array.isArray(procs) ? procs : [];
42
+ }
43
43
 
44
44
  async function refresh() {
45
- catalog.value = loadCatalog();
45
+ currentCwd.value = getActiveProjectCwd();
46
46
  try {
47
47
  const proj = await fetch("/__nwire/project");
48
48
  if (proj.ok) {
@@ -52,21 +52,7 @@ async function refresh() {
52
52
  } catch {
53
53
  /* current project unknown — fine */
54
54
  }
55
- const cwds = Object.keys(catalog.value);
56
- if (cwds.length === 0) {
57
- status.value = {};
58
- return;
59
- }
60
- try {
61
- const res = await fetch("/__nwire/projects/status", {
62
- method: "POST",
63
- headers: { "Content-Type": "application/json" },
64
- body: JSON.stringify({ cwds }),
65
- });
66
- if (res.ok) status.value = (await res.json()) as StatusMap;
67
- } catch {
68
- status.value = {};
69
- }
55
+ void refetch();
70
56
  }
71
57
 
72
58
  function copyStartCommand(cwd: string) {
@@ -92,7 +78,8 @@ function openProject(cwd: string) {
92
78
 
93
79
  function forget(cwd: string) {
94
80
  forgetProject(cwd);
95
- catalog.value = loadCatalog();
81
+ // Trigger a refetch so the discovery query reflects the removed entry.
82
+ void refetch();
96
83
  }
97
84
 
98
85
  /**
@@ -163,14 +150,19 @@ onMounted(() => {
163
150
  v-for="p in projects"
164
151
  :key="p.cwd"
165
152
  class="border border-zinc-800 rounded-lg p-4 bg-zinc-950/40 hover:border-zinc-700 transition-colors group"
153
+ :data-cwd="p.cwd"
154
+ data-testid="project-card"
166
155
  >
167
156
  <div class="flex items-start justify-between gap-3">
168
157
  <div class="min-w-0">
169
158
  <div class="flex items-center gap-2">
170
159
  <FolderOpen v-if="p.cwd === currentCwd" class="w-4 h-4 text-emerald-400 shrink-0" />
171
160
  <Folder v-else class="w-4 h-4 text-zinc-500 shrink-0" />
172
- <h2 class="font-semibold text-sm tracking-tight truncate">
173
- {{ p.name }}
161
+ <h2
162
+ class="font-semibold text-sm tracking-tight truncate"
163
+ data-testid="project-name"
164
+ >
165
+ {{ p.snapshot.name }}
174
166
  </h2>
175
167
  <span
176
168
  v-if="p.cwd === currentCwd"
@@ -185,7 +177,8 @@ onMounted(() => {
185
177
  </div>
186
178
  <button
187
179
  class="opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-rose-400 transition-opacity p-1"
188
- :title="`Forget ${p.name}`"
180
+ :title="`Forget ${p.snapshot.name}`"
181
+ data-testid="project-forget"
189
182
  @click="forget(p.cwd)"
190
183
  >
191
184
  <Trash2 class="w-3.5 h-3.5" />
@@ -193,22 +186,26 @@ onMounted(() => {
193
186
  </div>
194
187
 
195
188
  <!-- Composition -->
196
- <div v-if="p.composition" class="grid grid-cols-4 gap-2 mt-4 text-xs text-zinc-400">
189
+ <div
190
+ v-if="p.snapshot.composition"
191
+ class="grid grid-cols-4 gap-2 mt-4 text-xs text-zinc-400"
192
+ data-testid="project-composition"
193
+ >
197
194
  <div>
198
195
  <div class="text-zinc-600 text-[10px] uppercase tracking-wide">apps</div>
199
- <div>{{ p.composition.apps }}</div>
196
+ <div>{{ p.snapshot.composition.apps }}</div>
200
197
  </div>
201
198
  <div>
202
199
  <div class="text-zinc-600 text-[10px] uppercase tracking-wide">plugins</div>
203
- <div>{{ p.composition.plugins }}</div>
200
+ <div>{{ p.snapshot.composition.plugins }}</div>
204
201
  </div>
205
202
  <div>
206
203
  <div class="text-zinc-600 text-[10px] uppercase tracking-wide">actions</div>
207
- <div>{{ p.composition.actions }}</div>
204
+ <div>{{ p.snapshot.composition.actions }}</div>
208
205
  </div>
209
206
  <div>
210
207
  <div class="text-zinc-600 text-[10px] uppercase tracking-wide">events</div>
211
- <div>{{ p.composition.events }}</div>
208
+ <div>{{ p.snapshot.composition.events }}</div>
212
209
  </div>
213
210
  </div>
214
211
  <div v-else class="mt-4 text-[11px] text-zinc-600">No composition snapshot yet.</div>
@@ -216,11 +213,15 @@ onMounted(() => {
216
213
  <!-- Running status -->
217
214
  <div class="flex items-center justify-between mt-4 pt-3 border-t border-zinc-900">
218
215
  <div class="flex items-center gap-2 text-[11px]">
219
- <template v-if="status[p.cwd]?.processes.length">
216
+ <template v-if="processList(p.cwd).length">
220
217
  <Activity class="w-3 h-3 text-emerald-400" />
221
- <span class="text-emerald-300"> {{ status[p.cwd].processes.length }} running </span>
218
+ <span class="text-emerald-300"> {{ processList(p.cwd).length }} running </span>
222
219
  <span class="text-zinc-600 font-mono">
223
- ({{ status[p.cwd].processes.map((x) => x.port ?? "?").join(", ") }})
220
+ ({{
221
+ processList(p.cwd)
222
+ .map((x) => x.port ?? "?")
223
+ .join(", ")
224
+ }})
224
225
  </span>
225
226
  </template>
226
227
  <template v-else>
@@ -232,9 +233,9 @@ onMounted(() => {
232
233
  <button
233
234
  class="text-[11px] text-zinc-500 hover:text-zinc-200 flex items-center gap-1.5 px-2 py-1 rounded border border-zinc-800 hover:border-zinc-700 disabled:opacity-50"
234
235
  :disabled="rescanState[p.cwd] === 'running'"
235
- @click="rescan(p.cwd)"
236
236
  :title="`Run nwire cache against ${p.cwd}`"
237
237
  data-testid="project-rescan"
238
+ @click="rescan(p.cwd)"
238
239
  >
239
240
  <RefreshCw
240
241
  class="w-3 h-3"
@@ -248,14 +249,15 @@ onMounted(() => {
248
249
  <button
249
250
  v-if="p.cwd !== currentCwd"
250
251
  class="text-[11px] text-zinc-500 hover:text-zinc-200 flex items-center gap-1.5 px-2 py-1 rounded border border-zinc-800 hover:border-zinc-700"
251
- @click="copyStartCommand(p.cwd)"
252
252
  title="Copy `cd … && nwire studio` to clipboard"
253
+ @click="copyStartCommand(p.cwd)"
253
254
  >
254
255
  <Copy class="w-3 h-3" />
255
256
  {{ copiedCwd === p.cwd ? "Copied!" : "Copy cmd" }}
256
257
  </button>
257
258
  <button
258
259
  class="text-[11px] text-zinc-100 bg-emerald-700 hover:bg-emerald-600 px-2 py-1 rounded"
260
+ data-testid="project-open"
259
261
  @click="openProject(p.cwd)"
260
262
  >
261
263
  {{ p.cwd === currentCwd ? "Open" : "Switch" }}
@@ -263,7 +265,7 @@ onMounted(() => {
263
265
  </div>
264
266
  </div>
265
267
  <div class="text-[10px] text-zinc-700 mt-2">
266
- last visited {{ new Date(p.lastVisited).toLocaleString() }}
268
+ last visited {{ new Date(p.snapshot.lastVisited).toLocaleString() }}
267
269
  </div>
268
270
  </article>
269
271
  </div>
@@ -0,0 +1,344 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Streams (Live) — the real-time telemetry firehose.
4
+ *
5
+ * /streams?correlationId=…&kind=…
6
+ *
7
+ * Source picker at the top lets the user switch between:
8
+ * - Live — SSE tail of the current run file.
9
+ * - A past run — static snapshot loaded from `/__nwire/telemetry/runs/:id`.
10
+ *
11
+ * In live mode: holds an SSE connection (`useTelemetry`) and shows every
12
+ * record as it lands. In history mode: loads the chosen run and freezes.
13
+ *
14
+ * - Top: a KPI strip (records · rate/s · chains · failures) + status.
15
+ * - Source picker: Live vs past-run selector.
16
+ * - Filter: free text, kind dropdown, failures-only, correlation chip.
17
+ * - Left: the firehose as a keyboard-navigable `LiveTable`.
18
+ * - Right: the selected record in a MetadataInspector.
19
+ */
20
+ import { computed, ref, shallowRef, watch, onMounted, onUnmounted } from "vue";
21
+ import { useRoute, useRouter } from "vue-router";
22
+ import { Waves, PlayCircle, PauseCircle, Trash2, X, Clock } from "lucide-vue-next";
23
+ import { useTelemetry, useRunList, type TelemetrySource } from "@/composables/useTelemetry";
24
+ import type { TelemetryRecord } from "@/lib/telemetry";
25
+ import {
26
+ filterRecords,
27
+ computeKpis,
28
+ distinctKinds,
29
+ recordSubject,
30
+ type FilterSpec,
31
+ } from "@/lib/live-table";
32
+ import {
33
+ LiveTable,
34
+ MetadataInspector,
35
+ KpiTile,
36
+ StatusBadge,
37
+ KindBadge,
38
+ FilterInput,
39
+ EmptyState,
40
+ } from "@/components";
41
+
42
+ const route = useRoute();
43
+ const router = useRouter();
44
+
45
+ const { records, status, source, clear } = useTelemetry();
46
+ const { runs } = useRunList();
47
+
48
+ // ── Source picker ─────────────────────────────────────────────────────
49
+ function selectSource(s: TelemetrySource): void {
50
+ source.value = s;
51
+ selectedIndex.value = -1;
52
+ paused.value = false;
53
+ frozen.value = [];
54
+ }
55
+
56
+ const isLive = computed(() => source.value === "live");
57
+
58
+ function isActiveRun(runId: string): boolean {
59
+ if (isLive.value) return false;
60
+ const s = source.value;
61
+ return typeof s === "object" && s !== null && s.id === runId;
62
+ }
63
+
64
+ // ── Connection state → StatusBadge ───────────────────────────────────
65
+ const conn = computed(() => {
66
+ if (!isLive.value) return { status: "idle" as const, label: "history", pulse: false };
67
+ switch (status.value) {
68
+ case "open":
69
+ return { status: "live" as const, label: "live", pulse: true };
70
+ case "connecting":
71
+ case "reconnecting":
72
+ return { status: "warn" as const, label: status.value, pulse: false };
73
+ case "closed":
74
+ return { status: "error" as const, label: "closed", pulse: false };
75
+ default:
76
+ return { status: "idle" as const, label: "idle", pulse: false };
77
+ }
78
+ });
79
+
80
+ // ── Pause: freeze a snapshot so the table stops moving ────────────────
81
+ const paused = ref(false);
82
+ const frozen = shallowRef<TelemetryRecord[]>([]);
83
+ function togglePause(): void {
84
+ paused.value = !paused.value;
85
+ if (paused.value) frozen.value = records.value.slice();
86
+ }
87
+ const displayed = computed(() => (paused.value ? frozen.value : records.value));
88
+
89
+ // ── Filters (correlation + kind are deep-linkable) ───────────────────
90
+ const text = ref("");
91
+ const kindFilter = ref<string | null>((route.query.kind as string) ?? null);
92
+ const correlationId = ref<string | null>((route.query.correlationId as string) ?? null);
93
+ const failuresOnly = ref(false);
94
+
95
+ watch([kindFilter, correlationId], ([kind, cid]) => {
96
+ void router.replace({
97
+ query: {
98
+ ...route.query,
99
+ kind: kind ?? undefined,
100
+ correlationId: cid ?? undefined,
101
+ },
102
+ });
103
+ });
104
+
105
+ const kinds = computed(() => distinctKinds(records.value));
106
+
107
+ const spec = computed<FilterSpec>(() => ({
108
+ text: text.value,
109
+ kind: kindFilter.value,
110
+ correlationId: correlationId.value,
111
+ failuresOnly: failuresOnly.value,
112
+ }));
113
+
114
+ const filtered = computed(() => filterRecords(displayed.value, spec.value));
115
+ const kpis = computed(() => computeKpis(records.value));
116
+
117
+ // ── Selection / detail ───────────────────────────────────────────────
118
+ const selectedIndex = ref(-1);
119
+ const selected = computed<TelemetryRecord | null>(
120
+ () => filtered.value[selectedIndex.value] ?? null,
121
+ );
122
+
123
+ // Reset the cursor when the filter set changes shape so it never dangles.
124
+ watch(filtered, (rows) => {
125
+ if (selectedIndex.value >= rows.length) selectedIndex.value = rows.length - 1;
126
+ });
127
+
128
+ function pickCorrelation(): void {
129
+ const cid = selected.value?.envelope?.correlationId;
130
+ if (cid) correlationId.value = cid;
131
+ }
132
+
133
+ /** The record minus the envelope (shown separately) — the kind-specific body. */
134
+ function recordBody(r: TelemetryRecord): Record<string, unknown> {
135
+ const { envelope: _e, ...rest } = r as Record<string, unknown> & { envelope?: unknown };
136
+ return rest;
137
+ }
138
+
139
+ // ── `/` focuses the filter ───────────────────────────────────────────
140
+ const filterInput = ref<HTMLElement | null>(null);
141
+ function onGlobalKey(e: KeyboardEvent): void {
142
+ const el = e.target as HTMLElement | null;
143
+ const typing = el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA");
144
+ if (e.key === "/" && !typing) {
145
+ e.preventDefault();
146
+ (filterInput.value?.querySelector("input") as HTMLInputElement | null)?.focus();
147
+ }
148
+ }
149
+ onMounted(() => window.addEventListener("keydown", onGlobalKey));
150
+ onUnmounted(() => window.removeEventListener("keydown", onGlobalKey));
151
+
152
+ // ── Run label helpers ────────────────────────────────────────────────
153
+ function formatRunId(id: string): string {
154
+ // RunId shape: 2026-06-18T20-17-43-755Z-tjeddw → show date + time portion
155
+ const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}-\d{2}-\d{2})/.exec(id);
156
+ if (m) return `${m[1]} ${m[2]!.replaceAll("-", ":")}`;
157
+ return id;
158
+ }
159
+ </script>
160
+
161
+ <template>
162
+ <div class="h-full flex flex-col" data-testid="streams-page">
163
+ <!-- Header: title + status + KPI strip + controls -->
164
+ <div class="border-b border-zinc-800 px-4 py-3 flex flex-col gap-3">
165
+ <div class="flex items-center gap-3">
166
+ <Waves class="w-5 h-5 text-sky-400 shrink-0" />
167
+ <h1 class="font-semibold text-lg">Streams</h1>
168
+ <StatusBadge :status="conn.status" :label="conn.label" :pulse="conn.pulse" />
169
+ <span class="text-xs text-zinc-500 tabular-nums ml-1">
170
+ {{ filtered.length }} / {{ kpis.total }}
171
+ </span>
172
+ <div class="ml-auto flex items-center gap-1">
173
+ <button
174
+ class="p-1.5 rounded hover:bg-zinc-800 text-zinc-400 hover:text-zinc-100"
175
+ :title="paused ? 'Resume' : 'Pause'"
176
+ data-testid="pause-toggle"
177
+ @click="togglePause"
178
+ >
179
+ <PlayCircle v-if="paused" class="w-4 h-4 text-emerald-400" />
180
+ <PauseCircle v-else class="w-4 h-4" />
181
+ </button>
182
+ <button
183
+ class="p-1.5 rounded hover:bg-zinc-800 text-zinc-400 hover:text-zinc-100"
184
+ title="Clear buffer"
185
+ data-testid="clear-buffer"
186
+ @click="
187
+ clear();
188
+ frozen = [];
189
+ selectedIndex = -1;
190
+ "
191
+ >
192
+ <Trash2 class="w-4 h-4" />
193
+ </button>
194
+ </div>
195
+ </div>
196
+
197
+ <div class="flex items-stretch gap-2">
198
+ <KpiTile label="Records" :value="kpis.total" />
199
+ <KpiTile label="Rate" :value="kpis.perSecond" sub="per second" />
200
+ <KpiTile label="Chains" :value="kpis.chains" />
201
+ <KpiTile
202
+ label="Failures"
203
+ :value="kpis.failures"
204
+ :accent="kpis.failures > 0 ? '#fb7185' : undefined"
205
+ />
206
+ </div>
207
+
208
+ <!-- Source picker: Live vs past run -->
209
+ <div class="flex items-center gap-2" data-testid="source-picker">
210
+ <button
211
+ class="h-7 px-2.5 rounded text-xs font-medium border transition-colors"
212
+ :class="
213
+ isLive
214
+ ? 'bg-sky-950/60 border-sky-800 text-sky-300'
215
+ : 'bg-zinc-900 border-zinc-800 text-zinc-400 hover:text-zinc-200'
216
+ "
217
+ data-testid="source-live"
218
+ @click="selectSource('live')"
219
+ >
220
+ Live
221
+ </button>
222
+ <template v-if="runs.length > 0">
223
+ <div class="h-4 w-px bg-zinc-800" />
224
+ <div class="flex items-center gap-1 flex-wrap">
225
+ <Clock class="w-3 h-3 text-zinc-600 shrink-0" />
226
+ <button
227
+ v-for="run in runs"
228
+ :key="run.id"
229
+ class="h-7 px-2.5 rounded text-xs border transition-colors font-mono"
230
+ :class="
231
+ isActiveRun(run.id)
232
+ ? 'bg-zinc-700 border-zinc-600 text-zinc-100'
233
+ : 'bg-zinc-900 border-zinc-800 text-zinc-500 hover:text-zinc-200'
234
+ "
235
+ :data-testid="`source-run-${run.id}`"
236
+ @click="selectSource(run)"
237
+ >
238
+ {{ formatRunId(run.id) }}
239
+ </button>
240
+ </div>
241
+ </template>
242
+ </div>
243
+
244
+ <!-- Filter row -->
245
+ <div class="flex items-center gap-2 flex-wrap">
246
+ <div ref="filterInput" class="flex-1 min-w-[16rem]">
247
+ <FilterInput
248
+ v-model="text"
249
+ placeholder="filter kind / subject / id / tenant / user… ( / )"
250
+ />
251
+ </div>
252
+ <select
253
+ v-model="kindFilter"
254
+ data-testid="kind-filter"
255
+ class="h-9 rounded bg-zinc-900 border border-zinc-800 text-sm text-zinc-300 px-2 focus:outline-none focus:border-zinc-600"
256
+ >
257
+ <option :value="null">all kinds</option>
258
+ <option v-for="k in kinds" :key="k" :value="k">{{ k }}</option>
259
+ </select>
260
+ <button
261
+ class="h-9 px-3 rounded text-sm border transition-colors"
262
+ :class="
263
+ failuresOnly
264
+ ? 'bg-rose-950/50 border-rose-900 text-rose-300'
265
+ : 'bg-zinc-900 border-zinc-800 text-zinc-400 hover:text-zinc-200'
266
+ "
267
+ data-testid="failures-only"
268
+ @click="failuresOnly = !failuresOnly"
269
+ >
270
+ failures
271
+ </button>
272
+ <button
273
+ v-if="correlationId"
274
+ class="h-9 inline-flex items-center gap-1.5 px-3 rounded text-xs bg-purple-950/50 border border-purple-900 text-purple-300 hover:bg-purple-900/50"
275
+ data-testid="clear-correlation"
276
+ @click="correlationId = null"
277
+ >
278
+ <span class="font-mono">corr {{ correlationId.split("-")[0] }}</span>
279
+ <X class="w-3 h-3" />
280
+ </button>
281
+ </div>
282
+ </div>
283
+
284
+ <!-- Body: firehose + detail -->
285
+ <div class="flex-1 flex min-h-0">
286
+ <div class="flex-1 min-w-0 border-r border-zinc-800">
287
+ <LiveTable v-if="filtered.length" :rows="filtered" v-model:selectedIndex="selectedIndex" />
288
+ <EmptyState
289
+ v-else
290
+ :icon="Waves"
291
+ :title="
292
+ isLive && kpis.total === 0
293
+ ? 'Waiting for telemetry…'
294
+ : !isLive && kpis.total === 0
295
+ ? 'No records in this run.'
296
+ : 'No records match the filter.'
297
+ "
298
+ :hint="
299
+ isLive && kpis.total === 0
300
+ ? 'Dispatch an action or emit an event — every record lands here live.'
301
+ : !isLive && kpis.total === 0
302
+ ? 'The run file is empty or contains only unserializable records.'
303
+ : 'Loosen the filter, switch kinds, or clear the correlation chip.'
304
+ "
305
+ />
306
+ </div>
307
+
308
+ <!-- Detail -->
309
+ <div class="w-[24rem] shrink-0 flex flex-col min-h-0 bg-zinc-950/40">
310
+ <div v-if="selected" class="flex flex-col min-h-0">
311
+ <div class="px-3 py-2 border-b border-zinc-800 flex items-center gap-2">
312
+ <KindBadge variant="info">{{ selected.kind }}</KindBadge>
313
+ <span class="font-mono text-xs text-zinc-300 truncate">
314
+ {{ recordSubject(selected) }}
315
+ </span>
316
+ <button
317
+ v-if="selected.envelope?.correlationId && !correlationId"
318
+ class="ml-auto text-[10px] text-purple-400 hover:text-purple-300 shrink-0"
319
+ data-testid="follow-chain"
320
+ @click="pickCorrelation"
321
+ >
322
+ follow chain →
323
+ </button>
324
+ </div>
325
+ <div class="overflow-auto min-h-0">
326
+ <MetadataInspector
327
+ v-if="selected.envelope"
328
+ :data="selected.envelope"
329
+ label="Envelope"
330
+ />
331
+ <MetadataInspector :data="recordBody(selected)" label="Record" />
332
+ </div>
333
+ </div>
334
+ <div v-else class="flex-1 grid place-items-center p-6 text-center">
335
+ <p class="text-xs text-zinc-600">
336
+ Select a record to inspect its envelope + payload.
337
+ <br />
338
+ <span class="text-zinc-700">↑ ↓ to walk · Enter to open · / to filter</span>
339
+ </p>
340
+ </div>
341
+ </div>
342
+ </div>
343
+ </div>
344
+ </template>