@kopai/ui 0.0.5 → 0.1.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 (125) hide show
  1. package/README.md +137 -0
  2. package/dist/index.cjs +5069 -3
  3. package/dist/index.d.cts +301 -3
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +302 -3
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +5010 -3
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +25 -7
  10. package/src/components/KeyboardShortcuts/KeyboardShortcutsProvider.tsx +113 -0
  11. package/src/components/KeyboardShortcuts/ShortcutsHelpDialog.tsx +82 -0
  12. package/src/components/KeyboardShortcuts/context.ts +23 -0
  13. package/src/components/KeyboardShortcuts/index.ts +8 -0
  14. package/src/components/KeyboardShortcuts/types.ts +11 -0
  15. package/src/components/dashboard/Badge/Badge.stories.tsx +29 -0
  16. package/src/components/dashboard/Badge/index.tsx +32 -0
  17. package/src/components/dashboard/Button/Button.stories.tsx +107 -0
  18. package/src/components/dashboard/Button/index.tsx +63 -0
  19. package/src/components/dashboard/Card/Card.stories.tsx +81 -0
  20. package/src/components/dashboard/Card/index.tsx +58 -0
  21. package/src/components/dashboard/Chart/Chart.stories.tsx +48 -0
  22. package/src/components/dashboard/Chart/index.tsx +74 -0
  23. package/src/components/dashboard/DatePicker/DatePicker.stories.tsx +33 -0
  24. package/src/components/dashboard/DatePicker/index.tsx +41 -0
  25. package/src/components/dashboard/Divider/Divider.stories.tsx +17 -0
  26. package/src/components/dashboard/Divider/index.tsx +49 -0
  27. package/src/components/dashboard/Empty/Empty.stories.tsx +48 -0
  28. package/src/components/dashboard/Empty/index.tsx +46 -0
  29. package/src/components/dashboard/Grid/Grid.stories.tsx +52 -0
  30. package/src/components/dashboard/Grid/index.tsx +26 -0
  31. package/src/components/dashboard/Heading/Heading.stories.tsx +25 -0
  32. package/src/components/dashboard/Heading/index.tsx +27 -0
  33. package/src/components/dashboard/List/List.stories.tsx +37 -0
  34. package/src/components/dashboard/List/index.tsx +24 -0
  35. package/src/components/dashboard/Metric/Metric.stories.tsx +65 -0
  36. package/src/components/dashboard/Metric/index.tsx +36 -0
  37. package/src/components/dashboard/Stack/Stack.stories.tsx +61 -0
  38. package/src/components/dashboard/Stack/index.tsx +33 -0
  39. package/src/components/dashboard/Table/Table.stories.tsx +38 -0
  40. package/src/components/dashboard/Table/index.tsx +104 -0
  41. package/src/components/dashboard/Text/Text.stories.tsx +53 -0
  42. package/src/components/dashboard/Text/index.tsx +18 -0
  43. package/src/components/dashboard/index.ts +46 -0
  44. package/src/components/index.ts +17 -0
  45. package/src/components/observability/LogTimeline/LogDetailPane/AttributesTab.tsx +56 -0
  46. package/src/components/observability/LogTimeline/LogDetailPane/JsonTreeView.tsx +139 -0
  47. package/src/components/observability/LogTimeline/LogDetailPane/index.tsx +271 -0
  48. package/src/components/observability/LogTimeline/LogFilter.stories.tsx +66 -0
  49. package/src/components/observability/LogTimeline/LogFilter.test.tsx +696 -0
  50. package/src/components/observability/LogTimeline/LogFilter.tsx +674 -0
  51. package/src/components/observability/LogTimeline/LogRow.tsx +174 -0
  52. package/src/components/observability/LogTimeline/LogTimeline.stories.tsx +154 -0
  53. package/src/components/observability/LogTimeline/index.tsx +542 -0
  54. package/src/components/observability/LogTimeline/shortcuts.ts +18 -0
  55. package/src/components/observability/MetricHistogram/MetricHistogram.stories.tsx +20 -0
  56. package/src/components/observability/MetricHistogram/index.tsx +303 -0
  57. package/src/components/observability/MetricStat/MetricStat.stories.tsx +30 -0
  58. package/src/components/observability/MetricStat/index.tsx +281 -0
  59. package/src/components/observability/MetricTable/MetricTable.stories.tsx +20 -0
  60. package/src/components/observability/MetricTable/index.tsx +194 -0
  61. package/src/components/observability/MetricTimeSeries/MetricTimeSeries.stories.tsx +28 -0
  62. package/src/components/observability/MetricTimeSeries/index.tsx +462 -0
  63. package/src/components/observability/RawDataTable/RawDataTable.stories.tsx +27 -0
  64. package/src/components/observability/RawDataTable/index.tsx +131 -0
  65. package/src/components/observability/ServiceList/ServiceList.stories.tsx +20 -0
  66. package/src/components/observability/ServiceList/index.tsx +60 -0
  67. package/src/components/observability/ServiceList/shortcuts.ts +6 -0
  68. package/src/components/observability/TabBar/TabBar.stories.tsx +34 -0
  69. package/src/components/observability/TabBar/index.tsx +46 -0
  70. package/src/components/observability/TraceDetail/TraceDetail.stories.tsx +51 -0
  71. package/src/components/observability/TraceDetail/index.tsx +53 -0
  72. package/src/components/observability/TraceSearch/TraceSearch.stories.tsx +49 -0
  73. package/src/components/observability/TraceSearch/index.tsx +292 -0
  74. package/src/components/observability/TraceTimeline/DetailPane/AttributesTab.tsx +152 -0
  75. package/src/components/observability/TraceTimeline/DetailPane/EventsTab.tsx +128 -0
  76. package/src/components/observability/TraceTimeline/DetailPane/LinksTab.tsx +210 -0
  77. package/src/components/observability/TraceTimeline/DetailPane/index.tsx +174 -0
  78. package/src/components/observability/TraceTimeline/SpanRow.tsx +173 -0
  79. package/src/components/observability/TraceTimeline/TimelineBar.tsx +41 -0
  80. package/src/components/observability/TraceTimeline/Tooltip.tsx +42 -0
  81. package/src/components/observability/TraceTimeline/TraceHeader.tsx +88 -0
  82. package/src/components/observability/TraceTimeline/TraceTimeline.stories.tsx +25 -0
  83. package/src/components/observability/TraceTimeline/index.tsx +478 -0
  84. package/src/components/observability/TraceTimeline/shortcuts.ts +16 -0
  85. package/src/components/observability/__fixtures__/logs.ts +476 -0
  86. package/src/components/observability/__fixtures__/metrics.ts +216 -0
  87. package/src/components/observability/__fixtures__/raw-table.ts +204 -0
  88. package/src/components/observability/__fixtures__/services.ts +8 -0
  89. package/src/components/observability/__fixtures__/trace-summaries.ts +81 -0
  90. package/src/components/observability/__fixtures__/traces.ts +396 -0
  91. package/src/components/observability/index.ts +66 -0
  92. package/src/components/observability/renderers/OtelMetricDiscovery.tsx +77 -0
  93. package/src/components/observability/renderers/OtelMetricHistogram.tsx +29 -0
  94. package/src/components/observability/renderers/OtelMetricStat.tsx +44 -0
  95. package/src/components/observability/renderers/OtelMetricTable.tsx +29 -0
  96. package/src/components/observability/renderers/OtelMetricTimeSeries.tsx +30 -0
  97. package/src/components/observability/renderers/index.ts +5 -0
  98. package/src/components/observability/shared/LoadingSkeleton.tsx +43 -0
  99. package/src/components/observability/types.ts +113 -0
  100. package/src/components/observability/utils/attributes.ts +17 -0
  101. package/src/components/observability/utils/colors.ts +29 -0
  102. package/src/components/observability/utils/flatten-tree.ts +53 -0
  103. package/src/components/observability/utils/lttb.ts +121 -0
  104. package/src/components/observability/utils/time.ts +46 -0
  105. package/src/hooks/use-kopai-data.test.ts +296 -0
  106. package/src/hooks/use-kopai-data.ts +64 -0
  107. package/src/hooks/use-live-logs.test.ts +193 -0
  108. package/src/hooks/use-live-logs.ts +113 -0
  109. package/src/index.ts +15 -0
  110. package/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +33 -0
  111. package/src/lib/catalog.ts +165 -0
  112. package/src/lib/component-catalog.test.ts +357 -0
  113. package/src/lib/component-catalog.ts +171 -0
  114. package/src/lib/dashboard-datasource.ts +76 -0
  115. package/src/lib/generate-prompt-instructions.test.ts +27 -0
  116. package/src/lib/generate-prompt-instructions.ts +185 -0
  117. package/src/lib/log-buffer.test.ts +88 -0
  118. package/src/lib/log-buffer.ts +62 -0
  119. package/src/lib/observability-catalog.ts +143 -0
  120. package/src/lib/renderer.test.tsx +693 -0
  121. package/src/lib/renderer.tsx +276 -0
  122. package/src/pages/observability.tsx +828 -0
  123. package/src/providers/kopai-provider.tsx +51 -0
  124. package/src/styles/globals.css +46 -0
  125. package/src/vite-env.d.ts +1 -0
