@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.
- package/package.json +5 -3
- package/src/App.vue +62 -56
- package/src/components/BcCard.stories.ts +47 -0
- package/src/components/BcCard.vue +152 -0
- package/src/components/DurationBar.stories.ts +55 -0
- package/src/components/DurationBar.vue +72 -0
- package/src/components/ErrorCard.stories.ts +133 -0
- package/src/components/ErrorCard.vue +153 -0
- package/src/components/GraphCanvas.stories.ts +48 -0
- package/src/components/GraphCanvas.vue +88 -0
- package/src/components/KpiTile.stories.ts +32 -0
- package/src/components/KpiTile.vue +39 -0
- package/src/components/LiveTable.stories.ts +78 -0
- package/src/components/LiveTable.vue +186 -0
- package/src/components/MetadataInspector.stories.ts +53 -0
- package/src/components/MetadataInspector.vue +105 -0
- package/src/components/NodeCard.stories.ts +44 -0
- package/src/components/NodeCard.vue +150 -0
- package/src/components/RcaPanel.stories.ts +95 -0
- package/src/components/RcaPanel.vue +223 -0
- package/src/components/ServiceNode.vue +134 -0
- package/src/components/SourceDrawer.vue +6 -4
- package/src/components/SourcePill.vue +10 -3
- package/src/components/StatusBadge.stories.ts +33 -0
- package/src/components/StatusBadge.vue +54 -0
- package/src/components/Waterfall.stories.ts +85 -0
- package/src/components/Waterfall.vue +53 -0
- package/src/components/WaterfallRow.vue +74 -0
- package/src/components/__tests__/BcCard.test.ts +53 -0
- package/src/components/__tests__/DurationBar.test.ts +31 -0
- package/src/components/__tests__/ErrorCard.test.ts +71 -0
- package/src/components/__tests__/KpiTile.test.ts +23 -0
- package/src/components/__tests__/LiveTable.test.ts +100 -0
- package/src/components/__tests__/MetadataInspector.test.ts +38 -0
- package/src/components/__tests__/NodeCard.test.ts +116 -0
- package/src/components/__tests__/RcaPanel.test.ts +81 -0
- package/src/components/__tests__/StatusBadge.test.ts +23 -0
- package/src/components/__tests__/Waterfall.test.ts +54 -0
- package/src/components/index.ts +13 -0
- package/src/composables/__tests__/composables-context.test.ts +107 -0
- package/src/composables/__tests__/useTelemetry.test.ts +104 -0
- package/src/composables/__tests__/useTelemetryRuns.test.ts +282 -0
- package/src/composables/useDiscovery.ts +73 -0
- package/src/composables/useEndpoints.ts +94 -0
- package/src/composables/useLogTail.ts +51 -0
- package/src/composables/useManifest.ts +43 -0
- package/src/composables/useProcesses.ts +114 -0
- package/src/composables/useProject.ts +34 -0
- package/src/composables/useTelemetry.ts +270 -0
- package/src/lib/__tests__/bc-graph.test.ts +218 -0
- package/src/lib/__tests__/dispatch-form.test.ts +113 -0
- package/src/lib/__tests__/error-friendly.test.ts +198 -0
- package/src/lib/__tests__/home.test.ts +231 -0
- package/src/lib/__tests__/inspect.test.ts +160 -0
- package/src/lib/__tests__/kind-colors.test.ts +59 -0
- package/src/lib/__tests__/live-table.test.ts +194 -0
- package/src/lib/__tests__/manifest-health.test.ts +120 -0
- package/src/lib/__tests__/manifest.test.ts +87 -0
- package/src/lib/__tests__/metadata.test.ts +47 -0
- package/src/lib/__tests__/node-metrics.test.ts +144 -0
- package/src/lib/__tests__/operate.test.ts +97 -0
- package/src/lib/__tests__/pipeline-flow.test.ts +79 -0
- package/src/lib/__tests__/rca.test.ts +124 -0
- package/src/lib/__tests__/telemetry.test.ts +91 -0
- package/src/lib/__tests__/topology-graph.test.ts +331 -0
- package/src/lib/__tests__/topology-view.test.ts +154 -0
- package/src/lib/__tests__/waterfall.test.ts +165 -0
- package/src/lib/bc-graph.ts +298 -0
- package/src/lib/dispatch-form.ts +160 -0
- package/src/lib/error-friendly.ts +288 -0
- package/src/lib/home.ts +191 -0
- package/src/lib/inspect.ts +226 -0
- package/src/lib/kind-colors.ts +132 -0
- package/src/lib/live-table.ts +204 -0
- package/src/lib/manifest-health.ts +71 -0
- package/src/lib/manifest.ts +139 -0
- package/src/lib/metadata.ts +52 -0
- package/src/lib/node-metrics.ts +242 -0
- package/src/lib/operate.ts +114 -0
- package/src/lib/pipeline-flow.ts +120 -0
- package/src/lib/rca.ts +193 -0
- package/src/lib/telemetry.ts +155 -0
- package/src/lib/topology-graph.ts +551 -0
- package/src/lib/topology-view.ts +185 -0
- package/src/lib/waterfall.ts +148 -0
- package/src/main.ts +63 -29
- package/src/pages/Errors.vue +272 -0
- package/src/pages/Home.stories.ts +7 -8
- package/src/pages/Home.vue +255 -540
- package/src/pages/Hooks.stories.ts +44 -0
- package/src/pages/Hooks.vue +165 -164
- package/src/pages/Inspect.vue +240 -0
- package/src/pages/Map.vue +187 -0
- package/src/pages/Operate.vue +74 -0
- package/src/pages/Plugins.stories.ts +1 -1
- package/src/pages/Plugins.vue +174 -238
- package/src/pages/Projects.vue +62 -60
- package/src/pages/Streams.vue +344 -0
- package/src/pages/Topology.vue +318 -136
- package/src/pages/Trace.vue +174 -412
- package/src/pages/__tests__/Home.test.ts +109 -54
- package/src/pages/__tests__/Hooks.test.ts +5 -5
- package/src/pages/__tests__/Inspect.test.ts +111 -0
- package/src/pages/__tests__/Plugins.test.ts +85 -35
- package/src/pages/__tests__/Trace.test.ts +117 -0
- package/src/pages/operate/CommandsPanel.vue +186 -0
- package/src/pages/{Dispatch.vue → operate/DispatchPanel.vue} +100 -203
- package/src/pages/operate/EndpointPicker.vue +56 -0
- package/src/pages/operate/RunPanel.vue +316 -0
- package/src/server/__tests__/nwire-read.test.ts +80 -0
- package/src/server/nwire-read.ts +63 -0
- package/vite.config.ts +220 -2
- package/src/lib/__tests__/normalize-cache.test.ts +0 -105
- package/src/lib/cache.ts +0 -312
- package/src/lib/normalize-cache.ts +0 -92
- package/src/pages/Actions.vue +0 -171
- package/src/pages/Apps.vue +0 -177
- package/src/pages/Commands.vue +0 -262
- package/src/pages/Events.vue +0 -210
- package/src/pages/Live.vue +0 -249
- package/src/pages/Overview.vue +0 -161
- package/src/pages/Projections.vue +0 -148
- package/src/pages/Queries.vue +0 -148
- package/src/pages/Run.vue +0 -618
- package/src/pages/Sinks.vue +0 -124
- package/src/pages/TraceNode.vue +0 -164
- package/src/pages/Workflows.vue +0 -184
- package/src/pages/__tests__/Actions.test.ts +0 -98
- package/src/pages/__tests__/Projections.test.ts +0 -90
- 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
|
+
};
|