@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.
- 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,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
|
|
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)}
|
|
48
|
+
return `${prefix}${encodeURIComponent(file)}${lineSuffix}`;
|
|
48
49
|
}
|
|
49
|
-
|
|
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}
|
|
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
|
|
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
|
-
|
|
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
|
|
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>
|