@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
@@ -0,0 +1,223 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `RcaPanel` — the root-cause workbench for one incident. Four tabs:
4
+ *
5
+ * • What happened — the friendly title/summary + the suggested fix, the
6
+ * failing unit, its source, and the retry/dead-letter state.
7
+ * • Timeline — every record on the chain in order, with ms offsets and
8
+ * failures flagged, so you see the cascade that led to the root cause.
9
+ * • Impact — the blast radius: tenant, user, apps, records, retries.
10
+ * • Evidence — the raw error (message + stack) and a flat, copyable
11
+ * inspector over the root record (envelope + payload).
12
+ *
13
+ * Data comes pre-shaped from `lib/rca` (incident, timeline, impact); this is a
14
+ * presentational shell. The source pill emits up so the page opens the drawer.
15
+ */
16
+ import { computed, ref } from "vue";
17
+ import { Wrench, ListTree, Radius, FileSearch, Lightbulb } from "lucide-vue-next";
18
+ import type { Incident } from "@/lib/rca";
19
+ import { buildTimeline, computeImpact } from "@/lib/rca";
20
+ import { kindColor, recordColorKey } from "@/lib/kind-colors";
21
+ import { retryNarrative } from "@/lib/error-friendly";
22
+ import MetadataInspector from "./MetadataInspector.vue";
23
+ import SourcePill from "./SourcePill.vue";
24
+ import KindBadge from "./KindBadge.vue";
25
+
26
+ const props = defineProps<{
27
+ incident: Incident;
28
+ /** Source location of the failing unit, resolved from the manifest. */
29
+ source?: { file: string; line?: number; column?: number };
30
+ }>();
31
+
32
+ const emit = defineEmits<{
33
+ (e: "open-source", source: { file: string; line?: number; column?: number }): void;
34
+ }>();
35
+
36
+ type Tab = "what" | "timeline" | "impact" | "evidence";
37
+ const tab = ref<Tab>("what");
38
+ const tabs: { id: Tab; label: string; icon: typeof Wrench }[] = [
39
+ { id: "what", label: "What happened", icon: Wrench },
40
+ { id: "timeline", label: "Timeline", icon: ListTree },
41
+ { id: "impact", label: "Impact", icon: Radius },
42
+ { id: "evidence", label: "Evidence", icon: FileSearch },
43
+ ];
44
+
45
+ const friendly = computed(() => props.incident.friendly);
46
+ const timeline = computed(() => buildTimeline(props.incident));
47
+ const impact = computed(() => computeImpact(props.incident));
48
+ const retry = computed(() => retryNarrative(props.incident.root));
49
+ // The terminal record (dead-letter) often drops the stack; prefer whichever
50
+ // failure on the chain carries one, falling back to the headline error.
51
+ const rootError = computed(
52
+ () => props.incident.failures.find((f) => f.error?.stack)?.error ?? props.incident.root.error,
53
+ );
54
+
55
+ function recordLabel(r: Record<string, unknown>): string {
56
+ return (
57
+ (r.action as string) ??
58
+ (r.projection as string) ??
59
+ (r.workflow as string) ??
60
+ (r.call as string) ??
61
+ (r.query as string) ??
62
+ (r.sourceEvent as string) ??
63
+ (r.event as string) ??
64
+ ""
65
+ );
66
+ }
67
+
68
+ function fmtOffset(ms?: number): string {
69
+ if (ms === undefined) return "";
70
+ if (ms < 1000) return `+${ms}ms`;
71
+ return `+${(ms / 1000).toFixed(2)}s`;
72
+ }
73
+ </script>
74
+
75
+ <template>
76
+ <div class="flex flex-col h-full min-h-0" data-testid="rca-panel">
77
+ <!-- tabs -->
78
+ <div class="flex border-b border-zinc-800 shrink-0">
79
+ <button
80
+ v-for="t in tabs"
81
+ :key="t.id"
82
+ class="flex items-center gap-1.5 px-3 py-2.5 text-xs border-b-2 -mb-px transition-colors"
83
+ :class="
84
+ tab === t.id
85
+ ? 'border-orange-400 text-zinc-100'
86
+ : 'border-transparent text-zinc-500 hover:text-zinc-300'
87
+ "
88
+ :data-testid="`rca-tab-${t.id}`"
89
+ @click="tab = t.id"
90
+ >
91
+ <component :is="t.icon" class="w-3.5 h-3.5" />
92
+ {{ t.label }}
93
+ </button>
94
+ </div>
95
+
96
+ <div class="flex-1 overflow-auto min-h-0">
97
+ <!-- ── What happened ──────────────────────────────────────────── -->
98
+ <section v-if="tab === 'what'" class="p-4 space-y-4" data-testid="rca-what">
99
+ <div>
100
+ <h3 class="text-base font-medium text-zinc-100">{{ friendly.title }}</h3>
101
+ <p class="mt-1 text-sm text-zinc-400 leading-relaxed">{{ friendly.summary }}</p>
102
+ </div>
103
+
104
+ <div
105
+ class="flex items-start gap-2.5 rounded-lg border border-emerald-900/50 bg-emerald-950/20 p-3"
106
+ data-testid="rca-suggestion"
107
+ >
108
+ <Lightbulb class="w-4 h-4 mt-0.5 shrink-0 text-emerald-400" />
109
+ <p class="text-xs text-emerald-200/90 leading-relaxed">{{ friendly.suggestion }}</p>
110
+ </div>
111
+
112
+ <dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
113
+ <dt class="text-zinc-500">Failing unit</dt>
114
+ <dd class="flex items-center gap-2 min-w-0">
115
+ <KindBadge variant="neutral">{{ friendly.subject.kind }}</KindBadge>
116
+ <span class="font-mono text-zinc-300 truncate">{{ friendly.subject.name }}</span>
117
+ </dd>
118
+
119
+ <template v-if="retry">
120
+ <dt class="text-zinc-500">Retries</dt>
121
+ <dd class="text-zinc-300">{{ retry }}</dd>
122
+ </template>
123
+
124
+ <template v-if="source">
125
+ <dt class="text-zinc-500">Source</dt>
126
+ <dd>
127
+ <SourcePill :source="source" compact @click="emit('open-source', source!)" />
128
+ </dd>
129
+ </template>
130
+ </dl>
131
+ </section>
132
+
133
+ <!-- ── Timeline ───────────────────────────────────────────────── -->
134
+ <section v-else-if="tab === 'timeline'" class="p-3" data-testid="rca-timeline">
135
+ <ol class="relative">
136
+ <li
137
+ v-for="(entry, i) in timeline"
138
+ :key="i"
139
+ class="flex items-center gap-3 px-1 py-1.5 rounded"
140
+ :class="entry.failure ? 'bg-rose-950/20' : ''"
141
+ data-testid="rca-timeline-row"
142
+ :data-failure="entry.failure"
143
+ >
144
+ <span class="text-[10px] tabular-nums text-zinc-600 w-14 text-right shrink-0">
145
+ {{ fmtOffset(entry.offsetMs) }}
146
+ </span>
147
+ <span
148
+ class="w-2 h-2 rounded-full shrink-0"
149
+ :style="{ backgroundColor: kindColor(recordColorKey(entry.record.kind)) }"
150
+ />
151
+ <span class="text-xs text-zinc-300 font-mono shrink-0">{{ entry.record.kind }}</span>
152
+ <span class="text-xs text-zinc-500 truncate">{{ recordLabel(entry.record) }}</span>
153
+ <span
154
+ v-if="entry.record.durationMs !== undefined"
155
+ class="ml-auto text-[10px] tabular-nums text-zinc-600 shrink-0"
156
+ >
157
+ {{ (entry.record.durationMs as number).toFixed(1) }}ms
158
+ </span>
159
+ </li>
160
+ </ol>
161
+ </section>
162
+
163
+ <!-- ── Impact ─────────────────────────────────────────────────── -->
164
+ <section v-else-if="tab === 'impact'" class="p-4 space-y-4" data-testid="rca-impact">
165
+ <div class="grid grid-cols-2 gap-2">
166
+ <div class="rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2">
167
+ <div class="text-[10px] uppercase tracking-wide text-zinc-500">Records on chain</div>
168
+ <div class="text-xl font-semibold tabular-nums text-zinc-100">{{ impact.records }}</div>
169
+ </div>
170
+ <div class="rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2">
171
+ <div class="text-[10px] uppercase tracking-wide text-zinc-500">Failures</div>
172
+ <div class="text-xl font-semibold tabular-nums text-rose-300">
173
+ {{ impact.failures }}
174
+ </div>
175
+ </div>
176
+ <div class="rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2">
177
+ <div class="text-[10px] uppercase tracking-wide text-zinc-500">Retries</div>
178
+ <div class="text-xl font-semibold tabular-nums text-zinc-100">{{ impact.retries }}</div>
179
+ </div>
180
+ <div class="rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2">
181
+ <div class="text-[10px] uppercase tracking-wide text-zinc-500">Dead-lettered</div>
182
+ <div
183
+ class="text-xl font-semibold"
184
+ :class="impact.deadLettered ? 'text-rose-300' : 'text-emerald-300'"
185
+ >
186
+ {{ impact.deadLettered ? "Yes" : "No" }}
187
+ </div>
188
+ </div>
189
+ </div>
190
+
191
+ <dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
192
+ <template v-if="impact.tenant">
193
+ <dt class="text-zinc-500">Tenant</dt>
194
+ <dd class="font-mono text-zinc-300">{{ impact.tenant }}</dd>
195
+ </template>
196
+ <template v-if="impact.userId">
197
+ <dt class="text-zinc-500">User</dt>
198
+ <dd class="font-mono text-zinc-300">{{ impact.userId }}</dd>
199
+ </template>
200
+ <dt class="text-zinc-500">Apps</dt>
201
+ <dd class="text-zinc-300">{{ impact.apps.length ? impact.apps.join(", ") : "—" }}</dd>
202
+ </dl>
203
+ </section>
204
+
205
+ <!-- ── Evidence ───────────────────────────────────────────────── -->
206
+ <section v-else class="flex flex-col min-h-0" data-testid="rca-evidence">
207
+ <div v-if="rootError" class="p-4 border-b border-zinc-800">
208
+ <div class="text-[10px] uppercase tracking-wide text-zinc-500 mb-1">Root error</div>
209
+ <div class="font-mono text-xs text-rose-300">
210
+ {{ rootError.name }}: {{ rootError.message }}
211
+ </div>
212
+ <pre
213
+ v-if="rootError.stack"
214
+ class="mt-2 text-[10px] text-zinc-500 whitespace-pre-wrap break-all max-h-40 overflow-auto"
215
+ data-testid="rca-stack"
216
+ >{{ rootError.stack }}</pre
217
+ >
218
+ </div>
219
+ <MetadataInspector :data="incident.root" label="Root record" class="flex-1 min-h-0" />
220
+ </section>
221
+ </div>
222
+ </div>
223
+ </template>
@@ -0,0 +1,134 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `ServiceNode` — one node on the Topology service map: a rounded service card
4
+ * with a kind icon, title, kind subtitle, a status dot, and a live metric row
5
+ * (throughput · errors · p50). Accent-coloured by kind. Before any run the
6
+ * metrics show a dim "—" (neutral placeholder, never a misleading zero); once
7
+ * telemetry flows they fill in and the status dot turns live/error.
8
+ *
9
+ * Pure presentational — it takes a resolved `ServiceNodeData` and the canvas
10
+ * decides selection + click. Used as a VueFlow custom node and standalone.
11
+ */
12
+ import { computed } from "vue";
13
+ import * as icons from "lucide-vue-next";
14
+ import { kindColor, kindIcon } from "@/lib/kind-colors";
15
+ import type { NodeMetrics } from "@/lib/node-metrics";
16
+ import { fmtCount, fmtLatency } from "@/lib/node-metrics";
17
+
18
+ const props = defineProps<{
19
+ id: string;
20
+ kind: string;
21
+ name: string;
22
+ /** Short kind subtitle (defaults to the kind). */
23
+ subtitle?: string;
24
+ public?: boolean;
25
+ selected?: boolean;
26
+ /** Live metrics, or undefined before any run (neutral placeholders). */
27
+ metrics?: NodeMetrics;
28
+ }>();
29
+
30
+ const color = computed(() => kindColor(props.kind));
31
+ const IconComp = computed(
32
+ () => (icons as Record<string, unknown>)[kindIcon(props.kind)] ?? icons.Circle,
33
+ );
34
+
35
+ /** Status: error if any failures, live if any throughput, else idle. */
36
+ const status = computed<"idle" | "live" | "error">(() => {
37
+ const m = props.metrics;
38
+ if (!m || m.count === 0) return "idle";
39
+ return m.errors > 0 ? "error" : "live";
40
+ });
41
+
42
+ const statusDot = computed(
43
+ () =>
44
+ ({
45
+ idle: "bg-zinc-600",
46
+ live: "bg-emerald-400",
47
+ error: "bg-rose-400",
48
+ })[status.value],
49
+ );
50
+
51
+ /** Strip the `${app}.` head so the title reads short inside its card. */
52
+ const title = computed(() => {
53
+ const dot = props.name.indexOf(".");
54
+ return dot > 0 ? props.name.slice(dot + 1) : props.name;
55
+ });
56
+ </script>
57
+
58
+ <template>
59
+ <div
60
+ class="group relative w-full rounded-xl border bg-zinc-900/95 px-3 py-2.5 shadow-sm transition-all cursor-pointer backdrop-blur"
61
+ :class="
62
+ selected
63
+ ? 'border-transparent ring-2 shadow-lg'
64
+ : 'border-zinc-800 hover:border-zinc-700 hover:shadow-md'
65
+ "
66
+ :style="selected ? { '--tw-ring-color': color } : undefined"
67
+ :data-testid="`service-node-${id}`"
68
+ >
69
+ <!-- accent rail -->
70
+ <span
71
+ class="absolute left-0 top-2 bottom-2 w-1 rounded-full"
72
+ :style="{ background: color }"
73
+ aria-hidden="true"
74
+ />
75
+
76
+ <!-- header: icon + title + status -->
77
+ <div class="flex items-center gap-2 pl-1.5">
78
+ <span
79
+ class="grid h-7 w-7 shrink-0 place-items-center rounded-lg"
80
+ :style="{ background: `${color}1f`, color }"
81
+ >
82
+ <component :is="IconComp" class="h-4 w-4" />
83
+ </span>
84
+ <div class="min-w-0 flex-1">
85
+ <div class="flex items-center gap-1.5">
86
+ <span class="truncate font-medium text-[13px] text-zinc-100" :title="name">{{
87
+ title
88
+ }}</span>
89
+ <span
90
+ v-if="public"
91
+ class="rounded bg-emerald-500/15 px-1 text-[8px] font-semibold uppercase tracking-wide text-emerald-300"
92
+ data-testid="service-node-public"
93
+ >pub</span
94
+ >
95
+ </div>
96
+ <div class="text-[10px] uppercase tracking-wide text-zinc-500">{{ subtitle ?? kind }}</div>
97
+ </div>
98
+ <span
99
+ class="h-2 w-2 shrink-0 rounded-full"
100
+ :class="[statusDot, status === 'live' ? 'animate-pulse' : '']"
101
+ :data-status="status"
102
+ :data-testid="`service-node-status-${id}`"
103
+ />
104
+ </div>
105
+
106
+ <!-- metric row -->
107
+ <div
108
+ class="mt-2 grid grid-cols-3 gap-1 border-t border-zinc-800/70 pt-1.5 pl-1.5"
109
+ data-testid="service-node-metrics"
110
+ >
111
+ <div class="flex flex-col">
112
+ <span class="font-mono text-[12px] tabular-nums text-zinc-200" data-testid="metric-count">{{
113
+ fmtCount(metrics?.count)
114
+ }}</span>
115
+ <span class="text-[8px] uppercase tracking-wide text-zinc-600">runs</span>
116
+ </div>
117
+ <div class="flex flex-col">
118
+ <span
119
+ class="font-mono text-[12px] tabular-nums"
120
+ :class="metrics && metrics.errors > 0 ? 'text-rose-300' : 'text-zinc-200'"
121
+ data-testid="metric-errors"
122
+ >{{ fmtCount(metrics?.errors) }}</span
123
+ >
124
+ <span class="text-[8px] uppercase tracking-wide text-zinc-600">errors</span>
125
+ </div>
126
+ <div class="flex flex-col">
127
+ <span class="font-mono text-[12px] tabular-nums text-zinc-200" data-testid="metric-p50">{{
128
+ fmtLatency(metrics?.p50)
129
+ }}</span>
130
+ <span class="text-[8px] uppercase tracking-wide text-zinc-600">p50</span>
131
+ </div>
132
+ </div>
133
+ </div>
134
+ </template>
@@ -17,7 +17,7 @@ import MonacoViewer from "./MonacoViewer.vue";
17
17
  import SourcePill from "./SourcePill.vue";
