@kopai/ui 0.8.0 → 0.10.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 (52) hide show
  1. package/dist/index.cjs +2704 -1288
  2. package/dist/index.d.cts +38 -1
  3. package/dist/index.d.cts.map +1 -1
  4. package/dist/index.d.mts +38 -1
  5. package/dist/index.d.mts.map +1 -1
  6. package/dist/index.mjs +2722 -1300
  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 +8 -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/MetricStat/index.tsx +12 -4
  15. package/src/components/observability/MetricTimeSeries/index.tsx +8 -9
  16. package/src/components/observability/ServiceList/shortcuts.ts +1 -1
  17. package/src/components/observability/TraceComparison/index.tsx +332 -0
  18. package/src/components/observability/TraceDetail/TraceDetail.stories.tsx +0 -4
  19. package/src/components/observability/TraceDetail/index.tsx +4 -3
  20. package/src/components/observability/TraceSearch/DurationBar.tsx +38 -0
  21. package/src/components/observability/TraceSearch/ScatterPlot.tsx +135 -0
  22. package/src/components/observability/TraceSearch/SearchForm.tsx +195 -0
  23. package/src/components/observability/TraceSearch/SortDropdown.tsx +32 -0
  24. package/src/components/observability/TraceSearch/index.tsx +211 -218
  25. package/src/components/observability/TraceTimeline/DetailPane/EventsTab.tsx +1 -7
  26. package/src/components/observability/TraceTimeline/FlamegraphView.tsx +232 -0
  27. package/src/components/observability/TraceTimeline/GraphView.tsx +322 -0
  28. package/src/components/observability/TraceTimeline/Minimap.tsx +260 -0
  29. package/src/components/observability/TraceTimeline/SpanDetailInline.tsx +184 -0
  30. package/src/components/observability/TraceTimeline/SpanRow.tsx +14 -24
  31. package/src/components/observability/TraceTimeline/SpanSearch.tsx +76 -0
  32. package/src/components/observability/TraceTimeline/StatisticsView.tsx +223 -0
  33. package/src/components/observability/TraceTimeline/TimeRuler.tsx +54 -0
  34. package/src/components/observability/TraceTimeline/TimelineBar.tsx +18 -2
  35. package/src/components/observability/TraceTimeline/TraceHeader.tsx +116 -51
  36. package/src/components/observability/TraceTimeline/ViewTabs.tsx +34 -0
  37. package/src/components/observability/TraceTimeline/index.tsx +254 -110
  38. package/src/components/observability/index.ts +15 -0
  39. package/src/components/observability/renderers/OtelMetricStat.tsx +40 -0
  40. package/src/components/observability/renderers/OtelTraceDetail.tsx +1 -4
  41. package/src/components/observability/shared/TooltipEntryList.tsx +25 -0
  42. package/src/components/observability/utils/flatten-tree.ts +15 -0
  43. package/src/components/observability/utils/time.ts +9 -0
  44. package/src/hooks/use-kopai-data.test.ts +34 -0
  45. package/src/hooks/use-kopai-data.ts +23 -5
  46. package/src/hooks/use-live-logs.test.ts +4 -0
  47. package/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +1 -1
  48. package/src/lib/component-catalog.ts +15 -0
  49. package/src/lib/renderer.test.tsx +2 -0
  50. package/src/pages/observability.test.tsx +8 -0
  51. package/src/pages/observability.tsx +397 -236
  52. package/src/providers/kopai-provider.tsx +4 -0
@@ -1,6 +1,11 @@
1
- import { useState } from "react";
2
- import { formatDuration, formatTimestamp } from "../utils/time.js";
1
+ import { useState, useMemo } from "react";
2
+ import { formatTimestamp } from "../utils/time.js";
3
3
  import { getServiceColor } from "../utils/colors.js";
