@kopai/ui 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/index.cjs +2427 -1139
  2. package/dist/index.d.cts +36 -7
  3. package/dist/index.d.cts.map +1 -1
  4. package/dist/index.d.mts +36 -7
  5. package/dist/index.d.mts.map +1 -1
  6. package/dist/index.mjs +2376 -1082
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +13 -13
  9. package/src/components/KeyboardShortcuts/KeyboardShortcutsProvider.tsx +7 -7
  10. package/src/components/observability/DynamicDashboard/DynamicDashboard.test.tsx +5 -0
  11. package/src/components/observability/LogTimeline/LogFilter.tsx +5 -1
  12. package/src/components/observability/LogTimeline/index.tsx +6 -2
  13. package/src/components/observability/MetricHistogram/index.tsx +20 -19
  14. package/src/components/observability/MetricTimeSeries/index.tsx +8 -9
  15. package/src/components/observability/ServiceList/shortcuts.ts +1 -1
  16. package/src/components/observability/TraceComparison/index.tsx +332 -0
  17. package/src/components/observability/TraceDetail/TraceDetail.stories.tsx +0 -4
  18. package/src/components/observability/TraceDetail/index.tsx +4 -3
  19. package/src/components/observability/TraceSearch/DurationBar.tsx +38 -0
  20. package/src/components/observability/TraceSearch/ScatterPlot.tsx +135 -0
  21. package/src/components/observability/TraceSearch/SearchForm.tsx +195 -0
  22. package/src/components/observability/TraceSearch/SortDropdown.tsx +32 -0
  23. package/src/components/observability/TraceSearch/index.tsx +211 -218
  24. package/src/components/observability/TraceTimeline/DetailPane/EventsTab.tsx +1 -7
  25. package/src/components/observability/TraceTimeline/FlamegraphView.tsx +232 -0
  26. package/src/components/observability/TraceTimeline/GraphView.tsx +322 -0
  27. package/src/components/observability/TraceTimeline/Minimap.tsx +260 -0
  28. package/src/components/observability/TraceTimeline/SpanDetailInline.tsx +184 -0
  29. package/src/components/observability/TraceTimeline/SpanRow.tsx +14 -24
  30. package/src/components/observability/TraceTimeline/SpanSearch.tsx +76 -0
  31. package/src/components/observability/TraceTimeline/StatisticsView.tsx +223 -0
  32. package/src/components/observability/TraceTimeline/TimeRuler.tsx +54 -0
  33. package/src/components/observability/TraceTimeline/TimelineBar.tsx +18 -2
  34. package/src/components/observability/TraceTimeline/TraceHeader.tsx +116 -51
  35. package/src/components/observability/TraceTimeline/ViewTabs.tsx +34 -0
  36. package/src/components/observability/TraceTimeline/index.tsx +254 -110
  37. package/src/components/observability/index.ts +15 -0
  38. package/src/components/observability/renderers/OtelTraceDetail.tsx +1 -4
  39. package/src/components/observability/shared/TooltipEntryList.tsx +25 -0
  40. package/src/components/observability/utils/flatten-tree.ts +15 -0
  41. package/src/components/observability/utils/time.ts +9 -0
  42. package/src/hooks/use-kopai-data.test.ts +3 -0
  43. package/src/hooks/use-kopai-data.ts +11 -0
  44. package/src/hooks/use-live-logs.test.ts +3 -0
  45. package/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +1 -1
  46. package/src/lib/component-catalog.ts +15 -0
  47. package/src/pages/observability.test.tsx +5 -0
  48. package/src/pages/observability.tsx +314 -235
  49. package/src/providers/kopai-provider.tsx +3 -0