18
18
 
19
19
  const props = defineProps<{
20
- source?: { file: string; line: number; column?: number } | null;
20
+ source?: { file: string; line?: number; column?: number } | null;
21
21
  }>();
22
22
 
23
23
  const emit = defineEmits<{ (e: "close"): void }>();
@@ -43,10 +43,12 @@ const ideHref = computed(() => {
43
43
  if (!props.source) return undefined;
44
44
  const { file, line, column } = props.source;
45
45
  const prefix = idePrefix();
46
+ const lineSuffix = line !== undefined ? `:${line}` : "";
46
47
  if (prefix.includes("?path=")) {
47
- return `${prefix}${encodeURIComponent(file)}:${line}`;
48
+ return `${prefix}${encodeURIComponent(file)}${lineSuffix}`;
48
49
  }
49
- return `${prefix}${file}:${line}${column ? `:${column}` : ""}`;
50
+ const colSuffix = line !== undefined && column ? `:${column}` : "";
51
+ return `${prefix}${file}${lineSuffix}${colSuffix}`;
50
52
  });
51
53
 
52
54
  const content = ref<string>("");
@@ -137,7 +139,7 @@ watch(
137
139
  The file is outside the Studio process's working directory, or the path doesn't exist
138
140
  on this machine. Try the
139
141
  <a
140
- :href="`vscode://file${source.file}:${source.line}`"
142
+ :href="`vscode://file${source.file}${source.line !== undefined ? `:${source.line}` : ''}`"
141
143
  class="text-orange-400 underline"
142
144
  >
143
145
  IDE link
@@ -14,7 +14,7 @@ import { Copy, Check } from "lucide-vue-next";
14
14
  import { useCopy } from "../composables/useCopy";
15
15
 
16
16
  const props = defineProps<{
17
- source?: { file: string; line: number; column?: number };
17
+ source?: { file: string; line?: number; column?: number };
18
18
  /** Compact mode — no label, just the path:line. Default false. */
19
19
  compact?: boolean;
20
20
  }>();
@@ -31,7 +31,10 @@ const fileSegment = computed(() => {
31
31
 
32
32
  const copyText = computed(() => {
33
33
  if (!props.source) return "";
34
- return `${props.source.file}:${props.source.line}${props.source.column ? `:${props.source.column}` : ""}`;
34
+ const line = props.source.line !== undefined ? `:${props.source.line}` : "";
35
+ const col =
36
+ props.source.line !== undefined && props.source.column ? `:${props.source.column}` : "";
37
+ return `${props.source.file}${line}${col}`;
35
38
  });
36
39
 
37
40
  const onCopy = (e: Event) => {
@@ -47,6 +50,7 @@ const onCopy = (e: Event) => {
47
50
  type="button"
48
51
  class="inline-flex items-center gap-1.5 rounded-md border border-zinc-800 bg-zinc-900/50 px-2 py-1 text-xs font-mono text-zinc-400 hover:border-zinc-700 hover:text-zinc-100 hover:bg-zinc-900 transition-colors"
49
52
  :title="`Open ${copyText} in Studio's source panel`"
53
+ data-testid="source-pill"
50
54
  @click="$emit('click')"
51
55
  >
52
56
  <span
@@ -55,7 +59,10 @@ const onCopy = (e: Event) => {
55
59
  >
56
60
  source
57
61
  </span>
58
- <span>{{ fileSegment }}:{{ source.line }}</span>
62
+ <span
63
+ >{{ fileSegment
64
+ }}<template v-if="source.line !== undefined">:{{ source.line }}</template></span
65
+ >
59
66
  <span
60
67
  :title="copied ? 'Copied!' : `Copy ${copyText}`"
61
68
  class="ml-1 inline-flex items-center text-zinc-500 hover:text-zinc-300"
@@ -0,0 +1,33 @@
1
+ import type { Meta, StoryObj } from "@storybook/vue3-vite";
2
+ import StatusBadge from "./StatusBadge.vue";
3
+
4
+ const meta: Meta<typeof StatusBadge> = {
5
+ title: "Map/StatusBadge",
6
+ component: StatusBadge,
7
+ tags: ["autodocs"],
8
+ argTypes: {
9
+ status: { control: { type: "select" }, options: ["ok", "warn", "error", "idle", "live"] },
10
+ },
11
+ };
12
+ export default meta;
13
+
14
+ type Story = StoryObj<typeof StatusBadge>;
15
+
16
+ export const Default: Story = {
17
+ args: { status: "ok", label: "Healthy" },
18
+ };
19
+
20
+ export const All: Story = {
21
+ render: () => ({
22
+ components: { StatusBadge },
23
+ template: `
24
+ <div class="flex flex-col gap-2 bg-zinc-900 p-4">
25
+ <StatusBadge status="ok" label="Healthy" />
26
+ <StatusBadge status="live" label="Streaming" pulse />
27
+ <StatusBadge status="warn" label="Degraded" />
28
+ <StatusBadge status="error" label="Unreachable" />
29
+ <StatusBadge status="idle" label="Idle" />
30
+ </div>
31
+ `,
32
+ }),
33
+ };
@@ -0,0 +1,54 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `StatusBadge` — a coloured dot + label for a health/liveness state.
4
+ * `pulse` animates the dot (use for "live"/streaming states). Kept tiny and
5
+ * presentational so Map, Streams and Errors all read status the same way.
6
+ */
7
+ import { computed } from "vue";
8
+
9
+ const props = withDefaults(
10
+ defineProps<{
11
+ status: "ok" | "warn" | "error" | "idle" | "live";
12
+ label?: string;
13
+ pulse?: boolean;
14
+ }>(),
15
+ { label: undefined, pulse: false },
16
+ );
17
+
18
+ const dot = computed(
19
+ () =>
20
+ ({
21
+ ok: "bg-emerald-400",
22
+ live: "bg-emerald-400",
23
+ warn: "bg-amber-400",
24
+ error: "bg-rose-400",
25
+ idle: "bg-zinc-600",
26
+ })[props.status],
27
+ );
28
+
29
+ const text = computed(
30
+ () =>
31
+ ({
32
+ ok: "text-emerald-300",
33
+ live: "text-emerald-300",
34
+ warn: "text-amber-300",
35
+ error: "text-rose-300",
36
+ idle: "text-zinc-500",
37
+ })[props.status],
38
+ );
39
+ </script>
40
+
41
+ <template>
42
+ <span class="inline-flex items-center gap-1.5 text-xs" :class="text" data-testid="status-badge">
43
+ <span class="relative flex h-2 w-2">
44
+ <span
45
+ v-if="pulse"
46
+ class="absolute inline-flex h-full w-full rounded-full opacity-60 animate-ping"
47
+ :class="dot"
48
+ data-testid="status-pulse"
49
+ />
50
+ <span class="relative inline-flex h-2 w-2 rounded-full" :class="dot" />
51
+ </span>
52
+ <span v-if="label">{{ label }}</span>
53
+ </span>
54
+ </template>
@@ -0,0 +1,85 @@
1
+ import type { Meta, StoryObj } from "@storybook/vue3-vite";
2
+ import { expect, userEvent, within } from "storybook/test";
3
+ import Waterfall from "./Waterfall.vue";
4
+ import type { TelemetryRecord, TraceNode } from "@/lib/telemetry";
5
+
6
+ const env = (messageId: string, causationId: string) => ({
7
+ messageId,
8
+ causationId,
9
+ correlationId: "c-9f8e",
10
+ });
11
+ const t = (ms: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, 0, ms)).toISOString();
12
+ function node(record: TelemetryRecord, children: TraceNode[] = []): TraceNode {
13
+ return { record, children };
14
+ }
15
+
16
+ const forest: TraceNode[] = [
17
+ node(
18
+ {
19
+ kind: "action.dispatched",
20
+ name: "orders.place",
21
+ ts: t(0),
22
+ durationMs: 8,
23
+ envelope: env("m1", "m1"),
24
+ },
25
+ [
26
+ node(
27
+ {
28
+ kind: "query.executed",
29
+ name: "orders.lookup",
30
+ ts: t(8),
31
+ durationMs: 120,
32
+ envelope: env("m2", "m1"),
33
+ },
34
+ [
35
+ node({
36
+ kind: "event.published",
37
+ name: "orders.looked-up",
38
+ ts: t(128),
39
+ durationMs: 2,
40
+ envelope: env("m4", "m2"),
41
+ }),
42
+ ],
43
+ ),
44
+ node({
45
+ kind: "action.failed",
46
+ name: "orders.charge",
47
+ ts: t(10),
48
+ durationMs: 40,
49
+ error: { name: "E", message: "declined" },
50
+ envelope: env("m3", "m1"),
51
+ }),
52
+ ],
53
+ ),
54
+ ];
55
+
56
+ const meta: Meta<typeof Waterfall> = {
57
+ title: "Flow/Waterfall",
58
+ component: Waterfall,
59
+ tags: ["autodocs"],
60
+ render: (args) => ({
61
+ components: { Waterfall },
62
+ setup: () => ({ args }),
63
+ template: `<div class="bg-zinc-950 w-[640px]"><Waterfall v-bind="args" /></div>`,
64
+ }),
65
+ };
66
+ export default meta;
67
+
68
+ type Story = StoryObj<typeof Waterfall>;
69
+
70
+ export const Default: Story = {
71
+ args: { forest },
72
+ play: async ({ canvasElement }) => {
73
+ const c = within(canvasElement);
74
+ await expect(c.getAllByTestId("waterfall-row")).toHaveLength(4);
75
+ // The failed span is present and the critical path is highlighted.
76
+ await expect(canvasElement.querySelector('[data-failed="true"]')).toBeTruthy();
77
+ await expect(canvasElement.querySelector('[data-critical="true"]')).toBeTruthy();
78
+ // Clicking a row selects it.
79
+ await userEvent.click(c.getAllByTestId("waterfall-row")[1]!);
80
+ },
81
+ };
82
+
83
+ export const Replaying: Story = {
84
+ args: { forest, reveal: 2 },
85
+ };
@@ -0,0 +1,53 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `Waterfall` — renders a causation forest as a time-positioned waterfall:
4
+ * a span-relative axis on top, then one `WaterfallRow` per node (depth-indented,
5
+ * critical path highlighted, failures red). Layout geometry comes from
6
+ * `buildWaterfall`; this component is the chrome + selection wiring + an
7
+ * optional replay cursor (`reveal`) that dims rows past the cursor.
8
+ *
9
+ * <Waterfall :forest="forest" :selected-key="key" @select="..." />
10
+ */
11
+ import { computed } from "vue";
12
+ import { buildWaterfall, type WaterfallRow } from "@/lib/waterfall";
13
+ import type { TraceNode } from "@/lib/telemetry";
14
+ import WaterfallRowItem from "./WaterfallRow.vue";
15
+
16
+ const props = withDefaults(
17
+ defineProps<{
18
+ forest: readonly TraceNode[];
19
+ /** `key` of the selected row. */
20
+ selectedKey?: string;
21
+ /** Replay cursor — rows from this index on are dimmed. Default: all shown. */
22
+ reveal?: number;
23
+ }>(),
24
+ { selectedKey: undefined, reveal: undefined },
25
+ );
26
+
27
+ const emit = defineEmits<{ (e: "select", row: WaterfallRow): void }>();
28
+
29
+ const layout = computed(() => buildWaterfall(props.forest));
30
+ const revealCount = computed(() => props.reveal ?? layout.value.rows.length);
31
+ </script>
32
+
33
+ <template>
34
+ <div class="flex flex-col min-h-0" data-testid="waterfall">
35
+ <!-- Axis: 0 → total span -->
36
+ <div class="flex items-center text-[10px] text-zinc-600 px-2 pb-1 border-b border-zinc-800/70">
37
+ <span class="w-1/2">{{ layout.rows.length }} spans</span>
38
+ <span class="flex-1 text-left">0ms</span>
39
+ <span class="w-14 text-right tabular-nums">{{ layout.spanMs.toFixed(0) }}ms</span>
40
+ </div>
41
+
42
+ <div class="overflow-auto divide-y divide-zinc-900/50">
43
+ <WaterfallRowItem
44
+ v-for="(row, i) in layout.rows"
45
+ :key="row.key"
46
+ :row="row"
47
+ :selected="row.key === selectedKey"
48
+ :dimmed="i >= revealCount"
49
+ @select="emit('select', $event)"
50
+ />
51
+ </div>
52
+ </div>
53
+ </template>