4
+ import { SearchForm } from "./SearchForm.js";
5
+ import type { SearchFormValues } from "./SearchForm.js";
6
+ import { ScatterPlot } from "./ScatterPlot.js";
7
+ import { SortDropdown } from "./SortDropdown.js";
8
+ import { DurationBar } from "./DurationBar.js";
4
9
 
5
10
  // ---------------------------------------------------------------------------
6
11
  // Public types
@@ -19,274 +24,262 @@ export interface TraceSummary {
19
24
  }
20
25
 
21
26
  export interface TraceSearchFilters {
27
+ service?: string;
22
28
  operation?: string;
23
- lookbackMs?: number;
29
+ tags?: string;
30
+ lookback?: string;
24
31
  minDuration?: string;
25
32
  maxDuration?: string;
26
33
  limit: number;
27
34
  }
28
35
 
29
36
  export interface TraceSearchProps {
37
+ // Search form data
38
+ services?: string[];
30
39
  service: string;
31
- traces: TraceSummary[];
32
40
  operations?: string[];
41
+ // Results
42
+ traces: TraceSummary[];
33
43
  isLoading?: boolean;
34
44
  error?: Error;
45
+ // Callbacks
35
46
  onSelectTrace: (traceId: string) => void;
36
- onBack: () => void;
37
47
  onSearch?: (filters: TraceSearchFilters) => void;
48
+ onCompare?: (traceIds: [string, string]) => void;
49
+ // Sort
50
+ sort?: string;
51
+ onSortChange?: (sort: string) => void;
38
52
  }
39
53
 
40
54
  // ---------------------------------------------------------------------------
41
- // Lookback presets
55
+ // Sort helpers
42
56
  // ---------------------------------------------------------------------------
43
57
 
44
- const LOOKBACK_OPTIONS = [
45
- { label: "Last 5 Minutes", ms: 5 * 60_000 },
46
- { label: "Last 15 Minutes", ms: 15 * 60_000 },
47
- { label: "Last 30 Minutes", ms: 30 * 60_000 },
48
- { label: "Last 1 Hour", ms: 60 * 60_000 },
49
- { label: "Last 2 Hours", ms: 2 * 60 * 60_000 },
50
- { label: "Last 6 Hours", ms: 6 * 60 * 60_000 },
51
- { label: "Last 12 Hours", ms: 12 * 60 * 60_000 },
52
- { label: "Last 24 Hours", ms: 24 * 60 * 60_000 },
53
- ] as const;
58
+ function sortTraces(traces: TraceSummary[], sort: string): TraceSummary[] {
59
+ const sorted = [...traces];
60
+ switch (sort) {
61
+ case "longest":
62
+ return sorted.sort((a, b) => b.durationMs - a.durationMs);
63
+ case "shortest":
64
+ return sorted.sort((a, b) => a.durationMs - b.durationMs);
65
+ case "mostSpans":
66
+ return sorted.sort((a, b) => b.spanCount - a.spanCount);
67
+ case "leastSpans":
68
+ return sorted.sort((a, b) => a.spanCount - b.spanCount);
69
+ case "recent":
70
+ default:
71
+ return sorted.sort((a, b) => b.timestampMs - a.timestampMs);
72
+ }
73
+ }
54
74
 
55
75
  // ---------------------------------------------------------------------------
56
76
  // Component
57
77
  // ---------------------------------------------------------------------------
58
78
 
