@nwire/studio 0.12.1 → 0.13.0

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,186 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `LiveTable` — the telemetry firehose as a keyboard-navigable, sortable table.
4
+ * Presentational + behavioral: it renders the rows it's handed (already
5
+ * filtered by the page), colours each by kind, sorts on header click, and
6
+ * drives selection with ↑/↓/Enter. All data math lives in `lib/live-table`.
7
+ *
8
+ * <LiveTable :rows="rows" v-model:selectedIndex="i" @select="open" />
9
+ */
10
+ import { computed, ref, watch } from "vue";
11
+ import { ChevronUp, ChevronDown } from "lucide-vue-next";
12
+ import type { TelemetryRecord } from "@/lib/telemetry";
13
+ import {
14
+ recordSubject,
15
+ recordDuration,
16
+ recordTime,
17
+ sortRecords,
18
+ nextIndex,
19
+ shortId,
20
+ type SortKey,
21
+ type SortDir,
22
+ } from "@/lib/live-table";
23
+ import { kindColor, recordColorKey } from "@/lib/kind-colors";
24
+
25
+ const props = withDefaults(
26
+ defineProps<{
27
+ rows: readonly TelemetryRecord[];
28
+ selectedIndex?: number;
29
+ /** Initial sort column. Default `time`. */
30
+ sortKey?: SortKey;
31
+ /** Initial sort direction. Default `desc` (newest first). */
32
+ sortDir?: SortDir;
33
+ }>(),
34
+ { selectedIndex: -1, sortKey: "time", sortDir: "desc" },
35
+ );
36
+
37
+ const emit = defineEmits<{
38
+ "update:selectedIndex": [index: number];
39
+ select: [record: TelemetryRecord, index: number];
40
+ }>();
41
+
42
+ const sortKey = ref<SortKey>(props.sortKey);
43
+ const sortDir = ref<SortDir>(props.sortDir);
44
+
45
+ const sorted = computed(() => sortRecords(props.rows, sortKey.value, sortDir.value));
46
+
47
+ function toggleSort(key: SortKey): void {
48
+ if (sortKey.value === key) {
49
+ sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
50
+ } else {
51
+ sortKey.value = key;
52
+ sortDir.value = key === "subject" || key === "kind" ? "asc" : "desc";
53
+ }
54
+ }
55
+
56
+ function move(delta: number): void {
57
+ const i = nextIndex(props.selectedIndex, sorted.value.length, delta);
58
+ emit("update:selectedIndex", i);
59
+ }
60
+
61
+ function onKeydown(e: KeyboardEvent): void {
62
+ if (e.key === "ArrowDown") {
63
+ e.preventDefault();
64
+ move(1);
65
+ } else if (e.key === "ArrowUp") {
66
+ e.preventDefault();
67
+ move(-1);
68
+ } else if (e.key === "Enter") {
69
+ const r = sorted.value[props.selectedIndex];
70
+ if (r) emit("select", r, props.selectedIndex);
71
+ }
72
+ }
73
+
74
+ function choose(r: TelemetryRecord, i: number): void {
75
+ emit("update:selectedIndex", i);
76
+ emit("select", r, i);
77
+ }
78
+
79
+ function timeLabel(r: TelemetryRecord): string {
80
+ const t = recordTime(r);
81
+ return t > 0 ? new Date(t).toLocaleTimeString() : "—";
82
+ }
83
+
84
+ function durationLabel(r: TelemetryRecord): string {
85
+ const d = recordDuration(r);
86
+ return d === undefined ? "" : `${Math.round(d)}ms`;
87
+ }
88
+
89
+ // Keep the selected row in view as the selection moves by keyboard.
90
+ const rowEls = ref<HTMLElement[]>([]);
91
+ watch(
92
+ () => props.selectedIndex,
93
+ (i) => {
94
+ rowEls.value[i]?.scrollIntoView({ block: "nearest" });
95
+ },
96
+ );
97
+
98
+ const columns: { key: SortKey; label: string; align?: string }[] = [
99
+ { key: "time", label: "Time" },
100
+ { key: "kind", label: "Kind" },
101
+ { key: "subject", label: "Subject" },
102
+ { key: "duration", label: "Duration", align: "text-right" },
103
+ ];
104
+ </script>
105
+
106
+ <template>
107
+ <div
108
+ class="h-full overflow-auto outline-none focus:ring-1 focus:ring-zinc-700 rounded"
109
+ tabindex="0"
110
+ role="grid"
111
+ data-testid="live-table"
112
+ @keydown="onKeydown"
113
+ >
114
+ <table class="w-full text-sm border-collapse">
115
+ <thead class="sticky top-0 z-10 bg-zinc-950">
116
+ <tr class="border-b border-zinc-800 text-left">
117
+ <th
118
+ v-for="col in columns"
119
+ :key="col.key"
120
+ class="px-3 py-2 text-[10px] uppercase tracking-wider text-zinc-500 font-medium cursor-pointer select-none hover:text-zinc-300"
121
+ :class="col.align"
122
+ :data-testid="`col-${col.key}`"
123
+ @click="toggleSort(col.key)"
124
+ >
125
+ <span class="inline-flex items-center gap-1">
126
+ {{ col.label }}
127
+ <component
128
+ :is="sortDir === 'asc' ? ChevronUp : ChevronDown"
129
+ v-if="sortKey === col.key"
130
+ class="w-3 h-3"
131
+ />
132
+ </span>
133
+ </th>
134
+ <th class="px-3 py-2 text-[10px] uppercase tracking-wider text-zinc-500 font-medium">
135
+ Correlation
136
+ </th>
137
+ </tr>
138
+ </thead>
139
+ <tbody>
140
+ <tr
141
+ v-for="(r, i) in sorted"
142
+ :key="i"
143
+ :ref="
144
+ (el) => {
145
+ if (el) rowEls[i] = el as HTMLElement;
146
+ }
147
+ "
148
+ class="border-b border-zinc-900 cursor-pointer transition-colors"
149
+ :class="i === selectedIndex ? 'bg-zinc-900/80' : 'hover:bg-zinc-900/40'"
150
+ :aria-selected="i === selectedIndex"
151
+ role="row"
152
+ data-testid="live-row"
153
+ @click="choose(r, i)"
154
+ >
155
+ <td class="px-3 py-1.5 text-[11px] text-zinc-500 tabular-nums whitespace-nowrap">
156
+ {{ timeLabel(r) }}
157
+ </td>
158
+ <td class="px-3 py-1.5 whitespace-nowrap">
159
+ <span
160
+ class="inline-flex items-center gap-1.5 font-mono text-[11px]"
161
+ data-testid="row-kind"
162
+ >
163
+ <span
164
+ class="inline-block w-2 h-2 rounded-full shrink-0"
165
+ :style="{ backgroundColor: kindColor(recordColorKey(r.kind)) }"
166
+ />
167
+ {{ r.kind }}
168
+ </span>
169
+ </td>
170
+ <td class="px-3 py-1.5 font-mono text-[12px] text-zinc-200 max-w-[22rem] truncate">
171
+ {{ recordSubject(r) }}
172
+ <span v-if="r.appName" class="text-zinc-600 text-[10px] ml-1">{{ r.appName }}</span>
173
+ </td>
174
+ <td
175
+ class="px-3 py-1.5 text-right text-[11px] text-zinc-400 tabular-nums whitespace-nowrap"
176
+ >
177
+ {{ durationLabel(r) }}
178
+ </td>
179
+ <td class="px-3 py-1.5 font-mono text-[10px] text-zinc-600 whitespace-nowrap">
180
+ {{ shortId(r.envelope?.correlationId) }}
181
+ </td>
182
+ </tr>
183
+ </tbody>
184
+ </table>
185
+ </div>
186
+ </template>
@@ -0,0 +1,53 @@
1
+ import type { Meta, StoryObj } from "@storybook/vue3-vite";
2
+ import { expect, within } from "storybook/test";
3
+ import MetadataInspector from "./MetadataInspector.vue";
4
+
5
+ const meta: Meta<typeof MetadataInspector> = {
6
+ title: "Flow/MetadataInspector",
7
+ component: MetadataInspector,
8
+ tags: ["autodocs"],
9
+ };
10
+ export default meta;
11
+
12
+ type Story = StoryObj<typeof MetadataInspector>;
13
+
14
+ export const Span: Story = {
15
+ args: {
16
+ label: "Span · action.completed",
17
+ data: {
18
+ kind: "action.completed",
19
+ durationMs: 12.4,
20
+ envelope: {
21
+ messageId: "m-1a2b",
22
+ correlationId: "c-9f8e",
23
+ causationId: "m-1a2b",
24
+ tenant: "acme",
25
+ userId: "u-42",
26
+ },
27
+ input: { authorId: "demo", body: "hello world", flagged: false },
28
+ },
29
+ },
30
+ play: async ({ canvasElement }) => {
31
+ const c = within(canvasElement);
32
+ await expect(c.getAllByTestId("meta-row").length).toBeGreaterThan(3);
33
+ await expect(c.getByText("envelope.correlationId")).toBeInTheDocument();
34
+ },
35
+ };
36
+
37
+ export const Failure: Story = {
38
+ args: {
39
+ label: "Span · action.failed",
40
+ data: {
41
+ kind: "action.failed",
42
+ error: { name: "PaymentDeclined", message: "card declined", code: 402 },
43
+ envelope: { messageId: "m-7", correlationId: "c-9f8e", causationId: "m-1a2b" },
44
+ },
45
+ },
46
+ };
47
+
48
+ export const Empty: Story = {
49
+ args: { label: "Span", data: {} },
50
+ play: async ({ canvasElement }) => {
51
+ await expect(within(canvasElement).getByTestId("meta-empty")).toBeInTheDocument();
52
+ },
53
+ };
@@ -0,0 +1,105 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `MetadataInspector` — a flat, copyable view of a nested record. Flattens the
4
+ * value to dot-notation `key → value` rows (via `flattenMetadata`), colours each
5
+ * value by type, copies a single value on row-click, and copies the whole thing
6
+ * as JSON from the header. Used in the trace inspector for the selected span's
7
+ * envelope / payload / error.
8
+ *
9
+ * <MetadataInspector :data="record" label="Span" />
10
+ */
11
+ import { computed, ref } from "vue";
12
+ import { Copy, Check } from "lucide-vue-next";
13
+ import { flattenMetadata, type MetaEntry } from "@/lib/metadata";
14
+
15
+ const props = withDefaults(
16
+ defineProps<{
17
+ /** Any value — object, array, or primitive. */
18
+ data: unknown;
19
+ /** Optional heading. */
20
+ label?: string;
21
+ }>(),
22
+ { label: undefined },
23
+ );
24
+
25
+ const entries = computed<MetaEntry[]>(() => flattenMetadata(props.data));
26
+ const copied = ref<string | null>(null);
27
+
28
+ async function copy(text: string, key: string): Promise<void> {
29
+ try {
30
+ await navigator.clipboard?.writeText(text);
31
+ copied.value = key;
32
+ setTimeout(() => {
33
+ if (copied.value === key) copied.value = null;
34
+ }, 1200);
35
+ } catch {
36
+ /* clipboard unavailable (or denied) — copy is best-effort */
37
+ }
38
+ }
39
+
40
+ const valueClass: Record<MetaEntry["type"], string> = {
41
+ string: "text-amber-200",
42
+ number: "text-sky-300",
43
+ boolean: "text-violet-300",
44
+ null: "text-zinc-500 italic",
45
+ };
46
+
47
+ function display(e: MetaEntry): string {
48
+ return e.value === null ? "null" : String(e.value);
49
+ }
50
+ </script>
51
+
52
+ <template>
53
+ <div class="flex flex-col min-h-0" data-testid="metadata-inspector">
54
+ <div v-if="label" class="flex items-center justify-between px-3 py-2 border-b border-zinc-800">
55
+ <h3 class="text-[10px] uppercase tracking-wide text-zinc-500">{{ label }}</h3>
56
+ <button
57
+ class="inline-flex items-center gap-1 text-[10px] text-zinc-500 hover:text-zinc-300"
58
+ data-testid="meta-copy-all"
59
+ title="Copy as JSON"
60
+ @click="copy(JSON.stringify(data, null, 2), '__all__')"
61
+ >
62
+ <component :is="copied === '__all__' ? Check : Copy" class="w-3 h-3" />
63
+ JSON
64
+ </button>
65
+ </div>
66
+
67
+ <div
68
+ v-if="entries.length === 0"
69
+ class="px-3 py-4 text-xs text-zinc-600 italic"
70
+ data-testid="meta-empty"
71
+ >
72
+ No metadata.
73
+ </div>
74
+
75
+ <ul v-else class="overflow-auto divide-y divide-zinc-900/70">
76
+ <li
77
+ v-for="e in entries"
78
+ :key="e.path"
79
+ class="group flex items-baseline gap-2 px-3 py-1 hover:bg-zinc-900/50 cursor-pointer"
80
+ data-testid="meta-row"
81
+ :title="`Copy ${e.path}`"
82
+ @click="copy(display(e), e.path)"
83
+ >
84
+ <span
85
+ class="font-mono text-[11px] text-zinc-500 shrink-0 max-w-[45%] truncate"
86
+ data-testid="meta-key"
87
+ >
88
+ {{ e.path }}
89
+ </span>
90
+ <span
91
+ class="font-mono text-[11px] break-all"
92
+ :class="valueClass[e.type]"
93
+ data-testid="meta-value"
94
+ >
95
+ {{ display(e) }}
96
+ </span>
97
+ <component
98
+ :is="copied === e.path ? Check : Copy"
99
+ class="w-3 h-3 ml-auto shrink-0 self-center opacity-0 group-hover:opacity-100"
100
+ :class="copied === e.path ? 'text-emerald-400' : 'text-zinc-600'"
101
+ />
102
+ </li>
103
+ </ul>
104
+ </div>
105
+ </template>
@@ -0,0 +1,44 @@
1
+ import type { Meta, StoryObj } from "@storybook/vue3-vite";
2
+ import NodeCard from "./NodeCard.vue";
3
+ import type { BcNodeDetail } from "@/lib/bc-graph";
4
+
5
+ const detail: BcNodeDetail = {
6
+ id: "action:orders.place",
7
+ kind: "action",
8
+ name: "orders.place",
9
+ app: "orders",
10
+ description: "An authenticated customer places an order from their cart.",
11
+ public: true,
12
+ source: { file: "/examples/orders/app/actions.ts", line: 40, column: 6 },
13
+ schema: {
14
+ type: "object",
15
+ properties: { cartId: { type: "string" }, total: { type: "number" } },
16
+ required: ["cartId"],
17
+ },
18
+ emits: ["orders.placed"],
19
+ extra: { retry: 3 },
20
+ };
21
+
22
+ const meta: Meta<typeof NodeCard> = {
23
+ title: "Map/NodeCard",
24
+ component: NodeCard,
25
+ tags: ["autodocs"],
26
+ };
27
+ export default meta;
28
+
29
+ type Story = StoryObj<typeof NodeCard>;
30
+
31
+ export const Action: Story = {
32
+ render: () => ({
33
+ components: { NodeCard },
34
+ setup: () => ({ detail }),
35
+ template: `<div class="h-[480px] w-[360px] bg-zinc-950 border border-zinc-800"><NodeCard :detail="detail" /></div>`,
36
+ }),
37
+ };
38
+
39
+ export const Empty: Story = {
40
+ render: () => ({
41
+ components: { NodeCard },
42
+ template: `<div class="h-[480px] w-[360px] bg-zinc-950 border border-zinc-800"><NodeCard :detail="null" /></div>`,
43
+ }),
44
+ };
@@ -0,0 +1,150 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `NodeCard` — the Map's detail panel for a selected primitive. Leads with
4
+ * intent (kind, name, description, visibility), then the concrete surface:
5
+ * input schema, emitted events, `file:line` source, and any remaining
6
+ * kind-specific fields. Pure presentational — it takes a resolved
7
+ * `BcNodeDetail` and emits navigation intents.
8
+ */
9
+ import { computed } from "vue";
10
+ import { Globe, Lock } from "lucide-vue-next";
11
+ import type { BcNodeDetail } from "@/lib/bc-graph";
12
+ import { kindColor } from "@/lib/kind-colors";
13
+ import KindBadge from "./KindBadge.vue";
14
+ import SchemaTree from "./SchemaTree.vue";
15
+ import JsonView from "./JsonView.vue";
16
+ import SourcePill from "./SourcePill.vue";
17
+
18
+ const props = defineProps<{ detail: BcNodeDetail | null }>();
19
+
20
+ const emit = defineEmits<{
21
+ (e: "open-source", source: NonNullable<BcNodeDetail["source"]>): void;
22
+ (e: "select-event", name: string): void;
23
+ }>();
24
+
25
+ const hasExtra = computed(() => props.detail != null && Object.keys(props.detail.extra).length > 0);
26
+
27
+ /** Show a config module from its `config/` segment down — drop the absolute prefix. */
28
+ function configFileLabel(file: string): string {
29
+ const i = file.search(/(^|[/\\])config[/\\]/);
30
+ return i === -1 ? file : file.slice(i).replace(/^[/\\]/, "");
31
+ }
32
+ </script>
33
+
34
+ <template>
35
+ <div v-if="detail" class="flex flex-col h-full" data-testid="node-card">
36
+ <div class="px-4 py-3 border-b border-zinc-800">
37
+ <div class="flex items-center gap-2">
38
+ <span
39
+ class="h-2.5 w-2.5 rounded-full shrink-0"
40
+ :style="{ background: kindColor(detail.kind) }"
41
+ />
42
+ <KindBadge>{{ detail.kind }}</KindBadge>
43
+ <component
44
+ :is="detail.public ? Globe : Lock"
45
+ class="w-3.5 h-3.5 shrink-0"
46
+ :class="detail.public ? 'text-emerald-500' : 'text-zinc-600'"
47
+ :data-testid="detail.public ? 'node-public' : 'node-private'"
48
+ />
49
+ </div>
50
+ <div class="mt-1.5 font-mono text-sm text-zinc-100 break-all">{{ detail.name }}</div>
51
+ <div v-if="detail.app" class="text-[11px] text-zinc-500">{{ detail.app }}</div>
52
+ </div>
53
+
54
+ <div class="flex-1 overflow-y-auto p-4 space-y-4">
55
+ <p v-if="detail.description" class="text-sm text-zinc-300 leading-relaxed">
56
+ {{ detail.description }}
57
+ </p>
58
+
59
+ <SchemaTree v-if="detail.schema" :schema="detail.schema" label="Input schema" />
60
+
61
+ <div v-if="detail.emits?.length" class="space-y-1.5">
62
+ <h3 class="text-xs uppercase tracking-wide text-zinc-500">Emits</h3>
63
+ <div class="flex flex-wrap gap-1.5">
64
+ <button
65
+ v-for="ev in detail.emits"
66
+ :key="ev"
67
+ type="button"
68
+ class="font-mono text-[11px] px-2 py-0.5 rounded border border-zinc-800 bg-zinc-900 text-zinc-300 hover:border-zinc-700"
69
+ :data-testid="`emits-${ev}`"
70
+ @click="emit('select-event', ev)"
71
+ >
72
+ {{ ev }}
73
+ </button>
74
+ </div>
75
+ </div>
76
+
77
+ <div v-if="detail.invariants?.length" class="space-y-1.5" data-testid="business-rules">
78
+ <h3 class="text-xs uppercase tracking-wide text-zinc-500">Business rules</h3>
79
+ <ul class="space-y-2">
80
+ <li
81
+ v-for="(inv, i) in detail.invariants"
82
+ :key="i"
83
+ class="rounded border border-zinc-800 bg-zinc-900/60 px-2.5 py-2"
84
+ >
85
+ <p v-if="inv.message" class="text-sm text-zinc-200 leading-snug">{{ inv.message }}</p>
86
+ <p v-else class="text-sm italic text-zinc-500 leading-snug">
87
+ (rule with no stated message)
88
+ </p>
89
+ <code class="mt-1 block font-mono text-[11px] text-zinc-500 break-all">{{
90
+ inv.rule
91
+ }}</code>
92
+ </li>
93
+ </ul>
94
+ </div>
95
+
96
+ <div v-if="detail.env?.length" class="space-y-1.5" data-testid="environment">
97
+ <h3 class="text-xs uppercase tracking-wide text-zinc-500">Environment</h3>
98
+ <div class="flex flex-wrap gap-1.5">
99
+ <code
100
+ v-for="name in detail.env"
101
+ :key="name"
102
+ class="font-mono text-[11px] px-2 py-0.5 rounded border border-zinc-800 bg-zinc-900 text-zinc-300"
103
+ :data-testid="`env-${name}`"
104
+ >
105
+ {{ name }}
106
+ </code>
107
+ </div>
108
+ </div>
109
+
110
+ <div v-if="detail.config?.length" class="space-y-1.5" data-testid="config">
111
+ <h3 class="text-xs uppercase tracking-wide text-zinc-500">Config</h3>
112
+ <ul class="space-y-2">
113
+ <li
114
+ v-for="mod in detail.config"
115
+ :key="mod.file"
116
+ class="rounded border border-zinc-800 bg-zinc-900/60 px-2.5 py-2"
117
+ >
118
+ <code class="block font-mono text-[11px] text-zinc-400 break-all">{{
119
+ configFileLabel(mod.file)
120
+ }}</code>
121
+ <div v-if="mod.keys.length" class="mt-1.5 flex flex-wrap gap-1">
122
+ <span
123
+ v-for="k in mod.keys"
124
+ :key="k"
125
+ class="font-mono text-[11px] px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-300"
126
+ >{{ k }}</span
127
+ >
128
+ </div>
129
+ <p v-else class="mt-1 text-[11px] italic text-zinc-500">(no top-level keys found)</p>
130
+ </li>
131
+ </ul>
132
+ </div>
133
+
134
+ <JsonView v-if="hasExtra" :value="detail.extra" label="Details" />
135
+
136
+ <div v-if="detail.source">
137
+ <h3 class="text-xs uppercase tracking-wide text-zinc-500 mb-1.5">Source</h3>
138
+ <SourcePill :source="detail.source" @click="emit('open-source', detail.source!)" />
139
+ </div>
140
+ </div>
141
+ </div>
142
+
143
+ <div
144
+ v-else
145
+ class="h-full flex items-center justify-center p-8 text-center text-xs text-zinc-600"
146
+ data-testid="node-card-empty"
147
+ >
148
+ Select a primitive to inspect it.
149
+ </div>
150
+ </template>
@@ -0,0 +1,95 @@
1
+ import type { Meta, StoryObj } from "@storybook/vue3-vite";
2
+ import { expect, within, userEvent } from "storybook/test";
3
+ import RcaPanel from "./RcaPanel.vue";
4
+ import { groupIncidents } from "@/lib/rca";
5
+ import type { TelemetryRecord } from "@/lib/telemetry";
6
+
7
+ const env = (m: string, c: string) => ({
8
+ messageId: m,
9
+ causationId: c,
10
+ correlationId: "c-9f8e",
11
+ tenant: "acme",
12
+ userId: "u-42",
13
+ });
14
+
15
+ // A failed chain: dispatch → retrying action.failed → dead-lettered.
16
+ const RECORDS: TelemetryRecord[] = [
17
+ {
18
+ kind: "action.dispatched",
19
+ action: "orders.charge",
20
+ appName: "billing",
21
+ ts: "2026-01-01T00:00:00.000Z",
22
+ durationMs: 4,
23
+ envelope: env("m1", "m1"),
24
+ },
25
+ {
26
+ kind: "event.published",
27
+ event: "ChargeStarted",
28
+ appName: "billing",
29
+ ts: "2026-01-01T00:00:00.010Z",
30
+ envelope: env("m2", "m1"),
31
+ },
32
+ {
33
+ kind: "action.failed",
34
+ action: "orders.charge",
35
+ appName: "billing",
36
+ attempt: 1,
37
+ maxAttempts: 3,
38
+ willRetry: true,
39
+ durationMs: 40,
40
+ ts: "2026-01-01T00:00:00.050Z",
41
+ error: {
42
+ name: "TimeoutError",
43
+ message: "upstream timed out",
44
+ stack:
45
+ "TimeoutError: upstream timed out\n at chargeCard (apps/billing/orders.charge.ts:42:11)",
46
+ },
47
+ envelope: env("m3", "m1"),
48
+ },
49
+ {
50
+ kind: "dlq.recorded",
51
+ action: "orders.charge",
52
+ appName: "billing",
53
+ attempts: 3,
54
+ ts: "2026-01-01T00:00:00.300Z",
55
+ error: { name: "TimeoutError", message: "upstream timed out" },
56
+ envelope: env("m4", "m1"),
57
+ },
58
+ ];
59
+
60
+ const incident = groupIncidents(RECORDS)[0]!;
61
+
62
+ const meta: Meta<typeof RcaPanel> = {
63
+ title: "Errors/RcaPanel",
64
+ component: RcaPanel,
65
+ tags: ["autodocs"],
66
+ decorators: [
67
+ (story) => ({
68
+ components: { story },
69
+ template:
70
+ '<div class="h-[480px] border border-zinc-800 rounded-lg overflow-hidden"><story /></div>',
71
+ }),
72
+ ],
73
+ };
74
+ export default meta;
75
+
76
+ type Story = StoryObj<typeof RcaPanel>;
77
+
78
+ export const Default: Story = {
79
+ args: { incident, source: { file: "apps/billing/orders.charge.ts", line: 42 } },
80
+ play: async ({ canvasElement }) => {
81
+ const c = within(canvasElement);
82
+ await expect(c.getByTestId("rca-what")).toBeInTheDocument();
83
+ await expect(c.getByTestId("rca-suggestion")).toBeInTheDocument();
84
+
85
+ await userEvent.click(c.getByTestId("rca-tab-timeline"));
86
+ await expect(c.getAllByTestId("rca-timeline-row")).toHaveLength(4);
87
+
88
+ await userEvent.click(c.getByTestId("rca-tab-evidence"));
89
+ await expect(c.getByTestId("rca-evidence")).toHaveTextContent("TimeoutError");
90
+ },
91
+ };
92
+
93
+ export const WithoutSource: Story = {
94
+ args: { incident },
95
+ };