@kopai/ui 0.7.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 (50) hide show
  1. package/dist/index.cjs +2451 -1157
  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 +2399 -1099
  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 +25 -14
  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/OtelLogTimeline.tsx +9 -5
  39. package/src/components/observability/renderers/OtelTraceDetail.tsx +1 -4
  40. package/src/components/observability/shared/TooltipEntryList.tsx +25 -0
  41. package/src/components/observability/utils/flatten-tree.ts +15 -0
  42. package/src/components/observability/utils/time.ts +9 -0
  43. package/src/hooks/use-kopai-data.test.ts +4 -0
  44. package/src/hooks/use-kopai-data.ts +11 -0
  45. package/src/hooks/use-live-logs.test.ts +4 -0
  46. package/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +1 -1
  47. package/src/lib/component-catalog.ts +15 -0
  48. package/src/pages/observability.test.tsx +16 -12
  49. package/src/pages/observability.tsx +323 -245
  50. package/src/providers/kopai-provider.tsx +4 -0
@@ -0,0 +1,223 @@
1
+ import { useMemo, useState } from "react";
2
+ import type { ParsedTrace, SpanNode } from "../types.js";
3
+ import { formatDuration } from "../utils/time.js";
4
+ import { flattenAllSpans } from "../utils/flatten-tree.js";
5
+
6
+ interface StatisticsViewProps {
7
+ trace: ParsedTrace;
8
+ }
9
+
10
+ interface SpanStats {
11
+ key: string;
12
+ serviceName: string;
13
+ spanName: string;
14
+ count: number;
15
+ totalDuration: number;
16
+ avgDuration: number;
17
+ minDuration: number;
18
+ maxDuration: number;
19
+ selfTimeTotal: number;
20
+ selfTimeAvg: number;
21
+ selfTimeMin: number;
22
+ selfTimeMax: number;
23
+ }
24
+
25
+ type SortField =
26
+ | "name"
27
+ | "count"
28
+ | "total"
29
+ | "avg"
30
+ | "min"
31
+ | "max"
32
+ | "selfTotal"
33
+ | "selfAvg"
34
+ | "selfMin"
35
+ | "selfMax";
36
+
37
+ function computeSelfTime(span: SpanNode): number {
38
+ const childrenTotal = span.children.reduce(
39
+ (sum, child) => sum + child.durationMs,
40
+ 0
41
+ );
42
+ return Math.max(0, span.durationMs - childrenTotal);
43
+ }
44
+
45
+ function computeStats(trace: ParsedTrace): SpanStats[] {
46
+ const allFlattened = flattenAllSpans(trace.rootSpans);
47
+ const groups = new Map<string, { spans: SpanNode[]; selfTimes: number[] }>();
48
+
49
+ for (const { span } of allFlattened) {
50
+ const key = `${span.serviceName}:${span.name}`;
51
+ let group = groups.get(key);
52
+ if (!group) {
53
+ group = { spans: [], selfTimes: [] };
54
+ groups.set(key, group);
55
+ }
56
+ group.spans.push(span);
57
+ group.selfTimes.push(computeSelfTime(span));
58
+ }
59
+
60
+ const stats: SpanStats[] = [];
61
+ for (const [key, { spans, selfTimes }] of groups) {
62
+ const durations = spans.map((s) => s.durationMs);
63
+ const count = spans.length;
64
+ const totalDuration = durations.reduce((a, b) => a + b, 0);
65
+ const selfTimeTotal = selfTimes.reduce((a, b) => a + b, 0);
66
+
67
+ const firstSpan = spans[0];
68
+ if (!firstSpan) continue;
69
+
70
+ stats.push({
71
+ key,
72
+ serviceName: firstSpan.serviceName,
73
+ spanName: firstSpan.name,
74
+ count,
75
+ totalDuration,
76
+ avgDuration: totalDuration / count,
77
+ minDuration: Math.min(...durations),
78
+ maxDuration: Math.max(...durations),
79
+ selfTimeTotal,
80
+ selfTimeAvg: selfTimeTotal / count,
81
+ selfTimeMin: Math.min(...selfTimes),
82
+ selfTimeMax: Math.max(...selfTimes),
83
+ });
84
+ }
85
+
86
+ return stats;
87
+ }
88
+
89
+ function getSortValue(stat: SpanStats, field: SortField): number | string {
90
+ switch (field) {
91
+ case "name":
92
+ return stat.key.toLowerCase();
93
+ case "count":
94
+ return stat.count;
95
+ case "total":
96
+ return stat.totalDuration;
97
+ case "avg":
98
+ return stat.avgDuration;
99
+ case "min":
100
+ return stat.minDuration;
101
+ case "max":
102
+ return stat.maxDuration;
103
+ case "selfTotal":
104
+ return stat.selfTimeTotal;
105
+ case "selfAvg":
106
+ return stat.selfTimeAvg;
107
+ case "selfMin":
108
+ return stat.selfTimeMin;
109
+ case "selfMax":
110
+ return stat.selfTimeMax;
111
+ }
112
+ }
113
+
114
+ const COLUMNS: { label: string; field: SortField }[] = [
115
+ { label: "Name", field: "name" },
116
+ { label: "Count", field: "count" },
117
+ { label: "Total", field: "total" },
118
+ { label: "Avg", field: "avg" },
119
+ { label: "Min", field: "min" },
120
+ { label: "Max", field: "max" },
121
+ { label: "ST Total", field: "selfTotal" },
122
+ { label: "ST Avg", field: "selfAvg" },
123
+ { label: "ST Min", field: "selfMin" },
124
+ { label: "ST Max", field: "selfMax" },
125
+ ];
126
+
127
+ export function StatisticsView({ trace }: StatisticsViewProps) {
128
+ const [sortField, setSortField] = useState<SortField>("total");
129
+ const [sortAsc, setSortAsc] = useState(false);
130
+
131
+ const stats = useMemo(() => computeStats(trace), [trace]);
132
+
133
+ const sorted = useMemo(() => {
134
+ const copy = [...stats];
135
+ copy.sort((a, b) => {
136
+ const aVal = getSortValue(a, sortField);
137
+ const bVal = getSortValue(b, sortField);
138
+ let cmp: number;
139
+ if (typeof aVal === "string" && typeof bVal === "string") {
140
+ cmp = aVal.localeCompare(bVal);
141
+ } else if (typeof aVal === "number" && typeof bVal === "number") {
142
+ cmp = aVal - bVal;
143
+ } else {
144
+ cmp = 0;
145
+ }
146
+ return sortAsc ? cmp : -cmp;
147
+ });
148
+ return copy;
149
+ }, [stats, sortField, sortAsc]);
150
+
151
+ const handleSort = (field: SortField) => {
152
+ if (sortField === field) {
153
+ setSortAsc((p) => !p);
154
+ } else {
155
+ setSortField(field);
156
+ setSortAsc(false);
157
+ }
158
+ };
159
+
160
+ return (
161
+ <div className="flex-1 overflow-auto p-2">
162
+ <table className="w-full text-sm border-collapse">
163
+ <thead>
164
+ <tr className="border-b border-border">
165
+ {COLUMNS.map((col) => (
166
+ <th
167
+ key={col.field}
168
+ className="px-3 py-2 text-left text-xs font-medium text-muted-foreground cursor-pointer select-none hover:text-foreground whitespace-nowrap"
169
+ onClick={() => handleSort(col.field)}
170
+ >
171
+ {col.label}{" "}
172
+ {sortField === col.field ? (sortAsc ? "▲" : "▼") : ""}
173
+ </th>
174
+ ))}
175
+ </tr>
176
+ </thead>
177
+ <tbody>
178
+ {sorted.map((stat, i) => (
179
+ <tr
180
+ key={stat.key}
181
+ className={`border-b border-border/50 ${i % 2 === 0 ? "bg-background" : "bg-muted/30"}`}
182
+ >
183
+ <td className="px-3 py-1.5 text-foreground font-mono text-xs whitespace-nowrap">
184
+ <span className="text-muted-foreground">
185
+ {stat.serviceName}
186
+ </span>
187
+ <span className="text-muted-foreground/50">:</span>{" "}
188
+ {stat.spanName}
189
+ </td>
190
+ <td className="px-3 py-1.5 text-foreground tabular-nums">
191
+ {stat.count}
192
+ </td>
193
+ <td className="px-3 py-1.5 text-foreground tabular-nums">
194
+ {formatDuration(stat.totalDuration)}
195
+ </td>
196
+ <td className="px-3 py-1.5 text-foreground tabular-nums">
197
+ {formatDuration(stat.avgDuration)}
198
+ </td>
199
+ <td className="px-3 py-1.5 text-foreground tabular-nums">
200
+ {formatDuration(stat.minDuration)}
201
+ </td>
202
+ <td className="px-3 py-1.5 text-foreground tabular-nums">
203
+ {formatDuration(stat.maxDuration)}
204
+ </td>
205
+ <td className="px-3 py-1.5 text-foreground tabular-nums">
206
+ {formatDuration(stat.selfTimeTotal)}
207
+ </td>
208
+ <td className="px-3 py-1.5 text-foreground tabular-nums">
209
+ {formatDuration(stat.selfTimeAvg)}
210
+ </td>
211
+ <td className="px-3 py-1.5 text-foreground tabular-nums">
212
+ {formatDuration(stat.selfTimeMin)}
213
+ </td>
214
+ <td className="px-3 py-1.5 text-foreground tabular-nums">
215
+ {formatDuration(stat.selfTimeMax)}
216
+ </td>
217
+ </tr>
218
+ ))}
219
+ </tbody>
220
+ </table>
221
+ </div>
222
+ );
223
+ }
@@ -0,0 +1,54 @@
1
+ import { formatDuration } from "../utils/time.js";
2
+
3
+ export interface TimeRulerProps {
4
+ totalDurationMs: number;
5
+ leftColumnWidth: string;
6
+ offsetMs?: number;
7
+ }
8
+
9
+ const TICK_COUNT = 5;
10
+
11
+ export function TimeRuler({
12
+ totalDurationMs,
13
+ leftColumnWidth,
14
+ offsetMs = 0,
15
+ }: TimeRulerProps) {
16
+ const ticks = Array.from({ length: TICK_COUNT + 1 }, (_, i) => {
17
+ const fraction = i / TICK_COUNT;
18
+ return {
19
+ label: formatDuration(offsetMs + totalDurationMs * fraction),
20
+ percent: fraction * 100,
21
+ };
22
+ });
23
+
24
+ return (
25
+ <div className="flex border-b border-border bg-background">
26
+ <div className="flex-shrink-0" style={{ width: leftColumnWidth }} />
27
+ <div className="flex-1 relative h-6 px-2">
28
+ {ticks.map((tick) => (
29
+ <div
30
+ key={tick.percent}
31
+ className="absolute top-0 h-full flex flex-col justify-end"
32
+ style={{ left: `${tick.percent}%` }}
33
+ >
34
+ <div className="h-2 border-l border-muted-foreground/40" />
35
+ <span
36
+ className="text-[10px] text-muted-foreground font-mono -translate-x-1/2 absolute bottom-0 whitespace-nowrap"
37
+ style={{
38
+ left: 0,
39
+ transform:
40
+ tick.percent === 100
41
+ ? "translateX(-100%)"
42
+ : tick.percent === 0
43
+ ? "none"
44
+ : "translateX(-50%)",
45
+ }}
46
+ >
47
+ {tick.label}
48
+ </span>
49
+ </div>
50
+ ))}
51
+ </div>
52
+ </div>
53
+ );
54
+ }
@@ -19,21 +19,37 @@ export function TimelineBar({
19
19
 
20
20
  const leftPercent = relativeStart * 100;
21
21
  const widthPercent = Math.max(0.2, relativeDuration * 100);
22
+ const isWide = widthPercent > 8;
22
23
 
23
24
  const tooltipText = `${span.name}\n${formatDuration(span.durationMs)}\nStatus: ${isError ? "ERROR" : "OK"}`;
25
+ const durationLabel = formatDuration(span.durationMs);
24
26
 
25
27
  return (
26
28
  <div className="relative h-full">
27
29
  <Tooltip content={tooltipText}>
28
30
  <div className="absolute inset-0">
29
31
  <div
30
- className="absolute top-1/2 -translate-y-1/2 h-2 rounded-sm cursor-pointer hover:opacity-80 transition-opacity"
32
+ className="absolute top-1/2 -translate-y-1/2 h-2 rounded-sm cursor-pointer hover:opacity-80 transition-opacity flex items-center"
31
33
  style={{
32
34
  left: `${leftPercent}%`,
33
35
  width: `max(2px, ${widthPercent}%)`,
34
36
  backgroundColor: barColor,
35
37
  }}
36
- />
38
+ >
39
+ {isWide && (
40
+ <span className="text-[10px] font-mono text-white px-1 truncate">
41
+ {durationLabel}
42
+ </span>
43
+ )}
44
+ </div>
45
+ {!isWide && (
46
+ <span
47
+ className="absolute top-1/2 -translate-y-1/2 text-[10px] font-mono text-muted-foreground whitespace-nowrap"
48
+ style={{ left: `calc(${leftPercent + widthPercent}% + 4px)` }}
49
+ >
50
+ {durationLabel}
51
+ </span>
52
+ )}
37
53
  </div>
38
54
  </Tooltip>
39
55
  </div>
@@ -1,18 +1,40 @@
1
1
  import { useState } from "react";
2
2
  import type { ParsedTrace } from "../types.js";
3
3
  import { formatDuration, formatTimestamp } from "../utils/time.js";
4
+ import { getServiceColor } from "../utils/colors.js";
4
5
 
5
6
  export interface TraceHeaderProps {
6
7
  trace: ParsedTrace;
8
+ services?: string[];
9
+ onHeaderToggle?: () => void;
10
+ isCollapsed?: boolean;
7
11
  }
8
12
 
9
- export function TraceHeader({ trace }: TraceHeaderProps) {
13
+ function computeMaxDepth(spans: ParsedTrace["rootSpans"]): number {
14
+ let max = 0;
15
+ function walk(nodes: ParsedTrace["rootSpans"], depth: number) {
16
+ for (const node of nodes) {
17
+ if (depth > max) max = depth;
18
+ walk(node.children, depth + 1);
19
+ }
20
+ }
21
+ walk(spans, 1);
22
+ return max;
23
+ }
24
+
25
+ export function TraceHeader({
26
+ trace,
27
+ services = [],
28
+ onHeaderToggle,
29
+ isCollapsed = false,
30
+ }: TraceHeaderProps) {
10
31
  const [copied, setCopied] = useState(false);
11
32
 
12
33
  const rootSpan = trace.rootSpans[0];
13
34
  const rootServiceName = rootSpan?.serviceName ?? "unknown";
14
35
  const rootSpanName = rootSpan?.name ?? "unknown";
15
36
  const totalDuration = trace.maxTimeMs - trace.minTimeMs;
37
+ const maxDepth = computeMaxDepth(trace.rootSpans);
16
38
 
17
39
  const handleCopyTraceId = async () => {
18
40
  try {
@@ -26,63 +48,106 @@ export function TraceHeader({ trace }: TraceHeaderProps) {
26
48
 
27
49
  return (
28
50
  <div className="bg-background border-b border-border px-4 py-3">
29
- <div className="flex items-center gap-6 flex-wrap">
30
- <div className="flex items-center gap-2">
31
- <span className="text-xs font-semibold text-muted-foreground">
32
- Trace ID:
33
- </span>
51
+ <div className="flex items-center gap-2 mb-1">
52
+ {onHeaderToggle && (
34
53
  <button
35
- onClick={handleCopyTraceId}
36
- className="text-sm font-mono bg-muted px-2 py-1 rounded hover:bg-muted/80 transition-colors text-foreground"
37
- title="Click to copy"
54
+ onClick={onHeaderToggle}
55
+ className="p-0.5 text-muted-foreground hover:text-foreground"
56
+ aria-label={isCollapsed ? "Expand header" : "Collapse header"}
38
57
  >
39
- {trace.traceId.slice(0, 16)}...
58
+ <svg
59
+ className={`w-4 h-4 transition-transform ${isCollapsed ? "-rotate-90" : ""}`}
60
+ fill="none"
61
+ stroke="currentColor"
62
+ viewBox="0 0 24 24"
63
+ >
64
+ <path
65
+ strokeLinecap="round"
66
+ strokeLinejoin="round"
67
+ strokeWidth={2}
68
+ d="M19 9l-7 7-7-7"
69
+ />
70
+ </svg>
40
71
  </button>
41
- {copied && (
42
- <span className="text-xs text-green-600 dark:text-green-400 font-medium">
43
- Copied!
44
- </span>
45
- )}
46
- </div>
72
+ )}
73
+ <span className="text-sm font-semibold text-foreground">
74
+ {rootServiceName}: {rootSpanName}
75
+ </span>
76
+ </div>
47
77
 
48
- <div className="flex items-center gap-2">
49
- <span className="text-xs font-semibold text-muted-foreground">
50
- Root:
51
- </span>
52
- <span className="text-sm">
53
- <span className="text-muted-foreground">{rootServiceName}</span>
54
- <span className="mx-1 text-muted-foreground/70">/</span>
55
- <span className="font-medium text-foreground">{rootSpanName}</span>
56
- </span>
57
- </div>
78
+ {!isCollapsed && (
79
+ <>
80
+ <div className="flex items-center gap-6 flex-wrap">
81
+ <div className="flex items-center gap-2">
82
+ <span className="text-xs font-semibold text-muted-foreground">
83
+ Trace ID:
84
+ </span>
85
+ <button
86
+ onClick={handleCopyTraceId}
87
+ className="text-sm font-mono bg-muted px-2 py-1 rounded hover:bg-muted/80 transition-colors text-foreground"
88
+ title="Click to copy"
89
+ >
90
+ {trace.traceId.slice(0, 16)}...
91
+ </button>
92
+ {copied && (
93
+ <span className="text-xs text-green-600 dark:text-green-400 font-medium">
94
+ Copied!
95
+ </span>
96
+ )}
97
+ </div>
58
98
 
59
- <div className="flex items-center gap-2">
60
- <span className="text-xs font-semibold text-muted-foreground">
61
- Duration:
62
- </span>
63
- <span className="text-sm font-medium text-foreground">
64
- {formatDuration(totalDuration)}
65
- </span>
66
- </div>
99
+ <div className="flex items-center gap-2">
100
+ <span className="text-xs font-semibold text-muted-foreground">
101
+ Duration:
102
+ </span>
103
+ <span className="text-sm font-medium text-foreground">
104
+ {formatDuration(totalDuration)}
105
+ </span>
106
+ </div>
67
107
 
68
- <div className="flex items-center gap-2">
69
- <span className="text-xs font-semibold text-muted-foreground">
70
- Spans:
71
- </span>
72
- <span className="text-sm font-medium text-foreground">
73
- {trace.totalSpanCount}
74
- </span>
75
- </div>
108
+ <div className="flex items-center gap-2">
109
+ <span className="text-xs font-semibold text-muted-foreground">
110
+ Spans:
111
+ </span>
112
+ <span className="text-sm font-medium text-foreground">
113
+ {trace.totalSpanCount}
114
+ </span>
115
+ </div>
76
116
 
77
- <div className="flex items-center gap-2">
78
- <span className="text-xs font-semibold text-muted-foreground">
79
- Started:
80
- </span>
81
- <span className="text-sm text-foreground">
82
- {formatTimestamp(trace.minTimeMs)}
83
- </span>
84
- </div>
85
- </div>
117
+ <div className="flex items-center gap-2">
118
+ <span className="text-xs font-semibold text-muted-foreground">
119
+ Depth:
120
+ </span>
121
+ <span className="text-sm font-medium text-foreground">
122
+ {maxDepth}
123
+ </span>
124
+ </div>
125
+
126
+ <div className="flex items-center gap-2">
127
+ <span className="text-xs font-semibold text-muted-foreground">
128
+ Started:
129
+ </span>
130
+ <span className="text-sm text-foreground">
131
+ {formatTimestamp(trace.minTimeMs)}
132
+ </span>
133
+ </div>
134
+ </div>
135
+
136
+ {services.length > 0 && (
137
+ <div className="flex items-center gap-3 mt-2 flex-wrap">
138
+ {services.map((svc) => (
139
+ <div key={svc} className="flex items-center gap-1.5">
140
+ <span
141
+ className="w-2.5 h-2.5 rounded-full flex-shrink-0"
142
+ style={{ backgroundColor: getServiceColor(svc) }}
143
+ />
144
+ <span className="text-xs text-muted-foreground">{svc}</span>
145
+ </div>
146
+ ))}
147
+ </div>
148
+ )}
149
+ </>
150
+ )}
86
151
  </div>
87
152
  );
88
153
  }
@@ -0,0 +1,34 @@
1
+ export const VIEWS = ["timeline", "graph", "statistics", "flamegraph"] as const;
2
+ export type ViewName = (typeof VIEWS)[number];
3
+
4
+ export interface ViewTabsProps {
5
+ activeView: ViewName;
6
+ onChange: (view: ViewName) => void;
7
+ }
8
+
9
+ const VIEW_LABELS: Record<string, string> = {
10
+ timeline: "Timeline",
11
+ graph: "Graph",
12
+ statistics: "Statistics",
13
+ flamegraph: "Flamegraph",
14
+ };
15
+
16
+ export function ViewTabs({ activeView, onChange }: ViewTabsProps) {
17
+ return (
18
+ <div className="flex border-b border-border bg-background">
19
+ {VIEWS.map((view) => (
20
+ <button
21
+ key={view}
22
+ onClick={() => onChange(view)}
23
+ className={`px-4 py-1.5 text-sm font-medium transition-colors ${
24
+ activeView === view
25
+ ? "text-foreground border-b-2 border-blue-500"
26
+ : "text-muted-foreground hover:text-foreground"
27
+ }`}
28
+ >
29
+ {VIEW_LABELS[view]}
30
+ </button>
31
+ ))}
32
+ </div>
33
+ );
34
+ }