@@ -0,0 +1,292 @@
1
+ import { useState } from "react";
2
+ import { formatDuration, formatTimestamp } from "../utils/time.js";
3
+ import { getServiceColor } from "../utils/colors.js";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Public types
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export interface TraceSummary {
10
+ traceId: string;
11
+ rootSpanName: string;
12
+ serviceName: string;
13
+ durationMs: number;
14
+ statusCode: string;
15
+ timestampMs: number;
16
+ spanCount: number;
17
+ services: { name: string; count: number; hasError: boolean }[];
18
+ errorCount: number;
19
+ }
20
+
21
+ export interface TraceSearchFilters {
22
+ operation?: string;
23
+ lookbackMs?: number;
24
+ minDuration?: string;
25
+ maxDuration?: string;
26
+ limit: number;
27
+ }
28
+
29
+ export interface TraceSearchProps {
30
+ service: string;
31
+ traces: TraceSummary[];
32
+ operations?: string[];
33
+ isLoading?: boolean;
34
+ error?: Error;
35
+ onSelectTrace: (traceId: string) => void;
36
+ onBack: () => void;
37
+ onSearch?: (filters: TraceSearchFilters) => void;
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Lookback presets
42
+ // ---------------------------------------------------------------------------
43
+
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;
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Component
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export function TraceSearch({
60
+ service,
61
+ traces,
62
+ operations = [],
63
+ isLoading,
64
+ error,
65
+ onSelectTrace,
66
+ onBack,
67
+ onSearch,
68
+ }: 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);
75
+
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,
84
+ });
85
+ };
86
+
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>
100
+
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>
134
+
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>
153
+
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>
171
+
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>
185
+
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>
200
+
201
+ <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"
204
+ >
205
+ Find Traces
206
+ </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...
217
+ </div>
218
+ )}
219
+
220
+ {error && (
221
+ <div className="text-red-400 py-4">
222
+ Error loading traces: {error.message}
223
+ </div>
224
+ )}
225
+
226
+ {!isLoading && !error && traces.length === 0 && (
227
+ <div className="text-muted-foreground py-8">
228
+ No traces found for {service}
229
+ </div>
230
+ )}
231
+
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>
254
+
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
+ }}
273
+ >
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>
281
+
282
+ {/* Timestamp */}
283
+ <div className="text-xs text-muted-foreground mt-1 text-right">
284
+ {formatTimestamp(t.timestampMs)}
285
+ </div>
286
+ </div>
287
+ ))}
288
+ </div>
289
+ )}
290
+ </div>
291
+ );
292
+ }
@@ -0,0 +1,152 @@
1
+ import { useMemo } from "react";
2
+ import type { SpanNode } from "../../types.js";
3
+ import {
4
+ formatAttributeValue,
5
+ isComplexValue,
6
+ } from "../../utils/attributes.js";
7
+
8
+ export interface AttributesTabProps {
9
+ span: SpanNode;
10
+ }
11
+
12
+ const HTTP_SEMANTIC_CONVENTIONS = new Set([
13
+ "http.method",
14
+ "http.url",
15
+ "http.status_code",
16
+ "http.target",
17
+ "http.host",
18
+ "http.scheme",
19
+ "http.route",
20
+ "http.user_agent",
21
+ "http.request_content_length",
22
+ "http.response_content_length",
23
+ ]);
24
+
25
+ export function AttributesTab({ span }: AttributesTabProps) {
26
+ const { httpAttributes, otherAttributes, resourceAttributes } =
27
+ useMemo(() => {
28
+ const http: Array<[string, unknown]> = [];
29
+ const other: Array<[string, unknown]> = [];
30
+ const resource: Array<[string, unknown]> = [];
31
+
32
+ if (span.attributes) {
33
+ Object.entries(span.attributes).forEach(([key, value]) => {
34
+ if (HTTP_SEMANTIC_CONVENTIONS.has(key)) {
35
+ http.push([key, value]);
36
+ } else {
37
+ other.push([key, value]);
38
+ }
39
+ });
40
+ }
41
+
42
+ if (span.resourceAttributes) {
43
+ Object.entries(span.resourceAttributes).forEach(([key, value]) => {
44
+ resource.push([key, value]);
45
+ });
46
+ }
47
+
48
+ http.sort(([a], [b]) => a.localeCompare(b));
49
+ other.sort(([a], [b]) => a.localeCompare(b));
50
+ resource.sort(([a], [b]) => a.localeCompare(b));
51
+
52
+ return {
53
+ httpAttributes: http,
54
+ otherAttributes: other,
55
+ resourceAttributes: resource,
56
+ };
57
+ }, [span]);
58
+
59
+ const hasAttributes =
60
+ httpAttributes.length > 0 ||
61
+ otherAttributes.length > 0 ||
62
+ resourceAttributes.length > 0;
63
+
64
+ if (!hasAttributes) {
65
+ return (
66
+ <div className="text-sm text-muted-foreground text-center py-8">
67
+ No attributes available
68
+ </div>
69
+ );
70
+ }
71
+
72
+ return (
73
+ <div className="space-y-6">
74
+ {httpAttributes.length > 0 && (
75
+ <section>
76
+ <h3 className="text-sm font-semibold text-foreground mb-3 flex items-center">
77
+ <span className="w-2 h-2 bg-blue-500 rounded-full mr-2" />
78
+ HTTP Attributes
79
+ </h3>
80
+ <div className="space-y-2">
81
+ {httpAttributes.map(([key, value]) => (
82
+ <AttributeRow key={key} attrKey={key} value={value} highlighted />
83
+ ))}
84
+ </div>
85
+ </section>
86
+ )}
87
+
88
+ {otherAttributes.length > 0 && (
89
+ <section>
90
+ <h3 className="text-sm font-semibold text-foreground mb-3">
91
+ Span Attributes
92
+ </h3>
93
+ <div className="space-y-2">
94
+ {otherAttributes.map(([key, value]) => (
95
+ <AttributeRow key={key} attrKey={key} value={value} />
96
+ ))}
97
+ </div>
98
+ </section>
99
+ )}
100
+
101
+ {resourceAttributes.length > 0 && (
102
+ <section>
103
+ <h3 className="text-sm font-semibold text-foreground mb-3">
104
+ Resource Attributes
105
+ </h3>
106
+ <div className="space-y-2">
107
+ {resourceAttributes.map(([key, value]) => (
108
+ <AttributeRow key={key} attrKey={key} value={value} />
109
+ ))}
110
+ </div>
111
+ </section>
112
+ )}
113
+ </div>
114
+ );
115
+ }
116
+
117
+ interface AttributeRowProps {
118
+ attrKey: string;
119
+ value: unknown;
120
+ highlighted?: boolean;
121
+ }
122
+
123
+ function AttributeRow({ attrKey, value, highlighted }: AttributeRowProps) {
124
+ const isComplex = isComplexValue(value);
125
+ const formattedValue = formatAttributeValue(value);
126
+
127
+ return (
128
+ <div
129
+ className={`grid grid-cols-[minmax(150px,1fr)_2fr] gap-4 p-2 rounded text-sm ${
130
+ highlighted
131
+ ? "bg-blue-50 dark:bg-blue-950 border-l-2 border-blue-500"
132
+ : "bg-muted"
133
+ }`}
134
+ >
135
+ <div
136
+ className={`font-mono font-medium break-words ${highlighted ? "text-blue-700 dark:text-blue-300" : "text-foreground"}`}
137
+ title={attrKey}
138
+ >
139
+ {attrKey}
140
+ </div>
141
+ <div className="break-words">
142
+ {isComplex ? (
143
+ <pre className="text-xs text-foreground bg-background p-2 rounded border border-border overflow-x-auto">
144
+ {formattedValue}
145
+ </pre>
146
+ ) : (
147
+ <span className="text-foreground">{formattedValue}</span>
148
+ )}
149
+ </div>
150
+ </div>
151
+ );
152
+ }
@@ -0,0 +1,128 @@
1
+ import { useState } from "react";
2
+ import type { SpanNode } from "../../types.js";
3
+ import { formatDuration } from "../../utils/time.js";
4
+ import { formatAttributeValue } from "../../utils/attributes.js";
5
+
6
+ export interface EventsTabProps {
7
+ span: SpanNode;
8
+ }
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
+ export function EventsTab({ span }: EventsTabProps) {
17
+ const [expandedEvents, setExpandedEvents] = useState<Set<number>>(new Set());
18
+
19
+ const toggleEventExpanded = (index: number) => {
20
+ setExpandedEvents((prev) => {
21
+ const next = new Set(prev);
22
+ if (next.has(index)) next.delete(index);
23
+ else next.add(index);
24
+ return next;
25
+ });
26
+ };
27
+
28
+ if (!span.events || span.events.length === 0) {
29
+ return (
30
+ <div className="text-sm text-muted-foreground text-center py-8">
31
+ No events available
32
+ </div>
33
+ );
34
+ }
35
+
36
+ return (
37
+ <div className="space-y-3">
38
+ {span.events.map((event, index) => {
39
+ const isExpanded = expandedEvents.has(index);
40
+ const hasAttributes =
41
+ event.attributes && Object.keys(event.attributes).length > 0;
42
+ const relativeTime = formatRelativeTime(
43
+ event.timeUnixMs,
44
+ span.startTimeUnixMs
45
+ );
46
+
47
+ return (
48
+ <div
49
+ key={index}
50
+ className="border border-border rounded-lg overflow-hidden"
51
+ >
52
+ <div className="bg-muted p-3">
53
+ <div className="flex items-start justify-between gap-2">
54
+ <div className="flex-1 min-w-0">
55
+ <div className="font-medium text-sm text-foreground truncate">
56
+ {event.name}
57
+ </div>
58
+ <div className="text-xs text-muted-foreground mt-1 font-mono">
59
+ {relativeTime} from span start
60
+ </div>
61
+ </div>
62
+ {hasAttributes && (
63
+ <button
64
+ onClick={() => toggleEventExpanded(index)}
65
+ className="p-1 hover:bg-muted/80 rounded transition-colors"
66
+ aria-label={
67
+ isExpanded ? "Collapse attributes" : "Expand attributes"
68
+ }
69
+ aria-expanded={isExpanded}
70
+ >
71
+ <svg
72
+ className={`w-4 h-4 text-muted-foreground transition-transform ${isExpanded ? "rotate-180" : ""}`}
73
+ fill="none"
74
+ stroke="currentColor"
75
+ viewBox="0 0 24 24"
76
+ >
77
+ <path
78
+ strokeLinecap="round"
79
+ strokeLinejoin="round"
80
+ strokeWidth={2}
81
+ d="M19 9l-7 7-7-7"
82
+ />
83
+ </svg>
84
+ </button>
85
+ )}
86
+ </div>
87
+ </div>
88
+
89
+ {hasAttributes && isExpanded && (
90
+ <div className="p-3 bg-background border-t border-border">
91
+ <div className="text-xs font-semibold text-foreground mb-2">
92
+ Attributes
93
+ </div>
94
+ <div className="space-y-2">
95
+ {Object.entries(event.attributes).map(([key, value]) => (
96
+ <div
97
+ key={key}
98
+ className="grid grid-cols-[minmax(100px,1fr)_2fr] gap-3 text-xs"
99
+ >
100
+ <div className="font-mono font-medium text-foreground break-words">
101
+ {key}
102
+ </div>
103
+ <div className="text-foreground break-words">
104
+ {typeof value === "object" ? (
105
+ <pre className="text-xs bg-muted p-2 rounded border border-border overflow-x-auto">
106
+ {formatAttributeValue(value)}
107
+ </pre>
108
+ ) : (
109
+ <span>{formatAttributeValue(value)}</span>
110
+ )}
111
+ </div>
112
+ </div>
113
+ ))}
114
+ </div>
115
+ </div>
116
+ )}
117
+
118
+ {!hasAttributes && (
119
+ <div className="px-3 pb-3 text-xs text-muted-foreground italic">
120
+ No attributes
121
+ </div>
122
+ )}
123
+ </div>
124
+ );
125
+ })}
126
+ </div>
127
+ );
128
+ }