59
79
  export function TraceSearch({
80
+ services = [],
60
81
  service,
61
- traces,
62
82
  operations = [],
83
+ traces,
63
84
  isLoading,
64
85
  error,
65
86
  onSelectTrace,
66
- onBack,
67
87
  onSearch,
88
+ onCompare,
89
+ sort: controlledSort,
90
+ onSortChange,
68
91
  }: TraceSearchProps) {
69
- const [operation, setOperation] = useState("all");
70
- const [lookbackIdx, setLookbackIdx] = useState<number>(-1);
71
- const [minDuration, setMinDuration] = useState("");
72
- const [maxDuration, setMaxDuration] = useState("");
73
- const [limit, setLimit] = useState(20);
74
- const [filtersOpen, setFiltersOpen] = useState(true);
92
+ // Sort state (internal fallback if not controlled)
93
+ const [internalSort, setInternalSort] = useState("recent");
94
+ const currentSort = controlledSort ?? internalSort;
95
+ const handleSortChange = (s: string) => {
96
+ if (onSortChange) onSortChange(s);
97
+ else setInternalSort(s);
98
+ };
75
99
 
76
- const handleFindTraces = () => {
77
- onSearch?.({
78
- operation: operation !== "all" ? operation : undefined,
79
- lookbackMs:
80
- lookbackIdx >= 0 ? LOOKBACK_OPTIONS[lookbackIdx]!.ms : undefined,
81
- minDuration: minDuration || undefined,
82
- maxDuration: maxDuration || undefined,
83
- limit,
100
+ // Comparison state
101
+ const [selected, setSelected] = useState<Set<string>>(new Set());
102
+
103
+ const toggleSelected = (traceId: string) => {
104
+ setSelected((prev) => {
105
+ const next = new Set(prev);
106
+ if (next.has(traceId)) {
107
+ next.delete(traceId);
108
+ } else {
109
+ // Max 2 selected
110
+ if (next.size >= 2) return prev;
111
+ next.add(traceId);
112
+ }
113
+ return next;
84
114
  });
85
115
  };
86
116
 
87
- return (
88
- <div>
89
- {/* Breadcrumb */}
90
- <div className="flex items-center gap-1.5 text-sm text-muted-foreground mb-4">
91
- <button
92
- onClick={onBack}
93
- className="hover:text-foreground transition-colors"
94
- >
95
- Services
96
- </button>
97
- <span>/</span>
98
- <span className="text-foreground">{service}</span>
99
- </div>
117
+ const handleFormSubmit = (values: SearchFormValues) => {
118
+ onSearch?.({
119
+ service: values.service || undefined,
120
+ operation: values.operation || undefined,
121
+ tags: values.tags || undefined,
122
+ lookback: values.lookback || undefined,
123
+ minDuration: values.minDuration || undefined,
124
+ maxDuration: values.maxDuration || undefined,
125
+ limit: values.limit,
126
+ });
127
+ };
100
128
 
101
- {/* Filter panel */}
102
- {onSearch && (
103
- <div className="border border-border rounded-lg mb-4">
104
- <button
105
- onClick={() => setFiltersOpen((v) => !v)}
106
- className="w-full flex items-center justify-between px-4 py-2.5 text-sm font-medium text-foreground hover:bg-muted/30 transition-colors"
107
- >
108
- <span>Filters</span>
109
- <span className="text-muted-foreground text-xs">
110
- {filtersOpen ? "▲" : "▼"}
111
- </span>
112
- </button>
113
- {filtersOpen && (
114
- <div className="px-4 pb-4 pt-1 border-t border-border space-y-3">
115
- <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
116
- {/* Operation */}
117
- <label className="space-y-1">
118
- <span className="text-xs text-muted-foreground">
119
- Operation
120
- </span>
121
- <select
122
- value={operation}
123
- onChange={(e) => setOperation(e.target.value)}
124
- className="w-full bg-muted/50 border border-border rounded px-2 py-1.5 text-sm text-foreground"
125
- >
126
- <option value="all">All</option>
127
- {operations.map((op) => (
128
- <option key={op} value={op}>
129
- {op}
130
- </option>
131
- ))}
132
- </select>
133
- </label>
129
+ const sortedTraces = useMemo(
130
+ () => sortTraces(traces, currentSort),
131
+ [traces, currentSort]
132
+ );
134
133
 
135
- {/* Lookback */}
136
- <label className="space-y-1">
137
- <span className="text-xs text-muted-foreground">
138
- Lookback
139
- </span>
140
- <select
141
- value={lookbackIdx}
142
- onChange={(e) => setLookbackIdx(Number(e.target.value))}
143
- className="w-full bg-muted/50 border border-border rounded px-2 py-1.5 text-sm text-foreground"
144
- >
145
- <option value={-1}>All time</option>
146
- {LOOKBACK_OPTIONS.map((opt, i) => (
147
- <option key={i} value={i}>
148
- {opt.label}
149
- </option>
150
- ))}
151
- </select>
152
- </label>
134
+ const maxDurationMs = useMemo(
135
+ () => Math.max(...traces.map((t) => t.durationMs), 0),
136
+ [traces]
137
+ );
153
138
 
154
- {/* Limit */}
155
- <label className="space-y-1">
156
- <span className="text-xs text-muted-foreground">Limit</span>
157
- <input
158
- type="number"
159
- min={1}
160
- max={1000}
161
- value={limit}
162
- onChange={(e) => {
163
- const n = Number(e.target.value);
164
- setLimit(
165
- Number.isNaN(n) ? 20 : Math.max(1, Math.min(1000, n))
166
- );
167
- }}
168
- className="w-full bg-muted/50 border border-border rounded px-2 py-1.5 text-sm text-foreground"
169
- />
170
- </label>
139
+ const selectedArr = Array.from(selected);
171
140
 
172
- {/* Min Duration */}
173
- <label className="space-y-1">
174
- <span className="text-xs text-muted-foreground">
175
- Min Duration
176
- </span>
177
- <input
178
- type="text"
179
- placeholder="e.g. 100ms"
180
- value={minDuration}
181
- onChange={(e) => setMinDuration(e.target.value)}
182
- className="w-full bg-muted/50 border border-border rounded px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground/50"
183
- />
184
- </label>
141
+ return (
142
+ <div className="flex gap-6 min-h-0">
143
+ {/* Left sidebar */}
144
+ {onSearch && (
145
+ <div className="w-72 shrink-0 border border-border rounded-lg p-4 self-start">
146
+ <SearchForm
147
+ services={services}
148
+ operations={operations}
149
+ initialValues={{ service }}
150
+ onSubmit={handleFormSubmit}
151
+ isLoading={isLoading}
152
+ />
153
+ </div>
154
+ )}
185
155
 
186
- {/* Max Duration */}
187
- <label className="space-y-1">
188
- <span className="text-xs text-muted-foreground">
189
- Max Duration
190
- </span>
191
- <input
192
- type="text"
193
- placeholder="e.g. 5s"
194
- value={maxDuration}
195
- onChange={(e) => setMaxDuration(e.target.value)}
196
- className="w-full bg-muted/50 border border-border rounded px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground/50"
197
- />
198
- </label>
199
- </div>
156
+ {/* Right content */}
157
+ <div className="flex-1 min-w-0 space-y-4">
158
+ {/* Scatter plot */}
159
+ {traces.length > 0 && (
160
+ <ScatterPlot traces={traces} onSelectTrace={onSelectTrace} />
161
+ )}
200
162
 
163
+ {/* Sort bar + result count */}
164
+ <div className="flex items-center justify-between gap-2">
165
+ <span className="text-sm text-muted-foreground">
166
+ {traces.length} Trace{traces.length !== 1 ? "s" : ""}
167
+ </span>
168
+ <div className="flex items-center gap-2">
169
+ {onCompare && selected.size === 2 && (
201
170
  <button
202
- onClick={handleFindTraces}
203
- className="px-4 py-1.5 text-sm font-medium bg-foreground text-background rounded hover:bg-foreground/90 transition-colors"
171
+ onClick={() => onCompare(selectedArr as [string, string])}
172
+ className="px-3 py-1.5 text-xs font-medium bg-foreground text-background rounded hover:bg-foreground/90 transition-colors"
204
173
  >
205
- Find Traces
174
+ Compare
206
175
  </button>
207
- </div>
208
- )}
209
- </div>
210
- )}
211
-
212
- {/* Results */}
213
- {isLoading && (
214
- <div className="flex items-center gap-2 text-muted-foreground py-8">
215
- <div className="w-4 h-4 border-2 border-muted-foreground border-t-transparent rounded-full animate-spin" />
216
- Loading traces...
176
+ )}
177
+ <SortDropdown value={currentSort} onChange={handleSortChange} />
178
+ </div>
217
179
  </div>
218
- )}
219
180
 
220
- {error && (
221
- <div className="text-red-400 py-4">
222
- Error loading traces: {error.message}
223
- </div>
224
- )}
181
+ {/* Loading */}
182
+ {isLoading && (
183
+ <div className="flex items-center gap-2 text-muted-foreground py-8">
184
+ <div className="w-4 h-4 border-2 border-muted-foreground border-t-transparent rounded-full animate-spin" />
185
+ Loading traces...
186
+ </div>
187
+ )}
225
188
 
226
- {!isLoading && !error && traces.length === 0 && (
227
- <div className="text-muted-foreground py-8">
228
- No traces found for {service}
229
- </div>
230
- )}
189
+ {/* Error */}
190
+ {error && (
191
+ <div className="text-red-400 py-4">
192
+ Error loading traces: {error.message}
193
+ </div>
194
+ )}
231
195
 
232
- {traces.length > 0 && (
233
- <div className="space-y-2">
234
- {traces.map((t) => (
235
- <div
236
- key={t.traceId}
237
- onClick={() => onSelectTrace(t.traceId)}
238
- className="border border-border rounded-lg px-4 py-3 hover:border-foreground/30 hover:bg-muted/30 cursor-pointer transition-colors"
239
- >
240
- {/* Title line */}
241
- <div className="flex items-baseline justify-between gap-2">
242
- <div className="flex items-baseline gap-1.5 min-w-0">
243
- <span className="font-medium text-foreground truncate">
244
- {t.serviceName}: {t.rootSpanName}
245
- </span>
246
- <span className="text-xs font-mono text-muted-foreground shrink-0">
247
- {t.traceId.slice(0, 7)}
248
- </span>
249
- </div>
250
- <span className="text-sm text-foreground/80 shrink-0">
251
- {formatDuration(t.durationMs)}
252
- </span>
253
- </div>
196
+ {/* Empty */}
197
+ {!isLoading && !error && traces.length === 0 && (
198
+ <div className="text-muted-foreground py-8">No traces found</div>
199
+ )}
254
200
 
255
- {/* Tags line */}
256
- <div className="flex items-center flex-wrap gap-1.5 mt-1.5">
257
- <span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
258
- {t.spanCount} Span{t.spanCount !== 1 ? "s" : ""}
259
- </span>
260
- {t.errorCount > 0 && (
261
- <span className="text-xs px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">
262
- {t.errorCount} Error{t.errorCount !== 1 ? "s" : ""}
263
- </span>
264
- )}
265
- {t.services.map((svc) => (
266
- <span
267
- key={svc.name}
268
- className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded"
269
- style={{
270
- backgroundColor: `${getServiceColor(svc.name)}20`,
271
- color: getServiceColor(svc.name),
272
- }}
201
+ {/* Result cards */}
202
+ {sortedTraces.length > 0 && (
203
+ <div className="space-y-2">
204
+ {sortedTraces.map((t) => (
205
+ <div
206
+ key={t.traceId}
207
+ className="border border-border rounded-lg px-4 py-3 hover:border-foreground/30 hover:bg-muted/30 cursor-pointer transition-colors"
208
+ >
209
+ {/* Title line */}
210
+ <div className="flex items-center gap-2">
211
+ {onCompare && (
212
+ <input
213
+ type="checkbox"
214
+ checked={selected.has(t.traceId)}
215
+ onChange={() => toggleSelected(t.traceId)}
216
+ onClick={(e) => e.stopPropagation()}
217
+ className="shrink-0"
218
+ disabled={!selected.has(t.traceId) && selected.size >= 2}
219
+ />
220
+ )}
221
+ <div
222
+ className="flex-1 min-w-0"
223
+ onClick={() => onSelectTrace(t.traceId)}
273
224
  >
274
- {svc.hasError && (
275
- <span className="w-1.5 h-1.5 rounded-full bg-red-500 shrink-0" />
276
- )}
277
- {svc.name} ({svc.count})
278
- </span>
279
- ))}
280
- </div>
225
+ <div className="flex items-baseline justify-between gap-2">
226
+ <div className="flex items-baseline gap-1.5 min-w-0">
227
+ <span className="font-medium text-foreground truncate">
228
+ {t.serviceName}: {t.rootSpanName}
229
+ </span>
230
+ <span className="text-xs font-mono text-muted-foreground shrink-0">
231
+ {t.traceId.slice(0, 7)}
232
+ </span>
233
+ </div>
234
+ </div>
235
+
236
+ {/* Duration bar */}
237
+ <div className="mt-1.5">
238
+ <DurationBar
239
+ durationMs={t.durationMs}
240
+ maxDurationMs={maxDurationMs}
241
+ color={getServiceColor(t.serviceName)}
242
+ />
243
+ </div>
281
244
 
282
- {/* Timestamp */}
283
- <div className="text-xs text-muted-foreground mt-1 text-right">
284
- {formatTimestamp(t.timestampMs)}
245
+ {/* Tags line */}
246
+ <div className="flex items-center flex-wrap gap-1.5 mt-1.5">
247
+ <span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
248
+ {t.spanCount} Span{t.spanCount !== 1 ? "s" : ""}
249
+ </span>
250
+ {t.errorCount > 0 && (
251
+ <span className="text-xs px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">
252
+ {t.errorCount} Error{t.errorCount !== 1 ? "s" : ""}
253
+ </span>
254
+ )}
255
+ {t.services.map((svc) => (
256
+ <span
257
+ key={svc.name}
258
+ className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded"
259
+ style={{
260
+ backgroundColor: `${getServiceColor(svc.name)}20`,
261
+ color: getServiceColor(svc.name),
262
+ }}
263
+ >
264
+ {svc.hasError && (
265
+ <span className="w-1.5 h-1.5 rounded-full bg-red-500 shrink-0" />
266
+ )}
267
+ {svc.name} ({svc.count})
268
+ </span>
269
+ ))}
270
+ </div>
271
+
272
+ {/* Timestamp */}
273
+ <div className="text-xs text-muted-foreground mt-1 text-right">
274
+ {formatTimestamp(t.timestampMs)}
275
+ </div>
276
+ </div>
277
+ </div>
285
278
  </div>
286
- </div>
287
- ))}
288
- </div>
289
- )}
279
+ ))}
280
+ </div>
281
+ )}
282
+ </div>
290
283
  </div>
291
284
  );
292
285
  }
@@ -1,18 +1,12 @@
1
1
  import { useState } from "react";
2
2
  import type { SpanNode } from "../../types.js";
3
- import { formatDuration } from "../../utils/time.js";
3
+ import { formatRelativeTime } from "../../utils/time.js";
4
4
  import { formatAttributeValue } from "../../utils/attributes.js";
5
5
 
6
6
  export interface EventsTabProps {
7
7
  span: SpanNode;
8
8
  }
9
9
 
10
- function formatRelativeTime(eventTimeMs: number, spanStartMs: number): string {
11
- const relativeMs = eventTimeMs - spanStartMs;
12
- const prefix = relativeMs < 0 ? "-" : "+";
13
- return `${prefix}${formatDuration(Math.abs(relativeMs))}`;
14
- }
15
-
16
10
  export function EventsTab({ span }: EventsTabProps) {
17
11
  const [expandedEvents, setExpandedEvents] = useState<Set<number>>(new Set());
18
12