@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
@@ -5,24 +5,24 @@ import type { SpanNode } from "../types.js";
5
5
  type OtelTracesRow = denormalizedSignals.OtelTracesRow;
6
6
 
7
7
  export interface TraceDetailProps {
8
- service: string;
9
8
  traceId: string;
10
9
  rows: OtelTracesRow[];
11
10
  isLoading?: boolean;
12
11
  error?: Error;
13
12
  selectedSpanId?: string;
14
13
  onSpanClick?: (span: SpanNode) => void;
14
+ onSpanDeselect?: () => void;
15
15
  onBack: () => void;
16
16
  }
17
17
 
18
18
  export function TraceDetail({
19
- service,
20
19
  traceId,
21
20
  rows,
22
21
  isLoading,
23
22
  error,
24
23
  selectedSpanId,
25
24
  onSpanClick,
25
+ onSpanDeselect,
26
26
  onBack,
27
27
  }: TraceDetailProps) {
28
28
  return (
@@ -33,7 +33,7 @@ export function TraceDetail({
33
33
  onClick={onBack}
34
34
  className="hover:text-foreground transition-colors"
35
35
  >
36
- Services / {service}
36
+ Traces
37
37
  </button>
38
38
  <span>/</span>
39
39
  <span className="text-foreground font-mono text-xs">
@@ -47,6 +47,7 @@ export function TraceDetail({
47
47
  error={error}
48
48
  selectedSpanId={selectedSpanId}
49
49
  onSpanClick={onSpanClick}
50
+ onSpanDeselect={onSpanDeselect}
50
51
  />
51
52
  </div>
52
53
  );
@@ -0,0 +1,38 @@
1
+ /**
2
+ * DurationBar - Horizontal bar showing relative trace duration.
3
+ */
4
+
5
+ import { formatDuration } from "../utils/time.js";
6
+
7
+ export interface DurationBarProps {
8
+ durationMs: number;
9
+ maxDurationMs: number;
10
+ color: string;
11
+ }
12
+
13
+ export function DurationBar({
14
+ durationMs,
15
+ maxDurationMs,
16
+ color,
17
+ }: DurationBarProps) {
18
+ const rawPct = maxDurationMs > 0 ? (durationMs / maxDurationMs) * 100 : 0;
19
+ const widthPct = durationMs <= 0 ? 0 : Math.min(Math.max(rawPct, 1), 100);
20
+
21
+ return (
22
+ <div className="flex items-center gap-2">
23
+ <div className="flex-1 h-2 bg-muted/30 rounded overflow-hidden">
24
+ <div
25
+ className="h-full rounded"
26
+ style={{
27
+ width: `${widthPct}%`,
28
+ backgroundColor: color,
29
+ opacity: 0.7,
30
+ }}
31
+ />
32
+ </div>
33
+ <span className="text-xs text-foreground/80 shrink-0 w-16 text-right font-mono">
34
+ {formatDuration(durationMs)}
35
+ </span>
36
+ </div>
37
+ );
38
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * ScatterPlot - Scatter chart showing trace duration vs timestamp.
3
+ */
4
+
5
+ import { useMemo, useCallback } from "react";
6
+ import {
7
+ ScatterChart,
8
+ Scatter,
9
+ XAxis,
10
+ YAxis,
11
+ CartesianGrid,
12
+ Tooltip,
13
+ ResponsiveContainer,
14
+ Cell,
15
+ } from "recharts";
16
+ import type { TraceSummary } from "./index.js";
17
+ import { getServiceColor } from "../utils/colors.js";
18
+ import { formatDuration, formatTimestamp } from "../utils/time.js";
19
+
20
+ export interface ScatterPlotProps {
21
+ traces: TraceSummary[];
22
+ onSelectTrace: (traceId: string) => void;
23
+ }
24
+
25
+ interface ScatterPoint {
26
+ x: number;
27
+ y: number;
28
+ traceId: string;
29
+ serviceName: string;
30
+ rootSpanName: string;
31
+ spanCount: number;
32
+ hasError: boolean;
33
+ }
34
+
35
+ function CustomTooltip({
36
+ active,
37
+ payload,
38
+ }: {
39
+ active?: boolean;
40
+ payload?: Array<{ payload: ScatterPoint }>;
41
+ }) {
42
+ if (!active || !payload?.[0]) return null;
43
+ const d = payload[0].payload;
44
+ return (
45
+ <div className="bg-background border border-border rounded px-3 py-2 text-xs shadow-lg">
46
+ <div className="font-medium text-foreground">
47
+ {d.serviceName}: {d.rootSpanName}
48
+ </div>
49
+ <div className="text-muted-foreground mt-1">
50
+ {d.spanCount} span{d.spanCount !== 1 ? "s" : ""} &middot;{" "}
51
+ {formatDuration(d.y)}
52
+ </div>
53
+ <div className="text-muted-foreground">{formatTimestamp(d.x)}</div>
54
+ </div>
55
+ );
56
+ }
57
+
58
+ export function ScatterPlot({ traces, onSelectTrace }: ScatterPlotProps) {
59
+ const data = useMemo<ScatterPoint[]>(
60
+ () =>
61
+ traces.map((t) => ({
62
+ x: t.timestampMs,
63
+ y: t.durationMs,
64
+ traceId: t.traceId,
65
+ serviceName: t.serviceName,
66
+ rootSpanName: t.rootSpanName,
67
+ spanCount: t.spanCount,
68
+ hasError: t.errorCount > 0,
69
+ })),
70
+ [traces]
71
+ );
72
+
73
+ const handleClick = useCallback(
74
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
75
+ (entry: any) => {
76
+ const payload = entry?.payload as ScatterPoint | undefined;
77
+ if (payload?.traceId) {
78
+ onSelectTrace(payload.traceId);
79
+ }
80
+ },
81
+ [onSelectTrace]
82
+ );
83
+
84
+ if (traces.length === 0) return null;
85
+
86
+ return (
87
+ <div className="border border-border rounded-lg p-4 bg-background">
88
+ <ResponsiveContainer width="100%" height={200}>
89
+ <ScatterChart margin={{ top: 8, right: 8, bottom: 4, left: 0 }}>
90
+ <CartesianGrid
91
+ strokeDasharray="3 3"
92
+ stroke="hsl(var(--border))"
93
+ opacity={0.4}
94
+ />
95
+ <XAxis
96
+ dataKey="x"
97
+ type="number"
98
+ domain={["dataMin", "dataMax"]}
99
+ tickFormatter={(v: number) => {
100
+ const d = new Date(v);
101
+ return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`;
102
+ }}
103
+ tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
104
+ stroke="hsl(var(--border))"
105
+ name="Time"
106
+ />
107
+ <YAxis
108
+ dataKey="y"
109
+ type="number"
110
+ tickFormatter={(v: number) => formatDuration(v)}
111
+ tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
112
+ stroke="hsl(var(--border))"
113
+ name="Duration"
114
+ width={70}
115
+ />
116
+ <Tooltip content={<CustomTooltip />} />
117
+ <Scatter data={data} onClick={handleClick} cursor="pointer">
118
+ {data.map((point, i) => (
119
+ <Cell
120
+ key={i}
121
+ fill={
122
+ point.hasError
123
+ ? "#ef4444"
124
+ : getServiceColor(point.serviceName)
125
+ }
126
+ stroke={point.hasError ? "#ef4444" : "none"}
127
+ strokeWidth={point.hasError ? 2 : 0}
128
+ />
129
+ ))}
130
+ </Scatter>
131
+ </ScatterChart>
132
+ </ResponsiveContainer>
133
+ </div>
134
+ );
135
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * SearchForm - Jaeger-style sidebar search form for trace filtering.
3
+ * Owns its own form state; parent only receives values on submit.
4
+ */
5
+
6
+ import { useState, useEffect } from "react";
7
+
8
+ export interface SearchFormValues {
9
+ service: string;
10
+ operation: string;
11
+ tags: string;
12
+ lookback: string;
13
+ minDuration: string;
14
+ maxDuration: string;
15
+ limit: number;
16
+ }
17
+
18
+ export interface SearchFormProps {
19
+ services: string[];
20
+ operations: string[];
21
+ initialValues?: Partial<SearchFormValues>;
22
+ onSubmit: (values: SearchFormValues) => void;
23
+ isLoading?: boolean;
24
+ }
25
+
26
+ const LOOKBACK_OPTIONS = [
27
+ { label: "Last 5 Minutes", value: "5m" },
28
+ { label: "Last 15 Minutes", value: "15m" },
29
+ { label: "Last 30 Minutes", value: "30m" },
30
+ { label: "Last 1 Hour", value: "1h" },
31
+ { label: "Last 2 Hours", value: "2h" },
32
+ { label: "Last 6 Hours", value: "6h" },
33
+ { label: "Last 12 Hours", value: "12h" },
34
+ { label: "Last 24 Hours", value: "24h" },
35
+ ] as const;
36
+
37
+ const inputClass =
38
+ "w-full bg-muted/50 border border-border rounded px-2 py-1.5 text-sm text-foreground";
39
+
40
+ export function SearchForm({
41
+ services,
42
+ operations,
43
+ initialValues,
44
+ onSubmit,
45
+ isLoading,
46
+ }: SearchFormProps) {
47
+ const [service, setService] = useState(initialValues?.service ?? "");
48
+ const [operation, setOperation] = useState(initialValues?.operation ?? "");
49
+ const [tags, setTags] = useState(initialValues?.tags ?? "");
50
+ const [lookback, setLookback] = useState(initialValues?.lookback ?? "");
51
+ const [minDuration, setMinDuration] = useState(
52
+ initialValues?.minDuration ?? ""
53
+ );
54
+ const [maxDuration, setMaxDuration] = useState(
55
+ initialValues?.maxDuration ?? ""
56
+ );
57
+ const [limit, setLimit] = useState(initialValues?.limit ?? 20);
58
+
59
+ // Sync service from URL-driven changes
60
+ useEffect(() => {
61
+ if (initialValues?.service != null) setService(initialValues.service);
62
+ }, [initialValues?.service]);
63
+
64
+ const handleSubmit = () => {
65
+ onSubmit({
66
+ service,
67
+ operation,
68
+ tags,
69
+ lookback,
70
+ minDuration,
71
+ maxDuration,
72
+ limit,
73
+ });
74
+ };
75
+
76
+ return (
77
+ <div className="space-y-4">
78
+ <h3 className="text-sm font-semibold text-foreground uppercase tracking-wider">
79
+ Search
80
+ </h3>
81
+
82
+ {/* Service */}
83
+ <label className="block space-y-1">
84
+ <span className="text-xs text-muted-foreground">Service</span>
85
+ <select
86
+ value={service}
87
+ onChange={(e) => setService(e.target.value)}
88
+ className={inputClass}
89
+ >
90
+ <option value="">All Services</option>
91
+ {services.map((s) => (
92
+ <option key={s} value={s}>
93
+ {s}
94
+ </option>
95
+ ))}
96
+ </select>
97
+ </label>
98
+
99
+ {/* Operation */}
100
+ <label className="block space-y-1">
101
+ <span className="text-xs text-muted-foreground">Operation</span>
102
+ <select
103
+ value={operation}
104
+ onChange={(e) => setOperation(e.target.value)}
105
+ className={inputClass}
106
+ >
107
+ <option value="">All Operations</option>
108
+ {operations.map((op) => (
109
+ <option key={op} value={op}>
110
+ {op}
111
+ </option>
112
+ ))}
113
+ </select>
114
+ </label>
115
+
116
+ {/* Tags */}
117
+ <label className="block space-y-1">
118
+ <span className="text-xs text-muted-foreground">Tags</span>
119
+ <textarea
120
+ value={tags}
121
+ onChange={(e) => setTags(e.target.value)}
122
+ placeholder={'key=value key2="quoted value"'}
123
+ rows={3}
124
+ className={`${inputClass} placeholder:text-muted-foreground/50 resize-y`}
125
+ />
126
+ </label>
127
+
128
+ {/* Lookback */}
129
+ <label className="block space-y-1">
130
+ <span className="text-xs text-muted-foreground">Lookback</span>
131
+ <select
132
+ value={lookback}
133
+ onChange={(e) => setLookback(e.target.value)}
134
+ className={inputClass}
135
+ >
136
+ <option value="">All time</option>
137
+ {LOOKBACK_OPTIONS.map((opt) => (
138
+ <option key={opt.value} value={opt.value}>
139
+ {opt.label}
140
+ </option>
141
+ ))}
142
+ </select>
143
+ </label>
144
+
145
+ {/* Min / Max Duration */}
146
+ <div className="grid grid-cols-2 gap-2">
147
+ <label className="block space-y-1">
148
+ <span className="text-xs text-muted-foreground">Min Duration</span>
149
+ <input
150
+ type="text"
151
+ placeholder="e.g. 100ms"
152
+ value={minDuration}
153
+ onChange={(e) => setMinDuration(e.target.value)}
154
+ className={`${inputClass} placeholder:text-muted-foreground/50`}
155
+ />
156
+ </label>
157
+ <label className="block space-y-1">
158
+ <span className="text-xs text-muted-foreground">Max Duration</span>
159
+ <input
160
+ type="text"
161
+ placeholder="e.g. 5s"
162
+ value={maxDuration}
163
+ onChange={(e) => setMaxDuration(e.target.value)}
164
+ className={`${inputClass} placeholder:text-muted-foreground/50`}
165
+ />
166
+ </label>
167
+ </div>
168
+
169
+ {/* Limit */}
170
+ <label className="block space-y-1">
171
+ <span className="text-xs text-muted-foreground">Limit</span>
172
+ <input
173
+ type="number"
174
+ min={1}
175
+ max={1000}
176
+ value={limit}
177
+ onChange={(e) => {
178
+ const n = Number(e.target.value);
179
+ setLimit(Number.isNaN(n) ? 20 : Math.max(1, Math.min(1000, n)));
180
+ }}
181
+ className={inputClass}
182
+ />
183
+ </label>
184
+
185
+ {/* Submit */}
186
+ <button
187
+ onClick={handleSubmit}
188
+ disabled={isLoading}
189
+ className="w-full px-4 py-2 text-sm font-medium bg-foreground text-background rounded hover:bg-foreground/90 transition-colors disabled:opacity-50"
190
+ >
191
+ {isLoading ? "Searching..." : "Find Traces"}
192
+ </button>
193
+ </div>
194
+ );
195
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * SortDropdown - Sort control for trace results.
3
+ */
4
+
5
+ export interface SortDropdownProps {
6
+ value: string;
7
+ onChange: (sort: string) => void;
8
+ }
9
+
10
+ const SORT_OPTIONS = [
11
+ { value: "recent", label: "Most Recent" },
12
+ { value: "longest", label: "Longest First" },
13
+ { value: "shortest", label: "Shortest First" },
14
+ { value: "mostSpans", label: "Most Spans" },
15
+ { value: "leastSpans", label: "Least Spans" },
16
+ ] as const;
17
+
18
+ export function SortDropdown({ value, onChange }: SortDropdownProps) {
19
+ return (
20
+ <select
21
+ value={value}
22
+ onChange={(e) => onChange(e.target.value)}
23
+ className="bg-muted/50 border border-border rounded px-2 py-1.5 text-sm text-foreground"
24
+ >
25
+ {SORT_OPTIONS.map((opt) => (
26
+ <option key={opt.value} value={opt.value}>
27
+ {opt.label}
28
+ </option>
29
+ ))}
30
+ </select>
31
+ );
32
+ }