@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
package/src/pages/Trace.vue
CHANGED
|
@@ -1,303 +1,138 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
* Trace —
|
|
3
|
+
* Flow / Trace — the live causation waterfall.
|
|
4
4
|
*
|
|
5
5
|
* /trace?correlationId=…
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* Subscribes to the telemetry stream (`useTelemetry`), groups records by
|
|
8
|
+
* correlation, and renders the selected chain as a time-positioned waterfall
|
|
9
|
+
* (tree depth + critical path + kind colours). The right rail is a flat,
|
|
10
|
+
* copyable metadata inspector for the selected span. A replay control scrubs
|
|
11
|
+
* the chain so you can watch a trace unfold span by span.
|
|
11
12
|
*
|
|
12
|
-
* - Left: trace picker — recent
|
|
13
|
-
*
|
|
14
|
-
* - Middle: the
|
|
15
|
-
*
|
|
16
|
-
* - Right: context explorer — modules touched, apps participating,
|
|
17
|
-
* unique events fired, total span, error count. The "who
|
|
18
|
-
* touched this trace?" answer.
|
|
13
|
+
* - Left: trace picker — recent correlations, newest first, with span +
|
|
14
|
+
* failure counts. Connection state lives in the header.
|
|
15
|
+
* - Middle: the waterfall + replay (play / pause / restart / scrub).
|
|
16
|
+
* - Right: metadata inspector for the selected span.
|
|
19
17
|
*/
|
|
20
|
-
import { computed,
|
|
18
|
+
import { computed, ref, watch, onUnmounted } from "vue";
|
|
21
19
|
import { useRoute, useRouter } from "vue-router";
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
Network,
|
|
28
|
-
RefreshCw,
|
|
29
|
-
Search,
|
|
30
|
-
Workflow,
|
|
31
|
-
Zap,
|
|
32
|
-
} from "lucide-vue-next";
|
|
33
|
-
import { useCache } from "@/lib/cache";
|
|
34
|
-
import { SourceDrawer } from "@/components";
|
|
35
|
-
import TraceNode from "./TraceNode.vue";
|
|
36
|
-
|
|
37
|
-
interface BufferedEvent {
|
|
38
|
-
seq: number;
|
|
39
|
-
eventName?: string;
|
|
40
|
-
payload: unknown;
|
|
41
|
-
envelope: {
|
|
42
|
-
messageId: string;
|
|
43
|
-
correlationId: string;
|
|
44
|
-
causationId: string;
|
|
45
|
-
tenant?: string;
|
|
46
|
-
userId?: string;
|
|
47
|
-
timestamp: string;
|
|
48
|
-
version: number;
|
|
49
|
-
};
|
|
50
|
-
source: "in-process" | "external";
|
|
51
|
-
appName: string;
|
|
52
|
-
capturedAt: string;
|
|
53
|
-
}
|
|
20
|
+
import { Activity, Play, Pause, RotateCcw, Workflow } from "lucide-vue-next";
|
|
21
|
+
import { useTelemetry } from "@/composables/useTelemetry";
|
|
22
|
+
import { buildCorrelationTree, isFailure, type TelemetryRecord } from "@/lib/telemetry";
|
|
23
|
+
import { buildWaterfall, type WaterfallRow } from "@/lib/waterfall";
|
|
24
|
+
import { StatusBadge, Waterfall, MetadataInspector, EmptyState } from "@/components";
|
|
54
25
|
|
|
55
26
|
const route = useRoute();
|
|
56
27
|
const router = useRouter();
|
|
57
|
-
const { cache } = useCache();
|
|
58
|
-
|
|
59
|
-
const events = ref<BufferedEvent[]>([]);
|
|
60
|
-
const status = ref<"connecting" | "open" | "closed" | "error">("connecting");
|
|
61
|
-
const expanded = ref<Record<string, boolean>>({});
|
|
62
|
-
const filter = ref("");
|
|
63
|
-
const sourcePreview = ref<{ file: string; line: number; column?: number } | null>(null);
|
|
64
28
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* `?action=<name>` seeds the search filter on first paint so the operator
|
|
82
|
-
* lands on traces whose events were emitted by (or named after) the action.
|
|
83
|
-
* It is a non-destructive seed — the user can clear it. The existing
|
|
84
|
-
* `?correlationId` deep-link path is untouched and still wins selection.
|
|
85
|
-
*/
|
|
86
|
-
function applyActionFilter(): void {
|
|
87
|
-
const action = route.query.action;
|
|
88
|
-
if (typeof action === "string" && action.length > 0) {
|
|
89
|
-
filter.value = action;
|
|
29
|
+
const { records, status, byCorrelation, connect } = useTelemetry();
|
|
30
|
+
|
|
31
|
+
// ── Connection state → StatusBadge ───────────────────────────────────
|
|
32
|
+
const conn = computed(() => {
|
|
33
|
+
switch (status.value) {
|
|
34
|
+
case "open":
|
|
35
|
+
return { status: "live" as const, label: "live", pulse: true };
|
|
36
|
+
case "connecting":
|
|
37
|
+
case "reconnecting":
|
|
38
|
+
return { status: "warn" as const, label: status.value, pulse: false };
|
|
39
|
+
case "closed":
|
|
40
|
+
return { status: "error" as const, label: "closed", pulse: false };
|
|
41
|
+
default:
|
|
42
|
+
return { status: "idle" as const, label: "idle", pulse: false };
|
|
90
43
|
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
onMounted(() => {
|
|
94
|
-
applyActionFilter();
|
|
95
|
-
void start();
|
|
96
44
|
});
|
|
97
|
-
watch(() => route.query.action, applyActionFilter);
|
|
98
|
-
onUnmounted(() => es?.close());
|
|
99
45
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
46
|
+
// ── Correlations, newest first ───────────────────────────────────────
|
|
47
|
+
interface TraceSummary {
|
|
48
|
+
readonly id: string;
|
|
49
|
+
readonly count: number;
|
|
50
|
+
readonly failures: number;
|
|
51
|
+
readonly lastIndex: number;
|
|
103
52
|
}
|
|
104
53
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
54
|
+
const traces = computed<TraceSummary[]>(() => {
|
|
55
|
+
const lastIndex = new Map<string, number>();
|
|
56
|
+
records.value.forEach((r, i) => {
|
|
57
|
+
const cid = r.envelope?.correlationId;
|
|
58
|
+
if (cid) lastIndex.set(cid, i);
|
|
59
|
+
});
|
|
60
|
+
const out: TraceSummary[] = [];
|
|
61
|
+
for (const [id, recs] of byCorrelation.value) {
|
|
62
|
+
if (!id) continue; // skip the envelope-less bucket
|
|
63
|
+
out.push({
|
|
64
|
+
id,
|
|
65
|
+
count: recs.length,
|
|
66
|
+
failures: recs.filter(isFailure).length,
|
|
67
|
+
lastIndex: lastIndex.get(id) ?? -1,
|
|
68
|
+
});
|
|
113
69
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
function connectStream() {
|
|
117
|
-
status.value = "connecting";
|
|
118
|
-
es?.close();
|
|
119
|
-
es = new EventSource("/_nwire/events/stream");
|
|
120
|
-
es.onopen = () => {
|
|
121
|
-
status.value = "open";
|
|
122
|
-
};
|
|
123
|
-
es.onerror = () => {
|
|
124
|
-
status.value = "error";
|
|
125
|
-
};
|
|
126
|
-
es.onmessage = (m) => {
|
|
127
|
-
try {
|
|
128
|
-
const evt = JSON.parse(m.data) as BufferedEvent;
|
|
129
|
-
events.value.push(evt);
|
|
130
|
-
if (events.value.length > 2000) events.value.splice(0, 500);
|
|
131
|
-
} catch {
|
|
132
|
-
/* ignore */
|
|
133
|
-
}
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function reconnect() {
|
|
138
|
-
connectStream();
|
|
139
|
-
}
|
|
140
|
-
function refresh() {
|
|
141
|
-
void loadRecent();
|
|
142
|
-
}
|
|
70
|
+
return out.sort((a, b) => b.lastIndex - a.lastIndex);
|
|
71
|
+
});
|
|
143
72
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
for (const e of events.value) {
|
|
148
|
-
seen.set(e.envelope.correlationId, e.seq);
|
|
149
|
-
}
|
|
150
|
-
return Array.from(seen.entries())
|
|
151
|
-
.sort((a, b) => b[1] - a[1])
|
|
152
|
-
.map(([id]) => id);
|
|
73
|
+
const selectedCorrelation = computed<string | undefined>({
|
|
74
|
+
get: () => (route.query.correlationId as string | undefined) ?? traces.value[0]?.id,
|
|
75
|
+
set: (id) => router.replace({ path: "/trace", query: id ? { correlationId: id } : {} }),
|
|
153
76
|
});
|
|
154
77
|
|
|
155
|
-
const
|
|
156
|
-
const
|
|
157
|
-
if (!
|
|
158
|
-
return
|
|
159
|
-
if (id.toLowerCase().includes(q)) return true;
|
|
160
|
-
// Filter on event names + tenant within the trace too.
|
|
161
|
-
const traceEvents = events.value.filter((e) => e.envelope.correlationId === id);
|
|
162
|
-
return traceEvents.some(
|
|
163
|
-
(e) =>
|
|
164
|
-
e.eventName?.toLowerCase().includes(q) ||
|
|
165
|
-
e.envelope.tenant?.toLowerCase().includes(q) ||
|
|
166
|
-
e.envelope.userId?.toLowerCase().includes(q),
|
|
167
|
-
);
|
|
168
|
-
});
|
|
78
|
+
const forest = computed(() => {
|
|
79
|
+
const id = selectedCorrelation.value;
|
|
80
|
+
if (!id) return [];
|
|
81
|
+
return buildCorrelationTree(byCorrelation.value.get(id) ?? []);
|
|
169
82
|
});
|
|
170
83
|
|
|
171
|
-
function
|
|
84
|
+
function shortId(id: string): string {
|
|
172
85
|
return id.split("-")[0] ?? id.slice(0, 8);
|
|
173
86
|
}
|
|
174
|
-
function correlationCount(id: string): number {
|
|
175
|
-
let n = 0;
|
|
176
|
-
for (const e of events.value) if (e.envelope.correlationId === id) n++;
|
|
177
|
-
return n;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// ── The causal tree for the selected correlation ─────────────────────
|
|
181
|
-
interface TraceNode {
|
|
182
|
-
evt: BufferedEvent;
|
|
183
|
-
children: TraceNode[];
|
|
184
|
-
}
|
|
185
87
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const causalForest = computed<TraceNode[]>(() => {
|
|
194
|
-
const evts = selectedTraceEvents.value;
|
|
195
|
-
if (evts.length === 0) return [];
|
|
196
|
-
const byMsg = new Map<string, TraceNode>();
|
|
197
|
-
for (const evt of evts) byMsg.set(evt.envelope.messageId, { evt, children: [] });
|
|
198
|
-
|
|
199
|
-
const roots: TraceNode[] = [];
|
|
200
|
-
for (const node of byMsg.values()) {
|
|
201
|
-
const cid = node.evt.envelope.causationId;
|
|
202
|
-
if (!cid || cid === node.evt.envelope.messageId) {
|
|
203
|
-
roots.push(node);
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
const parent = byMsg.get(cid);
|
|
207
|
-
if (parent) parent.children.push(node);
|
|
208
|
-
else roots.push(node);
|
|
209
|
-
}
|
|
210
|
-
return roots;
|
|
88
|
+
// ── Selected span → inspector ────────────────────────────────────────
|
|
89
|
+
const selectedKey = ref<string | undefined>(undefined);
|
|
90
|
+
const rows = computed(() => buildWaterfall(forest.value).rows);
|
|
91
|
+
const selectedRecord = computed<TelemetryRecord | undefined>(() => {
|
|
92
|
+
const rs = rows.value;
|
|
93
|
+
return (rs.find((r) => r.key === selectedKey.value) ?? rs[0])?.record;
|
|
211
94
|
});
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const eventIndex = computed(() => {
|
|
215
|
-
const m = new Map<
|
|
216
|
-
string,
|
|
217
|
-
{ app: string; source?: { file: string; line: number; column?: number } }
|
|
218
|
-
>();
|
|
219
|
-
if (cache.value) {
|
|
220
|
-
for (const e of cache.value.events) {
|
|
221
|
-
m.set(e.name, { app: e.app, source: e.source });
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
return m;
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
// ── Context explorer ─────────────────────────────────────────────────
|
|
228
|
-
interface ContextDigest {
|
|
229
|
-
apps: Set<string>;
|
|
230
|
-
uniqueEvents: Set<string>;
|
|
231
|
-
startedAt: string;
|
|
232
|
-
endedAt: string;
|
|
233
|
-
spanMs: number;
|
|
234
|
-
total: number;
|
|
235
|
-
inProcess: number;
|
|
236
|
-
external: number;
|
|
237
|
-
tenants: Set<string>;
|
|
238
|
-
users: Set<string>;
|
|
95
|
+
function onSelect(row: WaterfallRow): void {
|
|
96
|
+
selectedKey.value = row.key;
|
|
239
97
|
}
|
|
240
98
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const unique = new Set<string>();
|
|
246
|
-
const tenants = new Set<string>();
|
|
247
|
-
const users = new Set<string>();
|
|
248
|
-
let inProc = 0;
|
|
249
|
-
let external = 0;
|
|
99
|
+
// ── Replay scrub ─────────────────────────────────────────────────────
|
|
100
|
+
const reveal = ref<number | undefined>(undefined); // undefined = show all
|
|
101
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
102
|
+
const playing = ref(false);
|
|
250
103
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
if (e.envelope.tenant) tenants.add(e.envelope.tenant);
|
|
257
|
-
if (e.envelope.userId) users.add(e.envelope.userId);
|
|
258
|
-
if (e.source === "external") external++;
|
|
259
|
-
else inProc++;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const startedAt = evts[0]!.capturedAt;
|
|
263
|
-
const endedAt = evts[evts.length - 1]!.capturedAt;
|
|
264
|
-
const spanMs = new Date(endedAt).getTime() - new Date(startedAt).getTime();
|
|
265
|
-
|
|
266
|
-
return {
|
|
267
|
-
apps,
|
|
268
|
-
uniqueEvents: unique,
|
|
269
|
-
startedAt,
|
|
270
|
-
endedAt,
|
|
271
|
-
spanMs,
|
|
272
|
-
total: evts.length,
|
|
273
|
-
inProcess: inProc,
|
|
274
|
-
external,
|
|
275
|
-
tenants,
|
|
276
|
-
users,
|
|
277
|
-
};
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
function isExpanded(id: string): boolean {
|
|
281
|
-
return expanded.value[id] !== false; // default expanded
|
|
282
|
-
}
|
|
283
|
-
function toggle(id: string): void {
|
|
284
|
-
expanded.value[id] = !isExpanded(id);
|
|
104
|
+
function stop(): void {
|
|
105
|
+
if (timer) clearInterval(timer);
|
|
106
|
+
timer = null;
|
|
107
|
+
playing.value = false;
|
|
285
108
|
}
|
|
286
|
-
function
|
|
287
|
-
|
|
109
|
+
function play(): void {
|
|
110
|
+
stop();
|
|
111
|
+
const total = rows.value.length;
|
|
112
|
+
if (total === 0) return;
|
|
113
|
+
reveal.value = reveal.value && reveal.value < total ? reveal.value : 0;
|
|
114
|
+
playing.value = true;
|
|
115
|
+
timer = setInterval(() => {
|
|
116
|
+
reveal.value = (reveal.value ?? 0) + 1;
|
|
117
|
+
if ((reveal.value ?? 0) >= total) {
|
|
118
|
+
reveal.value = undefined; // settle to "all"
|
|
119
|
+
stop();
|
|
120
|
+
}
|
|
121
|
+
}, 350);
|
|
288
122
|
}
|
|
289
|
-
function
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
return s.length > 80 ? s.slice(0, 80) + "…" : s;
|
|
123
|
+
function restart(): void {
|
|
124
|
+
stop();
|
|
125
|
+
reveal.value = undefined;
|
|
293
126
|
}
|
|
294
127
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
);
|
|
128
|
+
// Reset selection + replay when the trace changes.
|
|
129
|
+
watch(selectedCorrelation, () => {
|
|
130
|
+
selectedKey.value = undefined;
|
|
131
|
+
restart();
|
|
132
|
+
});
|
|
133
|
+
onUnmounted(stop);
|
|
134
|
+
|
|
135
|
+
const loading = computed(() => status.value === "connecting" && records.value.length === 0);
|
|
301
136
|
</script>
|
|
302
137
|
|
|
303
138
|
<template>
|
|
@@ -308,185 +143,112 @@ watch(
|
|
|
308
143
|
<div class="flex items-center gap-2 min-w-0">
|
|
309
144
|
<Workflow class="w-4 h-4 text-orange-400" />
|
|
310
145
|
<h1 class="text-sm font-medium truncate">Traces</h1>
|
|
311
|
-
<span
|
|
312
|
-
class="text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-zinc-900 text-zinc-400"
|
|
313
|
-
>
|
|
314
|
-
{{ status }}
|
|
315
|
-
</span>
|
|
316
146
|
</div>
|
|
317
|
-
<
|
|
318
|
-
class="p-1 rounded hover:bg-zinc-800 text-zinc-400"
|
|
319
|
-
title="Refresh"
|
|
320
|
-
@click="refresh"
|
|
321
|
-
>
|
|
322
|
-
<RefreshCw class="w-3.5 h-3.5" />
|
|
323
|
-
</button>
|
|
147
|
+
<StatusBadge :status="conn.status" :label="conn.label" :pulse="conn.pulse" />
|
|
324
148
|
</header>
|
|
325
149
|
|
|
326
|
-
<div class="px-3 py-2 border-b border-zinc-800">
|
|
327
|
-
|
|
328
|
-
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500" />
|
|
329
|
-
<input
|
|
330
|
-
v-model="filter"
|
|
331
|
-
placeholder="filter…"
|
|
332
|
-
class="w-full bg-zinc-900 border border-zinc-800 rounded pl-7 pr-2 py-1 text-xs placeholder:text-zinc-600 focus:outline-none focus:border-zinc-600"
|
|
333
|
-
/>
|
|
334
|
-
</div>
|
|
335
|
-
<div class="text-[10px] text-zinc-500 mt-1">
|
|
336
|
-
{{ filteredCorrelations.length }} traces · {{ events.length }} events
|
|
337
|
-
</div>
|
|
150
|
+
<div class="px-3 py-2 border-b border-zinc-800 text-[10px] text-zinc-500">
|
|
151
|
+
{{ traces.length }} traces · {{ records.length }} records
|
|
338
152
|
</div>
|
|
339
153
|
|
|
340
154
|
<div class="flex-1 overflow-auto">
|
|
341
|
-
<div v-if="
|
|
342
|
-
{{
|
|
155
|
+
<div v-if="traces.length === 0" class="p-4 text-xs text-zinc-500">
|
|
156
|
+
{{ loading ? "Connecting…" : "Waiting for behavior…" }}
|
|
343
157
|
<button
|
|
344
|
-
v-if="status === '
|
|
158
|
+
v-if="status === 'closed'"
|
|
345
159
|
class="block mt-2 text-orange-400 hover:underline"
|
|
346
|
-
@click="
|
|
160
|
+
@click="connect()"
|
|
347
161
|
>
|
|
348
162
|
Reconnect
|
|
349
163
|
</button>
|
|
350
164
|
</div>
|
|
351
165
|
<button
|
|
352
|
-
v-for="
|
|
353
|
-
:key="id"
|
|
166
|
+
v-for="t in traces"
|
|
167
|
+
:key="t.id"
|
|
354
168
|
class="w-full text-left px-3 py-2 border-b border-zinc-900 hover:bg-zinc-900/50 transition-colors"
|
|
355
|
-
:class="{ 'bg-zinc-900/70': id === selectedCorrelation }"
|
|
356
|
-
|
|
169
|
+
:class="{ 'bg-zinc-900/70': t.id === selectedCorrelation }"
|
|
170
|
+
data-testid="trace-row"
|
|
171
|
+
@click="selectedCorrelation = t.id"
|
|
357
172
|
>
|
|
358
173
|
<div class="flex items-center justify-between gap-2">
|
|
359
|
-
<span class="font-mono text-xs text-zinc-200 truncate">
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
174
|
+
<span class="font-mono text-xs text-zinc-200 truncate">{{ shortId(t.id) }}</span>
|
|
175
|
+
<span class="flex items-center gap-2 shrink-0">
|
|
176
|
+
<span
|
|
177
|
+
v-if="t.failures"
|
|
178
|
+
class="text-[10px] text-rose-400 tabular-nums"
|
|
179
|
+
data-testid="trace-failures"
|
|
180
|
+
>{{ t.failures }} ✕</span
|
|
181
|
+
>
|
|
182
|
+
<span class="text-[10px] text-zinc-500 tabular-nums">{{ t.count }}</span>
|
|
364
183
|
</span>
|
|
365
184
|
</div>
|
|
366
185
|
</button>
|
|
367
186
|
</div>
|
|
368
187
|
</aside>
|
|
369
188
|
|
|
370
|
-
<!-- ── Middle:
|
|
189
|
+
<!-- ── Middle: waterfall + replay ───────────────────────────────── -->
|
|
371
190
|
<main class="flex-1 flex flex-col min-w-0">
|
|
372
191
|
<header class="border-b border-zinc-800 px-4 py-3 flex items-center gap-3">
|
|
373
192
|
<Activity class="w-4 h-4 text-emerald-400" />
|
|
374
|
-
<h2 class="text-sm font-medium">Causal
|
|
193
|
+
<h2 class="text-sm font-medium">Causal waterfall</h2>
|
|
375
194
|
<span v-if="selectedCorrelation" class="text-xs font-mono text-zinc-500">
|
|
376
|
-
corr {{
|
|
195
|
+
corr {{ shortId(selectedCorrelation) }}
|
|
377
196
|
</span>
|
|
378
|
-
<
|
|
197
|
+
<div v-if="forest.length" class="ml-auto flex items-center gap-1.5">
|
|
198
|
+
<button
|
|
199
|
+
class="p-1 rounded hover:bg-zinc-800 text-zinc-400"
|
|
200
|
+
:title="playing ? 'Pause' : 'Play trace'"
|
|
201
|
+
data-testid="replay-toggle"
|
|
202
|
+
@click="playing ? stop() : play()"
|
|
203
|
+
>
|
|
204
|
+
<component :is="playing ? Pause : Play" class="w-3.5 h-3.5" />
|
|
205
|
+
</button>
|
|
206
|
+
<button
|
|
207
|
+
class="p-1 rounded hover:bg-zinc-800 text-zinc-400"
|
|
208
|
+
title="Restart"
|
|
209
|
+
data-testid="replay-restart"
|
|
210
|
+
@click="restart"
|
|
211
|
+
>
|
|
212
|
+
<RotateCcw class="w-3.5 h-3.5" />
|
|
213
|
+
</button>
|
|
214
|
+
<input
|
|
215
|
+
type="range"
|
|
216
|
+
min="0"
|
|
217
|
+
:max="rows.length"
|
|
218
|
+
:value="reveal ?? rows.length"
|
|
219
|
+
class="w-28 accent-orange-400"
|
|
220
|
+
data-testid="replay-scrub"
|
|
221
|
+
@input="reveal = Number(($event.target as HTMLInputElement).value)"
|
|
222
|
+
/>
|
|
223
|
+
</div>
|
|
379
224
|
</header>
|
|
380
225
|
|
|
381
|
-
<div class="flex-1 overflow-auto
|
|
382
|
-
<
|
|
383
|
-
v-if="!selectedCorrelation ||
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
Pick a trace
|
|
387
|
-
|
|
388
|
-
<
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
:toggle="toggle"
|
|
396
|
-
:format-time="formatTime"
|
|
397
|
-
:payload-preview="payloadPreview"
|
|
398
|
-
@open-source="(s) => (sourcePreview = s)"
|
|
399
|
-
/>
|
|
400
|
-
</li>
|
|
401
|
-
</ul>
|
|
226
|
+
<div class="flex-1 overflow-auto">
|
|
227
|
+
<EmptyState
|
|
228
|
+
v-if="!selectedCorrelation || forest.length === 0"
|
|
229
|
+
:icon="Activity"
|
|
230
|
+
title="No trace selected"
|
|
231
|
+
hint="Pick a trace on the left to see its causation waterfall, or trigger behavior to populate the stream."
|
|
232
|
+
/>
|
|
233
|
+
<Waterfall
|
|
234
|
+
v-else
|
|
235
|
+
:forest="forest"
|
|
236
|
+
:selected-key="selectedRecord ? (selectedKey ?? rows[0]?.key) : undefined"
|
|
237
|
+
:reveal="reveal"
|
|
238
|
+
@select="onSelect"
|
|
239
|
+
/>
|
|
402
240
|
</div>
|
|
403
241
|
</main>
|
|
404
242
|
|
|
405
|
-
<!-- ── Right:
|
|
243
|
+
<!-- ── Right: metadata inspector ────────────────────────────────── -->
|
|
406
244
|
<aside class="w-80 border-l border-zinc-800 flex flex-col shrink-0">
|
|
407
|
-
<
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
<div v-else class="overflow-auto divide-y divide-zinc-800">
|
|
415
|
-
<section class="px-4 py-3">
|
|
416
|
-
<h3 class="text-[10px] uppercase tracking-wide text-zinc-500 mb-2">Span</h3>
|
|
417
|
-
<div class="flex items-center gap-2 text-xs">
|
|
418
|
-
<Clock class="w-3.5 h-3.5 text-zinc-500" />
|
|
419
|
-
<span class="font-mono">{{ context.spanMs }} ms</span>
|
|
420
|
-
<span class="text-zinc-600">·</span>
|
|
421
|
-
<span class="font-mono text-zinc-400">{{ context.total }} events</span>
|
|
422
|
-
</div>
|
|
423
|
-
<div class="text-[10px] text-zinc-500 font-mono mt-1">
|
|
424
|
-
{{ formatTime(context.startedAt) }} → {{ formatTime(context.endedAt) }}
|
|
425
|
-
</div>
|
|
426
|
-
</section>
|
|
427
|
-
|
|
428
|
-
<section class="px-4 py-3">
|
|
429
|
-
<h3 class="text-[10px] uppercase tracking-wide text-zinc-500 mb-2">Apps</h3>
|
|
430
|
-
<div class="flex flex-wrap gap-1">
|
|
431
|
-
<span
|
|
432
|
-
v-for="a in [...context.apps]"
|
|
433
|
-
:key="a"
|
|
434
|
-
class="inline-flex items-center gap-1 text-xs font-mono bg-zinc-900 border border-zinc-800 rounded px-1.5 py-0.5"
|
|
435
|
-
>
|
|
436
|
-
<Globe class="w-3 h-3 text-emerald-400" />
|
|
437
|
-
{{ a }}
|
|
438
|
-
</span>
|
|
439
|
-
</div>
|
|
440
|
-
</section>
|
|
441
|
-
|
|
442
|
-
<section v-if="context.uniqueEvents.size" class="px-4 py-3">
|
|
443
|
-
<h3 class="text-[10px] uppercase tracking-wide text-zinc-500 mb-2">Distinct events</h3>
|
|
444
|
-
<ul class="space-y-1">
|
|
445
|
-
<li
|
|
446
|
-
v-for="ev in [...context.uniqueEvents]"
|
|
447
|
-
:key="ev"
|
|
448
|
-
class="flex items-center gap-2 text-xs font-mono"
|
|
449
|
-
>
|
|
450
|
-
<Zap class="w-3 h-3 text-amber-400" />
|
|
451
|
-
{{ ev }}
|
|
452
|
-
</li>
|
|
453
|
-
</ul>
|
|
454
|
-
</section>
|
|
455
|
-
|
|
456
|
-
<section v-if="context.external > 0" class="px-4 py-3">
|
|
457
|
-
<h3 class="text-[10px] uppercase tracking-wide text-zinc-500 mb-2">Origin</h3>
|
|
458
|
-
<div class="text-xs space-y-0.5">
|
|
459
|
-
<div class="flex items-center gap-2">
|
|
460
|
-
<Globe class="w-3 h-3 text-emerald-400" />
|
|
461
|
-
<span class="text-zinc-400">in-process</span>
|
|
462
|
-
<span class="ml-auto font-mono">{{ context.inProcess }}</span>
|
|
463
|
-
</div>
|
|
464
|
-
<div class="flex items-center gap-2">
|
|
465
|
-
<Network class="w-3 h-3 text-violet-400" />
|
|
466
|
-
<span class="text-zinc-400">external (bus)</span>
|
|
467
|
-
<span class="ml-auto font-mono">{{ context.external }}</span>
|
|
468
|
-
</div>
|
|
469
|
-
</div>
|
|
470
|
-
</section>
|
|
471
|
-
|
|
472
|
-
<section v-if="context.tenants.size || context.users.size" class="px-4 py-3">
|
|
473
|
-
<h3 class="text-[10px] uppercase tracking-wide text-zinc-500 mb-2">Actor / scope</h3>
|
|
474
|
-
<div v-if="context.tenants.size" class="text-xs space-y-0.5">
|
|
475
|
-
<div class="text-zinc-500">Tenants</div>
|
|
476
|
-
<div v-for="t in [...context.tenants]" :key="t" class="font-mono">
|
|
477
|
-
{{ t }}
|
|
478
|
-
</div>
|
|
479
|
-
</div>
|
|
480
|
-
<div v-if="context.users.size" class="text-xs space-y-0.5 mt-2">
|
|
481
|
-
<div class="text-zinc-500">Users</div>
|
|
482
|
-
<div v-for="u in [...context.users]" :key="u" class="font-mono truncate">
|
|
483
|
-
{{ u }}
|
|
484
|
-
</div>
|
|
485
|
-
</div>
|
|
486
|
-
</section>
|
|
487
|
-
</div>
|
|
245
|
+
<MetadataInspector
|
|
246
|
+
v-if="selectedRecord"
|
|
247
|
+
:data="selectedRecord"
|
|
248
|
+
:label="`Span · ${selectedRecord.kind}`"
|
|
249
|
+
class="h-full"
|
|
250
|
+
/>
|
|
251
|
+
<div v-else class="p-4 text-xs text-zinc-500 italic">Select a span to inspect it.</div>
|
|
488
252
|
</aside>
|
|
489
|
-
|
|
490
|
-
<SourceDrawer :source="sourcePreview" @close="sourcePreview = null" />
|
|
491
253
|
</div>
|
|
492
254
|
</template>
|