@@ -0,0 +1,232 @@
1
+ import { useMemo, useState, useCallback } from "react";
2
+ import type { ParsedTrace, SpanNode } from "../types.js";
3
+ import { getServiceColor } from "../utils/colors.js";
4
+ import { formatDuration } from "../utils/time.js";
5
+ import { flattenAllSpans } from "../utils/flatten-tree.js";
6
+
7
+ interface FlamegraphViewProps {
8
+ trace: ParsedTrace;
9
+ onSpanClick?: (span: SpanNode) => void;
10
+ selectedSpanId?: string;
11
+ }
12
+
13
+ const ROW_HEIGHT = 24;
14
+ const MIN_WIDTH = 1;
15
+ const LABEL_MIN_WIDTH = 40;
16
+
17
+ function findSpanById(rootSpans: SpanNode[], spanId: string): SpanNode | null {
18
+ for (const root of rootSpans) {
19
+ if (root.spanId === spanId) return root;
20
+ const found = findSpanById(root.children, spanId);
21
+ if (found) return found;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ function getAncestorPath(rootSpans: SpanNode[], targetId: string): SpanNode[] {
27
+ const path: SpanNode[] = [];
28
+ function walk(span: SpanNode, ancestors: SpanNode[]): boolean {
29
+ if (span.spanId === targetId) {
30
+ path.push(...ancestors, span);
31
+ return true;
32
+ }
33
+ for (const child of span.children) {
34
+ if (walk(child, [...ancestors, span])) return true;
35
+ }
36
+ return false;
37
+ }
38
+ for (const root of rootSpans) {
39
+ if (walk(root, [])) break;
40
+ }
41
+ return path;
42
+ }
43
+
44
+ export function FlamegraphView({
45
+ trace,
46
+ onSpanClick,
47
+ selectedSpanId,
48
+ }: FlamegraphViewProps) {
49
+ const [zoomSpanId, setZoomSpanId] = useState<string | null>(null);
50
+ const [tooltip, setTooltip] = useState<{
51
+ span: SpanNode;
52
+ x: number;
53
+ y: number;
54
+ } | null>(null);
55
+
56
+ const zoomRoot = useMemo(() => {
57
+ if (!zoomSpanId) return null;
58
+ return findSpanById(trace.rootSpans, zoomSpanId);
59
+ }, [trace.rootSpans, zoomSpanId]);
60
+
61
+ const breadcrumbs = useMemo(() => {
62
+ if (!zoomSpanId) return [];
63
+ return getAncestorPath(trace.rootSpans, zoomSpanId);
64
+ }, [trace.rootSpans, zoomSpanId]);
65
+
66
+ const viewRoots = zoomRoot ? [zoomRoot] : trace.rootSpans;
67
+ const viewMinTime = zoomRoot ? zoomRoot.startTimeUnixMs : trace.minTimeMs;
68
+ const viewMaxTime = zoomRoot ? zoomRoot.endTimeUnixMs : trace.maxTimeMs;
69
+ const viewDuration = viewMaxTime - viewMinTime;
70
+
71
+ const flatSpans = useMemo(
72
+ () =>
73
+ flattenAllSpans(viewRoots).map((fs) => ({
74
+ span: fs.span,
75
+ depth: fs.level,
76
+ })),
77
+ [viewRoots]
78
+ );
79
+
80
+ const maxDepth = useMemo(
81
+ () => flatSpans.reduce((max, fs) => Math.max(max, fs.depth), 0) + 1,
82
+ [flatSpans]
83
+ );
84
+
85
+ const svgWidth = 1200;
86
+ const svgHeight = maxDepth * ROW_HEIGHT;
87
+
88
+ const handleClick = useCallback(
89
+ (span: SpanNode) => {
90
+ onSpanClick?.(span);
91
+ setZoomSpanId(span.spanId);
92
+ },
93
+ [onSpanClick]
94
+ );
95
+
96
+ const handleZoomOut = useCallback((spanId: string | null) => {
97
+ setZoomSpanId(spanId);
98
+ }, []);
99
+
100
+ return (
101
+ <div className="flex-1 overflow-auto p-2">
102
+ {/* Breadcrumb bar */}
103
+ {breadcrumbs.length > 0 && (
104
+ <div className="flex items-center gap-1 text-xs text-muted-foreground mb-2 flex-wrap">
105
+ <button
106
+ className="hover:text-foreground underline"
107
+ onClick={() => handleZoomOut(null)}
108
+ >
109
+ root
110
+ </button>
111
+ {breadcrumbs.map((bc, i) => (
112
+ <span key={bc.spanId} className="flex items-center gap-1">
113
+ <span className="text-muted-foreground/50">&gt;</span>
114
+ {i < breadcrumbs.length - 1 ? (
115
+ <button
116
+ className="hover:text-foreground underline"
117
+ onClick={() => handleZoomOut(bc.spanId)}
118
+ >
119
+ {bc.serviceName}: {bc.name}
120
+ </button>
121
+ ) : (
122
+ <span className="text-foreground">
123
+ {bc.serviceName}: {bc.name}
124
+ </span>
125
+ )}
126
+ </span>
127
+ ))}
128
+ </div>
129
+ )}
130
+
131
+ {/* SVG flamegraph */}
132
+ <div className="overflow-x-auto">
133
+ <svg
134
+ width={svgWidth}
135
+ height={svgHeight}
136
+ className="block"
137
+ onMouseLeave={() => setTooltip(null)}
138
+ >
139
+ {flatSpans.map(({ span, depth }) => {
140
+ const x =
141
+ viewDuration > 0
142
+ ? ((span.startTimeUnixMs - viewMinTime) / viewDuration) *
143
+ svgWidth
144
+ : 0;
145
+ const w =
146
+ viewDuration > 0
147
+ ? Math.max(
148
+ MIN_WIDTH,
149
+ (span.durationMs / viewDuration) * svgWidth
150
+ )
151
+ : svgWidth;
152
+ const y = depth * ROW_HEIGHT;
153
+ const color = getServiceColor(span.serviceName);
154
+ const isSelected = span.spanId === selectedSpanId;
155
+ const showLabel = w >= LABEL_MIN_WIDTH;
156
+ const label = `${span.serviceName}: ${span.name}`;
157
+
158
+ return (
159
+ <g
160
+ key={span.spanId}
161
+ className="cursor-pointer"
162
+ onClick={() => handleClick(span)}
163
+ onMouseEnter={(e) =>
164
+ setTooltip({
165
+ span,
166
+ x: e.clientX,
167
+ y: e.clientY,
168
+ })
169
+ }
170
+ onMouseMove={(e) =>
171
+ setTooltip((prev) =>
172
+ prev ? { ...prev, x: e.clientX, y: e.clientY } : null
173
+ )
174
+ }
175
+ onMouseLeave={() => setTooltip(null)}
176
+ >
177
+ <rect
178
+ x={x}
179
+ y={y}
180
+ width={w}
181
+ height={ROW_HEIGHT - 1}
182
+ fill={color}
183
+ opacity={0.85}
184
+ rx={2}
185
+ stroke={isSelected ? "#ffffff" : "transparent"}
186
+ strokeWidth={isSelected ? 2 : 0}
187
+ className="hover:opacity-100"
188
+ />
189
+ {showLabel && (
190
+ <text
191
+ x={x + 4}
192
+ y={y + ROW_HEIGHT / 2 + 1}
193
+ dominantBaseline="middle"
194
+ fill="#ffffff"
195
+ fontSize={11}
196
+ fontFamily="monospace"
197
+ clipPath={`inset(0 0 0 0)`}
198
+ >
199
+ <tspan>
200
+ {label.length > w / 7
201
+ ? label.slice(0, Math.floor(w / 7) - 1) + "\u2026"
202
+ : label}
203
+ </tspan>
204
+ </text>
205
+ )}
206
+ </g>
207
+ );
208
+ })}
209
+ </svg>
210
+ </div>
211
+
212
+ {/* Tooltip */}
213
+ {tooltip && (
214
+ <div
215
+ className="fixed z-50 pointer-events-none bg-popover border border-border rounded px-3 py-2 text-xs shadow-lg"
216
+ style={{
217
+ left: tooltip.x + 12,
218
+ top: tooltip.y + 12,
219
+ }}
220
+ >
221
+ <div className="font-medium text-foreground">{tooltip.span.name}</div>
222
+ <div className="text-muted-foreground">
223
+ {tooltip.span.serviceName}
224
+ </div>
225
+ <div className="text-foreground mt-1">
226
+ {formatDuration(tooltip.span.durationMs)}
227
+ </div>
228
+ </div>
229
+ )}
230
+ </div>
231
+ );
232
+ }
@@ -0,0 +1,322 @@
1
+ /**
2
+ * GraphView - SVG-based DAG showing service dependencies within a trace.
3
+ */
4
+
5
+ import { useMemo } from "react";
6
+ import type { ParsedTrace, SpanNode } from "../types.js";
7
+ import { getServiceColor } from "../utils/colors.js";
8
+
9
+ export interface GraphViewProps {
10
+ trace: ParsedTrace;
11
+ }
12
+
13
+ // ── DAG types ──
14
+
15
+ interface ServiceNode {
16
+ name: string;
17
+ spanCount: number;
18
+ errorCount: number;
19
+ layer: number;
20
+ x: number;
21
+ y: number;
22
+ }
23
+
24
+ interface ServiceEdge {
25
+ from: string;
26
+ to: string;
27
+ callCount: number;
28
+ totalDurationMs: number;
29
+ }
30
+
31
+ // ── DAG construction ──
32
+
33
+ function buildDAG(trace: ParsedTrace) {
34
+ const nodeMap = new Map<string, { spanCount: number; errorCount: number }>();
35
+ const edgeMap = new Map<
36
+ string,
37
+ { callCount: number; totalDurationMs: number }
38
+ >();
39
+ const childServices = new Map<string, Set<string>>();
40
+
41
+ function walk(span: SpanNode, parentService?: string) {
42
+ const svc = span.serviceName;
43
+
44
+ const existing = nodeMap.get(svc);
45
+ if (existing) {
46
+ existing.spanCount++;
47
+ if (span.status === "ERROR") existing.errorCount++;
48
+ } else {
49
+ nodeMap.set(svc, {
50
+ spanCount: 1,
51
+ errorCount: span.status === "ERROR" ? 1 : 0,
52
+ });
53
+ }
54
+
55
+ if (parentService && parentService !== svc) {
56
+ const key = `${parentService}→${svc}`;
57
+ const edge = edgeMap.get(key);
58
+ if (edge) {
59
+ edge.callCount++;
60
+ edge.totalDurationMs += span.durationMs;
61
+ } else {
62
+ edgeMap.set(key, { callCount: 1, totalDurationMs: span.durationMs });
63
+ }
64
+ if (!childServices.has(parentService))
65
+ childServices.set(parentService, new Set());
66
+ const parentChildren = childServices.get(parentService);
67
+ if (parentChildren) parentChildren.add(svc);
68
+ }
69
+
70
+ for (const child of span.children) {
71
+ walk(child, svc);
72
+ }
73
+ }
74
+
75
+ for (const root of trace.rootSpans) {
76
+ walk(root);
77
+ }
78
+
79
+ const edges: ServiceEdge[] = [];
80
+ for (const [key, meta] of edgeMap) {
81
+ const [from, to] = key.split("→");
82
+ if (from && to) edges.push({ from, to, ...meta });
83
+ }
84
+
85
+ return { nodeMap, edges, childServices };
86
+ }
87
+
88
+ // ── Layout ──
89
+
90
+ const NODE_W = 160;
91
+ const NODE_H = 60;
92
+ const LAYER_GAP_Y = 100;
93
+ const NODE_GAP_X = 40;
94
+
95
+ function layoutNodes(
96
+ nodeMap: Map<string, { spanCount: number; errorCount: number }>,
97
+ edges: ServiceEdge[]
98
+ ): ServiceNode[] {
99
+ // Build adjacency for BFS
100
+ const children = new Map<string, Set<string>>();
101
+ const hasParent = new Set<string>();
102
+ for (const e of edges) {
103
+ if (!children.has(e.from)) children.set(e.from, new Set());
104
+ const fromChildren = children.get(e.from);
105
+ if (fromChildren) fromChildren.add(e.to);
106
+ hasParent.add(e.to);
107
+ }
108
+
109
+ // Root services = no incoming edges
110
+ const roots = [...nodeMap.keys()].filter((s) => !hasParent.has(s));
111
+ if (roots.length === 0 && nodeMap.size > 0) {
112
+ const firstKey = nodeMap.keys().next().value;
113
+ if (firstKey !== undefined) roots.push(firstKey);
114
+ }
115
+
116
+ // BFS to assign layers (with cycle protection)
117
+ const layerOf = new Map<string, number>();
118
+ const enqueueCount = new Map<string, number>();
119
+ const maxEnqueue = nodeMap.size * 2;
120
+ const queue: string[] = [];
121
+ for (const r of roots) {
122
+ layerOf.set(r, 0);
123
+ queue.push(r);
124
+ }
125
+ while (queue.length > 0) {
126
+ const cur = queue.shift();
127
+ if (!cur) continue;
128
+ const curLayer = layerOf.get(cur);
129
+ if (curLayer === undefined) continue;
130
+ const kids = children.get(cur);
131
+ if (!kids) continue;
132
+ for (const kid of kids) {
133
+ const prev = layerOf.get(kid);
134
+ const count = enqueueCount.get(kid) ?? 0;
135
+ if (prev === undefined && count < maxEnqueue) {
136
+ layerOf.set(kid, curLayer + 1);
137
+ enqueueCount.set(kid, count + 1);
138
+ queue.push(kid);
139
+ }
140
+ }
141
+ }
142
+
143
+ // Any unvisited nodes get layer 0
144
+ for (const name of nodeMap.keys()) {
145
+ if (!layerOf.has(name)) layerOf.set(name, 0);
146
+ }
147
+
148
+ // Group by layer
149
+ const layers = new Map<number, string[]>();
150
+ for (const [name, layer] of layerOf) {
151
+ if (!layers.has(layer)) layers.set(layer, []);
152
+ const layerNames = layers.get(layer);
153
+ if (layerNames) layerNames.push(name);
154
+ }
155
+
156
+ // Position
157
+ const nodes: ServiceNode[] = [];
158
+ const maxLayerWidth = Math.max(
159
+ ...Array.from(layers.values()).map((l) => l.length),
160
+ 1
161
+ );
162
+ const totalWidth = maxLayerWidth * (NODE_W + NODE_GAP_X) - NODE_GAP_X;
163
+
164
+ for (const [layer, names] of layers) {
165
+ const layerWidth = names.length * (NODE_W + NODE_GAP_X) - NODE_GAP_X;
166
+ const offsetX = (totalWidth - layerWidth) / 2;
167
+ names.forEach((name, i) => {
168
+ const meta = nodeMap.get(name);
169
+ if (!meta) return;
170
+ nodes.push({
171
+ name,
172
+ spanCount: meta.spanCount,
173
+ errorCount: meta.errorCount,
174
+ layer,
175
+ x: offsetX + i * (NODE_W + NODE_GAP_X),
176
+ y: layer * (NODE_H + LAYER_GAP_Y),
177
+ });
178
+ });
179
+ }
180
+
181
+ return nodes;
182
+ }
183
+
184
+ // ── Component ──
185
+
186
+ export function GraphView({ trace }: GraphViewProps) {
187
+ const { nodes, edges, svgWidth, svgHeight } = useMemo(() => {
188
+ const { nodeMap, edges } = buildDAG(trace);
189
+ const nodes = layoutNodes(nodeMap, edges);
190
+
191
+ const maxX = Math.max(...nodes.map((n) => n.x + NODE_W), NODE_W);
192
+ const maxY = Math.max(...nodes.map((n) => n.y + NODE_H), NODE_H);
193
+ const padding = 40;
194
+
195
+ return {
196
+ nodes,
197
+ edges,
198
+ svgWidth: maxX + padding * 2,
199
+ svgHeight: maxY + padding * 2,
200
+ };
201
+ }, [trace]);
202
+
203
+ const nodeByName = useMemo(() => {
204
+ const m = new Map<string, ServiceNode>();
205
+ for (const n of nodes) m.set(n.name, n);
206
+ return m;
207
+ }, [nodes]);
208
+
209
+ const padding = 40;
210
+
211
+ return (
212
+ <div className="flex-1 overflow-auto bg-background p-4 flex justify-center">
213
+ <svg
214
+ viewBox={`0 0 ${svgWidth} ${svgHeight}`}
215
+ width={svgWidth}
216
+ height={svgHeight}
217
+ role="img"
218
+ aria-label="Service dependency graph"
219
+ >
220
+ <defs>
221
+ <marker
222
+ id="arrowhead"
223
+ markerWidth="10"
224
+ markerHeight="7"
225
+ refX="9"
226
+ refY="3.5"
227
+ orient="auto"
228
+ >
229
+ <polygon points="0 0, 10 3.5, 0 7" fill="#94a3b8" />
230
+ </marker>
231
+ </defs>
232
+
233
+ {/* Edges */}
234
+ {edges.map((edge) => {
235
+ const from = nodeByName.get(edge.from);
236
+ const to = nodeByName.get(edge.to);
237
+ if (!from || !to) return null;
238
+
239
+ const x1 = padding + from.x + NODE_W / 2;
240
+ const y1 = padding + from.y + NODE_H;
241
+ const x2 = padding + to.x + NODE_W / 2;
242
+ const y2 = padding + to.y;
243
+
244
+ const midY = (y1 + y2) / 2;
245
+
246
+ const d = `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
247
+
248
+ return (
249
+ <g key={`${edge.from}→${edge.to}`}>
250
+ <path
251
+ d={d}
252
+ fill="none"
253
+ stroke="#475569"
254
+ strokeWidth={1.5}
255
+ markerEnd="url(#arrowhead)"
256
+ />
257
+ {edge.callCount > 1 && (
258
+ <text
259
+ x={(x1 + x2) / 2}
260
+ y={midY - 6}
261
+ textAnchor="middle"
262
+ fontSize={11}
263
+ fill="#94a3b8"
264
+ >
265
+ {edge.callCount}x
266
+ </text>
267
+ )}
268
+ </g>
269
+ );
270
+ })}
271
+
272
+ {/* Nodes */}
273
+ {nodes.map((node) => {
274
+ const color = getServiceColor(node.name);
275
+ const hasError = node.errorCount > 0;
276
+ const textColor = "#f8fafc";
277
+ const nx = padding + node.x;
278
+ const ny = padding + node.y;
279
+
280
+ return (
281
+ <g key={node.name}>
282
+ <rect
283
+ x={nx}
284
+ y={ny}
285
+ width={NODE_W}
286
+ height={NODE_H}
287
+ rx={8}
288
+ ry={8}
289
+ fill={color}
290
+ stroke={hasError ? "#ef4444" : "none"}
291
+ strokeWidth={hasError ? 2 : 0}
292
+ />
293
+ <text
294
+ x={nx + NODE_W / 2}
295
+ y={ny + 24}
296
+ textAnchor="middle"
297
+ fontSize={13}
298
+ fontWeight={600}
299
+ fill={textColor}
300
+ >
301
+ {node.name.length > 18
302
+ ? node.name.slice(0, 16) + "..."
303
+ : node.name}
304
+ </text>
305
+ <text
306
+ x={nx + NODE_W / 2}
307
+ y={ny + 44}
308
+ textAnchor="middle"
309
+ fontSize={11}
310
+ fill={textColor}
311
+ opacity={0.85}
312
+ >
313
+ {node.spanCount} span{node.spanCount !== 1 ? "s" : ""}
314
+ {node.errorCount > 0 && ` · ${node.errorCount} err`}
315
+ </text>
316
+ </g>
317
+ );
318
+ })}
319
+ </svg>
320
+ </div>
321
+ );
322
+ }